diff --git a/editors/vscode/client/ConfigService.ts b/editors/vscode/client/ConfigService.ts index c60a687a065c4..3ebba13c0399f 100644 --- a/editors/vscode/client/ConfigService.ts +++ b/editors/vscode/client/ConfigService.ts @@ -1,4 +1,5 @@ import * as path from "node:path"; +import { promises as fs } from "node:fs"; import { ConfigurationChangeEvent, RelativePattern, Uri, workspace, WorkspaceFolder } from "vscode"; import { DiagnosticPullMode } from "vscode-languageclient"; import { validateSafeBinaryPath } from "./PathValidator"; @@ -113,20 +114,8 @@ export class ConfigService implements IDisposable { settingsBinary: string | undefined, defaultPattern: string, ): Promise { - const cwd = this.workspaceConfigs.keys().next().value; - if (!cwd) { - return undefined; - } - if (!settingsBinary) { - // try to find the binary in node_modules/.bin, resolve to the first workspace folder - const files = await workspace.findFiles( - new RelativePattern(cwd, `**/node_modules/.bin/${defaultPattern}`), - null, - 1, - ); - - return files.length > 0 ? files[0].fsPath : undefined; + return this.searchBinaryInWorkspaces(defaultPattern); } if (!workspace.isTrusted) { @@ -140,6 +129,10 @@ export class ConfigService implements IDisposable { if (!path.isAbsolute(settingsBinary)) { // if the path is not absolute, resolve it to the first workspace folder + const cwd = this.workspaceConfigs.keys().next().value; + if (!cwd) { + return undefined; + } settingsBinary = path.normalize(path.join(cwd, settingsBinary)); // strip the leading slash on Windows if (process.platform === "win32" && settingsBinary.startsWith("\\")) { @@ -150,6 +143,51 @@ export class ConfigService implements IDisposable { return settingsBinary; } + /** + * Search for binary in all workspace folders, using optimized strategy: + * 1. Check direct paths in all workspace node_modules/.bin/ first + * 2. Fall back to recursive glob search only if needed + */ + private async searchBinaryInWorkspaces(defaultPattern: string): Promise { + const workspacePaths = Array.from(this.workspaceConfigs.keys()); + + if (workspacePaths.length === 0) { + return undefined; + } + + // First, try direct path checks in all workspace folders + for (const workspacePath of workspacePaths) { + const directPath = path.join(workspacePath, "node_modules", ".bin", defaultPattern); + try { + await fs.access(directPath); + // Convert to proper file system path format (handles Windows path normalization) + return Uri.file(directPath).fsPath; + } catch { + // File doesn't exist, continue to next workspace + } + } + + // If direct path checks fail, fall back to recursive glob search + // Try each workspace folder in order + for (const workspacePath of workspacePaths) { + try { + const files = await workspace.findFiles( + new RelativePattern(workspacePath, `**/node_modules/.bin/${defaultPattern}`), + null, + 1, + ); + + if (files.length > 0) { + return files[0].fsPath; + } + } catch { + // Glob search failed (timeout, permission issues, etc.), try next workspace + } + } + + return undefined; + } + private async onVscodeConfigChange(event: ConfigurationChangeEvent): Promise { let isConfigChanged = false; diff --git a/editors/vscode/tests/unit/ConfigService.spec.ts b/editors/vscode/tests/unit/ConfigService.spec.ts index 4a6aadce646ec..66fa6de4863a3 100644 --- a/editors/vscode/tests/unit/ConfigService.spec.ts +++ b/editors/vscode/tests/unit/ConfigService.spec.ts @@ -1,5 +1,5 @@ import { strictEqual } from 'assert'; -import { workspace } from 'vscode'; +import { Uri, workspace, WorkspaceEdit } from 'vscode'; import { ConfigService } from '../../client/ConfigService.js'; import { WORKSPACE_FOLDER } from '../test-helpers.js'; @@ -110,4 +110,74 @@ suite('ConfigService', () => { strictEqual(relativeServerPath, `${workspace_path}\\relative\\oxlint`); }); }); + + suite('Binary Auto-Discovery', () => { + const createTestBinary = async (binaryName: string, workspaceUri: Uri = WORKSPACE_FOLDER.uri) => { + const binDir = Uri.joinPath(workspaceUri, 'node_modules', '.bin'); + const binaryUri = Uri.joinPath(binDir, binaryName); + const edit = new WorkspaceEdit(); + edit.createFile(binaryUri, { + contents: Buffer.from('#!/usr/bin/env node\nconsole.log("test");'), + overwrite: true, + }); + await workspace.applyEdit(edit); + return binaryUri.fsPath; + }; + + const cleanupTestBinary = async (binaryName: string, workspaceUri: Uri = WORKSPACE_FOLDER.uri) => { + const binDir = Uri.joinPath(workspaceUri, 'node_modules', '.bin'); + const binaryUri = Uri.joinPath(binDir, binaryName); + const edit = new WorkspaceEdit(); + edit.deleteFile(binaryUri, { ignoreIfNotExists: true }); + await workspace.applyEdit(edit); + }; + + teardown(async () => { + await cleanupTestBinary('oxlint'); + await cleanupTestBinary('oxfmt'); + }); + + test('finds binary in workspace node_modules/.bin/', async () => { + const expectedPath = await createTestBinary('oxlint'); + const service = new ConfigService(); + + const binaryPath = await service.getOxlintServerBinPath(); + + strictEqual(binaryPath, expectedPath); + }); + + test('returns undefined when binary not found', async () => { + const service = new ConfigService(); + + const binaryPath = await service.getOxlintServerBinPath(); + + strictEqual(binaryPath, undefined); + }); + + test('finds oxfmt binary in workspace', async () => { + const expectedPath = await createTestBinary('oxfmt'); + const service = new ConfigService(); + + const binaryPath = await service.getOxfmtServerBinPath(); + + strictEqual(binaryPath, expectedPath); + }); + + test('returns properly formatted file system path', async () => { + await createTestBinary('oxlint'); + const service = new ConfigService(); + + const binaryPath = await service.getOxlintServerBinPath(); + + strictEqual(typeof binaryPath, 'string'); + if (process.platform === 'win32') { + // On Windows, fsPath should use backslashes and have drive letter + strictEqual(binaryPath!.includes('/'), false, 'Windows path should not contain forward slashes'); + strictEqual(binaryPath![1], ':', 'Windows path should have drive letter'); + } else { + // On Unix, fsPath should use forward slashes + strictEqual(binaryPath!.startsWith('/'), true, 'Unix path should start with /'); + } + }); + }); });