Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,40 @@ documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.7.0] - 2026-06-17

### Added

- **TypeScript** `ToolConfig.isFormatter` — optional boolean flag for tool
extensions that provide LSP-based document formatting
(`textDocument/formatting`, `rangeFormatting`, `rangesFormatting`).

- **TypeScript** `NullFormatter` class — a lifecycle-aware placeholder
`DocumentFormattingEditProvider`. It registers a no-op formatter at
activation time (so VS Code lists the extension in the formatter picker
*before* the LSP has finished starting) and exposes `register()`,
`unregister()`, `isRegistered()`, and `dispose()` for manual control.
Exported from the package root.

- **TypeScript** `createToolContext` — when `ToolConfig.isFormatter` is
`true`, the shared activation pattern now automatically:
- registers the `NullFormatter` placeholder immediately at activation,
- disposes it when the language client transitions to `State.Running`
(including the "already Running" case where the initial transition would
otherwise be missed),
- re-registers it if the client later transitions to `State.Stopped` or
`State.Starting` (e.g. during a restart), and disposes it again on the
next `Running`,
- disposes it on `ctx.dispose()` / `deactivateServer()`.

This fixes the "duplicate formatter entry" regression reported in
[microsoft/vscode-black-formatter#752](https://github.com/microsoft/vscode-black-formatter/issues/752)
and unblocks the same fix for other formatter extensions
(`vscode-autopep8`, `vscode-isort`, …).

Linter-only extensions (pylint, flake8, mypy, …) are unaffected — the
placeholder is **not** registered when `isFormatter` is `false` or unset.

## [0.6.1] - 2026-06-17

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.6.1
0.7.0
2 changes: 1 addition & 1 deletion python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "vscode-common-python-lsp"
version = "0.6.1"
version = "0.7.0"
description = "Shared Python utilities for VS Code Python tool extensions"
readme = "README.md"
license = "MIT"
Expand Down
4 changes: 2 additions & 2 deletions typescript/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion typescript/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@vscode/common-python-lsp",
"version": "0.6.1",
"version": "0.7.0",
"description": "Shared TypeScript utilities for VS Code Python tool extensions",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
66 changes: 66 additions & 0 deletions typescript/src/activation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@
*/

import * as vscode from 'vscode';
import { State } from 'vscode-languageclient';
import { LanguageClient } from 'vscode-languageclient/node';
import { createConfigFileWatchers } from './configWatcher';
import { traceError, traceLog, traceVerbose } from './logging';
import { NullFormatter } from './nullFormatter';
import { PythonEnvironmentsProvider } from './python';
import { restartServer, RestartServerOptions } from './server';
import { checkIfConfigurationChanged, getWorkspaceSettings } from './settings';
Expand Down Expand Up @@ -100,6 +102,9 @@ export function createToolContext(options: CreateToolContextOptions): ToolExtens
let disposed = false;
let serverDisposables: vscode.Disposable[] = [];

const nullFormatter = toolConfig.isFormatter ? new NullFormatter() : undefined;
nullFormatter?.register();

const ctx: ToolExtensionContext = {
lsClient: undefined,

Expand All @@ -116,6 +121,25 @@ export function createToolContext(options: CreateToolContextOptions): ToolExtens
}
isRestarting = true;
try {
// Re-register the placeholder at the start of each restart
// cycle when there is no healthy running client. This covers
// two gaps the per-state listener misses:
// 1. Extension-driven restarts (config/interpreter change):
// runServer() disposes the old state listener *before*
// restartServer() stops the previous client, so
// Stopped/Starting transitions fire into a dead listener.
// 2. Failure paths: if restartServer() throws or returns
// client: undefined the state-listener block is skipped
// entirely.
// The guard avoids re-introducing the duplicate-formatter
// symptom: if the old client is still Running (serving
// formatting requests), re-registering the placeholder would
// make the extension appear twice in the formatter picker
// until restartServer() stops the old client.
if (nullFormatter && (!ctx.lsClient || ctx.lsClient.state !== State.Running)) {
nullFormatter.register();
Comment thread
edvilme marked this conversation as resolved.
}

const projectRoot = await getProjectRoot();
if (disposed) {
return;
Expand Down Expand Up @@ -149,6 +173,11 @@ export function createToolContext(options: CreateToolContextOptions): ToolExtens
}
serverDisposables = [];

// Re-register the placeholder so the extension stays
// visible in the formatter picker while there is no
// interpreter (and therefore no running LSP formatter).
nullFormatter?.register();

updateStatus(
vscode.l10n.t('Please select a Python interpreter.'),
vscode.LanguageStatusSeverity.Error,
Expand Down Expand Up @@ -207,9 +236,45 @@ export function createToolContext(options: CreateToolContextOptions): ToolExtens

ctx.lsClient = result.client;
serverDisposables = result.disposables;

if (nullFormatter && result.client) {
if (result.client.state === State.Running) {
nullFormatter.unregister();
} else {
// New client not yet Running — ensure placeholder is
// visible while the server finishes starting. This
// covers the extension-driven restart path where the
// guard at the top skipped register() because the
// *old* client was still Running at that point.
nullFormatter.register();
}
serverDisposables.push(
result.client.onDidChangeState((e) => {
switch (e.newState) {
case State.Running:
nullFormatter.unregister();
break;
case State.Stopped:
case State.Starting:
nullFormatter.register();
break;
}
Comment thread
edvilme marked this conversation as resolved.
}),
Comment thread
edvilme marked this conversation as resolved.
);
} else if (nullFormatter && !result.client) {
// No client available — ensure placeholder is visible
// so the extension remains in the formatter picker.
nullFormatter.register();
}
}
} catch (ex) {
traceError(`Server restart failed: ${ex}`);
// Ensure placeholder stays visible after a failure — but only
// when there is no healthy running client (same guard as the
// top of runServer to avoid the duplicate-formatter symptom).
if (nullFormatter && (!ctx.lsClient || ctx.lsClient.state !== State.Running)) {
nullFormatter.register();
}
} finally {
isRestarting = false;
}
Expand Down Expand Up @@ -244,6 +309,7 @@ export function createToolContext(options: CreateToolContextOptions): ToolExtens
}
}
serverDisposables = [];
nullFormatter?.dispose();
},
};

