Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
cb21eac
feat(linter/plugins): rule options
wagenet Nov 18, 2025
449f47c
docs: update comments
overlookmotel Dec 1, 2025
793bc49
style: formatting
overlookmotel Dec 1, 2025
e4c82ce
style: use `for ... of` loop
overlookmotel Dec 1, 2025
5769d4e
docs: shorten comment
overlookmotel Dec 1, 2025
a62ff03
refactor: export `ExternalLinterSetupConfigsCb` from `oxc_linter` crate
overlookmotel Dec 1, 2025
a91c4f9
docs: revise comment
overlookmotel Dec 1, 2025
d516f76
docs: revise comment
overlookmotel Dec 1, 2025
330604e
move calling `setupConfigs` to after check for `list_rules`
overlookmotel Dec 1, 2025
07c023d
docs: add comment
overlookmotel Dec 1, 2025
63881d7
test: combine test into existing test fixture
overlookmotel Dec 1, 2025
8ecc071
test: test all JSON value types
overlookmotel Dec 1, 2025
71a16fa
style: shorten code (dereference early)
overlookmotel Dec 1, 2025
fde3021
refactor: convert to `PathBuf` explicitly
overlookmotel Dec 1, 2025
0ec8111
style: longer var names
overlookmotel Dec 1, 2025
8f2a6e2
test: fix tests
overlookmotel Dec 1, 2025
a0cf146
style: combine imports
overlookmotel Dec 1, 2025
9b8c848
test: make test more realistic
overlookmotel Dec 1, 2025
67edfd9
refactor: shorten code
overlookmotel Dec 1, 2025
9ab19a3
style: rename vars
overlookmotel Dec 1, 2025
6667b91
style: full stops on comments
overlookmotel Dec 1, 2025
624eff8
refactor: remove unnecessary type annotation
overlookmotel Dec 1, 2025
736841b
refactor: assume `options` passed to `add_options` is an array
overlookmotel Dec 1, 2025
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
11 changes: 8 additions & 3 deletions apps/oxlint/src-js/bindings.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,28 @@ export declare function getBufferOffset(buffer: Uint8Array): number

/** JS callback to lint a file. */
export type JsLintFileCb =
((arg0: string, arg1: number, arg2: Uint8Array | undefined | null, arg3: Array<number>, arg4: string) => string)
((arg0: string, arg1: number, arg2: Uint8Array | undefined | null, arg3: Array<number>, arg4: Array<number>, arg5: string) => string)

/** JS callback to load a JS plugin. */
export type JsLoadPluginCb =
((arg0: string, arg1?: string | undefined | null) => Promise<string>)

/** JS callback to setup configs. */
export type JsSetupConfigsCb =
((arg: string) => void)

/**
* NAPI entry point.
*
* JS side passes in:
* 1. `args`: Command line arguments (process.argv.slice(2))
* 2. `load_plugin`: Load a JS plugin from a file path.
* 3. `lint_file`: Lint a file.
* 3. `setup_configs`: Setup configuration options.
* 4. `lint_file`: Lint a file.
*
* Returns `true` if linting succeeded without errors, `false` otherwise.
*/
export declare function lint(args: Array<string>, loadPlugin: JsLoadPluginCb, lintFile: JsLintFileCb): Promise<boolean>
export declare function lint(args: Array<string>, loadPlugin: JsLoadPluginCb, setupConfigs: JsSetupConfigsCb, lintFile: JsLintFileCb): Promise<boolean>

