What happens when you port ESLint rules to Rust — Oxc AST, the visitor pattern, and the reality of JS plugins
I've been frustrated by linting pipeline speed before. Every time I saved a file, ESLint would run, and once the project grew to thousands of files, the feedback loop started taking several seconds. That's when I first encountered Oxlint — driven by curiosity about just how fast something written in Rust could be, I decided to port a rule myself. The short answer: it was a far more different world than I expected.
This post covers how Oxc's AST structure differs from ESLint's, how Rust's visitor pattern works, and how far the JS plugin system can actually take you — from a practitioner's perspective. I'll cover both paths: writing rules purely in Rust, and bringing the existing ESLint ecosystem over via JS plugins.
Oxlint's JS plugin support was first announced as a Preview on October 9, 2025, then promoted to Alpha on March 11, 2026, moving one step closer to general adoption. This post is written against the Alpha milestone (as of March 2026).
The target audience is developers who have written ESLint rules before, or who care about linting pipeline performance. Some Rust background is helpful but not required to follow the flow.
ESLint and Oxlint Have Fundamentally Different Execution Models
ESLint's Rule Structure
The heart of an ESLint rule is the object returned by create(). Keys are AST node type strings; values are handler functions called when that node is visited.
// Basic ESLint rule structure
module.exports = {
create(context) {
return {
CallExpression(node) {
if (isConsoleLog(node)) {
context.report({ node, message: "Remove console.log" });
}
},
};
},
};This structure is dynamic-dispatch-based. At runtime it looks at a string key to decide which handler to call, paying that cost for every node.
Oxlint's Rule Trait
In Oxlint, rule authors work directly with the Rule trait. You can implement one or more of three hooks.
// Basic Oxlint rule structure (oxc_linter crate)
impl Rule for NoConsoleLog {
fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
if let AstKind::CallExpression(call_expr) = node.kind() {
if is_console_log(call_expr) {
ctx.diagnostic(no_console_log_diagnostic(call_expr.span));
}
}
}
}run() is conceptually called for every AST node, but in practice the framework contains filtering that dispatches only to rules interested in a given node type. According to the Oxlint architecture write-up (Inside Oxlint), each rule pre-declares which AstKind values it cares about in a bitset form, and during traversal the framework skips calling any rule not listed for that node. Whether this pre-computation happens at Rust const evaluation time or at process initialization is an implementation detail; for our purposes it's enough to note that it's already decided before node traversal begins.
run_once(ctx) is used when you need to scan the entire file exactly once. It suits rules like no-duplicate-imports that must collect all import statements and judge them together. run_on_symbol(symbol_id, ctx) is called once per symbol. For example, a no-unused-vars-style rule that catches "declared but never referenced variables" is more natural when it checks reference counts once per symbol in the symbol table, rather than re-evaluating on every node during traversal.
The key point is that rule authors only implement these three hooks. The visitor logic that actually walks the AST and invokes the hooks is the framework's responsibility.
AST Structure: Memory Arenas and the 'a Lifetime Parameter
Honestly, the most disorienting thing when I first looked at the Oxc AST was 'a. Every function signature has <'a> attached — AstNode<'a>, LintContext<'a>… "Why does everything carry a lifetime parameter?"
The answer is memory arenas.
The oxc_ast crate does not allocate AST nodes individually on the heap. When parsing begins, a single arena (Arena) is created and all nodes are placed onto it. When parsing is done, the arena is discarded all at once — no GC, no per-node deallocation cost.
// Conceptual example — simplified from actual Oxc internals
pub struct Parser<'a> {
allocator: &'a Allocator, // arena allocator
// ...
}
// All nodes in the parse result carry the 'a lifetime
// Note: Vec here is not std::vec::Vec but a custom arena-backed Vec
// provided by oxc_allocator
pub struct Program<'a> {
pub body: Vec<'a, Statement<'a>>,
// ...
}'a is a compile-time guarantee that "this data is valid only as long as the arena is alive." When a rule function takes <'a>, it is receiving that arena lifetime directly.
Another notable design choice is storing span offsets as u32 instead of usize. Most source files don't exceed 4 GB, so u32 is sufficient while using half the memory. It seems minor, but with hundreds of thousands of nodes the difference adds up.
Complex semantic analysis is handled by oxc_semantic in a separate pass — symbol tables, scope chains, and variable reference tracking all live there. Rules access this via ctx.semantic().symbols().
Visitor Patterns: Visit, VisitMut, Traverse — and Where Rule Authors Fit
Oxc provides three visitor traits.
| Trait | Characteristics | Primary Use |
|---|---|---|
Visit |
Immutable references, read-only AST | Linting, analysis |
VisitMut |
Mutable references, can modify AST | Transpilation, auto-fix |
Traverse |
enter/exit hooks, context propagation | Complex transforms, codegen |
One important point: lint rule authors do not implement these three traits directly. Rule authors only interact with the Rule trait; actual AST traversal is performed internally by the Oxlint framework using Visit-style logic. Rules simply receive nodes as callbacks delivered by that traversal.
So when is Traverse used? Its primary consumers are transpilers and codegen tools — tools that actually transform the AST and need to precisely track scope entry and exit during that process.
// Conceptual example of the Traverse trait (based on the oxc_traverse crate)
// This is a transformer pattern, not a linter rule
impl<'a> Traverse<'a> for MyTransformer {
fn enter_function(&mut self, func: &mut Function<'a>, ctx: &mut TraverseCtx<'a>) {
self.scope_depth += 1;
}
fn exit_function(&mut self, func: &mut Function<'a>, ctx: &mut TraverseCtx<'a>) {
self.scope_depth -= 1;
}
}Lessons from Porting no-useless-assignment
Looking at a community attempt to port eslint/no-useless-assignment, a few patterns stand out.
First, copying the ESLint source code directly is not possible. JavaScript's create() return structure and Rust's Rule trait don't map one-to-one. Because of ownership and borrowing, the logic itself must be rethought in a Rust-idiomatic way.
Second, symbol tracking goes through ctx.semantic(). In the code below, is_never_read_after_assign is not a real function — it's a placeholder to show the flow. In an actual port, that spot would contain much longer logic that walks through the symbol's reference list and reasons about control flow.
// Pseudocode for illustration only, not runnable
// AssignmentTarget splits into SimpleAssignmentTarget and AssignmentTargetPattern;
// identifiers are on the SimpleAssignmentTarget side
impl Rule for NoUselessAssignment {
fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
if let AstKind::AssignmentExpression(assign_expr) = node.kind() {
if let AssignmentTarget::SimpleAssignmentTarget(
SimpleAssignmentTarget::AssignmentTargetIdentifier(ident)
) = &assign_expr.left {
let symbols = ctx.semantic().symbols();
// (this function does not exist; shown for conceptual clarity)
if is_never_read_after_assign(ident, symbols, ctx) {
ctx.diagnostic(useless_assignment_diagnostic(assign_expr.span));
}
}
}
}
}ctx.semantic().symbols() provides the symbol table for the entire file. Because where each variable is defined and referenced has already been analyzed, the rule doesn't need to re-traverse the AST itself.
Rules that require auto-fixing involve oxc_codegen, which is responsible for regenerating source code from a modified AST.
JS Plugin Compatibility: What Works and Where It Breaks
As mentioned, Oxlint JS Plugins Alpha was announced in March 2026, promoted from the October 2025 Preview — a progression in release stage, not merely a rename.
The core mechanism is Raw Transfer — passing data between Rust and Node.js without copying it, which minimizes the performance penalty of using JS plugins. According to the benchmark in the Alpha announcement blog post, on the Node.js repository (6,298 files, Mac Mini M4), Oxlint + JS plugins took 21 seconds vs ESLint's 1 minute 43 seconds. Dividing those two numbers gives roughly a 4.9× difference — note that this is a calculated value, not an explicitly stated multiplier from the original post.
For Raw Transfer's environmental requirements (Node.js version, file size limits, etc.) and behavior on Node versions below 22, consult the JS plugins usage guide. This post only flags that environment requirements exist and must be verified before adoption.
Current Compatibility Boundaries
| Scenario | Support Status |
|---|---|
| General ESLint plugins | Alpha stage; many work (verify per plugin) |
@typescript-eslint type-aware rules |
Not currently supported in JS plugins |
| Plugins outside officially tested set | No guarantee; verify individually |
| Rust native rules | Stable; type-aware available as Preview |
Among @typescript-eslint rules, those requiring type information (no-floating-promises, no-unsafe-argument, etc.) are not yet supported via the JS plugin path. Type-aware linting opened as a Preview in the Rust native path in August 2025, but is not yet connected to the JS plugin path.
Incremental Migration: The Role of eslint-plugin-oxlint
You don't have to do a full switch all at once. There is a bridge package called eslint-plugin-oxlint that automatically disables rules in your ESLint config that Oxlint already handles — letting you run both tools in parallel and migrate gradually.
// .eslintrc.js
module.exports = {
plugins: ["oxlint"],
extends: ["plugin:oxlint/recommended"],
// Rules handled by Oxlint are automatically turned off
};There is one gotcha: some plugins like eslint-stylistic declare ESLint itself as a peerDependency, meaning ESLint ends up installed even in Oxlint-only projects. This is a plugin ecosystem issue that the Oxlint team can't directly fix.
Rust Rules vs JS Plugins: How to Choose
| Criterion | Rust Native Rules | JS Plugins |
|---|---|---|
| Performance | Native execution, minimal overhead via framework filtering | Gap narrowed by Raw Transfer (still significant per March 2026 Alpha benchmark) |
| Implementation effort | Rust learning curve, full rewrite required | Reuse existing JS rules |
| Type-aware rules | Preview stage support | Not currently supported |
| Stability | Stable | Alpha stage |
| Best fit | General-purpose rules, performance-critical cases | Team-specific rules, ESLint migrations |
I wanted to cite side-by-side numbers for both paths, but I couldn't find reliable conditional benchmarks (specifying file count, hardware, and version) for the Rust native path in my references. Only the JS plugin path has the 6,298-file benchmark cited above, so I judged it more honest to rely on that single data point.
Conclusion: A Checklist for Adoption Decisions
Oxc's real differentiators aren't marketing copy — they're the three things examined in this post: the memory model that pushes nodes into an arena from the parsing stage itself, pre-filtering that selects only interested rules before node traversal begins, and the single-pipeline architecture where parser, semantic analyzer, linter, and codegen all share the same arena. These three working together produce the impression of "fast."
If you're weighing adoption, mapping these questions to your team's situation is the practical approach:
- If you rely heavily on type-aware rules: the JS plugin path isn't ready yet; watching the Rust native type-aware Preview is the safer bet.
- If no one on the team knows Rust: rather than contributing to core, start with
eslint-plugin-oxlintin parallel — lower learning burden. - If you have many team-specific custom rules: JS plugin Alpha is a realistic candidate, but if this is a production-critical pipeline, design a rollback path given the Alpha status.
- Check environment requirements: review the official usage guide for JS plugin runtime requirements before adopting.
If learning Rust is on the table, porting a simple rule yourself (e.g., no-console-log) is worth it for the experience alone. Seeing it work with just a few lines of pattern matching inside a single run() hook gives you the visceral sense that writing Oxlint rules is far more approachable than it first appears.
References
- Oxc Official Site
- Adding Linter Rules — Oxc Official Contribution Guide
- AST Structure Official Docs
- Oxlint JS Plugins Preview Announcement (2025-10-09)
- Oxlint JS Plugins Alpha Announcement (2026-03-11)
- Oxlint Type-Aware Preview (2025-08-17)
- JS Plugins Usage Guide
- Writing JS Plugins Guide
- Migrate from ESLint Guide
- eslint-plugin-oxlint (npm)
- Implementing eslint/no-useless-assignment in oxlint
- Inside Oxlint: Linter Architecture and Rule System
- oxc_ast crate docs
- oxc-project/oxc GitHub Repository