Skip to content
Open
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
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 @@ -2,20 +2,25 @@
/* eslint-disable */
/** 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 =
((arg0: string) => string)

/**
* 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>
24 changes: 20 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,27 @@ 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 - JSON serialization of an array containing all rule options across all configurations.
* @returns "ok" on success or error message on failure
*/
function setupConfigsWrapper(optionsJSON: string): string {
debugAssertIsNonNull(setupConfigs);
return setupConfigs(optionsJSON);
}

/**
* Lint a file.
*
Expand All @@ -38,6 +52,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
* @param settingsJSON - Settings for file, as JSON
* @returns Diagnostics or error serialized to JSON string
*/
Expand All @@ -46,19 +61,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
26 changes: 26 additions & 0 deletions apps/oxlint/src-js/plugins/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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 - JSON serialization of an array containing all rule options across all configurations.
* @returns "ok" on success, or error message on failure
*/
export function setupConfigs(optionsJSON: string): string {
Comment on lines +4 to +14
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

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

[nitpick] The comment states "Note: the name setupConfigs is currently incorrect, as we only populate rule options." While this is acknowledged as a TODO, consider renaming to setupOptions or setupRuleOptions to accurately reflect the current functionality. This would make the code clearer until multi-config workspace support is implemented.

Suggested change
* 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 - JSON serialization of an array containing all rule options across all configurations.
* @returns "ok" on success, or error message on failure
*/
export function setupConfigs(optionsJSON: string): string {
* Populates Rust-resolved rule options on the JS side.
* Called from Rust side after all rule options have been resolved.
*
* The intention is for this function to eventually transfer all configurations in a multi-config workspace.
* The configuration relevant to each file would then be resolved on the JS side.
*
* @param optionsJSON - JSON serialization of an array containing all rule options across all configurations.
* @returns "ok" on success, or error message on failure
*/
export function setupOptions(optionsJSON: string): string {

Copilot uses AI. Check for mistakes.
// TODO: setup settings using this function
try {
setOptions(optionsJSON);
// TODO: flesh out error handling.
// Currently, the only procedure that may fail is `JSON.parse()`
// in `setOptions()`, but it won't because the JSON string from
// the rust side is serde serialized.
return "ok";
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
}
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";
8 changes: 5 additions & 3 deletions apps/oxlint/src-js/plugins/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,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
* @param settingsJSON - Settings for file, as JSON
* @returns Diagnostics or error serialized to JSON string
*/
Expand All @@ -54,11 +55,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);
return JSON.stringify({ Success: diagnostics });
Expand Down Expand Up @@ -156,6 +155,9 @@ 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];
Comment on lines +158 to 162
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@overlookmotel You mentioned this could be simplified to allOptions[optionsId]. How would plugin-provided default options get used in that case?


Expand Down
26 changes: 24 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 ruleId 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,23 @@ 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 - JSON string of outer array of per-options arrays.
*/
export function setOptions(optionsJson: string): void {
const parsed = JSON.parse(optionsJson);
if (DEBUG) {
if (!isArray(parsed))
throw new TypeError("Expected optionsJson to decode to an array", { cause: parsed });
// Basic shape validation: each element must be an array (options tuple array)
for (let i = 0; i < parsed.length; i++) {
const el = parsed[i];
if (!isArray(el)) throw new TypeError("Each options entry must be an array", { cause: el });
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is a debug check because I this validation is done probably done on the rust side as well. I need to confirm this.

deepFreezeArray(parsed);
allOptions = parsed as Readonly<Options>[];
}
48 changes: 45 additions & 3 deletions apps/oxlint/src/js_plugins/external_linter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,20 @@ use oxc_linter::{

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,45 @@ pub enum LintFileReturnValue {
Failure(String),
}

/// Wrap `setupConfigs` JS callback as a normal Rust function.
///
/// 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,
) -> Box<dyn Fn(String) -> Result<(), String> + Send + Sync> {
Box::new(move |options_json: String| {
let (tx, rx) = channel();

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

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

match rx.recv() {
Ok(Ok(result)) => {
if result == "ok" {
Ok(())
} else {
Err(result)
}
}
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 +113,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 +129,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 All @@ -291,6 +291,18 @@ impl CliRunner {
}
};

// TODO: refactor this elsewhere.
// This code is in the oxlint app, not in oxc_linter crate
if let Some(ref external_linter) = external_linter
&& let Err(err) = external_plugin_store.setup_configs(external_linter)
{
print_and_flush_stdout(
stdout,
&format!("Failed to setup external plugin options: {err}\n"),
);
return CliRunResult::InvalidOptionConfig;
}

let report_unused_directives = match inline_config_options.report_unused_directives {
ReportUnusedDirectives::WithoutSeverity(true) => Some(AllowWarnDeny::Warn),
ReportUnusedDirectives::WithSeverity(Some(severity)) => Some(severity),
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
FnArgs<(String,)>, // Stringified options array
// Return value
String, // Result ("ok" or error message)
// Arguments (repeated)
FnArgs<(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
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"categories": {
"correctness": "off"
},
"jsPlugins": ["./plugin.ts"],
"rules": {
"test-plugin-options/check-options": ["error", true, { "expected": "production" }]
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
debugger;
Loading
Loading