/**
* Parse AST into provided `Uint8Array` buffer, synchronously.
Expand Down
23 changes: 19 additions & 4 deletions apps/oxlint/src-js/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { debugAssertIsNonNull } from "./utils/asserts.js";
// Using `typeof wrapper` here makes TS check that the function signatures of `loadPlugin` and `loadPluginWrapper`
// are identical. Ditto `lintFile` and `lintFileWrapper`.
let loadPlugin: typeof loadPluginWrapper | null = null;
let setupConfigs: typeof setupConfigsWrapper | null = null;
let lintFile: typeof lintFileWrapper | null = null;

/**
Expand All @@ -21,14 +22,26 @@ function loadPluginWrapper(path: string, packageName: string | null): Promise<st
// Use promises here instead of making `loadPluginWrapper` an async function,
// to avoid a micro-tick and extra wrapper `Promise` in all later calls to `loadPluginWrapper`
return import("./plugins/index.js").then((mod) => {
({ loadPlugin, lintFile } = mod);
({ loadPlugin, lintFile, setupConfigs } = mod);
return loadPlugin(path, packageName);
});
}
debugAssertIsNonNull(loadPlugin);
return loadPlugin(path, packageName);
}

/**
* Bootstrap configuration options.
*
* Delegates to `setupConfigs`, which was lazy-loaded by `loadPluginWrapper`.
*
* @param optionsJSON - Array of all rule options across all configurations, serialized as JSON
*/
function setupConfigsWrapper(optionsJSON: string): void {
debugAssertIsNonNull(setupConfigs);
setupConfigs(optionsJSON);
}

/**
* Lint a file.
*
Expand All @@ -38,6 +51,7 @@ function loadPluginWrapper(path: string, packageName: string | null): Promise<st
* @param bufferId - ID of buffer containing file data
* @param buffer - Buffer containing file data, or `null` if buffer with this ID was previously sent to JS
* @param ruleIds - IDs of rules to run on this file
* @param optionsIds - IDs of options to use for rules on this file, in same order as `ruleIds`
* @param settingsJSON - Settings for file, as JSON
* @returns Diagnostics or error serialized to JSON string
*/
Expand All @@ -46,19 +60,20 @@ function lintFileWrapper(
bufferId: number,
buffer: Uint8Array | null,
ruleIds: number[],
optionsIds: number[],
settingsJSON: string,
): string {
// `lintFileWrapper` is never called without `loadPluginWrapper` being called first,
// so `lintFile` must be defined here
debugAssertIsNonNull(lintFile);
return lintFile(filePath, bufferId, buffer, ruleIds, settingsJSON);
return lintFile(filePath, bufferId, buffer, ruleIds, optionsIds, settingsJSON);
}

// Get command line arguments, skipping first 2 (node binary and script path)
const args = process.argv.slice(2);

// Call Rust, passing `loadPlugin` and `lintFile` as callbacks, and CLI arguments
const success = await lint(args, loadPluginWrapper, lintFileWrapper);
// Call Rust, passing `loadPlugin`, `setupConfigs`, and `lintFile` as callbacks, and CLI arguments
const success = await lint(args, loadPluginWrapper, setupConfigsWrapper, lintFileWrapper);

// Note: It's recommended to set `process.exitCode` instead of calling `process.exit()`.
// `process.exit()` kills the process immediately and `stdout` may not be flushed before process dies.
Expand Down
16 changes: 16 additions & 0 deletions apps/oxlint/src-js/plugins/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { setOptions } from "./options.js";

