Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
484c41e
docs(linter): Update DefaultRuleConfig deserialization for more compl…
connorshea Dec 6, 2025
f79c1aa
refactor(linter): Refactor the DefaultRuleConfig implementation to si…
connorshea Dec 6, 2025
2054095
Add more tests for the DefaultRuleConfig behavior.
connorshea Dec 6, 2025
8c59173
Add more tests, simply code a bit hopefully.
connorshea Dec 6, 2025
986d18c
Updates for tests.
connorshea Dec 6, 2025
766f7c7
I'm sure this can be done better, but I've done my best trying to opt…
connorshea Dec 6, 2025
0e5ecc9
refactor(linter): Rewrite SortKeys config options to be a lot simpler…
connorshea Dec 6, 2025
6b7ccaa
fmt
connorshea Dec 6, 2025
d0b25c0
docs(linter): Provide correct config docs for eslint/no-inner-declara…
connorshea Dec 6, 2025
d32281e
Clean the code up a bit.
connorshea Dec 6, 2025
bbfd138
Resolve lint warning and fix feedback.
connorshea Dec 6, 2025
4eeccd3
Resolve feedback.
connorshea Dec 6, 2025
d71a82b
Resolve lint warnings.
connorshea Dec 6, 2025
96cf9ae
doctests lol
connorshea Dec 6, 2025
5c771ef
docs(linter): Update eslint/func-style rule to fix the config option …
connorshea Dec 6, 2025
414a7f5
docs(linter): Add auto-generated config option docs for `eslint/arrow…
connorshea Dec 6, 2025
536781c
Revert no_inner_declarations.rs back to its state on main.
connorshea Dec 6, 2025
962bfe2
Appease the linter.
connorshea Dec 6, 2025
fdc4795
Remove all but one usage of clone in DefaultRuleConfig deserialization.
connorshea Dec 7, 2025
332f8ea
refactor(linter): Remove support for double-object tuples.
connorshea Dec 7, 2025
948e173
Add another test case.
connorshea Dec 7, 2025
3fea757
Fix tuple test to use an actual tuple struct.
connorshea Dec 7, 2025
8375a6d
Remove test for handling of extra elements, no need to support that.
connorshea Dec 7, 2025
43c982a
refactor: Avoid converting into an array and then back for no reason.
connorshea Dec 7, 2025
200d057
Flatten the code a bit.
connorshea Dec 7, 2025
19b5d40
Add a few comments.
connorshea Dec 7, 2025
5d40c28
Create an assert function to simplify testing the DefaultRuleConfig.
connorshea Dec 7, 2025
a6a0eb1
Update one more test to use the custom assert fn
connorshea Dec 7, 2025
5f2ee80
Resolve lint violations.
connorshea Dec 7, 2025
232b8ba
docs(linter): Updated `eslint/yoda` rule with auto-gen config options…
connorshea Dec 10, 2025
48cda35
fix(linter): Fix default behavior of the `eslint/eqeqeq` rule, and al…
connorshea Dec 10, 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
277 changes: 267 additions & 10 deletions crates/oxc_linter/src/rule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,19 @@ pub trait Rule: Sized + Default + fmt::Debug {
/// }
/// }
/// ```
///
/// For rules that take a tuple configuration object, e.g. `["foobar", { param: true, other_param: false }]`, you can also use this with a tuple struct:
/// ```ignore
/// pub struct MyRuleWithTupleConfig(FirstParamType, SecondParamType);
///
/// impl Rule for MyRuleWithTupleConfig {
/// fn from_configuration(value: serde_json::Value) -> Self {
/// serde_json::from_value::<DefaultRuleConfig<MyRuleWithTupleConfig>>(value)
/// .unwrap_or_default()
/// .into_inner()
/// }
/// }
/// ```
#[derive(Debug, Clone)]
pub struct DefaultRuleConfig<T>(T);

Expand Down Expand Up @@ -117,15 +130,56 @@ where

let value = serde_json::Value::deserialize(deserializer)?;

