Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 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
2 changes: 2 additions & 0 deletions .vscode-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,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"),
},
},
Expand Down
64 changes: 48 additions & 16 deletions README.md

Large diffs are not rendered by default.

84 changes: 82 additions & 2 deletions client/ConfigService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { ConfigurationChangeEvent, Uri, workspace, WorkspaceFolder } from "vscode";
import * as path from "node:path";
import { ConfigurationChangeEvent, Uri, window, workspace, WorkspaceFolder } from "vscode";
import { detectVitePlusProject, VitePlusError } from "./detectVitePlus";
import { getShellEnv } from "./getShellEnv";
import { DiagnosticPullMode } from "vscode-languageclient";
import {
BinarySearchResult,
Expand All @@ -16,13 +19,21 @@ import {
WorkspaceConfig,
} from "./WorkspaceConfig";

interface VitePlusSearchFolder {
root: string;
start: string;
enabled: boolean | null | undefined;
configuredPath: string | undefined;
}

export class ConfigService implements IDisposable {
public static readonly namespace = "oxc";
private readonly _disposables: IDisposable[] = [];

public vsCodeConfig: VSCodeConfig;

private workspaceConfigs: Map<string, WorkspaceConfig> = new Map();
private readonly vitePlusSearches = new Map<string, Promise<BinarySearchResult | null>>();

public onConfigChange:
| ((this: ConfigService, config: ConfigurationChangeEvent) => Promise<void>)
Expand Down Expand Up @@ -116,12 +127,17 @@ export class ConfigService implements IDisposable {

private async searchBinaryPath(
settingsBinary: string | undefined,
defaultBinaryName: string,
defaultBinaryName: "oxlint" | "oxfmt",
): Promise<BinarySearchResult | undefined> {
if (settingsBinary) {
return searchSettingsBin(defaultBinaryName, settingsBinary);
}

const vitePlus = await this.searchVitePlus();
if (vitePlus) {
return { ...vitePlus, vitePlus: defaultBinaryName === "oxlint" ? "lint" : "fmt" };
}

return (
(await searchProjectNodeModulesBin(defaultBinaryName)) ??
(await searchYarnPnpBin(defaultBinaryName)) ??
Expand All @@ -130,6 +146,70 @@ export class ConfigService implements IDisposable {
);
}

private async searchVitePlus(): Promise<BinarySearchResult | null> {
if (!workspace.isTrusted) return null;

const documentUri = window.activeTextEditor?.document.uri;
const activeFolder =
documentUri?.scheme === "file" ? workspace.getWorkspaceFolder(documentUri) : undefined;
const folders = (activeFolder ? [activeFolder] : (workspace.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,
enabled: config.get<boolean | null>("vitePlus.enable"),
configuredPath: config.get<string>("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 {
this.vitePlusSearches.delete(key);
}
}

private async resolveVitePlus(
folders: VitePlusSearchFolder[],
): Promise<BinarySearchResult | null> {
for (const { root, start, enabled, configuredPath } of folders) {
if (enabled === false) 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, enabled === true, root);
if (!project) continue;
// 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: BinarySearchResult | undefined = project.vpPath
? { path: project.vpPath, loader: "native" }
: ((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<void> {
let isConfigChanged = false;

Expand Down
2 changes: 2 additions & 0 deletions client/VSCodeConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ 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}.vitePlus.enable`) ||
event.affectsConfiguration(`${ConfigService.namespace}.useExecPath`)
);
}
Expand Down
87 changes: 87 additions & 0 deletions client/detectVitePlus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
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<string, unknown>;
devDependencies?: Record<string, unknown>;
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,
enabled = 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 (enabled) {
// 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"]) {
if (isRootWorkspace(dir, pkg) || dir === path.dirname(dir)) return null;
dir = path.dirname(dir);
pkg = readPackageJson(dir);
}
}

const root = dir;
while (true) {
const binNames = process.platform === "win32" ? ["vp.cmd", "vp.exe"] : ["vp"];
for (const name of binNames) {
const vpPath = path.join(dir, "node_modules", ".bin", name);
if (existsSync(vpPath)) return { root, vpPath };
}
if (isRootWorkspace(dir, pkg) || dir === path.dirname(dir)) return { root };
dir = path.dirname(dir);
pkg = readPackageJson(dir);
}
}

export class VitePlusError extends Error {}
33 changes: 33 additions & 0 deletions client/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ 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(
Expand All @@ -118,6 +119,38 @@ export async function activate(context: ExtensionContext) {
}),
);

// A window has one client per tool. Re-resolve on navigation, and restart
// only when the executable, Vite+ command, or project directory changes.
const switchProject = async () => {
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();
}
Expand Down
54 changes: 49 additions & 5 deletions client/findBinary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -44,7 +46,11 @@ async function searchNodeModulesDefaultBinPath(
): Promise<BinarySearchResult | undefined> {
const candidates = folders.flatMap((folder) => {
const basePath = path.join(folder, ".bin", binaryName);
return process.platform === "win32" ? [basePath, `${basePath}.exe`] : [basePath];
return process.platform === "win32"
? binaryName === "vp"
? [`${basePath}.cmd`, `${basePath}.exe`, basePath]
: [basePath, `${basePath}.exe`]
: [basePath];
});

const exists = await Promise.all(
Expand Down Expand Up @@ -202,6 +208,7 @@ export async function searchYarnPnpBin(
*/
export async function searchGlobalNodeModulesBin(
binaryName: string,
packageName = binaryName,
): Promise<BinarySearchResult | undefined> {
const globalPaths = await globalNodeModulesPaths();

Expand All @@ -213,6 +220,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(
Expand All @@ -229,8 +253,9 @@ export async function searchGlobalNodeModulesBin(
*/
export async function searchEnvPath(
defaultBinaryName: string,
environment: Record<string, string | undefined> = env,
): Promise<BinarySearchResult | undefined> {
const envPath = env.PATH;
const envPath = environment.PATH;

if (!envPath) {
return undefined;
Expand All @@ -244,7 +269,11 @@ export async function searchEnvPath(
return [];
}
const basePath = path.join(folder, defaultBinaryName);
return process.platform === "win32" ? [basePath, `${basePath}.exe`] : [basePath];
return process.platform === "win32"
? defaultBinaryName === "vp"
? [`${basePath}.cmd`, `${basePath}.exe`, basePath]
: [basePath, `${basePath}.exe`]
: [basePath];
});

const binary = await Promise.all(
Expand All @@ -270,6 +299,7 @@ export async function searchEnvPath(
export async function searchSettingsBin(
defaultBinaryName: string,
settingsBinary: string,
cwd = workspace.workspaceFolders?.[0]?.uri.fsPath,
): Promise<BinarySearchResult | undefined> {
if (!workspace.isTrusted) {
return;
Expand All @@ -281,7 +311,6 @@ export async function searchSettingsBin(
}

if (!path.isAbsolute(settingsBinary)) {
const cwd = workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!cwd) {
return undefined;
}
Expand All @@ -298,7 +327,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));
Expand Down
Loading