diff --git a/editors/vscode/client/commands.ts b/editors/vscode/client/commands.ts new file mode 100644 index 0000000000000..9698f081e6968 --- /dev/null +++ b/editors/vscode/client/commands.ts @@ -0,0 +1,9 @@ +const commandPrefix = 'oxc'; + +export const enum OxcCommands { + RestartServer = `${commandPrefix}.restartServer`, + ShowOutputChannel = `${commandPrefix}.showOutputChannel`, + ToggleEnable = `${commandPrefix}.toggleEnable`, + // only for linter.ts usage + ApplyAllFixesFile = `${commandPrefix}.applyAllFixesFile`, +} diff --git a/editors/vscode/client/extension.ts b/editors/vscode/client/extension.ts index 99dbceb6d355a..3158df3053fd8 100644 --- a/editors/vscode/client/extension.ts +++ b/editors/vscode/client/extension.ts @@ -1,367 +1,63 @@ -import { promises as fsPromises } from 'node:fs'; +import { commands, ExtensionContext, window, workspace } from 'vscode'; -import { - commands, - ExtensionContext, - StatusBarAlignment, - StatusBarItem, - ThemeColor, - Uri, - window, - workspace, -} from 'vscode'; - -import { - ConfigurationParams, - ExecuteCommandRequest, - MessageType, - 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 { VSCodeConfig } from './VSCodeConfig'; +import { + activate as activateLinter, + deactivate as deactivateLinter, + onConfigChange as onConfigChangeLinter, + restartClient, + toggleClient, +} from './linter'; -const languageClientName = 'oxc'; const outputChannelName = 'Oxc'; -const commandPrefix = 'oxc'; - -const enum OxcCommands { - RestartServer = `${commandPrefix}.restartServer`, - ApplyAllFixesFile = `${commandPrefix}.applyAllFixesFile`, - ShowOutputChannel = `${commandPrefix}.showOutputChannel`, - ToggleEnable = `${commandPrefix}.toggleEnable`, -} - -const enum LspCommands { - FixAll = 'oxc.fixAll', -} - -let client: LanguageClient | undefined; - -let oxcStatusBarItem: StatusBarItem; - -// 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) { const configService = new ConfigService(); - allowedToStartServer = configService.vsCodeConfig.requireConfig - ? (await workspace.findFiles(`**/.oxlintrc.json`, '**/node_modules/**', 1)).length > 0 - : true; - const restartCommand = commands.registerCommand(OxcCommands.RestartServer, async () => { - if (client === undefined) { - window.showErrorMessage('oxc client not found'); - return; - } + const outputChannel = window.createOutputChannel(outputChannelName, { + log: true, + }); - 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'); - } + const restartCommand = commands.registerCommand(OxcCommands.RestartServer, async () => { + await restartClient(); }); const showOutputCommand = commands.registerCommand(OxcCommands.ShowOutputChannel, () => { - client?.outputChannel?.show(); + outputChannel.show(); }); const toggleEnable = commands.registerCommand(OxcCommands.ToggleEnable, async () => { await configService.vsCodeConfig.updateEnable(!configService.vsCodeConfig.enable); - if (client === undefined || !allowedToStartServer) { - return; - } - - if (client.isRunning()) { - if (!configService.vsCodeConfig.enable) { - await client.stop(); - } - } else { - if (configService.vsCodeConfig.enable) { - await client.start(); - } - } + await toggleClient(configService); }); - const applyAllFixesFile = commands.registerCommand(OxcCommands.ApplyAllFixesFile, async () => { - if (!client) { - window.showErrorMessage('oxc client not found'); - return; + const onDidChangeWorkspaceFoldersDispose = workspace.onDidChangeWorkspaceFolders(async (event) => { + for (const folder of event.added) { + configService.addWorkspaceConfig(folder); } - const textEditor = window.activeTextEditor; - if (!textEditor) { - window.showErrorMessage('active text editor not found'); - return; + for (const folder of event.removed) { + configService.removeWorkspaceConfig(folder); } - - const params = { - command: LspCommands.FixAll, - arguments: [ - { - uri: textEditor.document.uri.toString(), - }, - ], - }; - - await client.sendRequest(ExecuteCommandRequest.type, params); - }); - - const outputChannel = window.createOutputChannel(outputChannelName, { - log: true, }); context.subscriptions.push( - applyAllFixesFile, restartCommand, showOutputCommand, toggleEnable, configService, outputChannel, + onDidChangeWorkspaceFoldersDispose, ); - 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 nodePath = configService.vsCodeConfig.nodePath; - const serverEnv: Record = { - ...process.env, - RUST_LOG: process.env.RUST_LOG || 'info', - }; - if (nodePath) { - serverEnv.PATH = `${nodePath}${process.platform === 'win32' ? ';' : ':'}${process.env.PATH ?? ''}`; - } - - const path = await findBinary(); - - const run: Executable = - process.env.OXLINT_LSP_TEST === 'true' - ? { - command: 'node', - args: [path!, '--lsp'], - options: { - env: serverEnv, - }, - } - : { - command: path!, - args: ['--lsp'], - options: { - // On Windows we need to run the binary in a shell to be able to execute the shell npm bin script. - // Searching for the right `.exe` file inside `node_modules/` is not reliable as it depends on - // the package manager used (npm, yarn, pnpm, etc) and the package version. - // The npm bin script is a shell script that points to the actual binary. - // Security: We validated the userDefinedBinary in `configService.getUserServerBinPath()`. - shell: process.platform === 'win32', - env: serverEnv, - }, - }; - - 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) => { - switch (params.type) { - case MessageType.Debug: - outputChannel.debug(params.message); - break; - case MessageType.Log: - outputChannel.info(params.message); - break; - case MessageType.Info: - window.showInformationMessage(params.message); - break; - case MessageType.Warning: - window.showWarningMessage(params.message); - break; - case MessageType.Error: - window.showErrorMessage(params.message); - break; - default: - outputChannel.info(params.message); - } - }); - - context.subscriptions.push(onNotificationDispose); - - const onDeleteFilesDispose = workspace.onDidDeleteFiles((event) => { - for (const fileUri of event.files) { - client?.diagnostics?.delete(fileUri); - } - }); - - context.subscriptions.push(onDeleteFilesDispose); - - const onDidChangeWorkspaceFoldersDispose = workspace.onDidChangeWorkspaceFolders(async (event) => { - for (const folder of event.added) { - configService.addWorkspaceConfig(folder); - } - for (const folder of event.removed) { - configService.removeWorkspaceConfig(folder); - } - }); - - context.subscriptions.push(onDidChangeWorkspaceFoldersDispose); - configService.onConfigChange = async function onConfigChange(event) { - updateStatusBar(context, this.vsCodeConfig.enable); - - if (client === undefined) { - return; - } - - // update the initializationOptions for a possible restart - client.clientOptions.initializationOptions = this.languageServerConfig; - - if (configService.effectsWorkspaceConfigChange(event) && client.isRunning()) { - await client.sendNotification('workspace/didChangeConfiguration', { - settings: this.languageServerConfig, - }); - } + await onConfigChangeLinter(context, event, configService); }; - updateStatusBar(context, configService.vsCodeConfig.enable); - if (allowedToStartServer) { - if (configService.vsCodeConfig.enable) { - await client.start(); - } - } else { - generateActivatorByConfig(configService.vsCodeConfig, context); - } + await activateLinter(context, outputChannel, configService); } 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(context: ExtensionContext, enable: boolean) { - if (!oxcStatusBarItem) { - oxcStatusBarItem = window.createStatusBarItem(StatusBarAlignment.Right, 100); - oxcStatusBarItem.command = OxcCommands.ToggleEnable; - context.subscriptions.push(oxcStatusBarItem); - oxcStatusBarItem.show(); - } - - const { bgColor, icon, tooltipText } = getStatusBarState(enable); - - oxcStatusBarItem.text = `$(${icon}) oxc`; - oxcStatusBarItem.backgroundColor = new ThemeColor(bgColor); - oxcStatusBarItem.tooltip = tooltipText; -} - -function generateActivatorByConfig(config: VSCodeConfig, context: ExtensionContext): void { - const watcher = workspace.createFileSystemWatcher('**/.oxlintrc.json', false, true, !config.requireConfig); - watcher.onDidCreate(async () => { - allowedToStartServer = true; - updateStatusBar(context, 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(context, false); - if (client && client.isRunning()) { - await client.stop(); - } - } - }); - - context.subscriptions.push(watcher); + await deactivateLinter(); } diff --git a/editors/vscode/client/linter.ts b/editors/vscode/client/linter.ts new file mode 100644 index 0000000000000..ec256e4c6d32a --- /dev/null +++ b/editors/vscode/client/linter.ts @@ -0,0 +1,340 @@ +import { promises as fsPromises } from 'node:fs'; + +import { + commands, + ConfigurationChangeEvent, + ExtensionContext, + LogOutputChannel, + StatusBarAlignment, + StatusBarItem, + ThemeColor, + Uri, + window, + workspace, +} from 'vscode'; + +import { + ConfigurationParams, + ExecuteCommandRequest, + MessageType, + ShowMessageNotification, +} from 'vscode-languageclient'; + +import { Executable, LanguageClient, LanguageClientOptions, ServerOptions } from 'vscode-languageclient/node'; + +import { join } from 'node:path'; +import { ConfigService } from './ConfigService'; +import { VSCodeConfig } from './VSCodeConfig'; +import { OxcCommands } from './commands'; + +const languageClientName = 'oxc'; + +const enum LspCommands { + FixAll = 'oxc.fixAll', +} + +let client: LanguageClient | undefined; + +let oxcStatusBarItem: StatusBarItem; + +// 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, +) { + 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 nodePath = configService.vsCodeConfig.nodePath; + const serverEnv: Record = { + ...process.env, + RUST_LOG: process.env.RUST_LOG || 'info', + }; + if (nodePath) { + serverEnv.PATH = `${nodePath}${process.platform === 'win32' ? ';' : ':'}${process.env.PATH ?? ''}`; + } + + const path = await findBinary(); + + const run: Executable = + process.env.OXLINT_LSP_TEST === 'true' + ? { + command: 'node', + args: [path!, '--lsp'], + options: { + env: serverEnv, + }, + } + : { + command: path!, + args: ['--lsp'], + options: { + // On Windows we need to run the binary in a shell to be able to execute the shell npm bin script. + // Searching for the right `.exe` file inside `node_modules/` is not reliable as it depends on + // the package manager used (npm, yarn, pnpm, etc) and the package version. + // The npm bin script is a shell script that points to the actual binary. + // Security: We validated the userDefinedBinary in `configService.getUserServerBinPath()`. + shell: process.platform === 'win32', + env: serverEnv, + }, + }; + + 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) => { + switch (params.type) { + case MessageType.Debug: + outputChannel.debug(params.message); + break; + case MessageType.Log: + outputChannel.info(params.message); + break; + case MessageType.Info: + window.showInformationMessage(params.message); + break; + case MessageType.Warning: + window.showWarningMessage(params.message); + break; + case MessageType.Error: + window.showErrorMessage(params.message); + break; + default: + outputChannel.info(params.message); + } + }); + + context.subscriptions.push(onNotificationDispose); + + const onDeleteFilesDispose = workspace.onDidDeleteFiles((event) => { + for (const fileUri of event.files) { + client?.diagnostics?.delete(fileUri); + } + }); + + context.subscriptions.push(onDeleteFilesDispose); + + updateStatusBar(context, configService.vsCodeConfig.enable); + if (allowedToStartServer) { + if (configService.vsCodeConfig.enable) { + await client.start(); + } + } else { + generateActivatorByConfig(configService.vsCodeConfig, context); + } +} + +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(context: ExtensionContext, enable: boolean) { + if (!oxcStatusBarItem) { + oxcStatusBarItem = window.createStatusBarItem(StatusBarAlignment.Right, 100); + oxcStatusBarItem.command = OxcCommands.ToggleEnable; + context.subscriptions.push(oxcStatusBarItem); + oxcStatusBarItem.show(); + } + + const { bgColor, icon, tooltipText } = getStatusBarState(enable); + + oxcStatusBarItem.text = `$(${icon}) oxc`; + oxcStatusBarItem.backgroundColor = new ThemeColor(bgColor); + oxcStatusBarItem.tooltip = tooltipText; +} + +function generateActivatorByConfig(config: VSCodeConfig, context: ExtensionContext): void { + const watcher = workspace.createFileSystemWatcher('**/.oxlintrc.json', false, true, !config.requireConfig); + watcher.onDidCreate(async () => { + allowedToStartServer = true; + updateStatusBar(context, 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(context, 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( + context: ExtensionContext, + event: ConfigurationChangeEvent, + configService: ConfigService, +): Promise { + updateStatusBar(context, 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, + }); + } +}