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

492 lines
18 KiB
TypeScript

import { createContext } from "./context.ts";
import { deepFreezeJsonArray } from "./json.ts";
import { compileSchema, DEFAULT_OPTIONS } from "./options.ts";
import { switchWorkspace } from "./workspace.ts";
import { getErrorMessage } from "../utils/utils.ts";
import { debugAssertIsNonNull } from "../utils/asserts.ts";
import type { Writable } from "type-fest";
import type { Context } from "./context.ts";
import type { Options, SchemaValidator } from "./options.ts";
import type { RuleMeta } from "./rule_meta.ts";
import type { AfterHook, BeforeHook, Visitor, VisitorWithHooks } from "./types.ts";
import type { SetNullable } from "../utils/types.ts";
/**
* Linter plugin, comprising multiple rules
*/
export interface Plugin {
meta?: {
name?: string;
};
rules: Record<string, Rule>;
}
/**
* Linter rule.
*
* `Rule` can have either `create` method, or `createOnce` method.
* If `createOnce` method is present, `create` is ignored.
*
* If defining the rule with `createOnce`, and you want the rule to work with ESLint too,
* you need to wrap the plugin containing the rule with `eslintCompatPlugin`.
*/
export type Rule = CreateRule | CreateOnceRule;
export interface CreateRule {
meta?: RuleMeta;
create: (context: Context) => Visitor;
}
export interface CreateOnceRule {
meta?: RuleMeta;
create?: (context: Context) => Visitor;
createOnce: (context: Context) => VisitorWithHooks;
}
/**
* Linter rule, context object, and other details of rule.
* If `rule` has a `createOnce` method, the visitor it returns is stored in `visitor` property.
*/
export type RuleDetails = CreateRuleDetails | CreateOnceRuleDetails;
interface RuleDetailsBase {
// Static properties of the rule
readonly context: Context;
readonly isFixable: boolean;
readonly hasSuggestions: boolean;
readonly messages: Readonly<Record<string, string>> | null;
readonly defaultOptions: Readonly<Options>;
// Function to validate options against schema.
// `false` means validation is disabled. Rule accepts any options.
// `null` means no validator provided. Rule does not accept any options.
readonly optionsSchemaValidator: SchemaValidator | false | null;
// Updated for each file
ruleIndex: number;
}
interface CreateRuleDetails extends RuleDetailsBase {
readonly rule: CreateRule;
readonly visitor: null;
readonly beforeHook: null;
readonly afterHook: null;
}
interface CreateOnceRuleDetails extends RuleDetailsBase {
readonly rule: CreateOnceRule;
readonly visitor: Visitor;
readonly beforeHook: BeforeHook | null;
readonly afterHook: AfterHook | null;
}
// Rule objects for loaded rules.
// Indexed by `ruleId`, which is passed to `lintFile`.
// May be changed when switching workspaces.
export let registeredRules: RuleDetails[] = [];
// Names of loaded plugins.
// May be changed when switching workspaces.
export let registeredPluginNames = new Set<string>();
/**
* Set `registeredRules`. Used when switching workspaces.
* @param rules - Array of `RuleDetails` objects
*/
export function setRegisteredRules(rules: RuleDetails[]) {
registeredRules = rules;
}
/**
* Set `registeredPluginNames`. Used when switching workspaces.
* @param pluginNames - Set of registered plugin names
*/
export function setRegisteredPluginNames(pluginNames: Set<string>) {
registeredPluginNames = pluginNames;
}
// `before` hook which makes rule never run.
const neverRunBeforeHook: BeforeHook = () => false;
// Plugin details returned to Rust
interface PluginDetails {
// Plugin name
name: string;
// Index of first rule of this plugin within `registeredRules`
offset: number;
// Names of rules within this plugin, in same order as in `registeredRules`
ruleNames: string[];
}
/**
* Load a plugin.
*
* Main logic is in separate function `loadPluginImpl`, because V8 cannot optimize functions containing try/catch.
*
* @param url - Absolute path of plugin file as a `file://...` URL
* @param pluginName - Plugin name (either alias or package name)
* @param pluginNameIsAlias - `true` if plugin name is an alias (takes priority over name that plugin defines itself)
* @param workspaceUri - Workspace URI (`null` in CLI, string in LSP)
* @returns Plugin details or error serialized to JSON string
*/
export async function loadPlugin(
url: string,
pluginName: string | null,
pluginNameIsAlias: boolean,
workspaceUri: string | null,
): Promise<string> {
try {
const plugin = (await import(url)).default as Plugin;
const res = registerPlugin(plugin, pluginName, pluginNameIsAlias, workspaceUri);
return JSON.stringify({ Success: res });
} catch (err) {
return JSON.stringify({ Failure: getErrorMessage(err) });
}
}
/**
* Register a plugin.
*
* @param plugin - Plugin
* @param pluginName - Plugin name (either alias or package name)
* @param pluginNameIsAlias - `true` if plugin name is an alias (takes priority over name that plugin defines itself)
* @param workspaceUri - Workspace URI (`null` in CLI, string in LSP)
* @returns - Plugin details
* @throws {Error} If `plugin.meta.name` is `null` / `undefined` and `packageName` not provided
* @throws {Error} If another plugin with the same name is already registered
* @throws {TypeError} If one of plugin's rules is malformed, or its `createOnce` method returns invalid visitor
* @throws {TypeError} If `plugin.meta.name` is not a string
*/
export function registerPlugin(
plugin: Plugin,
pluginName: string | null,
pluginNameIsAlias: boolean,
workspaceUri: string | null,
): PluginDetails {
// TODO: Use a validation library to assert the shape of the plugin, and of rules
pluginName = getPluginName(plugin, pluginName, pluginNameIsAlias);
// Switch to requested workspace.
// In CLI, `workspaceUri` is `null`, and there's only 1 workspace, so no need to switch.
// In LSP, there can be multiple workspaces, so we need to switch if we're not already in the right one.
if (workspaceUri !== null) switchWorkspace(workspaceUri);
if (registeredPluginNames.has(pluginName)) {
throw new Error(
`Plugin name '${pluginName}' is already registered. Use a different name or an alias in \`jsPlugins\`.`,
);
}
const offset = registeredRules.length;
const { rules } = plugin;
const ruleNames = Object.keys(rules);
const ruleNamesLen = ruleNames.length;
for (let i = 0; i < ruleNamesLen; i++) {
const ruleName = ruleNames[i],
rule = rules[ruleName];
const fullRuleName = `${pluginName}/${ruleName}`;
// Validate `rule.meta` and convert to vars with standardized shape
let isFixable = false,
hasSuggestions = false,
messages: Record<string, string> | null = null,
defaultOptions: Readonly<Options> = DEFAULT_OPTIONS,
schemaValidator: SchemaValidator | false | null = null;
const ruleMeta = rule.meta;
if (ruleMeta != null) {
if (typeof ruleMeta !== "object") throw new TypeError("Invalid `rule.meta`");
const { fixable } = ruleMeta;
if (fixable != null) {
// `true` and `false` aren't valid values for `meta.fixable`, but we accept them for
// backward compatibility with some ESLint plugins
if (
fixable !== "code"
&& fixable !== "whitespace"
&& fixable !== true
&& fixable !== false
) {
throw new TypeError("Invalid `rule.meta.fixable`");
}
isFixable = (fixable as "code" | "whitespace" | true | false) !== false;
}
const inputHasSuggestions = ruleMeta.hasSuggestions;
if (inputHasSuggestions === true) {
hasSuggestions = true;
} else if (inputHasSuggestions != null && inputHasSuggestions !== false) {
throw new TypeError("Invalid `rule.meta.hasSuggestions`");
}
// If `schema` provided, compile schema to validator for applying schema defaults to options
schemaValidator = compileSchema(ruleMeta.schema);
// Get default options
const inputDefaultOptions = ruleMeta.defaultOptions;
if (inputDefaultOptions != null) {
if (!Array.isArray(inputDefaultOptions)) {
throw new TypeError("`rule.meta.defaultOptions` must be an array if provided");
}
// ESLint treats empty `defaultOptions` the same as no `defaultOptions`,
// and does not validate against schema
if (inputDefaultOptions.length !== 0) {
defaultOptions = conformDefaultOptions(inputDefaultOptions);
// Validate default options against schema, if schema was provided.
// This also applies any defaults from schema.
// `schemaValidator` can mutate original `rule.meta.defaultOptions` object in place,
// but that's what ESLint does, so it's OK.
if (schemaValidator === null) {
throw new Error(
`Rule ${fullRuleName}:\n`
+ "Rules which accept options must provide a schema as `rule.meta.schema`, "
+ "or disable schema validation with `rule.meta.schema: false` (not recommended).",
);
}
// Note: `defaultOptions` is not frozen yet
if (schemaValidator !== false) {
schemaValidator(defaultOptions as Writable<typeof defaultOptions>, fullRuleName);
}
deepFreezeJsonArray(defaultOptions as Writable<typeof defaultOptions>);
}
}
// Extract messages for messageId support
const inputMessages = ruleMeta.messages;
if (inputMessages != null) {
if (typeof inputMessages !== "object") {
throw new TypeError("`rule.meta.messages` must be an object if provided");
}
messages = inputMessages;
}
}
// Create `RuleDetails` object for rule.
const ruleDetails: RuleDetails = {
rule: rule as CreateRule, // Could also be `CreateOnceRule`, but just to satisfy type checker
context: null!, // Filled in below
isFixable,
hasSuggestions,
messages,
defaultOptions,
optionsSchemaValidator: schemaValidator,
ruleIndex: 0,
visitor: null,
beforeHook: null,
afterHook: null,
};
// Create `Context` object for rule. This will be re-used for every file.
const context = createContext(ruleDetails);
(ruleDetails as Writable<RuleDetails>).context = context;
if ("createOnce" in rule) {
// TODO: Compile visitor object to array here, instead of repeating compilation on each file
const visitorWithHooks = rule.createOnce(context) as SetNullable<
VisitorWithHooks,
"before" | "after"
>;
if (typeof visitorWithHooks !== "object" || visitorWithHooks === null) {
throw new TypeError("`createOnce` must return an object");
}
let { before: beforeHook, after: afterHook, ...visitor } = visitorWithHooks;
beforeHook = conformHookFn(beforeHook, "before");
afterHook = conformHookFn(afterHook, "after");
// If empty visitor, make this rule never run by substituting a `before` hook which always returns `false`.
// This means the original `before` hook won't run either.
//
// Reason for doing this is:
// In future, we may do a check on Rust side whether AST contains any nodes which rules act on,
// and if not, skip calling into JS entirely. In that case, the `before` hook won't get called.
// We can't emulate that behavior exactly, but we can at least emulate it in this simple case,
// and prevent users defining rules with *only* a `before` hook, which they expect to run on every file.
if (Object.keys(visitor).length === 0) {
beforeHook = neverRunBeforeHook;
afterHook = null;
}
(ruleDetails as unknown as Writable<CreateOnceRuleDetails>).visitor = visitor;
(ruleDetails as unknown as Writable<CreateOnceRuleDetails>).beforeHook = beforeHook;
(ruleDetails as unknown as Writable<CreateOnceRuleDetails>).afterHook = afterHook;
}
// Set `id` property on `Context` object.
// Do this after calling `createOnce` - `createOnce` should not have access to `id` property.
Object.defineProperty(ruleDetails.context, "id", { value: fullRuleName });
registeredRules.push(ruleDetails);
}
registeredPluginNames.add(pluginName);
return { name: pluginName, offset, ruleNames };
}
/**
* Get plugin name.
*
* - Plugin is named with an alias in config, return the alias.
* - If `plugin.meta.name` is defined, return it.
* - Otherwise, fall back to `packageName`, if defined.
* - If neither is defined, throw an error.
*
* @param plugin - Plugin object
* @param pluginName - Plugin name (either alias or package name)
* @param pluginNameIsAlias - `true` if plugin name is an alias (takes priority over name that plugin defines itself)
* @returns Plugin name
* @throws {TypeError} If `plugin.meta.name` is not a string
* @throws {Error} If neither `plugin.meta.name` nor `packageName` are defined
*/
function getPluginName(
plugin: Plugin,
pluginName: string | null,
pluginNameIsAlias: boolean,
): string {
// If plugin is defined with an alias in config, that takes priority
if (pluginNameIsAlias) {
debugAssertIsNonNull(pluginName);
return pluginName;
}
// If plugin defines its own name, that takes priority over package name.
// Normalize plugin name.
const pluginMetaName = plugin.meta?.name;
if (pluginMetaName != null) {
if (typeof pluginMetaName !== "string") {
throw new TypeError("`plugin.meta.name` must be a string if defined");
}
return normalizePluginName(pluginMetaName);
}
// Fallback to package name (which is already normalized on Rust side)
if (pluginName !== null) return pluginName;
throw new Error(
"Plugin must either define `meta.name`, be loaded from an NPM package with a `name` field in `package.json`, "
+ "or be given an alias in config",
);
}
/**
* Normalize plugin name by stripping common plugin prefixes and suffixes.
*
* This handles the various naming conventions used in the ESLint and Oxlint plugin ecosystems:
* - `eslint-plugin-foo` -> `foo`
* - `oxlint-plugin-foo` -> `foo`
* - `@scope/eslint-plugin` -> `@scope`
* - `@scope/eslint-plugin-foo` -> `@scope/foo`
* - `@scope/oxlint-plugin` -> `@scope`
* - `@scope/oxlint-plugin-foo` -> `@scope/foo`
*
* This logic is replicated on Rust side in `normalize_plugin_name` in `crates/oxc_linter/src/config/plugins.rs`.
* The 2 implementations must be kept in sync.
*
* @param name - Plugin name defined by plugin
* @returns Normalized plugin name
*/
function normalizePluginName(name: string): string {
const pluginPrefixes = ["eslint-plugin", "oxlint-plugin"] as const;
const slashIndex = name.indexOf("/");
// If no slash, it's a non-scoped package. Trim off a known plugin prefix.
if (slashIndex === -1) {
for (const prefix of pluginPrefixes) {
const prefixWithDash = `${prefix}-`;
if (name.startsWith(prefixWithDash)) return name.slice(prefixWithDash.length);
}
return name;
}
const scope = name.slice(0, slashIndex),
rest = name.slice(slashIndex + 1);
for (const prefix of pluginPrefixes) {
// `@scope/eslint-plugin` -> `@scope`
// `@scope/oxlint-plugin` -> `@scope`
if (rest === prefix) return scope;
// `@scope/eslint-plugin-foo` -> `@scope/foo`
// `@scope/oxlint-plugin-foo` -> `@scope/foo`
const prefixWithDash = `${prefix}-`;
if (rest.startsWith(prefixWithDash)) return `${scope}/${rest.slice(prefixWithDash.length)}`;
}
// No normalization needed
return name;
}
/**
* Serialize default options to JSON and deserialize again.
*
* This is the simplest way to make sure that `defaultOptions` does not contain any `undefined` values,
* or circular references. It may also be the fastest, as `JSON.parse` and `JSON.stringify` are native code.
* If we move to doing options merging on Rust side, we'll need to convert to JSON anyway.
*
* Special handling for `Infinity` / `-Infinity` values, to ensure they survive the round trip.
* Without this, they would be converted to `null`.
*
* @param defaultOptions - Default options array
* @returns Conformed default options array
*/
function conformDefaultOptions(defaultOptions: Options): Options {
let json,
containsInfinity = false;
try {
json = JSON.stringify(defaultOptions, (key, value) => {
if (value === Infinity || value === -Infinity) {
containsInfinity = true;
return value === Infinity ? POS_INFINITY_PLACEHOLDER : NEG_INFINITY_PLACEHOLDER;
}
return value;
});
} catch (err) {
throw new Error(
`\`rule.meta.defaultOptions\` must be JSON-serializable: ${getErrorMessage(err)}`,
);
}
if (containsInfinity) {
const plainJson = JSON.stringify(defaultOptions);
if (
plainJson.includes(POS_INFINITY_PLACEHOLDER)
|| plainJson.includes(NEG_INFINITY_PLACEHOLDER)
) {
throw new Error(
`\`rule.meta.defaultOptions\` cannot contain the strings "${POS_INFINITY_PLACEHOLDER}" or "${NEG_INFINITY_PLACEHOLDER}"`,
);
}
// `JSON.parse` will convert these back to `Infinity` / `-Infinity`
json = json
.replaceAll(POS_INFINITY_PLACEHOLDER_STR, "1e+400")
.replaceAll(NEG_INFINITY_PLACEHOLDER_STR, "-1e+400");
}
return JSON.parse(json);
}
const POS_INFINITY_PLACEHOLDER = "$_$_$_POS_INFINITY_$_$_$";
const NEG_INFINITY_PLACEHOLDER = "$_$_$_NEG_INFINITY_$_$_$";
const POS_INFINITY_PLACEHOLDER_STR = JSON.stringify(POS_INFINITY_PLACEHOLDER);
const NEG_INFINITY_PLACEHOLDER_STR = JSON.stringify(NEG_INFINITY_PLACEHOLDER);
/**
* Validate and conform `before` / `after` hook function.
* @param hookFn - Hook function, or `null` / `undefined`
* @param hookName - Name of the hook
* @returns Hook function, or null
* @throws {TypeError} If `hookFn` is not a function, `null`, or `undefined`
*/
function conformHookFn<H>(hookFn: H | null | undefined, hookName: string): H | null {
if (hookFn == null) return null;
if (typeof hookFn !== "function") {
throw new TypeError(`\`${hookName}\` hook must be a function if provided`);
}
return hookFn;
}