Skip to content
Closed
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 Cargo.lock

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

4 changes: 3 additions & 1 deletion apps/oxlint/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ oxc_span = { workspace = true }

bpaf = { workspace = true, features = ["autocomplete", "bright-color", "derive"] }
cow-utils = { workspace = true }
futures = { workspace = true, optional = true }
ignore = { workspace = true, features = ["simd-accel"] }
miette = { workspace = true }
napi = { workspace = true }
Expand All @@ -43,6 +44,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
simdutf8 = { workspace = true, optional = true }
tempfile = { workspace = true }
tokio = { workspace = true, optional = true }
tracing-subscriber = { workspace = true, features = [] } # Omit the `regex` feature

[target.'cfg(not(any(target_os = "linux", target_os = "freebsd", target_arch = "arm", target_family = "wasm")))'.dependencies]
Expand All @@ -61,6 +63,6 @@ lazy-regex = { workspace = true }
[features]
default = []
allocator = ["dep:mimalloc-safe"]
oxlint2 = ["oxc_linter/oxlint2", "oxc_allocator/fixed_size", "dep:simdutf8"]
oxlint2 = ["oxc_linter/oxlint2", "oxc_allocator/fixed_size", "dep:futures", "dep:simdutf8", "dep:tokio"]
disable_oxlint2 = ["oxc_linter/disable_oxlint2", "oxc_allocator/disable_fixed_size"]
force_test_reporter = ["oxc_linter/force_test_reporter"]
5 changes: 3 additions & 2 deletions apps/oxlint/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use std::io::BufWriter;

pub use oxc_linter::{
ExternalLinter, ExternalLinterLintFileCb, ExternalLinterLoadPluginCb, LintFileResult,
PluginLoadResult,
ExternalLinter, ExternalLinterInitWorkerThreadsCb, ExternalLinterLintFileCb,
ExternalLinterLoadPluginCb, ExternalLinterLoadPluginsCb, ExternalLinterWorkerCallbacks,
LintFileResult, PluginLoadResult,
};

mod command;
Expand Down
55 changes: 54 additions & 1 deletion apps/oxlint/src/lint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,13 @@ impl LintRunner {
let (mut diagnostic_service, tx_error) =
Self::get_diagnostic_service(&output_formatter, &warning_options, &misc_options);

#[allow(unused_mut, clippy::allow_attributes)]
let mut external_linter = self.external_linter;
#[cfg(all(feature = "oxlint2", not(feature = "disable_oxlint2")))]
{
Self::init_js_worker_threads(external_linter.as_mut(), &external_plugin_store);
}

let config_store = ConfigStore::new(lint_config, nested_configs, external_plugin_store);

let files_to_lint = paths
Expand All @@ -308,7 +315,7 @@ impl LintRunner {
}
}

let linter = Linter::new(LintOptions::default(), config_store, self.external_linter)
let linter = Linter::new(LintOptions::default(), config_store, external_linter)
.with_fix(fix_options.fix_kind())
.with_report_unused_directives(report_unused_directives);

Expand Down Expand Up @@ -372,6 +379,52 @@ impl LintRunner {
CliRunResult::LintSucceeded
}
}

#[cfg(all(feature = "oxlint2", not(feature = "disable_oxlint2")))]
fn init_js_worker_threads(
external_linter: Option<&mut ExternalLinter>,
external_plugin_store: &ExternalPluginStore,
) {
use futures::future::try_join_all;

// If no JS plugins used, exit
if external_plugin_store.plugin_paths().is_empty() {
return;
}

// Start 1 JS worker thread for each rayon thread
let external_linter = external_linter.unwrap();
let thread_count = u32::try_from(rayon::current_num_threads()).unwrap();

let callbacks = external_linter.init_worker_threads(thread_count);

// Load all plugins on each worker thread
let plugin_paths = external_plugin_store.plugin_paths().iter().cloned().collect::<Vec<_>>();

tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on({
let callbacks = &*callbacks;
async move {
let futures = callbacks
.iter()
.map(|callbacks| (callbacks.load_plugins)(plugin_paths.clone()));

let joined = try_join_all(futures).await;
joined.map(|_| ())
}
})
})
.unwrap();

// Store JS functions to lint a file in `ExternalLinter`.
// 1 function for each worker thread.
let lint_file_on_threads = callbacks
.into_iter()
.map(|callbacks| callbacks.lint_file)
.collect::<Vec<_>>()
.into_boxed_slice();
external_linter.set_lint_file_on_threads(lint_file_on_threads);
}
}

impl LintRunner {
Expand Down
2 changes: 1 addition & 1 deletion crates/oxc_linter/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true, features = ["preserve_order"] } # preserve_order: print config with ordered keys.
simdutf8 = { workspace = true }
smallvec = { workspace = true }
tokio = { workspace = true, optional = true }
tokio = { workspace = true, features = ["rt-multi-thread"], optional = true }

[dev-dependencies]
insta = { workspace = true }
Expand Down
15 changes: 5 additions & 10 deletions crates/oxc_linter/src/config/config_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -533,17 +533,12 @@ impl ConfigStoreBuilder {
return Ok(());
}