if let serde_json::Value::Array(arr) = value {
let config = arr
.into_iter()
.next()
.and_then(|v| serde_json::from_value(v).ok())
.unwrap_or_else(T::default);
Ok(DefaultRuleConfig(config))
} else {
Err(D::Error::custom("Expected array for rule configuration"))
// Even if it only has a single type parameter T, we still expect an array
// for ESLint-style rule configurations.
let serde_json::Value::Array(arr) = value else {
return Err(D::Error::custom("Expected array for rule configuration"));
};

// Empty array -> use T::default() (fast-path; no clones)
if arr.is_empty() {
return Ok(DefaultRuleConfig(T::default()));
}

// Match on the array contents:
// - [arg] where arg is an object => try parsing `arg` as T; if that
// fails, attempt to parse [arg, {}] into T (helps tuple-of-objects)
// otherwise fall back to T::default().
// - otherwise => try parsing the whole array as T (tuple form). If
// that fails, parse the first element into T or fall back to default.
match arr.as_slice() {
[first] if first.is_object() => {
// Try the simple object-as-T path first.
if let Ok(config) = serde_json::from_value::<T>(first.clone()) {
return Ok(DefaultRuleConfig(config));
}
Copy link
Contributor

Choose a reason for hiding this comment

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

is there any way we can avoid the clones here? When i originally wrote this I was super careful about the clones, as this is a hot-ish path, so avoiding them where we can is ideal.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah I was a bit surprised this didn't impact the benchmarks at all, but I don't know if they're really exercising the config options much. I'll give these improvements a shot, but I may end up needing some help on getting it to work

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I managed to get rid of almost all the clones, although the code is still jank.


// Attempt the tuple-of-objects case by appending an empty object
// (so `[obj]` -> `[obj, {}]`). If that deserializes to T, accept it.
let arr_two = serde_json::Value::Array(vec![
first.clone(),
serde_json::Value::Object(serde_json::Map::new()),
]);
if let Ok(config) = serde_json::from_value::<T>(arr_two) {
return Ok(DefaultRuleConfig(config));
}
Copy link
Contributor

Choose a reason for hiding this comment

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

is this the same behaviour as eslint? I'm wondering whether we can just fallback to default if they only provide one of the config options

Copy link
Contributor Author

@connorshea connorshea Dec 7, 2025

Choose a reason for hiding this comment

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

I guess that depends on what you mean by this question. We can fallback to default for the second option if they only pass the first, but if you're asking to fallback to default for both tuple values when only one is passed, that'd be different from ESLint's implementation.

The three main shapes different rules take (as you know) are generally:

  • [{ "bar": "baz" }]
  • ["foo"]
  • ["foo", { "bar": "baz" }]

But a small handful of rules (literally 3 in core ESLint as far as I can tell, and one is stylistic/deprecated) take a tuple of objects, e.g. no-restricted-syntax:

"no-restricted-syntax": [
    "error",
    {
        "selector": "FunctionExpression",
        "message": "Function expressions are not allowed."
    },
    {
        "selector": "CallExpression[callee.name='setTimeout'][arguments.length!=2]",
        "message": "setTimeout must always be invoked with two arguments."
    }
]

For no-restricted-syntax, you can just pass the first object value and it'll use the default for the second value (you can also technically pass an object for the first argument and a string for the second, which I despise). We need to somehow determine whether the shape of T matches this pattern in order to optimize what we do here, which I had trouble doing because I'm a Rust noob lol. So Copilot and I ended up with that current implementation which does wacky serialization attempts.

Since this pattern is only used in a small number of rules, maybe for optimization reasons we should assume that - if the first element in the config array is an object - this should just be treated as an object-only config like [{ "bar": "baz" }]. No need to bother with the case of a two-object tuple rule for now. Almost all the tuple rules are in the shape of ["foo", { "bar": "baz" }] anyway.

And then any rules that need the more complex handling can be dealt with later, or just won't be able to use DefaultRuleConfig/will need some alternative implementation like DefaultRuleConfigWithDoubleObject.

Does skipping the ability to handle two-object config tuples sound fine as a solution here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed the logic for two-object tuple support in 332f8ea and that simplified things a ton.


// Nothing worked; use T::default().
Ok(DefaultRuleConfig(T::default()))
}
_ => {
let first = arr.first().cloned();

if let Ok(t) = serde_json::from_value::<T>(serde_json::Value::Array(arr)) {
return Ok(DefaultRuleConfig(t));
}

// Parsing the whole array failed; parse first element if present.
let config =
first.and_then(|v| serde_json::from_value(v).ok()).unwrap_or_else(T::default);

Ok(DefaultRuleConfig(config))
Copy link
Contributor

Choose a reason for hiding this comment

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

this feels wierd, you go from arr which is an array, clone it, just to make a new serde array?

I think we can be a bit stricter with what we accept here, if it improves performance.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Rewrote this part to mostly fix this problem.

}
}
}
}
Expand Down Expand Up @@ -391,7 +445,9 @@ impl From<RuleFixMeta> for FixKind {

#[cfg(test)]
mod test {
use crate::{RuleMeta, RuleRunner};
use rustc_hash::FxHashMap;

use crate::{RuleMeta, RuleRunner, rule::DefaultRuleConfig};

use super::RuleCategory;

Expand Down Expand Up @@ -490,6 +546,207 @@ mod test {
);
}

#[test]
fn test_deserialize_default_rule_config_single() {
// single element present
let de: DefaultRuleConfig<u32> = serde_json::from_str("[123]").unwrap();
assert_eq!(de.into_inner(), 123u32);
let de: DefaultRuleConfig<bool> = serde_json::from_str("[true]").unwrap();
assert!(de.into_inner());
let de: DefaultRuleConfig<bool> = serde_json::from_str("[false]").unwrap();
assert!(!de.into_inner());

// empty array should use defaults
let de: DefaultRuleConfig<String> = serde_json::from_str("[]").unwrap();
assert_eq!(de.into_inner(), String::default());
}

#[derive(serde::Deserialize, Debug, PartialEq, Eq)]
#[serde(default)]
struct Obj {
foo: String,
}

impl Default for Obj {
fn default() -> Self {
Self { foo: "defaultval".to_string() }
}
}

#[derive(serde::Deserialize, Debug, PartialEq, Eq)]
#[serde(default)]
struct Pair(u32, Obj);

impl Default for Pair {
fn default() -> Self {
Self(123u32, Obj::default())
}
}

#[test]
fn test_deserialize_default_rule_config_tuple() {
// both elements present
let de: DefaultRuleConfig<Pair> =
serde_json::from_str(r#"[42, { "foo": "abc" }]"#).unwrap();
assert_eq!(de.into_inner(), Pair(42u32, Obj { foo: "abc".to_string() }));

// only first element present -> parsing the entire array into `Pair`
// will fail, so we parse the first element. Since Pair has #[serde(default)],
// serde will use the default value for the missing second field.
let de: DefaultRuleConfig<Pair> = serde_json::from_str("[10]").unwrap();
assert_eq!(de.into_inner(), Pair(10u32, Obj { foo: "defaultval".to_string() }));

// empty array -> both default
let de: DefaultRuleConfig<Pair> = serde_json::from_str("[]").unwrap();
assert_eq!(de.into_inner(), Pair(123u32, Obj { foo: "defaultval".to_string() }));
}

#[test]
fn test_deserialize_default_rule_config_object_in_array() {
// Single-element array containing an object should parse into the object
// configuration (fallback behavior, not the "entire-array as T" path).
let de: DefaultRuleConfig<Obj> = serde_json::from_str(r#"[{ "foo": "xyz" }]"#).unwrap();
assert_eq!(de.into_inner(), Obj { foo: "xyz".to_string() });

// Extra elements in the array should be ignored when parsing the object
let de: DefaultRuleConfig<Obj> = serde_json::from_str(r#"[{ "foo": "yyy" }, 42]"#).unwrap();
assert_eq!(de.into_inner(), Obj { foo: "yyy".to_string() });

// Empty array -> default
let de: DefaultRuleConfig<Obj> = serde_json::from_str("[]").unwrap();
assert_eq!(de.into_inner(), Obj { foo: "defaultval".to_string() });
}

#[derive(serde::Deserialize, Debug, PartialEq, Eq, Default)]
#[serde(default)]
struct ComplexConfig {
foo: FxHashMap<String, serde_json::Value>,
}

#[test]
fn test_deserialize_default_rule_config_with_complex_shape() {
// A complex object shape for the rule config, like
// `[ { "foo": { "obj": "value" } } ]`.
let json = r#"[ { "foo": { "obj": "value" } } ]"#;
let de: DefaultRuleConfig<ComplexConfig> = serde_json::from_str(json).unwrap();
let cfg = de.into_inner();

let val = cfg.foo.get("obj").expect("obj key present");
assert_eq!(val, &serde_json::Value::String("value".to_string()));
}

#[derive(serde::Deserialize, Debug, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
enum EnumOptions {
#[default]
OptionA,
OptionB,
}

#[test]
fn test_deserialize_default_rule_config_with_enum_config() {
// A basic enum config option.
let json = r#"["optionA"]"#;
let de: DefaultRuleConfig<EnumOptions> = serde_json::from_str(json).unwrap();

assert_eq!(de.into_inner(), EnumOptions::OptionA);
}

#[derive(serde::Deserialize, Default, Debug, PartialEq, Eq)]
#[serde(default)]
struct TupleWithEnumAndObjectConfig {
option: EnumOptions,
extra: Obj,
}

#[test]
fn test_deserialize_default_rule_config_with_enum_and_object() {
// A basic enum config option.
let json = r#"["optionA", { "foo": "bar" }]"#;
let de: DefaultRuleConfig<TupleWithEnumAndObjectConfig> =
serde_json::from_str(json).unwrap();

assert_eq!(
de.into_inner(),
TupleWithEnumAndObjectConfig {
option: EnumOptions::OptionA,
extra: Obj { foo: "bar".to_string() }
}
);

// Ensure that we can pass just one value and it'll provide the default for the second.
let json = r#"["optionB"]"#;
let de: DefaultRuleConfig<TupleWithEnumAndObjectConfig> =
serde_json::from_str(json).unwrap();

assert_eq!(
de.into_inner(),
TupleWithEnumAndObjectConfig {
option: EnumOptions::OptionB,
extra: Obj { foo: "defaultval".to_string() }
}
);
}

#[derive(serde::Deserialize, Debug, PartialEq, Eq, Default)]
#[serde(default)]
struct Obj2 {
bar: bool,
}

#[derive(serde::Deserialize, Debug, PartialEq, Eq, Default)]
#[serde(default)]
struct ExampleTupleConfig(Obj, Obj2);

#[test]
fn test_deserialize_default_rule_with_two_object_tuple() {
// Test a rule config that is a tuple of two objects.
let json = r#"[{ "foo": "fooval" }, { "bar": true }]"#;
let de: DefaultRuleConfig<ExampleTupleConfig> = serde_json::from_str(json).unwrap();

assert_eq!(
de.into_inner(),
ExampleTupleConfig(Obj { foo: "fooval".to_string() }, Obj2 { bar: true })
);

// Ensure that we can pass just one value and it'll provide the default for the second.
let json = r#"[{ "foo": "onlyfooval" }]"#;
let de: DefaultRuleConfig<ExampleTupleConfig> = serde_json::from_str(json).unwrap();

assert_eq!(
de.into_inner(),
ExampleTupleConfig(Obj { foo: "onlyfooval".to_string() }, Obj2 { bar: false })
);
}

#[derive(serde::Deserialize, Debug, PartialEq, Eq)]
#[serde(default)]
struct ExampleObjConfig {
baz: String,
qux: bool,
}

impl Default for ExampleObjConfig {
fn default() -> Self {
Self { baz: "defaultbaz".to_string(), qux: false }
}
}

#[test]
fn test_deserialize_default_rule_with_object_with_multiple_fields() {
// Test a rule config that is a simple object with multiple fields.
let json = r#"[{ "baz": "fooval", "qux": true }]"#;
let de: DefaultRuleConfig<ExampleObjConfig> = serde_json::from_str(json).unwrap();

assert_eq!(de.into_inner(), ExampleObjConfig { baz: "fooval".to_string(), qux: true });

// Ensure that missing fields get their default values.
let json = r#"[{ "qux": true }]"#;
let de: DefaultRuleConfig<ExampleObjConfig> = serde_json::from_str(json).unwrap();

assert_eq!(de.into_inner(), ExampleObjConfig { baz: "defaultbaz".to_string(), qux: true });
}

fn assert_rule_runs_on_node_types<R: RuleMeta + RuleRunner>(
rule: &R,
node_types: &[oxc_ast::AstType],
Expand Down
Loading
Loading