Skip to content

docs(linter): Handle rules that use tuple config options with DefaultRuleConfig#16555

Closed
connorshea wants to merge 31 commits intomainfrom
handle-tuple-rules-better
Closed

docs(linter): Handle rules that use tuple config options with DefaultRuleConfig#16555
connorshea wants to merge 31 commits intomainfrom
handle-tuple-rules-better

Conversation

@connorshea
Copy link
Member

@connorshea connorshea commented Dec 6, 2025

This enables DefaultRuleConfig to handle tuple structs properly so we can have an easier time resolving #16023.

This is also part of #14743.

This allows us to refactor the way we handle the config for SortKeys, so it's much simpler. And also updates func-style and arrow-body-style to use the tuple pattern and have correct config option docs. I initially tried to update the no-inner-declarations rule to use the tuple pattern, but it lead to test failures due to what I suspect is a bug in the implementation of that rule. So I split that into its own PR.

This was done with help from GitHub Copilot + their Raptor mini model, but I've added enough tests - and all the rules that currently use DefaultRuleConfig still pass their tests - that I feel pretty good about it being functional, albeit probably not elegant. Happy to take feedback on improving this in that regard especially.

Updated the eslint/func-style rule:

## Configuration

### The 1st option

type: `"expression" | "declaration"`

### The 2nd option

This option is an object with the following properties:

#### allowArrowFunctions

type: `boolean`

default: `false`

When true, arrow functions are allowed regardless of the style setting.

#### allowTypeAnnotation

type: `boolean`

default: `false`

When true, functions with type annotations are allowed regardless of the style setting.

#### overrides

type: `object`

##### overrides.namedExports

type: `string | null`

default: `null`

For eslint/arrow-body-style:

## Configuration

### The 1st option

type: `"as-needed" | "always" | "never"`

#### `"as-needed"`

`as-needed` enforces no braces where they can be omitted (default).

Examples of **incorrect** code for this rule with the `as-needed` option:

