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 codefixer.replace(span, "new code")- replace codefixer.insert_before(span, "prefix")- insert beforefixer.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
- Pick an unchecked rule from the issue checklist
- Open the rule file:
crates/oxc_linter/src/rules/[plugin]/[rule_name].rs - Find the diagnostic function (usually at the top of the file)
- Add
.with_help("descriptive, actionable help text") - Run
cargo test -p oxc_linter -- [rule_name] - Update snapshots if needed
- 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 `_`")