main
ts 270 lines 7.76 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7 /* eslint-disable no-for-of-loops/no-for-of-loops */
8
9 import type {SourceLocation as BabelSourceLocation} from '@babel/types';
10 import {
11 type CompilerSuggestion,
12 CompilerSuggestionOperation,
13 LintRules,
14 type LintRule,
15 ErrorSeverity,
16 LintRulePreset,
17 } from 'babel-plugin-react-compiler';
18 import type {CompileErrorDetail} from 'babel-plugin-react-compiler/src/Entrypoint';
19 import {printCodeFrame} from 'babel-plugin-react-compiler/src/CompilerError';
20 import {type Linter, type Rule} from 'eslint';
21 import runReactCompiler, {RunCacheEntry} from './RunReactCompiler';
22
23 function assertExhaustive(_: never, errorMsg: string): never {
24 throw new Error(errorMsg);
25 }
26
27 /**
28 * Get the primary source location from a CompileErrorDetail.
29 * Handles both the new format (details array) and legacy format (flat loc).
30 */
31 function primaryLocation(
32 detail: CompileErrorDetail,
33 ): BabelSourceLocation | null {
34 if (detail.details != null) {
35 const firstError = detail.details.find(d => d.kind === 'error');
36 if (firstError != null) {
37 return firstError.loc ?? null;
38 }
39 }
40 return detail.loc ?? null;
41 }
42
43 /**
44 * Format an error message from a CompileErrorDetail.
45 */
46 function printErrorMessage(source: string, error: CompileErrorDetail): string {
47 const buffer = [`[ReactCompilerError] ${error.reason}`];
48 if (error.description != null) {
49 buffer.push(`\n\n${error.description}.`);
50 }
51 /*
52 * A CompileError's source location(s) may be provided either as a `details`
53 * array (CompilerDiagnostic and the Rust compiler) or as a single flat `loc`
54 * (legacy CompilerErrorDetail). Normalize to a list so both shapes render
55 * code frames identically.
56 */
57 const details =
58 error.details ??
59 (error.loc != null
60 ? [{kind: 'error' as const, loc: error.loc, message: error.reason}]
61 : []);
62 for (const detail of details) {
63 if (detail.kind === 'error') {
64 const loc = detail.loc;
65 if (loc == null || typeof loc === 'symbol') {
66 continue;
67 }
68 let codeFrame: string;
69 try {
70 codeFrame = printCodeFrame(source, loc, detail.message ?? '');
71 } catch {
72 codeFrame = detail.message ?? '';
73 }
74 buffer.push('\n\n');
75 if (loc.filename != null) {
76 // ESLint uses 1-indexed columns
77 buffer.push(
78 `${loc.filename}:${loc.start.line}:${loc.start.column + 1}\n`,
79 );
80 }
81 buffer.push(codeFrame);
82 } else if (detail.kind === 'hint') {
83 buffer.push('\n\n');
84 buffer.push(detail.message ?? '');
85 }
86 }
87 return buffer.join('');
88 }
89
90 function makeSuggestions(
91 detail: CompileErrorDetail,
92 ): Array<Rule.SuggestionReportDescriptor> {
93 const suggest: Array<Rule.SuggestionReportDescriptor> = [];
94 if (Array.isArray(detail.suggestions)) {
95 for (const suggestion of detail.suggestions as Array<CompilerSuggestion>) {
96 switch (suggestion.op) {
97 case CompilerSuggestionOperation.InsertBefore:
98 suggest.push({
99 desc: suggestion.description,
100 fix(fixer) {
101 return fixer.insertTextBeforeRange(
102 suggestion.range,
103 suggestion.text,
104 );
105 },
106 });
107 break;
108 case CompilerSuggestionOperation.InsertAfter:
109 suggest.push({
110 desc: suggestion.description,
111 fix(fixer) {
112 return fixer.insertTextAfterRange(
113 suggestion.range,
114 suggestion.text,
115 );
116 },
117 });
118 break;
119 case CompilerSuggestionOperation.Replace:
120 suggest.push({
121 desc: suggestion.description,
122 fix(fixer) {
123 return fixer.replaceTextRange(suggestion.range, suggestion.text);
124 },
125 });
126 break;
127 case CompilerSuggestionOperation.Remove:
128 suggest.push({
129 desc: suggestion.description,
130 fix(fixer) {
131 return fixer.removeRange(suggestion.range);
132 },
133 });
134 break;
135 default:
136 assertExhaustive(suggestion, 'Unhandled suggestion operation');
137 }
138 }
139 }
140 return suggest;
141 }
142
143 function getReactCompilerResult(context: Rule.RuleContext): RunCacheEntry {
144 // Compat with older versions of eslint
145 const sourceCode = context.sourceCode ?? context.getSourceCode();
146 const filename = context.filename ?? context.getFilename();
147 const userOpts = context.options[0] ?? {};
148
149 const results = runReactCompiler({
150 sourceCode,
151 filename,
152 userOpts,
153 });
154
155 return results;
156 }
157
158 function hasFlowSuppression(
159 program: RunCacheEntry,
160 nodeLoc: BabelSourceLocation,
161 suppressions: Array<string>,
162 ): boolean {
163 for (const commentNode of program.flowSuppressions) {
164 if (
165 suppressions.includes(commentNode.code) &&
166 commentNode.line === nodeLoc.start.line - 1
167 ) {
168 return true;
169 }
170 }
171 return false;
172 }
173
174 function makeRule(rule: LintRule): Rule.RuleModule {
175 const create = (context: Rule.RuleContext): Rule.RuleListener => {
176 const result = getReactCompilerResult(context);
177
178 for (const event of result.events) {
179 if (event.kind === 'CompileError') {
180 const detail = event.detail;
181 if (detail.category === rule.category) {
182 const loc = primaryLocation(detail);
183 if (loc == null) {
184 continue;
185 }
186 if (
187 hasFlowSuppression(result, loc, [
188 'react-rule-hook',
189 'react-rule-unsafe-ref',
190 ])
191 ) {
192 // If Flow already caught this error, we don't need to report it again.
193 continue;
194 }
195 /*
196 * TODO: if multiple rules report the same linter category,
197 * we should deduplicate them with a "reported" set
198 */
199 context.report({
200 message: printErrorMessage(result.sourceCode, detail),
201 loc,
202 suggest: makeSuggestions(detail),
203 });
204 }
205 }
206 }
207 return {};
208 };
209
210 return {
211 meta: {
212 type: 'problem',
213 docs: {
214 description: rule.description,
215 recommended: rule.preset === LintRulePreset.Recommended,
216 url: `https://react.dev/reference/eslint-plugin-react-hooks/lints/${rule.name}`,
217 },
218 fixable: 'code',
219 hasSuggestions: true,
220 // validation is done at runtime with zod
221 schema: [{type: 'object', additionalProperties: true}],
222 },
223 create,
224 };
225 }
226
227 type RulesConfig = {
228 [name: string]: {rule: Rule.RuleModule; severity: ErrorSeverity};
229 };
230
231 export const allRules: RulesConfig = LintRules.reduce((acc, rule) => {
232 acc[rule.name] = {rule: makeRule(rule), severity: rule.severity};
233 return acc;
234 }, {} as RulesConfig);
235
236 export const recommendedRules: RulesConfig = LintRules.filter(
237 rule => rule.preset === LintRulePreset.Recommended,
238 ).reduce((acc, rule) => {
239 acc[rule.name] = {rule: makeRule(rule), severity: rule.severity};
240 return acc;
241 }, {} as RulesConfig);
242
243 export const recommendedLatestRules: RulesConfig = LintRules.filter(
244 rule =>
245 rule.preset === LintRulePreset.Recommended ||
246 rule.preset === LintRulePreset.RecommendedLatest,
247 ).reduce((acc, rule) => {
248 acc[rule.name] = {rule: makeRule(rule), severity: rule.severity};
249 return acc;
250 }, {} as RulesConfig);
251
252 export function mapErrorSeverityToESlint(
253 severity: ErrorSeverity,
254 ): Linter.StringSeverity {
255 switch (severity) {
256 case ErrorSeverity.Error: {
257 return 'error';
258 }
259 case ErrorSeverity.Warning: {
260 return 'warn';
261 }
262 case ErrorSeverity.Hint:
263 case ErrorSeverity.Off: {
264 return 'off';
265 }
266 default: {
267 assertExhaustive(severity, `Unhandled severity: ${severity}`);
268 }
269 }
270 }