From e8aa84ac8c2fbcad88e4f3bd05e68a16164062f9 Mon Sep 17 00:00:00 2001 From: Sysix <3897725+Sysix@users.noreply.github.com> Date: Mon, 24 Nov 2025 12:23:01 +0000 Subject: [PATCH] refactor(vscode): introduce `ToolInterface` (#15974) > This PR refactors the VSCode extension's linter functionality by introducing a ToolInterface abstraction. The refactoring moves from function-based exports to a class-based architecture, preparing the codebase for supporting multiple tools (e.g., linter and formatter) with a consistent interface. --- editors/vscode/client/extension.ts | 19 +- editors/vscode/client/linter.ts | 283 ----------------- editors/vscode/client/tools/ToolInterface.ts | 39 +++ editors/vscode/client/tools/linter.ts | 292 ++++++++++++++++++ .../vscode/client/{ => tools}/lsp_helper.ts | 0 editors/vscode/tests/lsp_helper.spec.ts | 2 +- 6 files changed, 339 insertions(+), 296 deletions(-) delete mode 100644 editors/vscode/client/linter.ts create mode 100644 editors/vscode/client/tools/ToolInterface.ts create mode 100644 editors/vscode/client/tools/linter.ts rename editors/vscode/client/{ => tools}/lsp_helper.ts (100%) diff --git a/editors/vscode/client/extension.ts b/editors/vscode/client/extension.ts index 3b42ba2b3e7c0..eba3ccbe72968 100644 --- a/editors/vscode/client/extension.ts +++ b/editors/vscode/client/extension.ts @@ -2,16 +2,11 @@ import { commands, ExtensionContext, window, workspace } from 'vscode'; import { OxcCommands } from './commands'; import { ConfigService } from './ConfigService'; -import { - activate as activateLinter, - deactivate as deactivateLinter, - onConfigChange as onConfigChangeLinter, - restartClient, - toggleClient, -} from './linter'; import StatusBarItemHandler from './StatusBarItemHandler'; +import Linter from './tools/linter'; const outputChannelName = 'Oxc'; +const linter = new Linter(); export async function activate(context: ExtensionContext) { const configService = new ConfigService(); @@ -21,7 +16,7 @@ export async function activate(context: ExtensionContext) { }); const restartCommand = commands.registerCommand(OxcCommands.RestartServer, async () => { - await restartClient(); + await linter.restartClient(); }); const showOutputCommand = commands.registerCommand(OxcCommands.ShowOutputChannel, () => { @@ -31,7 +26,7 @@ export async function activate(context: ExtensionContext) { const toggleEnable = commands.registerCommand(OxcCommands.ToggleEnable, async () => { await configService.vsCodeConfig.updateEnable(!configService.vsCodeConfig.enable); - await toggleClient(configService); + await linter.toggleClient(configService); }); const onDidChangeWorkspaceFoldersDispose = workspace.onDidChangeWorkspaceFolders(async (event) => { @@ -56,14 +51,14 @@ export async function activate(context: ExtensionContext) { ); configService.onConfigChange = async function onConfigChange(event) { - await onConfigChangeLinter(event, configService, statusBarItemHandler); + await linter.onConfigChange(event, configService, statusBarItemHandler); }; - await activateLinter(context, outputChannel, configService, statusBarItemHandler); + await linter.activate(context, outputChannel, configService, statusBarItemHandler); // Show status bar item after activation statusBarItemHandler.show(); } export async function deactivate(): Promise { - await deactivateLinter(); + await linter.deactivate(); } diff --git a/editors/vscode/client/linter.ts b/editors/vscode/client/linter.ts deleted file mode 100644 index 57b22f85781d1..0000000000000 --- a/editors/vscode/client/linter.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { promises as fsPromises } from 'node:fs'; - -import { commands, ConfigurationChangeEvent, ExtensionContext, LogOutputChannel, Uri, window, workspace } from 'vscode'; - -import { ConfigurationParams, ExecuteCommandRequest, ShowMessageNotification } from 'vscode-languageclient'; - -import { Executable, LanguageClient, LanguageClientOptions, ServerOptions } from 'vscode-languageclient/node'; - -import { join } from 'node:path'; -import { OxcCommands } from './commands'; -import { ConfigService } from './ConfigService'; -import { onClientNotification, runExecutable } from './lsp_helper'; -import StatusBarItemHandler from './StatusBarItemHandler'; -import { VSCodeConfig } from './VSCodeConfig'; - -const languageClientName = 'oxc'; - -const enum LspCommands { - FixAll = 'oxc.fixAll', -} - -let client: LanguageClient | undefined; - -// Global flag to check if the user allows us to start the server. -// When `oxc.requireConfig` is `true`, make sure one `.oxlintrc.json` file is present. -let allowedToStartServer: boolean; - -export async function activate( - context: ExtensionContext, - outputChannel: LogOutputChannel, - configService: ConfigService, - statusBarItemHandler: StatusBarItemHandler, -) { - allowedToStartServer = configService.vsCodeConfig.requireConfig - ? (await workspace.findFiles(`**/.oxlintrc.json`, '**/node_modules/**', 1)).length > 0 - : true; - - const applyAllFixesFile = commands.registerCommand(OxcCommands.ApplyAllFixesFile, async () => { - if (!client) { - window.showErrorMessage('oxc client not found'); - return; - } - const textEditor = window.activeTextEditor; - if (!textEditor) { - window.showErrorMessage('active text editor not found'); - return; - } - - const params = { - command: LspCommands.FixAll, - arguments: [ - { - uri: textEditor.document.uri.toString(), - }, - ], - }; - - await client.sendRequest(ExecuteCommandRequest.type, params); - }); - - context.subscriptions.push(applyAllFixesFile); - - async function findBinary(): Promise { - const bin = configService.getUserServerBinPath(); - if (workspace.isTrusted && bin) { - try { - await fsPromises.access(bin); - return bin; - } catch (e) { - outputChannel.error(`Invalid bin path: ${bin}`, e); - } - } - const ext = process.platform === 'win32' ? '.exe' : ''; - // NOTE: The `./target/release` path is aligned with the path defined in .github/workflows/release_vscode.yml - return process.env.SERVER_PATH_DEV ?? join(context.extensionPath, `./target/release/oxc_language_server${ext}`); - } - - const path = await findBinary(); - - const run: Executable = runExecutable(path, configService.vsCodeConfig.nodePath); - const serverOptions: ServerOptions = { - run, - debug: run, - }; - - outputChannel.info(`Using server binary at: ${path}`); - - // see https://github.com/oxc-project/oxc/blob/9b475ad05b750f99762d63094174be6f6fc3c0eb/crates/oxc_linter/src/loader/partial_loader/mod.rs#L17-L20 - const supportedExtensions = ['astro', 'cjs', 'cts', 'js', 'jsx', 'mjs', 'mts', 'svelte', 'ts', 'tsx', 'vue']; - - // If the extension is launched in debug mode then the debug server options are used - // Otherwise the run options are used - // Options to control the language client - let clientOptions: LanguageClientOptions = { - // Register the server for plain text documents - documentSelector: [ - { - pattern: `**/*.{${supportedExtensions.join(',')}}`, - scheme: 'file', - }, - ], - initializationOptions: configService.languageServerConfig, - outputChannel, - traceOutputChannel: outputChannel, - middleware: { - handleDiagnostics: (uri, diagnostics, next) => { - for (const diag of diagnostics) { - // https://github.com/oxc-project/oxc/issues/12404 - if (typeof diag.code === 'object' && diag.code?.value === 'eslint-plugin-unicorn(filename-case)') { - diag.message += '\nYou may need to close the file and restart VSCode after renaming a file by only casing.'; - } - } - next(uri, diagnostics); - }, - workspace: { - configuration: (params: ConfigurationParams) => { - return params.items.map((item) => { - if (item.section !== 'oxc_language_server') { - return null; - } - if (item.scopeUri === undefined) { - return null; - } - - return configService.getWorkspaceConfig(Uri.parse(item.scopeUri))?.toLanguageServerConfig() ?? null; - }); - }, - }, - }, - }; - - // Create the language client and start the client. - client = new LanguageClient(languageClientName, serverOptions, clientOptions); - - const onNotificationDispose = client.onNotification(ShowMessageNotification.type, (params) => { - onClientNotification(params, outputChannel); - }); - - context.subscriptions.push(onNotificationDispose); - - const onDeleteFilesDispose = workspace.onDidDeleteFiles((event) => { - for (const fileUri of event.files) { - client?.diagnostics?.delete(fileUri); - } - }); - - context.subscriptions.push(onDeleteFilesDispose); - - updateStatusBar(statusBarItemHandler, configService.vsCodeConfig.enable); - if (allowedToStartServer) { - if (configService.vsCodeConfig.enable) { - await client.start(); - } - } else { - generateActivatorByConfig(configService.vsCodeConfig, context, statusBarItemHandler); - } -} - -export async function deactivate(): Promise { - if (!client) { - return undefined; - } - await client.stop(); - client = undefined; -} - -/** - * Get the status bar state based on whether oxc is enabled and allowed to start. - */ -function getStatusBarState(enable: boolean): { bgColor: string; icon: string; tooltipText: string } { - if (!allowedToStartServer) { - return { - bgColor: 'statusBarItem.offlineBackground', - icon: 'circle-slash', - tooltipText: 'oxc is disabled (no .oxlintrc.json found)', - }; - } else if (!enable) { - return { bgColor: 'statusBarItem.warningBackground', icon: 'check', tooltipText: 'oxc is disabled' }; - } else { - return { bgColor: 'statusBarItem.activeBackground', icon: 'check-all', tooltipText: 'oxc is enabled' }; - } -} - -function updateStatusBar(statusBarItemHandler: StatusBarItemHandler, enable: boolean) { - const { bgColor, icon, tooltipText } = getStatusBarState(enable); - - let text = - `**${tooltipText}**\n\n` + - `[$(terminal) Open Output](command:${OxcCommands.ShowOutputChannel})\n\n` + - `[$(refresh) Restart Server](command:${OxcCommands.RestartServer})\n\n`; - - if (enable) { - text += `[$(stop) Stop Server](command:${OxcCommands.ToggleEnable})\n\n`; - } else { - text += `[$(play) Start Server](command:${OxcCommands.ToggleEnable})\n\n`; - } - - statusBarItemHandler.setColorAndIcon(bgColor, icon); - statusBarItemHandler.updateToolTooltip('linter', text); -} - -function generateActivatorByConfig( - config: VSCodeConfig, - context: ExtensionContext, - statusBarItemHandler: StatusBarItemHandler, -): void { - const watcher = workspace.createFileSystemWatcher('**/.oxlintrc.json', false, true, !config.requireConfig); - watcher.onDidCreate(async () => { - allowedToStartServer = true; - updateStatusBar(statusBarItemHandler, config.enable); - if (client && !client.isRunning() && config.enable) { - await client.start(); - } - }); - - watcher.onDidDelete(async () => { - // only can be called when config.requireConfig - allowedToStartServer = (await workspace.findFiles(`**/.oxlintrc.json`, '**/node_modules/**', 1)).length > 0; - if (!allowedToStartServer) { - updateStatusBar(statusBarItemHandler, false); - if (client && client.isRunning()) { - await client.stop(); - } - } - }); - - context.subscriptions.push(watcher); -} - -export async function restartClient(): Promise { - if (client === undefined) { - window.showErrorMessage('oxc client not found'); - return; - } - - try { - if (client.isRunning()) { - await client.restart(); - window.showInformationMessage('oxc server restarted.'); - } else { - await client.start(); - } - } catch (err) { - client.error('Restarting client failed', err, 'force'); - } -} - -export async function toggleClient(configService: ConfigService): Promise { - if (client === undefined || !allowedToStartServer) { - return; - } - - if (client.isRunning()) { - if (!configService.vsCodeConfig.enable) { - await client.stop(); - } - } else { - if (configService.vsCodeConfig.enable) { - await client.start(); - } - } -} - -export async function onConfigChange( - event: ConfigurationChangeEvent, - configService: ConfigService, - statusBarItemHandler: StatusBarItemHandler, -): Promise { - updateStatusBar(statusBarItemHandler, configService.vsCodeConfig.enable); - - if (client === undefined) { - return; - } - - // update the initializationOptions for a possible restart - client.clientOptions.initializationOptions = configService.languageServerConfig; - - if (configService.effectsWorkspaceConfigChange(event) && client.isRunning()) { - await client.sendNotification('workspace/didChangeConfiguration', { - settings: configService.languageServerConfig, - }); - } -} diff --git a/editors/vscode/client/tools/ToolInterface.ts b/editors/vscode/client/tools/ToolInterface.ts new file mode 100644 index 0000000000000..34f88e72411a9 --- /dev/null +++ b/editors/vscode/client/tools/ToolInterface.ts @@ -0,0 +1,39 @@ +import { ConfigurationChangeEvent, ExtensionContext, LogOutputChannel } from 'vscode'; +import { ConfigService } from '../ConfigService'; +import StatusBarItemHandler from '../StatusBarItemHandler'; + +export default interface ToolInterface { + /** + * Activates the tool and initializes any necessary resources. + */ + activate( + context: ExtensionContext, + outputChannel: LogOutputChannel, + configService: ConfigService, + statusBarItemHandler: StatusBarItemHandler, + ): Promise; + + /** + * Deactivates the tool and cleans up any resources. + */ + deactivate(): Promise; + + /** + * Toggles the tool's active state based on configuration. + */ + toggleClient(configService: ConfigService): Promise; + + /** + * Restart the tool. + */ + restartClient(): Promise; + + /** + * Handles configuration changes. + */ + onConfigChange( + event: ConfigurationChangeEvent, + configService: ConfigService, + statusBarItemHandler: StatusBarItemHandler, + ): Promise; +} diff --git a/editors/vscode/client/tools/linter.ts b/editors/vscode/client/tools/linter.ts new file mode 100644 index 0000000000000..c52e8f0b83a68 --- /dev/null +++ b/editors/vscode/client/tools/linter.ts @@ -0,0 +1,292 @@ +import { promises as fsPromises } from 'node:fs'; + +import { commands, ConfigurationChangeEvent, ExtensionContext, LogOutputChannel, Uri, window, workspace } from 'vscode'; + +import { ConfigurationParams, ExecuteCommandRequest, ShowMessageNotification } from 'vscode-languageclient'; + +import { Executable, LanguageClient, LanguageClientOptions, ServerOptions } from 'vscode-languageclient/node'; + +import { join } from 'node:path'; +import { OxcCommands } from '../commands'; +import { ConfigService } from '../ConfigService'; +import StatusBarItemHandler from '../StatusBarItemHandler'; +import { VSCodeConfig } from '../VSCodeConfig'; +import { onClientNotification, runExecutable } from './lsp_helper'; +import ToolInterface from './ToolInterface'; + +const languageClientName = 'oxc'; + +const enum LspCommands { + FixAll = 'oxc.fixAll', +} + +export default class LinterTool implements ToolInterface { + // Global flag to check if the user allows us to start the server. + // When `oxc.requireConfig` is `true`, make sure one `.oxlintrc.json` file is present. + private allowedToStartServer: boolean = false; + + // LSP client instance + private client: LanguageClient | undefined; + + async activate( + context: ExtensionContext, + outputChannel: LogOutputChannel, + configService: ConfigService, + statusBarItemHandler: StatusBarItemHandler, + ): Promise { + this.allowedToStartServer = configService.vsCodeConfig.requireConfig + ? (await workspace.findFiles(`**/.oxlintrc.json`, '**/node_modules/**', 1)).length > 0 + : true; + + const applyAllFixesFile = commands.registerCommand(OxcCommands.ApplyAllFixesFile, async () => { + if (!this.client) { + window.showErrorMessage('oxc client not found'); + return; + } + const textEditor = window.activeTextEditor; + if (!textEditor) { + window.showErrorMessage('active text editor not found'); + return; + } + + const params = { + command: LspCommands.FixAll, + arguments: [ + { + uri: textEditor.document.uri.toString(), + }, + ], + }; + + await this.client.sendRequest(ExecuteCommandRequest.type, params); + }); + + context.subscriptions.push(applyAllFixesFile); + + async function findBinary(): Promise { + const bin = configService.getUserServerBinPath(); + if (workspace.isTrusted && bin) { + try { + await fsPromises.access(bin); + return bin; + } catch (e) { + outputChannel.error(`Invalid bin path: ${bin}`, e); + } + } + const ext = process.platform === 'win32' ? '.exe' : ''; + // NOTE: The `./target/release` path is aligned with the path defined in .github/workflows/release_vscode.yml + return process.env.SERVER_PATH_DEV ?? join(context.extensionPath, `./target/release/oxc_language_server${ext}`); + } + + const path = await findBinary(); + + const run: Executable = runExecutable(path, configService.vsCodeConfig.nodePath); + const serverOptions: ServerOptions = { + run, + debug: run, + }; + + outputChannel.info(`Using server binary at: ${path}`); + + // see https://github.com/oxc-project/oxc/blob/9b475ad05b750f99762d63094174be6f6fc3c0eb/crates/oxc_linter/src/loader/partial_loader/mod.rs#L17-L20 + const supportedExtensions = ['astro', 'cjs', 'cts', 'js', 'jsx', 'mjs', 'mts', 'svelte', 'ts', 'tsx', 'vue']; + + // If the extension is launched in debug mode then the debug server options are used + // Otherwise the run options are used + // Options to control the language client + let clientOptions: LanguageClientOptions = { + // Register the server for plain text documents + documentSelector: [ + { + pattern: `**/*.{${supportedExtensions.join(',')}}`, + scheme: 'file', + }, + ], + initializationOptions: configService.languageServerConfig, + outputChannel, + traceOutputChannel: outputChannel, + middleware: { + handleDiagnostics: (uri, diagnostics, next) => { + for (const diag of diagnostics) { + // https://github.com/oxc-project/oxc/issues/12404 + if (typeof diag.code === 'object' && diag.code?.value === 'eslint-plugin-unicorn(filename-case)') { + diag.message += + '\nYou may need to close the file and restart VSCode after renaming a file by only casing.'; + } + } + next(uri, diagnostics); + }, + workspace: { + configuration: (params: ConfigurationParams) => { + return params.items.map((item) => { + if (item.section !== 'oxc_language_server') { + return null; + } + if (item.scopeUri === undefined) { + return null; + } + + return configService.getWorkspaceConfig(Uri.parse(item.scopeUri))?.toLanguageServerConfig() ?? null; + }); + }, + }, + }, + }; + + // Create the language client and start the client. + this.client = new LanguageClient(languageClientName, serverOptions, clientOptions); + + const onNotificationDispose = this.client.onNotification(ShowMessageNotification.type, (params) => { + onClientNotification(params, outputChannel); + }); + + context.subscriptions.push(onNotificationDispose); + + const onDeleteFilesDispose = workspace.onDidDeleteFiles((event) => { + for (const fileUri of event.files) { + this.client?.diagnostics?.delete(fileUri); + } + }); + + context.subscriptions.push(onDeleteFilesDispose); + + this.updateStatusBar(statusBarItemHandler, configService.vsCodeConfig.enable); + if (this.allowedToStartServer) { + if (configService.vsCodeConfig.enable) { + await this.client.start(); + } + } else { + this.generateActivatorByConfig(configService.vsCodeConfig, context, statusBarItemHandler); + } + } + + async deactivate(): Promise { + if (!this.client) { + return undefined; + } + await this.client.stop(); + this.client = undefined; + } + + async toggleClient(configService: ConfigService): Promise { + if (this.client === undefined || !this.allowedToStartServer) { + return; + } + + if (this.client.isRunning()) { + if (!configService.vsCodeConfig.enable) { + await this.client.stop(); + } + } else { + if (configService.vsCodeConfig.enable) { + await this.client.start(); + } + } + } + + async restartClient(): Promise { + if (this.client === undefined) { + window.showErrorMessage('oxc client not found'); + return; + } + + try { + if (this.client.isRunning()) { + await this.client.restart(); + window.showInformationMessage('oxc server restarted.'); + } else { + await this.client.start(); + } + } catch (err) { + this.client.error('Restarting client failed', err, 'force'); + } + } + + async onConfigChange( + event: ConfigurationChangeEvent, + configService: ConfigService, + statusBarItemHandler: StatusBarItemHandler, + ): Promise { + this.updateStatusBar(statusBarItemHandler, configService.vsCodeConfig.enable); + + if (this.client === undefined) { + return; + } + + // update the initializationOptions for a possible restart + this.client.clientOptions.initializationOptions = configService.languageServerConfig; + + if (configService.effectsWorkspaceConfigChange(event) && this.client.isRunning()) { + await this.client.sendNotification('workspace/didChangeConfiguration', { + settings: configService.languageServerConfig, + }); + } + } + + /** + * ------- Helpers ------- + */ + + /** + * Get the status bar state based on whether oxc is enabled and allowed to start. + */ + getStatusBarState(enable: boolean): { bgColor: string; icon: string; tooltipText: string } { + if (!this.allowedToStartServer) { + return { + bgColor: 'statusBarItem.offlineBackground', + icon: 'circle-slash', + tooltipText: 'oxc is disabled (no .oxlintrc.json found)', + }; + } else if (!enable) { + return { bgColor: 'statusBarItem.warningBackground', icon: 'check', tooltipText: 'oxc is disabled' }; + } else { + return { bgColor: 'statusBarItem.activeBackground', icon: 'check-all', tooltipText: 'oxc is enabled' }; + } + } + + updateStatusBar(statusBarItemHandler: StatusBarItemHandler, enable: boolean) { + const { bgColor, icon, tooltipText } = this.getStatusBarState(enable); + + let text = + `**${tooltipText}**\n\n` + + `[$(terminal) Open Output](command:${OxcCommands.ShowOutputChannel})\n\n` + + `[$(refresh) Restart Server](command:${OxcCommands.RestartServer})\n\n`; + + if (enable) { + text += `[$(stop) Stop Server](command:${OxcCommands.ToggleEnable})\n\n`; + } else { + text += `[$(play) Start Server](command:${OxcCommands.ToggleEnable})\n\n`; + } + + statusBarItemHandler.setColorAndIcon(bgColor, icon); + statusBarItemHandler.updateToolTooltip('linter', text); + } + + generateActivatorByConfig( + config: VSCodeConfig, + context: ExtensionContext, + statusBarItemHandler: StatusBarItemHandler, + ): void { + const watcher = workspace.createFileSystemWatcher('**/.oxlintrc.json', false, true, !config.requireConfig); + watcher.onDidCreate(async () => { + this.allowedToStartServer = true; + this.updateStatusBar(statusBarItemHandler, config.enable); + if (this.client && !this.client.isRunning() && config.enable) { + await this.client.start(); + } + }); + + watcher.onDidDelete(async () => { + // only can be called when config.requireConfig + this.allowedToStartServer = (await workspace.findFiles(`**/.oxlintrc.json`, '**/node_modules/**', 1)).length > 0; + if (!this.allowedToStartServer) { + this.updateStatusBar(statusBarItemHandler, false); + if (this.client && this.client.isRunning()) { + await this.client.stop(); + } + } + }); + + context.subscriptions.push(watcher); + } +} diff --git a/editors/vscode/client/lsp_helper.ts b/editors/vscode/client/tools/lsp_helper.ts similarity index 100% rename from editors/vscode/client/lsp_helper.ts rename to editors/vscode/client/tools/lsp_helper.ts diff --git a/editors/vscode/tests/lsp_helper.spec.ts b/editors/vscode/tests/lsp_helper.spec.ts index d01203491e2b2..f2b3756209975 100644 --- a/editors/vscode/tests/lsp_helper.spec.ts +++ b/editors/vscode/tests/lsp_helper.spec.ts @@ -1,5 +1,5 @@ import { strictEqual } from 'assert'; -import { runExecutable } from '../client/lsp_helper'; +import { runExecutable } from '../client/tools/lsp_helper'; suite('runExecutable', () => { const originalPlatform = process.platform;