Skip to content

Commit e466ff8

Browse files
committed
feat(vscode): fallback to globally installed oxlint/oxfmt packages
1 parent c9a914a commit e466ff8

File tree

3 files changed

+178
-78
lines changed

3 files changed

+178
-78
lines changed

editors/vscode/client/ConfigService.ts

Lines changed: 11 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
import * as path from "node:path";
21
import { ConfigurationChangeEvent, Uri, workspace, WorkspaceFolder } from "vscode";
32
import { DiagnosticPullMode } from "vscode-languageclient";
4-
import { validateSafeBinaryPath } from "./PathValidator";
3+
import {
4+
searchGlobalNodeModulesBin,
5+
searchProjectNodeModulesBin,
6+
searchSettingsBin,
7+
} from "./findBinary";
58
import { IDisposable } from "./types";
69
import { VSCodeConfig } from "./VSCodeConfig";
710
import {
@@ -113,84 +116,14 @@ export class ConfigService implements IDisposable {
113116
settingsBinary: string | undefined,
114117
defaultBinaryName: string,
115118
): Promise<string | undefined> {
116-
if (!settingsBinary) {
117-
return this.searchNodeModulesBin(defaultBinaryName);
118-
}
119-
120-
if (!workspace.isTrusted) {
121-
return;
119+
if (settingsBinary) {
120+
return searchSettingsBin(settingsBinary);
122121
}
123122

124-
// validates the given path is safe to use
125-
if (!validateSafeBinaryPath(settingsBinary)) {
126-
return undefined;
127-
}
128-
129-
if (!path.isAbsolute(settingsBinary)) {
130-
const cwd = this.workspaceConfigs.keys().next().value;
131-
if (!cwd) {
132-
return undefined;
133-
}
134-
// if the path is not absolute, resolve it to the first workspace folder
135-
settingsBinary = path.normalize(path.join(cwd, settingsBinary));
136-
settingsBinary = this.removeWindowsLeadingSlash(settingsBinary);
137-
}
138-
139-
if (process.platform !== "win32" && settingsBinary.endsWith(".exe")) {
140-
// on non-Windows, remove `.exe` extension if present
141-
settingsBinary = settingsBinary.slice(0, -4);
142-
}
143-
144-
try {
145-
await workspace.fs.stat(Uri.file(settingsBinary));
146-
return settingsBinary;
147-
} catch {}
148-
149-
// on Windows, also check for `.exe` extension (bun uses `.exe` for its binaries)
150-
if (process.platform === "win32") {
151-
if (!settingsBinary.endsWith(".exe")) {
152-
settingsBinary += ".exe";
153-
}
154-
155-
try {
156-
await workspace.fs.stat(Uri.file(settingsBinary));
157-
return settingsBinary;
158-
} catch {}
159-
}
160-
161-
// no valid binary found
162-
return undefined;
163-
}
164-
165-
/**
166-
* strip the leading slash on Windows
167-
*/
168-
private removeWindowsLeadingSlash(path: string): string {
169-
if (process.platform === "win32" && path.startsWith("\\")) {
170-
return path.slice(1);
171-
}
172-
return path;
173-
}
174-
175-
/**
176-
* Search for the binary in all workspaces' node_modules/.bin directories.
177-
* If multiple workspaces contain the binary, the first one found is returned.
178-
*/
179-
private async searchNodeModulesBin(binaryName: string): Promise<string | undefined> {
180-
// try to resolve via require.resolve
181-
try {
182-
const resolvedPath = require
183-
.resolve(binaryName, {
184-
paths: workspace.workspaceFolders?.map((folder) => folder.uri.fsPath) ?? [],
185-
})
186-
// we want to target the binary instead of the main index file
187-
// Improvement: search inside package.json "bin" and `main` field for more reliability
188-
.replace(
189-
`${binaryName}${path.sep}dist${path.sep}index.js`,
190-
`${binaryName}${path.sep}bin${path.sep}${binaryName}`,
191-
);
192-
return resolvedPath;
193-
} catch {}
123+
return (
124+
(await searchProjectNodeModulesBin(defaultBinaryName)) ??
125+
(await searchGlobalNodeModulesBin(defaultBinaryName))
126+
);
194127
}
195128

196129
private async onVscodeConfigChange(event: ConfigurationChangeEvent): Promise<void> {
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { spawnSync } from "node:child_process";
2+
import { homedir } from "node:os";
3+
import * as path from "node:path";
4+
import { Uri, workspace } from "vscode";
5+
import { validateSafeBinaryPath } from "./PathValidator";
6+
7+
function replaceTargetFromMainToBin(resolvedPath: string, binaryName: string): string {
8+
// we want to target the binary instead of the main index file
9+
// Improvement: search inside package.json "bin" and `main` field for more reliability
10+
return resolvedPath.replace(
11+
`${binaryName}${path.sep}dist${path.sep}index.js`,
12+
`${binaryName}${path.sep}bin${path.sep}${binaryName}`,
13+
);
14+
}
15+
/**
16+
* Search for the binary in all workspaces' node_modules/.bin directories.
17+
* If multiple workspaces contain the binary, the first one found is returned.
18+
*/
19+
export async function searchProjectNodeModulesBin(binaryName: string): Promise<string | undefined> {
20+
// try to resolve via require.resolve
21+
try {
22+
const resolvedPath = replaceTargetFromMainToBin(
23+
require.resolve(binaryName, {
24+
paths: workspace.workspaceFolders?.map((folder) => folder.uri.fsPath) ?? [],
25+
}),
26+
binaryName,
27+
);
28+
return resolvedPath;
29+
} catch {}
30+
}
31+
32+
/**
33+
* Search for the binary in global node_modules.
34+
* Returns undefined if not found.
35+
*/
36+
export async function searchGlobalNodeModulesBin(binaryName: string): Promise<string | undefined> {
37+
// try to resolve via require.resolve
38+
try {
39+
const resolvedPath = replaceTargetFromMainToBin(
40+
require.resolve(binaryName, { paths: globalNodeModulesPaths() }),
41+
binaryName,
42+
);
43+
return resolvedPath;
44+
} catch {}
45+
}
46+
47+
/**
48+
* Search for the binary based on user settings.
49+
* If the path is relative, it is resolved against the first workspace folder.
50+
* Returns undefined if no valid binary is found or the path is unsafe.
51+
*/
52+
export async function searchSettingsBin(settingsBinary: string): Promise<string | undefined> {
53+
if (!workspace.isTrusted) {
54+
return;
55+
}
56+
57+
// validates the given path is safe to use
58+
if (!validateSafeBinaryPath(settingsBinary)) {
59+
return undefined;
60+
}
61+
62+
if (!path.isAbsolute(settingsBinary)) {
63+
const cwd = workspace.workspaceFolders?.[0]?.uri.fsPath;
64+
if (!cwd) {
65+
return undefined;
66+
}
67+
// if the path is not absolute, resolve it to the first workspace folder
68+
settingsBinary = path.normalize(path.join(cwd, settingsBinary));
69+
}
70+
71+
if (process.platform !== "win32" && settingsBinary.endsWith(".exe")) {
72+
// on non-Windows, remove `.exe` extension if present
73+
settingsBinary = settingsBinary.slice(0, -4);
74+
}
75+
76+
try {
77+
await workspace.fs.stat(Uri.file(settingsBinary));
78+
return settingsBinary;
79+
} catch {}
80+
81+
// on Windows, also check for `.exe` extension (bun uses `.exe` for its binaries)
82+
if (process.platform === "win32") {
83+
if (!settingsBinary.endsWith(".exe")) {
84+
settingsBinary += ".exe";
85+
}
86+
87+
try {
88+
await workspace.fs.stat(Uri.file(settingsBinary));
89+
return settingsBinary;
90+
} catch {}
91+
}
92+
93+
// no valid binary found
94+
return undefined;
95+
}
96+
97+
// copied from: https://github.com/biomejs/biome-vscode/blob/ae9b6df2254d0ff8ee9d626554251600eb2ca118/src/locator.ts#L28-L49
98+
function globalNodeModulesPaths(): string[] {
99+
const npmGlobalNodeModulesPath = safeSpawnSync("npm", ["root", "-g"]);
100+
const pnpmGlobalNodeModulesPath = safeSpawnSync("pnpm", ["root", "-g"]);
101+
const bunGlobalNodeModulesPath = path.resolve(homedir(), ".bun/install/global/node_modules");
102+
103+
return [npmGlobalNodeModulesPath, pnpmGlobalNodeModulesPath, bunGlobalNodeModulesPath].filter(
104+
Boolean,
105+
) as string[];
106+
}
107+
108+
// only use this function with internal code, because it executes shell commands
109+
// which could be a security risk if the command or args are user-controlled
110+
const safeSpawnSync = (command: string, args: readonly string[] = []): string | undefined => {
111+
let output: string | undefined;
112+
113+
try {
114+
const result = spawnSync(command, args, {
115+
shell: true,
116+
encoding: "utf8",
117+
});
118+
119+
if (result.error || result.status !== 0) {
120+
output = undefined;
121+
} else {
122+
const trimmed = result.stdout.trim();
123+
output = trimmed ? trimmed : undefined;
124+
}
125+
} catch {
126+
output = undefined;
127+
}
128+
129+
return output;
130+
};
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { strictEqual } from "assert";
2+
import * as path from "node:path";
3+
import { searchGlobalNodeModulesBin, searchProjectNodeModulesBin } from "../../client/findBinary";
4+
5+
suite("findBinary", () => {
6+
const binaryName = "oxlint";
7+
8+
suite("searchProjectNodeModulesBin", () => {
9+
test("should return undefined when binary is not found in project node_modules", async () => {
10+
const result = await searchProjectNodeModulesBin("non-existent-binary-package-name-12345");
11+
strictEqual(result, undefined);
12+
});
13+
14+
// this depends on the binary being installed in the oxc project's node_modules
15+
test("should replace dist/index.js with bin/<binary-name> in resolved path", async () => {
16+
const result = (await searchProjectNodeModulesBin(binaryName))!;
17+
18+
strictEqual(result.includes(`${path.sep}dist${path.sep}index.js`), false);
19+
strictEqual(result.includes(`${path.sep}bin${path.sep}${binaryName}`), true);
20+
});
21+
});
22+
23+
suite("searchGlobalNodeModulesBin", () => {
24+
test("should return undefined when binary is not found in global node_modules", async () => {
25+
const result = await searchGlobalNodeModulesBin("non-existent-binary-package-name-12345");
26+
strictEqual(result, undefined);
27+
});
28+
29+
// Skipping this test as it may depend on the actual global installation of the binary
30+
test.skip("should replace dist/index.js with bin/<binary-name> in resolved path", async () => {
31+
const result = (await searchGlobalNodeModulesBin(binaryName))!;
32+
33+
strictEqual(result.includes(`${path.sep}dist${path.sep}index.js`), false);
34+
strictEqual(result.includes(`${path.sep}bin${path.sep}${binaryName}`), true);
35+
});
36+
});
37+
});

0 commit comments

Comments
 (0)