From 2fcc079964c0bcc8f20f43c7c4c9467d6773b95b Mon Sep 17 00:00:00 2001
From: Sysix <3897725+Sysix@users.noreply.github.com>
Date: Fri, 5 Dec 2025 15:51:39 +0000
Subject: [PATCH] chore(vscode): second language server connection for `oxfmt`
(#15837)
This PR implements a second language server connection only for `oxfmt`. Currently, it still uses the shipped `oxc_language_server` binary for formatting. When defining a custom `oxc.path.oxlint` (or `oxc.path.server`), it will start the search process for `node_modules/.bin/oxfmt` or uses `oxc.path.oxfmt`.
This should be **not** a breaking change. When `node_modules/.bin/oxfmt` should be required for formatting, the `ConfigService.useOxcLanguageServerForFormatting` must not be modified. I would love to wait for the correct search implementation for `.node_modules/.bin/oxlint` before breaking the server detection (and probably removing the shipped server too).
### Default / with `oxfmt` installed:
Using the internal language server (once)
### with `oxc.path.oxlint`:
Using the oxlint path server for linting.
Searching for `oxfmt` inside `node_modules/.bin`
### with `oxc.path.fmt`:
Using the internal language server for linting.
Using `oxc.path.fmt` binary
---
.vscode/settings.json | 3 +
editors/vscode/client/ConfigService.ts | 66 ++++-
editors/vscode/client/StatusBarItemHandler.ts | 4 +-
editors/vscode/client/VSCodeConfig.ts | 11 +
editors/vscode/client/WorkspaceConfig.ts | 14 +-
editors/vscode/client/commands.ts | 12 +-
editors/vscode/client/extension.ts | 118 +++++++--
editors/vscode/client/tools/formatter.ts | 245 ++++++++++++++++++
editors/vscode/client/tools/linter.ts | 37 ++-
editors/vscode/package.json | 23 +-
editors/vscode/tests/ConfigService.spec.ts | 61 ++++-
editors/vscode/tests/VSCodeConfig.spec.ts | 5 +-
editors/vscode/tests/commands.spec.ts | 46 +++-
13 files changed, 580 insertions(+), 65 deletions(-)
create mode 100644 editors/vscode/client/tools/formatter.ts
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 222f58a0a93b1..19c5ebb0fc926 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,7 +1,10 @@
{
+ // "oxc.path.oxlint": "apps/oxlint/dist/cli.js", // debug with local oxlint build
"oxc.typeAware": true,
"oxc.configPath": "oxlintrc.json",
"oxc.unusedDisableDirectives": "deny",
+
+ // "oxc.path.oxfmt": "apps/oxfmt/dist/cli.js", // debug with local oxfmt build
"oxc.fmt.experimental": true,
"oxc.fmt.configPath": "oxfmtrc.jsonc",
"[javascript]": {
diff --git a/editors/vscode/client/ConfigService.ts b/editors/vscode/client/ConfigService.ts
index 630f4fc6883bd..4379f0ead6e8f 100644
--- a/editors/vscode/client/ConfigService.ts
+++ b/editors/vscode/client/ConfigService.ts
@@ -3,12 +3,23 @@ import { ConfigurationChangeEvent, Uri, workspace, WorkspaceFolder } from "vscod
import { validateSafeBinaryPath } from "./PathValidator";
import { IDisposable } from "./types";
import { VSCodeConfig } from "./VSCodeConfig";
-import { WorkspaceConfig, WorkspaceConfigInterface } from "./WorkspaceConfig";
+import {
+ OxfmtWorkspaceConfigInterface,
+ OxlintWorkspaceConfigInterface,
+ WorkspaceConfig,
+ WorkspaceConfigInterface,
+} from "./WorkspaceConfig";
export class ConfigService implements IDisposable {
public static readonly namespace = "oxc";
private readonly _disposables: IDisposable[] = [];
+ /**
+ * Indicates whether the `oxc_language_server` is being used as the formatter.
+ * If true, the formatter functionality is handled by the language server itself.
+ */
+ public useOxcLanguageServerForFormatting: boolean = false;
+
public vsCodeConfig: VSCodeConfig;
private workspaceConfigs: Map = new Map();
@@ -33,10 +44,29 @@ export class ConfigService implements IDisposable {
this._disposables.push(disposeChangeListener);
}
- public get languageServerConfig(): { workspaceUri: string; options: WorkspaceConfigInterface }[] {
+ public get languageServerConfig(): {
+ workspaceUri: string;
+ options: WorkspaceConfigInterface | OxlintWorkspaceConfigInterface;
+ }[] {
+ return [...this.workspaceConfigs.entries()].map(([path, config]) => {
+ const options = this.useOxcLanguageServerForFormatting
+ ? config.toLanguageServerConfig()
+ : config.toOxlintConfig();
+
+ return {
+ workspaceUri: Uri.file(path).toString(),
+ options,
+ };
+ });
+ }
+
+ public get formatterServerConfig(): {
+ workspaceUri: string;
+ options: OxfmtWorkspaceConfigInterface;
+ }[] {
return [...this.workspaceConfigs.entries()].map(([path, config]) => ({
workspaceUri: Uri.file(path).toString(),
- options: config.toLanguageServerConfig(),
+ options: config.toOxfmtConfig(),
}));
}
@@ -88,6 +118,36 @@ export class ConfigService implements IDisposable {
return bin;
}
+ public async getOxfmtServerBinPath(): Promise {
+ let bin = this.vsCodeConfig.binPathOxfmt;
+ if (!bin) {
+ // try to find oxfmt in node_modules/.bin
+ const files = await workspace.findFiles("**/node_modules/.bin/oxfmt", null, 1);
+
+ return files.length > 0 ? files[0].fsPath : undefined;
+ }
+
+ // validates the given path is safe to use
+ if (validateSafeBinaryPath(bin) === false) {
+ return undefined;
+ }
+
+ if (!path.isAbsolute(bin)) {
+ // if the path is not absolute, resolve it to the first workspace folder
+ const cwd = this.workspaceConfigs.keys().next().value;
+ if (!cwd) {
+ return undefined;
+ }
+ bin = path.normalize(path.join(cwd, bin));
+ // strip the leading slash on Windows
+ if (process.platform === "win32" && bin.startsWith("\\")) {
+ bin = bin.slice(1);
+ }
+ }
+
+ return bin;
+ }
+
private async onVscodeConfigChange(event: ConfigurationChangeEvent): Promise {
let isConfigChanged = false;
diff --git a/editors/vscode/client/StatusBarItemHandler.ts b/editors/vscode/client/StatusBarItemHandler.ts
index 5246a0b721131..b95c549d44c3e 100644
--- a/editors/vscode/client/StatusBarItemHandler.ts
+++ b/editors/vscode/client/StatusBarItemHandler.ts
@@ -34,7 +34,9 @@ export default class StatusBarItemHandler {
}
private updateFullTooltip(): void {
- const text = Array.from(this.tooltipSections.values()).join("\n\n");
+ const text = [this.tooltipSections.get("linter"), this.tooltipSections.get("formatter")]
+ .filter(Boolean)
+ .join("\n\n---\n\n");
if (!(this.statusBarItem.tooltip instanceof MarkdownString)) {
this.statusBarItem.tooltip = new MarkdownString("", true);
diff --git a/editors/vscode/client/VSCodeConfig.ts b/editors/vscode/client/VSCodeConfig.ts
index 96fe003a5e1a5..254fe0aee1290 100644
--- a/editors/vscode/client/VSCodeConfig.ts
+++ b/editors/vscode/client/VSCodeConfig.ts
@@ -5,6 +5,7 @@ export class VSCodeConfig implements VSCodeConfigInterface {
private _enable!: boolean;
private _trace!: TraceLevel;
private _binPathOxlint: string | undefined;
+ private _binPathOxfmt: string | undefined;
private _nodePath: string | undefined;
private _requireConfig!: boolean;
@@ -25,6 +26,7 @@ export class VSCodeConfig implements VSCodeConfigInterface {
this._enable = this.configuration.get("enable") ?? true;
this._trace = this.configuration.get("trace.server") || "off";
this._binPathOxlint = binPathOxlint;
+ this._binPathOxfmt = this.configuration.get("path.oxfmt");
this._nodePath = this.configuration.get("path.node");
this._requireConfig = this.configuration.get("requireConfig") ?? false;
}
@@ -56,6 +58,15 @@ export class VSCodeConfig implements VSCodeConfigInterface {
return this.configuration.update("path.oxlint", value);
}
+ get binPathOxfmt(): string | undefined {
+ return this._binPathOxfmt;
+ }
+
+ updateBinPathOxfmt(value: string | undefined): PromiseLike {
+ this._binPathOxfmt = value;
+ return this.configuration.update("path.oxfmt", value);
+ }
+
get nodePath(): string | undefined {
return this._nodePath;
}
diff --git a/editors/vscode/client/WorkspaceConfig.ts b/editors/vscode/client/WorkspaceConfig.ts
index 39d68cc9fe800..9b8fadf573769 100644
--- a/editors/vscode/client/WorkspaceConfig.ts
+++ b/editors/vscode/client/WorkspaceConfig.ts
@@ -100,6 +100,16 @@ export interface WorkspaceConfigInterface {
["fmt.configPath"]?: string | null;
}
+export type OxlintWorkspaceConfigInterface = Omit<
+ WorkspaceConfigInterface,
+ "fmt.experimental" | "fmt.configPath"
+>;
+
+export type OxfmtWorkspaceConfigInterface = Pick<
+ WorkspaceConfigInterface,
+ "fmt.experimental" | "fmt.configPath"
+>;
+
export class WorkspaceConfig {
private _configPath: string | null = null;
private _tsConfigPath: string | null = null;
@@ -297,7 +307,7 @@ export class WorkspaceConfig {
};
}
- public toOxlintConfig(): Omit {
+ public toOxlintConfig(): OxlintWorkspaceConfigInterface {
return {
run: this.runTrigger,
configPath: this.configPath ?? null,
@@ -314,7 +324,7 @@ export class WorkspaceConfig {
};
}
- public toOxfmtConfig(): Pick {
+ public toOxfmtConfig(): OxfmtWorkspaceConfigInterface {
return {
["fmt.experimental"]: this.formattingExperimental,
["fmt.configPath"]: this.formattingConfigPath ?? null,
diff --git a/editors/vscode/client/commands.ts b/editors/vscode/client/commands.ts
index 393294ec17e5e..d1b5cdb6dd84e 100644
--- a/editors/vscode/client/commands.ts
+++ b/editors/vscode/client/commands.ts
@@ -1,9 +1,15 @@
const commandPrefix = "oxc";
export const enum OxcCommands {
- ShowOutputChannel = `${commandPrefix}.showOutputChannel`,
+ // always available, even if no tool is active
+ ShowOutputChannelLint = `${commandPrefix}.showOutputChannel`,
+ ShowOutputChannelFmt = `${commandPrefix}.showOutputChannelFormatter`,
+
// only for linter.ts usage
- RestartServer = `${commandPrefix}.restartServer`,
- ToggleEnable = `${commandPrefix}.toggleEnable`,
+ RestartServerLint = `${commandPrefix}.restartServer`, // without `Linter` suffix for backward compatibility
+ ToggleEnableLint = `${commandPrefix}.toggleEnable`, // without `Linter` suffix for backward compatibility
ApplyAllFixesFile = `${commandPrefix}.applyAllFixesFile`,
+
+ // only for formatter.ts usage
+ RestartServerFmt = `${commandPrefix}.restartServerFormatter`,
}
diff --git a/editors/vscode/client/extension.ts b/editors/vscode/client/extension.ts
index 50faeb146ff0a..81d5a830053c3 100644
--- a/editors/vscode/client/extension.ts
+++ b/editors/vscode/client/extension.ts
@@ -3,20 +3,37 @@ import { commands, ExtensionContext, window, workspace } from "vscode";
import { OxcCommands } from "./commands";
import { ConfigService } from "./ConfigService";
import StatusBarItemHandler from "./StatusBarItemHandler";
+import Formatter from "./tools/formatter";
import Linter from "./tools/linter";
+import ToolInterface from "./tools/ToolInterface";
const outputChannelName = "Oxc";
-const linter = new Linter();
+const tools: ToolInterface[] = [];
+
+if (process.env.SKIP_LINTER_TEST !== "true") {
+ tools.push(new Linter());
+}
+if (process.env.SKIP_FORMATTER_TEST !== "true") {
+ tools.push(new Formatter());
+}
export async function activate(context: ExtensionContext) {
const configService = new ConfigService();
- const outputChannel = window.createOutputChannel(outputChannelName, {
+ const outputChannelLint = window.createOutputChannel(outputChannelName + " (Lint)", {
+ log: true,
+ });
+
+ const outputChannelFormat = window.createOutputChannel(outputChannelName + " (Fmt)", {
log: true,
});
- const showOutputCommand = commands.registerCommand(OxcCommands.ShowOutputChannel, () => {
- outputChannel.show();
+ const showOutputLintCommand = commands.registerCommand(OxcCommands.ShowOutputChannelLint, () => {
+ outputChannelLint.show();
+ });
+
+ const showOutputFmtCommand = commands.registerCommand(OxcCommands.ShowOutputChannelFmt, () => {
+ outputChannelFormat.show();
});
const onDidChangeWorkspaceFoldersDispose = workspace.onDidChangeWorkspaceFolders(
@@ -33,35 +50,92 @@ export async function activate(context: ExtensionContext) {
const statusBarItemHandler = new StatusBarItemHandler(context.extension.packageJSON?.version);
context.subscriptions.push(
- showOutputCommand,
+ showOutputLintCommand,
+ showOutputFmtCommand,
configService,
- outputChannel,
+ outputChannelLint,
+ outputChannelFormat,
onDidChangeWorkspaceFoldersDispose,
statusBarItemHandler,
);
configService.onConfigChange = async function onConfigChange(event) {
- await linter.onConfigChange(event, configService, statusBarItemHandler);
- };
- const binaryPath = await linter.getBinary(context, outputChannel, configService);
-
- // For the linter this should never happen, but just in case.
- if (!binaryPath) {
- statusBarItemHandler.setColorAndIcon("statusBarItem.errorBackground", "error");
- statusBarItemHandler.updateToolTooltip(
- "linter",
- "Error: No valid oxc language server binary found.",
+ await Promise.all(
+ tools.map((tool) => tool.onConfigChange(event, configService, statusBarItemHandler)),
);
- statusBarItemHandler.show();
- outputChannel.error("No valid oxc language server binary found.");
- return;
+ };
+
+ const binaryPaths = await Promise.all(
+ tools.map((tool) =>
+ tool.getBinary(
+ context,
+ tool instanceof Linter ? outputChannelLint : outputChannelFormat,
+ configService,
+ ),
+ ),
+ );
+
+ // remove this block, when `oxfmt` binary is always required. This will be a breaking change.
+ if (
+ binaryPaths.some((path) => path?.includes("oxc_language_server")) &&
+ !configService.vsCodeConfig.binPathOxfmt
+ ) {
+ configService.useOxcLanguageServerForFormatting = true;
}
- await linter.activate(context, binaryPath, outputChannel, configService, statusBarItemHandler);
- // Show status bar item after activation
+ await Promise.all(
+ tools.map((tool): Promise => {
+ const binaryPath = binaryPaths[tools.indexOf(tool)];
+
+ // For the linter this should never happen, but just in case.
+ if (!binaryPath && tool instanceof Linter) {
+ statusBarItemHandler.setColorAndIcon("statusBarItem.errorBackground", "error");
+ statusBarItemHandler.updateToolTooltip(
+ "linter",
+ "**oxlint disabled**\n\nError: No valid oxc language server binary found.",
+ );
+ return Promise.resolve();
+ }
+
+ if (tool instanceof Formatter) {
+ if (configService.useOxcLanguageServerForFormatting) {
+ // The formatter is already handled by the linter tool in this case.
+ statusBarItemHandler.updateToolTooltip(
+ "formatter",
+ "**oxfmt disabled**\n\noxc_language_server is used for formatting.",
+ );
+ outputChannelFormat.appendLine("oxc_language_server is used for formatting.");
+ return Promise.resolve();
+ } else if (!binaryPath) {
+ // No valid binary found for the formatter.
+ statusBarItemHandler.updateToolTooltip(
+ "formatter",
+ "**oxfmt disabled**\n\nNo valid oxfmt binary found.",
+ );
+ outputChannelFormat.appendLine(
+ "No valid oxfmt binary found. Formatter will not be activated.",
+ );
+ return Promise.resolve();
+ }
+ }
+
+ // binaryPath is guaranteed to be defined here.
+ const binaryPathResolved = binaryPath!;
+
+ return tool.activate(
+ context,
+ binaryPathResolved,
+ tool instanceof Linter ? outputChannelLint : outputChannelFormat,
+ configService,
+ statusBarItemHandler,
+ );
+ }),
+ );
+
+ // Finally show the status bar item.
statusBarItemHandler.show();
}
export async function deactivate(): Promise {
- await linter.deactivate();
+ await Promise.all(tools.map((tool) => tool.deactivate()));
}
diff --git a/editors/vscode/client/tools/formatter.ts b/editors/vscode/client/tools/formatter.ts
new file mode 100644
index 0000000000000..33c920f93bac9
--- /dev/null
+++ b/editors/vscode/client/tools/formatter.ts
@@ -0,0 +1,245 @@
+import { promises as fsPromises } from "node:fs";
+
+import {
+ commands,
+ ConfigurationChangeEvent,
+ ExtensionContext,
+ LogOutputChannel,
+ Uri,
+ window,
+ workspace,
+} from "vscode";
+
+import { ConfigurationParams, ShowMessageNotification } from "vscode-languageclient";
+
+import {
+ Executable,
+ LanguageClient,
+ LanguageClientOptions,
+ ServerOptions,
+} from "vscode-languageclient/node";
+
+import { OxcCommands } from "../commands";
+import { ConfigService } from "../ConfigService";
+import StatusBarItemHandler from "../StatusBarItemHandler";
+import { onClientNotification, runExecutable } from "./lsp_helper";
+import ToolInterface from "./ToolInterface";
+
+const languageClientName = "oxc";
+
+export default class FormatterTool implements ToolInterface {
+ // LSP client instance
+ private client: LanguageClient | undefined;
+
+ async getBinary(
+ _context: ExtensionContext,
+ outputChannel: LogOutputChannel,
+ configService: ConfigService,
+ ): Promise {
+ const bin = await configService.getOxfmtServerBinPath();
+ if (workspace.isTrusted && bin) {
+ try {
+ await fsPromises.access(bin);
+ return bin;
+ } catch (e) {
+ outputChannel.error(`Invalid bin path: ${bin}`, e);
+ }
+ }
+ return process.env.SERVER_PATH_DEV;
+ }
+
+ async activate(
+ context: ExtensionContext,
+ binaryPath: string,
+ outputChannel: LogOutputChannel,
+ configService: ConfigService,
+ statusBarItemHandler: StatusBarItemHandler,
+ ) {
+ const restartCommand = commands.registerCommand(OxcCommands.RestartServerFmt, async () => {
+ await this.restartClient();
+ });
+
+ outputChannel.info(`Using server binary at: ${binaryPath}`);
+
+ const run: Executable = runExecutable(binaryPath, configService.vsCodeConfig.nodePath);
+
+ const serverOptions: ServerOptions = {
+ run,
+ debug: run,
+ };
+
+ // This list is not used as-is for implementation to determine whether formatting processing is possible.
+ const supportedExtensions = [
+ "cjs",
+ "cts",
+ "js",
+ "jsx",
+ "mjs",
+ "mts",
+ "ts",
+ "tsx",
+ // https://github.com/oxc-project/oxc/blob/f3e9913f534e36195b9b5a6244dd21076ed8715e/crates/oxc_formatter/src/service/parse_utils.rs#L24-L45
+ "_js",
+ "bones",
+ "es",
+ "es6",
+ "gs",
+ "jake",
+ "javascript",
+ "jsb",
+ "jscad",
+ "jsfl",
+ "jslib",
+ "jsm",
+ "jspre",
+ "jss",
+ "njs",
+ "pac",
+ "sjs",
+ "ssjs",
+ "xsjs",
+ "xsjslib",
+ // https://github.com/oxc-project/oxc/blob/f3e9913f534e36195b9b5a6244dd21076ed8715e/crates/oxc_formatter/src/service/parse_utils.rs#L73
+ // allow `*.start.frag` and `*.end.frag`,
+ "frag",
+ ];
+
+ // Special filenames that are valid JS files
+ // https://github.com/oxc-project/oxc/blob/f3e9913f534e36195b9b5a6244dd21076ed8715e/crates/oxc_formatter/src/service/parse_utils.rs#L47C4-L52
+ const specialFilenames = [
+ "Jakefile",
+
+ // covered by the "frag" extension above
+ // "start.frag",
+ // "end.frag",
+ ];
+
+ // 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
+ const clientOptions: LanguageClientOptions = {
+ // Register the server for plain text documents
+ documentSelector: [
+ {
+ pattern: `**/*.{${supportedExtensions.join(",")}}`,
+ scheme: "file",
+ },
+ ...specialFilenames.map((filename) => ({
+ pattern: `**/${filename}`,
+ scheme: "file",
+ })),
+ ],
+ initializationOptions: configService.formatterServerConfig,
+ outputChannel,
+ traceOutputChannel: outputChannel,
+ middleware: {
+ 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))?.toOxfmtConfig() ?? 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(restartCommand, onNotificationDispose);
+
+ updateStatsBar(statusBarItemHandler, configService);
+ if (configService.vsCodeConfig.enable) {
+ await this.client.start();
+ }
+ }
+
+ async deactivate(): Promise {
+ if (!this.client) {
+ return undefined;
+ }
+ await this.client.stop();
+ this.client = undefined;
+ }
+
+ 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 toggleClient(configService: ConfigService): Promise {
+ if (this.client === undefined) {
+ return;
+ }
+
+ if (this.client.isRunning()) {
+ if (!configService.vsCodeConfig.enable) {
+ await this.client.stop();
+ }
+ } else {
+ if (configService.vsCodeConfig.enable) {
+ await this.client.start();
+ }
+ }
+ }
+
+ async onConfigChange(
+ event: ConfigurationChangeEvent,
+ configService: ConfigService,
+ statusBarItemHandler: StatusBarItemHandler,
+ ): Promise {
+ updateStatsBar(statusBarItemHandler, configService);
+
+ if (this.client === undefined) {
+ return;
+ }
+
+ // update the initializationOptions for a possible restart
+ this.client.clientOptions.initializationOptions = configService.formatterServerConfig;
+
+ if (configService.effectsWorkspaceConfigChange(event) && this.client.isRunning()) {
+ await this.client.sendNotification("workspace/didChangeConfiguration", {
+ settings: configService.formatterServerConfig,
+ });
+ }
+ }
+}
+
+function updateStatsBar(statusBarItemHandler: StatusBarItemHandler, configService: ConfigService) {
+ let text = configService.vsCodeConfig.enable ? `**oxfmt enabled**\n\n` : `**oxfmt disabled**\n\n`;
+
+ text +=
+ `[$(terminal) Open Output](command:${OxcCommands.ShowOutputChannelFmt})\n\n` +
+ `[$(refresh) Restart Server](command:${OxcCommands.RestartServerFmt})\n\n`;
+
+ statusBarItemHandler.updateToolTooltip("formatter", text);
+}
diff --git a/editors/vscode/client/tools/linter.ts b/editors/vscode/client/tools/linter.ts
index 5ac3f408f044a..5ea0fd51124d0 100644
--- a/editors/vscode/client/tools/linter.ts
+++ b/editors/vscode/client/tools/linter.ts
@@ -13,6 +13,7 @@ import {
import {
ConfigurationParams,
ExecuteCommandRequest,
+ InitializeParams,
ShowMessageNotification,
} from "vscode-languageclient";
@@ -37,6 +38,16 @@ const enum LspCommands {
FixAll = "oxc.fixAll",
}
+class NoFormatterLanguageClient extends LanguageClient {
+ protected fillInitializeParams(params: InitializeParams): void {
+ // Disable formatting capabilities to prevent conflicts with the formatter tool.
+ delete params.capabilities.textDocument?.formatting;
+ delete params.capabilities.textDocument?.rangeFormatting;
+
+ super.fillInitializeParams(params);
+ }
+}
+
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.
@@ -78,11 +89,11 @@ export default class LinterTool implements ToolInterface {
? (await workspace.findFiles(`**/.oxlintrc.json`, "**/node_modules/**", 1)).length > 0
: true;
- const restartCommand = commands.registerCommand(OxcCommands.RestartServer, async () => {
+ const restartCommand = commands.registerCommand(OxcCommands.RestartServerLint, async () => {
await this.restartClient();
});
- const toggleEnable = commands.registerCommand(OxcCommands.ToggleEnable, async () => {
+ const toggleEnable = commands.registerCommand(OxcCommands.ToggleEnableLint, async () => {
await configService.vsCodeConfig.updateEnable(!configService.vsCodeConfig.enable);
await this.toggleClient(configService);
@@ -185,8 +196,12 @@ export default class LinterTool implements ToolInterface {
},
};
- // Create the language client and start the client.
- this.client = new LanguageClient(languageClientName, serverOptions, clientOptions);
+ // If the formatter is not handled by the language server, disable formatting capabilities to prevent conflicts.
+ if (configService.useOxcLanguageServerForFormatting) {
+ this.client = new LanguageClient(languageClientName, serverOptions, clientOptions);
+ } else {
+ this.client = new NoFormatterLanguageClient(languageClientName, serverOptions, clientOptions);
+ }
const onNotificationDispose = this.client.onNotification(
ShowMessageNotification.type,
@@ -288,7 +303,11 @@ export default class LinterTool implements ToolInterface {
/**
* Get the status bar state based on whether oxc is enabled and allowed to start.
*/
- getStatusBarState(enable: boolean): { bgColor: string; icon: string; tooltipText: string } {
+ getStatusBarState(enable: boolean): {
+ bgColor: string;
+ icon: string;
+ tooltipText: string;
+ } {
if (!this.allowedToStartServer) {
return {
bgColor: "statusBarItem.offlineBackground",
@@ -315,13 +334,13 @@ export default class LinterTool implements ToolInterface {
let text =
`**${tooltipText}**\n\n` +
- `[$(terminal) Open Output](command:${OxcCommands.ShowOutputChannel})\n\n` +
- `[$(refresh) Restart Server](command:${OxcCommands.RestartServer})\n\n`;
+ `[$(terminal) Open Output](command:${OxcCommands.ShowOutputChannelLint})\n\n` +
+ `[$(refresh) Restart Server](command:${OxcCommands.RestartServerLint})\n\n`;
if (enable) {
- text += `[$(stop) Stop Server](command:${OxcCommands.ToggleEnable})\n\n`;
+ text += `[$(stop) Stop Server](command:${OxcCommands.ToggleEnableLint})\n\n`;
} else {
- text += `[$(play) Start Server](command:${OxcCommands.ToggleEnable})\n\n`;
+ text += `[$(play) Start Server](command:${OxcCommands.ToggleEnableLint})\n\n`;
}
statusBarItemHandler.setColorAndIcon(bgColor, icon);
diff --git a/editors/vscode/package.json b/editors/vscode/package.json
index 400faaa172d18..f42f712895cbe 100644
--- a/editors/vscode/package.json
+++ b/editors/vscode/package.json
@@ -41,7 +41,12 @@
"commands": [
{
"command": "oxc.restartServer",
- "title": "Restart Oxc Server",
+ "title": "Restart oxlint Server",
+ "category": "Oxc"
+ },
+ {
+ "command": "oxc.restartServerFormatter",
+ "title": "Restart oxfmt Server",
"category": "Oxc"
},
{
@@ -51,7 +56,12 @@
},
{
"command": "oxc.showOutputChannel",
- "title": "Show Output Channel",
+ "title": "Show Output Channel (Linter)",
+ "category": "Oxc"
+ },
+ {
+ "command": "oxc.showOutputChannelFormatter",
+ "title": "Show Output Channel (Formatter)",
"category": "Oxc"
},
{
@@ -204,6 +214,11 @@
"scope": "window",
"markdownDescription": "Path to an Oxc linter binary. Will be used by the language server instead of the bundled one."
},
+ "oxc.path.oxfmt": {
+ "type": "string",
+ "scope": "window",
+ "markdownDescription": "Path to an Oxc formatter binary. Will be used by the language server instead of the bundled one."
+ },
"oxc.path.node": {
"type": "string",
"scope": "window",
@@ -239,7 +254,9 @@
"supported": "limited",
"description": "The Extension will always use the Language Server shipped with the Extension.",
"restrictedConfigurations": [
- "oxc.path.server"
+ "oxc.path.server",
+ "oxc.path.oxlint",
+ "oxc.path.oxfmt"
]
}
},
diff --git a/editors/vscode/tests/ConfigService.spec.ts b/editors/vscode/tests/ConfigService.spec.ts
index 20b8d41e52432..399e605fa94db 100644
--- a/editors/vscode/tests/ConfigService.spec.ts
+++ b/editors/vscode/tests/ConfigService.spec.ts
@@ -7,13 +7,13 @@ const conf = workspace.getConfiguration('oxc');
suite('ConfigService', () => {
setup(async () => {
- const keys = ['path.server'];
+ const keys = ['path.server', 'path.oxlint', 'path.oxfmt'];
await Promise.all(keys.map(key => conf.update(key, undefined)));
});
teardown(async () => {
- const keys = ['path.server'];
+ const keys = ['path.server', 'path.oxlint', 'path.oxfmt'];
await Promise.all(keys.map(key => conf.update(key, undefined)));
});
@@ -29,6 +29,47 @@ suite('ConfigService', () => {
return workspace_path;
};
+ suite('getOxfmtServerBinPath', () => {
+ testSingleFolderMode('resolves relative server path with workspace folder', async () => {
+ const service = new ConfigService();
+ const nonDefinedServerPath = await service.getOxfmtServerBinPath();
+
+ strictEqual(nonDefinedServerPath, undefined);
+
+ await conf.update('path.oxfmt', '/absolute/oxfmt');
+ const absoluteServerPath = await service.getOxfmtServerBinPath();
+
+ strictEqual(absoluteServerPath, '/absolute/oxfmt');
+
+ await conf.update('path.oxfmt', './relative/oxfmt');
+ const relativeServerPath = await service.getOxfmtServerBinPath();
+
+ const workspace_path = getWorkspaceFolderPlatformSafe();
+ strictEqual(relativeServerPath, `${workspace_path}/relative/oxfmt`);
+ });
+
+ testSingleFolderMode('returns undefined for unsafe server path', async () => {
+ const service = new ConfigService();
+ await conf.update('path.oxfmt', '../unsafe/oxfmt');
+ const unsafeServerPath = await service.getOxfmtServerBinPath();
+
+ strictEqual(unsafeServerPath, undefined);
+ });
+
+ testSingleFolderMode('returns backslashes path on Windows', async () => {
+ if (process.platform !== 'win32') {
+ return;
+ }
+ const service = new ConfigService();
+ await conf.update('path.oxfmt', './relative/oxfmt');
+ const relativeServerPath = await service.getOxfmtServerBinPath();
+ const workspace_path = getWorkspaceFolderPlatformSafe();
+
+ strictEqual(workspace_path[1], ':', 'The test workspace folder must be an absolute path with a drive letter on Windows');
+ strictEqual(relativeServerPath, `${workspace_path}\\relative\\oxfmt`);
+ });
+ });
+
suite('getUserServerBinPath', () => {
testSingleFolderMode('resolves relative server path with workspace folder', async () => {
const service = new ConfigService();
@@ -36,37 +77,37 @@ suite('ConfigService', () => {
strictEqual(nonDefinedServerPath, undefined);
- await conf.update('path.server', '/absolute/oxc_language_server');
+ await conf.update('path.oxlint', '/absolute/oxlint');
const absoluteServerPath = service.getUserServerBinPath();
- strictEqual(absoluteServerPath, '/absolute/oxc_language_server');
+ strictEqual(absoluteServerPath, '/absolute/oxlint');
- await conf.update('path.server', './relative/oxc_language_server');
+ await conf.update('path.oxlint', './relative/oxlint');
const relativeServerPath = service.getUserServerBinPath();
const workspace_path = getWorkspaceFolderPlatformSafe();
- strictEqual(relativeServerPath, `${workspace_path}/relative/oxc_language_server`);
+ strictEqual(relativeServerPath, `${workspace_path}/relative/oxlint`);
});
testSingleFolderMode('returns undefined for unsafe server path', async () => {
const service = new ConfigService();
- await conf.update('path.server', '../unsafe/oxc_language_server');
+ await conf.update('path.oxlint', '../unsafe/oxlint');
const unsafeServerPath = service.getUserServerBinPath();
strictEqual(unsafeServerPath, undefined);
});
- testSingleFolderMode('returns backslashes path on Windows', async () => {
+ testSingleFolderMode('returns backslashes path on Windows', async () => {
if (process.platform !== 'win32') {
return;
}
const service = new ConfigService();
- await conf.update('path.server', './relative/oxc_language_server');
+ await conf.update('path.oxlint', './relative/oxlint');
const relativeServerPath = service.getUserServerBinPath();
const workspace_path = getWorkspaceFolderPlatformSafe();
strictEqual(workspace_path[1], ':', 'The test workspace folder must be an absolute path with a drive letter on Windows');
- strictEqual(relativeServerPath, `${workspace_path}\\relative\\oxc_language_server`);
+ strictEqual(relativeServerPath, `${workspace_path}\\relative\\oxlint`);
});
});
});
diff --git a/editors/vscode/tests/VSCodeConfig.spec.ts b/editors/vscode/tests/VSCodeConfig.spec.ts
index 6ee29a4353a0f..5c9837e6a34f0 100644
--- a/editors/vscode/tests/VSCodeConfig.spec.ts
+++ b/editors/vscode/tests/VSCodeConfig.spec.ts
@@ -6,7 +6,7 @@ import { testSingleFolderMode } from './test-helpers.js';
const conf = workspace.getConfiguration('oxc');
suite('VSCodeConfig', () => {
- const keys = ['enable', 'requireConfig', 'trace.server', 'path.server', 'path.oxlint', 'path.node'];
+ const keys = ['enable', 'requireConfig', 'trace.server', 'path.server', 'path.oxlint', 'path.oxfmt', 'path.node'];
setup(async () => {
await Promise.all(keys.map(key => conf.update(key, undefined)));
});
@@ -22,6 +22,7 @@ suite('VSCodeConfig', () => {
strictEqual(config.requireConfig, false);
strictEqual(config.trace, 'off');
strictEqual(config.binPathOxlint, '');
+ strictEqual(config.binPathOxfmt, '');
strictEqual(config.nodePath, '');
});
@@ -40,6 +41,7 @@ suite('VSCodeConfig', () => {
config.updateRequireConfig(true),
config.updateTrace('messages'),
config.updateBinPathOxlint('./binary'),
+ config.updateBinPathOxfmt('./formatter'),
config.updateNodePath('./node'),
]);
@@ -49,6 +51,7 @@ suite('VSCodeConfig', () => {
strictEqual(wsConfig.get('requireConfig'), true);
strictEqual(wsConfig.get('trace.server'), 'messages');
strictEqual(wsConfig.get('path.oxlint'), './binary');
+ strictEqual(wsConfig.get('path.oxfmt'), './formatter');
strictEqual(wsConfig.get('path.node'), './node');
});
});
diff --git a/editors/vscode/tests/commands.spec.ts b/editors/vscode/tests/commands.spec.ts
index 287d364ed6654..6ef932e03f9b6 100644
--- a/editors/vscode/tests/commands.spec.ts
+++ b/editors/vscode/tests/commands.spec.ts
@@ -31,31 +31,55 @@ suite('commands', () => {
testSingleFolderMode('listed commands', async () => {
const oxcCommands = (await commands.getCommands(true)).filter(x => x.startsWith('oxc.'));
- const extraCommands = process.env.SKIP_LINTER_TEST === 'true' ? [] : [
- 'oxc.fixAll',
+ const expectedCommands = [
+ 'oxc.showOutputChannel',
+ 'oxc.showOutputChannelFormatter',
];
- deepStrictEqual([
- 'oxc.showOutputChannel',
- 'oxc.restartServer',
- 'oxc.toggleEnable',
- 'oxc.applyAllFixesFile', // TODO: only if linter tests are enabled
- ...extraCommands,
- ], oxcCommands);
+ if (process.env.SKIP_LINTER_TEST !== 'true') {
+ expectedCommands.push(
+ 'oxc.restartServer',
+ 'oxc.toggleEnable',
+ 'oxc.applyAllFixesFile',
+ 'oxc.fixAll',
+ );
+ }
+
+ if (process.env.SKIP_FORMATTER_TEST !== 'true' && !process.env.SERVER_PATH_DEV?.includes('oxc_language_server')) {
+ expectedCommands.push(
+ 'oxc.restartServerFormatter',
+ );
+ }
+
+ deepStrictEqual(expectedCommands, oxcCommands);
});
testSingleFolderMode('oxc.showOutputChannel', async () => {
await commands.executeCommand('oxc.showOutputChannel');
- await sleep(500);
+ await sleep(250);
notEqual(window.activeTextEditor, undefined);
const { uri } = window.activeTextEditor!.document;
- strictEqual(uri.toString(), 'output:oxc.oxc-vscode.Oxc');
+ strictEqual(uri.toString(), 'output:oxc.oxc-vscode.Oxc%20%28Lint%29');
+
+ await commands.executeCommand('workbench.action.closeActiveEditor');
+ });
+
+ testSingleFolderMode('oxc.showOutputChannelFormatter', async () => {
+ await commands.executeCommand('oxc.showOutputChannelFormatter');
+ await sleep(250);
+
+ notEqual(window.activeTextEditor, undefined);
+ const { uri } = window.activeTextEditor!.document;
+ strictEqual(uri.toString(), 'output:oxc.oxc-vscode.Oxc%20%28Fmt%29');
await commands.executeCommand('workbench.action.closeActiveEditor');
});
testSingleFolderMode('oxc.toggleEnable', async () => {
+ if (process.env.SKIP_LINTER_TEST === 'true') {
+ return;
+ }
const isEnabledBefore = workspace.getConfiguration('oxc').get('enable');
strictEqual(isEnabledBefore, true);