diff --git a/apps/oxlint/package.json b/apps/oxlint/package.json index 329d79b2415a5..1845fd08da453 100644 --- a/apps/oxlint/package.json +++ b/apps/oxlint/package.json @@ -44,6 +44,7 @@ "execa": "^9.6.0", "jiti": "^2.6.0", "tsdown": "catalog:", + "type-fest": "^5.2.0", "typescript": "catalog:", "vitest": "catalog:" }, diff --git a/apps/oxlint/src-js/plugins/context.ts b/apps/oxlint/src-js/plugins/context.ts index 82ff4cafc9119..55639187e935e 100644 --- a/apps/oxlint/src-js/plugins/context.ts +++ b/apps/oxlint/src-js/plugins/context.ts @@ -1,13 +1,42 @@ +/* + * Context objects for rules. + * + * Context objects are in 2 layers: + * 1. Context object for each rule. + * 2. File context object, shared across all rules. + * + * This mirrors ESLint's `RuleContext` and `FileContext` types (with `RuleContext` inheriting from `FileContext`). + * Some ESLint plugins rely on this 2-layer structure. https://github.com/oxc-project/oxc/issues/15325 + * + * The difference is that we don't create new file context and rule context objects for each file, but instead reuse + * the same objects over and over. After plugin loading is complete, no further `Context` objects are created. + * This reduces pressure on garbage collector, and is required to support `createOnce` API. + * + * ## Rule context + * + * Each rule has its own `Context` object. It is passed to that rule's `create` and `createOnce` functions. + * `Context` objects are created during plugin loading for each rule. + * For each file, the same `Context` object is reused over and over. + * + * ## File context + * + * All `Context` objects have `FILE_CONTEXT` as their prototype, which provides getters for file-specific properties. + * `FILE_CONTEXT` is a singleton object, shared across all rules. + * `FILE_CONTEXT` contains no state, only getters which return other singletons (`SOURCE_CODE`), + * and global variables (`filePath`, `settings`, `cwd`). + */ + import { getFixes } from './fix.js'; import { getOffsetFromLineColumn } from './location.js'; import { SOURCE_CODE } from './source_code.js'; import { settings, initSettings } from './settings.js'; import type { Fix, FixFn } from './fix.ts'; +import type { RuleAndContext } from './load.ts'; import type { SourceCode } from './source_code.ts'; import type { Location, Ranged } from './types.ts'; -const { hasOwn, keys: ObjectKeys } = Object; +const { hasOwn, keys: ObjectKeys, freeze } = Object; // Diagnostic in form passed by user to `Context#report()` export type Diagnostic = DiagnosticWithNode | DiagnosticWithLoc; @@ -42,217 +71,216 @@ export const diagnostics: DiagnosticReport[] = []; // Cached current working directory let cwd: string | null = null; -/** - * Update a `Context` with file-specific data. - * - * We have to define this function within class body, as it's not possible to access private property - * `#internal` from outside the class. - * We don't use a normal class method, because we don't want to expose this to user. - * - * @param context - `Context` object - * @param ruleIndex - Index of this rule within `ruleIds` passed from Rust - * @param filePath - Absolute path of file being linted - */ -export let setupContextForFile: (context: Context, ruleIndex: number, filePath: string) => void; - -// Internal data within `Context` that don't want to expose to plugins. -// Stored as `#internal` property of `Context`. -export interface InternalContext { - // Full rule name, including plugin name e.g. `my-plugin/my-rule`. - id: string; - // Index into `ruleIds` sent from Rust - ruleIndex: number; - // Absolute path of file being linted - filePath: string; - // Options - options: unknown[]; - // `true` if rule can provide fixes (`meta.fixable` in `RuleMeta` is 'code' or 'whitespace') - isFixable: boolean; - // Message templates for messageId support - messages: Record | null; -} +// Absolute path of file being linted. +let filePath: string | null = null; /** - * Get internal data from `Context`. - * - * Throws an `Error` if `Context` has not been set up for a file (in body of `createOnce`). - * - * We have to define this function within class body, as it's not possible to access private property - * `#internal` from outside the class. - * We don't use a normal class method, because we don't want to expose this to user. - * We don't use a private class method, because private property/method accesses are somewhat expensive. - * - * @param context - `Context` object - * @param actionDescription - Description of the action being attempted. Used in error message if context is not set up. - * @returns `InternalContext` object - * @throws {Error} If context has not been set up + * Set up context for linting a file. + * @param filePathInput - Absolute path of file being linted */ -let getInternal: (context: Context, actionDescription: string) => InternalContext; +export function setupFileContext(filePathInput: string): void { + filePath = filePathInput; +} /** - * Context class. + * Reset file context. * - * Each rule has its own `Context` object. It is passed to that rule's `create` function. + * This disables all getters on `Context` objects, and `FILE_CONTEXT`. + * Only way user could trigger a getter if this wasn't done is to store a `Context` object, and then access one of its + * properties in next tick, in between linting files (highly unlikely). But it's cheap to do, so we cover this odd case. */ -export class Context { - // Internal data. - // Initialized in constructor, updated by `setupContextForFile` before running visitor on file. - #internal: InternalContext; - - /** - * @class - * @param fullRuleName - Rule name, in form `/` - * @param isFixable - Whether the rule can provide fixes - * @param messages - Message templates for `messageId` support (or `null` if none) - */ - constructor(fullRuleName: string, isFixable: boolean, messages: Record | null) { - this.#internal = { - id: fullRuleName, - filePath: '', - ruleIndex: -1, - options: [], - isFixable, - messages, - }; - } - - // Getter for full rule name, in form `/` - get id() { - // Note: We can allow accessing `id` in `createOnce`, as it's not file-specific. So skip `getInternal` call. - return this.#internal.id; - } +export function resetFileContext(): void { + filePath = null; +} +// Singleton object for file-specific properties. +// +// Only one file is linted at a time, so we reuse a single object for all files. +// This object is used as the prototype for `Context` objects for each rule. +// It has no state, only getters which return other singletons, or global variables. +// +// IMPORTANT: Getters must not use `this`, to support wrapped context objects. +// https://github.com/oxc-project/oxc/issues/15325 +const FILE_CONTEXT = freeze({ // Getter for absolute path of file being linted. get filename() { - return getInternal(this, 'access `context.filename`').filePath; - } + if (filePath === null) throw new Error('Cannot access `context.filename` in `createOnce`'); + return filePath; + }, // Getter for absolute path of file being linted. // TODO: Unclear how this differs from `filename`. get physicalFilename() { - return getInternal(this, 'access `context.physicalFilename`').filePath; - } + if (filePath === null) throw new Error('Cannot access `context.physicalFilename` in `createOnce`'); + return filePath; + }, // Getter for current working directory. get cwd() { - // Note: We can allow accessing `cwd` in `createOnce`, as it's global. So skip `getInternal` call. + // Note: We can allow accessing `cwd` in `createOnce`, as it's global if (cwd === null) cwd = process.cwd(); return cwd; - } + }, - // Getter for options for file being linted. - get options() { - return getInternal(this, 'access `context.options`').options; - } + // Getter for `SourceCode` for file being linted. + get sourceCode(): SourceCode { + if (filePath === null) throw new Error('Cannot access `context.sourceCode` in `createOnce`'); + return SOURCE_CODE; + }, + // Getter for settings for file being linted. get settings() { - getInternal(this, 'access `context.settings`'); + if (filePath === null) throw new Error('Cannot access `context.settings` in `createOnce`'); if (settings === null) initSettings(); return settings; - } + }, +}); - // Getter for `SourceCode` for file being linted. - get sourceCode(): SourceCode { - getInternal(this, 'access `context.sourceCode`'); - return SOURCE_CODE; - } +type FileContext = typeof FILE_CONTEXT; - /** - * Report error. - * @param diagnostic - Diagnostic object - * @throws {TypeError} If `diagnostic` is invalid - */ - report(diagnostic: Diagnostic): void { - const internal = getInternal(this, 'report errors'); - - // Get message, resolving message from `messageId` if present - let message = getMessage(diagnostic, internal); - - // Interpolate placeholders {{key}} with data values - if (hasOwn(diagnostic, 'data')) { - const { data } = diagnostic; - if (data != null) { - message = message.replace(/\{\{([^}]+)\}\}/g, (match, key) => { - key = key.trim(); - const value = data[key]; - return value !== undefined ? String(value) : match; - }); - } - } +// Context object for a rule. +export interface Context extends FileContext { + // Rule ID, in form `/` + id: string; + // Rule options for this rule on this file. + // Getter, which returns `RuleAndContext#options`. + options: unknown[]; + // Report an error/warning. + report(diagnostic: Diagnostic): void; +} - // TODO: Validate `diagnostic` - let start: number, end: number, loc: Location; - - if (hasOwn(diagnostic, 'loc') && (loc = (diagnostic as DiagnosticWithLoc).loc) != null) { - // `loc` - if (typeof loc !== 'object') throw new TypeError('`loc` must be an object'); - start = getOffsetFromLineColumn(loc.start); - end = getOffsetFromLineColumn(loc.end); - } else { - // `node` - const { node } = diagnostic as DiagnosticWithNode; - if (node == null) throw new TypeError('Either `node` or `loc` is required'); - if (typeof node !== 'object') throw new TypeError('`node` must be an object'); - - // ESLint uses `loc` here instead of `range`. - // We can't do that because AST nodes don't have `loc` property yet. In any case, `range` is preferable, - // as otherwise we have to convert `loc` to `range` which is expensive at present. - // TODO: Revisit this once we have `loc` support in AST, and a fast translation table to convert `loc` to `range`. - const { range } = node; - if (range === null || typeof range !== 'object') throw new TypeError('`node.range` must be present'); - start = range[0]; - end = range[1]; - - // Do type validation checks here, to ensure no error in serialization / deserialization. - // Range validation happens on Rust side. - if ( - typeof start !== 'number' || - typeof end !== 'number' || - start < 0 || - end < 0 || - (start | 0) !== start || - (end | 0) !== end - ) { - throw new TypeError('`node.range[0]` and `node.range[1]` must be non-negative integers'); - } - } +/** + * Create `Context` object for a rule. + * @param fullRuleName - Full rule name, including plugin name e.g. `my-plugin/my-rule` + * @param ruleAndContext - `RuleAndContext` object + * @returns `Context` object + */ +export function createContext(fullRuleName: string, ruleAndContext: RuleAndContext): Readonly { + // Create `Context` object for rule. + // + // All properties are enumerable, to support a pattern which some ESLint plugins use: + // ``` + // function create(context) { + // const wrappedContext = { + // __proto__: Object.getPrototypeOf(context), + // ...context, + // report = (diagnostic) => { + // doSomethingBeforeReporting(diagnostic); + // context.report(diagnostic); + // }, + // }; + // return baseRule.create(wrappedContext); + // } + // ``` + // + // Object is frozen to prevent user mutating it. + // + // IMPORTANT: Methods/getters must not use `this`, to support wrapped context objects + // or e.g. `const { report } = context; report(diagnostic);`. + // https://github.com/oxc-project/oxc/issues/15325 + return freeze({ + // Inherit from `FILE_CONTEXT`, which provides getters for file-specific properties + __proto__: FILE_CONTEXT, + // Rule ID, in form `/` + id: fullRuleName, + // Getter for rule options for this rule on this file + get options(): Readonly { + if (filePath === null) throw new Error('Cannot access `context.options` in `createOnce`'); + return ruleAndContext.options; + }, + /** + * Report error. + * @param diagnostic - Diagnostic object + * @throws {TypeError} If `diagnostic` is invalid + */ + report(diagnostic: Diagnostic): void { + // Delegate to `reportImpl`, passing rule-specific details (`RuleAndContext`) + reportImpl(diagnostic, ruleAndContext); + }, + } as unknown as Context); // It seems TS can't understand `__proto__: FILE_CONTEXT` +} - diagnostics.push({ - message, - start, - end, - ruleIndex: internal.ruleIndex, - fixes: getFixes(diagnostic, internal), - }); +/** + * Report error. + * @param diagnostic - Diagnostic object + * @param ruleAndContext - `RuleAndContext` object, containing rule-specific details e.g. `isFixable` + * @throws {TypeError} If `diagnostic` is invalid + */ +function reportImpl(diagnostic: Diagnostic, ruleAndContext: RuleAndContext): void { + if (filePath === null) throw new Error('Cannot report errors in `createOnce`'); + + // Get message, resolving message from `messageId` if present + let message = getMessage(diagnostic, ruleAndContext); + + // Interpolate placeholders {{key}} with data values + if (hasOwn(diagnostic, 'data')) { + const { data } = diagnostic; + if (data != null) { + message = message.replace(/\{\{([^}]+)\}\}/g, (match, key) => { + key = key.trim(); + const value = data[key]; + return value !== undefined ? String(value) : match; + }); + } } - static { - setupContextForFile = (context, ruleIndex, filePath) => { - // TODO: Support `options` - const internal = context.#internal; - internal.ruleIndex = ruleIndex; - internal.filePath = filePath; - }; - - getInternal = (context, actionDescription) => { - const internal = context.#internal; - if (internal.ruleIndex === -1) throw new Error(`Cannot ${actionDescription} in \`createOnce\``); - return internal; - }; + // TODO: Validate `diagnostic` + let start: number, end: number, loc: Location; + + if (hasOwn(diagnostic, 'loc') && (loc = (diagnostic as DiagnosticWithLoc).loc) != null) { + // `loc` + if (typeof loc !== 'object') throw new TypeError('`loc` must be an object'); + start = getOffsetFromLineColumn(loc.start); + end = getOffsetFromLineColumn(loc.end); + } else { + // `node` + const { node } = diagnostic as DiagnosticWithNode; + if (node == null) throw new TypeError('Either `node` or `loc` is required'); + if (typeof node !== 'object') throw new TypeError('`node` must be an object'); + + // ESLint uses `loc` here instead of `range`. + // We can't do that because AST nodes don't have `loc` property yet. In any case, `range` is preferable, + // as otherwise we have to convert `loc` to `range` which is expensive at present. + // TODO: Revisit this once we have `loc` support in AST, and a fast translation table to convert `loc` to `range`. + const { range } = node; + if (range === null || typeof range !== 'object') throw new TypeError('`node.range` must be present'); + start = range[0]; + end = range[1]; + + // Do type validation checks here, to ensure no error in serialization / deserialization. + // Range validation happens on Rust side. + if ( + typeof start !== 'number' || + typeof end !== 'number' || + start < 0 || + end < 0 || + (start | 0) !== start || + (end | 0) !== end + ) { + throw new TypeError('`node.range[0]` and `node.range[1]` must be non-negative integers'); + } } + + diagnostics.push({ + message, + start, + end, + ruleIndex: ruleAndContext.ruleIndex, + fixes: getFixes(diagnostic, ruleAndContext), + }); } /** * Get message from diagnostic. * @param diagnostic - Diagnostic object - * @param internal - Internal context object + * @param ruleAndContext - `RuleAndContext` object, containing rule-specific `messages` * @returns Message string * @throws {Error|TypeError} If neither `message` nor `messageId` provided, or of wrong type */ -function getMessage(diagnostic: Diagnostic, internal: InternalContext): string { +function getMessage(diagnostic: Diagnostic, ruleAndContext: RuleAndContext): string { if (hasOwn(diagnostic, 'messageId')) { const { messageId } = diagnostic as { messageId: string | null | undefined }; - if (messageId != null) return resolveMessageFromMessageId(messageId, internal); + if (messageId != null) return resolveMessageFromMessageId(messageId, ruleAndContext); } if (hasOwn(diagnostic, 'message')) { @@ -267,12 +295,12 @@ function getMessage(diagnostic: Diagnostic, internal: InternalContext): string { /** * Resolve a message ID to its message string, with optional data interpolation. * @param messageId - The message ID to resolve - * @param internal - Internal context containing messages + * @param ruleAndContext - `RuleAndContext` object, containing rule-specific `messages` * @returns Resolved message string * @throws {Error} If `messageId` is not found in `messages` */ -function resolveMessageFromMessageId(messageId: string, internal: InternalContext): string { - const { messages } = internal; +function resolveMessageFromMessageId(messageId: string, ruleAndContext: RuleAndContext): string { + const { messages } = ruleAndContext; if (messages === null) { throw new Error(`Cannot use messageId '${messageId}' - rule does not define any messages in \`meta.messages\``); } diff --git a/apps/oxlint/src-js/plugins/fix.ts b/apps/oxlint/src-js/plugins/fix.ts index e8cd9260deab0..58dc1336733e2 100644 --- a/apps/oxlint/src-js/plugins/fix.ts +++ b/apps/oxlint/src-js/plugins/fix.ts @@ -1,6 +1,7 @@ import { assertIs } from './utils.js'; -import type { Diagnostic, InternalContext } from './context.ts'; +import type { Diagnostic } from './context.ts'; +import type { RuleAndContext } from './load.ts'; import type { Range, Ranged } from './types.ts'; const { prototype: ArrayPrototype, from: ArrayFrom } = Array, @@ -77,12 +78,12 @@ export type Fixer = typeof FIXER; * with getters. As we're not managing to be 100% bulletproof anyway, maybe we don't need to be quite so defensive. * * @param diagnostic - Diagnostic object - * @param internal - Internal context object + * @param ruleAndContext - `RuleAndContext` object, containing rule-specific `isFixable` value * @returns Non-empty array of `Fix` objects, or `null` if none * @throws {Error} If rule is not marked as fixable but `fix` function returns fixes, * or if `fix` function returns any invalid `Fix` objects */ -export function getFixes(diagnostic: Diagnostic, internal: InternalContext): Fix[] | null { +export function getFixes(diagnostic: Diagnostic, ruleAndContext: RuleAndContext): Fix[] | null { // ESLint silently ignores non-function `fix` values, so we do the same const { fix } = diagnostic; if (typeof fix !== 'function') return null; @@ -137,7 +138,7 @@ export function getFixes(diagnostic: Diagnostic, internal: InternalContext): Fix // ESLint does not throw this error if `fix` function returns only falsy values. // We've already exited if that is the case, so we're reproducing that behavior. - if (internal.isFixable === false) { + if (ruleAndContext.isFixable === false) { throw new Error('Fixable rules must set the `meta.fixable` property to "code" or "whitespace".'); } diff --git a/apps/oxlint/src-js/plugins/lint.ts b/apps/oxlint/src-js/plugins/lint.ts index f2579f7d2ebf7..e9f129efeabc0 100644 --- a/apps/oxlint/src-js/plugins/lint.ts +++ b/apps/oxlint/src-js/plugins/lint.ts @@ -1,4 +1,4 @@ -import { diagnostics, setupContextForFile } from './context.js'; +import { diagnostics, setupFileContext, resetFileContext } from './context.js'; import { registeredRules } from './load.js'; import { setSettingsForFile, resetSettings } from './settings.js'; import { ast, initAst, resetSourceAndAst, setupSourceForFile } from './source_code.js'; @@ -104,6 +104,9 @@ function lintFileImpl( throw new Error('Expected `ruleIds` to be a non-zero len array'); } + // Pass file path to context module, so `Context`s know what file is being linted + setupFileContext(filePath); + // Pass buffer to source code module, so it can decode source text and deserialize AST on demand. // // We don't want to do this eagerly, because all rules might return empty visitors, @@ -125,8 +128,11 @@ function lintFileImpl( for (let i = 0; i < ruleIds.length; i++) { const ruleId = ruleIds[i], ruleAndContext = registeredRules[ruleId]; + + // Set `ruleIndex` for rule. It's used when sending diagnostics back to Rust. + ruleAndContext.ruleIndex = i; + const { rule, context } = ruleAndContext; - setupContextForFile(context, i, filePath); let { visitor } = ruleAndContext; if (visitor === null) { @@ -182,7 +188,8 @@ function lintFileImpl( afterHooks.length = 0; } - // Reset source, AST, and settings, to free memory + // Reset file context, source, AST, and settings, to free memory + resetFileContext(); resetSourceAndAst(); resetSettings(); } diff --git a/apps/oxlint/src-js/plugins/load.ts b/apps/oxlint/src-js/plugins/load.ts index 68b0c5c2df6cc..eee39da385059 100644 --- a/apps/oxlint/src-js/plugins/load.ts +++ b/apps/oxlint/src-js/plugins/load.ts @@ -1,8 +1,10 @@ import { pathToFileURL } from 'node:url'; -import { Context } from './context.js'; +import { createContext } from './context.js'; import { getErrorMessage } from './utils.js'; +import type { Writable } from 'type-fest'; +import type { Context } from './context.ts'; import type { AfterHook, BeforeHook, RuleMeta, Visitor, VisitorWithHooks } from './types.ts'; const ObjectKeys = Object.keys; @@ -35,19 +37,27 @@ export interface CreateOnceRule { // Linter rule and context object. // If `rule` has a `createOnce` method, the visitor it returns is stored in `visitor`. -type RuleAndContext = CreateRuleAndContext | CreateOnceRuleAndContext; +export type RuleAndContext = CreateRuleAndContext | CreateOnceRuleAndContext; + +interface RuleAndContextBase { + // Static properties of the rule + readonly context: Readonly; + readonly isFixable: boolean; + readonly messages: Readonly> | null; + // Updated for each file + ruleIndex: number; + options: Readonly; +} -interface CreateRuleAndContext { +interface CreateRuleAndContext extends RuleAndContextBase { rule: CreateRule; - context: Context; visitor: null; beforeHook: null; afterHook: null; } -interface CreateOnceRuleAndContext { +interface CreateOnceRuleAndContext extends RuleAndContextBase { rule: CreateOnceRule; - context: Context; visitor: Visitor; beforeHook: BeforeHook | null; afterHook: AfterHook | null; @@ -63,6 +73,9 @@ export const registeredRules: RuleAndContext[] = []; // `before` hook which makes rule never run. const neverRunBeforeHook: BeforeHook = () => false; +// Default rule options +const DEFAULT_OPTIONS: Readonly = Object.freeze([]); + // Plugin details returned to Rust interface PluginDetails { // Plugin name @@ -149,11 +162,23 @@ async function loadPluginImpl(path: string, packageName?: string): Promise).context = context; - let ruleAndContext; if ('createOnce' in rule) { // TODO: Compile visitor object to array here, instead of repeating compilation on each file let visitorWithHooks = rule.createOnce(context); @@ -178,9 +203,9 @@ async function loadPluginImpl(path: string, packageName?: string): Promise { it('should return empty object for `parserServices` without throwing', async () => { await testFixture('parser_services'); }); + + it('wrapping context should work', async () => { + await testFixture('context_wrapping'); + }); }); diff --git a/apps/oxlint/test/fixtures/context_wrapping/.oxlintrc.json b/apps/oxlint/test/fixtures/context_wrapping/.oxlintrc.json new file mode 100644 index 0000000000000..ffe4e46065344 --- /dev/null +++ b/apps/oxlint/test/fixtures/context_wrapping/.oxlintrc.json @@ -0,0 +1,10 @@ +{ + "jsPlugins": ["./plugin.ts"], + "categories": { + "correctness": "off" + }, + "rules": { + "wrapped-context/wrapped-rule": "error", + "wrapped-context/wrapped-rule2": "error" + } +} diff --git a/apps/oxlint/test/fixtures/context_wrapping/files/index.js b/apps/oxlint/test/fixtures/context_wrapping/files/index.js new file mode 100644 index 0000000000000..e9fe0090d6381 --- /dev/null +++ b/apps/oxlint/test/fixtures/context_wrapping/files/index.js @@ -0,0 +1 @@ +console.log('Hello, world!'); diff --git a/apps/oxlint/test/fixtures/context_wrapping/output.snap.md b/apps/oxlint/test/fixtures/context_wrapping/output.snap.md new file mode 100644 index 0000000000000..a6155f1e3c6e5 --- /dev/null +++ b/apps/oxlint/test/fixtures/context_wrapping/output.snap.md @@ -0,0 +1,74 @@ +# Exit code +1 + +# stdout +``` + x wrapped-context(wrapped-rule): wrapped 1: filename: /files/index.js + ,-[files/index.js:1:1] + 1 | console.log('Hello, world!'); + : ^ + `---- + + x wrapped-context(wrapped-rule): wrapped 1: id: wrapped-context/wrapped-rule + ,-[files/index.js:1:1] + 1 | console.log('Hello, world!'); + : ^ + `---- + + x wrapped-context(wrapped-rule): wrapped 1: source text: "console.log('Hello, world!');\n" + ,-[files/index.js:1:1] + 1 | console.log('Hello, world!'); + : ^ + `---- + + x wrapped-context(wrapped-rule2): wrapped 2: filename: /files/index.js + ,-[files/index.js:1:1] + 1 | console.log('Hello, world!'); + : ^ + `---- + + x wrapped-context(wrapped-rule2): wrapped 2: id: wrapped-context/wrapped-rule2 + ,-[files/index.js:1:1] + 1 | console.log('Hello, world!'); + : ^ + `---- + + x wrapped-context(wrapped-rule2): wrapped 2: source text: "console.log('Hello, world!');\n" + ,-[files/index.js:1:1] + 1 | console.log('Hello, world!'); + : ^ + `---- + + x wrapped-context(wrapped-rule): wrapped 1: Identifier: 'console' + ,-[files/index.js:1:1] + 1 | console.log('Hello, world!'); + : ^^^^^^^ + `---- + + x wrapped-context(wrapped-rule2): wrapped 2: Identifier: 'console' + ,-[files/index.js:1:1] + 1 | console.log('Hello, world!'); + : ^^^^^^^ + `---- + + x wrapped-context(wrapped-rule): wrapped 1: Identifier: 'log' + ,-[files/index.js:1:9] + 1 | console.log('Hello, world!'); + : ^^^ + `---- + + x wrapped-context(wrapped-rule2): wrapped 2: Identifier: 'log' + ,-[files/index.js:1:9] + 1 | console.log('Hello, world!'); + : ^^^ + `---- + +Found 0 warnings and 10 errors. +Finished in Xms on 1 file using X threads. +``` + +# stderr +``` +WARNING: JS plugins are experimental and not subject to semver. +Breaking changes are possible while JS plugins support is under development. +``` diff --git a/apps/oxlint/test/fixtures/context_wrapping/plugin.ts b/apps/oxlint/test/fixtures/context_wrapping/plugin.ts new file mode 100644 index 0000000000000..fdb7dcfb3edf5 --- /dev/null +++ b/apps/oxlint/test/fixtures/context_wrapping/plugin.ts @@ -0,0 +1,62 @@ +import type { Plugin, Rule, Context, Diagnostic, Node } from '../../../dist/index.js'; + +const SPAN: Node = { + start: 0, + end: 0, + range: [0, 0], + loc: { + start: { line: 0, column: 0 }, + end: { line: 0, column: 0 }, + }, +}; + +function createWrappedReportFunction(context: Context, prefix: string): (diagnostic: Diagnostic) => void { + const { report } = context; + return (diagnostic: Diagnostic) => report({ ...diagnostic, message: `${prefix}: ${diagnostic.message}` }); +} + +const baseRule: Rule = { + create(context) { + context.report({ message: `id: ${context.id}`, node: SPAN }); + context.report({ message: `filename: ${context.filename}`, node: SPAN }); + context.report({ message: `source text: ${JSON.stringify(context.sourceCode.text)}`, node: SPAN }); + + return { + Identifier(node) { + context.report({ message: `Identifier: '${node.name}'`, node }); + }, + }; + }, +}; + +const plugin: Plugin = { + meta: { + name: 'wrapped-context', + }, + rules: { + // Two different forms of context wrapping + 'wrapped-rule': { + create(context) { + const wrappedContext = Object.create(context, { + report: { + value: createWrappedReportFunction(context, 'wrapped 1'), + writable: false, + }, + }); + + return baseRule.create(wrappedContext); + }, + }, + 'wrapped-rule2': { + create(context) { + const wrappedContext = Object.create(Object.getPrototypeOf(context)); + Object.assign(wrappedContext, context); + wrappedContext.report = createWrappedReportFunction(context, 'wrapped 2'); + + return baseRule.create(wrappedContext); + }, + }, + }, +}; + +export default plugin; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a2b5d410d562..7d41eeb5b0940 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,9 @@ importers: tsdown: specifier: 'catalog:' version: 0.15.12(@arethetypeswrong/core@0.18.2)(publint@0.3.15)(typescript@5.9.3) + type-fest: + specifier: ^5.2.0 + version: 5.2.0 typescript: specifier: 'catalog:' version: 5.9.3 @@ -3835,6 +3838,10 @@ packages: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tapable@2.3.0: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} @@ -3944,6 +3951,10 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + type-fest@5.2.0: + resolution: {integrity: sha512-xxCJm+Bckc6kQBknN7i9fnP/xobQRsRQxR01CztFkp/h++yfVxUUcmMgfR2HttJx/dpWjS9ubVuyspJv24Q9DA==} + engines: {node: '>=20'} + typed-rest-client@1.8.11: resolution: {integrity: sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==} @@ -7689,6 +7700,8 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + tagged-tag@1.0.0: {} + tapable@2.3.0: {} tar-fs@2.1.4: @@ -7794,6 +7807,10 @@ snapshots: type-fest@4.41.0: {} + type-fest@5.2.0: + dependencies: + tagged-tag: 1.0.0 + typed-rest-client@1.8.11: dependencies: qs: 6.14.0