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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion crates/oxc_linter/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []

Expand Down Expand Up @@ -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 }
Expand Down
76 changes: 69 additions & 7 deletions crates/oxc_linter/src/config/config_builder.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -88,7 +89,7 @@ impl ConfigStoreBuilder {
pub fn from_oxlintrc(
start_empty: bool,
oxlintrc: Oxlintrc,
_external_linter: Option<&ExternalLinter>,
external_linter: Option<&ExternalLinter>,
) -> Result<Self, ConfigBuilderError> {
// TODO: this can be cached to avoid re-computing the same oxlintrc
fn resolve_oxlintrc_config(
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -418,7 +471,11 @@ pub enum ConfigBuilderError {
file: String,
reason: String,
},
UnknownPlugin(String),
PluginLoadFailed {
plugin_name: String,
error: String,
},
NoExternalLinterConfigured,
}

impl Display for ConfigBuilderError {
Expand All @@ -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(())
}
}
}
Expand Down
19 changes: 17 additions & 2 deletions napi/oxlint2/src/index.js
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down
13 changes: 13 additions & 0 deletions napi/oxlint2/test/__snapshots__/e2e.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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'
"
`;
18 changes: 18 additions & 0 deletions napi/oxlint2/test/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
5 changes: 5 additions & 0 deletions napi/oxlint2/test/fixtures/basic_custom_plugin/.oxlintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"plugins": [
"./test_plugin"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default {
rules: {},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"plugins": [
"./test_plugin"
]
}
2 changes: 1 addition & 1 deletion napi/playground/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ oxc_index = { workspace = true }
oxc_linter = { workspace = true }
oxc_napi = { workspace = true }

napi = { workspace = true }
napi = { workspace = true, features = ["async"] }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any way we can avoid this?

napi-derive = { workspace = true }
rustc-hash = { workspace = true }
serde = { workspace = true }
Expand Down
Loading