let result = {
let plugin_path = plugin_path.clone();
tokio::task::block_in_place(move || {
tokio::runtime::Handle::current()
.block_on((external_linter.load_plugin)(plugin_path))
})
.map_err(|e| ConfigBuilderError::PluginLoadFailed {
let result = external_linter.load_plugin(&plugin_path).map_err(|error| {
ConfigBuilderError::PluginLoadFailed {
plugin_specifier: plugin_specifier.to_string(),
error: e.to_string(),
})
}?;
error,
}
})?;

match result {
PluginLoadResult::Success { name, offset, rule_names } => {
Expand Down
123 changes: 110 additions & 13 deletions crates/oxc_linter/src/external_linter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,45 @@ use serde::{Deserialize, Serialize};

use oxc_allocator::Allocator;

pub type ExternalLinterLoadPluginCb = Arc<
/// Callback functions for a JS worker thread.
pub struct ExternalLinterWorkerCallbacks {
pub load_plugins: ExternalLinterLoadPluginsCb,
pub lint_file: ExternalLinterLintFileCb,
}

/// Initialize JS worker threads.
pub type ExternalLinterInitWorkerThreadsCb = Arc<
dyn Fn(
String,
u32,
) -> Pin<
Box<
dyn Future<
Output = Result<PluginLoadResult, Box<dyn std::error::Error + Send + Sync>>,
> + Send,
>,
Box<dyn Future<Output = Result<Box<[ExternalLinterWorkerCallbacks]>, String>> + Send>,
> + Send
+ Sync
+ 'static,
>;

pub type ExternalLinterLintFileCb =
Arc<dyn Fn(String, Vec<u32>, &Allocator) -> Result<Vec<LintFileResult>, String> + Sync + Send>;
/// Load a JS plugin on main thread.
pub type ExternalLinterLoadPluginCb = Arc<
dyn Fn(String) -> Pin<Box<dyn Future<Output = Result<PluginLoadResult, String>> + Send>>
+ Send
+ Sync
+ 'static,
>;

/// Load multiple JS plugins on a worker thread.
pub type ExternalLinterLoadPluginsCb = Arc<
dyn Fn(Vec<String>) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send>>
+ Send
+ Sync
+ 'static,
>;

/// Lint a file on a worker thread.
pub type ExternalLinterLintFileCb = Arc<
dyn Fn(String, Vec<u32>, &Allocator, usize) -> Result<Vec<LintFileResult>, String>
+ Sync
+ Send,
>;

#[derive(Clone, Debug, Deserialize, Serialize)]
pub enum PluginLoadResult {
Expand Down Expand Up @@ -49,16 +72,90 @@ pub struct Loc {
#[derive(Clone)]
#[cfg_attr(not(all(feature = "oxlint2", not(feature = "disable_oxlint2"))), expect(dead_code))]
pub struct ExternalLinter {
pub(crate) load_plugin: ExternalLinterLoadPluginCb,
pub(crate) lint_file: ExternalLinterLintFileCb,
init_worker_threads: ExternalLinterInitWorkerThreadsCb,
load_plugin: ExternalLinterLoadPluginCb,
lint_file_fns: Box<[ExternalLinterLintFileCb]>,
}

impl ExternalLinter {
pub fn new(
init_worker_threads: ExternalLinterInitWorkerThreadsCb,
load_plugin: ExternalLinterLoadPluginCb,
lint_file: ExternalLinterLintFileCb,
) -> Self {
Self { load_plugin, lint_file }
Self { init_worker_threads, load_plugin, lint_file_fns: Box::new([]) }
}

/// Initialize JS worker threads.
///
/// # Panics
///
/// Panics if either:
/// * The current thread is not a Tokio runtime thread.
/// * The JS worker threads failed to initialize.
#[cfg(all(feature = "oxlint2", not(feature = "disable_oxlint2")))]
pub fn init_worker_threads(&self, thread_count: u32) -> Box<[ExternalLinterWorkerCallbacks]> {
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on((self.init_worker_threads)(thread_count))
})
.unwrap()
}

#[cfg(not(all(feature = "oxlint2", not(feature = "disable_oxlint2"))))]
#[expect(unused_variables, clippy::unused_self)]
pub fn init_worker_threads(&self, thread_count: u32) -> Box<[ExternalLinterWorkerCallbacks]> {
unreachable!();
}

/// Set the lint file callbacks for each worker thread.
pub fn set_lint_file_on_threads(
&mut self,
lint_file_on_threads: Box<[ExternalLinterLintFileCb]>,
) {
self.lint_file_fns = lint_file_on_threads;
}

/// Load a JS plugin on main thread.
///
/// # Errors
/// Returns `Err` if error on JS side.
///
/// # Panics
/// Panics if the current thread is not a Tokio runtime thread.
#[cfg(all(feature = "oxlint2", not(feature = "disable_oxlint2")))]
pub fn load_plugin(&self, plugin_path: &str) -> Result<PluginLoadResult, String> {
let plugin_path = plugin_path.to_string();
tokio::task::block_in_place(move || {
tokio::runtime::Handle::current().block_on((self.load_plugin)(plugin_path))
})
}

#[cfg(not(all(feature = "oxlint2", not(feature = "disable_oxlint2"))))]
#[expect(unused_variables, clippy::unused_self, clippy::missing_errors_doc)]
pub fn load_plugin(&self, plugin_path: &str) -> Result<PluginLoadResult, String> {
unreachable!();
}

/// Lint a file.
///
/// # Errors
///
/// Returns `Err` if:
/// * Error on JS side.
/// * Current thread is not a Rayon thread.
#[expect(clippy::unnecessary_safety_comment)]
pub fn lint_file(
&self,
file_path: String,
rule_ids: Vec<u32>,
allocator: &Allocator,
) -> Result<Vec<LintFileResult>, String> {
let thread_id = rayon::current_thread_index()
.ok_or_else(|| String::from("Current thread must be a Rayon thread"))?;

let lint_file = &self.lint_file_fns[thread_id];
// SAFETY: We have verified that the current thread is a Rayon thread.
// `rayon::current_thread_index()` always returns a number less than the number of threads in pool.
lint_file(file_path, rule_ids, allocator, thread_id)
}
}

Expand Down
11 changes: 9 additions & 2 deletions crates/oxc_linter/src/external_plugin_store.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
use std::fmt;

use rustc_hash::{FxHashMap, FxHashSet};
use indexmap::IndexSet;
use rustc_hash::{FxBuildHasher, FxHashMap};

use oxc_index::{IndexVec, define_index_type};

type FxIndexSet<T> = IndexSet<T, FxBuildHasher>;

define_index_type! {
pub struct ExternalPluginId = u32;
}
Expand All @@ -14,14 +17,18 @@ define_index_type! {

#[derive(Debug, Default)]
pub struct ExternalPluginStore {
registered_plugin_paths: FxHashSet<String>,
registered_plugin_paths: FxIndexSet<String>,

plugins: IndexVec<ExternalPluginId, ExternalPlugin>,
plugin_names: FxHashMap<String, ExternalPluginId>,
rules: IndexVec<ExternalRuleId, ExternalRule>,
}

impl ExternalPluginStore {
pub fn plugin_paths(&self) -> &FxIndexSet<String> {
&self.registered_plugin_paths
}

pub fn is_plugin_registered(&self, plugin_path: &str) -> bool {
self.registered_plugin_paths.contains(plugin_path)
}
Expand Down
7 changes: 4 additions & 3 deletions crates/oxc_linter/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ pub use crate::{
},
context::LintContext,
external_linter::{
ExternalLinter, ExternalLinterLintFileCb, ExternalLinterLoadPluginCb, LintFileResult,
PluginLoadResult,
ExternalLinter, ExternalLinterInitWorkerThreadsCb, ExternalLinterLintFileCb,
ExternalLinterLoadPluginCb, ExternalLinterLoadPluginsCb, ExternalLinterWorkerCallbacks,
LintFileResult, PluginLoadResult,
},
external_plugin_store::{ExternalPluginStore, ExternalRuleId},
fixer::FixKind,
Expand Down Expand Up @@ -260,7 +261,7 @@ impl Linter {
unsafe { metadata_ptr.write(metadata) };

// Pass AST and rule IDs to JS
let result = (external_linter.lint_file)(
let result = external_linter.lint_file(
path.to_str().unwrap().to_string(),
external_rules.iter().map(|(rule_id, _)| rule_id.raw()).collect(),
allocator,
Expand Down
10 changes: 10 additions & 0 deletions napi/oxlint2/src-js/assertIs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Assert a value is of a certain type.
*
* Has no runtime effect - only for guiding the type-checker.
* Minification removes this function and all calls to it, so it has zero runtime cost.
*
* @param value - Value
*/
// oxlint-disable-next-line no-unused-vars
export default function assertIs<T>(value: unknown): asserts value is T {}
15 changes: 14 additions & 1 deletion napi/oxlint2/src-js/bindings.d.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
/* auto-generated by NAPI-RS */
/* eslint-disable */
/** Initialize JS worker threads. */
export type JsInitWorkerThreadsCb =
((arg: number) => Promise<undefined>)

/** Lint a file on a worker thread. */
export type JsLintFileCb =
((arg0: string, arg1: number, arg2: Uint8Array | undefined | null, arg3: Array<number>) => string)

/** Load a JS plugin on main thread. */
export type JsLoadPluginCb =
((arg: string) => Promise<string>)

export declare function lint(loadPlugin: JsLoadPluginCb, lintFile: JsLintFileCb): Promise<boolean>
/** Load multiple JS plugins on a worker thread. */
export type JsLoadPluginsCb =
((arg: Array<string>) => Promise<undefined>)

export declare function lint(initWorkerThreads: JsInitWorkerThreadsCb, loadPlugin: JsLoadPluginCb): Promise<boolean>

/** Register a JS worker thread. */
export declare function registerWorker(loadPlugins: JsLoadPluginsCb, lintFile: JsLintFileCb): void
Loading
Loading