\```js
/* arrow-body-style: ["error", "as-needed"] */

/* ✘ Bad: */
const foo = () => {
  return 0;
};
\```

Examples of **correct** code for this rule with the `as-needed` option:

\```js
/* arrow-body-style: ["error", "as-needed"] */

/* ✔ Good: */
const foo1 = () => 0;

const foo2 = (retv, name) => {
  retv[name] = true;
  return retv;
};

const foo3 = () => {
  bar();
};
\```

#### `"always"`

`always` enforces braces around the function body.

Examples of **incorrect** code for this rule with the `always` option:

\```js
/* arrow-body-style: ["error", "always"] */

/* ✘ Bad: */
const foo = () => 0;
\```

Examples of **correct** code for this rule with the `always` option:

\```js
/* arrow-body-style: ["error", "always"] */

/* ✔ Good: */
const foo = () => {
  return 0;
};
\```

#### `"never"`

`never` enforces no braces around the function body (constrains arrow functions to the role of returning an expression)

Examples of **incorrect** code for this rule with the `never` option:

\```js
/* arrow-body-style: ["error", "never"] */

/* ✘ Bad: */
const foo = () => {
  return 0;
};
\```

Examples of **correct** code for this rule with the `never` option:

\```js
/* arrow-body-style: ["error", "never"] */

/* ✔ Good: */
const foo = () => 0;
const bar = () => ({ foo: 0 });
\```

### The 2nd option

This option is an object with the following properties:

#### requireReturnForObjectLiteral

type: `boolean`

default: `false`

If `true`, requires braces and an explicit return for object literals.

Note: This option only applies when the first option is `"as-needed"`.

Examples of **incorrect** code for this rule with the `{ "requireReturnForObjectLiteral": true }` option:

\```js
/* arrow-body-style: ["error", "as-needed", { "requireReturnForObjectLiteral": true }]*/

/* ✘ Bad: */
const foo = () => ({});
const bar = () => ({ bar: 0 });
\```

Examples of **correct** code for this rule with the `{ "requireReturnForObjectLiteral": true }` option:

\```js
/* arrow-body-style: ["error", "as-needed", { "requireReturnForObjectLiteral": true }]*/

/* ✔ Good: */
const foo = () => {};
const bar = () => {
  return { bar: 0 };
};
\```

@connorshea connorshea requested a review from camc314 as a code owner December 6, 2025 05:53
Copilot AI review requested due to automatic review settings December 6, 2025 05:53
@github-actions github-actions bot added A-linter Area - Linter C-cleanup Category - technical debt or refactoring. Solution not expected to change behavior labels Dec 6, 2025
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR refactors the linter's rule configuration handling by enhancing DefaultRuleConfig to properly support tuple struct configurations, making it easier to implement ESLint-style array-based rule configs. The refactoring simplifies the sort_keys rule implementation and updates no_inner_declarations to use the new pattern.

Key Changes:

  • Enhanced DefaultRuleConfig deserializer to handle tuple structs with smart fallback behavior for partial configurations
  • Refactored sort_keys rule to use the new pattern, removing ~30 lines of manual config parsing
  • Converted no_inner_declarations to use tuple struct configuration with DefaultRuleConfig

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
crates/oxc_linter/src/rule.rs Enhanced DefaultRuleConfig to handle tuple configs with comprehensive test coverage; updated documentation with tuple struct examples
crates/oxc_linter/src/rules/eslint/sort_keys.rs Simplified from_configuration using DefaultRuleConfig pattern; removed helper methods in favor of direct tuple destructuring
crates/oxc_linter/src/rules/eslint/no_inner_declarations.rs Refactored to tuple struct config with DefaultRuleConfig; introduces breaking change in default block_scoped_functions behavior
Comments suppressed due to low confidence (1)

crates/oxc_linter/src/rules/eslint/no_inner_declarations.rs:30

  • This refactoring changes the default behavior for block_scoped_functions. Previously, when no second parameter was provided (e.g., ["functions"]), the old implementation defaulted to Some(BlockScopedFunctions::Allow). Now with #[serde(default)], the second field defaults to None.

This breaks tests like the one on line 218-219 which expects ["functions"] to allow inner declarations in strict mode.

To maintain backward compatibility, you could either:

  1. Implement Default for NoInnerDeclarationsConfigObject to return Some(BlockScopedFunctions::Allow) instead of None
  2. Update the logic in the run method to treat None as Allow (though this changes the meaning of the field)

The PR description mentions this is being left for review - a decision is needed on whether to match ESLint's behavior (where None might be the correct default) or maintain backward compatibility.

#[derive(Debug, Default, Clone, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", default)]
struct NoInnerDeclarationsConfigObject {
    /// Controls whether function declarations in nested blocks are allowed in strict mode (ES6+ behavior).
    #[schemars(with = "BlockScopedFunctions")]
    block_scoped_functions: Option<BlockScopedFunctions>,
}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@codspeed-hq
Copy link

codspeed-hq bot commented Dec 6, 2025

CodSpeed Performance Report

Merging #16555 will not alter performance

Comparing handle-tuple-rules-better (48cda35) with main (8babdf9)

Summary

✅ 4 untouched
⏩ 41 skipped1

Footnotes

  1. 41 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@connorshea connorshea changed the title refactor(linter): Handle tuple rules more effectively with DefaultRuleConfig refactor(linter): Handle rules that use tuple config options with DefaultRuleConfig Dec 6, 2025
@connorshea connorshea changed the title refactor(linter): Handle rules that use tuple config options with DefaultRuleConfig docs(linter): Handle rules that use tuple config options with DefaultRuleConfig Dec 6, 2025
@github-actions github-actions bot added the C-docs Category - Documentation. Related to user-facing or internal documentation label Dec 6, 2025
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

…ex config shapes.

This will make it much easier to handle rules which accept tuples. I still need to fix a few things, though.
…mplify some things.

This will hopefully also ensure that the most common case (a default config object for a given rule) is fast. But I still need to simplify the rest of the code here.
… now that we can do tuples in DefaultRuleConfig.

This is a proof-of-concept to confirm that these updates to the DefaultRuleConfig tuple handling work as intended and simplify the code :)
…tions rule.

I'm pretty sure there's a bug in this rule. It doesn't seem like it should've been returning `None` for the `block_scoped_functions` value by default, the original ESLint rule returns "allow" by default. But I left the behavior alone here, we can address that later if we want to.
I'm pretty sure the no_inner_declarations rule has a bug in it right now, so it failing here isn't really indicative of any problem with the .

It's very odd to me that it was defaulting to None for the `block_scoped_functions` instead of `Allow`, I don't understand why that was done and it seems like there are behavioral differences vs. the original rule as a result.
@connorshea connorshea force-pushed the handle-tuple-rules-better branch from ca59885 to 962bfe2 Compare December 6, 2025 22:52
@connorshea
Copy link
Member Author

Rebased to make sure the changes to sort-keys on main didn't cause any issues (I ran the tests after trying the rebase locally and they passed fine)

Comment on lines 153 to 155
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
Member 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
Member 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.

@connorshea connorshea requested a review from camc314 December 7, 2025 20:34
@connorshea
Copy link
Member Author

connorshea commented Dec 7, 2025

Ok, this is all ready for re-review.

…, I have. (#16562)

Uses DefaultRuleConfig now, this rule does. Updated to use a proper
tuple config, it has been.
    
Continue to pass, the tests do.

Part of #14743 and dependent on #16555, this PR is.

Generated docs:

```md
## Configuration

