Skip to content

Commit 8569aab

Browse files
eve0415claude
andauthored
feat(eslint): ESLint plugin with full react-compiler diagnostic parity (#19)
## Summary - Adds a complete ESLint plugin exposing `react-compiler` diagnostics via the NAPI `lint()` API, with 27 rules matching upstream's `eslint-plugin-react-compiler` - Implements per-function suppression handling (`eslint-disable`, `"use no memo"`), `no-unused-directives` with suggest-only fixes, Flow suppression, and full `PluginOptions`/`EnvironmentConfig` passthrough - Includes comprehensive Vitest test suite ported from upstream covering hooks, refs, setState-in-render, capitalized calls, effect dependencies, suppressions, TypeScript, and options passthrough ## Test plan - [x] `cargo test --package oxc_react_compiler` — Rust-side lint integration tests pass - [x] `pnpm vitest` — Full Vitest ESLint RuleTester suite passes (17 test files) - [x] `cargo run --release --bin conformance -- --update` — No conformance regressions - [x] `cargo clippy --workspace --all-features --all-targets -- -D warnings` — Clean - [x] `cargo fmt --all` — Formatted 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b8a76a2 commit 8569aab

57 files changed

Lines changed: 5647 additions & 128 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,13 @@ We have achieved **100% conformance parity** with the upstream React Compiler. T
2525
npm install oxc-plugin-react-compiler
2626
```
2727

28-
> [!NOTE]
29-
> This plugin requires **Vite 8.0 or later**.
30-
3128
## Usage
3229

30+
### Vite
31+
32+
> [!NOTE]
33+
> The Vite integration requires **Vite 8.0 or later**.
34+
3335
```ts
3436
import { defineConfig } from 'vite';
3537
import react from '@vitejs/plugin-react';
@@ -48,6 +50,37 @@ export default defineConfig({
4850
});
4951
```
5052

53+
### ESLint
54+
55+
The lint plugin is exposed from the `oxc-plugin-react-compiler/eslint` subpath:
56+
57+
```ts
58+
import reactCompiler from 'oxc-plugin-react-compiler/eslint';
59+
60+
export default [reactCompiler.configs.recommended];
61+
```
62+
63+
The exported rules use the `oxc-react-compiler/*` namespace.
64+
65+
### Oxlint
66+
67+
Oxlint JS plugin support is also exposed from the same `oxc-plugin-react-compiler/eslint` subpath:
68+
69+
```json
70+
{
71+
"jsPlugins": ["oxc-plugin-react-compiler/eslint"],
72+
"rules": {
73+
"oxc-react-compiler/purity": "error",
74+
"oxc-react-compiler/refs": "error",
75+
"oxc-react-compiler/no-unused-directives": "error"
76+
}
77+
}
78+
```
79+
80+
Use `oxc-plugin-react-compiler/eslint` as the Oxlint JS plugin specifier. The bare
81+
`oxc-plugin-react-compiler` package root is the Vite plugin entrypoint, not the
82+
Oxlint plugin entrypoint.
83+
5184
## Experimental Nature
5285

5386
This project serves as an experiment to explore AI-assisted development in complex compiler porting tasks. The codebase is being heavily co-developed with AI assistants including Claude Code and Codex. Due to API rate limits and the inherent complexity of the task, development is expected to span several months. The initial primitive implementation took about a month to prove the viability of a Rust-based React compiler.

crates/oxc_react_compiler/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ indexmap = { workspace = true }
2424
hmac = { workspace = true }
2525
sha2 = { workspace = true }
2626
uuid = { workspace = true }
27+
serde = { workspace = true }
2728

2829
[dev-dependencies]
2930
serde_json = { workspace = true }

crates/oxc_react_compiler/src/error.rs

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
55
use std::fmt;
66

7+
use crate::hir::types::SourceLocation;
8+
79
/// Severity levels for compiler diagnostics.
810
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
911
pub enum DiagnosticSeverity {
@@ -17,18 +19,184 @@ pub enum DiagnosticSeverity {
1719
Invariant,
1820
}
1921

22+
/// Error category for lint rules, matching upstream's `ErrorCategory` enum.
23+
/// Each variant maps to a distinct ESLint rule name.
24+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
25+
pub enum ErrorCategory {
26+
Hooks,
27+
CapitalizedCalls,
28+
StaticComponents,
29+
UseMemo,
30+
Factories,
31+
PreserveManualMemo,
32+
IncompatibleLibrary,
33+
Immutability,
34+
Globals,
35+
Refs,
36+
EffectDependencies,
37+
EffectSetState,
38+
EffectDerivationsOfState,
39+
ErrorBoundaries,
40+
Purity,
41+
RenderSetState,
42+
Invariant,
43+
Todo,
44+
Syntax,
45+
UnsupportedSyntax,
46+
Config,
47+
Gating,
48+
Suppression,
49+
AutomaticEffectDependencies,
50+
Fire,
51+
FBT,
52+
/// Used by the no-unused-directives rule (not an upstream ErrorCategory).
53+
UnusedDirective,
54+
}
55+
56+
impl fmt::Display for ErrorCategory {
57+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58+
f.write_str(self.rule_name())
59+
}
60+
}
61+
62+
impl ErrorCategory {
63+
/// Returns the kebab-case rule name matching upstream's `LintRule.name`.
64+
pub fn rule_name(self) -> &'static str {
65+
match self {
66+
Self::Hooks => "hooks",
67+
Self::CapitalizedCalls => "capitalized-calls",
68+
Self::StaticComponents => "static-components",
69+
Self::UseMemo => "use-memo",
70+
Self::Factories => "component-hook-factories",
71+
Self::PreserveManualMemo => "preserve-manual-memoization",
72+
Self::IncompatibleLibrary => "incompatible-library",
73+
Self::Immutability => "immutability",
74+
Self::Globals => "globals",
75+
Self::Refs => "refs",
76+
Self::EffectDependencies => "memoized-effect-dependencies",
77+
Self::EffectSetState => "set-state-in-effect",
78+
Self::EffectDerivationsOfState => "no-deriving-state-in-effects",
79+
Self::ErrorBoundaries => "error-boundaries",
80+
Self::Purity => "purity",
81+
Self::RenderSetState => "set-state-in-render",
82+
Self::Invariant => "invariant",
83+
Self::Todo => "todo",
84+
Self::Syntax => "syntax",
85+
Self::UnsupportedSyntax => "unsupported-syntax",
86+
Self::Config => "config",
87+
Self::Gating => "gating",
88+
Self::Suppression => "rule-suppression",
89+
Self::AutomaticEffectDependencies => "automatic-effect-dependencies",
90+
Self::Fire => "fire",
91+
Self::FBT => "fbt",
92+
Self::UnusedDirective => "no-unused-directives",
93+
}
94+
}
95+
96+
/// Returns the default ESLint severity for this category.
97+
pub fn default_severity(self) -> ErrorSeverity {
98+
match self {
99+
Self::IncompatibleLibrary | Self::UnsupportedSyntax => ErrorSeverity::Warning,
100+
Self::Todo => ErrorSeverity::Hint,
101+
_ => ErrorSeverity::Error,
102+
}
103+
}
104+
105+
/// Whether this rule is included in the "recommended" preset.
106+
pub fn recommended(self) -> bool {
107+
matches!(
108+
self,
109+
Self::StaticComponents
110+
| Self::UseMemo
111+
| Self::Factories
112+
| Self::PreserveManualMemo
113+
| Self::IncompatibleLibrary
114+
| Self::Immutability
115+
| Self::Globals
116+
| Self::Refs
117+
| Self::EffectSetState
118+
| Self::ErrorBoundaries
119+
| Self::Purity
120+
| Self::RenderSetState
121+
| Self::UnsupportedSyntax
122+
| Self::Config
123+
| Self::Gating
124+
| Self::UnusedDirective
125+
)
126+
}
127+
}
128+
129+
/// ESLint-level severity for lint rules.
130+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131+
pub enum ErrorSeverity {
132+
Error,
133+
Warning,
134+
Hint,
135+
Off,
136+
}
137+
138+
impl fmt::Display for ErrorSeverity {
139+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140+
match self {
141+
Self::Error => f.write_str("error"),
142+
Self::Warning => f.write_str("warning"),
143+
Self::Hint => f.write_str("hint"),
144+
Self::Off => f.write_str("off"),
145+
}
146+
}
147+
}
148+
149+
/// A related diagnostic location with context message.
150+
#[derive(Debug, Clone)]
151+
pub struct RelatedDiagnostic {
152+
pub message: String,
153+
pub span: Option<oxc_span::Span>,
154+
}
155+
156+
/// An auto-fix suggestion for a diagnostic.
157+
#[derive(Debug, Clone)]
158+
pub enum CompilerSuggestion {
159+
InsertBefore {
160+
description: String,
161+
range: (u32, u32),
162+
text: String,
163+
},
164+
InsertAfter {
165+
description: String,
166+
range: (u32, u32),
167+
text: String,
168+
},
169+
Remove {
170+
description: String,
171+
range: (u32, u32),
172+
},
173+
Replace {
174+
description: String,
175+
range: (u32, u32),
176+
text: String,
177+
},
178+
}
179+
20180
/// A compiler diagnostic.
21181
#[derive(Debug, Clone)]
22182
pub struct CompilerDiagnostic {
23183
pub severity: DiagnosticSeverity,
24184
pub message: String,
185+
pub category: ErrorCategory,
186+
pub span: Option<oxc_span::Span>,
187+
pub related: Vec<RelatedDiagnostic>,
188+
pub suggestions: Vec<CompilerSuggestion>,
25189
}
26190

27191
impl Default for CompilerDiagnostic {
28192
fn default() -> Self {
29193
Self {
30194
severity: DiagnosticSeverity::Invariant,
31195
message: String::new(),
196+
category: ErrorCategory::Invariant,
197+
span: None,
198+
related: Vec::new(),
199+
suggestions: Vec::new(),
32200
}
33201
}
34202
}
@@ -81,6 +249,8 @@ impl CompilerError {
81249
diagnostics: vec![CompilerDiagnostic {
82250
severity: DiagnosticSeverity::Invariant,
83251
message,
252+
category: ErrorCategory::Invariant,
253+
..Default::default()
84254
}],
85255
})
86256
}
@@ -103,6 +273,77 @@ impl From<BailOut> for CompilerError {
103273
}
104274
}
105275

276+
/// Extract the OXC span from a `SourceLocation`, if it has one.
277+
pub fn extract_span(loc: &SourceLocation) -> Option<oxc_span::Span> {
278+
match loc {
279+
SourceLocation::Source(range) => Some(range.original_span),
280+
SourceLocation::Generated => None,
281+
}
282+
}
283+
284+
/// Extract the source range from a `SourceLocation`, if it has one.
285+
pub fn extract_source_range(loc: &SourceLocation) -> Option<&crate::hir::types::SourceRange> {
286+
match loc {
287+
SourceLocation::Source(range) => Some(range),
288+
SourceLocation::Generated => None,
289+
}
290+
}
291+
292+
/// A lint-related location with line:column info.
293+
#[derive(Debug, Clone)]
294+
pub struct LintRelated {
295+
pub message: String,
296+
pub start_line: u32,
297+
pub start_column: u32,
298+
pub end_line: u32,
299+
pub end_column: u32,
300+
}
301+
302+
/// A lint-level auto-fix suggestion.
303+
#[derive(Debug, Clone)]
304+
pub struct LintSuggestion {
305+
pub description: String,
306+
pub op: SuggestionOp,
307+
pub range: (u32, u32),
308+
pub text: Option<String>,
309+
}
310+
311+
/// The operation type for a suggestion.
312+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313+
pub enum SuggestionOp {
314+
InsertBefore,
315+
InsertAfter,
316+
Remove,
317+
Replace,
318+
}
319+
320+
impl fmt::Display for SuggestionOp {
321+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
322+
match self {
323+
Self::InsertBefore => f.write_str("insert-before"),
324+
Self::InsertAfter => f.write_str("insert-after"),
325+
Self::Remove => f.write_str("remove"),
326+
Self::Replace => f.write_str("replace"),
327+
}
328+
}
329+
}
330+
331+
/// A structured lint diagnostic with line:column location info,
332+
/// ready to be returned via NAPI.
333+
#[derive(Debug, Clone)]
334+
pub struct LintDiagnostic {
335+
pub category: ErrorCategory,
336+
pub message: String,
337+
pub severity: ErrorSeverity,
338+
pub start_line: u32,
339+
pub start_column: u32,
340+
pub end_line: u32,
341+
pub end_column: u32,
342+
pub has_location: bool,
343+
pub related: Vec<LintRelated>,
344+
pub suggestions: Vec<LintSuggestion>,
345+
}
346+
106347
#[cfg(test)]
107348
mod tests {
108349
use super::*;
@@ -121,6 +362,37 @@ mod tests {
121362
let _diag = CompilerDiagnostic {
122363
severity: DiagnosticSeverity::InvalidReact,
123364
message: "test".to_string(),
365+
category: ErrorCategory::Hooks,
366+
..Default::default()
124367
};
125368
}
369+
370+
#[test]
371+
fn test_error_category_rule_names() {
372+
assert_eq!(ErrorCategory::Hooks.rule_name(), "hooks");
373+
assert_eq!(
374+
ErrorCategory::RenderSetState.rule_name(),
375+
"set-state-in-render"
376+
);
377+
assert_eq!(
378+
ErrorCategory::PreserveManualMemo.rule_name(),
379+
"preserve-manual-memoization"
380+
);
381+
assert_eq!(
382+
ErrorCategory::UnusedDirective.rule_name(),
383+
"no-unused-directives"
384+
);
385+
}
386+
387+
#[test]
388+
fn test_error_severity_display() {
389+
assert_eq!(format!("{}", ErrorSeverity::Error), "error");
390+
assert_eq!(format!("{}", ErrorSeverity::Warning), "warning");
391+
}
392+
393+
#[test]
394+
fn test_suggestion_op_display() {
395+
assert_eq!(format!("{}", SuggestionOp::InsertBefore), "insert-before");
396+
assert_eq!(format!("{}", SuggestionOp::Remove), "remove");
397+
}
126398
}

0 commit comments

Comments
 (0)