/**
* Populates Rust-resolved configuration options on the JS side.
* Called from Rust side after all configuration options have been resolved.
*
* Note: the name `setupConfigs` is currently incorrect, as we only populate rule options.
* The intention is for this function to transfer all configurations in a multi-config workspace.
* The configuration relevant to each file would then be resolved on the JS side.
*
* @param optionsJSON - Array of all rule options across all configurations, serialized as JSON
*/
export function setupConfigs(optionsJSON: string): void {
// TODO: setup settings using this function
setOptions(optionsJSON);
}
1 change: 1 addition & 0 deletions apps/oxlint/src-js/plugins/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { lintFile } from "./lint.js";
export { loadPlugin } from "./load.js";
export { setupConfigs } from "./config.js";
10 changes: 6 additions & 4 deletions apps/oxlint/src-js/plugins/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const PARSER_SERVICES_DEFAULT: Record<string, unknown> = Object.freeze({});
* @param bufferId - ID of buffer containing file data
* @param buffer - Buffer containing file data, or `null` if buffer with this ID was previously sent to JS
* @param ruleIds - IDs of rules to run on this file
* @param optionsIds - IDs of options to use for rules on this file, in same order as `ruleIds`
* @param settingsJSON - Settings for file, as JSON
* @returns Diagnostics or error serialized to JSON string
*/
Expand All @@ -56,11 +57,9 @@ export function lintFile(
bufferId: number,
buffer: Uint8Array | null,
ruleIds: number[],
optionsIds: number[],
settingsJSON: string,
): string {
// TODO: Get `optionsIds` from Rust side
const optionsIds = ruleIds.map((_) => DEFAULT_OPTIONS_ID);

try {
lintFileImpl(filePath, bufferId, buffer, ruleIds, optionsIds, settingsJSON);

Expand All @@ -85,7 +84,7 @@ export function lintFile(
* @param bufferId - ID of buffer containing file data
* @param buffer - Buffer containing file data, or `null` if buffer with this ID was previously sent to JS
* @param ruleIds - IDs of rules to run on this file
* @param optionsIds - IDs of options to use for rules on this file
* @param optionsIds - IDs of options to use for rules on this file, in same order as `ruleIds`
* @param settingsJSON - Stringified settings for this file
* @throws {Error} If any parameters are invalid
* @throws {*} If any rule throws
Expand Down Expand Up @@ -164,6 +163,9 @@ export function lintFileImpl(
// Set `options` for rule
const optionsId = optionsIds[i];
debugAssert(optionsId < allOptions.length, "Options ID out of bounds");

// If the rule has no user-provided options, use the plugin-provided default
// options (which falls back to `DEFAULT_OPTIONS`)
ruleDetails.options =
optionsId === DEFAULT_OPTIONS_ID ? ruleDetails.defaultOptions : allOptions[optionsId];

Expand Down
30 changes: 28 additions & 2 deletions apps/oxlint/src-js/plugins/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ export type Options = JsonValue[];
// Default rule options
export const DEFAULT_OPTIONS: Readonly<Options> = Object.freeze([]);

// All rule options
export const allOptions: Readonly<Options>[] = [DEFAULT_OPTIONS];
// All rule options.
// Indexed by options ID sent alongside rule ID for each file.
// Element 0 is always the default options (empty array).
export let allOptions: Readonly<Options>[] = [DEFAULT_OPTIONS];

// Index into `allOptions` for default options
export const DEFAULT_OPTIONS_ID = 0;
Expand Down Expand Up @@ -128,3 +130,27 @@ function mergeValues(configValue: JsonValue, defaultValue: JsonValue): JsonValue

return freeze(merged);
}

/**
* Set all external rule options.
* Called once from Rust after config building, before any linting occurs.
* @param optionsJSON - Array of all rule options across all configurations, serialized as JSON
*/
export function setOptions(optionsJson: string): void {
allOptions = JSON.parse(optionsJson);

// Validate that `allOptions` is an array of arrays
if (DEBUG) {
if (!isArray(allOptions)) {
throw new TypeError(`Expected optionsJson to decode to an array, got ${typeof allOptions}`);
}
for (const options of allOptions) {
if (!isArray(options)) {
throw new TypeError(`Each options entry must be an array, got ${typeof options}`);
}
}
}

// `allOptions`' type is `Readonly`, but the array is mutable until after this call
deepFreezeArray(allOptions as JsonValue[]);
}
45 changes: 40 additions & 5 deletions apps/oxlint/src/js_plugins/external_linter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,26 @@ use serde::Deserialize;

use oxc_allocator::{Allocator, free_fixed_size_allocator};
use oxc_linter::{
ExternalLinter, ExternalLinterLintFileCb, ExternalLinterLoadPluginCb, LintFileResult,
PluginLoadResult,
ExternalLinter, ExternalLinterLintFileCb, ExternalLinterLoadPluginCb,
ExternalLinterSetupConfigsCb, LintFileResult, PluginLoadResult,
};

use crate::{
generated::raw_transfer_constants::{BLOCK_ALIGN, BUFFER_SIZE},
run::{JsLintFileCb, JsLoadPluginCb},
run::{JsLintFileCb, JsLoadPluginCb, JsSetupConfigsCb},
};

/// Wrap JS callbacks as normal Rust functions, and create [`ExternalLinter`].
pub fn create_external_linter(
load_plugin: JsLoadPluginCb,
setup_configs: JsSetupConfigsCb,
lint_file: JsLintFileCb,
) -> ExternalLinter {
let rust_load_plugin = wrap_load_plugin(load_plugin);
let rust_setup_configs = wrap_setup_configs(setup_configs);
let rust_lint_file = wrap_lint_file(lint_file);

ExternalLinter::new(rust_load_plugin, rust_lint_file)
ExternalLinter::new(rust_load_plugin, rust_setup_configs, rust_lint_file)
}

/// Wrap `loadPlugin` JS callback as a normal Rust function.
Expand Down Expand Up @@ -59,6 +61,38 @@ pub enum LintFileReturnValue {
Failure(String),
}

/// Wrap `setupConfigs` JS callback as a normal Rust function.
///
/// The JS-side `setupConfigs` function is synchronous, but it's wrapped in a `ThreadsafeFunction`,
/// so cannot be called synchronously. Use an `mpsc::channel` to wait for the result from JS side,
/// and block current thread until `setupConfigs` completes execution.
fn wrap_setup_configs(cb: JsSetupConfigsCb) -> ExternalLinterSetupConfigsCb {
Box::new(move |options_json: String| {
let (tx, rx) = channel();

// Send data to JS
let status = cb.call_with_return_value(
options_json,
ThreadsafeFunctionCallMode::NonBlocking,
move |result, _env| {
let _ = match &result {
Ok(()) => tx.send(Ok(())),
Err(e) => tx.send(Err(e.to_string())),
};
result
},
);

assert!(status == Status::Ok, "Failed to schedule setupConfigs callback: {status:?}");

match rx.recv() {
Ok(Ok(())) => Ok(()),
Ok(Err(err)) => Err(err),
Err(err) => panic!("setupConfigs callback did not respond: {err}"),
}
})
}

/// Wrap `lintFile` JS callback as a normal Rust function.
///
/// The returned function creates a `Uint8Array` referencing the memory of the given `Allocator`,
Expand All @@ -72,6 +106,7 @@ fn wrap_lint_file(cb: JsLintFileCb) -> ExternalLinterLintFileCb {
Box::new(
move |file_path: String,
rule_ids: Vec<u32>,
options_ids: Vec<u32>,
settings_json: String,
allocator: &Allocator| {
let (tx, rx) = channel();
Expand All @@ -87,7 +122,7 @@ fn wrap_lint_file(cb: JsLintFileCb) -> ExternalLinterLintFileCb {

// Send data to JS
let status = cb.call_with_return_value(
FnArgs::from((file_path, buffer_id, buffer, rule_ids, settings_json)),
FnArgs::from((file_path, buffer_id, buffer, rule_ids, options_ids, settings_json)),
ThreadsafeFunctionCallMode::NonBlocking,
move |result, _env| {
let _ = match &result {
Expand Down
14 changes: 13 additions & 1 deletion apps/oxlint/src/lint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ impl CliRunner {
|| nested_configs.values().any(|config| config.plugins().has_import());
let mut options = LintServiceOptions::new(self.cwd).with_cross_module(use_cross_module);

let lint_config = match config_builder.build(&external_plugin_store) {
let lint_config = match config_builder.build(&mut external_plugin_store) {
Ok(config) => config,
Err(e) => {
print_and_flush_stdout(
Expand Down Expand Up @@ -329,6 +329,18 @@ impl CliRunner {
return CliRunResult::None;
}

// Send JS plugins config to JS side
if let Some(external_linter) = &external_linter {
let res = config_store.external_plugin_store().setup_configs(external_linter);
if let Err(err) = res {
print_and_flush_stdout(
stdout,
&format!("Failed to setup external plugin options: {err}\n"),
);
return CliRunResult::InvalidOptionConfig;
}
}

let files_to_lint = paths
.into_iter()
.filter(|path| !ignore_matcher.should_ignore(Path::new(path)))
Expand Down
36 changes: 30 additions & 6 deletions apps/oxlint/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,28 @@ pub type JsLintFileCb = ThreadsafeFunction<
u32, // Buffer ID
Option<Uint8Array>, // Buffer (optional)
Vec<u32>, // Array of rule IDs
Vec<u32>, // Array of options IDs
String, // Stringified settings effective for the file
)>,
// Return value
String, // `Vec<LintFileResult>`, serialized to JSON
// Arguments (repeated)
FnArgs<(String, u32, Option<Uint8Array>, Vec<u32>, String)>,
FnArgs<(String, u32, Option<Uint8Array>, Vec<u32>, Vec<u32>, String)>,
// Error status
Status,
// CalleeHandled
false,
>;

/// JS callback to setup configs.
#[napi]
pub type JsSetupConfigsCb = ThreadsafeFunction<
// Arguments
String, // Options array, as JSON string
// Return value
(), // `void`
// Arguments (repeated)
String,
// Error status
Status,
// CalleeHandled
Expand All @@ -57,20 +73,27 @@ pub type JsLintFileCb = ThreadsafeFunction<
/// JS side passes in:
/// 1. `args`: Command line arguments (process.argv.slice(2))
/// 2. `load_plugin`: Load a JS plugin from a file path.
/// 3. `lint_file`: Lint a file.
/// 3. `setup_configs`: Setup configuration options.
/// 4. `lint_file`: Lint a file.
///
/// Returns `true` if linting succeeded without errors, `false` otherwise.
#[expect(clippy::allow_attributes)]
#[allow(clippy::trailing_empty_array, clippy::unused_async)] // https://github.com/napi-rs/napi-rs/issues/2758
#[napi]
pub async fn lint(args: Vec<String>, load_plugin: JsLoadPluginCb, lint_file: JsLintFileCb) -> bool {
lint_impl(args, load_plugin, lint_file).await.report() == ExitCode::SUCCESS
pub async fn lint(
args: Vec<String>,
load_plugin: JsLoadPluginCb,
setup_configs: JsSetupConfigsCb,
lint_file: JsLintFileCb,
) -> bool {
lint_impl(args, load_plugin, setup_configs, lint_file).await.report() == ExitCode::SUCCESS
}

/// Run the linter.
async fn lint_impl(
args: Vec<String>,
load_plugin: JsLoadPluginCb,
setup_configs: JsSetupConfigsCb,
lint_file: JsLintFileCb,
) -> CliRunResult {
// Convert String args to OsString for compatibility with bpaf
Expand Down Expand Up @@ -104,10 +127,11 @@ async fn lint_impl(

// JS plugins are only supported on 64-bit little-endian platforms at present
#[cfg(all(target_pointer_width = "64", target_endian = "little"))]
let external_linter = Some(crate::js_plugins::create_external_linter(load_plugin, lint_file));
let external_linter =
Some(crate::js_plugins::create_external_linter(load_plugin, setup_configs, lint_file));
#[cfg(not(all(target_pointer_width = "64", target_endian = "little")))]
let external_linter = {
let (_, _) = (load_plugin, lint_file);
let (_, _, _) = (load_plugin, setup_configs, lint_file);
None
};

Expand Down
Loading
Loading