diff --git a/.vscode-test.mjs b/.vscode-test.mjs index 2cddb86..fd645fe 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -45,6 +45,8 @@ const allTestSuites = new Map([ workspaceFolder: multiRootWorkspaceFile, env: { MULTI_FOLDER_WORKSPACE: "true", + SKIP_LINTER_TEST: "true", + SKIP_FORMATTER_TEST: "true", YARN_FOUND_BIN: path.resolve(import.meta.dirname, "node_modules/oxlint/dist/cli.js"), }, }, diff --git a/README.md b/README.md index 3720e1c..fd8cc90 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,48 @@ The extension does not bundle the Oxc tools. For the recommended setup, install See the official [Oxlint editor setup](https://oxc.rs/docs/guide/usage/linter/editors.html) and [Oxfmt editor setup](https://oxc.rs/docs/guide/usage/formatter/editors.html) guides for installation details. +## Vite+ + +For projects that declare `vite-plus` in `dependencies` or `devDependencies`, the extension runs `vp lint --lsp` and `vp fmt --lsp`. Detection starts from the active file's directory and stops at the monorepo root (`pnpm-workspace.yaml`, `package.json` with `workspaces`, or `lerna.json`). With no active workspace file, the extension checks the workspace folders in order. It checks the local `node_modules/.bin/vp` shim first, then the shell's `PATH` and global package installations. + +To select Vite+ without automatic dependency detection, add this to your workspace's `.vscode/settings.json`: + +```json +{ + "oxc.lint.binarySource": "vite-plus", + "oxc.fmt.binarySource": "vite-plus" +} +``` + +Each tool has its own source setting. Both default to `"auto"`, which detects a direct `vite-plus` dependency and otherwise uses the standalone tool. Set a source to `"oxc"` to use the standalone tool even in a Vite+ project. For example, to use your own Oxlint installation with Vite+ formatting: + +```json +{ + "oxc.lint.binarySource": "oxc", + "oxc.fmt.binarySource": "vite-plus" +} +``` + +You can also set `oxc.path.vp` to an absolute path or a path relative to the workspace folder. This setting selects Vite+ without dependency detection for tools whose source is `"auto"` or `"vite-plus"`. A source of `"oxc"` ignores this path: + +```json +{ + "oxc.path.vp": "./node_modules/.bin/vp" +} +``` + +On Windows, use `./node_modules/.bin/vp.cmd` for npm or pnpm, or `./node_modules/.bin/vp.exe` for Bun. + +With `oxc.useExecPath`, the extension runs npm and pnpm entries with VS Code's bundled Node. This includes global pnpm installations. You can also set `oxc.path.vp` to `./node_modules/vite-plus/bin/vp` to select the JavaScript entry directly. Running without a system Node installation requires `vite-plus` `0.3.2` or later. Earlier releases still require `node` on `PATH` for their subprocesses. + +The source settings and `oxc.path.vp` can differ between workspace folders. Explicit `oxc.path.oxlint` and `oxc.path.oxfmt` settings take priority over source selection for their respective tools. Changing a source setting restarts only its tool. + +Vite+ does not support nested configuration. The extension automatically disables nested config discovery for each server started through `vp`. This applies to both linting and formatting without changing your saved settings. Standalone tools continue to use `oxc.disableNestedConfig` and `oxc.fmt.disableNestedConfig`. + +The `"vite-plus"` source uses the nearest `package.json` or monorepo root as its working directory, with the workspace folder as a fallback. Switching between source directories in the same package does not restart the servers. + +The extension rechecks Vite+ when you switch files and restarts a server if its executable or project directory changes. It uses one server per tool for the window. If Vite+ is selected but `vp` is unavailable, the status item and output channels show an install hint. Install your dependencies and run the **Oxc: Restart oxlint Server** and **Oxc: Restart oxfmt Server** commands. A failed Vite+ launch shows an install or upgrade hint. Vite+ integration requires a trusted workspace, and `oxc.requireConfig` does not require a separate Oxlint configuration when Vite+ is selected. + ## Oxlint This is the linter for Oxc. The currently supported features are listed below. @@ -96,22 +138,25 @@ Following configurations are supported via `settings.json` and affect the window Following configurations are supported via `settings.json` and can be changed for each workspace: -| Key | Default Value | Possible Values | Description | -| ----------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `oxc.configPath` | `null` | `` \| `` | Path to oxlint configuration. Keep it empty to enable nested configuration. | -| `oxc.disableNestedConfig` | `false` | `true` \| `false` | Disable searching for nested configuration files. When set to true, only the configuration file specified in `oxc.configPath` (if any) will be used. | -| `oxc.fixKind` | `null` | `safe_fix` \| `safe_fix_or_suggestion` \| `dangerous_fix` \| `dangerous_fix_or_suggestion` \| `none` \| `all` | Specify the kind of fixes to suggest/apply. | -| `oxc.fmt.configPath` | `null` | `` \| `` | Path to an oxfmt configuration file | -| `oxc.fmt.disableNestedConfig` | `false` | `true` \| `false` | Disable searching for nested configuration files. When set to true, only the configuration file specified in `oxc.fmt.configPath` (if any) will be used. | -| `oxc.lint.customization` | `null` | `Record` \| `` | Customizes linting rules behavior. See for details. | -| `oxc.lint.run` | `onType` | `onSave` \| `onType` | Run the linter on save (onSave) or on type (onType) | -| `oxc.requireConfig` | `false` | `true` \| `false` | Start the language server only when a `.oxlintrc.json(c)` or `oxlint.config.ts` file exists in one of the workspaces. | -| `oxc.tsConfigPath` | `null` | `` \| `` | Path to the project's TypeScript config file. If your `tsconfig.json` is not at the root, you will need this set for the `import` plugin rules to resolve imports correctly. | -| `oxc.typeAware` | `null` | `true` \| `false` \| `` | Forces type-aware linting. Requires the `oxlint-tsgolint` package. It is preferred to use `options.typeAware` in your configuration file | -| `oxc.unusedDisableDirectives` | `null` | `allow` \| `warn` \| `deny` | Define how directive comments like `// oxlint-disable-line` should be reported, when no errors would have been reported on that line anyway. It is preferred to use `options.reportUnusedDisableDirectives` in your configuration file | -| Deprecated | | | | -| `oxc.flags` | `{}` | `Record` | Specific Oxlint flags to pass to the language server. | -| `oxc.fmt.experimental` | `true` | `true` \| `false` | Enable Oxfmt formatting support. | +| Key | Default Value | Possible Values | Description | +| ----------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `oxc.configPath` | `null` | `` \| `` | Path to oxlint configuration. Keep it empty to enable nested configuration. | +| `oxc.disableNestedConfig` | `false` | `true` \| `false` | Disable searching for nested configuration files. When set to true, only the configuration file specified in `oxc.configPath` (if any) will be used. The extension always sends `true` when using `vp lint --lsp`. | +| `oxc.fixKind` | `null` | `safe_fix` \| `safe_fix_or_suggestion` \| `dangerous_fix` \| `dangerous_fix_or_suggestion` \| `none` \| `all` | Specify the kind of fixes to suggest/apply. | +| `oxc.fmt.binarySource` | `auto` | `auto` \| `vite-plus` \| `oxc` | Select the formatter source. `auto` detects a direct `vite-plus` dependency or uses `oxc.path.vp`, otherwise it finds standalone Oxfmt. `vite-plus` selects `vp fmt --lsp` without dependency detection. `oxc` finds standalone Oxfmt and ignores `oxc.path.vp`. An explicit `oxc.path.oxfmt` takes priority. | +| `oxc.fmt.configPath` | `null` | `` \| `` | Path to an oxfmt configuration file | +| `oxc.fmt.disableNestedConfig` | `false` | `true` \| `false` | Disable searching for nested configuration files. When set to true, only the configuration file specified in `oxc.fmt.configPath` (if any) will be used. The extension always sends `true` when using `vp fmt --lsp`. | +| `oxc.lint.binarySource` | `auto` | `auto` \| `vite-plus` \| `oxc` | Select the linter source. `auto` detects a direct `vite-plus` dependency or uses `oxc.path.vp`, otherwise it finds standalone Oxlint. `vite-plus` selects `vp lint --lsp` without dependency detection. `oxc` finds standalone Oxlint and ignores `oxc.path.vp`. An explicit `oxc.path.oxlint` takes priority. | +| `oxc.lint.customization` | `null` | `Record` \| `` | Customizes linting rules behavior. See for details. | +| `oxc.lint.run` | `onType` | `onSave` \| `onType` | Run the linter on save (onSave) or on type (onType) | +| `oxc.path.vp` | - | `` | Path to a `vp` executable. Relative paths use the workspace folder. Setting this path selects Vite+ without dependency detection for tools whose `binarySource` is `auto` or `vite-plus`. Tools whose `binarySource` is `oxc` ignore this path. Explicit `oxc.path.oxlint` and `oxc.path.oxfmt` settings take priority. | +| `oxc.requireConfig` | `false` | `true` \| `false` | Start the language server only when a `.oxlintrc.json(c)` or `oxlint.config.ts` file exists in one of the workspaces. | +| `oxc.tsConfigPath` | `null` | `` \| `` | Path to the project's TypeScript config file. If your `tsconfig.json` is not at the root, you will need this set for the `import` plugin rules to resolve imports correctly. | +| `oxc.typeAware` | `null` | `true` \| `false` \| `` | Forces type-aware linting. Requires the `oxlint-tsgolint` package. It is preferred to use `options.typeAware` in your configuration file | +| `oxc.unusedDisableDirectives` | `null` | `allow` \| `warn` \| `deny` | Define how directive comments like `// oxlint-disable-line` should be reported, when no errors would have been reported on that line anyway. It is preferred to use `options.reportUnusedDisableDirectives` in your configuration file | +| Deprecated | | | | +| `oxc.flags` | `{}` | `Record` | Specific Oxlint flags to pass to the language server. | +| `oxc.fmt.experimental` | `true` | `true` \| `false` | Enable Oxfmt formatting support. | #### FixKind diff --git a/client/ConfigService.ts b/client/ConfigService.ts index f488a02..49cd395 100644 --- a/client/ConfigService.ts +++ b/client/ConfigService.ts @@ -1,13 +1,17 @@ -import { ConfigurationChangeEvent, Uri, workspace, WorkspaceFolder } from "vscode"; +import * as path from "node:path"; +import { ConfigurationChangeEvent, Uri, window, workspace, WorkspaceFolder } from "vscode"; import { DiagnosticPullMode } from "vscode-languageclient"; +import { detectVitePlusProject, VitePlusError } from "./detectVitePlus"; import { BinarySearchResult, + clearGlobalNodeModulesPathsCache, searchGlobalNodeModulesBin, searchEnvPath, searchProjectNodeModulesBin, searchSettingsBin, searchYarnPnpBin, } from "./findBinary"; +import { getShellEnv } from "./getShellEnv"; import { IDisposable } from "./types"; import { VSCodeConfig } from "./VSCodeConfig"; import { @@ -16,6 +20,15 @@ import { WorkspaceConfig, } from "./WorkspaceConfig"; +type BinarySource = "auto" | "vite-plus" | "oxc"; + +interface VitePlusSearchFolder { + root: string; + start: string; + source: BinarySource; + configuredPath: string | undefined; +} + export class ConfigService implements IDisposable { public static readonly namespace = "oxc"; private readonly _disposables: IDisposable[] = []; @@ -23,6 +36,7 @@ export class ConfigService implements IDisposable { public vsCodeConfig: VSCodeConfig; private workspaceConfigs: Map = new Map(); + private readonly vitePlusSearches = new Map>(); public onConfigChange: | ((this: ConfigService, config: ConfigurationChangeEvent) => Promise) @@ -44,27 +58,23 @@ export class ConfigService implements IDisposable { this._disposables.push(disposeChangeListener); } - public get oxlintServerConfig(): { + public getOxlintServerConfig(isVitePlus = false): { workspaceUri: string; options: OxlintWorkspaceConfigInterface; }[] { - return [...this.workspaceConfigs.entries()].map(([path, config]) => { - const options = config.toOxlintConfig(); - - return { - workspaceUri: Uri.file(path).toString(), - options, - }; - }); + return [...this.workspaceConfigs.entries()].map(([path, config]) => ({ + workspaceUri: Uri.file(path).toString(), + options: config.toOxlintConfig(isVitePlus), + })); } - public get formatterServerConfig(): { + public getFormatterServerConfig(isVitePlus = false): { workspaceUri: string; options: OxfmtWorkspaceConfigInterface; }[] { return [...this.workspaceConfigs.entries()].map(([path, config]) => ({ workspaceUri: Uri.file(path).toString(), - options: config.toOxfmtConfig(), + options: config.toOxfmtConfig(isVitePlus), })); } @@ -97,6 +107,11 @@ export class ConfigService implements IDisposable { return this.searchBinaryPath(this.vsCodeConfig.binPathOxfmt, "oxfmt"); } + public clearBinarySearchCaches(): void { + clearGlobalNodeModulesPathsCache(); + this.vitePlusSearches.clear(); + } + public shouldRequestDiagnostics( textDocumentUri: Uri, diagnosticPullMode: DiagnosticPullMode, @@ -116,12 +131,18 @@ export class ConfigService implements IDisposable { private async searchBinaryPath( settingsBinary: string | undefined, - defaultBinaryName: string, + defaultBinaryName: "oxlint" | "oxfmt", ): Promise { if (settingsBinary) { return searchSettingsBin(defaultBinaryName, settingsBinary); } + const command = defaultBinaryName === "oxlint" ? "lint" : "fmt"; + const vitePlus = await this.searchVitePlus(command); + if (vitePlus) { + return { ...vitePlus, vitePlus: command }; + } + return ( (await searchProjectNodeModulesBin(defaultBinaryName)) ?? (await searchYarnPnpBin(defaultBinaryName)) ?? @@ -130,6 +151,82 @@ export class ConfigService implements IDisposable { ); } + private async searchVitePlus(command: "lint" | "fmt"): Promise { + if (!workspace.isTrusted) { + return null; + } + + const documentUri = window.activeTextEditor?.document.uri; + const activeFolder = + documentUri?.scheme === "file" ? workspace.getWorkspaceFolder(documentUri) : undefined; + const workspaceFolders = activeFolder ? [activeFolder] : (workspace.workspaceFolders ?? []); + const folders = workspaceFolders.map((folder): VitePlusSearchFolder => { + const config = workspace.getConfiguration(ConfigService.namespace, folder.uri); + return { + root: folder.uri.fsPath, + start: activeFolder && documentUri ? path.dirname(documentUri.fsPath) : folder.uri.fsPath, + source: config.get(`${command}.binarySource`) ?? "auto", + configuredPath: config.get("path.vp"), + }; + }); + // Share only searches with the same document context and settings. A slow + // search for another project must not select its binary after navigation. + const key = JSON.stringify(folders); + const pending = this.vitePlusSearches.get(key); + if (pending) { + return pending; + } + const search = this.resolveVitePlus(folders); + this.vitePlusSearches.set(key, search); + try { + return await search; + } finally { + if (this.vitePlusSearches.get(key) === search) { + this.vitePlusSearches.delete(key); + } + } + } + + private async resolveVitePlus( + folders: VitePlusSearchFolder[], + ): Promise { + for (const { root, start, source, configuredPath } of folders) { + if (source === "oxc") { + continue; + } + if (configuredPath) { + // An explicit vp path opts in without requiring a dependency declaration. + // oxlint-disable-next-line no-await-in-loop -- workspace folder order is significant + const binary = await searchSettingsBin("vp", configuredPath, root); + if (!binary) { + throw new VitePlusError(`Invalid Vite+ binary: ${configuredPath}. Check oxc.path.vp.`); + } + return { ...binary, cwd: root }; + } + + const project = detectVitePlusProject(start, source === "vite-plus", root); + if (!project) { + continue; + } + if (project.vpPath) { + return { path: project.vpPath, loader: "native", cwd: project.root }; + } + // Global vp is eligible only after detection or explicit opt-in. + // oxlint-disable no-await-in-loop -- global lookup requires a Vite+ project + const binary = + (await searchEnvPath("vp", await getShellEnv())) ?? + (await searchGlobalNodeModulesBin("vp", "vite-plus")); + // oxlint-enable no-await-in-loop + if (!binary) { + throw new VitePlusError( + `Vite+ selected in ${project.root}, but no vp binary was found. Run your package manager's install command (for example, pnpm install), or set oxc.path.vp, then restart the Oxc servers.`, + ); + } + return { ...binary, cwd: project.root }; + } + return null; + } + private async onVscodeConfigChange(event: ConfigurationChangeEvent): Promise { let isConfigChanged = false; diff --git a/client/VSCodeConfig.ts b/client/VSCodeConfig.ts index 9dfc492..8660901 100644 --- a/client/VSCodeConfig.ts +++ b/client/VSCodeConfig.ts @@ -154,6 +154,7 @@ export class VSCodeConfig implements VSCodeConfigInterface { private effectsGeneralLSPConnection(event: ConfigurationChangeEvent): boolean { return ( event.affectsConfiguration(`${ConfigService.namespace}.path.node`) || + event.affectsConfiguration(`${ConfigService.namespace}.path.vp`) || event.affectsConfiguration(`${ConfigService.namespace}.useExecPath`) ); } @@ -161,6 +162,7 @@ export class VSCodeConfig implements VSCodeConfigInterface { effectsOxlintConnection(event: ConfigurationChangeEvent): boolean { return ( event.affectsConfiguration(`${ConfigService.namespace}.path.oxlint`) || + event.affectsConfiguration(`${ConfigService.namespace}.lint.binarySource`) || event.affectsConfiguration(`${ConfigService.namespace}.path.tsgolint`) || this.effectsGeneralLSPConnection(event) ); @@ -169,6 +171,7 @@ export class VSCodeConfig implements VSCodeConfigInterface { effectsOxfmtConnection(event: ConfigurationChangeEvent): boolean { return ( event.affectsConfiguration(`${ConfigService.namespace}.path.oxfmt`) || + event.affectsConfiguration(`${ConfigService.namespace}.fmt.binarySource`) || this.effectsGeneralLSPConnection(event) ); } diff --git a/client/WorkspaceConfig.ts b/client/WorkspaceConfig.ts index ea84dad..ca8c69a 100644 --- a/client/WorkspaceConfig.ts +++ b/client/WorkspaceConfig.ts @@ -375,31 +375,33 @@ export class WorkspaceConfig { return diagnosticPullMode === this.runTrigger; } - public toOxlintConfig(): OxlintWorkspaceConfigInterface { + public toOxlintConfig(isVitePlus = false): OxlintWorkspaceConfigInterface { + // Vite+ uses one configuration, so nested Oxc configs must not override it. + const disableNestedConfig = isVitePlus || this.disableNestedConfig; return { configPath: this.configPath ?? undefined, tsConfigPath: this.tsConfigPath ?? undefined, unusedDisableDirectives: this.unusedDisableDirectives ?? undefined, typeAware: this.typeAware ?? undefined, - disableNestedConfig: this.disableNestedConfig, + disableNestedConfig, fixKind: this.fixKind ?? undefined, rulesCustomization: this.rulesCustomization ?? undefined, // keep for backward compatibility run: this.runTrigger, // deprecated, kept for backward compatibility flags: { - disable_nested_config: this.disableNestedConfig ? "true" : "false", + disable_nested_config: disableNestedConfig ? "true" : "false", ...(this.fixKind ? { fix_kind: this.fixKind } : {}), }, }; } - public toOxfmtConfig(): OxfmtWorkspaceConfigInterface { + public toOxfmtConfig(isVitePlus = false): OxfmtWorkspaceConfigInterface { return { // @ts-expect-error -- deprecated setting, kept for backward compatibility ["fmt.experimental"]: true, ["fmt.configPath"]: this.formattingConfigPath ?? undefined, - ["fmt.disableNestedConfig"]: this.formattingDisableNestedConfig, + ["fmt.disableNestedConfig"]: isVitePlus || this.formattingDisableNestedConfig, }; } } diff --git a/client/detectVitePlus.ts b/client/detectVitePlus.ts new file mode 100644 index 0000000..fd586d3 --- /dev/null +++ b/client/detectVitePlus.ts @@ -0,0 +1,97 @@ +import { existsSync, readFileSync, statSync } from "node:fs"; +import * as path from "node:path"; + +export interface VitePlusProject { + /** The declaring ancestor, or the nearest package/workspace root in forced mode. */ + root: string; + /** Undefined means Vite+ is selected but is not installed locally. */ + vpPath?: string; +} + +interface PackageJson { + dependencies?: Record; + devDependencies?: Record; + workspaces?: unknown; +} + +function readPackageJson(dir: string): PackageJson | null { + try { + return JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8")); + } catch { + return null; + } +} + +function isRootWorkspace(dir: string, pkg: PackageJson | null): boolean { + return ( + existsSync(path.join(dir, "pnpm-workspace.yaml")) || + existsSync(path.join(dir, "lerna.json")) || + Boolean(pkg?.workspaces) + ); +} + +/** + * Implements Phases 1 and 2 of the editor detection RFC. The bound is the + * monorepo root, which can be above the folder opened in the editor. + * Global lookup belongs to the caller and must only run for a non-null result. + * https://github.com/voidzero-dev/vite-plus/pull/1614 + */ +export function detectVitePlusProject( + start: string, + forceVitePlus = false, + workspaceFolder?: string, +): VitePlusProject | null { + let dir = path.resolve(start); + try { + if (statSync(dir).isFile()) { + dir = path.dirname(dir); + } + } catch { + // The caller can also pass a directory that does not exist yet. + } + + let pkg = readPackageJson(dir); + if (forceVitePlus) { + // Explicit opt-in skips dependency detection, but still needs a stable cwd + // when the active document moves between source directories. + const fallbackRoot = workspaceFolder ? path.resolve(workspaceFolder) : dir; + while (!pkg && !isRootWorkspace(dir, pkg)) { + const parent = path.dirname(dir); + if ((workspaceFolder && dir === fallbackRoot) || parent === dir) { + dir = fallbackRoot; + pkg = readPackageJson(dir); + break; + } + dir = parent; + pkg = readPackageJson(dir); + } + } else { + while (!pkg?.dependencies?.["vite-plus"] && !pkg?.devDependencies?.["vite-plus"]) { + const parent = path.dirname(dir); + if (isRootWorkspace(dir, pkg) || dir === parent) { + return null; + } + dir = parent; + pkg = readPackageJson(dir); + } + } + + const root = dir; + const binNames = process.platform === "win32" ? ["vp.cmd", "vp.exe"] : ["vp"]; + while (true) { + for (const name of binNames) { + const vpPath = path.join(dir, "node_modules", ".bin", name); + if (existsSync(vpPath)) { + return { root, vpPath }; + } + } + const parent = path.dirname(dir); + if (isRootWorkspace(dir, pkg) || dir === parent) { + return { root }; + } + dir = parent; + pkg = readPackageJson(dir); + } +} + +export class VitePlusError extends Error {} diff --git a/client/extension.ts b/client/extension.ts index ed09e84..e441803 100644 --- a/client/extension.ts +++ b/client/extension.ts @@ -10,7 +10,7 @@ import ToolInterface from "./tools/ToolInterface"; const outputChannelName = "Oxc"; const tools: ToolInterface[] = []; -export async function activate(context: ExtensionContext) { +export async function activate(context: ExtensionContext): Promise { const configService = new ConfigService(); const outputChannelLint = window.createOutputChannel(outputChannelName + " (Lint)", { @@ -74,7 +74,7 @@ export async function activate(context: ExtensionContext) { context.subscriptions.push(formatter); } - const restartTool = async (tool: ToolInterface, outputChannel: LogOutputChannel) => { + async function restartTool(tool: ToolInterface, outputChannel: LogOutputChannel): Promise { try { await tool.restart(); } catch (e) { @@ -82,7 +82,7 @@ export async function activate(context: ExtensionContext) { Try to restart the editor manually. `); } - }; + } configService.onConfigChange = async function onConfigChange(event) { await Promise.all(tools.map((tool) => tool.onConfigChange(event))); @@ -109,15 +109,44 @@ export async function activate(context: ExtensionContext) { outputChannelFormat.info("Searching for oxfmt binary."); outputChannelLint.info("Searching for oxlint binary."); + const initialDocument = window.activeTextEditor?.document.uri.toString(); const binaryPaths = await Promise.all(tools.map((tool) => tool.getBinary())); - await Promise.all( - tools.map((tool): Promise => { - const binaryPath = binaryPaths[tools.indexOf(tool)]; - return tool.activate(binaryPath); + await Promise.all(tools.map((tool, index) => tool.activate(binaryPaths[index]))); + + // A window has one client per tool. Re-resolve on navigation, and restart + // only when the executable, Vite+ command, or project directory changes. + async function switchProject(): Promise { + await Promise.all( + tools.map(async (tool) => { + try { + await tool.restart(true); + } catch (error) { + const output = tool instanceof Linter ? outputChannelLint : outputChannelFormat; + output.error( + `Failed to switch language server: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }), + ); + } + context.subscriptions.push( + window.onDidChangeActiveTextEditor((editor) => { + if ( + editor?.document.uri.scheme !== "file" || + !workspace.getWorkspaceFolder(editor.document.uri) + ) { + return; + } + void switchProject(); }), ); + // Navigation during binary discovery or server startup predates the listener. + if (window.activeTextEditor?.document.uri.toString() !== initialDocument) { + await switchProject(); + } + // Finally show the status bar item. statusBarItemHandler.show(); } diff --git a/client/findBinary.ts b/client/findBinary.ts index 1f179c9..8d46a11 100644 --- a/client/findBinary.ts +++ b/client/findBinary.ts @@ -11,6 +11,8 @@ export type BinarySearchResult = { path: string; loader: "node" | "native"; yarnPnpLoaderPath?: string; // only set if loader is 'node' and found via Yarn PnP + vitePlus?: "lint" | "fmt"; + cwd?: string; }; /** @internal only used for testing */ @@ -38,32 +40,38 @@ export function replaceTargetFromMainToBin(resolvedPath: string, binaryName: str throw new Error(`Could not find package.json for "${binaryName}"`); } +function binaryCandidates(folder: string, binaryName: string): string[] { + const basePath = path.join(folder, binaryName); + if (process.platform !== "win32") { + return [basePath]; + } + // Prefer Windows shims over the extensionless POSIX vp shim. + if (binaryName === "vp") { + return [`${basePath}.cmd`, `${basePath}.exe`, basePath]; + } + return [basePath, `${basePath}.exe`]; +} + async function searchNodeModulesDefaultBinPath( binaryName: string, folders: string[], ): Promise { - const candidates = folders.flatMap((folder) => { - const basePath = path.join(folder, ".bin", binaryName); - return process.platform === "win32" ? [basePath, `${basePath}.exe`] : [basePath]; - }); + const candidates = folders.flatMap((folder) => + binaryCandidates(path.join(folder, ".bin"), binaryName), + ); - const exists = await Promise.all( + const binaries = await Promise.all( candidates.map(async (candidate) => { try { await workspace.fs.stat(Uri.file(candidate)); - return true; + return { path: candidate, loader: "native" } as const; } catch { - return false; + return undefined; } }), ); - const firstExistingCandidateIndex = exists.findIndex(Boolean); - if (firstExistingCandidateIndex === -1) { - return undefined; - } - - return { path: candidates[firstExistingCandidateIndex], loader: "native" }; + return binaries.find(Boolean); } /** * Returns node_modules paths derived from all package.json files found in the workspace. @@ -202,6 +210,7 @@ export async function searchYarnPnpBin( */ export async function searchGlobalNodeModulesBin( binaryName: string, + packageName = binaryName, ): Promise { const globalPaths = await globalNodeModulesPaths(); @@ -213,6 +222,23 @@ export async function searchGlobalNodeModulesBin( if (result) { return result; } + // A package's executable can have a different name (vite-plus provides vp). + // Read only the global package directories, without resolving into a parent. + if (packageName !== binaryName) { + for (const globalPath of globalPaths) { + try { + const packageDir = path.join(globalPath, packageName); + const pkg = JSON.parse(readFileSync(path.join(packageDir, "package.json"), "utf8")); + const binEntry = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.[binaryName]; + if (pkg.name !== packageName || typeof binEntry !== "string") continue; + const binPath = path.resolve(packageDir, binEntry); + // oxlint-disable-next-line no-await-in-loop -- preserve global lookup priority + await workspace.fs.stat(Uri.file(binPath)); + return { path: binPath, loader: "node" }; + } catch {} + } + return undefined; + } // fallback to direct binary lookup via require.resolve try { const resolvedPath = replaceTargetFromMainToBin( @@ -229,22 +255,20 @@ export async function searchGlobalNodeModulesBin( */ export async function searchEnvPath( defaultBinaryName: string, + environment: Record = env, ): Promise { - const envPath = env.PATH; + const envPath = environment.PATH; if (!envPath) { return undefined; } - // generate candidate paths by joining each PATH entry with the binary name - // on Windows, also consider the .exe extension + // Ignore empty PATH entries and preserve directory and executable priority. const candidates = envPath.split(path.delimiter).flatMap((folder) => { - // filter out empty entries which can occur if PATH starts or ends with a delimiter if (!folder) { return []; } - const basePath = path.join(folder, defaultBinaryName); - return process.platform === "win32" ? [basePath, `${basePath}.exe`] : [basePath]; + return binaryCandidates(folder, defaultBinaryName); }); const binary = await Promise.all( @@ -270,6 +294,7 @@ export async function searchEnvPath( export async function searchSettingsBin( defaultBinaryName: string, settingsBinary: string, + cwd = workspace.workspaceFolders?.[0]?.uri.fsPath, ): Promise { if (!workspace.isTrusted) { return; @@ -281,7 +306,6 @@ export async function searchSettingsBin( } if (!path.isAbsolute(settingsBinary)) { - const cwd = workspace.workspaceFolders?.[0]?.uri.fsPath; if (!cwd) { return undefined; } @@ -298,7 +322,22 @@ export async function searchSettingsBin( settingsBinary.endsWith(".js") || settingsBinary.endsWith(".cjs") || settingsBinary.endsWith(".mjs") || - settingsBinary.endsWith(`${defaultBinaryName}${path.sep}bin${path.sep}${defaultBinaryName}`); + settingsBinary.endsWith(`${defaultBinaryName}${path.sep}bin${path.sep}${defaultBinaryName}`) || + (defaultBinaryName === "vp" && settingsBinary.endsWith(path.join("vite-plus", "bin", "vp"))); + + // npm creates both a POSIX shim and a .cmd shim on Windows. Prefer the + // latter when the configured vp path omits the extension. + if ( + process.platform === "win32" && + defaultBinaryName === "vp" && + !isNode && + !path.extname(settingsBinary) + ) { + try { + await workspace.fs.stat(Uri.file(`${settingsBinary}.cmd`)); + return { path: `${settingsBinary}.cmd`, loader: "native" }; + } catch {} + } try { await workspace.fs.stat(Uri.file(settingsBinary)); @@ -321,8 +360,21 @@ export async function searchSettingsBin( return undefined; } +let cachedGlobalNodeModulesPaths: Promise | undefined; + +/** Refresh package-manager locations on an explicit server restart. */ +export function clearGlobalNodeModulesPathsCache(): void { + cachedGlobalNodeModulesPaths = undefined; +} + +function globalNodeModulesPaths(): Promise { + // Lint and format share the pending lookup and its result across navigation. + // Only locations are cached; binary searches still check the filesystem. + return (cachedGlobalNodeModulesPaths ??= resolveGlobalNodeModulesPaths()); +} + // copied from: https://github.com/biomejs/biome-vscode/blob/ae9b6df2254d0ff8ee9d626554251600eb2ca118/src/locator.ts#L28-L49 -async function globalNodeModulesPaths(): Promise { +async function resolveGlobalNodeModulesPaths(): Promise { const npmGlobalNodeModulesPath = await safeSpawnSync("npm", ["root", "-g"]); const pnpmGlobalNodeModulesPath = await safeSpawnSync("pnpm", ["root", "-g"]); const bunGlobalNodeModulesPath = path.resolve(homedir(), ".bun/install/global/node_modules"); @@ -334,12 +386,10 @@ async function globalNodeModulesPaths(): Promise { // only use this function with internal code, because it executes shell commands // which could be a security risk if the command or args are user-controlled -const safeSpawnSync = async ( +async function safeSpawnSync( command: string, args: readonly string[] = [], -): Promise => { - let output: string | undefined; - +): Promise { try { const result = spawnSync(command, args, { shell: true, @@ -348,14 +398,10 @@ const safeSpawnSync = async ( }); if (result.error || result.status !== 0) { - output = undefined; - } else { - const trimmed = result.stdout.trim(); - output = trimmed ? trimmed : undefined; + return undefined; } + return result.stdout.trim() || undefined; } catch { - output = undefined; + return undefined; } - - return output; -}; +} diff --git a/client/getShellEnv.ts b/client/getShellEnv.ts index 39d5153..999f4c6 100644 --- a/client/getShellEnv.ts +++ b/client/getShellEnv.ts @@ -22,7 +22,15 @@ export async function getShellEnv(): Promise> // windows electron app does not have the problem of individual shell environment, as it inherits the environment from the parent process. if (process.platform === "win32") { - cachedEnv = Promise.resolve({ ...process.env }); + // Plain objects lose process.env's case-insensitive lookup on Windows. + // Normalize PATH so discovery and launch use the same value. + const windowsEnv = Object.fromEntries( + Object.entries(process.env).map(([key, value]) => [ + key.toUpperCase() === "PATH" ? "PATH" : key, + value, + ]), + ); + cachedEnv = Promise.resolve(windowsEnv); return cachedEnv; } diff --git a/client/resolveVitePlusNodeEntry.ts b/client/resolveVitePlusNodeEntry.ts new file mode 100644 index 0000000..3899b8a --- /dev/null +++ b/client/resolveVitePlusNodeEntry.ts @@ -0,0 +1,55 @@ +import { closeSync, lstatSync, openSync, readFileSync, readSync, realpathSync } from "node:fs"; +import * as path from "node:path"; + +const nodeShebangPattern = /^#!\s*(?:\S*\/node|\S*\/env\s+(?:-S\s+)?node)(?:\s|$)/; + +function readShebang(file: string): string { + const fd = openSync(file, "r"); + try { + // A standalone native vp can be large; only read its header. + const buffer = Buffer.alloc(256); + const length = readSync(fd, buffer, 0, buffer.length, 0); + return buffer.toString("utf8", 0, length).split(/\r?\n/, 1)[0]; + } finally { + closeSync(fd); + } +} + +/** Resolve Node entry points without interpreting shell shims or native vp binaries as JS. */ +export function resolveVitePlusNodeEntry(vpPath: string): string | undefined { + try { + const shebang = readShebang(vpPath); + if (nodeShebangPattern.test(shebang)) { + return vpPath; + } + + const isCmd = path.extname(vpPath) === ".cmd"; + if (!isCmd && !/^#!.*(?:\/|\s)(?:sh|bash|dash|zsh|ksh)(?:\s|$)/.test(shebang)) { + return undefined; + } + + // Read the literal target used by npm/pnpm shims, including custom global directories. + // Do not execute the shim or expand shell variables to locate the entry. + const shim = readFileSync(vpPath, "utf8"); + const target = isCmd + ? /"((?:%(?:~dp0|dp0%)[\\/]|[a-zA-Z]:[\\/]|\\\\)[^"\r\n]+)"[ \t]+%\*/.exec(shim) + : /"\$basedir\/([^"\r\n]+)"[ \t]+"\$@"/.exec(shim); + if (!target) { + return undefined; + } + + // pnpm shell shims can follow symlinks before computing their base directory. + const shimPath = !isCmd && lstatSync(vpPath).isSymbolicLink() ? realpathSync(vpPath) : vpPath; + const entryPath = isCmd + ? target[1].replace(/^%(?:~dp0|dp0%)[\\/]/, "").replaceAll("\\", path.sep) + : target[1]; + const nodeEntry = path.resolve(path.dirname(shimPath), entryPath); + + if (nodeShebangPattern.test(readShebang(nodeEntry))) { + return nodeEntry; + } + } catch { + // An absent or unreadable entry must leave the original executable intact. + } + return undefined; +} diff --git a/client/tools/ToolInterface.ts b/client/tools/ToolInterface.ts index 73eb8b1..dad14b2 100644 --- a/client/tools/ToolInterface.ts +++ b/client/tools/ToolInterface.ts @@ -29,7 +29,7 @@ export default interface ToolInterface { /** * Restarts the tool, cleaning up resources and reinitializing with the current configuration. */ - restart(): Promise; + restart(onlyIfBinaryChanged?: boolean): Promise; /** * Handles configuration changes. diff --git a/client/tools/formatter.ts b/client/tools/formatter.ts index 2b0b5f6..4501d9b 100644 --- a/client/tools/formatter.ts +++ b/client/tools/formatter.ts @@ -1,4 +1,5 @@ import { promises as fsPromises } from "node:fs"; +import { isDeepStrictEqual } from "node:util"; import { CodeAction, @@ -22,10 +23,11 @@ import { import { OxcCommands } from "../commands"; import { ConfigService } from "../ConfigService"; +import { VitePlusError } from "../detectVitePlus"; +import type { BinarySearchResult } from "../findBinary"; import StatusBarItemHandler from "../StatusBarItemHandler"; import { onClientNotification, runExecutable } from "./lsp_helper"; import ToolInterface from "./ToolInterface"; -import type { BinarySearchResult } from "../findBinary"; const languageClientName = "oxc"; @@ -247,6 +249,9 @@ const supportedLanguageIds = [ export default class FormatterTool implements ToolInterface { // LSP client instance private client: LanguageClient | undefined; + private binary: BinarySearchResult | undefined; + private binaryError: string | undefined; + private restartQueue: Promise = Promise.resolve(); private documentSelectors = [ { @@ -262,7 +267,7 @@ export default class FormatterTool implements ToolInterface { })), ]; - private disposeResources: (() => Promise) | undefined; + private disposeResources: (() => void) | undefined; // Command and provider disposables (registered once at construction) private readonly restartCommand: { dispose: () => void }; @@ -317,32 +322,42 @@ export default class FormatterTool implements ToolInterface { } async getBinary(): Promise { + this.binaryError = undefined; if (process.env.SERVER_PATH_DEV_OXFMT) { const path = process.env.SERVER_PATH_DEV_OXFMT; return { path, loader: path.endsWith(".js") ? "node" : "native" }; } - const bin = await this.configService.getOxfmtServerBinPath(); - if (bin) { - try { - await fsPromises.access(bin.path); - return bin; - } catch (e) { - this.outputChannel.error(`Invalid bin path: ${bin.path}`, e); + let bin: BinarySearchResult | undefined; + try { + bin = await this.configService.getOxfmtServerBinPath(); + } catch (error) { + if (!(error instanceof VitePlusError)) { + throw error; } + this.binaryError = error.message; + return undefined; + } + if (!bin) { + return undefined; + } + try { + await fsPromises.access(bin.path); + return bin; + } catch (error) { + this.outputChannel.error(`Invalid bin path: ${bin.path}`, error); } } - async activate(binary?: BinarySearchResult) { - // No valid binary found for the formatter. + async activate(binary?: BinarySearchResult): Promise { + this.binary = binary; if (!binary) { - this.statusBarItemHandler.updateTool("formatter", false, "No valid oxfmt binary found."); - this.outputChannel.appendLine( - "No valid oxfmt binary found. Formatter will not be activated.", - ); - return Promise.resolve(); + const message = this.binaryError ?? "No valid oxfmt binary found."; + this.statusBarItemHandler.updateTool("formatter", false, message); + this.outputChannel.warn(message); + return; } - this.outputChannel.info(`Using server binary at: ${binary?.path}`); + this.outputChannel.info(`Using server binary at: ${binary.path}`); const run: Executable = await runExecutable( binary, @@ -361,23 +376,21 @@ export default class FormatterTool implements ToolInterface { const clientOptions: LanguageClientOptions = { // Register the server for plain text documents documentSelector: this.documentSelectors, - initializationOptions: this.configService.formatterServerConfig, + initializationOptions: this.configService.getFormatterServerConfig(!!binary.vitePlus), outputChannel: this.outputChannel, traceOutputChannel: this.outputChannel, middleware: { workspace: { configuration: (params: ConfigurationParams) => { return params.items.map((item) => { - if (item.section !== "oxc_language_server") { - return null; - } - if (item.scopeUri === undefined) { + if (item.section !== "oxc_language_server" || item.scopeUri === undefined) { return null; } return ( - this.configService.getWorkspaceConfig(Uri.parse(item.scopeUri))?.toOxfmtConfig() ?? - null + this.configService + .getWorkspaceConfig(Uri.parse(item.scopeUri)) + ?.toOxfmtConfig(!!this.binary?.vitePlus) ?? null ); }); }, @@ -395,36 +408,67 @@ export default class FormatterTool implements ToolInterface { }, ); - this.disposeResources = async () => { - try { - await this.client?.dispose(); - } catch { - // do nothing, the client may already be stopped - } + this.disposeResources = () => { onNotificationDispose.dispose(); }; if (this.configService.vsCodeConfig.enableOxfmt) { - await this.client.start(); + await this.startClient(); } this.updateStatusBar(); } async deactivate(): Promise { + await this.restartQueue; + await this.stopClient(); + } + + private async stopClient(): Promise { try { - await this.client?.stop(); - } catch { - // do nothing, the client may already be stopped + await this.client?.dispose(); + } catch (error) { + // A client whose startup failed can reject disposal. Still release our resources + // so a corrected executable can start without reloading the window. + this.outputChannel.warn("Failed to dispose the oxfmt client.", error); + } finally { + this.disposeResources?.(); + this.disposeResources = undefined; + this.client = undefined; } - await this.disposeResources?.(); - this.disposeResources = undefined; - this.client = undefined; } - async restart(): Promise { - await this.deactivate(); - const newBinaryPath = await this.getBinary(); - await this.activate(newBinaryPath); + restart(onlyIfBinaryChanged = false): Promise { + const restart = this.restartQueue.then(async () => { + if (!onlyIfBinaryChanged) { + this.configService.clearBinarySearchCaches(); + } + const previousError = this.binaryError; + const newBinary = await this.getBinary(); + if ( + onlyIfBinaryChanged && + isDeepStrictEqual(this.binary, newBinary) && + previousError === this.binaryError + ) { + return; + } + await this.stopClient(); + await this.activate(newBinary); + }); + this.restartQueue = restart.catch(() => {}); + return restart; + } + + private async startClient(): Promise { + try { + await this.client?.start(); + this.binaryError = undefined; + } catch (error) { + if (!this.binary?.vitePlus) { + throw error; + } + this.binaryError = `Failed to start Vite+ ${this.binary.vitePlus} --lsp. Install or upgrade vite-plus in ${this.binary.cwd}, then restart the Oxc servers. ${error instanceof Error ? error.message : String(error)}`; + this.outputChannel.error(this.binaryError); + } } async toggleClient(): Promise { @@ -436,19 +480,23 @@ export default class FormatterTool implements ToolInterface { if (!this.configService.vsCodeConfig.enableOxfmt) { await this.client.stop(); } - } else { - if (this.configService.vsCodeConfig.enableOxfmt) { - await this.client.start(); - } + } else if (this.configService.vsCodeConfig.enableOxfmt) { + await this.startClient(); } } - async onConfigChange(event: ConfigurationChangeEvent): Promise { + onConfigChange(event: ConfigurationChangeEvent): Promise { + const change = this.restartQueue.then(() => this.applyConfigChange(event)); + this.restartQueue = change.catch(() => {}); + return change; + } + + private async applyConfigChange(event: ConfigurationChangeEvent): Promise { if ( event.affectsConfiguration(`${ConfigService.namespace}.enable`) || event.affectsConfiguration(`${ConfigService.namespace}.enable.oxfmt`) ) { - await this.toggleClient(); // update the client state + await this.toggleClient(); } this.updateStatusBar(); @@ -457,11 +505,12 @@ export default class FormatterTool implements ToolInterface { } // update the initializationOptions for a possible restart - this.client.clientOptions.initializationOptions = this.configService.formatterServerConfig; + const settings = this.configService.getFormatterServerConfig(!!this.binary?.vitePlus); + this.client.clientOptions.initializationOptions = settings; if (this.configService.effectsWorkspaceConfigChange(event) && this.client.isRunning()) { await this.client.sendNotification("workspace/didChangeConfiguration", { - settings: this.configService.formatterServerConfig, + settings, }); } } @@ -472,7 +521,7 @@ export default class FormatterTool implements ToolInterface { this.formatActionProvider.dispose(); } - private updateStatusBar() { + private updateStatusBar(): void { const enable = this.configService.vsCodeConfig.enableOxfmt; let text = @@ -485,14 +534,15 @@ export default class FormatterTool implements ToolInterface { text += `[$(play) Start Server](command:${OxcCommands.ToggleEnableFmt})\n\n`; } - const tooltipText = enable ? undefined : "`oxc.enable.oxfmt` or `oxc.enable` is false"; + const tooltipText = + this.binaryError ?? (enable ? undefined : "`oxc.enable.oxfmt` or `oxc.enable` is false"); if (tooltipText) { text = `${tooltipText}\n\n` + text; } this.statusBarItemHandler.updateTool( "formatter", - enable, + enable && !this.binaryError, text, this.client?.initializeResult?.serverInfo?.version, ); diff --git a/client/tools/linter.ts b/client/tools/linter.ts index 8f88a32..01751c9 100644 --- a/client/tools/linter.ts +++ b/client/tools/linter.ts @@ -1,4 +1,5 @@ import { promises as fsPromises } from "node:fs"; +import { isDeepStrictEqual } from "node:util"; import { CodeActionKind, @@ -27,11 +28,12 @@ import { import { OxcCommands } from "../commands"; import { ConfigService } from "../ConfigService"; +import { VitePlusError } from "../detectVitePlus"; +import type { BinarySearchResult } from "../findBinary"; import StatusBarItemHandler from "../StatusBarItemHandler"; import { VSCodeConfig } from "../VSCodeConfig"; import { onClientNotification, runExecutable } from "./lsp_helper"; import ToolInterface from "./ToolInterface"; -import type { BinarySearchResult } from "../findBinary"; const languageClientName = "oxc"; @@ -144,8 +146,11 @@ export default class LinterTool implements ToolInterface { // LSP client instance private client: LanguageClient | undefined; + private binary: BinarySearchResult | undefined; + private binaryError: string | undefined; + private restartQueue: Promise = Promise.resolve(); - private disposeResources: (() => Promise) | undefined; + private disposeResources: (() => void) | undefined; // Command disposables (registered once at construction) private readonly restartCommand: { dispose: () => void }; @@ -205,32 +210,46 @@ export default class LinterTool implements ToolInterface { } async getBinary(): Promise { + this.binaryError = undefined; if (process.env.SERVER_PATH_DEV_OXLINT) { const path = process.env.SERVER_PATH_DEV_OXLINT; return { path, loader: path.endsWith(".js") ? "node" : "native" }; } - const bin = await this.configService.getOxlintServerBinPath(); - if (bin) { - try { - await fsPromises.access(bin.path); - return bin; - } catch (e) { - this.outputChannel.error(`Invalid bin path: ${bin.path}`, e); + let bin: BinarySearchResult | undefined; + try { + bin = await this.configService.getOxlintServerBinPath(); + } catch (error) { + if (!(error instanceof VitePlusError)) { + throw error; } + this.binaryError = error.message; + return undefined; + } + if (!bin) { + return undefined; + } + try { + await fsPromises.access(bin.path); + return bin; + } catch (error) { + this.outputChannel.error(`Invalid bin path: ${bin.path}`, error); } } async activate(binary?: BinarySearchResult): Promise { + this.binary = binary; if (!binary) { - this.statusBarItemHandler.updateTool("linter", false, "No valid oxlint binary found."); - this.outputChannel.appendLine("No valid oxlint binary found. Linter will not be activated."); - return Promise.resolve(); + const message = this.binaryError ?? "No valid oxlint binary found."; + this.statusBarItemHandler.updateTool("linter", false, message); + this.outputChannel.warn(message); + return; } - this.allowedToStartServer = this.configService.vsCodeConfig.requireConfig - ? (await workspace.findFiles(oxlintConfigDefaultFilePattern, "**/node_modules/**", 1)) - .length > 0 - : true; + const requiresConfig = !binary.vitePlus && this.configService.vsCodeConfig.requireConfig; + this.allowedToStartServer = + !requiresConfig || + (await workspace.findFiles(oxlintConfigDefaultFilePattern, "**/node_modules/**", 1)).length > + 0; const run: Executable = await runExecutable( binary, @@ -244,7 +263,7 @@ export default class LinterTool implements ToolInterface { debug: run, }; - this.outputChannel.info(`Using server binary at: ${binary?.path}`); + this.outputChannel.info(`Using server binary at: ${binary.path}`); // see https://github.com/oxc-project/oxc/blob/9b475ad05b750f99762d63094174be6f6fc3c0eb/crates/oxc_linter/src/loader/partial_loader/mod.rs#L17-L20 const supportedExtensions = [ @@ -272,7 +291,7 @@ export default class LinterTool implements ToolInterface { scheme: "file", }, ], - initializationOptions: this.configService.oxlintServerConfig, + initializationOptions: this.configService.getOxlintServerConfig(!!binary.vitePlus), outputChannel: this.outputChannel, traceOutputChannel: this.outputChannel, diagnosticPullOptions: { @@ -321,16 +340,14 @@ export default class LinterTool implements ToolInterface { workspace: { configuration: (params: ConfigurationParams) => { return params.items.map((item) => { - if (item.section !== "oxc_language_server") { - return null; - } - if (item.scopeUri === undefined) { + if (item.section !== "oxc_language_server" || item.scopeUri === undefined) { return null; } return ( - this.configService.getWorkspaceConfig(Uri.parse(item.scopeUri))?.toOxlintConfig() ?? - null + this.configService + .getWorkspaceConfig(Uri.parse(item.scopeUri)) + ?.toOxlintConfig(!!this.binary?.vitePlus) ?? null ); }); }, @@ -354,37 +371,40 @@ export default class LinterTool implements ToolInterface { }); let activatorDispatcher: { dispose: () => void } | undefined; + this.disposeResources = () => { + onNotificationDispose.dispose(); + onDeleteFilesDispose.dispose(); + activatorDispatcher?.dispose(); + }; + if (this.allowedToStartServer) { if (this.configService.vsCodeConfig.enableOxlint) { - await this.client.start(); + await this.startClient(); } } else { activatorDispatcher = this.generateActivatorByConfig(this.configService.vsCodeConfig); } - this.disposeResources = async () => { - try { - await this.client?.dispose(); - } catch { - // do nothing, the client may already be stopped - } - onNotificationDispose.dispose(); - onDeleteFilesDispose.dispose(); - activatorDispatcher?.dispose(); - }; - this.updateStatusBar(this.configService.vsCodeConfig.enableOxlint); } async deactivate(): Promise { + await this.restartQueue; + await this.stopClient(); + } + + private async stopClient(): Promise { try { - await this.client?.stop(); - } catch { - // do nothing, the client may already be stopped + await this.client?.dispose(); + } catch (error) { + // A client whose startup failed can reject disposal. Still release our resources + // so a corrected executable can start without reloading the window. + this.outputChannel.warn("Failed to dispose the oxlint client.", error); + } finally { + this.disposeResources?.(); + this.disposeResources = undefined; + this.client = undefined; } - await this.disposeResources?.(); - this.disposeResources = undefined; - this.client = undefined; } dispose(): void { @@ -393,34 +413,66 @@ export default class LinterTool implements ToolInterface { this.applyAllFixesCommand.dispose(); } - async toggleClient(configService: ConfigService): Promise { + async toggleClient(): Promise { if (this.client === undefined || !this.allowedToStartServer) { return; } if (this.client.isRunning()) { - if (!configService.vsCodeConfig.enableOxlint) { + if (!this.configService.vsCodeConfig.enableOxlint) { await this.client.stop(); } - } else { - if (configService.vsCodeConfig.enableOxlint) { - await this.client.start(); + } else if (this.configService.vsCodeConfig.enableOxlint) { + await this.startClient(); + } + } + + restart(onlyIfBinaryChanged = false): Promise { + const restart = this.restartQueue.then(async () => { + if (!onlyIfBinaryChanged) { + this.configService.clearBinarySearchCaches(); + } + const previousError = this.binaryError; + const newBinary = await this.getBinary(); + if ( + onlyIfBinaryChanged && + isDeepStrictEqual(this.binary, newBinary) && + previousError === this.binaryError + ) { + return; + } + await this.stopClient(); + await this.activate(newBinary); + }); + this.restartQueue = restart.catch(() => {}); + return restart; + } + + private async startClient(): Promise { + try { + await this.client?.start(); + this.binaryError = undefined; + } catch (error) { + if (!this.binary?.vitePlus) { + throw error; } + this.binaryError = `Failed to start Vite+ ${this.binary.vitePlus} --lsp. Install or upgrade vite-plus in ${this.binary.cwd}, then restart the Oxc servers. ${error instanceof Error ? error.message : String(error)}`; + this.outputChannel.error(this.binaryError); } } - async restart(): Promise { - await this.deactivate(); - const newBinaryPath = await this.getBinary(); - await this.activate(newBinaryPath); + onConfigChange(event: ConfigurationChangeEvent): Promise { + const change = this.restartQueue.then(() => this.applyConfigChange(event)); + this.restartQueue = change.catch(() => {}); + return change; } - async onConfigChange(event: ConfigurationChangeEvent): Promise { + private async applyConfigChange(event: ConfigurationChangeEvent): Promise { if ( event.affectsConfiguration(`${ConfigService.namespace}.enable`) || event.affectsConfiguration(`${ConfigService.namespace}.enable.oxlint`) ) { - await this.toggleClient(this.configService); // update the client state + await this.toggleClient(); } this.updateStatusBar(this.configService.vsCodeConfig.enableOxlint); @@ -429,11 +481,12 @@ export default class LinterTool implements ToolInterface { } // update the initializationOptions for a possible restart - this.client.clientOptions.initializationOptions = this.configService.oxlintServerConfig; + const settings = this.configService.getOxlintServerConfig(!!this.binary?.vitePlus); + this.client.clientOptions.initializationOptions = settings; if (this.configService.effectsWorkspaceConfigChange(event) && this.client.isRunning()) { await this.client.sendNotification("workspace/didChangeConfiguration", { - settings: this.configService.oxlintServerConfig, + settings, }); } } @@ -449,6 +502,7 @@ export default class LinterTool implements ToolInterface { isEnabled: boolean; tooltipText?: string; } { + if (this.binaryError) return { isEnabled: false, tooltipText: this.binaryError }; if (!this.allowedToStartServer) { return { isEnabled: false, @@ -502,7 +556,7 @@ export default class LinterTool implements ToolInterface { this.allowedToStartServer = true; this.updateStatusBar(config.enableOxlint); if (this.client && !this.client.isRunning() && config.enableOxlint) { - await this.client.start(); + await this.startClient(); } }); diff --git a/client/tools/lsp_helper.ts b/client/tools/lsp_helper.ts index 18ff6c4..8b1a446 100644 --- a/client/tools/lsp_helper.ts +++ b/client/tools/lsp_helper.ts @@ -4,6 +4,7 @@ import { LogOutputChannel, window } from "vscode"; import { Executable, MessageType, ShowMessageParams } from "vscode-languageclient/node"; import type { BinarySearchResult } from "../findBinary"; import { getShellEnv } from "../getShellEnv"; +import { resolveVitePlusNodeEntry } from "../resolveVitePlusNodeEntry"; export async function runExecutable( binary: BinarySearchResult, @@ -12,6 +13,12 @@ export async function runExecutable( tsgolintPath?: string, suppressProgramErrors?: boolean, ): Promise { + if (binary.vitePlus && useExecPath && binary.loader === "native") { + const nodeEntry = resolveVitePlusNodeEntry(binary.path); + if (nodeEntry) { + binary = { ...binary, path: nodeEntry, loader: "node" }; + } + } const shellEnv = await getShellEnv(); const serverEnv: Record = { @@ -26,10 +33,6 @@ export async function runExecutable( if (suppressProgramErrors) { serverEnv.OXLINT_TSGOLINT_DANGEROUSLY_SUPPRESS_PROGRAM_DIAGNOSTICS = "true"; } - // when the binary path ends with `oxlint/bin/oxlint` or a common js extension, we should run it with `node` - // the path is defined in `ConfigService.searchNodeModulesBin` - // Probably it would be better to read the shebang for unknown extensions, and run with `node` if the shebang contains `node`, - // but for now we can just check for common node extensions and the known path for `oxlint` const isNode = binary.loader === "node"; let nodeCommand: string; @@ -46,44 +49,49 @@ export async function runExecutable( serverEnv.PATH = `${nodeDir}${path.delimiter}${serverEnv.PATH ?? ""}`; } - const isWindows = process.platform === "win32"; + const args = binary.vitePlus ? [binary.vitePlus, "--lsp"] : ["--lsp"]; + + if (isNode || (useExecPath && !binary.vitePlus)) { + const nodeArgs = [binary.path, ...args]; + // Yarn PnP needs loaders for both CJS require() and ESM imports. + if (isNode && binary.yarnPnpLoaderPath) { + const esmLoaderPath = path.join(path.dirname(binary.yarnPnpLoaderPath), ".pnp.loader.mjs"); + nodeArgs.unshift( + "--require", + binary.yarnPnpLoaderPath, + "--loader", + pathToFileURL(esmLoaderPath).href, + ); + } - // In Yarn PnP environments, inject the PnP loaders so that both CJS require() - // and ESM import calls can resolve dependencies through PnP. - // --require .pnp.cjs: patches CJS resolution (e.g., oxlint's NAPI-RS bindings via createRequire) - // --loader .pnp.loader.mjs: patches ESM resolution (e.g., oxfmt's tinypool import) - const pnpArgs: string[] = []; - if (isNode && binary.yarnPnpLoaderPath) { - pnpArgs.push("--require", binary.yarnPnpLoaderPath); - const esmLoaderPath = path.join(path.dirname(binary.yarnPnpLoaderPath), ".pnp.loader.mjs"); - pnpArgs.push("--loader", pathToFileURL(esmLoaderPath).href); + return { + command: nodeCommand, + args: nodeArgs, + options: { + cwd: binary.cwd, + env: serverEnv, + }, + }; } - return isNode || useExecPath - ? { - command: nodeCommand, - args: [...pnpArgs, binary.path, "--lsp"], - options: { - env: serverEnv, - }, - } - : { - // On Windows with shell, quote the command path to handle spaces in usernames/paths - command: isWindows ? `"${binary.path}"` : binary.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 user defined binary path in `configService.searchBinaryPath()`. - shell: isWindows, - env: serverEnv, - }, - }; + // Keep native vp binaries and unresolved shell shims out of the Node launch path. + const isWindows = process.platform === "win32"; + return { + // Windows package-manager shims need a shell; quote paths that can contain spaces. + command: isWindows ? `"${binary.path}"` : binary.path, + args, + options: { + cwd: binary.cwd, + shell: isWindows, + env: serverEnv, + }, + }; } -export function onClientNotification(params: ShowMessageParams, outputChannel: LogOutputChannel) { +export function onClientNotification( + params: ShowMessageParams, + outputChannel: LogOutputChannel, +): void { switch (params.type) { case MessageType.Debug: outputChannel.debug(params.message); diff --git a/package.json b/package.json index f8d8cbc..e00b7a5 100644 --- a/package.json +++ b/package.json @@ -216,7 +216,7 @@ "type": "boolean", "scope": "resource", "default": false, - "markdownDescription": "Disable searching for nested configuration files. When set to true, only the configuration file specified in `oxc.configPath` (if any) will be used." + "markdownDescription": "Disable searching for nested configuration files. When set to true, only the configuration file specified in `oxc.configPath` (if any) will be used. The extension always sends `true` when using `vp lint --lsp`." }, "oxc.fixKind": { "type": [ @@ -301,7 +301,7 @@ "type": "boolean", "scope": "resource", "default": false, - "markdownDescription": "Disable searching for nested configuration files. When set to true, only the configuration file specified in `oxc.fmt.configPath` (if any) will be used." + "markdownDescription": "Disable searching for nested configuration files. When set to true, only the configuration file specified in `oxc.fmt.configPath` (if any) will be used. The extension always sends `true` when using `vp fmt --lsp`." }, "oxc.suppressProgramErrors": { "type": "boolean", @@ -326,6 +326,33 @@ "scope": "window", "markdownDescription": "Path to an Oxc formatter binary. Default: auto detection in `node_modules`." }, + "oxc.lint.binarySource": { + "type": "string", + "enum": [ + "auto", + "vite-plus", + "oxc" + ], + "scope": "resource", + "default": "auto", + "markdownDescription": "Select the linter source. `auto` detects a direct `vite-plus` dependency or uses `oxc.path.vp`, otherwise it finds standalone Oxlint. `vite-plus` selects `vp lint --lsp` without dependency detection. `oxc` finds standalone Oxlint and ignores `oxc.path.vp`. An explicit `oxc.path.oxlint` takes priority." + }, + "oxc.fmt.binarySource": { + "type": "string", + "enum": [ + "auto", + "vite-plus", + "oxc" + ], + "scope": "resource", + "default": "auto", + "markdownDescription": "Select the formatter source. `auto` detects a direct `vite-plus` dependency or uses `oxc.path.vp`, otherwise it finds standalone Oxfmt. `vite-plus` selects `vp fmt --lsp` without dependency detection. `oxc` finds standalone Oxfmt and ignores `oxc.path.vp`. An explicit `oxc.path.oxfmt` takes priority." + }, + "oxc.path.vp": { + "type": "string", + "scope": "resource", + "markdownDescription": "Path to a `vp` executable. Relative paths use the workspace folder. Setting this path selects Vite+ without dependency detection for tools whose `binarySource` is `auto` or `vite-plus`. Tools whose `binarySource` is `oxc` ignore this path. Explicit `oxc.path.oxlint` and `oxc.path.oxfmt` settings take priority." + }, "oxc.path.tsgolint": { "type": "string", "scope": "window", @@ -410,6 +437,9 @@ "oxc.path.oxfmt", "oxc.path.tsgolint", "oxc.path.node", + "oxc.path.vp", + "oxc.lint.binarySource", + "oxc.fmt.binarySource", "oxc.useExecPath" ] } diff --git a/tests/processMocks.ts b/tests/processMocks.ts new file mode 100644 index 0000000..dab44e7 --- /dev/null +++ b/tests/processMocks.ts @@ -0,0 +1,24 @@ +/** Register these helpers inside a Mocha suite to restore process state after each test. */ +export function mockProcessPlatform(): (platform: NodeJS.Platform) => void { + let descriptor: PropertyDescriptor; + setup(() => { + descriptor = Object.getOwnPropertyDescriptor(process, "platform")!; + }); + teardown(() => { + Object.defineProperty(process, "platform", descriptor); + }); + return (platform) => { + Object.defineProperty(process, "platform", { value: platform }); + }; +} + +export function mockProcessEnv(): void { + let descriptor: PropertyDescriptor; + setup(() => { + descriptor = Object.getOwnPropertyDescriptor(process, "env")!; + process.env = { ...process.env }; + }); + teardown(() => { + Object.defineProperty(process, "env", descriptor); + }); +} diff --git a/tests/unit/VSCodeConfig.spec.ts b/tests/unit/VSCodeConfig.spec.ts index 7622992..d772239 100644 --- a/tests/unit/VSCodeConfig.spec.ts +++ b/tests/unit/VSCodeConfig.spec.ts @@ -18,6 +18,9 @@ suite("VSCodeConfig", () => { "path.node", "useExecPath", "suppressProgramErrors", + "path.vp", + "lint.binarySource", + "fmt.binarySource", ]; setup(async () => { await Promise.all(keys.map((key) => conf.update(key, undefined))); @@ -39,6 +42,8 @@ suite("VSCodeConfig", () => { strictEqual(config.binPathTsGoLint, ""); strictEqual(config.nodePath, ""); strictEqual(config.useExecPath, false); + strictEqual(conf.get("lint.binarySource"), "auto"); + strictEqual(conf.get("fmt.binarySource"), "auto"); strictEqual( config.suppressProgramErrors, false, @@ -109,6 +114,9 @@ suite("VSCodeConfig", () => { { key: "path.tsgolint", affects: true }, { key: "path.node", affects: true }, { key: "useExecPath", affects: true }, + { key: "path.vp", affects: true }, + { key: "lint.binarySource", affects: true }, + { key: "fmt.binarySource", affects: false }, { key: "requireConfig", affects: false }, { key: "path.oxfmt", affects: false }, ]; @@ -136,6 +144,9 @@ suite("VSCodeConfig", () => { { key: "path.oxfmt", affects: true }, { key: "path.node", affects: true }, { key: "useExecPath", affects: true }, + { key: "path.vp", affects: true }, + { key: "fmt.binarySource", affects: true }, + { key: "lint.binarySource", affects: false }, { key: "path.tsgolint", affects: false }, { key: "requireConfig", affects: false }, { key: "path.oxlint", affects: false }, diff --git a/tests/unit/detectVitePlus.spec.ts b/tests/unit/detectVitePlus.spec.ts new file mode 100644 index 0000000..b18373a --- /dev/null +++ b/tests/unit/detectVitePlus.spec.ts @@ -0,0 +1,187 @@ +import { deepStrictEqual, strictEqual } from "assert"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import { detectVitePlusProject } from "../../client/detectVitePlus"; +import { mockProcessPlatform } from "../processMocks"; + +suite("detectVitePlusProject", () => { + let root: string; + const setPlatform = mockProcessPlatform(); + + function file(relative: string, content = ""): string { + const target = path.join(root, relative); + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync(target, content); + return target; + } + + function pkg( + relative = "", + value: object = { devDependencies: { "vite-plus": "latest" } }, + ): string { + return file(path.join(relative, "package.json"), JSON.stringify(value)); + } + + function shim(relative = ""): string { + return file( + path.join(relative, "node_modules/.bin", process.platform === "win32" ? "vp.cmd" : "vp"), + ); + } + + setup(() => { + root = mkdtempSync(path.join(tmpdir(), "detect-vite-plus-")); + file("pnpm-workspace.yaml"); + }); + + teardown(() => { + rmSync(root, { recursive: true, force: true }); + }); + + test("root-declared-and-installed, starting from a file", () => { + pkg(); + const vpPath = shim(); + const start = file("src/index.ts"); + deepStrictEqual(detectVitePlusProject(start), { root, vpPath }); + }); + + test("pnpm-subpackage-declared-root-hoisted", () => { + pkg("packages/app"); + const vpPath = shim(); + const start = file("packages/app/src/index.ts"); + deepStrictEqual(detectVitePlusProject(start), { + root: path.join(root, "packages/app"), + vpPath, + }); + }); + + test("npm-subpackage-direct-dep-unhoisted", () => { + rmSync(path.join(root, "pnpm-workspace.yaml")); + pkg("", { workspaces: ["packages/*"] }); + pkg("packages/app", { dependencies: { "vite-plus": "latest" } }); + const vpPath = shim("packages/app"); + deepStrictEqual(detectVitePlusProject(path.join(root, "packages/app")), { + root: path.join(root, "packages/app"), + vpPath, + }); + }); + + test("root-declared-no-local-no-global", () => { + pkg(); + deepStrictEqual(detectVitePlusProject(root), { root }); + }); + + test("transitive-install", () => { + pkg("", { dependencies: { other: "latest" } }); + pkg("node_modules/vite-plus", { name: "vite-plus" }); + shim(); + strictEqual(detectVitePlusProject(root), null); + }); + + test("plain-non-vite-plus", () => { + pkg("", { + name: "plain", + peerDependencies: { "vite-plus": "latest" }, + optionalDependencies: { "vite-plus": "latest" }, + }); + strictEqual(detectVitePlusProject(root), null); + }); + + test("yarn4-pnp", () => { + pkg(); + file(".pnp.cjs", "throw new Error('the detector must not execute PnP code')"); + deepStrictEqual(detectVitePlusProject(root), { root }); + }); + + for (const marker of ["pnpm", "npm", "lerna"]) { + test(`parent-vite-plus-nested-repo (${marker})`, () => { + pkg(); + shim(); + pkg("nested", marker === "npm" ? { workspaces: ["packages/*"] } : {}); + if (marker !== "npm") + file(`nested/${marker === "pnpm" ? "pnpm-workspace.yaml" : "lerna.json"}`); + const start = file("nested/src/index.ts"); + strictEqual(detectVitePlusProject(start), null); + }); + + test(`does not resolve a local binary above a ${marker} workspace`, () => { + pkg(); + shim(); + pkg("nested", { + dependencies: { "vite-plus": "latest" }, + ...(marker === "npm" ? { workspaces: [] } : {}), + }); + if (marker !== "npm") + file(`nested/${marker === "pnpm" ? "pnpm-workspace.yaml" : "lerna.json"}`); + deepStrictEqual(detectVitePlusProject(path.join(root, "nested")), { + root: path.join(root, "nested"), + }); + }); + } + + test("continues past malformed package.json files", () => { + pkg(); + const vpPath = shim(); + const start = file("src/package.json", "{"); + deepStrictEqual(detectVitePlusProject(start), { root, vpPath }); + }); + + test("explicit opt-in does not require a dependency declaration", () => { + pkg("", {}); + const vpPath = shim(); + deepStrictEqual(detectVitePlusProject(root, true), { root, vpPath }); + }); + + for (const marker of ["package", "workspace", "none"]) { + test(`forced mode has a stable project root with ${marker} metadata`, () => { + if (marker !== "workspace") rmSync(path.join(root, "pnpm-workspace.yaml")); + if (marker === "package") pkg("", {}); + const vpPath = shim(); + const firstFile = file("src/pages/index.ts"); + const secondFile = file("src/components/button.ts"); + deepStrictEqual(detectVitePlusProject(firstFile, true, root), { root, vpPath }); + deepStrictEqual(detectVitePlusProject(secondFile, true, root), { root, vpPath }); + }); + } + + test("forced mode retains a nested package root and a hoisted install", () => { + pkg("packages/app", {}); + const vpPath = shim(); + const start = file("packages/app/src/index.ts"); + deepStrictEqual(detectVitePlusProject(start, true, root), { + root: path.join(root, "packages/app"), + vpPath, + }); + }); + + test("prefers the nearest declaring package and its install", () => { + pkg(); + shim(); + pkg("packages/app"); + const vpPath = shim("packages/app"); + deepStrictEqual(detectVitePlusProject(path.join(root, "packages/app")), { + root: path.join(root, "packages/app"), + vpPath, + }); + }); + + test("selects vp.cmd on Windows", () => { + setPlatform("win32"); + pkg(); + file("node_modules/.bin/vp"); + file("node_modules/.bin/vp.exe"); + const vpPath = shim(); + deepStrictEqual(detectVitePlusProject(root), { root, vpPath }); + }); + + test("selects a local vp.exe shim on Windows before a hoisted vp.cmd", () => { + setPlatform("win32"); + pkg("packages/app"); + shim(); + const vpPath = file("packages/app/node_modules/.bin/vp.exe"); + deepStrictEqual(detectVitePlusProject(path.join(root, "packages/app")), { + root: path.join(root, "packages/app"), + vpPath, + }); + }); +}); diff --git a/tests/unit/extension.spec.ts b/tests/unit/extension.spec.ts new file mode 100644 index 0000000..8939332 --- /dev/null +++ b/tests/unit/extension.spec.ts @@ -0,0 +1,97 @@ +import { deepStrictEqual, strictEqual } from "assert"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import * as path from "node:path"; +import { mock } from "node:test"; +import { commands, ExtensionContext, Uri, window } from "vscode"; +import { activate, deactivate } from "../../client/extension"; +import type { BinarySearchResult } from "../../client/findBinary"; +import Formatter from "../../client/tools/formatter"; +import Linter from "../../client/tools/linter"; +import { WORKSPACE_FOLDER } from "../test-helpers"; +import { mockProcessEnv } from "../processMocks"; + +suite("navigation during extension activation", () => { + const root = path.join(WORKSPACE_FOLDER.uri.fsPath, "startup-navigation"); + mockProcessEnv(); + let context: ExtensionContext; + + setup(() => { + process.env.SKIP_LINTER_TEST = "false"; + process.env.SKIP_FORMATTER_TEST = "false"; + mkdirSync(root, { recursive: true }); + for (const name of ["a", "b"]) writeFileSync(path.join(root, `${name}.txt`), ""); + // The test host has already registered the extension's commands. + mock.method(commands, "registerCommand", () => ({ dispose() {} })); + // Log channels with the same name share a logger in VS Code. Keep disposal + // of these test instances from closing the activated extension's channels. + const createOutputChannel = window.createOutputChannel; + mock.method(window, "createOutputChannel", (name: string, options: { log: true }) => + createOutputChannel(`Activation test ${name}`, options), + ); + context = { + extension: { packageJSON: { version: "test" } }, + subscriptions: [], + } as unknown as ExtensionContext; + }); + + teardown(async () => { + await deactivate(); + for (const disposable of context.subscriptions) disposable.dispose(); + mock.restoreAll(); + await commands.executeCommand("workbench.action.closeAllEditors"); + rmSync(root, { recursive: true, force: true }); + }); + + for (const phase of ["discovery", "startup"] as const) { + test(`reconciles a project switch during ${phase}`, async () => { + const first = Uri.file(path.join(root, "a.txt")); + const second = Uri.file(path.join(root, "b.txt")); + await window.showTextDocument(first); + let release!: () => void; + let entered!: () => void; + let waiting = 0; + const gate = new Promise((resolve) => { + release = resolve; + }); + const ready = new Promise((resolve) => { + entered = resolve; + }); + async function wait(): Promise { + if (++waiting === 2) entered(); + await gate; + } + const selected = new Map(); + for (const [Tool, name] of [ + [Linter, "lint"], + [Formatter, "fmt"], + ] as const) { + mock.method(Tool.prototype, "getBinary", async () => { + const binary = { + path: window.activeTextEditor!.document.uri.fsPath, + loader: "native" as const, + }; + if (phase === "discovery") await wait(); + return binary; + }); + mock.method(Tool.prototype, "activate", async (binary?: BinarySearchResult) => { + selected.set(name, binary!.path); + if (phase === "startup") await wait(); + }); + mock.method(Tool.prototype, "restart", async (onlyIfBinaryChanged?: boolean) => { + strictEqual(onlyIfBinaryChanged, true); + selected.set(name, window.activeTextEditor!.document.uri.fsPath); + }); + mock.method(Tool.prototype, "deactivate", async () => {}); + } + const activation = activate(context); + try { + await ready; + await window.showTextDocument(second); + } finally { + release(); + } + await activation; + deepStrictEqual([...selected.values()], [second.fsPath, second.fsPath]); + }); + } +}); diff --git a/tests/unit/findBinary.spec.ts b/tests/unit/findBinary.spec.ts index fb07be9..504070f 100644 --- a/tests/unit/findBinary.spec.ts +++ b/tests/unit/findBinary.spec.ts @@ -1,20 +1,44 @@ -import { strictEqual, throws } from "assert"; +import { deepStrictEqual, strictEqual, throws } from "assert"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import * as path from "node:path"; import { tmpdir } from "node:os"; +import { mock } from "node:test"; import { Uri, workspace } from "vscode"; import { clearWorkspacePackageJsonNodeModulesCache, + clearGlobalNodeModulesPathsCache, replaceTargetFromMainToBin, searchGlobalNodeModulesBin, searchEnvPath, searchProjectNodeModulesBin, searchYarnPnpBin, + searchSettingsBin, } from "../../client/findBinary"; import { WORKSPACE_FOLDER } from "../test-helpers.js"; +import { mockProcessPlatform } from "../processMocks"; + +const shellEnv: typeof import("../../client/getShellEnv") = require( + path.join(__dirname, "../client/getShellEnv.js"), +); suite("findBinary", () => { const binaryName = "oxlint"; + const setPlatform = mockProcessPlatform(); + + test("prefers a Windows vp.cmd shim over the POSIX shim for a configured path", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "test-vp-cmd-")); + const vpPath = path.join(dir, "vp"); + writeFileSync(vpPath, ""); + writeFileSync(`${vpPath}.cmd`, ""); + try { + setPlatform("win32"); + const result = await searchSettingsBin("vp", vpPath); + strictEqual(result?.path, `${vpPath}.cmd`); + strictEqual(result?.loader, "native"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); suite("replaceTargetFromMainToBin", () => { let tmpDir: string; @@ -197,6 +221,77 @@ suite("findBinary", () => { }); suite("searchGlobalNodeModulesBin", () => { + let globalModules: string; + + setup(() => { + clearGlobalNodeModulesPathsCache(); + globalModules = mkdtempSync(path.join(tmpdir(), "test-global-modules-")); + }); + + teardown(() => { + mock.restoreAll(); + clearGlobalNodeModulesPathsCache(); + rmSync(globalModules, { recursive: true, force: true }); + }); + + test("shares package-manager probes across concurrent searches and repeated navigation", async () => { + const names = ["oxlint", "oxfmt", "vp"]; + const paths = names.map((name) => + path.join( + globalModules, + ".bin", + name === "vp" && process.platform === "win32" ? "vp.cmd" : name, + ), + ); + mkdirSync(path.join(globalModules, ".bin")); + for (const binPath of paths) writeFileSync(binPath, ""); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + mock.method(shellEnv, "getShellEnv", async () => { + await gate; + return {}; + }); + const probes = mock.method(require("node:child_process"), "spawnSync", () => ({ + status: 0, + stdout: globalModules, + })); + const pending = Promise.all(names.map((name) => searchGlobalNodeModulesBin(name))); + release(); + deepStrictEqual( + (await pending).map((binary) => binary?.path), + paths, + ); + strictEqual(probes.mock.callCount(), 2, "npm and pnpm should each run once"); + for (let navigation = 0; navigation < 3; navigation++) { + // oxlint-disable-next-line no-await-in-loop -- simulate successive file switches + const binaries = await Promise.all(names.map((name) => searchGlobalNodeModulesBin(name))); + deepStrictEqual( + binaries.map((binary) => binary?.path), + paths, + ); + } + strictEqual(probes.mock.callCount(), 2, "file switches must not repeat the probes"); + }); + + test("finds newly installed binaries without repeating package-manager probes", async () => { + mock.method(shellEnv, "getShellEnv", async () => ({})); + const probes = mock.method(require("node:child_process"), "spawnSync", () => ({ + status: 0, + stdout: globalModules, + })); + const name = "new-global-bin-test"; + strictEqual(await searchGlobalNodeModulesBin(name), undefined); + const binPath = path.join(globalModules, ".bin", name); + mkdirSync(path.dirname(binPath)); + writeFileSync(binPath, ""); + strictEqual((await searchGlobalNodeModulesBin(name))?.path, binPath); + rmSync(binPath); + strictEqual(await searchGlobalNodeModulesBin(name), undefined); + strictEqual(probes.mock.callCount(), 2); + }); + test("should return undefined when binary is not found in global node_modules", async () => { const result = await searchGlobalNodeModulesBin("non-existent-binary-package-name-12345"); strictEqual(result, undefined); diff --git a/tests/unit/getShellEnv.spec.ts b/tests/unit/getShellEnv.spec.ts index 4529e53..70bcff9 100644 --- a/tests/unit/getShellEnv.spec.ts +++ b/tests/unit/getShellEnv.spec.ts @@ -1,18 +1,18 @@ import { strictEqual } from "assert"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import * as path from "node:path"; +import { mockProcessEnv, mockProcessPlatform } from "../processMocks"; type GetShellEnvModule = { getShellEnv: () => Promise>; }; +let moduleId = 0; + async function loadFreshGetShellEnvModule(): Promise { - const timestamp = Date.now(); // append a query parameter to force a fresh import of the module to reset the cachedEnv variable - const module = await import(`../../client/getShellEnv.ts?ts=${timestamp}`); - - return module; + return import(`../../client/getShellEnv.ts?testModule=${moduleId++}`); } function createMockShellScript(dir: string, name: string, scriptBody: string): string { @@ -23,20 +23,19 @@ function createMockShellScript(dir: string, name: string, scriptBody: string): s suite("getShellEnv", () => { let tempDir: string; - const originalPlatform = process.platform; - const originalEnv = process.env; + const setPlatform = mockProcessPlatform(); + mockProcessEnv(); setup(() => { tempDir = mkdtempSync(path.join(tmpdir(), "get-shell-env-test-")); }); teardown(() => { - Object.defineProperty(process, "platform", { value: originalPlatform }); - process.env = originalEnv; + rmSync(tempDir, { recursive: true, force: true }); }); - test("returns process.env directly on win32", async () => { - Object.defineProperty(process, "platform", { value: "win32" }); + test("copies process.env on win32", async () => { + setPlatform("win32"); process.env.GET_SHELL_ENV_TEST_KEY = "windows-fast-path"; process.env.SHELL = path.join(tempDir, "does-not-matter-on-win32"); @@ -46,6 +45,21 @@ suite("getShellEnv", () => { strictEqual(env.GET_SHELL_ENV_TEST_KEY, "windows-fast-path"); }); + for (const key of ["Path", "PATH", "pAtH"]) { + test(`normalizes ${key} in the Windows environment copy`, async () => { + setPlatform("win32"); + process.env = { [key]: tempDir, KEEP_ME: "unchanged" }; + const { getShellEnv } = await loadFreshGetShellEnvModule(); + const env = await getShellEnv(); + + strictEqual(env.PATH, tempDir); + strictEqual(env.KEEP_ME, "unchanged"); + strictEqual(Object.keys(env).filter((name) => name.toUpperCase() === "PATH").length, 1); + strictEqual(process.env[key], tempDir, "the original environment must not change"); + strictEqual(Object.keys(process.env)[0], key); + }); + } + test("parses shell output into env object", async function () { if (process.platform === "win32") { this.skip(); diff --git a/tests/unit/lsp_helper.spec.ts b/tests/unit/lsp_helper.spec.ts index 5c44263..5354147 100644 --- a/tests/unit/lsp_helper.spec.ts +++ b/tests/unit/lsp_helper.spec.ts @@ -1,15 +1,218 @@ -import { strictEqual } from "assert"; +import { deepStrictEqual, strictEqual } from "assert"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { mock } from "node:test"; import { runExecutable } from "../../client/tools/lsp_helper"; import * as path from "node:path"; import { pathToFileURL } from "node:url"; +import { mockProcessEnv, mockProcessPlatform } from "../processMocks"; + +// Mock the shared CommonJS exports used by the compiled test modules. +const shellEnv: typeof import("../../client/getShellEnv") = require( + path.join(__dirname, "../client/getShellEnv.js"), +); suite("runExecutable", () => { const originalPlatform = process.platform; - const originalEnv = process.env; + const setPlatform = mockProcessPlatform(); + mockProcessEnv(); + let tempDir: string; + + setup(() => { + tempDir = mkdtempSync(path.join(tmpdir(), "vp-runtime-")); + mock.method(shellEnv, "getShellEnv", async () => ({ PATH: process.env.PATH })); + }); teardown(() => { - Object.defineProperty(process, "platform", { value: originalPlatform }); - process.env = originalEnv; + mock.restoreAll(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + for (const command of ["lint", "fmt"] as const) { + test(`runs vp ${command} --lsp in the project directory`, async () => { + const result = await runExecutable({ + path: "/project/node_modules/.bin/vp", + loader: "native", + vitePlus: command, + cwd: "/project", + }); + deepStrictEqual(result.args, [command, "--lsp"]); + strictEqual(result.options?.cwd, "/project"); + }); + + test(`runs the vp JavaScript entry point with ${command} --lsp and the configured runtime`, async () => { + const result = await runExecutable( + { + path: "/project/node_modules/vite-plus/bin/vp", + loader: "node", + vitePlus: command, + cwd: "/project", + }, + true, + ); + strictEqual(result.command, process.execPath); + deepStrictEqual(result.args, ["/project/node_modules/vite-plus/bin/vp", command, "--lsp"]); + strictEqual(result.options?.cwd, "/project"); + strictEqual(result.options?.env?.ELECTRON_RUN_AS_NODE, "1"); + }); + } + + for (const shimType of [ + "npm", + "pnpm", + "cmd", + "global-cmd", + "global-pnpm", + "global-pnpm-symlink", + "global-pnpm-cmd", + ] as const) { + for (const command of ["lint", "fmt"] as const) { + test(`runs ${shimType} vp ${command} with bundled Node and no system Node`, async function () { + if ((shimType === "npm" || shimType.endsWith("symlink")) && originalPlatform === "win32") { + this.skip(); + } + const isGlobalPnpm = shimType.startsWith("global-pnpm"); + const isCmd = shimType.endsWith("cmd"); + const modulesDir = isGlobalPnpm + ? path.join(tempDir, "custom global store", "5", "node_modules") + : path.join(tempDir, "node_modules"); + const nodeEntry = path.join(modulesDir, "vite-plus", "bin", "vp"); + let binDir = path.join(tempDir, "node_modules", ".bin"); + if (isGlobalPnpm) { + binDir = path.join(tempDir, "global bin"); + } else if (shimType === "global-cmd") { + binDir = tempDir; + } + let shim = path.join(binDir, isCmd ? "vp.cmd" : "vp"); + mkdirSync(path.dirname(nodeEntry), { recursive: true }); + mkdirSync(path.dirname(shim), { recursive: true }); + writeFileSync( + path.join(path.dirname(nodeEntry), "child.cjs"), + "process.stdout.write(JSON.stringify(process.argv.slice(2)));\n", + ); + writeFileSync( + nodeEntry, + `#!/usr/bin/env node +const { spawnSync } = require("node:child_process"); +const args = process.argv.slice(2); +const script = require("node:path").join(__dirname, "child.cjs"); +const child = spawnSync(process.execPath, [script, ...args], { + encoding: "utf8", +}); +if (child.error) throw child.error; +process.stdout.write(child.stdout); +process.stderr.write(child.stderr); +process.exit(child.status ?? 1); +`, + ); + if (shimType === "npm") { + symlinkSync(nodeEntry, shim); + } else { + const relativeEntry = path.relative(binDir, nodeEntry); + // pnpm shims can contain long NODE_PATH setup before the launch command. + const padding = isGlobalPnpm ? " ".repeat(512) : ""; + const script = isCmd + ? `@SETLOCAL\r\n@REM ${padding}\r\nnode "%~dp0\\${relativeEntry.replaceAll(path.sep, "\\")}" %*\r\n` + : `#!/bin/sh\nbasedir=$(dirname "$0")\n# ${padding}\nexec node "$basedir/${relativeEntry.replaceAll(path.sep, "/")}" "$@"\n`; + writeFileSync(shim, script); + } + if (isGlobalPnpm) { + // The recorded target must win over an unrelated adjacent installation. + const adjacentEntry = path.join(binDir, "node_modules", "vite-plus", "bin", "vp"); + mkdirSync(path.dirname(adjacentEntry), { recursive: true }); + writeFileSync(adjacentEntry, "#!/usr/bin/env node\nthrow new Error('wrong entry');\n"); + if (shimType === "global-pnpm-symlink") { + const alias = path.join(tempDir, "linked bin", "nested", "vp"); + mkdirSync(path.dirname(alias), { recursive: true }); + symlinkSync(shim, alias); + shim = alias; + } + } + process.env.PATH = path.join(tempDir, "no-system-node"); + + const result = await runExecutable( + { path: shim, loader: "native", vitePlus: command, cwd: tempDir }, + true, + ); + strictEqual(result.command, process.execPath); + let expectedEntry = nodeEntry; + if (shimType === "npm") expectedEntry = shim; + if (shimType === "global-pnpm-symlink") expectedEntry = realpathSync(nodeEntry); + deepStrictEqual(result.args, [expectedEntry, command, "--lsp"]); + strictEqual(result.options?.env?.ELECTRON_RUN_AS_NODE, "1"); + // vp must reuse process.execPath for its subprocess, without PATH shims. + const child = spawnSync(result.command, result.args, { + ...result.options, + encoding: "utf8", + timeout: 5000, + }); + strictEqual(child.status, 0, child.stderr || child.error?.message); + deepStrictEqual(JSON.parse(child.stdout), [command, "--lsp"]); + }); + } + } + + test("keeps an unrecognized shell wrapper despite an adjacent Vite+ installation", async function () { + if (originalPlatform === "win32") this.skip(); + const shim = path.join(tempDir, "node_modules", ".bin", "vp"); + const adjacentEntry = path.join(tempDir, "node_modules", "vite-plus", "bin", "vp"); + mkdirSync(path.dirname(shim), { recursive: true }); + mkdirSync(path.dirname(adjacentEntry), { recursive: true }); + writeFileSync(shim, '#!/bin/sh\nprintf "%s\\n" "custom wrapper" "$@"\n', { mode: 0o755 }); + writeFileSync(adjacentEntry, "#!/usr/bin/env node\nthrow new Error('wrong entry');\n"); + process.env.PATH = path.join(tempDir, "no-system-node"); + + const result = await runExecutable( + { path: shim, loader: "native", vitePlus: "lint", cwd: tempDir }, + true, + ); + strictEqual(result.command, shim); + deepStrictEqual(result.args, ["lint", "--lsp"]); + const child = spawnSync(result.command, result.args, { + ...result.options, + encoding: "utf8", + timeout: 5000, + }); + strictEqual(child.status, 0, child.stderr || child.error?.message); + strictEqual(child.stdout, "custom wrapper\nlint\n--lsp\n"); + }); + + test("keeps a global shim when its recorded target is not a Node entry", async () => { + const shim = path.join(tempDir, "vp.cmd"); + writeFileSync(shim, '@echo off\r\n"%~dp0\\native-vp" %*\r\n'); + writeFileSync(path.join(tempDir, "native-vp"), Buffer.from([0x7f, 0x45, 0x4c, 0x46])); + const adjacentEntry = path.join(tempDir, "node_modules", "vite-plus", "bin", "vp"); + mkdirSync(path.dirname(adjacentEntry), { recursive: true }); + writeFileSync(adjacentEntry, "#!/usr/bin/env node\n"); + + const result = await runExecutable({ path: shim, loader: "native", vitePlus: "lint" }, true); + strictEqual(result.command, process.platform === "win32" ? `"${shim}"` : shim); + deepStrictEqual(result.args, ["lint", "--lsp"]); + }); + + test("keeps a standalone native vp executable with useExecPath", async () => { + const vpPath = path.join(tempDir, "node_modules", ".bin", "vp"); + const nodeEntry = path.join(tempDir, "node_modules", "vite-plus", "bin", "vp"); + mkdirSync(path.dirname(vpPath), { recursive: true }); + mkdirSync(path.dirname(nodeEntry), { recursive: true }); + writeFileSync(vpPath, Buffer.from([0x7f, 0x45, 0x4c, 0x46])); + writeFileSync(nodeEntry, "#!/usr/bin/env node\n"); + const result = await runExecutable({ path: vpPath, loader: "native", vitePlus: "lint" }, true); + strictEqual(result.command, process.platform === "win32" ? `"${vpPath}"` : vpPath); + deepStrictEqual(result.args, ["lint", "--lsp"]); + }); + + test("quotes Windows vp.cmd paths and passes the subcommand through the shell", async () => { + setPlatform("win32"); + const result = await runExecutable({ + path: "C:\\My Project\\node_modules\\.bin\\vp.cmd", + loader: "native", + vitePlus: "fmt", + }); + strictEqual(result.command, '"C:\\My Project\\node_modules\\.bin\\vp.cmd"'); + strictEqual(result.options?.shell, true); + deepStrictEqual(result.args, ["fmt", "--lsp"]); }); test("should create Node.js executable for .js files", async () => { @@ -62,7 +265,7 @@ suite("runExecutable", () => { }); test("should use shell on Windows for binary executables", async () => { - Object.defineProperty(process, "platform", { value: "win32" }); + setPlatform("win32"); const result = await runExecutable({ path: "C:\\Path With Spaces\\oxc-language-server", @@ -73,7 +276,7 @@ suite("runExecutable", () => { }); test("should prepend nodePath to PATH", async () => { - Object.defineProperty(process, "platform", { value: "linux" }); + setPlatform("linux"); process.env.PATH = "/usr/bin:/bin"; const result = await runExecutable( @@ -90,7 +293,7 @@ suite("runExecutable", () => { }); test("should set path in quotes on Windows for binary executables", async () => { - Object.defineProperty(process, "platform", { value: "win32" }); + setPlatform("win32"); const result = await runExecutable({ path: "C:\\Path With Spaces\\oxc-language-server", diff --git a/tests/unit/resolveVitePlusNodeEntry.spec.ts b/tests/unit/resolveVitePlusNodeEntry.spec.ts new file mode 100644 index 0000000..ab9e71f --- /dev/null +++ b/tests/unit/resolveVitePlusNodeEntry.spec.ts @@ -0,0 +1,55 @@ +import { strictEqual } from "node:assert"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import * as path from "node:path"; +import { runInNewContext } from "node:vm"; + +// Exercise the compiled resolver with Windows path semantics on every CI platform. +// These tests do not claim to exercise Windows process creation. +suite("Windows Vite+ shim targets", () => { + const filename = path.join(__dirname, "../client/resolveVitePlusNodeEntry.js"); + const source = readFileSync(filename, "utf8"); + const realRequire = createRequire(filename); + const shimPath = "C:\\global bin\\vp.cmd"; + + for (const target of [ + "C:\\global store\\5\\node_modules\\vite-plus\\bin\\vp", + "D:\\global store\\5\\node_modules\\vite-plus\\bin\\vp", + "\\\\server\\share\\vite-plus\\bin\\vp", + ]) { + test(`resolves an absolute target: ${target}`, () => { + const files = new Map([ + [shimPath, `@echo off\r\nnode "${target}" %*\r\n`], + [target, "#!/usr/bin/env node\n"], + ]); + function read(file: string): string { + const contents = files.get(file); + if (contents === undefined) throw new Error(`ENOENT: ${file}`); + return contents; + } + const exports: typeof import("../../client/resolveVitePlusNodeEntry") = runInNewContext( + `${source}\nexports;`, + { + Buffer, + exports: {}, + require(id: string) { + if (id === "node:path") return path.win32; + if (id === "node:fs") { + return { + openSync: (file: string) => file, + closeSync() {}, + readFileSync: read, + realpathSync: (file: string) => file, + readSync(file: string, buffer: Buffer, offset: number, length: number) { + return buffer.write(read(file).slice(0, length), offset, length, "utf8"); + }, + }; + } + return realRequire(id); + }, + }, + ); + strictEqual(exports.resolveVitePlusNodeEntry(shimPath), target); + }); + } +}); diff --git a/tests/unit/vitePlus.spec.ts b/tests/unit/vitePlus.spec.ts new file mode 100644 index 0000000..2070c0b --- /dev/null +++ b/tests/unit/vitePlus.spec.ts @@ -0,0 +1,533 @@ +import { deepStrictEqual, rejects, strictEqual } from "assert"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import * as path from "node:path"; +import { mock } from "node:test"; +import { commands, ConfigurationTarget, Uri, window, workspace } from "vscode"; +import { ConfigService } from "../../client/ConfigService"; +import type { BinarySearchResult } from "../../client/findBinary"; +import { runExecutable } from "../../client/tools/lsp_helper"; +import { WORKSPACE_FOLDER, WORKSPACE_SECOND_FOLDER } from "../test-helpers"; +import { mockProcessEnv, mockProcessPlatform } from "../processMocks"; + +// Mock the shared CommonJS exports used by the compiled test modules. +const shellEnv: typeof import("../../client/getShellEnv") = require( + path.join(__dirname, "../client/getShellEnv.js"), +); +const findBinary: typeof import("../../client/findBinary") = require( + path.join(__dirname, "../client/findBinary.js"), +); + +suite("Vite+ server selection", () => { + const root = path.join(WORKSPACE_FOLDER.uri.fsPath, "vite-plus-tests"); + const secondRoot = + WORKSPACE_SECOND_FOLDER && path.join(WORKSPACE_SECOND_FOLDER.uri.fsPath, "vite-plus-tests"); + const conf = workspace.getConfiguration("oxc", WORKSPACE_FOLDER.uri); + const setPlatform = mockProcessPlatform(); + mockProcessEnv(); + let service: ConfigService; + + function file(relative: string, content = "", dir = root): string { + const target = path.join(dir, relative); + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync(target, content); + return target; + } + + function declare(dir = root): void { + file("package.json", JSON.stringify({ devDependencies: { "vite-plus": "latest" } }), dir); + } + + function shim(dir = root): string { + return file( + path.join("node_modules/.bin", process.platform === "win32" ? "vp.cmd" : "vp"), + "", + dir, + ); + } + + function standaloneTools(): Record<"oxlint" | "oxfmt", BinarySearchResult> { + const binaries = { + oxlint: { path: file("tools/oxlint.js"), loader: "node" as const }, + oxfmt: { path: file("tools/oxfmt.js"), loader: "node" as const }, + }; + mock.method( + findBinary, + "searchProjectNodeModulesBin", + async (name: "oxlint" | "oxfmt") => binaries[name], + ); + return binaries; + } + + async function sources(lint: string, fmt: string): Promise { + await Promise.all([ + conf.update("lint.binarySource", lint, ConfigurationTarget.WorkspaceFolder), + conf.update("fmt.binarySource", fmt, ConfigurationTarget.WorkspaceFolder), + ]); + } + + async function open(dir = root): Promise { + await window.showTextDocument(Uri.file(file("index.txt", "", dir))); + } + + setup(async () => { + findBinary.clearGlobalNodeModulesPathsCache(); + mock.method(shellEnv, "getShellEnv", async () => ({ PATH: process.env.PATH })); + file("pnpm-workspace.yaml"); + await open(); + service = new ConfigService(); + }); + + teardown(async () => { + service.dispose(); + findBinary.clearGlobalNodeModulesPathsCache(); + mock.restoreAll(); + await commands.executeCommand("workbench.action.closeAllEditors"); + for (const folder of [WORKSPACE_FOLDER, WORKSPACE_SECOND_FOLDER]) { + if (!folder) continue; + const config = workspace.getConfiguration("oxc", folder.uri); + // oxlint-disable-next-line no-await-in-loop -- reset each workspace folder + await Promise.all( + ["path.vp", "lint.binarySource", "fmt.binarySource"].map((key) => + config.update(key, undefined, ConfigurationTarget.WorkspaceFolder), + ), + ); + } + await workspace.getConfiguration("oxc").update("path.oxlint", undefined); + await workspace.getConfiguration("oxc").update("path.oxfmt", undefined); + rmSync(root, { recursive: true, force: true }); + if (secondRoot) rmSync(secondRoot, { recursive: true, force: true }); + }); + + test("uses vp lint/fmt for an active nested package", async () => { + declare(); + const vpPath = shim(); + const [lint, fmt] = await Promise.all([ + service.getOxlintServerBinPath(), + service.getOxfmtServerBinPath(), + ]); + deepStrictEqual(lint, { path: vpPath, loader: "native", cwd: root, vitePlus: "lint" }); + deepStrictEqual(fmt, { path: vpPath, loader: "native", cwd: root, vitePlus: "fmt" }); + }); + + test("explicit sources work without a package.json dependency", async () => { + const vpPath = shim(); + await sources("vite-plus", "vite-plus"); + strictEqual((await service.getOxlintServerBinPath())?.path, vpPath); + strictEqual((await service.getOxfmtServerBinPath())?.vitePlus, "fmt"); + }); + + test("explicit sources keep the same executable and cwd across source directories", async () => { + file("package.json", "{}"); + const vpPath = shim(); + await sources("vite-plus", "vite-plus"); + await open(path.join(root, "src/pages")); + const first = await service.getOxlintServerBinPath(); + await open(path.join(root, "src/components")); + deepStrictEqual(await service.getOxlintServerBinPath(), first); + deepStrictEqual(first, { path: vpPath, loader: "native", cwd: root, vitePlus: "lint" }); + strictEqual((await service.getOxfmtServerBinPath())?.cwd, root); + }); + + test("an explicit vp path opts in and resolves against the workspace folder", async () => { + const vpPath = file("bin/custom-vp.js"); + await conf.update( + "path.vp", + "./vite-plus-tests/bin/custom-vp.js", + ConfigurationTarget.WorkspaceFolder, + ); + deepStrictEqual(await service.getOxlintServerBinPath(), { + path: vpPath, + loader: "node", + cwd: WORKSPACE_FOLDER.uri.fsPath, + vitePlus: "lint", + }); + await commands.executeCommand("workbench.action.closeAllEditors"); + strictEqual( + (await service.getOxfmtServerBinPath())?.path, + vpPath, + "uses workspace folders when no document is active", + ); + }); + + test("recognizes the extensionless vite-plus JavaScript entry point", async () => { + const vpPath = file("node_modules/vite-plus/bin/vp"); + await conf.update("path.vp", vpPath, ConfigurationTarget.WorkspaceFolder); + strictEqual((await service.getOxfmtServerBinPath())?.loader, "node"); + }); + + test("invalid explicit vp paths give an error instead of falling back", async () => { + declare(); + shim(); + await conf.update("path.vp", "./missing-vp", ConfigurationTarget.WorkspaceFolder); + await rejects(service.getOxlintServerBinPath(), /Invalid Vite\+ binary.*oxc.path.vp/); + await conf.update("path.vp", "../unsafe-vp", ConfigurationTarget.WorkspaceFolder); + await rejects(service.getOxfmtServerBinPath(), /Invalid Vite\+ binary/); + }); + + test("explicit tool paths take priority over Vite+ for each tool", async () => { + declare(); + shim(); + await sources("vite-plus", "vite-plus"); + const lintPath = file("custom/oxlint.js"); + const fmtPath = file("custom/oxfmt.js"); + await workspace.getConfiguration("oxc").update("path.oxlint", lintPath); + strictEqual((await service.getOxlintServerBinPath())?.path, lintPath); + strictEqual((await service.getOxlintServerBinPath())?.vitePlus, undefined); + strictEqual((await service.getOxfmtServerBinPath())?.vitePlus, "fmt"); + await workspace.getConfiguration("oxc").update("path.oxfmt", fmtPath); + strictEqual((await service.getOxfmtServerBinPath())?.path, fmtPath); + strictEqual((await service.getOxfmtServerBinPath())?.vitePlus, undefined); + }); + + test("standalone sources ignore detection and an invalid explicit vp path", async () => { + declare(); + shim(); + const binaries = standaloneTools(); + await conf.update("path.vp", "./missing-vp", ConfigurationTarget.WorkspaceFolder); + await sources("oxc", "oxc"); + deepStrictEqual(await service.getOxlintServerBinPath(), binaries.oxlint); + deepStrictEqual(await service.getOxfmtServerBinPath(), binaries.oxfmt); + }); + + for (const lintSource of ["auto", "vite-plus", "oxc"]) { + for (const fmtSource of ["auto", "vite-plus", "oxc"]) { + test(`selects tools independently: lint=${lintSource}, fmt=${fmtSource}`, async () => { + declare(); + const vpPath = shim(); + const binaries = standaloneTools(); + await sources(lintSource, fmtSource); + const [lint, fmt] = await Promise.all([ + service.getOxlintServerBinPath(), + service.getOxfmtServerBinPath(), + ]); + deepStrictEqual( + lint, + lintSource === "oxc" + ? binaries.oxlint + : { path: vpPath, loader: "native", cwd: root, vitePlus: "lint" }, + ); + deepStrictEqual( + fmt, + fmtSource === "oxc" + ? binaries.oxfmt + : { path: vpPath, loader: "native", cwd: root, vitePlus: "fmt" }, + ); + }); + } + } + + for (const command of ["lint", "fmt"] as const) { + test(`forcing ${command} does not opt the other tool in without a dependency`, async () => { + const vpPath = shim(); + const binaries = standaloneTools(); + await conf.update( + `${command}.binarySource`, + "vite-plus", + ConfigurationTarget.WorkspaceFolder, + ); + const [lint, fmt] = await Promise.all([ + service.getOxlintServerBinPath(), + service.getOxfmtServerBinPath(), + ]); + if (command === "lint") { + strictEqual(lint?.path, vpPath); + deepStrictEqual(fmt, binaries.oxfmt); + } else { + deepStrictEqual(lint, binaries.oxlint); + strictEqual(fmt?.path, vpPath); + } + }); + + test(`an explicit vp path respects the standalone ${command} source`, async () => { + const vpPath = file("bin/custom-vp.js"); + const binaries = standaloneTools(); + await conf.update("path.vp", vpPath, ConfigurationTarget.WorkspaceFolder); + await conf.update(`${command}.binarySource`, "oxc", ConfigurationTarget.WorkspaceFolder); + const [lint, fmt] = await Promise.all([ + service.getOxlintServerBinPath(), + service.getOxfmtServerBinPath(), + ]); + if (command === "lint") { + deepStrictEqual(lint, binaries.oxlint); + strictEqual(fmt?.path, vpPath); + } else { + strictEqual(lint?.path, vpPath); + deepStrictEqual(fmt, binaries.oxfmt); + } + }); + } + + test("root-declared-no-local-global-on-path", async () => { + declare(); + const binDir = path.join(root, "global-bin"); + const vpPath = file(process.platform === "win32" ? "vp.cmd" : "vp", "", binDir); + process.env.PATH = binDir; + deepStrictEqual(await service.getOxlintServerBinPath(), { + path: vpPath, + loader: "native", + cwd: root, + vitePlus: "lint", + }); + const localPath = shim(); + strictEqual( + (await service.getOxlintServerBinPath())?.path, + localPath, + "local install must take priority after a restart", + ); + }); + + test("global-vp-without-declaration", async () => { + const binDir = path.join(root, "global-bin"); + file(process.platform === "win32" ? "vp.cmd" : "vp", "", binDir); + process.env.PATH = binDir; + strictEqual((await service.getOxlintServerBinPath())?.vitePlus, undefined); + strictEqual((await service.getOxfmtServerBinPath())?.vitePlus, undefined); + await conf.update("lint.binarySource", "vite-plus", ConfigurationTarget.WorkspaceFolder); + strictEqual( + (await service.getOxlintServerBinPath())?.vitePlus, + "lint", + "explicit opt-in permits global resolution without a dependency", + ); + strictEqual((await service.getOxfmtServerBinPath())?.vitePlus, undefined); + }); + + test("discovers global vp using the same shell PATH as the launcher", async () => { + declare(); + const shellBin = path.join(root, "shell-bin"); + const vpPath = file(process.platform === "win32" ? "vp.cmd" : "vp", "", shellBin); + process.env.PATH = path.join(root, "inherited-bin"); + mock.method(shellEnv, "getShellEnv", async () => ({ PATH: shellBin })); + const binary = await service.getOxlintServerBinPath(); + strictEqual(binary?.path, vpPath); + strictEqual((await runExecutable(binary!)).options?.env?.PATH, shellBin); + strictEqual( + process.env.PATH, + path.join(root, "inherited-bin"), + "shell discovery must not mutate the extension host environment", + ); + }); + + for (const extension of ["cmd", "exe"]) { + test(`discovers global vp.${extension} from a Windows Path environment key`, async () => { + declare(); + const shellBin = path.join(root, "shell-bin"); + const vpPath = file(`vp.${extension}`, "", shellBin); + setPlatform("win32"); + for (const key of Object.keys(process.env)) { + if (key.toUpperCase() === "PATH") delete process.env[key]; + } + process.env.Path = shellBin; + // Use a fresh instance of the real provider to exercise its environment copy. + const { getShellEnv } = await import(`../../client/getShellEnv.ts?windowsPath=${extension}`); + mock.method(shellEnv, "getShellEnv", getShellEnv); + + const binaries = await Promise.all([ + service.getOxlintServerBinPath(), + service.getOxfmtServerBinPath(), + ]); + await Promise.all( + binaries.map(async (binary) => { + strictEqual(binary?.path, vpPath); + const executable = await runExecutable(binary!, true); + strictEqual( + executable.options?.env?.PATH, + `${path.dirname(process.execPath)}${path.delimiter}${shellBin}`, + ); + strictEqual(executable.options?.env?.Path, undefined); + }), + ); + strictEqual(process.env.Path, shellBin); + strictEqual(process.env.PATH, undefined); + }); + } + + test("missing local and global installs give an install hint, even if plain tools exist", async () => { + declare(); + process.env.PATH = root; + mock.method(require("node:child_process"), "spawnSync", () => ({ status: 1 })); + mock.method(require("node:os"), "homedir", () => root); + await rejects(service.getOxlintServerBinPath(), /Vite\+ selected.*pnpm install/); + await rejects(service.getOxfmtServerBinPath(), /Vite\+ selected.*pnpm install/); + const vpPath = shim(); + strictEqual( + (await service.getOxlintServerBinPath())?.path, + vpPath, + "missing installs must not be cached", + ); + }); + + test("explicit Vite+ sources report a missing install without falling back to standalone tools", async () => { + standaloneTools(); + process.env.PATH = root; + mock.method(require("node:child_process"), "spawnSync", () => ({ status: 1 })); + mock.method(require("node:os"), "homedir", () => root); + await sources("vite-plus", "vite-plus"); + await rejects(service.getOxlintServerBinPath(), /Vite\+ selected.*pnpm install/); + await rejects(service.getOxfmtServerBinPath(), /Vite\+ selected.*pnpm install/); + }); + + test("resolves vp from a global vite-plus package, not a package named vp", async () => { + declare(); + process.env.PATH = root; + const globalModules = path.join(root, "global/node_modules"); + file( + "vite-plus/package.json", + JSON.stringify({ name: "vite-plus", bin: { vp: "bin/vp" } }), + globalModules, + ); + const vpPath = file("vite-plus/bin/vp", "", globalModules); + mock.method(require("node:child_process"), "spawnSync", () => ({ + status: 0, + stdout: globalModules, + })); + deepStrictEqual(await service.getOxfmtServerBinPath(), { + path: vpPath, + loader: "node", + cwd: root, + vitePlus: "fmt", + }); + }); + + test("reselects when navigating between Vite+ and plain workspace folders", async function () { + if (!secondRoot) this.skip(); + declare(); + const vpPath = shim(); + strictEqual((await service.getOxlintServerBinPath())?.path, vpPath); + file("pnpm-workspace.yaml", "", secondRoot!); + await open(secondRoot!); + strictEqual((await service.getOxlintServerBinPath())?.vitePlus, undefined); + await open(); + strictEqual((await service.getOxlintServerBinPath())?.path, vpPath); + }); + + test("explicit settings are scoped to the active workspace folder", async function () { + if (!secondRoot || !WORKSPACE_SECOND_FOLDER) this.skip(); + const firstPath = file("bin/vp.js"); + const secondPath = file("bin/vp.js", "", secondRoot!); + await conf.update( + "path.vp", + "./vite-plus-tests/bin/vp.js", + ConfigurationTarget.WorkspaceFolder, + ); + await workspace + .getConfiguration("oxc", WORKSPACE_SECOND_FOLDER!.uri) + .update("path.vp", "./vite-plus-tests/bin/vp.js", ConfigurationTarget.WorkspaceFolder); + strictEqual((await service.getOxlintServerBinPath())?.path, firstPath); + await open(secondRoot!); + strictEqual((await service.getOxlintServerBinPath())?.path, secondPath); + strictEqual((await service.getOxfmtServerBinPath())?.cwd, WORKSPACE_SECOND_FOLDER!.uri.fsPath); + }); + + test("source settings follow the active workspace folder independently for each tool", async function () { + if (!secondRoot || !WORKSPACE_SECOND_FOLDER) this.skip(); + declare(); + declare(secondRoot!); + const firstPath = shim(); + const secondPath = shim(secondRoot!); + const binaries = standaloneTools(); + await sources("oxc", "vite-plus"); + const secondConf = workspace.getConfiguration("oxc", WORKSPACE_SECOND_FOLDER!.uri); + await secondConf.update("lint.binarySource", "vite-plus", ConfigurationTarget.WorkspaceFolder); + await secondConf.update("fmt.binarySource", "oxc", ConfigurationTarget.WorkspaceFolder); + deepStrictEqual(await service.getOxlintServerBinPath(), binaries.oxlint); + strictEqual((await service.getOxfmtServerBinPath())?.path, firstPath); + await open(secondRoot!); + strictEqual((await service.getOxlintServerBinPath())?.path, secondPath); + deepStrictEqual(await service.getOxfmtServerBinPath(), binaries.oxfmt); + await open(); + deepStrictEqual(await service.getOxlintServerBinPath(), binaries.oxlint); + strictEqual((await service.getOxfmtServerBinPath())?.path, firstPath); + }); + + test("a refresh replaces pending discovery without the old search clearing the new one", async () => { + const vpPath = file("bin/vp.js"); + await conf.update("path.vp", vpPath, ConfigurationTarget.WorkspaceFolder); + let releaseFirst!: () => void; + let releaseSecond!: () => void; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + const secondGate = new Promise((resolve) => { + releaseSecond = resolve; + }); + const searchSettingsBin = findBinary.searchSettingsBin; + let lookups = 0; + mock.method( + findBinary, + "searchSettingsBin", + async (name: string, configuredPath: string, cwd?: string) => { + await (++lookups === 1 ? firstGate : secondGate); + return searchSettingsBin(name, configuredPath, cwd); + }, + ); + const first = service.getOxlintServerBinPath(); + service.clearBinarySearchCaches(); + const second = service.getOxlintServerBinPath(); + try { + strictEqual(lookups, 2, "refresh must start a new search"); + releaseFirst(); + await first; + const third = service.getOxfmtServerBinPath(); + strictEqual(lookups, 2, "lint and format must still share the refreshed search"); + releaseSecond(); + strictEqual((await second)?.path, vpPath); + strictEqual((await third)?.path, vpPath); + } finally { + releaseFirst(); + releaseSecond(); + await Promise.all([first, second]); + } + }); + + for (const change of ["none", "folder", "setting", "source"] as const) { + test(`shares an ongoing search only when its context is unchanged (${change})`, async function () { + if (change === "folder" && (!secondRoot || !WORKSPACE_SECOND_FOLDER)) this.skip(); + const firstPath = file("bin/first-vp.js"); + const secondPath = file("bin/second-vp.js", "", change === "folder" ? secondRoot! : root); + const standalone = change === "source" ? standaloneTools() : undefined; + await conf.update("path.vp", firstPath, ConfigurationTarget.WorkspaceFolder); + if (change === "folder") { + await workspace + .getConfiguration("oxc", WORKSPACE_SECOND_FOLDER!.uri) + .update("path.vp", secondPath, ConfigurationTarget.WorkspaceFolder); + } + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const searchSettingsBin = findBinary.searchSettingsBin; + let firstPathLookups = 0; + mock.method( + findBinary, + "searchSettingsBin", + async (name: string, configuredPath: string, cwd?: string) => { + if (configuredPath === firstPath) { + firstPathLookups++; + await gate; + } + return searchSettingsBin(name, configuredPath, cwd); + }, + ); + const first = service.getOxlintServerBinPath(); + let second: ReturnType; + try { + if (change === "folder") await open(secondRoot!); + if (change === "setting") { + await conf.update("path.vp", secondPath, ConfigurationTarget.WorkspaceFolder); + } + if (change === "source") { + await conf.update("fmt.binarySource", "oxc", ConfigurationTarget.WorkspaceFolder); + } + second = service.getOxfmtServerBinPath(); + } finally { + release(); + } + strictEqual((await first)?.path, firstPath); + strictEqual( + (await second!)?.path, + standalone?.oxfmt.path ?? (change === "none" ? firstPath : secondPath), + ); + strictEqual(firstPathLookups, 1, "matching lint/fmt requests should share discovery"); + }); + } +}); diff --git a/tests/unit/vitePlusLifecycle.spec.ts b/tests/unit/vitePlusLifecycle.spec.ts new file mode 100644 index 0000000..cf618af --- /dev/null +++ b/tests/unit/vitePlusLifecycle.spec.ts @@ -0,0 +1,241 @@ +import { deepStrictEqual, notStrictEqual, ok, rejects, strictEqual } from "assert"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import * as path from "node:path"; +import { mock } from "node:test"; +import { + CancellationTokenSource, + ConfigurationTarget, + LogOutputChannel, + window, + workspace, +} from "vscode"; +import type { LanguageClient } from "vscode-languageclient/node"; +import { ConfigService } from "../../client/ConfigService"; +import { + BinarySearchResult, + clearGlobalNodeModulesPathsCache, + searchGlobalNodeModulesBin, +} from "../../client/findBinary"; +import StatusBarItemHandler from "../../client/StatusBarItemHandler"; +import Formatter from "../../client/tools/formatter"; +import Linter from "../../client/tools/linter"; +import { WORKSPACE_FOLDER } from "../test-helpers"; + +function getWorkspaceOptions(settings: { workspaceUri: string; options: unknown }[]): unknown { + return settings.find(({ workspaceUri }) => workspaceUri === WORKSPACE_FOLDER.uri.toString())! + .options; +} + +for (const [Tool, command, getter] of [ + [Linter, "lint", "getOxlintServerBinPath"], + [Formatter, "fmt", "getOxfmtServerBinPath"], +] as const) { + suite(`Vite+ ${command} lifecycle`, () => { + const root = path.join(WORKSPACE_FOLDER.uri.fsPath, `vp-${command}-lifecycle`); + const nestedConfigKey = command === "lint" ? "disableNestedConfig" : "fmt.disableNestedConfig"; + let service: ConfigService; + let tool: Linter | Formatter; + let output: LogOutputChannel; + let status: StatusBarItemHandler; + let selected: BinarySearchResult; + + function assertNestedConfig(options: unknown, expected: boolean): void { + const values = options as Record; + strictEqual(values[nestedConfigKey], expected); + if (command === "lint") { + strictEqual( + (values.flags as Record).disable_nested_config, + String(expected), + ); + } + } + + setup(async () => { + // No server needs to run to exercise client replacement on navigation. + await workspace.getConfiguration("oxc").update("enable", false); + mkdirSync(root, { recursive: true }); + const vpPath = path.join(root, "vp.cjs"); + writeFileSync(vpPath, ""); + selected = { path: vpPath, loader: "node", vitePlus: command, cwd: root }; + service = new ConfigService(); + mock.method(service, getter, async () => selected); + output = window.createOutputChannel(`Vite+ ${command} lifecycle`, { log: true }); + status = new StatusBarItemHandler("test"); + tool = new Tool(output, service, status); + await tool.activate(selected); + }); + + teardown(async () => { + await tool.deactivate(); + tool.dispose(); + service.dispose(); + output.dispose(); + status.dispose(); + mock.restoreAll(); + clearGlobalNodeModulesPathsCache(); + await workspace.getConfiguration("oxc").update("enable", undefined); + await workspace + .getConfiguration("oxc", WORKSPACE_FOLDER.uri) + .update(nestedConfigKey, undefined, ConfigurationTarget.WorkspaceFolder); + rmSync(root, { recursive: true, force: true }); + }); + + for (const userSetting of [false, true]) { + test(`disables nested configs only for Vite+ with user setting ${userSetting}`, async () => { + const config = workspace.getConfiguration("oxc", WORKSPACE_FOLDER.uri); + await config.update(nestedConfigKey, userSetting, ConfigurationTarget.WorkspaceFolder); + service.getWorkspaceConfig(WORKSPACE_FOLDER.uri)!.refresh(); + const event = { + affectsConfiguration: (section: string) => section === `oxc.${nestedConfigKey}`, + }; + + async function checkClientConfig(expected: boolean): Promise { + const client = (tool as unknown as { client: LanguageClient }).client; + + assertNestedConfig( + getWorkspaceOptions(client.clientOptions.initializationOptions), + expected, + ); + const cancellation = new CancellationTokenSource(); + try { + const pulled = await client.clientOptions.middleware!.workspace!.configuration!( + { + items: [ + { section: "oxc_language_server", scopeUri: WORKSPACE_FOLDER.uri.toString() }, + { section: "unrelated", scopeUri: WORKSPACE_FOLDER.uri.toString() }, + { section: "oxc_language_server" }, + ], + }, + cancellation.token, + async () => [], + ); + ok(Array.isArray(pulled)); + assertNestedConfig(pulled[0], expected); + deepStrictEqual(pulled.slice(1), [null, null]); + } finally { + cancellation.dispose(); + } + + const running = mock.method(client, "isRunning", () => true); + const notification = mock.method(client, "sendNotification", async () => {}); + try { + await tool.onConfigChange(event); + strictEqual(notification.mock.callCount(), 1); + const [method, params] = notification.mock.calls[0].arguments; + strictEqual(method, "workspace/didChangeConfiguration"); + assertNestedConfig(getWorkspaceOptions(params.settings), expected); + assertNestedConfig( + getWorkspaceOptions(client.clientOptions.initializationOptions), + expected, + ); + } finally { + running.mock.restore(); + notification.mock.restore(); + } + strictEqual( + workspace.getConfiguration("oxc", WORKSPACE_FOLDER.uri).get(nestedConfigKey), + userSetting, + "the saved setting must not change", + ); + } + + await checkClientConfig(true); + const vitePlus = selected; + selected = { path: selected.path, loader: "node" }; + await tool.restart(true); + await checkClientConfig(userSetting); + selected = vitePlus; + await tool.restart(true); + await checkClientConfig(true); + }); + } + + test("keeps the client for unchanged binaries and replaces it when the project or mode changes", async () => { + const activation = mock.method(tool, "activate", tool.activate.bind(tool)); + await tool.restart(true); + strictEqual(activation.mock.callCount(), 0); + + selected = { ...selected, cwd: path.join(root, "another-project") }; + await tool.restart(true); + strictEqual(activation.mock.callCount(), 1); + + selected = { path: selected.path, loader: "node" }; + await tool.restart(true); + strictEqual(activation.mock.callCount(), 2); + + await tool.restart(); + strictEqual( + activation.mock.callCount(), + 3, + "the restart command must allow an unchanged binary", + ); + }); + + test("replaces a client after its server fails to launch", async () => { + mock.getter(service.vsCodeConfig, "useExecPath", () => false); + mock.getter(service.vsCodeConfig, "nodePath", () => path.join(root, "missing-node")); + await tool.restart(); + const failedClient = (tool as unknown as { client: LanguageClient }).client; + await rejects(failedClient.start()); + + await tool.restart(); + const replacement = (tool as unknown as { client: LanguageClient }).client; + notStrictEqual(replacement, failedClient); + ok(replacement, "a failed startup must not prevent the next activation"); + }); + + test("navigation reuses global locations and an explicit restart refreshes them", async () => { + const name = command === "lint" ? "oxlint" : "oxfmt"; + const firstModules = path.join(root, "first", "node_modules"); + const secondModules = path.join(root, "second", "node_modules"); + for (const dir of [firstModules, secondModules]) { + mkdirSync(path.join(dir, ".bin"), { recursive: true }); + writeFileSync(path.join(dir, ".bin", name), ""); + } + let globalModules = firstModules; + const probes = mock.method(require("node:child_process"), "spawnSync", () => ({ + status: 0, + stdout: globalModules, + })); + mock.method(service, getter, () => searchGlobalNodeModulesBin(name)); + const activation = mock.method(tool, "activate", tool.activate.bind(tool)); + await tool.restart(); + strictEqual( + activation.mock.calls[0].arguments[0]?.path, + path.join(firstModules, ".bin", name), + ); + strictEqual(probes.mock.callCount(), 2); + + globalModules = secondModules; + await tool.restart(true); + strictEqual(activation.mock.callCount(), 1); + strictEqual(probes.mock.callCount(), 2, "navigation must reuse the known locations"); + + await tool.restart(); + strictEqual( + activation.mock.calls[1].arguments[0]?.path, + path.join(secondModules, ".bin", name), + ); + strictEqual(probes.mock.callCount(), 4, "explicit restarts must query the locations again"); + }); + + test("waits for an ongoing restart before processing another restart or shutdown", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const activate = tool.activate.bind(tool); + const activation = mock.method(tool, "activate", async (binary?: BinarySearchResult) => { + await gate; + await activate(binary); + }); + const first = tool.restart(); + const second = tool.restart(); + const shutdown = tool.deactivate(); + release(); + await Promise.all([first, second, shutdown]); + strictEqual(activation.mock.callCount(), 2); + strictEqual(tool.getLspVersion(), undefined); + }); + }); +}