### The 1st option

type: `"never" | "always"`

#### `"never"`

The default `"never"` option can have exception options in an object literal, via `exceptRange` and `onlyEquality`.

#### `"always"`

The `"always"` option requires that literal values must always come first in comparisons.

### The 2nd option

This option is an object with the following properties:

#### exceptRange

type: `boolean`

default: `false`

If the `"exceptRange"` property is `true`, the rule _allows_ yoda conditions
in range comparisons which are wrapped directly in parentheses, including the
parentheses of an `if` or `while` condition.
A _range_ comparison tests whether a variable is inside or outside the range
between two literal values.

#### onlyEquality

type: `boolean`

default: `false`

If the `"onlyEquality"` property is `true`, the rule reports yoda
conditions _only_ for the equality operators `==` and `===`. The `onlyEquality`
option allows a superset of the exceptions which `exceptRange` allows, thus
both options are not useful together.
```
…so the config option docs for it (#16560)

This is part of #16023.

See [the tests for the original
rule](https://github.com/eslint/eslint/blob/b017f094d4e53728f8d335b9cf8b16dc074afda3/tests/lib/rules/eqeqeq.js).

We have a test in [the eqeqeq.rs
implementation](https://github.com/oxc-project/oxc/blob/e24aabdfa65044a7223e4ea7b294ad3bf5dfb1ec/crates/oxc_linter/src/rules/eslint/eqeqeq.rs)
like so:

```rs
// Issue: <#8773>
("href != null", Some(json!([{"null": "ignore"}]))),
```

The problem is that this test has an incorrect shape for the config
object, see here:

```jsonc
// Should always be in one of these three formats, all three work in the original rule as well:
"eslint/eqeqeq": ["error", "always", { "null": "never" }],
"eslint/eqeqeq": ["error", "always"],
"eslint/eqeqeq": ["error"],

// But right now the tests have a case where the string arg is skipped, while the ESLint rule does not allow this:
"eslint/eqeqeq": ["error", { "null": "ignore" }],
```

The problem is that the code _did_ previously handle this config array
as invalid. However, because the implementation of `from` on NullType
would fall back to `ignore` if it received bad data, it looked like it
worked:

```rs
impl NullType {
    pub fn from(raw: &str) -> Self {
        match raw {
            "always" => Self::Always,
            "never" => Self::Never,
            _ => Self::Ignore,
        }
    }
}
```

Because `always` is marked as the default value (and is also the default
value in the original ESLint rule), and so should be the default case.
The test was just hitting the fallback value, so it looked like it
worked, but really the fallback value was incorrect previously and did
not match the docs _or_ the ESLint behavior.

This fixes that issue by correcting the fallback value, and also fixes
the auto-generated config shape/docs, so it correctly represents itself
as taking a tuple.

Generated docs:

```md
## Configuration

### The 1st option

type: `"always" | "smart"`

#### `"always"`

Always require triple-equal comparisons, `===`/`!==`.
This is the default.

#### `"smart"`

Allow certain safe comparisons to use `==`/`!=` (`typeof`, literals, nullish).

### The 2nd option

This option is an object with the following properties:

#### null

type: `"always" | "never" | "ignore"`

##### `"always"`

Always require triple-equals when comparing with null, `=== null`/`!== null`.
This is the default.

##### `"never"`

Never require triple-equals when comparing with null, always use `== null`/`!= null`

##### `"ignore"`

