Files
oxc-project__oxc/apps/oxlint/src-js/js_config.ts
2026-08-20 14:36:47 +00:00

157 lines
4.9 KiB
TypeScript

import { importJsConfig, loadViteConfigField } from "@oxapps/shared";
import { getErrorMessage } from "./utils/utils.ts";
interface JsConfigResult {
path: string;
config: unknown; // Will be validated as Oxlintrc on Rust side, `null` means "skip this config"
}
const isObject = (v: unknown) => typeof v === "object" && v !== null && !Array.isArray(v);
type LoadJsConfigsResult =
| { Success: JsConfigResult[] }
| { Failures: { path: string; error: string }[] }
| { Error: string };
function validateConfigExtends(root: object): void {
const visited = new WeakSet<object>();
const inStack = new WeakSet<object>();
const stackObjects: object[] = [];
const stackPaths: string[] = [];
const formatCycleError = (refPath: string, cycleStart: string, idx: number): string => {
const cycle =
idx === -1
? `${cycleStart} -> ${cycleStart}`
: [...stackPaths.slice(idx), cycleStart].join(" -> ");
return (
"`extends` contains a circular reference.\n\n"
+ `${refPath} points back to ${cycleStart}\n`
+ `Cycle: ${cycle}`
);
};
const visit = (config: object, path: string): void => {
if (visited.has(config)) return;
if (inStack.has(config)) {
const idx = stackObjects.indexOf(config);
const cycleStart = idx === -1 ? "<unknown>" : stackPaths[idx];
throw new Error(formatCycleError(path, cycleStart, idx));
}
inStack.add(config);
stackObjects.push(config);
stackPaths.push(path);
const maybeExtends = (config as Record<string, unknown>).extends;
if (maybeExtends !== undefined) {
if (!Array.isArray(maybeExtends)) {
throw new Error(
"`extends` must be an array of config objects (strings/paths are not supported).",
);
}
for (let i = 0; i < maybeExtends.length; i++) {
const item = maybeExtends[i];
if (!isObject(item)) {
throw new Error(
`\`extends[${i}]\` must be a config object (strings/paths are not supported).`,
);
}
const itemPath = `${path}.extends[${i}]`;
if (inStack.has(item)) {
const idx = stackObjects.indexOf(item);
const cycleStart = idx === -1 ? "<unknown>" : stackPaths[idx];
throw new Error(formatCycleError(itemPath, cycleStart, idx));
}
visit(item, itemPath);
}
}
inStack.delete(config);
stackObjects.pop();
stackPaths.pop();
visited.add(config);
};
visit(root, "<root>");
}
/**
* Resolve a single config path to a `JsConfigResult`.
* Standard mode: default export must be a plain object.
*/
async function resolveJsConfig(path: string, cacheKey: number): Promise<JsConfigResult> {
const config = await importJsConfig(path, cacheKey);
validateConfigExtends(config);
return { path, config };
}
/**
* Resolve a single Vite+ config path to a `JsConfigResult`.
* Extracts the `.lint` field via `vite-plus`. Returns `null` config when missing (signals "skip").
*/
async function resolveVitePlusConfig(path: string): Promise<JsConfigResult> {
const lintConfig = await loadViteConfigField(path, "lint");
if (lintConfig === null) return { path, config: null };
validateConfigExtends(lintConfig);
return { path, config: lintConfig };
}
/**
* Load config files in parallel using the given resolver, and return JSON-stringified result.
*/
async function loadConfigs(
paths: string[],
resolver: (path: string) => Promise<JsConfigResult>,
): Promise<string> {
try {
const results = await Promise.allSettled(paths.map(resolver));
const successes: JsConfigResult[] = [];
const errors: { path: string; error: string }[] = [];
for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result.status === "fulfilled") {
successes.push(result.value);
} else {
errors.push({
path: paths[i],
error: getErrorMessage(result.reason),
});
}
}
if (errors.length > 0) {
return JSON.stringify({ Failures: errors } satisfies LoadJsConfigsResult);
}
return JSON.stringify({ Success: successes } satisfies LoadJsConfigsResult);
} catch (err) {
return JSON.stringify({
Error: getErrorMessage(err),
} satisfies LoadJsConfigsResult);
}
}
export type ConfigLoader = (paths: string[]) => Promise<string>;
/**
* Load standard oxlint JS/TS config files in parallel.
*/
export const loadJsConfigs: ConfigLoader = (paths) => {
// Share one cache-busting key across the batch so that `?cache=<key>` is identical
// for every path resolved in this call (consistent reload semantics for LSP).
const cacheKey = Date.now();
return loadConfigs(paths, (path) => resolveJsConfig(path, cacheKey));
};
/**
* Load Vite+ config files in parallel, extracting the `.lint` field from each.
*/
export const loadVitePlusConfigs: ConfigLoader = (paths) =>
loadConfigs(paths, resolveVitePlusConfig);