Skip to content
Open
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
2 changes: 2 additions & 0 deletions .vscode-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
},
},
Expand Down
77 changes: 61 additions & 16 deletions README.md

Large diffs are not rendered by default.

123 changes: 110 additions & 13 deletions client/ConfigService.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -16,13 +20,23 @@ 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[] = [];

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 All @@ -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),
}));
}

Expand Down Expand Up @@ -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,
Expand All @@ -116,12 +131,18 @@ 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 command = defaultBinaryName === "oxlint" ? "lint" : "fmt";
const vitePlus = await this.searchVitePlus(command);
if (vitePlus) {
return { ...vitePlus, vitePlus: command };
}

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

private async searchVitePlus(command: "lint" | "fmt"): Promise<BinarySearchResult | null> {
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<BinarySource>(`${command}.binarySource`) ?? "auto",
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 {
if (this.vitePlusSearches.get(key) === search) {
this.vitePlusSearches.delete(key);
}
}
}

private async resolveVitePlus(
folders: VitePlusSearchFolder[],
): Promise<BinarySearchResult | null> {
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<void> {
let isConfigChanged = false;

Expand Down
3 changes: 3 additions & 0 deletions client/VSCodeConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,13 +154,15 @@ 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`)
);
}

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)
);
Expand All @@ -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)
);
}
Expand Down
12 changes: 7 additions & 5 deletions client/WorkspaceConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
}
97 changes: 97 additions & 0 deletions client/detectVitePlus.ts
Original file line number Diff line number Diff line change
@@ -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<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,
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 {}
Loading