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
2 changes: 2 additions & 0 deletions apps/oxlint/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"name": "oxlint",
"version": "1.16.0",
"main": "dist/index.js",
"bin": "dist/cli.js",
"type": "module",
"scripts": {
Expand Down Expand Up @@ -34,6 +35,7 @@
"dist"
],
"devDependencies": {
"eslint": "^9.36.0",
"execa": "^9.6.0",
"tsdown": "0.15.1",
"typescript": "catalog:",
Expand Down
59 changes: 59 additions & 0 deletions apps/oxlint/src-js/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { Context } from './plugins/context.ts';
import type { Rule } from './plugins/load.ts';

const { defineProperty, getPrototypeOf, setPrototypeOf } = Object;

const dummyOptions: unknown[] = [],
dummyReport = () => {};

// Define a rule.
// If rule has `createOnce` method, add an ESLint-compatible `create` method which delegates to `createOnce`.
export function defineRule(rule: Rule): Rule {
if (!('createOnce' in rule)) return rule;
if ('create' in rule) throw new Error('Rules must define only `create` or `createOnce` methods, not both');

// Run `createOnce` with empty context object.
// Really, `context` should be an instance of `Context`, which would throw error on accessing e.g. `id`
// in body of `createOnce`. But any such bugs should have been caught when testing the rule in Oxlint,
// so should be OK to take this shortcut.
const context = Object.create(null, {
id: { value: '', enumerable: true, configurable: true },
options: { value: dummyOptions, enumerable: true, configurable: true },
report: { value: dummyReport, enumerable: true, configurable: true },
});

const { before: beforeHook, after: afterHook, ...visitor } = rule.createOnce(context as Context);

// Add `after` hook to `Program:exit` visit fn
if (afterHook !== null) {
const programExit = visitor['Program:exit'];
visitor['Program:exit'] = programExit
? (node) => {
programExit(node);
afterHook();
}
: (_node) => afterHook();
}

// Create `create` function
rule.create = (eslintContext) => {
// Copy properties from ESLint's context object to `context`.
// ESLint's context object is an object of form `{ id, options, report }`, with all other properties
// and methods on another object which is its prototype.
defineProperty(context, 'id', { value: eslintContext.id });
defineProperty(context, 'options', { value: eslintContext.options });
defineProperty(context, 'report', { value: eslintContext.report });
setPrototypeOf(context, getPrototypeOf(eslintContext));

// If `before` hook returns `false`, skip rest of traversal by returning an empty object as visitor
if (beforeHook !== null) {
const shouldRun = beforeHook();
if (shouldRun === false) return {};
}

// Return same visitor each time
return visitor;
};

return rule;
}
2 changes: 1 addition & 1 deletion apps/oxlint/src-js/plugins/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ interface Plugin {
// Linter rule.
// `Rule` can have either `create` method, or `createOnce` method.
// If `createOnce` method is present, `create` is ignored.
type Rule = CreateRule | CreateOnceRule;
export type Rule = CreateRule | CreateOnceRule;

interface CreateRule {
create: (context: Context) => Visitor;
Expand Down
112 changes: 112 additions & 0 deletions apps/oxlint/test/__snapshots__/e2e.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -800,6 +800,118 @@ Found 0 warnings and 26 errors.
Finished in Xms on 2 files using X threads."
`;

exports[`oxlint CLI > should support \`defineRule\` 1`] = `
"
x define-rule-plugin(create): create body:
| this === rule: true
,-[files/1.js:1:1]
1 | let a, b;
: ^
\`----

x define-rule-plugin(create-once): after hook:
| identNum: 2
| filename: files/1.js
,-[files/1.js:1:1]
1 | let a, b;
: ^
\`----

x define-rule-plugin(create-once): before hook:
| this === rule: true
| filename: files/1.js
,-[files/1.js:1:1]
1 | let a, b;
: ^
\`----

x define-rule-plugin(create): ident visit fn "a":
| filename: files/1.js
,-[files/1.js:1:5]
1 | let a, b;
: ^
\`----

x define-rule-plugin(create-once): ident visit fn "a":
| identNum: 1
| filename: files/1.js
,-[files/1.js:1:5]
1 | let a, b;
: ^
\`----

x define-rule-plugin(create): ident visit fn "b":
| filename: files/1.js
,-[files/1.js:1:8]
1 | let a, b;
: ^
\`----

x define-rule-plugin(create-once): ident visit fn "b":
| identNum: 2
| filename: files/1.js
,-[files/1.js:1:8]
1 | let a, b;
: ^
\`----

x define-rule-plugin(create): create body:
| this === rule: true
,-[files/2.js:1:1]
1 | let c, d;
: ^
\`----

x define-rule-plugin(create-once): after hook:
| identNum: 2
| filename: files/2.js
,-[files/2.js:1:1]
1 | let c, d;
: ^
\`----

x define-rule-plugin(create-once): before hook:
| this === rule: true
| filename: files/2.js
,-[files/2.js:1:1]
1 | let c, d;
: ^
\`----

x define-rule-plugin(create): ident visit fn "c":
| filename: files/2.js
,-[files/2.js:1:5]
1 | let c, d;
: ^
\`----

x define-rule-plugin(create-once): ident visit fn "c":
| identNum: 1
| filename: files/2.js
,-[files/2.js:1:5]
1 | let c, d;
: ^
\`----

x define-rule-plugin(create): ident visit fn "d":
| filename: files/2.js
,-[files/2.js:1:8]
1 | let c, d;
: ^
\`----

x define-rule-plugin(create-once): ident visit fn "d":
| identNum: 2
| filename: files/2.js
,-[files/2.js:1:8]
1 | let c, d;
: ^
\`----

Found 0 warnings and 14 errors.
Finished in Xms on 2 files using X threads."
`;

exports[`oxlint CLI > should work with multiple rules 1`] = `
"
x basic-custom-plugin(no-debugger): Unexpected Debugger Statement
Expand Down
47 changes: 47 additions & 0 deletions apps/oxlint/test/__snapshots__/eslint-compat.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`ESLint compatibility > \`defineRule\` should work 1`] = `
"
<root>/apps/oxlint/test/fixtures/defineRule/files/1.js
0:1 error create body:
this === rule: true testPlugin/create
0:1 error before hook:
this === rule: true
filename: files/1.js testPlugin/create-once
0:1 error after hook:
identNum: 2
filename: files/1.js testPlugin/create-once
1:5 error ident visit fn "a":
filename: files/1.js testPlugin/create
1:5 error ident visit fn "a":
identNum: 1
filename: files/1.js testPlugin/create-once
1:8 error ident visit fn "b":
filename: files/1.js testPlugin/create
1:8 error ident visit fn "b":
identNum: 2
filename: files/1.js testPlugin/create-once

<root>/apps/oxlint/test/fixtures/defineRule/files/2.js
0:1 error create body:
this === rule: true testPlugin/create
0:1 error before hook:
this === rule: true
filename: files/2.js testPlugin/create-once
0:1 error after hook:
identNum: 2
filename: files/2.js testPlugin/create-once
1:5 error ident visit fn "c":
filename: files/2.js testPlugin/create
1:5 error ident visit fn "c":
identNum: 1
filename: files/2.js testPlugin/create-once
1:8 error ident visit fn "d":
filename: files/2.js testPlugin/create
1:8 error ident visit fn "d":
identNum: 2
filename: files/2.js testPlugin/create-once

✖ 14 problems (14 errors, 0 warnings)
"
`;
6 changes: 6 additions & 0 deletions apps/oxlint/test/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,12 @@ describe('oxlint CLI', () => {
expect(normalizeOutput(stdout)).toMatchSnapshot();
});

it('should support `defineRule`', async () => {
const { stdout, exitCode } = await runOxlint('test/fixtures/defineRule');
expect(exitCode).toBe(1);
expect(normalizeOutput(stdout)).toMatchSnapshot();
});

it('should have UTF-16 spans in AST', async () => {
const { stdout, exitCode } = await runOxlint('test/fixtures/utf16_offsets');
expect(exitCode).toBe(1);
Expand Down
32 changes: 32 additions & 0 deletions apps/oxlint/test/eslint-compat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { dirname, join as pathJoin } from 'node:path';

import { describe, expect, it } from 'vitest';

import { execa } from 'execa';

const PACKAGE_ROOT_PATH = dirname(import.meta.dirname);
const REPO_ROOT_PATH = pathJoin(PACKAGE_ROOT_PATH, '../..');

async function runEslint(cwd: string, args: string[] = []) {
return await execa('pnpx', ['eslint', ...args], {
cwd: pathJoin(PACKAGE_ROOT_PATH, cwd),
reject: false,
});
}

function normalizeOutput(output: string): string {
const lines = output.split('\n');
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i];
if (line.startsWith(REPO_ROOT_PATH)) lines[i] = `<root>${line.slice(REPO_ROOT_PATH.length)}`;
}
return lines.join('\n');
}

describe('ESLint compatibility', () => {
it('`defineRule` should work', async () => {
const { stdout, exitCode } = await runEslint('test/fixtures/defineRule');
expect(exitCode).toBe(1);
expect(normalizeOutput(stdout)).toMatchSnapshot();
});
});
9 changes: 9 additions & 0 deletions apps/oxlint/test/fixtures/defineRule/.oxlintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"plugins": ["./test_plugin"],
"categories": {"correctness": "off"},
"rules": {
"define-rule-plugin/create": "error",
"define-rule-plugin/create-once": "error"
},
"ignorePatterns": ["test_plugin/**", "eslint.config.js"]
}
14 changes: 14 additions & 0 deletions apps/oxlint/test/fixtures/defineRule/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import testPlugin from './test_plugin/index.js';

export default [
{
files: ["files/*.js"],
plugins: {
testPlugin,
},
rules: {
"testPlugin/create": "error",
"testPlugin/create-once": "error",
},
},
];
1 change: 1 addition & 0 deletions apps/oxlint/test/fixtures/defineRule/files/1.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
let a, b;
1 change: 1 addition & 0 deletions apps/oxlint/test/fixtures/defineRule/files/2.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
let c, d;
Loading
Loading