How Oxc Linter Rules Work

This is the most relevant deep-dive for contributing to Oxc. The umbrella issue #19121 (adding help text to diagnostics) and bug fixes like #20054 and #19902 all require understanding how rules are structured.

Rule File Location

Rules live in crates/oxc_linter/src/rules/, organized by plugin:

rules/
  eslint/          # Core ESLint rules (no_unused_vars, eqeqeq, etc.)
  typescript/      # @typescript-eslint rules
  import/          # eslint-plugin-import rules
  react/           # eslint-plugin-react rules
  jsx_a11y/        # eslint-plugin-jsx-a11y rules
  unicorn/         # eslint-plugin-unicorn rules
  jest/            # eslint-plugin-jest rules
  vitest/          # eslint-plugin-vitest rules
  oxc/             # Oxc-specific rules
  node/            # eslint-plugin-node rules
  promise/         # eslint-plugin-promise rules
  nextjs/          # @next/eslint-plugin-next rules
  react_perf/      # eslint-plugin-react-perf rules
  vue/             # eslint-plugin-vue rules
  jsdoc/           # eslint-plugin-jsdoc rules

Anatomy of a Rule

Every rule follows the same pattern. Here’s the skeleton:

use oxc_ast::AstKind;
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::Span;

use crate::{context::LintContext, rule::Rule, AstNode};

// 1. Diagnostic builder function
fn my_rule_diagnostic(span: Span, name: &str) -> OxcDiagnostic {
    OxcDiagnostic::warn(format!("'{name}' should not be used here"))
        .with_help(format!("Consider replacing '{name}' with a safer alternative"))
        .with_label(span)
}

// 2. Rule struct (empty if no config, has fields if configurable)
#[derive(Debug, Default, Clone)]
pub struct MyRule;

// 3. Registration macro
declare_oxc_lint!(
    /// ### What it does
    /// Brief description of the rule.
    ///
    /// ### Why is this bad?
    /// Explanation of the problem.
    ///
    /// ### Examples
    ///
    /// Examples of **incorrect** code:
    /// ```js
    /// badCode();
    /// ```
    ///
    /// Examples of **correct** code:
    /// ```js
    /// goodCode();
    /// ```
    MyRule,
    eslint,        // plugin category
    correctness,   // rule category: correctness | suspicious | pedantic | style | nursery
);

// 4. Rule implementation
impl Rule for MyRule {
    fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
        // Match on AST node types you care about
        let AstKind::CallExpression(call) = node.kind() else {
            return;
        };

        // Your lint logic here
        if is_problematic(call) {
            ctx.diagnostic(my_rule_diagnostic(call.span, "something"));
        }
    }
}

The Diagnostic Builder

This is the key pattern for issue #19121 (adding help text):

// Basic warning
OxcDiagnostic::warn("Something is wrong")

// With a help message (what #19121 asks you to add)
OxcDiagnostic::warn("Something is wrong")
    .with_help("Try doing X instead")

// With a note (additional context)
OxcDiagnostic::warn("Something is wrong")
    .with_note("This was deprecated in ES2020")

// With a label pointing to specific code
OxcDiagnostic::warn("Something is wrong")
    .with_label(span)  // underlines the problematic code

// With multiple labels
OxcDiagnostic::warn("Something is wrong")
    .with_labels([
        span1.label("this variable"),
        span2.label("conflicts with this"),
    ])

// Full example
OxcDiagnostic::warn("Unexpected 'debugger' statement")
    .with_help("Remove this debugger statement before committing")
    .with_label(span.label("debugger statement here"))

Output looks like:

  ! eslint(no-debugger): Unexpected 'debugger' statement
     ,-[src/app.ts:42:5]
  41 |
  42 | debugger;
     : ^^^^^^^^^-- debugger statement here
  43 |
     `----
  help: Remove this debugger statement before committing

How Rules Are Discovered

The declare_oxc_lint! macro registers the rule. All rules in a plugin directory are collected in a mod.rs file:

// rules/eslint/mod.rs
mod no_debugger;
mod no_unused_vars;
// ... more rules

pub fn rules() -> Vec<RuleEnum> {
    vec![
        NoDebugger::default().into(),
        NoUnusedVars::default().into(),
        // ...
    ]
}

Testing a Rule

#[test]
fn test() {
    use crate::tester::Tester;

    let pass = vec![
        "const x = 1; console.log(x);",      // valid code
        "function foo(a) { return a; }",       // valid code
    ];

    let fail = vec![
        "const x = 1;",                       // unused var
        "function foo(a) { return 42; }",      // unused param
    ];

    Tester::new(MyRule::NAME, MyRule::PLUGIN, pass, fail)
        .test_and_snapshot();
}

Running the test generates/updates a snapshot file in crates/oxc_linter/src/snapshots/ that records the exact diagnostic output.

Autofix (for more advanced contributions)

Rules can provide automatic fixes:

fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
    let AstKind::DebuggerStatement(stmt) = node.kind() else {
        return;
    };

    ctx.diagnostic_with_fix(
        no_debugger_diagnostic(stmt.span),
        |fixer| {
            fixer.delete(&stmt.span)
        },
    );
}

Fix types:

  • fixer.delete(span) - remove code
  • fixer.replace(span, "new code") - replace code
  • fixer.insert_before(span, "prefix") - insert before
  • fixer.insert_after(span, "suffix") - insert after

Issue #20054 (prefer-optional-chain unsafe autofix) is about a bug in this fix logic.

Contribution Workflow for #19121

  1. Pick an unchecked rule from the issue checklist
  2. Open the rule file: crates/oxc_linter/src/rules/[plugin]/[rule_name].rs
  3. Find the diagnostic function (usually at the top of the file)
  4. Add .with_help("descriptive, actionable help text")
  5. Run cargo test -p oxc_linter -- [rule_name]
  6. Update snapshots if needed
  7. Submit PR with title: fix(linter): add help text to [rule-name]

The help text should be actionable: tell the user what to do, not just what’s wrong.

// Bad help text
.with_help("This is not allowed")

// Good help text
.with_help("Remove the debugger statement before committing")
.with_help("Use `===` instead of `==` to avoid type coercion")
.with_help("Prefix unused parameters with `_`")