Skip to content

Commit e70f4fa

Browse files
committed
feat(linter/plugins): support messageIds
1 parent ba33e2b commit e70f4fa

File tree

13 files changed

+225
-6
lines changed

13 files changed

+225
-6
lines changed

apps/oxlint/src-js/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@ import type { CreateOnceRule, Plugin, Rule } from './plugins/load.ts';
33
import type { BeforeHook, Visitor, VisitorWithHooks } from './plugins/types.ts';
44

55
export type * as ESTree from './generated/types.d.ts';
6-
export type { Context, Diagnostic, DiagnosticBase, DiagnosticWithLoc, DiagnosticWithNode } from './plugins/context.ts';
6+
export type {
7+
Context,
8+
Diagnostic,
9+
DiagnosticBase,
10+
DiagnosticWithLoc,
11+
DiagnosticWithMessageId,
12+
DiagnosticWithNode,
13+
} from './plugins/context.ts';
714
export type { Fix, Fixer, FixFn } from './plugins/fix.ts';
815
export type { CreateOnceRule, CreateRule, Plugin, Rule } from './plugins/load.ts';
916
export type {

apps/oxlint/src-js/plugins/context.ts

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,29 @@ import type { Location, Ranged } from './types.ts';
99
const { hasOwn } = Object;
1010

1111
// Diagnostic in form passed by user to `Context#report()`
12-
export type Diagnostic = DiagnosticWithNode | DiagnosticWithLoc;
12+
export type Diagnostic = DiagnosticWithNode | DiagnosticWithLoc | DiagnosticWithMessageId;
1313

1414
export interface DiagnosticBase {
15-
message: string;
15+
message?: string;
1616
fix?: FixFn;
1717
}
1818

1919
export interface DiagnosticWithNode extends DiagnosticBase {
20+
message: string;
2021
node: Ranged;
2122
}
2223

2324
export interface DiagnosticWithLoc extends DiagnosticBase {
25+
message: string;
2426
loc: Location;
2527
}
2628

29+
export interface DiagnosticWithMessageId extends DiagnosticBase {
30+
messageId: string;
31+
node?: Ranged;
32+
loc?: Location;
33+
}
34+
2735
// Diagnostic in form sent to Rust
2836
interface DiagnosticReport {
2937
message: string;
@@ -83,6 +91,8 @@ export interface InternalContext {
8391
options: unknown[];
8492
// `true` if rule can provide fixes (`meta.fixable` in `RuleMeta` is 'code' or 'whitespace')
8593
isFixable: boolean;
94+
// Message templates for messageId support
95+
messages: Record<string, string> | null;
8696
}
8797

8898
/**
@@ -98,14 +108,17 @@ export class Context {
98108
/**
99109
* @class
100110
* @param fullRuleName - Rule name, in form `<plugin>/<rule>`
111+
* @param isFixable - Whether the rule can provide fixes
112+
* @param messages - Message templates for messageId support
101113
*/
102-
constructor(fullRuleName: string, isFixable: boolean) {
114+
constructor(fullRuleName: string, isFixable: boolean, messages: Record<string, string> | null = null) {
103115
this.#internal = {
104116
id: fullRuleName,
105117
filePath: '',
106118
ruleIndex: -1,
107119
options: [],
108120
isFixable,
121+
messages,
109122
};
110123
}
111124

@@ -144,8 +157,21 @@ export class Context {
144157
report(diagnostic: Diagnostic): void {
145158
const internal = getInternal(this, 'report errors');
146159

160+
// Resolve message from messageId if present
161+
let message: string;
162+
if (hasOwn(diagnostic, 'messageId')) {
163+
const diagWithMessageId = diagnostic as DiagnosticWithMessageId;
164+
message = this.#resolveMessage(diagWithMessageId.messageId, internal);
165+
} else {
166+
message = diagnostic.message;
167+
if (typeof message !== 'string') {
168+
throw new TypeError('Either `message` or `messageId` is required');
169+
}
170+
}
171+
147172
// TODO: Validate `diagnostic`
148173
let start: number, end: number, loc: Location;
174+
149175
if (hasOwn(diagnostic, 'loc') && (loc = (diagnostic as DiagnosticWithLoc).loc) != null) {
150176
// `loc`
151177
if (typeof loc !== 'object') throw new TypeError('`loc` must be an object');
@@ -177,14 +203,42 @@ export class Context {
177203
}
178204

179205
diagnostics.push({
180-
message: diagnostic.message,
206+
message,
181207
start,
182208
end,
183209
ruleIndex: internal.ruleIndex,
184210
fixes: getFixes(diagnostic, internal),
185211
});
186212
}
187213

214+
/**
215+
* Resolve a messageId to its message string.
216+
* @param messageId - The message ID to resolve
217+
* @param internal - Internal context containing messages
218+
* @returns Resolved message string
219+
* @throws {Error} If messageId is not found in messages
220+
*/
221+
#resolveMessage(
222+
messageId: string,
223+
internal: InternalContext,
224+
): string {
225+
const { messages } = internal;
226+
227+
if (!messages) {
228+
throw new Error(`Cannot use messageId '${messageId}' - rule does not define any messages in meta.messages`);
229+
}
230+
231+
if (!hasOwn(messages, messageId)) {
232+
throw new Error(
233+
`Unknown messageId '${messageId}'. Available messages: ${
234+
Object.keys(messages).map((msg) => `'${msg}'`).join(', ')
235+
}`,
236+
);
237+
}
238+
239+
return messages[messageId];
240+
}
241+
188242
static {
189243
setupContextForFile = (context, ruleIndex, filePath) => {
190244
// TODO: Support `options`

apps/oxlint/src-js/plugins/load.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ async function loadPluginImpl(path: string): Promise<PluginDetails> {
122122

123123
// Validate `rule.meta` and convert to vars with standardized shape
124124
let isFixable = false;
125+
let messages: Record<string, string> | null = null;
125126
let ruleMeta = rule.meta;
126127
if (ruleMeta != null) {
127128
if (typeof ruleMeta !== 'object') throw new TypeError('Invalid `meta`');
@@ -131,11 +132,19 @@ async function loadPluginImpl(path: string): Promise<PluginDetails> {
131132
if (fixable !== 'code' && fixable !== 'whitespace') throw new TypeError('Invalid `meta.fixable`');
132133
isFixable = true;
133134
}
135+
136+
// Extract messages for messageId support
137+
if (ruleMeta.messages != null) {
138+
if (typeof ruleMeta.messages !== 'object' || Array.isArray(ruleMeta.messages)) {
139+
throw new TypeError('Invalid `meta.messages` - must be an object');
140+
}
141+
messages = ruleMeta.messages;
142+
}
134143
}
135144

136145
// Create `Context` object for rule. This will be re-used for every file.
137146
// It's updated with file-specific data before linting each file with `setupContextForFile`.
138-
const context = new Context(`${pluginName}/${ruleName}`, isFixable);
147+
const context = new Context(`${pluginName}/${ruleName}`, isFixable, messages);
139148

140149
let ruleAndContext;
141150
if ('createOnce' in rule) {

apps/oxlint/src-js/plugins/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export interface EnterExit {
8686
// TODO: Fill in all properties.
8787
export interface RuleMeta {
8888
fixable?: 'code' | 'whitespace' | null | undefined;
89+
messages?: Record<string, string>;
8990
[key: string]: unknown;
9091
}
9192

apps/oxlint/test/e2e.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ describe('oxlint CLI', () => {
4949
await testFixture('basic_custom_plugin');
5050
});
5151

52+
it('should support messageId', async () => {
53+
await testFixture('message_id_plugin');
54+
});
55+
56+
it('should report an error for unknown messageId', async () => {
57+
await testFixture('message_id_error');
58+
});
59+
5260
it('should load a custom plugin with various import styles', async () => {
5361
await testFixture('load_paths');
5462
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"jsPlugins": ["./plugin.ts"],
3+
"categories": {
4+
"correctness": "off"
5+
},
6+
"rules": {
7+
"message-id-error-plugin/test-rule": "error"
8+
}
9+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
debugger;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Exit code
2+
1
3+
4+
# stdout
5+
```
6+
x Error running JS plugin.
7+
| File path: <root>/apps/oxlint/test/fixtures/message_id_error/files/index.js
8+
| Error: Unknown messageId 'unknownMessage'. Available messages: 'validMessage'
9+
| at DebuggerStatement (<root>/apps/oxlint/test/fixtures/message_id_error/plugin.ts:18:21)
10+
11+
Found 0 warnings and 1 error.
12+
Finished in Xms on 1 file using X threads.
13+
```
14+
15+
# stderr
16+
```
17+
WARNING: JS plugins are experimental and not subject to semver.
18+
Breaking changes are possible while JS plugins support is under development.
19+
```
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { Plugin } from '../../../dist/index.js';
2+
3+
const plugin: Plugin = {
4+
meta: {
5+
name: 'message-id-error-plugin',
6+
},
7+
rules: {
8+
'test-rule': {
9+
meta: {
10+
messages: {
11+
validMessage: 'This is a valid message',
12+
},
13+
},
14+
create(context) {
15+
return {
16+
DebuggerStatement(node) {
17+
// Try to use an unknown messageId
18+
context.report({
19+
messageId: 'unknownMessage',
20+
node,
21+
});
22+
},
23+
};
24+
},
25+
},
26+
},
27+
};
28+
29+
export default plugin;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"jsPlugins": ["./plugin.ts"],
3+
"categories": {
4+
"correctness": "off"
5+
},
6+
"rules": {
7+
"message-id-plugin/no-var": "error"
8+
}
9+
}

0 commit comments

Comments
 (0)