diff --git a/apps/oxlint/src-js/index.ts b/apps/oxlint/src-js/index.ts index 7578cb3436344..cd59790139d9a 100644 --- a/apps/oxlint/src-js/index.ts +++ b/apps/oxlint/src-js/index.ts @@ -3,7 +3,14 @@ import type { CreateOnceRule, Plugin, Rule } from './plugins/load.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 } from './plugins/context.ts'; +export type { + Context, + Diagnostic, + DiagnosticBase, + DiagnosticWithLoc, + DiagnosticWithMessageId, + DiagnosticWithNode, +} 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 { diff --git a/apps/oxlint/src-js/plugins/context.ts b/apps/oxlint/src-js/plugins/context.ts index a64da012cef7b..291c592fa0e59 100644 --- a/apps/oxlint/src-js/plugins/context.ts +++ b/apps/oxlint/src-js/plugins/context.ts @@ -9,21 +9,29 @@ import type { Location, Ranged } from './types.ts'; const { hasOwn } = Object; // Diagnostic in form passed by user to `Context#report()` -export type Diagnostic = DiagnosticWithNode | DiagnosticWithLoc; +export type Diagnostic = DiagnosticWithNode | DiagnosticWithLoc | DiagnosticWithMessageId; export interface DiagnosticBase { - message: string; + message?: string; fix?: FixFn; } export interface DiagnosticWithNode extends DiagnosticBase { + message: string; node: Ranged; } export interface DiagnosticWithLoc extends DiagnosticBase { + message: string; loc: Location; } +export interface DiagnosticWithMessageId extends DiagnosticBase { + messageId: string; + node?: Ranged; + loc?: Location; +} + // Diagnostic in form sent to Rust interface DiagnosticReport { message: string; @@ -83,6 +91,8 @@ export interface InternalContext { 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; } /** @@ -98,14 +108,17 @@ export class Context { /** * @class * @param fullRuleName - Rule name, in form `/` + * @param isFixable - Whether the rule can provide fixes + * @param messages - Message templates for messageId support */ - constructor(fullRuleName: string, isFixable: boolean) { + constructor(fullRuleName: string, isFixable: boolean, messages: Record | null = null) { this.#internal = { id: fullRuleName, filePath: '', ruleIndex: -1, options: [], isFixable, + messages, }; } @@ -144,8 +157,21 @@ export class Context { report(diagnostic: Diagnostic): void { const internal = getInternal(this, 'report errors'); + // Resolve message from messageId if present + let message: string; + if (hasOwn(diagnostic, 'messageId')) { + const diagWithMessageId = diagnostic as DiagnosticWithMessageId; + message = this.#resolveMessage(diagWithMessageId.messageId, internal); + } else { + message = diagnostic.message; + if (typeof message !== 'string') { + throw new TypeError('Either `message` or `messageId` is required'); + } + } + // 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'); @@ -177,7 +203,7 @@ export class Context { } diagnostics.push({ - message: diagnostic.message, + message, start, end, ruleIndex: internal.ruleIndex, @@ -185,6 +211,34 @@ export class Context { }); } + /** + * Resolve a messageId to its message string. + * @param messageId - The message ID to resolve + * @param internal - Internal context containing messages + * @returns Resolved message string + * @throws {Error} If messageId is not found in messages + */ + #resolveMessage( + messageId: string, + internal: InternalContext, + ): string { + const { messages } = internal; + + if (!messages) { + 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 messages: ${ + Object.keys(messages).map((msg) => `'${msg}'`).join(', ') + }`, + ); + } + + return messages[messageId]; + } + static { setupContextForFile = (context, ruleIndex, filePath) => { // TODO: Support `options` diff --git a/apps/oxlint/src-js/plugins/load.ts b/apps/oxlint/src-js/plugins/load.ts index c32728a10b7ea..1f1d7f85e626b 100644 --- a/apps/oxlint/src-js/plugins/load.ts +++ b/apps/oxlint/src-js/plugins/load.ts @@ -122,6 +122,7 @@ async function loadPluginImpl(path: string): Promise { // Validate `rule.meta` and convert to vars with standardized shape let isFixable = false; + let messages: Record | null = null; let ruleMeta = rule.meta; if (ruleMeta != null) { if (typeof ruleMeta !== 'object') throw new TypeError('Invalid `meta`'); @@ -131,11 +132,19 @@ async function loadPluginImpl(path: string): Promise { if (fixable !== 'code' && fixable !== 'whitespace') throw new TypeError('Invalid `meta.fixable`'); isFixable = true; } + + // Extract messages for messageId support + if (ruleMeta.messages != null) { + if (typeof ruleMeta.messages !== 'object' || Array.isArray(ruleMeta.messages)) { + throw new TypeError('Invalid `meta.messages` - must be an object'); + } + messages = ruleMeta.messages; + } } // Create `Context` object for rule. This will be re-used for every file. // It's updated with file-specific data before linting each file with `setupContextForFile`. - const context = new Context(`${pluginName}/${ruleName}`, isFixable); + const context = new Context(`${pluginName}/${ruleName}`, isFixable, messages); let ruleAndContext; if ('createOnce' in rule) { diff --git a/apps/oxlint/src-js/plugins/types.ts b/apps/oxlint/src-js/plugins/types.ts index 4efdf2346d341..5ff4775aef667 100644 --- a/apps/oxlint/src-js/plugins/types.ts +++ b/apps/oxlint/src-js/plugins/types.ts @@ -86,6 +86,7 @@ export interface EnterExit { // TODO: Fill in all properties. export interface RuleMeta { fixable?: 'code' | 'whitespace' | null | undefined; + messages?: Record; [key: string]: unknown; } diff --git a/apps/oxlint/test/e2e.test.ts b/apps/oxlint/test/e2e.test.ts index 95878a0650684..58e975a2cf54e 100644 --- a/apps/oxlint/test/e2e.test.ts +++ b/apps/oxlint/test/e2e.test.ts @@ -49,6 +49,14 @@ describe('oxlint CLI', () => { await testFixture('basic_custom_plugin'); }); + it('should support messageId', async () => { + await testFixture('message_id_plugin'); + }); + + it('should report an error for unknown messageId', async () => { + await testFixture('message_id_error'); + }); + it('should load a custom plugin with various import styles', async () => { await testFixture('load_paths'); }); diff --git a/apps/oxlint/test/fixtures/message_id_error/.oxlintrc.json b/apps/oxlint/test/fixtures/message_id_error/.oxlintrc.json new file mode 100644 index 0000000000000..f8d5095f61915 --- /dev/null +++ b/apps/oxlint/test/fixtures/message_id_error/.oxlintrc.json @@ -0,0 +1,9 @@ +{ + "jsPlugins": ["./plugin.ts"], + "categories": { + "correctness": "off" + }, + "rules": { + "message-id-error-plugin/test-rule": "error" + } +} diff --git a/apps/oxlint/test/fixtures/message_id_error/files/index.js b/apps/oxlint/test/fixtures/message_id_error/files/index.js new file mode 100644 index 0000000000000..eab74692130a6 --- /dev/null +++ b/apps/oxlint/test/fixtures/message_id_error/files/index.js @@ -0,0 +1 @@ +debugger; diff --git a/apps/oxlint/test/fixtures/message_id_error/output.snap.md b/apps/oxlint/test/fixtures/message_id_error/output.snap.md new file mode 100644 index 0000000000000..f79439823f81f --- /dev/null +++ b/apps/oxlint/test/fixtures/message_id_error/output.snap.md @@ -0,0 +1,19 @@ +# Exit code +1 + +# stdout +``` + x Error running JS plugin. + | File path: /apps/oxlint/test/fixtures/message_id_error/files/index.js + | Error: Unknown messageId 'unknownMessage'. Available messages: 'validMessage' + | at DebuggerStatement (/apps/oxlint/test/fixtures/message_id_error/plugin.ts:18:21) + +Found 0 warnings and 1 error. +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/message_id_error/plugin.ts b/apps/oxlint/test/fixtures/message_id_error/plugin.ts new file mode 100644 index 0000000000000..3e87bda6383e0 --- /dev/null +++ b/apps/oxlint/test/fixtures/message_id_error/plugin.ts @@ -0,0 +1,29 @@ +import type { Plugin } from '../../../dist/index.js'; + +const plugin: Plugin = { + meta: { + name: 'message-id-error-plugin', + }, + rules: { + 'test-rule': { + meta: { + messages: { + validMessage: 'This is a valid message', + }, + }, + create(context) { + return { + DebuggerStatement(node) { + // Try to use an unknown messageId + context.report({ + messageId: 'unknownMessage', + node, + }); + }, + }; + }, + }, + }, +}; + +export default plugin; diff --git a/apps/oxlint/test/fixtures/message_id_plugin/.oxlintrc.json b/apps/oxlint/test/fixtures/message_id_plugin/.oxlintrc.json new file mode 100644 index 0000000000000..89bc4dc7e9dd7 --- /dev/null +++ b/apps/oxlint/test/fixtures/message_id_plugin/.oxlintrc.json @@ -0,0 +1,9 @@ +{ + "jsPlugins": ["./plugin.ts"], + "categories": { + "correctness": "off" + }, + "rules": { + "message-id-plugin/no-var": "error" + } +} diff --git a/apps/oxlint/test/fixtures/message_id_plugin/files/index.js b/apps/oxlint/test/fixtures/message_id_plugin/files/index.js new file mode 100644 index 0000000000000..145608d290971 --- /dev/null +++ b/apps/oxlint/test/fixtures/message_id_plugin/files/index.js @@ -0,0 +1,2 @@ +var reportUsingNode = 1; +var reportUsingRange = 1; diff --git a/apps/oxlint/test/fixtures/message_id_plugin/output.snap.md b/apps/oxlint/test/fixtures/message_id_plugin/output.snap.md new file mode 100644 index 0000000000000..548baaf1ad925 --- /dev/null +++ b/apps/oxlint/test/fixtures/message_id_plugin/output.snap.md @@ -0,0 +1,28 @@ +# Exit code +1 + +# stdout +``` + x message-id-plugin(no-var): Unexpected var, use let or const instead. + ,-[files/index.js:1:1] + 1 | var reportUsingNode = 1; + : ^^^^^^^^^^^^^^^^^^^^^^^^ + 2 | var reportUsingRange = 1; + `---- + + x message-id-plugin(no-var): Unexpected var, use let or const instead. + ,-[files/index.js:2:1] + 1 | var reportUsingNode = 1; + 2 | var reportUsingRange = 1; + : ^^^^^^^^^^^^^^^^^^^^^^^^^ + `---- + +Found 0 warnings and 2 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/message_id_plugin/plugin.ts b/apps/oxlint/test/fixtures/message_id_plugin/plugin.ts new file mode 100644 index 0000000000000..f0fd2cd4ef9f1 --- /dev/null +++ b/apps/oxlint/test/fixtures/message_id_plugin/plugin.ts @@ -0,0 +1,43 @@ +import type { Plugin } from '../../../dist/index.js'; + +const MESSAGE_ID_ERROR = 'no-var/error'; +const messages = { + [MESSAGE_ID_ERROR]: 'Unexpected var, use let or const instead.', +}; + +const plugin: Plugin = { + meta: { + name: 'message-id-plugin', + }, + rules: { + 'no-var': { + meta: { + messages, + }, + create(context) { + return { + VariableDeclaration(node) { + if (node.kind === 'var') { + const decl = node.declarations[0]; + const varName = decl.id.type === 'Identifier' ? decl.id.name : ''; + + if (varName === 'reportUsingNode') { + context.report({ + messageId: MESSAGE_ID_ERROR, + node, + }); + } else if (varName === 'reportUsingRange') { + context.report({ + messageId: MESSAGE_ID_ERROR, + loc: node.loc, + }); + } + } + }, + }; + }, + }, + }, +}; + +export default plugin;