Expand Down
3 changes: 3 additions & 0 deletions typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ export {
RestartServerResult,
} from './server';

// Null formatter helper
export { NullFormatter } from './nullFormatter';

// Activation / deactivation
export {
createToolContext,
Expand Down
63 changes: 63 additions & 0 deletions typescript/src/nullFormatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import { Disposable } from 'vscode';
import { traceLog } from './logging';
import { getDocumentSelector } from './utilities';
import { registerDocumentFormattingEditProvider } from './vscodeapi';

/**
* Lifecycle-aware placeholder DocumentFormattingEditProvider.
*
* A tool extension that provides LSP-based formatting needs to appear in
* VS Code's formatter picker *before* the language client has finished
* starting, otherwise the first "Format Document" invocation after
* activation is treated as "no formatter installed".
*
* This helper registers a no-op provider for `getDocumentSelector()` and
* lets callers dispose it (typically as soon as the real LSP provider takes
* over).
*
* **Important:** never leave both the placeholder and the LSP provider
* registered at the same time — VS Code will show the extension twice in
* the formatter picker (one entry per provider). The shared activation
* pattern in `activation.ts` handles this automatically when
* `ToolConfig.isFormatter` is `true`.
*/
export class NullFormatter implements Disposable {
private disposable: Disposable | undefined;

/** No-op if already registered. */
register(): void {
if (this.disposable) {
return;
}
this.disposable = registerDocumentFormattingEditProvider(getDocumentSelector(), {
provideDocumentFormattingEdits: () => {
traceLog('Formatting requested before server has started.');
return Promise.resolve(undefined);
},
});
}

/** No-op if not registered. */
unregister(): void {
if (!this.disposable) {
return;
}
try {
this.disposable.dispose();
} finally {
this.disposable = undefined;
}
}

/** True iff the placeholder is currently registered. */
isRegistered(): boolean {
return this.disposable !== undefined;
}

dispose(): void {
this.unregister();
}
}
25 changes: 25 additions & 0 deletions typescript/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,31 @@ export interface ToolConfig {

// Environment
extraEnvVars?: Record<string, string>;

/**
* Set to `true` for tools that provide LSP formatting (textDocument/formatting,
* rangeFormatting, rangesFormatting).
*
* When enabled, the common activation pattern will:
* - register a placeholder DocumentFormattingEditProvider at activation time
* (so VS Code lists the extension as a formatter *before* the LSP has
* finished starting),
* - dispose it automatically when the language client transitions to
* `Running` (so VS Code does not see two providers for the same selector
* and list the extension twice in the formatter picker),
* - re-register it if the client transitions back to `Stopped` / `Starting`
* during a crash/recovery restart, then dispose it again on the next
* `Running`,
* - re-register it at the start of each extension-driven restart cycle
* (config change, interpreter change, restart command) when the previous
* client is no longer `Running`, ensuring the placeholder is visible
* while the new server starts — and in failure paths where
* `restartServer()` throws or returns no client.
*
* Defaults to `false`. Linter-only tools (pylint, flake8, mypy, …) should
* leave this unset.
Comment thread
edvilme marked this conversation as resolved.
*/
isFormatter?: boolean;
}

/**
Expand Down
10 changes: 10 additions & 0 deletions typescript/tests/_languageclient_mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ export class LanguageClient {
this.clientOptions = clientOptions;
}

get state(): State {
return this._state;
}

onDidChangeState(listener: (e: { oldState: State; newState: State }) => void) {
this._stateListeners.push(listener);
return {
Expand All @@ -46,6 +50,12 @@ export class LanguageClient {
};
}

simulateStateChange(newState: State): void {
const oldState = this._state;
this._state = newState;
this._stateListeners.forEach((l) => l({ oldState, newState }));
}

Comment thread
edvilme marked this conversation as resolved.
async start(): Promise<void> {
this._state = State.Running;
}
Expand Down
Loading