Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/oxlint/src-js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
62 changes: 58 additions & 4 deletions apps/oxlint/src-js/plugins/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, string> | null;
}

/**
Expand All @@ -98,14 +108,17 @@ export class Context {
/**
* @class
* @param fullRuleName - Rule name, in form `<plugin>/<rule>`
* @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<string, string> | null = null) {
this.#internal = {
id: fullRuleName,
filePath: '',
ruleIndex: -1,
options: [],
isFixable,
messages,
};
}

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -177,14 +203,42 @@ export class Context {
}

diagnostics.push({
message: diagnostic.message,
message,
start,
end,
ruleIndex: internal.ruleIndex,
fixes: getFixes(diagnostic, internal),
});
}

/**
* 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`
Expand Down
11 changes: 10 additions & 1 deletion apps/oxlint/src-js/plugins/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ async function loadPluginImpl(path: string): Promise<PluginDetails> {

// Validate `rule.meta` and convert to vars with standardized shape
let isFixable = false;
let messages: Record<string, string> | null = null;
let ruleMeta = rule.meta;
if (ruleMeta != null) {
if (typeof ruleMeta !== 'object') throw new TypeError('Invalid `meta`');
Expand All @@ -131,11 +132,19 @@ async function loadPluginImpl(path: string): Promise<PluginDetails> {
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) {
Expand Down
1 change: 1 addition & 0 deletions apps/oxlint/src-js/plugins/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export interface EnterExit {
// TODO: Fill in all properties.
export interface RuleMeta {
fixable?: 'code' | 'whitespace' | null | undefined;
messages?: Record<string, string>;
[key: string]: unknown;
}

Expand Down
8 changes: 8 additions & 0 deletions apps/oxlint/test/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down
9 changes: 9 additions & 0 deletions apps/oxlint/test/fixtures/message_id_error/.oxlintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"jsPlugins": ["./plugin.ts"],
"categories": {
"correctness": "off"
},
"rules": {
"message-id-error-plugin/test-rule": "error"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
debugger;
19 changes: 19 additions & 0 deletions apps/oxlint/test/fixtures/message_id_error/output.snap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Exit code
1

# stdout
```
x Error running JS plugin.
| File path: <root>/apps/oxlint/test/fixtures/message_id_error/files/index.js
| Error: Unknown messageId 'unknownMessage'. Available messages: 'validMessage'
| at DebuggerStatement (<root>/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.
```
29 changes: 29 additions & 0 deletions apps/oxlint/test/fixtures/message_id_error/plugin.ts
Original file line number Diff line number Diff line change
@@ -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;
9 changes: 9 additions & 0 deletions apps/oxlint/test/fixtures/message_id_plugin/.oxlintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"jsPlugins": ["./plugin.ts"],
"categories": {
"correctness": "off"
},
"rules": {
"message-id-plugin/no-var": "error"
}
}
2 changes: 2 additions & 0 deletions apps/oxlint/test/fixtures/message_id_plugin/files/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
var reportUsingNode = 1;
var reportUsingRange = 1;
28 changes: 28 additions & 0 deletions apps/oxlint/test/fixtures/message_id_plugin/output.snap.md
Original file line number Diff line number Diff line change
@@ -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.
```
43 changes: 43 additions & 0 deletions apps/oxlint/test/fixtures/message_id_plugin/plugin.ts
Original file line number Diff line number Diff line change
@@ -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 : '<destructured>';

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;
Loading