main
ts 270 lines 7.64 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
8 import type {SourceLocation as BabelSourceLocation} from '@babel/types';
9 import {
10 CompilerSuggestionOperation,
11 type CompileErrorDetail,
12 } from 'babel-plugin-react-compiler/src';
13 import type {Linter, Rule} from 'eslint';
14 import runReactCompiler, {RunCacheEntry} from '../shared/RunReactCompiler';
15 import {
16 ErrorSeverity,
17 LintRulePreset,
18 LintRules,
19 printCodeFrame,
20 type LintRule,
21 } from 'babel-plugin-react-compiler/src/CompilerError';
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, matching the old
45 * CompilerErrorDetail.printErrorMessage() / CompilerDiagnostic.printErrorMessage() behavior.
46 */
47 function printErrorMessage(source: string, error: CompileErrorDetail): string {
48 const buffer = [`[ReactCompilerError] ${error.reason}`];
49 if (error.description != null) {
50 buffer.push(`\n\n${error.description}.`);
51 }
52 /*
53 * A CompileError's source location(s) may be provided either as a `details`
54 * array (CompilerDiagnostic and the Rust compiler) or as a single flat `loc`
55 * (legacy CompilerErrorDetail). Normalize to a list so both shapes render
56 * code frames identically.
57 */
58 const details =
59 error.details ??
60 (error.loc != null
61 ? [{kind: 'error' as const, loc: error.loc, message: error.reason}]
62 : []);
63 for (const detail of details) {
64 if (detail.kind === 'error') {
65 const loc = detail.loc;
66 if (loc == null || typeof loc === 'symbol') {
67 continue;
68 }
69 let codeFrame: string;
70 try {
71 codeFrame = printCodeFrame(source, loc, detail.message ?? '');
72 } catch {
73 codeFrame = detail.message ?? '';
74 }
75 buffer.push('\n\n');
76 if (loc.filename != null) {
77 // ESLint uses 1-indexed columns
78 buffer.push(
79 `${loc.filename}:${loc.start.line}:${loc.start.column + 1}\n`,
80 );
81 }
82 buffer.push(codeFrame);
83 } else if (detail.kind === 'hint') {
84 buffer.push('\n\n');
85 buffer.push(detail.message ?? '');
86 }
87 }
88 return buffer.join('');
89 }
90
91 function makeSuggestions(
92 detail: CompileErrorDetail,
93 ): Array<Rule.SuggestionReportDescriptor> {
94 const suggest: Array<Rule.SuggestionReportDescriptor> = [];
95 if (Array.isArray(detail.suggestions)) {
96 for (const suggestion of detail.suggestions as Array<any>) {
97 switch (suggestion.op) {
98 case CompilerSuggestionOperation.InsertBefore:
99 suggest.push({
100 desc: suggestion.description,
101 fix(fixer) {
102 return fixer.insertTextBeforeRange(
103 suggestion.range,
104 suggestion.text,
105 );
106 },
107 });
108 break;
109 case CompilerSuggestionOperation.InsertAfter:
110 suggest.push({
111 desc: suggestion.description,
112 fix(fixer) {
113 return fixer.insertTextAfterRange(
114 suggestion.range,
115 suggestion.text,
116 );
117 },
118 });
119 break;
120 case CompilerSuggestionOperation.Replace:
121 suggest.push({
122 desc: suggestion.description,
123 fix(fixer) {
124 return fixer.replaceTextRange(suggestion.range, suggestion.text);
125 },
126 });
127 break;
128 case CompilerSuggestionOperation.Remove:
129 suggest.push({
130 desc: suggestion.description,
131 fix(fixer) {
132 return fixer.removeRange(suggestion.range);
133 },
134 });
135 break;
136 default:
137 assertExhaustive(suggestion, 'Unhandled suggestion operation');
138 }
139 }
140 }
141 return suggest;
142 }
143
144 function getReactCompilerResult(context: Rule.RuleContext): RunCacheEntry {
145 // Compat with older versions of eslint
146 const sourceCode = context.sourceCode ?? context.getSourceCode();
147 const filename = context.filename ?? context.getFilename();
148 const userOpts = context.options[0] ?? {};
149
150 const results = runReactCompiler({
151 sourceCode,
152 filename,
153 userOpts,
154 });
155
156 return results;
157 }
158
159 function hasFlowSuppression(
160 program: RunCacheEntry,
161 nodeLoc: BabelSourceLocation,
162 suppressions: Array<string>,
163 ): boolean {
164 for (const commentNode of program.flowSuppressions) {
165 if (
166 suppressions.includes(commentNode.code) &&
167 commentNode.line === nodeLoc.start.line - 1
168 ) {
169 return true;
170 }
171 }
172 return false;
173 }
174
175 function makeRule(rule: LintRule): Rule.RuleModule {
176 const create = (context: Rule.RuleContext): Rule.RuleListener => {
177 const result = getReactCompilerResult(context);
178
179 for (const event of result.events) {
180 if (event.kind === 'CompileError') {
181 const detail = event.detail;
182 if (detail.category === rule.category) {
183 const loc = primaryLocation(detail);
184 if (loc == null) {
185 continue;
186 }
187 if (
188 hasFlowSuppression(result, loc, [
189 'react-rule-hook',
190 'react-rule-unsafe-ref',
191 ])
192 ) {
193 // If Flow already caught this error, we don't need to report it again.
194 continue;
195 }
196 /*
197 * TODO: if multiple rules report the same linter category,
198 * we should deduplicate them with a "reported" set
199 */
200 context.report({
201 message: printErrorMessage(result.sourceCode, detail),
202 loc,
203 suggest: makeSuggestions(detail),
204 });
205 }
206 }
207 }
208 return {};
209 };
210
211 return {
212 meta: {
213 type: 'problem',
214 docs: {
215 description: rule.description,
216 recommended: rule.preset === LintRulePreset.Recommended,
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 }