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
8 changes: 7 additions & 1 deletion apps/oxlint/src-js/bindings.d.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
/* auto-generated by NAPI-RS */
/* eslint-disable */
/**
* JS callable function to retrieve the serialized external rule options.
* Returns a JSON string of options arrays. Called once from JS after creating the external linter.
*/
export declare function getExternalRuleOptions(): string | null

/** 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, number]>) => string)

/** JS callback to load a JS plugin. */
export type JsLoadPluginCb =
Expand Down
3 changes: 2 additions & 1 deletion apps/oxlint/src-js/bindings.js
Original file line number Diff line number Diff line change
Expand Up @@ -575,5 +575,6 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}

const { lint } = nativeBinding
const { getExternalRuleOptions, lint } = nativeBinding
export { getExternalRuleOptions }
export { lint }
6 changes: 3 additions & 3 deletions apps/oxlint/src-js/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,20 @@ function loadPluginWrapper(path: string, packageName: string | null): Promise<st
* @param filePath - Absolute path of file being linted
* @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 ruleData - Array of [ruleId, optionsId] pairs for rules to run on this file
* @param settingsJSON - Settings for file, as JSON
* @returns Diagnostics or error serialized to JSON string
*/
function lintFileWrapper(
filePath: string,
bufferId: number,
buffer: Uint8Array | null,
ruleIds: number[],
ruleData: [number, number][],
settingsJSON: string,
): string {
// `lintFileWrapper` is never called without `loadPluginWrapper` being called first,
// so `lintFile` must be defined here
return lintFile!(filePath, bufferId, buffer, ruleIds, settingsJSON);
return lintFile!(filePath, bufferId, buffer, ruleData, settingsJSON);
}

// Get command line arguments, skipping first 2 (node binary and script path)
Expand Down
41 changes: 25 additions & 16 deletions apps/oxlint/src-js/plugins/lint.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { setupFileContext, resetFileContext } from './context.js';
import { registeredRules } from './load.js';
import { diagnostics } from './report.js';
import { registeredRules, allOptions, areOptionsInitialized, setOptions, type Options } from './load.js';
import { setSettingsForFile, resetSettings } from './settings.js';
import { ast, initAst, resetSourceAndAst, setupSourceForFile } from './source_code.js';
import { assertIs, getErrorMessage } from './utils.js';
import { addVisitorToCompiled, compiledVisitor, finalizeCompiledVisitor, initCompiledVisitor } from './visitor.js';
import { getExternalRuleOptions } from '../bindings.js';