Ignore null comparisons, allow either `== null`/`!= null` and `=== null`/`!== null`
```
@Boshen
Copy link
Member

Boshen commented Jan 21, 2026

I can merge if @connorshea you are still able to rebase this somehow.

@connorshea
Copy link
Member Author

I can merge if @connorshea you are still able to rebase this somehow.

After trying to rebase and get this working again, I think the best course of action is going to be a separate helper like DefaultRuleConfig, but dedicated to usage with tuple rules. So I'm working on that right now.

@connorshea
Copy link
Member Author

I have opened #18372 as the successor for this PR, it will provide the same benefits and reuses a lot of this PR's code, but should not impact performance of config parsing on any non-tuple rules and does not modify the existing DefaultRuleConfig implementation at all.

@connorshea connorshea closed this Jan 22, 2026
graphite-app bot pushed a commit that referenced this pull request Jan 22, 2026
…uple config options. (#18372)

This was built out of the ashes of #16555. Implements necessary work for #16023.

This introduces a new TupleRuleConfig. Rather than forcing DefaultRuleConfig to bend to our will as attempted previously, this PR adds a distinct rule config setup that is built to handle tuple-based rule configs.

This PR updates 3 rules to use TupleRuleConfig:

- `eslint/eqeqeq`
- `eslint/sort_keys`
- `eslint/yoda`

It also adds snapshot validation tests to ensure that they error as-expected when given an invalid config option.

The only real change here is in `eslint/eqeqeq`. We previously allowed passing _only_ an object to the rule config (due to #8790), but that is not valid in the original rule, and also makes implementing proper config option validation quite a bit more difficult. I had removed support for this in #16560, but that was part of the previous, doomed attempt at using DefaultRuleConfig with tuples. So it never got merged, and I'm redoing it here.

This is a necessary part of the change for this PR, as the alternative is to make the config options parsing/validation implementation _considerably_ more complex. This also brings us back in line with ESLint's behavior for this rule.

---

AI Disclosure: I used the code and tests from #16555 as the basis for the changes in `rule.rs`, then let Claude Opus 4.5 run with it and guided it toward the TupleRuleConfig concept. After I got a satisfactory TupleRuleConfig implementation, I then manually implemented the rest of the changes in this PR by copying the files over from #16555, applying the TupleRuleConfig rename, and then carefully going through the diffs to make sure nothing was deleted that shouldn't have been.

I have added tests to ensure that the config validation for tuple rules works, and have entirely avoided changing the unit tests in any of the 3 modified rules (other than the one change for eqeqeq I noted above).

---

<details>
<summary>Updated docs for the changed rules</summary>

Note that the sort-keys docs are entirely unchanged as they were correct prior to this, so I've excluded them.

yoda:
```md
## Configuration

### The 1st option

type: `"never" | "always"`

#### `"never"`

The default `"never"` option can have exception options in an object literal, via `exceptRange` and `onlyEquality`.

#### `"always"`

The `"always"` option requires that literal values must always come first in comparisons.

### The 2nd option

This option is an object with the following properties:

#### exceptRange

type: `boolean`

default: `false`

If the `"exceptRange"` property is `true`, the rule _allows_ yoda conditions
in range comparisons which are wrapped directly in parentheses, including the
parentheses of an `if` or `while` condition.
A _range_ comparison tests whether a variable is inside or outside the range
between two literal values.

#### onlyEquality

type: `boolean`

default: `false`

If the `"onlyEquality"` property is `true`, the rule reports yoda
conditions _only_ for the equality operators `==` and `===`. The `onlyEquality`
option allows a superset of the exceptions which `exceptRange` allows, thus
both options are not useful together.
```

eqeqeq:

```md
## Configuration

### The 1st option

type: `"always" | "smart"`

#### `"always"`

Always require triple-equal comparisons, `===`/`!==`.
This is the default.

#### `"smart"`

Allow certain safe comparisons to use `==`/`!=` (`typeof`, literals, nullish).

### The 2nd option

This option is an object with the following properties:

#### null

type: `"always" | "never" | "ignore"`

##### `"always"`

Always require triple-equals when comparing with null, `=== null`/`!== null`.
This is the default.

##### `"never"`

Never require triple-equals when comparing with null, always use `== null`/`!= null`.

##### `"ignore"`

Ignore null comparisons, allow either `== null`/`!= null` or `=== null`/`!== null`.
```

</details>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-linter Area - Linter C-cleanup Category - technical debt or refactoring. Solution not expected to change behavior C-docs Category - Documentation. Related to user-facing or internal documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants