diff --git a/apps/oxlint/src-js/index.ts b/apps/oxlint/src-js/index.ts index 8ebc219c92121..1ac31762c961f 100644 --- a/apps/oxlint/src-js/index.ts +++ b/apps/oxlint/src-js/index.ts @@ -4,16 +4,10 @@ import type { SourceCode } from './plugins/source_code.ts'; import type { BeforeHook, Visitor, VisitorWithHooks } from './plugins/types.ts'; export type * as ESTree from './generated/types.d.ts'; -export type { - Context, - Diagnostic, - DiagnosticBase, - DiagnosticWithLoc, - DiagnosticWithNode, - LanguageOptions, -} from './plugins/context.ts'; +export type { Context, LanguageOptions } from './plugins/context.ts'; export type { Fix, Fixer, FixFn } from './plugins/fix.ts'; export type { CreateOnceRule, CreateRule, Plugin, Rule } from './plugins/load.ts'; +export type { Diagnostic, DiagnosticBase, DiagnosticWithLoc, DiagnosticWithNode } from './plugins/report.ts'; export type { Definition, DefinitionType, diff --git a/apps/oxlint/src-js/plugins/context.ts b/apps/oxlint/src-js/plugins/context.ts index a819abb29a827..166850fe6a1d7 100644 --- a/apps/oxlint/src-js/plugins/context.ts +++ b/apps/oxlint/src-js/plugins/context.ts @@ -26,55 +26,23 @@ * and global variables (`filePath`, `settings`, `cwd`). */ -import { getFixes } from './fix.js'; -import { getOffsetFromLineColumn } from './location.js'; import { ast, initAst, SOURCE_CODE } from './source_code.js'; +import { report } from './report.js'; import { settings, initSettings } from './settings.js'; -import type { Fix, FixFn } from './fix.ts'; import type { RuleDetails } from './load.ts'; +import type { Diagnostic } from './report.ts'; import type { SourceCode } from './source_code.ts'; -import type { Location, Ranged } from './location.ts'; import type { ModuleKind } from '../generated/types.d.ts'; -const { hasOwn, keys: ObjectKeys, freeze, assign: ObjectAssign, create: ObjectCreate } = Object; - -// Diagnostic in form passed by user to `Context#report()` -export type Diagnostic = DiagnosticWithNode | DiagnosticWithLoc; - -export interface DiagnosticBase { - message?: string | null | undefined; - messageId?: string | null | undefined; - data?: Record | null | undefined; - fix?: FixFn; -} - -export interface DiagnosticWithNode extends DiagnosticBase { - node: Ranged; -} - -export interface DiagnosticWithLoc extends DiagnosticBase { - loc: Location; -} - -// Diagnostic in form sent to Rust -interface DiagnosticReport { - message: string; - start: number; - end: number; - ruleIndex: number; - fixes: Fix[] | null; -} - -// Diagnostics array. Reused for every file. -export const diagnostics: DiagnosticReport[] = []; +const { freeze, assign: ObjectAssign, create: ObjectCreate } = Object; // Cached current working directory let cwd: string | null = null; // Absolute path of file being linted. // When `null`, indicates that no file is currently being linted (in `createOnce`, or between linting files). -let filePath: string | null = null; +export let filePath: string | null = null; /** * Set up context for linting a file. @@ -391,124 +359,8 @@ export function createContext(fullRuleName: string, ruleDetails: RuleDetails): R * @throws {TypeError} If `diagnostic` is invalid */ report(diagnostic: Diagnostic): void { - // Delegate to `reportImpl`, passing rule-specific details (`RuleDetails`) - reportImpl(diagnostic, ruleDetails); + // Delegate to `report` implementation shared between all rules, passing rule-specific details (`RuleDetails`) + report(diagnostic, ruleDetails); }, } as unknown as Context); // It seems TS can't understand `__proto__: FILE_CONTEXT` } - -/** - * Report error. - * @param diagnostic - Diagnostic object - * @param ruleDetails - `RuleDetails` object, containing rule-specific details e.g. `isFixable` - * @throws {TypeError} If `diagnostic` is invalid - */ -function reportImpl(diagnostic: Diagnostic, ruleDetails: RuleDetails): void { - if (filePath === null) throw new Error('Cannot report errors in `createOnce`'); - - // Get message, resolving message from `messageId` if present - let message = getMessage(diagnostic, ruleDetails); - - // 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; - }); - } - } - - // 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: ruleDetails.ruleIndex, - fixes: getFixes(diagnostic, ruleDetails), - }); -} - -/** - * Get message from diagnostic. - * @param diagnostic - Diagnostic object - * @param ruleDetails - `RuleDetails` 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, ruleDetails: RuleDetails): string { - if (hasOwn(diagnostic, 'messageId')) { - const { messageId } = diagnostic as { messageId: string | null | undefined }; - if (messageId != null) return resolveMessageFromMessageId(messageId, ruleDetails); - } - - if (hasOwn(diagnostic, 'message')) { - const { message } = diagnostic; - if (typeof message === 'string') return message; - if (message != null) throw new TypeError('`message` must be a string'); - } - - throw new Error('Either `message` or `messageId` is required'); -} - -/** - * Resolve a message ID to its message string, with optional data interpolation. - * @param messageId - The message ID to resolve - * @param ruleDetails - `RuleDetails` object, containing rule-specific `messages` - * @returns Resolved message string - * @throws {Error} If `messageId` is not found in `messages` - */ -function resolveMessageFromMessageId(messageId: string, ruleDetails: RuleDetails): string { - const { messages } = ruleDetails; - if (messages === null) { - throw new Error(`Cannot use messageId '${messageId}' - rule does not define any messages in \`meta.messages\``); - } - - if (!hasOwn(messages, messageId)) { - throw new Error( - `Unknown messageId '${messageId}'. Available \`messageIds\`: ${ObjectKeys(messages) - .map((msg) => `'${msg}'`) - .join(', ')}`, - ); - } - - return messages[messageId]; -} diff --git a/apps/oxlint/src-js/plugins/fix.ts b/apps/oxlint/src-js/plugins/fix.ts index 885136ebb3508..8a47d2c101b2b 100644 --- a/apps/oxlint/src-js/plugins/fix.ts +++ b/apps/oxlint/src-js/plugins/fix.ts @@ -1,8 +1,8 @@ import { assertIs } from './utils.js'; -import type { Diagnostic } from './context.ts'; import type { RuleDetails } from './load.ts'; import type { Range, Ranged } from './location.ts'; +import type { Diagnostic } from './report.ts'; const { prototype: ArrayPrototype, from: ArrayFrom } = Array, { getPrototypeOf, hasOwn, prototype: ObjectPrototype } = Object, diff --git a/apps/oxlint/src-js/plugins/lint.ts b/apps/oxlint/src-js/plugins/lint.ts index 05db6426eb388..e7da024de00b2 100644 --- a/apps/oxlint/src-js/plugins/lint.ts +++ b/apps/oxlint/src-js/plugins/lint.ts @@ -1,5 +1,6 @@ -import { diagnostics, setupFileContext, resetFileContext } from './context.js'; +import { setupFileContext, resetFileContext } from './context.js'; import { registeredRules } from './load.js'; +import { diagnostics } from './report.js'; import { setSettingsForFile, resetSettings } from './settings.js'; import { ast, initAst, resetSourceAndAst, setupSourceForFile } from './source_code.js'; import { assertIs, getErrorMessage } from './utils.js'; diff --git a/apps/oxlint/src-js/plugins/report.ts b/apps/oxlint/src-js/plugins/report.ts new file mode 100644 index 0000000000000..b9de6b965196b --- /dev/null +++ b/apps/oxlint/src-js/plugins/report.ts @@ -0,0 +1,164 @@ +/* + * `report` function to report errors + diagnostic types. + */ + +import { filePath } from './context.js'; +import { getFixes } from './fix.js'; +import { getOffsetFromLineColumn } from './location.js'; + +import type { Fix, FixFn } from './fix.ts'; +import type { RuleDetails } from './load.ts'; +import type { Location, Ranged } from './location.ts'; + +const { hasOwn, keys: ObjectKeys } = Object; + +/** + * Diagnostic object. + * Passed to `Context#report()`. + */ +// This is the type of the value passed to `Context#report()` by user. +// `DiagnosticReport` (see below) is the type of diagnostics sent to Rust. +export type Diagnostic = DiagnosticWithNode | DiagnosticWithLoc; + +export interface DiagnosticBase { + message?: string | null | undefined; + messageId?: string | null | undefined; + data?: Record | null | undefined; + fix?: FixFn; +} + +export interface DiagnosticWithNode extends DiagnosticBase { + node: Ranged; +} + +export interface DiagnosticWithLoc extends DiagnosticBase { + loc: Location; +} + +// Diagnostic in form sent to Rust +interface DiagnosticReport { + message: string; + start: number; + end: number; + ruleIndex: number; + fixes: Fix[] | null; +} + +// Diagnostics array. Reused for every file. +export const diagnostics: DiagnosticReport[] = []; + +/** + * Report error. + * @param diagnostic - Diagnostic object + * @param ruleDetails - `RuleDetails` object, containing rule-specific details e.g. `isFixable` + * @throws {TypeError} If `diagnostic` is invalid + */ +export function report(diagnostic: Diagnostic, ruleDetails: RuleDetails): void { + if (filePath === null) throw new Error('Cannot report errors in `createOnce`'); + + // Get message, resolving message from `messageId` if present + let message = getMessage(diagnostic, ruleDetails); + + // 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; + }); + } + } + + // 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: ruleDetails.ruleIndex, + fixes: getFixes(diagnostic, ruleDetails), + }); +} + +/** + * Get message from diagnostic. + * @param diagnostic - Diagnostic object + * @param ruleDetails - `RuleDetails` 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, ruleDetails: RuleDetails): string { + if (hasOwn(diagnostic, 'messageId')) { + const { messageId } = diagnostic as { messageId: string | null | undefined }; + if (messageId != null) return resolveMessageFromMessageId(messageId, ruleDetails); + } + + if (hasOwn(diagnostic, 'message')) { + const { message } = diagnostic; + if (typeof message === 'string') return message; + if (message != null) throw new TypeError('`message` must be a string'); + } + + throw new Error('Either `message` or `messageId` is required'); +} + +/** + * Resolve a message ID to its message string, with optional data interpolation. + * @param messageId - The message ID to resolve + * @param ruleDetails - `RuleDetails` object, containing rule-specific `messages` + * @returns Resolved message string + * @throws {Error} If `messageId` is not found in `messages` + */ +function resolveMessageFromMessageId(messageId: string, ruleDetails: RuleDetails): string { + const { messages } = ruleDetails; + if (messages === null) { + throw new Error(`Cannot use messageId '${messageId}' - rule does not define any messages in \`meta.messages\``); + } + + if (!hasOwn(messages, messageId)) { + throw new Error( + `Unknown messageId '${messageId}'. Available \`messageIds\`: ${ObjectKeys(messages) + .map((msg) => `'${msg}'`) + .join(', ')}`, + ); + } + + return messages[messageId]; +}