diff --git a/Cargo.lock b/Cargo.lock index 80493e49d579a..8241ead15bba8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1961,6 +1961,7 @@ dependencies = [ "serde_json", "simdutf8", "smallvec", + "tokio", ] [[package]] diff --git a/crates/oxc_linter/Cargo.toml b/crates/oxc_linter/Cargo.toml index bb4265e17ca9e..484cb3c632524 100644 --- a/crates/oxc_linter/Cargo.toml +++ b/crates/oxc_linter/Cargo.toml @@ -17,7 +17,7 @@ description.workspace = true default = [] ruledocs = ["oxc_macros/ruledocs"] # Enables the `ruledocs` feature for conditional compilation language_server = ["oxc_data_structures/rope"] # For the Runtime to support needed information for the language server -oxlint2 = [] +oxlint2 = ["tokio/rt-multi-thread"] disable_oxlint2 = [] force_test_reporter = [] @@ -72,6 +72,7 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } simdutf8 = { workspace = true } smallvec = { workspace = true } +tokio = { workspace = true } [dev-dependencies] insta = { workspace = true } diff --git a/crates/oxc_linter/src/config/config_builder.rs b/crates/oxc_linter/src/config/config_builder.rs index 219390a518766..47eab582f3d3f 100644 --- a/crates/oxc_linter/src/config/config_builder.rs +++ b/crates/oxc_linter/src/config/config_builder.rs @@ -1,9 +1,10 @@ use std::{ fmt::{self, Debug, Display}, - path::PathBuf, + path::{Path, PathBuf}, }; use itertools::Itertools; +use oxc_resolver::{ResolveOptions, Resolver}; use rustc_hash::FxHashMap; use oxc_span::{CompactStr, format_compact_str}; @@ -88,7 +89,7 @@ impl ConfigStoreBuilder { pub fn from_oxlintrc( start_empty: bool, oxlintrc: Oxlintrc, - _external_linter: Option<&ExternalLinter>, + external_linter: Option<&ExternalLinter>, ) -> Result { // TODO: this can be cached to avoid re-computing the same oxlintrc fn resolve_oxlintrc_config( @@ -138,9 +139,14 @@ impl ConfigStoreBuilder { let (oxlintrc, extended_paths) = resolve_oxlintrc_config(oxlintrc)?; if let Some(plugins) = oxlintrc.plugins.as_ref() { - #[expect(clippy::never_loop)] + let resolver = oxc_resolver::Resolver::new(ResolveOptions::default()); for plugin_name in &plugins.external { - return Err(ConfigBuilderError::UnknownPlugin(plugin_name.clone())); + Self::load_external_plugin( + &oxlintrc.path, + plugin_name, + external_linter, + &resolver, + )?; } } let plugins = oxlintrc.plugins.unwrap_or_default(); @@ -378,6 +384,53 @@ impl ConfigStoreBuilder { oxlintrc.rules = OxlintRules::new(new_rules); serde_json::to_string_pretty(&oxlintrc).unwrap() } + + #[cfg(not(feature = "oxlint2"))] + fn load_external_plugin( + _oxlintrc_path: &Path, + _plugin_name: &str, + _external_linter: Option<&ExternalLinter>, + _resolver: &Resolver, + ) -> Result<(), ConfigBuilderError> { + Err(ConfigBuilderError::NoExternalLinterConfigured) + } + + #[cfg(feature = "oxlint2")] + fn load_external_plugin( + oxlintrc_path: &Path, + plugin_name: &str, + external_linter: Option<&ExternalLinter>, + resolver: &Resolver, + ) -> Result<(), ConfigBuilderError> { + use crate::PluginLoadResult; + let Some(linter) = external_linter else { + return Err(ConfigBuilderError::NoExternalLinterConfigured); + }; + let resolved = + resolver.resolve(oxlintrc_path.parent().unwrap(), plugin_name).map_err(|e| { + ConfigBuilderError::PluginLoadFailed { + plugin_name: plugin_name.into(), + error: e.to_string(), + } + })?; + + let result = tokio::task::block_in_place(move || { + tokio::runtime::Handle::current() + .block_on((linter.load_plugin)(resolved.full_path().to_str().unwrap().to_string())) + }) + .map_err(|e| ConfigBuilderError::PluginLoadFailed { + plugin_name: plugin_name.into(), + error: e.to_string(), + })?; + + match result { + PluginLoadResult::Success => Ok(()), + PluginLoadResult::Failure(e) => Err(ConfigBuilderError::PluginLoadFailed { + plugin_name: plugin_name.into(), + error: e, + }), + } + } } fn get_name(plugin_name: &str, rule_name: &str) -> CompactStr { @@ -418,7 +471,11 @@ pub enum ConfigBuilderError { file: String, reason: String, }, - UnknownPlugin(String), + PluginLoadFailed { + plugin_name: String, + error: String, + }, + NoExternalLinterConfigured, } impl Display for ConfigBuilderError { @@ -438,8 +495,13 @@ impl Display for ConfigBuilderError { ConfigBuilderError::InvalidConfigFile { file, reason } => { write!(f, "invalid config file {file}: {reason}") } - ConfigBuilderError::UnknownPlugin(plugin_name) => { - write!(f, "unknown plugin: {plugin_name}") + ConfigBuilderError::PluginLoadFailed { plugin_name, error } => { + write!(f, "Failed to load external plugin: {plugin_name}\n {error}")?; + Ok(()) + } + ConfigBuilderError::NoExternalLinterConfigured => { + 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.")?; + Ok(()) } } } diff --git a/napi/oxlint2/src/index.js b/napi/oxlint2/src/index.js index 81e52383298f4..b3e9e69fddfa2 100644 --- a/napi/oxlint2/src/index.js +++ b/napi/oxlint2/src/index.js @@ -1,12 +1,27 @@ import { lint } from './bindings.js'; class Linter { + pluginRegistry = new Map(); + run() { return lint(this.loadPlugin.bind(this), this.lint.bind(this)); } - loadPlugin = async (_pluginName) => { - throw new Error('unimplemented'); + loadPlugin = async (pluginName) => { + if (this.pluginRegistry.has(pluginName)) { + return JSON.stringify({ Success: null }); + } + + try { + const plugin = await import(pluginName); + this.pluginRegistry.set(pluginName, plugin); + return JSON.stringify({ Success: null }); + } catch (error) { + const errorMessage = 'message' in error && typeof error.message === 'string' + ? error.message + : 'An unknown error occurred'; + return JSON.stringify({ Failure: errorMessage }); + } }; lint = async () => { diff --git a/napi/oxlint2/test/__snapshots__/e2e.test.ts.snap b/napi/oxlint2/test/__snapshots__/e2e.test.ts.snap index 468cc0ba1f96e..93330779ddda6 100644 --- a/napi/oxlint2/test/__snapshots__/e2e.test.ts.snap +++ b/napi/oxlint2/test/__snapshots__/e2e.test.ts.snap @@ -4,3 +4,16 @@ exports[`cli options for bundling > should lint a directory 1`] = ` "Found 0 warnings and 0 errors. Finished in Xms on 0 files with 1 rules using X threads." `; + +exports[`cli options for bundling > should load a custom plugin 1`] = ` +"Found 0 warnings and 0 errors. +Finished in Xms on 1 file using X threads." +`; + +exports[`cli options for bundling > should report an error if a custom plugin cannot be loaded 1`] = ` +"Failed to parse configuration file. + + x Failed to load external plugin: ./test_plugin + | Cannot find module './test_plugin' +" +`; diff --git a/napi/oxlint2/test/e2e.test.ts b/napi/oxlint2/test/e2e.test.ts index e1eed338c1402..d4e71c84c0551 100644 --- a/napi/oxlint2/test/e2e.test.ts +++ b/napi/oxlint2/test/e2e.test.ts @@ -27,4 +27,22 @@ describe('cli options for bundling', () => { expect(exitCode).toBe(0); expect(normalizeOutput(stdout)).toMatchSnapshot(); }); + + it('should load a custom plugin', async () => { + const { stdout, exitCode } = await runOxlint( + 'test/fixtures/basic_custom_plugin', + ); + + expect(exitCode).toBe(0); + expect(normalizeOutput(stdout)).toMatchSnapshot(); + }); + + it('should report an error if a custom plugin cannot be loaded', async () => { + const { stdout, exitCode } = await runOxlint( + 'test/fixtures/missing_custom_plugin', + ); + + expect(exitCode).toBe(1); + expect(normalizeOutput(stdout)).toMatchSnapshot(); + }); }); diff --git a/napi/oxlint2/test/fixtures/basic_custom_plugin/.oxlintrc.json b/napi/oxlint2/test/fixtures/basic_custom_plugin/.oxlintrc.json new file mode 100644 index 0000000000000..ff6e43522f571 --- /dev/null +++ b/napi/oxlint2/test/fixtures/basic_custom_plugin/.oxlintrc.json @@ -0,0 +1,5 @@ +{ + "plugins": [ + "./test_plugin" + ] +} \ No newline at end of file diff --git a/napi/oxlint2/test/fixtures/basic_custom_plugin/test_plugin/index.js b/napi/oxlint2/test/fixtures/basic_custom_plugin/test_plugin/index.js new file mode 100644 index 0000000000000..4f00b08c16ffa --- /dev/null +++ b/napi/oxlint2/test/fixtures/basic_custom_plugin/test_plugin/index.js @@ -0,0 +1,3 @@ +export default { + rules: {}, +}; diff --git a/napi/oxlint2/test/fixtures/missing_custom_plugin/.oxlintrc.json b/napi/oxlint2/test/fixtures/missing_custom_plugin/.oxlintrc.json new file mode 100644 index 0000000000000..ff6e43522f571 --- /dev/null +++ b/napi/oxlint2/test/fixtures/missing_custom_plugin/.oxlintrc.json @@ -0,0 +1,5 @@ +{ + "plugins": [ + "./test_plugin" + ] +} \ No newline at end of file diff --git a/napi/playground/Cargo.toml b/napi/playground/Cargo.toml index e4014c699b1d5..634653ea9b1da 100644 --- a/napi/playground/Cargo.toml +++ b/napi/playground/Cargo.toml @@ -28,7 +28,7 @@ oxc_index = { workspace = true } oxc_linter = { workspace = true } oxc_napi = { workspace = true } -napi = { workspace = true } +napi = { workspace = true, features = ["async"] } napi-derive = { workspace = true } rustc-hash = { workspace = true } serde = { workspace = true }