Skip to content

Commit 17db742

Browse files
committed
fix(linter/plugins): output errors thrown in JS plugins
1 parent aba8a89 commit 17db742

File tree

25 files changed

+331
-23
lines changed

25 files changed

+331
-23
lines changed

apps/oxlint/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ napi = { workspace = true, features = ["async"], optional = true }
3434
napi-derive = { workspace = true, optional = true }
3535
rayon = { workspace = true }
3636
rustc-hash = { workspace = true }
37-
serde = { workspace = true }
37+
serde = { workspace = true, features = ["derive"] }
3838
serde_json = { workspace = true }
3939
simdutf8 = { workspace = true }
4040
tempfile = { workspace = true }

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

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
} from '../generated/constants.js';
77
import { diagnostics, setupContextForFile } from './context.js';
88
import { registeredRules } from './load.js';
9-
import { assertIs } from './utils.js';
9+
import { assertIs, getErrorMessage } from './utils.js';
1010
import { addVisitorToCompiled, compiledVisitor, finalizeCompiledVisitor, initCompiledVisitor } from './visitor.js';
1111

1212
// Lazy implementation
@@ -43,8 +43,40 @@ const textDecoder = new TextDecoder('utf-8', { ignoreBOM: true });
4343
// Array of `after` hooks to run after traversal. This array reused for every file.
4444
const afterHooks: AfterHook[] = [];
4545

46-
// Run rules on a file.
46+
/**
47+
* Run rules on a file.
48+
*
49+
* Main logic is in separate function `lintFileImpl`, because V8 cannot optimize functions containing try/catch.
50+
*
51+
* @param filePath - Absolute path of file being linted
52+
* @param bufferId - ID of buffer containing file data
53+
* @param buffer - Buffer containing file data, or `null` if buffer with this ID was previously sent to JS
54+
* @param ruleIds - IDs of rules to run on this file
55+
* @returns JSON result
56+
*/
4757
export function lintFile(filePath: string, bufferId: number, buffer: Uint8Array | null, ruleIds: number[]): string {
58+
try {
59+
lintFileImpl(filePath, bufferId, buffer, ruleIds);
60+
return JSON.stringify({ Success: diagnostics });
61+
} catch (err) {
62+
return JSON.stringify({ Failure: getErrorMessage(err) });
63+
} finally {
64+
diagnostics.length = 0;
65+
}
66+
}
67+
68+
/**
69+
* Run rules on a file.
70+
*
71+
* @param filePath - Absolute path of file being linted
72+
* @param bufferId - ID of buffer containing file data
73+
* @param buffer - Buffer containing file data, or `null` if buffer with this ID was previously sent to JS
74+
* @param ruleIds - IDs of rules to run on this file
75+
* @returns Diagnostics to send back to Rust
76+
* @throws {Error} If any parameters are invalid
77+
* @throws {*} If any rule throws
78+
*/
79+
function lintFileImpl(filePath: string, bufferId: number, buffer: Uint8Array | null, ruleIds: number[]) {
4880
// If new buffer, add it to `buffers` array. Otherwise, get existing buffer from array.
4981
// Do this before checks below, to make sure buffer doesn't get garbage collected when not expected
5082
// if there's an error.
@@ -142,9 +174,4 @@ export function lintFile(filePath: string, bufferId: number, buffer: Uint8Array
142174
// Reset array, ready for next file
143175
afterHooks.length = 0;
144176
}
145-
146-
// Send diagnostics back to Rust
147-
const ret = JSON.stringify(diagnostics);
148-
diagnostics.length = 0;
149-
return ret;
150177
}

apps/oxlint/src/js_plugins/external_linter.rs

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use napi::{
55
bindgen_prelude::{FnArgs, Uint8Array},
66
threadsafe_function::ThreadsafeFunctionCallMode,
77
};
8+
use serde::Deserialize;
89

