Skip to content

Commit 774d956

Browse files
committed
feat(linter): load custom JS plugins
1 parent 1141614 commit 774d956

File tree

10 files changed

+134
-11
lines changed

10 files changed

+134
-11
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/oxc_linter/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ description.workspace = true
1717
default = []
1818
ruledocs = ["oxc_macros/ruledocs"] # Enables the `ruledocs` feature for conditional compilation
1919
language_server = ["oxc_data_structures/rope"] # For the Runtime to support needed information for the language server
20-
oxlint2 = []
20+
oxlint2 = ["tokio/rt-multi-thread"]
2121
disable_oxlint2 = []
2222
force_test_reporter = []
2323

@@ -72,6 +72,7 @@ serde = { workspace = true, features = ["derive"] }
7272
serde_json = { workspace = true }
7373
simdutf8 = { workspace = true }
7474
smallvec = { workspace = true }
75+
tokio = { workspace = true }
7576

7677
[dev-dependencies]
7778
insta = { workspace = true }

crates/oxc_linter/src/config/config_builder.rs

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
use std::{
22
fmt::{self, Debug, Display},
3-
path::PathBuf,
3+
path::{Path, PathBuf},
44
};
55

66
use itertools::Itertools;
7+
use oxc_resolver::{ResolveOptions, Resolver};
78
use rustc_hash::FxHashMap;
89

