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

401 lines
16 KiB
TypeScript

/*
* Options for rules.
*/
import assert from "node:assert";
import Ajv from "ajv";
import ajvPackageJson from "ajv/package.json" with { type: "json" };
import metaSchema from "ajv/lib/refs/json-schema-draft-04.json" with { type: "json" };
import { setCwd } from "./context.ts";
import { registeredRules } from "./load.ts";
import { deepCloneJsonValue, deepFreezeJsonArray } from "./json.ts";
import { currentWorkspace, switchWorkspace } from "./workspace.ts";
import { debugAssert, debugAssertIsNonNull } from "../utils/asserts.ts";
import type { JSONSchema4 } from "json-schema";
import type { Writable } from "type-fest";
import type { JsonValue } from "./json.ts";
import type { RuleDetails } from "./load.ts";
// =================================================================================================
// Options types and constants
// =================================================================================================
/**
* Options for a rule on a file.
*/
export type Options = JsonValue[];
/**
* Schema describing valid options for a rule.
* `schema` property of `RuleMeta`.
*
* Can be one of:
* - `JSONSchema4` - Full JSON Schema object (must have `type: "array"` at root).
* - `JSONSchema4[]` - Array shorthand where each element describes corresponding options element.
* - `false` - Opts out of schema validation (not recommended).
*/
export type RuleOptionsSchema = JSONSchema4 | JSONSchema4[] | false;
/**
* Schema validator function.
* Does not return anything. Throws an error if validation fails.
* Should be passed full name of the rule, in form `<plugin>/<rule>`. This is used in error messages.
* `options` maybe be mutated if schema contains default values.
*/
export type SchemaValidator = (options: Options, ruleName: string) => void;
// Default rule options
export const DEFAULT_OPTIONS: Readonly<Options> = Object.freeze([]);
// All rule options.
// `lintFile` is called with an array of options IDs, which are indices into this array.
// First element is irrelevant - never accessed - because 0 index is a sentinel meaning default options.
// Set by `setOptions` at end of registering all plugins, and may also be changed when switching workspaces.
export let allOptions: Readonly<Options>[] | null = null;
/**
* Set `allOptions`. Used when switching workspaces.
* @param options - Array of options objects
*/
export function setAllOptions(options: Readonly<Options>[]) {
allOptions = options;
}
// Index into `allOptions` for default options
export const DEFAULT_OPTIONS_ID = 0;
// =================================================================================================
// Schema compilation
// =================================================================================================
// ESLint uses AJV v6.
// AJV v7 removed support for JSON Schema draft-04, which ESLint rule schemas use.
// We must stay on v6 to match ESLint's behavior.
debugAssert(
ajvPackageJson.version.startsWith("6."),
`AJV must be v6.x for JSON Schema draft-04 support, got ${ajvPackageJson.version}`,
);
// AJV instance configured to match ESLint's behavior.
// `useDefaults: true` applies schema `default` values to options during validation.
//
// Based on ESLint's AJV configuration:
// https://github.com/eslint/eslint/blob/v9.39.2/lib/config/config.js#L15-L16
// https://github.com/eslint/eslint/blob/v9.39.2/lib/shared/ajv.js#L18-L34
const AJV = new Ajv({
meta: false,
useDefaults: true,
validateSchema: false,
missingRefs: "ignore",
verbose: true,
schemaId: "auto",
});
AJV.addMetaSchema(metaSchema);
// Ajv internal API
(AJV._opts as { defaultMeta: string }).defaultMeta = metaSchema.id;
/**
* Compile a rule's schema into a validator function.
*
* Returned validator function will throw if validation fails.
*
* This function should be called once when loading a rule, and the returned validator stored in `RuleDetails`.
*
* ESLint allows array shorthand: `schema: [item1, item2]` which means `options[0]` must match `item1`, etc.
* This function converts that to a proper JSON Schema, before compiling it.
*
* Based on ESLint's `getRuleOptionsSchema`:
* https://github.com/eslint/eslint/blob/v9.39.2/lib/config/config.js#L177-L210
*
* @param schema - Rule's schema from `meta.schema`
* @returns Compiled AJV validator, or `null` if no options accepted, or `false` if validation is disabled
*/
export function compileSchema(
schema: RuleOptionsSchema | null | undefined,
): SchemaValidator | false | null {
// `null` or `undefined` means no validator provided. Rule does not accept any options.
// `false` means validation is disabled. Rule accepts any options.
if (schema == null) return null;
if (schema === false) return false;
// TODO: Does AJV already do this validation?
if (typeof schema !== "object") {
throw new TypeError("`rule.meta.schema` must be an array, object, or `false` if provided");
}
if (Array.isArray(schema)) {
// If schema is empty array, return `null`, indicating that rule does not accept any options
if (schema.length === 0) return null;
schema = {
type: "array",
items: schema,
minItems: 0,
maxItems: schema.length,
};
}
const validate = AJV.compile(schema);
return wrapSchemaValidator(validate);
}
/**
* Wrap AJV validator function to throw if validation fails.
* @param validate - AJV validator function
* @returns Wrapped validator function, which throws if validation fails
*/
function wrapSchemaValidator(validate: Ajv.ValidateFunction): SchemaValidator {
return (options: Options, ruleName: string): void => {
validate(options);
if (!validate.errors) return;
// Copied from https://github.com/eslint/eslint/blob/v9.39.2/lib/config/config.js#L609-L634
// The code doesn't match AJV's types, but we use the same version of AJV as ESLint, so assuming it's OK.
// We cast `error` to `any` to avoid TS errors.
throw new Error(
`Options validation failed for rule '${ruleName}':\n`
+ `Options:\n${JSON.stringify(options, null, 2)}\n`
+ `Errors:\n`
+ validate.errors
.map((error: any) => {
if (
error.keyword === "additionalProperties"
&& error.schema === false
&& typeof error.parentSchema?.properties === "object"
&& typeof error.params?.additionalProperty === "string"
) {
const expectedProperties = Object.keys(error.parentSchema.properties).map(
(property) => `"${property}"`,
);
return `\tValue ${JSON.stringify(error.data)} ${error.message}.\n\t\tUnexpected property "${error.params.additionalProperty}". Expected properties: ${expectedProperties.join(", ")}.`;
}
return `\tValue ${JSON.stringify(error.data)} ${error.message}.`;
})
.join("\n"),
);
};
}
// =================================================================================================
// Options processing
// =================================================================================================
/**
* Set all external rule options.
* Called once from Rust after config building, before any linting occurs.
* @param optionsJSON - Array of all rule options across all configurations, serialized as JSON
* @throws `Error` if options fail validation
*/
export function setOptions(optionsJson: string): void {
const details = JSON.parse(optionsJson);
allOptions = details.options;
debugAssertIsNonNull(allOptions, "`options` included in `optionsJSON` should not be null");
const { ruleIds, cwd } = details;
// Validate
if (DEBUG) {
assert(typeof cwd === "string", `cwd must be a string, got ${typeof cwd}`);
assert(Array.isArray(allOptions), `options must be an array, got ${typeof allOptions}`);
assert(Array.isArray(ruleIds), `ruleIds must be an array, got ${typeof ruleIds}`);
assert.strictEqual(
allOptions.length,
ruleIds.length,
"ruleIds and options arrays must be the same length",
);
for (const options of allOptions) {
assert(Array.isArray(options), `Elements of options must be arrays, got ${typeof options}`);
}
for (const ruleId of ruleIds) {
assert(
typeof ruleId === "number" && ruleId >= 0 && ruleId === Math.floor(ruleId),
`Elements of ruleIds must be non-negative integers, got ${ruleId}`,
);
}
}
// 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.
const { workspaceUri } = details;
if (workspaceUri !== null) switchWorkspace(workspaceUri);
// Set CWD
setCwd(cwd);
// Process each options array.
// For each options array, merge with default options and apply schema defaults for the corresponding rule.
// Skip the first, as index 0 is a sentinel value meaning default options. First element is never accessed.
// `processOptions` also deep-freezes the options.
for (let i = 1, len = allOptions.length; i < len; i++) {
allOptions[i] = processOptions(
// `allOptions`' type is `Readonly`, but the array is mutable at present
allOptions[i] as Writable<(typeof allOptions)[number]>,
registeredRules[ruleIds[i]],
);
}
// Store `cwd` and `allOptions` in workspace
if (workspaceUri !== null) {
debugAssertIsNonNull(currentWorkspace, "`currentWorkspace` should be initialized");
currentWorkspace.cwd = cwd;
currentWorkspace.allOptions = allOptions;
}
}
/**
* Process user-provided options for a rule by applying the rule's defaults
* - merging with default options and applying schema defaults for the rule.
*
* Order of operations (matching ESLint's behavior):
* 1. Merge with `defaultOptions` (config options take precedence).
* 2. Apply schema defaults via AJV (fills in remaining gaps).
*
* This order ensures precedence: config > defaultOptions > schema defaults.
*
* ESLint calls `#normalizeRulesConfig()` first (merges `defaultOptions`), then `validateRulesConfig()` (AJV):
* https://github.com/eslint/eslint/blob/v9.39.2/lib/config/config.js#L483-L484
* https://github.com/eslint/eslint/blob/v9.39.2/lib/config/config.js#L532-L637
*
* Returned options are deep frozen.
* `defaultOptions` must already be deep frozen before calling this function.
* `configOption` may be mutated.
*
* Default options merging follows the same logic as ESLint's `getRuleOptions`:
* https://github.com/eslint/eslint/blob/0f5a94a84beee19f376025c74f703f275d52c94b/lib/linter/linter.js#L443-L454
* https://github.com/eslint/eslint/blob/0f5a94a84beee19f376025c74f703f275d52c94b/lib/shared/deep-merge-arrays.js
*
* Notably, nested arrays are not merged - config options wins. e.g.:
* - Config options: [ [1] ]
* - Default options: [ [2, 3], 4 ]
* - Merged options: [ [1], 4 ]
*
* @param configOptions - Options from config (may be mutated by AJV)
* @param ruleDetails - Rule details
* @returns Processed options (deep frozen)
* @throws `Error` if options fail validation
*/
function processOptions(configOptions: Options, ruleDetails: RuleDetails): Readonly<Options> {
// Throw if no schema validator provided
const validator = ruleDetails.optionsSchemaValidator;
if (validator === null) {
throw new Error(`Rule '${ruleDetails.context.id}' does not accept options`);
}
// Merge with `defaultOptions` first
const { defaultOptions } = ruleDetails;
const options =
defaultOptions === DEFAULT_OPTIONS
? configOptions
: mergeOptions(configOptions, defaultOptions);
// Apply schema defaults (mutates `options` in place).
//
// AJV validation with `useDefaults: true` fills in default values from the schema.
// `mergeOptions` cloned `defaultOptions`, so mutations made by AJV validation won't affect `defaultOptions`
// (and `defaultOptions` is frozen anyway, so it can't be mutated).
// `configOptions` may be mutated, but that's OK, because we only use it once.
if (validator !== false) validator(options, ruleDetails.context.id);
deepFreezeJsonArray(options);
return options;
}
/**
* Merge user-provided options from config with rule's default options.
*
* Config options take precedence over default options.
*
* Options returned are entirely mutable. No parts are frozen, even parts which come from default options.
* Any parts of `defaultOptions` which are included are deep cloned.
* Any parts of `configOptions` which are included in return value are *not* cloned.
*
* @param configOptions - Options from config
* @param defaultOptions - Default options from `rule.meta.defaultOptions`
* @returns Merged options (mutable, not frozen)
*/
function mergeOptions(configOptions: Options, defaultOptions: Readonly<Options>): Options {
const merged: Options = [];
const defaultOptionsLength = defaultOptions.length,
configOptionsLength = configOptions.length,
bothLength = Math.min(defaultOptionsLength, configOptionsLength);
// Merge elements shared by both arrays
let i = 0;
for (; i < bothLength; i++) {
merged.push(mergeValues(configOptions[i], defaultOptions[i]));
}
// Take remaining elements from whichever array is longer
if (defaultOptionsLength > configOptionsLength) {
// `defaultOptions` has more elements - deep clone remaining elements
for (; i < defaultOptionsLength; i++) {
merged.push(deepCloneJsonValue(defaultOptions[i]));
}
} else {
// `configOptions` has more elements - just copy references (will be frozen later)
for (; i < configOptionsLength; i++) {
merged.push(configOptions[i]);
}
}
return merged;
}
/**
* Merge value from user-provided options with value from default options.
*
* Config value takes precedence over default value.
* Returns a mutable value (not frozen) - caller is responsible for freezing.
* `configValue` is mutated in place.
*
* @param configValue - Value from config
* @param defaultValue - Value from default options
* @returns Merged value (mutable)
*/
function mergeValues(configValue: JsonValue, defaultValue: JsonValue): JsonValue {
// If config value is a primitive or array, it wins
if (configValue === null || typeof configValue !== "object" || Array.isArray(configValue)) {
return configValue;
}
// If default value is a primitive or an array, config value wins (it's an object)
if (defaultValue === null || typeof defaultValue !== "object" || Array.isArray(defaultValue)) {
return configValue;
}
// Both are objects (not arrays) - merge `defaultValue` into `configValue`.
//
// Symbol properties and circular references are not possible in JSON, so no need to handle them here.
// `defaultValue` is from JSON, so we can use a simple `for..in` loop over `defaultValue`.
for (const key in defaultValue) {
// `Object.hasOwn` not `in`, in case `key` is `"__proto__"`
if (Object.hasOwn(configValue, key)) {
// Both have this key - recursively merge.
// `key` is an own property of both `configValue` and `defaultValue`.
// Therefore, we don't need special handling for if `key` is `"__proto__"`.
// All the property reads and writes here will affect only the owned properties of these objects,
// (including if those properties are named `"__proto__"`).
configValue[key] = mergeValues(configValue[key], defaultValue[key]);
} else {
// Only `defaultValue` has this key - deep clone and add to `configValue`.
// `Object.defineProperty` in case `key` is `"__proto__"`.
Object.defineProperty(configValue, key, {
value: deepCloneJsonValue(defaultValue[key]),
writable: true,
enumerable: true,
configurable: true,
});
}
}
return configValue;
}