910
use oxc_allocator::{Allocator, free_fixed_size_allocator};
1011
use oxc_linter::{
@@ -48,6 +49,13 @@ fn wrap_load_plugin(cb: JsLoadPluginCb) -> ExternalLinterLoadPluginCb {
4849
})
4950
}
5051

52+
/// Result returned by `lintFile` JS callback.
53+
#[derive(Clone, Debug, Deserialize)]
54+
pub enum LintFileReturnValue {
55+
Success(Vec<LintFileResult>),
56+
Failure(String),
57+
}
58+
5159
/// Wrap `lintFile` JS callback as a normal Rust function.
5260
///
5361
/// The returned function creates a `Uint8Array` referencing the memory of the given `Allocator`,
@@ -79,7 +87,7 @@ fn wrap_lint_file(cb: JsLintFileCb) -> ExternalLinterLintFileCb {
7987
ThreadsafeFunctionCallMode::NonBlocking,
8088
move |result, _env| {
8189
let _ = match &result {
82-
Ok(r) => match serde_json::from_str::<Vec<LintFileResult>>(r) {
90+
Ok(r) => match serde_json::from_str::<LintFileReturnValue>(r) {
8391
Ok(v) => tx.send(Ok(v)),
8492
Err(_e) => tx.send(Err("Failed to deserialize lint result".to_string())),
8593
},
@@ -90,14 +98,15 @@ fn wrap_lint_file(cb: JsLintFileCb) -> ExternalLinterLintFileCb {
9098
},
9199
);
92100

93-
if status != Status::Ok {
94-
return Err(format!("Failed to schedule callback: {status:?}"));
95-
}
101+
assert!(status == Status::Ok, "Failed to schedule callback: {status:?}");
96102

97103
match rx.recv() {
98-
Ok(Ok(x)) => Ok(x),
99-
Ok(Err(e)) => Err(format!("Callback reported error: {e}")),
100-
Err(e) => Err(format!("Callback did not respond: {e}")),
104+
Ok(Ok(x)) => match x {
105+
LintFileReturnValue::Success(diagnostics) => Ok(diagnostics),
106+
LintFileReturnValue::Failure(err) => Err(err),
107+
},
108+
Ok(Err(err)) => panic!("Callback reported error: {err}"),
109+
Err(err) => panic!("Callback did not respond: {err}"),
101110
}
102111
})
103112
}