910
use oxc_span::{CompactStr, format_compact_str};
@@ -88,7 +89,7 @@ impl ConfigStoreBuilder {
8889
pub fn from_oxlintrc(
8990
start_empty: bool,
9091
oxlintrc: Oxlintrc,
91-
_external_linter: Option<&ExternalLinter>,
92+
external_linter: Option<&ExternalLinter>,
9293
) -> Result<Self, ConfigBuilderError> {
9394
// TODO: this can be cached to avoid re-computing the same oxlintrc
9495
fn resolve_oxlintrc_config(
@@ -138,9 +139,14 @@ impl ConfigStoreBuilder {
138139
let (oxlintrc, extended_paths) = resolve_oxlintrc_config(oxlintrc)?;
139140

140141
if let Some(plugins) = oxlintrc.plugins.as_ref() {
141-
#[expect(clippy::never_loop)]
142+
let resolver = oxc_resolver::Resolver::new(ResolveOptions::default());
142143
for plugin_name in &plugins.external {
143-
return Err(ConfigBuilderError::UnknownPlugin(plugin_name.clone()));
144+
Self::load_external_plugin(
145+
&oxlintrc.path,
146+
plugin_name,
147+
external_linter,
148+
&resolver,
149+
)?;
144150
}
145151
}
146152
let plugins = oxlintrc.plugins.unwrap_or_default();
@@ -378,6 +384,53 @@ impl ConfigStoreBuilder {
378384
oxlintrc.rules = OxlintRules::new(new_rules);
379385
serde_json::to_string_pretty(&oxlintrc).unwrap()
380386
}
387+
388+
#[cfg(not(feature = "oxlint2"))]
389+
fn load_external_plugin(
390+
_oxlintrc_path: &Path,
391+
_plugin_name: &str,
392+
_external_linter: Option<&ExternalLinter>,
393+
_resolver: &Resolver,
394+
) -> Result<(), ConfigBuilderError> {
395+
Err(ConfigBuilderError::NoExternalLinterConfigured)
396+
}
397+
398+
#[cfg(feature = "oxlint2")]
399+
fn load_external_plugin(
400+
oxlintrc_path: &Path,
401+
plugin_name: &str,
402+
external_linter: Option<&ExternalLinter>,
403+
resolver: &Resolver,
404+
) -> Result<(), ConfigBuilderError> {
405+
use crate::PluginLoadResult;
406+
let Some(linter) = external_linter else {
407+
return Err(ConfigBuilderError::NoExternalLinterConfigured);
408+
};
409+
let resolved =
410+
resolver.resolve(oxlintrc_path.parent().unwrap(), plugin_name).map_err(|e| {
411+
ConfigBuilderError::PluginLoadFailed {
412+
plugin_name: plugin_name.into(),
413+
error: e.to_string(),
414+
}
415+
})?;
416+
417+
let result = tokio::task::block_in_place(move || {
418+
tokio::runtime::Handle::current()
419+
.block_on((linter.load_plugin)(resolved.full_path().to_str().unwrap().to_string()))
420+
})
421+
.map_err(|e| ConfigBuilderError::PluginLoadFailed {
422+
plugin_name: plugin_name.into(),
423+
error: e.to_string(),
424+
})?;
425+
426+
match result {
427+
PluginLoadResult::Success => Ok(()),
428+
PluginLoadResult::Failure(e) => Err(ConfigBuilderError::PluginLoadFailed {
429+
plugin_name: plugin_name.into(),
430+
error: e,
431+
}),
432+
}
433+
}
381434
}
382435

383436
fn get_name(plugin_name: &str, rule_name: &str) -> CompactStr {
@@ -418,7 +471,11 @@ pub enum ConfigBuilderError {
418471
file: String,
419472
reason: String,
420473
},
421-
UnknownPlugin(String),
474+
PluginLoadFailed {
475+
plugin_name: String,
476+
error: String,
477+
},
478+
NoExternalLinterConfigured,
422479
}
423480

424481
impl Display for ConfigBuilderError {
@@ -438,8 +495,13 @@ impl Display for ConfigBuilderError {
438495
ConfigBuilderError::InvalidConfigFile { file, reason } => {
439496
write!(f, "invalid config file {file}: {reason}")
440497
}
441-
ConfigBuilderError::UnknownPlugin(plugin_name) => {
442-
write!(f, "unknown plugin: {plugin_name}")
498+
ConfigBuilderError::PluginLoadFailed { plugin_name, error } => {
499+
write!(f, "Failed to load external plugin: {plugin_name}\n {error}")?;
500+
Ok(())
501+
}
502+
ConfigBuilderError::NoExternalLinterConfigured => {
503+
f.write_str("Failed to load external plugin because no external linter was configured. This means the Oxlint binary was executed directly rather than via napi bindings.")?;
504+
Ok(())
443505
}
444506
}
445507
}

napi/oxlint2/src/index.js

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,27 @@
11
import { lint } from './bindings.js';
22

33
class Linter {
4+
pluginRegistry = new Map();
5+
46
run() {
57
return lint(this.loadPlugin.bind(this), this.lint.bind(this));
68
}
79

8-
loadPlugin = async (_pluginName) => {
9-
throw new Error('unimplemented');
10+
loadPlugin = async (pluginName) => {
11+
if (this.pluginRegistry.has(pluginName)) {
12+
return JSON.stringify({ Success: null });
13+
}
14+
15+
try {
16+
const plugin = await import(pluginName);
17+
this.pluginRegistry.set(pluginName, plugin);
18+
return JSON.stringify({ Success: null });
19+
} catch (error) {
20+
const errorMessage = 'message' in error && typeof error.message === 'string'
21+
? error.message
22+
: 'An unknown error occurred';
23+
return JSON.stringify({ Failure: errorMessage });
24+
}
1025
};
1126

1227
lint = async () => {

napi/oxlint2/test/__snapshots__/e2e.test.ts.snap

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,16 @@ exports[`cli options for bundling > should lint a directory 1`] = `
44
"Found 0 warnings and 0 errors.
55
Finished in Xms on 0 files with 1 rules using X threads."
66
`;
7+
8+
exports[`cli options for bundling > should load a custom plugin 1`] = `
9+
"Found 0 warnings and 0 errors.
10+
Finished in Xms on 1 file using X threads."
11+
`;
12+
13+
exports[`cli options for bundling > should report an error if a custom plugin cannot be loaded 1`] = `
14+
"Failed to parse configuration file.
15+
16+
x Failed to load external plugin: ./test_plugin
17+
| Cannot find module './test_plugin'
18+
"
19+
`;

napi/oxlint2/test/e2e.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,22 @@ describe('cli options for bundling', () => {
2727
expect(exitCode).toBe(0);
2828
expect(normalizeOutput(stdout)).toMatchSnapshot();
2929
});
30+
31+
it('should load a custom plugin', async () => {
32+
const { stdout, exitCode } = await runOxlint(
33+
'test/fixtures/basic_custom_plugin',
34+
);
35+
36+
expect(exitCode).toBe(0);
37+
expect(normalizeOutput(stdout)).toMatchSnapshot();
38+
});
39+
40+
it('should report an error if a custom plugin cannot be loaded', async () => {
41+
const { stdout, exitCode } = await runOxlint(
42+
'test/fixtures/missing_custom_plugin',
43+
);
44+
45+
expect(exitCode).toBe(1);
46+
expect(normalizeOutput(stdout)).toMatchSnapshot();
47+
});
3048
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"plugins": [
3+
"./test_plugin"
4+
]
5+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default {
2+
rules: {},
3+
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"plugins": [
3+
"./test_plugin"
4+
]
5+
}

napi/playground/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ oxc_index = { workspace = true }
2828
oxc_linter = { workspace = true }
2929
oxc_napi = { workspace = true }
3030

31-
napi = { workspace = true }
31+
napi = { workspace = true, features = ["async"] }
3232
napi-derive = { workspace = true }
3333
rustc-hash = { workspace = true }
3434
serde = { workspace = true }

0 commit comments

Comments
 (0)