// Lazy implementation
/*
Expand Down Expand Up @@ -38,19 +39,19 @@ const PARSER_SERVICES_DEFAULT: Record<string, unknown> = Object.freeze({});
* @param filePath - Absolute path of file being linted
* @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 ruleData - Array of [ruleId, optionsId] pairs for rules to run on this file
* @param settingsJSON - Settings for file, as JSON
* @returns Diagnostics or error serialized to JSON string
* @returns JSON result
*/
export function lintFile(
filePath: string,
bufferId: number,
buffer: Uint8Array | null,
ruleIds: number[],
ruleData: Array<[number, number]>,
settingsJSON: string,
): string {
try {
lintFileImpl(filePath, bufferId, buffer, ruleIds, settingsJSON);
lintFileImpl(filePath, bufferId, buffer, ruleData, settingsJSON);
return JSON.stringify({ Success: diagnostics });
} catch (err) {
return JSON.stringify({ Failure: getErrorMessage(err) });
Expand All @@ -65,8 +66,8 @@ export function lintFile(
* @param filePath - Absolute path of file being linted
* @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 settingsJSON - Stringified settings for this file
* @param ruleData - Array of [ruleId, optionsId] pairs for rules to run on this file
* @param settingsJSON - Settings for file, as JSON
* @returns Diagnostics to send back to Rust
* @throws {Error} If any parameters are invalid
* @throws {*} If any rule throws
Expand All @@ -75,7 +76,7 @@ function lintFileImpl(
filePath: string,
bufferId: number,
buffer: Uint8Array | null,
ruleIds: number[],
ruleData: Array<[number, number]>,
settingsJSON: string,
) {
// If new buffer, add it to `buffers` array. Otherwise, get existing buffer from array.
Expand All @@ -101,8 +102,8 @@ function lintFileImpl(
if (typeof filePath !== 'string' || filePath.length === 0) {
throw new Error('expected filePath to be a non-zero length string');
}
if (!Array.isArray(ruleIds) || ruleIds.length === 0) {
throw new Error('Expected `ruleIds` to be a non-zero len array');
if (!Array.isArray(ruleData) || ruleData.length === 0) {
throw new Error('Expected `ruleData` to be a non-zero len array');
}

// Pass file path to context module, so `Context`s know what file is being linted
Expand All @@ -126,19 +127,27 @@ function lintFileImpl(
// Get visitors for this file from all rules
initCompiledVisitor();

for (let i = 0, len = ruleIds.length; i < len; i++) {
const ruleId = ruleIds[i],
ruleDetails = registeredRules[ruleId];
// Initialize external rule options if not already initialized
if (!areOptionsInitialized()) {
const optionsJson = getExternalRuleOptions();
if (optionsJson !== null && optionsJson.length > 0) {
setOptions(optionsJson);
}
}

// Set `ruleIndex` for rule. It's used when sending diagnostics back to Rust.
for (let i = 0; i < ruleData.length; i++) {
const [ruleId, optionsId] = ruleData[i];
Copy link
Member

Choose a reason for hiding this comment

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

Replace with:

Suggested change
const [ruleId, optionsId] = ruleData[i];
const ruleId = ruleData[0],
optionsId = ruleData[1];

I know the original is more idiomatic, but in apps/oxlint we prioritize perf over readability! Array destructuring is much more expensive for the JS engine.

const ruleDetails = registeredRules[ruleId];
const options = allOptions[optionsId];
ruleDetails.ruleIndex = i;
ruleDetails.options = options === undefined || !Array.isArray(options) ? [] : (options as Options);
Comment on lines +141 to +143
Copy link
Member

@overlookmotel overlookmotel Nov 18, 2025

Choose a reason for hiding this comment

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

We should be able to compress this to:

ruleDetails.ruleIndex = i;
ruleDetails.options = allOptions[optionsId];

We need to make sure allOptions[0] is always an empty array. Rust already passes 0 as optionsId when there are no options for the rule.

Copy link
Member

Choose a reason for hiding this comment

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

Also, we can get rid of DEFAULT_OPTIONS in load.ts, and initialize ruleDetails.options as null when the RuleDetails is originally created. ruleDetails.options is now set before running the rule on each file, so this default is always overwritten.


const { rule, context } = ruleDetails;
const { rule } = ruleDetails;

let { visitor } = ruleDetails;
if (visitor === null) {
// Rule defined with `create` method
visitor = rule.create(context);
visitor = rule.create(ruleDetails.context);
Comment on lines -136 to +150
Copy link
Member

@overlookmotel overlookmotel Nov 18, 2025

Choose a reason for hiding this comment

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

These changes are good, but unrelated to this PR. I've broken out this change to #15842 (merged).

Please rebase on latest main.

} else {
// Rule defined with `createOnce` method
const { beforeHook, afterHook } = ruleDetails;
Expand Down
37 changes: 37 additions & 0 deletions apps/oxlint/src-js/plugins/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,43 @@ import type { JsonValue } from './json.ts';
import type { RuleMeta } from './rule_meta.ts';
import type { AfterHook, BeforeHook, Visitor, VisitorWithHooks } from './types.ts';

// All external rule options serialized from Rust.
// Indexed by ExternalOptionsId (numeric) sent alongside ruleId for each file.
// Element 0 is always an empty array (reserved for rules with no options).
export let allOptions: unknown[][] = [];
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
export let allOptions: unknown[][] = [];
export let allOptions: Options[] = [];

Then can get rid of an as Options in lint.ts.


// Track if options have been initialized to avoid re-initialization
let optionsInitialized = false;

/**
* 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 {
try {
const parsed = JSON.parse(optionsJson);
if (!Array.isArray(parsed)) throw new TypeError('Expected optionsJson to decode to an array');
// 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 (!Array.isArray(el)) throw new TypeError('Each options entry must be an array');
}
// Always update options - they may be set multiple times during the process
allOptions = parsed;
optionsInitialized = true;
} catch (err) {
// Re-throw with clearer message for Rust side logging.
throw new Error(
'Failed to parse external rule options JSON: ' + (err instanceof Error ? err.message : String(err)),
);
}
}

export function areOptionsInitialized(): boolean {
return optionsInitialized;
}

const ObjectKeys = Object.keys;

/**
Expand Down
4 changes: 2 additions & 2 deletions apps/oxlint/src/js_plugins/external_linter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ pub enum LintFileReturnValue {
fn wrap_lint_file(cb: JsLintFileCb) -> ExternalLinterLintFileCb {
Box::new(
move |file_path: String,
rule_ids: Vec<u32>,
rule_data: Vec<(u32, u32)>,
settings_json: String,
allocator: &Allocator| {
let (tx, rx) = channel();
Expand All @@ -87,7 +87,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_data, settings_json)),
ThreadsafeFunctionCallMode::NonBlocking,
move |result, _env| {
let _ = match &result {
Expand Down
9 changes: 8 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,13 @@ impl CliRunner {
return CliRunResult::None;
}

// After building config, serialize external rule options for JS side.
#[cfg(all(feature = "napi", target_pointer_width = "64", target_endian = "little"))]
{
let store = config_store.external_plugin_store();
crate::set_external_options_json(store);
}
Comment on lines +332 to +337
Copy link
Member

Choose a reason for hiding this comment

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

Instead of storing the options in a static, just call into JS here and send the options over to JS. Then there's no need for JS-side code to repeatedly check "did I get the options already?" and call back into Rust to get them (if it didn't already).


let files_to_lint = paths
.into_iter()
.filter(|path| !ignore_matcher.should_ignore(Path::new(path)))
Expand Down
30 changes: 27 additions & 3 deletions apps/oxlint/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,30 @@ use crate::{
result::CliRunResult,
};

#[cfg(all(feature = "napi", target_pointer_width = "64", target_endian = "little"))]
use oxc_linter::ExternalPluginStore;

#[cfg(all(feature = "napi", target_pointer_width = "64", target_endian = "little"))]
use std::sync::OnceLock;

#[cfg(all(feature = "napi", target_pointer_width = "64", target_endian = "little"))]
static EXTERNAL_OPTIONS_JSON: OnceLock<String> = OnceLock::new();

/// Set serialized external rule options JSON after building configs.
/// Called from Rust side (internal) before any linting, then consumed on first call to `lint`.
#[cfg(all(feature = "napi", target_pointer_width = "64", target_endian = "little"))]
pub fn set_external_options_json(plugin_store: &ExternalPluginStore) {
let _ = EXTERNAL_OPTIONS_JSON.set(plugin_store.serialize_all_options());
}

/// JS callable function to retrieve the serialized external rule options.
/// Returns a JSON string of options arrays. Called once from JS after creating the external linter.
#[cfg(all(feature = "napi", target_pointer_width = "64", target_endian = "little"))]
#[napi]
pub fn get_external_rule_options() -> Option<String> {
EXTERNAL_OPTIONS_JSON.get().cloned()
}

Comment on lines +19 to +42
Copy link
Member

@overlookmotel overlookmotel Nov 18, 2025

Choose a reason for hiding this comment

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

I don't think we need any of this (see above).

/// JS callback to load a JS plugin.
#[napi]
pub type JsLoadPluginCb = ThreadsafeFunction<
Expand All @@ -39,13 +63,13 @@ pub type JsLintFileCb = ThreadsafeFunction<
String, // Absolute path of file to lint
u32, // Buffer ID
Option<Uint8Array>, // Buffer (optional)
Vec<u32>, // Array of rule IDs
String, // Stringified settings effective for the file
Vec<(u32, u32)>, // Array of [ruleId, optionsId] pairs
String, // Settings JSON
)>,
// 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, u32)>, String)>,
// Error status
Status,
// CalleeHandled
Expand Down
8 changes: 8 additions & 0 deletions apps/oxlint/test/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@ describe('oxlint CLI', () => {
await testFixture('custom_plugin_via_overrides');
});

it('should pass options to custom plugin rules', async () => {
await testFixture('custom_plugin_with_options');
});

it('should pass options to custom plugin rules (working version)', async () => {
await testFixture('custom_plugin_options_working');
});
Comment on lines +99 to +105
Copy link
Member

Choose a reason for hiding this comment

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

Do we need 2 tests here? (working version)???


it('should report an error if a custom plugin is missing', async () => {
await testFixture('missing_custom_plugin');
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"jsPlugins": ["./plugin.js"],
"rules": {
"test-plugin-options/check-options": ["error", true, { "expected": "production" }]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Test file with debugger statement
debugger;

console.log('Hello, world!');
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Exit code
1

# stdout
```
! eslint(no-debugger): `debugger` statement is not allowed
,-[files/index.js:2:1]
1 | // Test file with debugger statement
2 | debugger;
: ^^^^^^^^^
3 |
`----
help: Remove the debugger statement

x test-plugin-options(check-options): Expected value to be production, got disabled
,-[files/index.js:2:1]
1 | // Test file with debugger statement
2 | debugger;
: ^^^^^^^^^
3 |
`----

Found 1 warning and 1 error.
Finished in Xms on 1 file using X threads.
```

# stderr
```
WARNING: JS plugins are experimental and not subject to semver.
Breaking changes are possible while JS plugins support is under development.
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
export default {
meta: {
name: 'test-plugin-options',
},
rules: {
'check-options': {
meta: {
messages: {
wrongValue: 'Expected value to be {{expected}}, got {{actual}}',
noOptions: 'No options provided',
},
},
createOnce(context) {
Copy link
Member

@overlookmotel overlookmotel Nov 18, 2025

Choose a reason for hiding this comment

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

Please use create (not createOnce) in tests, for consistency.

This will also simplify the implementation - can do the check for options in body of create method, instead of in before.

// Don't access context.options here - it's not available yet!
// Options are only available in visitor methods after setupContextForFile is called.

return {
before() {
// Options are now available since setupContextForFile was called
const options = context.options;

// Check if options were passed correctly
if (!options || options.length === 0) {
context.report({
messageId: 'noOptions',
loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 1 } },
});
return false;
}
return true;
},

DebuggerStatement(node) {
// Options are available in visitor methods
const options = context.options;

// First option is a boolean
const shouldReport = options[0];
// Second option is an object
const config = options[1] || {};

if (shouldReport) {
context.report({
messageId: 'wrongValue',
data: {
expected: String(config.expected || 'enabled'),
actual: 'disabled',
},
node,
});
}
},
};
Comment on lines +17 to +53
Copy link
Member

Choose a reason for hiding this comment

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

Can greatly simplify all this. Just unconditionally report JSON.stringify(context.options). Then eyeball the snapshot to make sure it looks right.

},
},
},
};
Loading
Loading