apps/oxlint/test/__snapshots__/e2e.test.ts.snap

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,76 @@ exports[`oxlint CLI > should report an error if a custom plugin throws an error
690690
"
691691
`;
692692
693+
exports[`oxlint CLI > should report an error if a custom plugin throws an error during linting > in \`after\` hook 1`] = `
694+
"
695+
x Error running JS plugin.
696+
| File path: <root>/oxc/apps/oxlint/test/fixtures/custom_plugin_lint_after_hook_error/index.js
697+
| Error: Whoops!
698+
| at after (<root>/apps/oxlint/test/fixtures/custom_plugin_lint_after_hook_error/test_plugin/index.js:10:19)
699+
700+
Found 0 warnings and 1 error.
701+
Finished in Xms on 1 file using X threads."
702+
`;
703+
704+
exports[`oxlint CLI > should report an error if a custom plugin throws an error during linting > in \`before\` hook 1`] = `
705+
"
706+
x Error running JS plugin.
707+
| File path: <root>/oxc/apps/oxlint/test/fixtures/custom_plugin_lint_before_hook_error/index.js
708+
| Error: Whoops!
709+
| at before (<root>/apps/oxlint/test/fixtures/custom_plugin_lint_before_hook_error/test_plugin/index.js:10:19)
710+
711+
Found 0 warnings and 1 error.
712+
Finished in Xms on 1 file using X threads."
713+
`;
714+
715+
exports[`oxlint CLI > should report an error if a custom plugin throws an error during linting > in \`create\` method 1`] = `
716+
"
717+
x Error running JS plugin.
718+
| File path: <root>/oxc/apps/oxlint/test/fixtures/custom_plugin_lint_create_error/y.js
719+
| Error: Whoops!
720+
| at Object.create (<root>/apps/oxlint/test/fixtures/custom_plugin_lint_create_error/test_plugin/index.js:8:15)
721+
722+
x Error running JS plugin.
723+
| File path: <root>/oxc/apps/oxlint/test/fixtures/custom_plugin_lint_create_error/x.js
724+
| Error: Whoops!
725+
| at Object.create (<root>/apps/oxlint/test/fixtures/custom_plugin_lint_create_error/test_plugin/index.js:8:15)
726+
727+
Found 0 warnings and 2 errors.
728+
Finished in Xms on 2 files using X threads."
729+
`;
730+
731+
exports[`oxlint CLI > should report an error if a custom plugin throws an error during linting > in \`createOnce\` method 1`] = `
732+
"Failed to parse configuration file.
733+
734+
x Failed to load JS plugin: ./test_plugin
735+
| Error: Whoops!
736+
| at Object.createOnce (<root>/apps/oxlint/test/fixtures/custom_plugin_lint_createOnce_error/test_plugin/index.js:8:15)
737+
"
738+
`;
739+
740+
exports[`oxlint CLI > should report an error if a custom plugin throws an error during linting > in \`fix\` function 1`] = `
741+
"
742+
x Error running JS plugin.
743+
| File path: <root>/oxc/apps/oxlint/test/fixtures/custom_plugin_lint_fix_error/index.js
744+
| Error: Whoops!
745+
| at Object.fix (<root>/apps/oxlint/test/fixtures/custom_plugin_lint_fix_error/test_plugin/index.js:14:23)
746+
| at Identifier (<root>/apps/oxlint/test/fixtures/custom_plugin_lint_fix_error/test_plugin/index.js:10:21)
747+
748+
Found 0 warnings and 1 error.
749+
Finished in Xms on 1 file using X threads."
750+
`;
751+
752+
exports[`oxlint CLI > should report an error if a custom plugin throws an error during linting > in visit function 1`] = `
753+
"
754+
x Error running JS plugin.
755+
| File path: <root>/oxc/apps/oxlint/test/fixtures/custom_plugin_lint_visit_error/index.js
756+
| Error: Whoops!
757+
| at Identifier (<root>/apps/oxlint/test/fixtures/custom_plugin_lint_visit_error/test_plugin/index.js:10:19)
758+
759+
Found 0 warnings and 1 error.
760+
Finished in Xms on 1 file using X threads."
761+
`;
762+
693763
exports[`oxlint CLI > should report an error if a rule is not found within a custom plugin 1`] = `
694764
"Failed to parse configuration file.
695765

apps/oxlint/test/e2e.test.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest';
66
import { execa } from 'execa';
77

88
const PACKAGE_ROOT_PATH = dirname(import.meta.dirname);
9+
const ROOT_PATH = pathJoin(PACKAGE_ROOT_PATH, '../../../');
910
const CLI_PATH = pathJoin(PACKAGE_ROOT_PATH, 'dist/cli.js');
1011
const ROOT_URL = new URL('../../../', import.meta.url).href;
1112
const FIXTURES_URL = new URL('./fixtures/', import.meta.url).href;
@@ -37,12 +38,19 @@ function normalizeOutput(output: string): string {
3738
// Remove lines from stack traces which are outside `fixtures` directory.
3839
// Replace path to repo root in stack traces with `<root>`.
3940
lines = lines.flatMap((line) => {
40-
// e.g. ` | at file:///path/to/oxc/apps/oxlint/test/fixtures/foor/bar.js:1:1`
41-
// e.g. ` | at whatever (file:///path/to/oxc/apps/oxlint/test/fixtures/foor/bar.js:1:1)`
42-
const match = line.match(/^(\s*\|\s+at (?:.+?\()?)(.+)$/);
41+
// e.g. ` | at file:///path/to/oxc/apps/oxlint/test/fixtures/foo/bar.js:1:1`
42+
// e.g. ` | at whatever (file:///path/to/oxc/apps/oxlint/test/fixtures/foo/bar.js:1:1)`
43+
let match = line.match(/^(\s*\|\s+at (?:.+?\()?)(.+)$/);
4344
if (match) {
4445
const [, premable, at] = match;
4546
return at.startsWith(FIXTURES_URL) ? [`${premable}<root>/${at.slice(ROOT_URL.length)}`] : [];
47+
} else {
48+
// e.g. ` | File path: /path/to/oxc/apps/oxlint/test/fixtures/foo/bar.js`
49+
match = line.match(/^(\s*\|\s+File path: )(.+)$/);
50+
if (match) {
51+
const [, premable, path] = match;
52+
if (path.startsWith(ROOT_PATH)) return [`${premable}<root>/${path.slice(ROOT_PATH.length)}`];
53+
}
4654
}
4755
return [line];
4856
});
@@ -129,6 +137,44 @@ describe('oxlint CLI', () => {
129137
expect(normalizeOutput(stdout)).toMatchSnapshot();
130138
});
131139

140+
describe('should report an error if a custom plugin throws an error during linting', () => {
141+
it('in `create` method', async () => {
142+
const { stdout, exitCode } = await runOxlint('test/fixtures/custom_plugin_lint_create_error');
143+
expect(exitCode).toBe(1);
144+
expect(normalizeOutput(stdout)).toMatchSnapshot();
145+
});
146+
147+
it('in `createOnce` method', async () => {
148+
const { stdout, exitCode } = await runOxlint('test/fixtures/custom_plugin_lint_createOnce_error');
149+
expect(exitCode).toBe(1);
150+
expect(normalizeOutput(stdout)).toMatchSnapshot();
151+
});
152+
153+
it('in visit function', async () => {
154+
const { stdout, exitCode } = await runOxlint('test/fixtures/custom_plugin_lint_visit_error');
155+
expect(exitCode).toBe(1);
156+
expect(normalizeOutput(stdout)).toMatchSnapshot();
157+
});
158+
159+
it('in `before` hook', async () => {
160+
const { stdout, exitCode } = await runOxlint('test/fixtures/custom_plugin_lint_before_hook_error');
161+
expect(exitCode).toBe(1);
162+
expect(normalizeOutput(stdout)).toMatchSnapshot();
163+
});
164+
165+
it('in `after` hook', async () => {
166+
const { stdout, exitCode } = await runOxlint('test/fixtures/custom_plugin_lint_after_hook_error');
167+
expect(exitCode).toBe(1);
168+
expect(normalizeOutput(stdout)).toMatchSnapshot();
169+
});
170+
171+
it('in `fix` function', async () => {
172+
const { stdout, exitCode } = await runOxlint('test/fixtures/custom_plugin_lint_fix_error');
173+
expect(exitCode).toBe(1);
174+
expect(normalizeOutput(stdout)).toMatchSnapshot();
175+
});
176+
});
177+
132178
it('should report the correct severity when using a custom plugin', async () => {
133179
const { stdout, exitCode } = await runOxlint('test/fixtures/basic_custom_plugin_warn_severity');
134180
expect(exitCode).toBe(0);
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"plugins": ["./test_plugin"],
3+
"categories": { "correctness": "off" },
4+
"rules": {
5+
"error-plugin/error": "error"
6+
},
7+
"ignorePatterns": ["test_plugin/**"]
8+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
let x;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export default {
2+
meta: {
3+
name: "error-plugin",
4+
},
5+
rules: {
6+
error: {
7+
createOnce(_context) {
8+
return {
9+
after() {
10+
throw new Error("Whoops!");
11+
},
12+
};
13+
},
14+
},
15+
},
16+
};
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"plugins": ["./test_plugin"],
3+
"categories": { "correctness": "off" },
4+
"rules": {
5+
"error-plugin/error": "error"
6+
},
7+
"ignorePatterns": ["test_plugin/**"]
8+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
let x;

0 commit comments

Comments
 (0)