Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 11 additions & 78 deletions editors/vscode/client/ConfigService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import * as path from "node:path";
import { ConfigurationChangeEvent, Uri, workspace, WorkspaceFolder } from "vscode";
import { DiagnosticPullMode } from "vscode-languageclient";
import { validateSafeBinaryPath } from "./PathValidator";
import {
searchGlobalNodeModulesBin,
searchProjectNodeModulesBin,
searchSettingsBin,
} from "./findBinary";
import { IDisposable } from "./types";
import { VSCodeConfig } from "./VSCodeConfig";
import {
Expand Down Expand Up @@ -113,84 +116,14 @@ export class ConfigService implements IDisposable {
settingsBinary: string | undefined,
defaultBinaryName: string,
): Promise<string | undefined> {
if (!settingsBinary) {
return this.searchNodeModulesBin(defaultBinaryName);
}

if (!workspace.isTrusted) {
return;
if (settingsBinary) {
return searchSettingsBin(settingsBinary);
}

// validates the given path is safe to use
if (!validateSafeBinaryPath(settingsBinary)) {
return undefined;
}

if (!path.isAbsolute(settingsBinary)) {
const cwd = this.workspaceConfigs.keys().next().value;
if (!cwd) {
return undefined;
}
// if the path is not absolute, resolve it to the first workspace folder
settingsBinary = path.normalize(path.join(cwd, settingsBinary));
settingsBinary = this.removeWindowsLeadingSlash(settingsBinary);
}

if (process.platform !== "win32" && settingsBinary.endsWith(".exe")) {
// on non-Windows, remove `.exe` extension if present
settingsBinary = settingsBinary.slice(0, -4);
}

try {
await workspace.fs.stat(Uri.file(settingsBinary));
return settingsBinary;
} catch {}

// on Windows, also check for `.exe` extension (bun uses `.exe` for its binaries)
if (process.platform === "win32") {
if (!settingsBinary.endsWith(".exe")) {
settingsBinary += ".exe";
}

try {
await workspace.fs.stat(Uri.file(settingsBinary));
return settingsBinary;
} catch {}
}

// no valid binary found
return undefined;
}

/**
* strip the leading slash on Windows
*/
private removeWindowsLeadingSlash(path: string): string {
if (process.platform === "win32" && path.startsWith("\\")) {
return path.slice(1);
}
return path;
}

/**
* Search for the binary in all workspaces' node_modules/.bin directories.
* If multiple workspaces contain the binary, the first one found is returned.
*/
private async searchNodeModulesBin(binaryName: string): Promise<string | undefined> {
// try to resolve via require.resolve
try {
const resolvedPath = require
.resolve(binaryName, {
paths: workspace.workspaceFolders?.map((folder) => folder.uri.fsPath) ?? [],
})
// we want to target the binary instead of the main index file
// Improvement: search inside package.json "bin" and `main` field for more reliability
.replace(
`${binaryName}${path.sep}dist${path.sep}index.js`,
`${binaryName}${path.sep}bin${path.sep}${binaryName}`,
);
return resolvedPath;
} catch {}
return (
(await searchProjectNodeModulesBin(defaultBinaryName)) ??
(await searchGlobalNodeModulesBin(defaultBinaryName))
);
}

private async onVscodeConfigChange(event: ConfigurationChangeEvent): Promise<void> {
Expand Down
130 changes: 130 additions & 0 deletions editors/vscode/client/findBinary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { spawnSync } from "node:child_process";
import { homedir } from "node:os";
import * as path from "node:path";
import { Uri, workspace } from "vscode";
import { validateSafeBinaryPath } from "./PathValidator";

function replaceTargetFromMainToBin(resolvedPath: string, binaryName: string): string {
// we want to target the binary instead of the main index file
// Improvement: search inside package.json "bin" and `main` field for more reliability
return resolvedPath.replace(
`${binaryName}${path.sep}dist${path.sep}index.js`,
`${binaryName}${path.sep}bin${path.sep}${binaryName}`,
);
}
/**
* Search for the binary in all workspaces' node_modules/.bin directories.
* If multiple workspaces contain the binary, the first one found is returned.
*/
export async function searchProjectNodeModulesBin(binaryName: string): Promise<string | undefined> {
// try to resolve via require.resolve
try {
const resolvedPath = replaceTargetFromMainToBin(
require.resolve(binaryName, {
paths: workspace.workspaceFolders?.map((folder) => folder.uri.fsPath) ?? [],
}),
binaryName,
);
return resolvedPath;
} catch {}
}

/**
* Search for the binary in global node_modules.
* Returns undefined if not found.
*/
export async function searchGlobalNodeModulesBin(binaryName: string): Promise<string | undefined> {
// try to resolve via require.resolve
try {
const resolvedPath = replaceTargetFromMainToBin(
require.resolve(binaryName, { paths: globalNodeModulesPaths() }),
binaryName,
);
return resolvedPath;
} catch {}
}

/**
* Search for the binary based on user settings.
* If the path is relative, it is resolved against the first workspace folder.
* Returns undefined if no valid binary is found or the path is unsafe.
*/
export async function searchSettingsBin(settingsBinary: string): Promise<string | undefined> {
if (!workspace.isTrusted) {
return;
}

// validates the given path is safe to use
if (!validateSafeBinaryPath(settingsBinary)) {
return undefined;
}

if (!path.isAbsolute(settingsBinary)) {
const cwd = workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!cwd) {
return undefined;
}
// if the path is not absolute, resolve it to the first workspace folder
settingsBinary = path.normalize(path.join(cwd, settingsBinary));
}

if (process.platform !== "win32" && settingsBinary.endsWith(".exe")) {
// on non-Windows, remove `.exe` extension if present
settingsBinary = settingsBinary.slice(0, -4);
}

try {
await workspace.fs.stat(Uri.file(settingsBinary));
return settingsBinary;
} catch {}

// on Windows, also check for `.exe` extension (bun uses `.exe` for its binaries)
if (process.platform === "win32") {
if (!settingsBinary.endsWith(".exe")) {
settingsBinary += ".exe";
}

try {
await workspace.fs.stat(Uri.file(settingsBinary));
return settingsBinary;
} catch {}
}

// no valid binary found
return undefined;
}

// copied from: https://github.com/biomejs/biome-vscode/blob/ae9b6df2254d0ff8ee9d626554251600eb2ca118/src/locator.ts#L28-L49
function globalNodeModulesPaths(): string[] {
const npmGlobalNodeModulesPath = safeSpawnSync("npm", ["root", "-g"]);
const pnpmGlobalNodeModulesPath = safeSpawnSync("pnpm", ["root", "-g"]);
const bunGlobalNodeModulesPath = path.resolve(homedir(), ".bun/install/global/node_modules");

return [npmGlobalNodeModulesPath, pnpmGlobalNodeModulesPath, bunGlobalNodeModulesPath].filter(
Boolean,
) as string[];
}

// 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 = (command: string, args: readonly string[] = []): string | undefined => {
let output: string | undefined;

try {
const result = spawnSync(command, args, {
shell: true,
encoding: "utf8",
});

if (result.error || result.status !== 0) {
output = undefined;
} else {
const trimmed = result.stdout.trim();
output = trimmed ? trimmed : undefined;
}
} catch {
output = undefined;
}

return output;
};
37 changes: 37 additions & 0 deletions editors/vscode/tests/unit/findBinary.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { strictEqual } from "assert";
import * as path from "node:path";
import { searchGlobalNodeModulesBin, searchProjectNodeModulesBin } from "../../client/findBinary";

suite("findBinary", () => {
const binaryName = "oxlint";

suite("searchProjectNodeModulesBin", () => {
test("should return undefined when binary is not found in project node_modules", async () => {
const result = await searchProjectNodeModulesBin("non-existent-binary-package-name-12345");
strictEqual(result, undefined);
});

// this depends on the binary being installed in the oxc project's node_modules
test("should replace dist/index.js with bin/<binary-name> in resolved path", async () => {
const result = (await searchProjectNodeModulesBin(binaryName))!;

strictEqual(result.includes(`${path.sep}dist${path.sep}index.js`), false);
strictEqual(result.includes(`${path.sep}bin${path.sep}${binaryName}`), true);
});
});

suite("searchGlobalNodeModulesBin", () => {
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);
});

// Skipping this test as it may depend on the actual global installation of the binary
test.skip("should replace dist/index.js with bin/<binary-name> in resolved path", async () => {
const result = (await searchGlobalNodeModulesBin(binaryName))!;

strictEqual(result.includes(`${path.sep}dist${path.sep}index.js`), false);
strictEqual(result.includes(`${path.sep}bin${path.sep}${binaryName}`), true);
});
});
});
Loading