main
rs 305 lines 10.8 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 use react_compiler_ast::common::{Comment, CommentData};
8 use react_compiler_diagnostics::{
9 CompilerDiagnostic, CompilerDiagnosticDetail, CompilerError, CompilerSuggestion,
10 CompilerSuggestionOperation, ErrorCategory,
11 };
12
13 #[derive(Debug, Clone)]
14 pub enum SuppressionSource {
15 Eslint,
16 Flow,
17 }
18
19 /// Captures the start and end range of a pair of eslint-disable ... eslint-enable comments.
20 /// In the case of a CommentLine or a relevant Flow suppression, both the disable and enable
21 /// point to the same comment.
22 ///
23 /// The enable comment can be missing in the case where only a disable block is present,
24 /// ie the rest of the file has potential React violations.
25 #[derive(Debug, Clone)]
26 pub struct SuppressionRange {
27 pub disable_comment: CommentData,
28 pub enable_comment: Option<CommentData>,
29 pub source: SuppressionSource,
30 }
31
32 fn comment_data(comment: &Comment) -> &CommentData {
33 match comment {
34 Comment::CommentBlock(data) | Comment::CommentLine(data) => data,
35 }
36 }
37
38 /// Check if a comment value matches `eslint-disable-next-line <rule>` for any rule in `rule_names`.
39 fn matches_eslint_disable_next_line(value: &str, rule_names: &[String]) -> bool {
40 if let Some(rest) = value.strip_prefix("eslint-disable-next-line ") {
41 return rule_names
42 .iter()
43 .any(|name| rest.starts_with(name.as_str()));
44 }
45 // Also check with leading space (comment values often have leading whitespace)
46 let trimmed = value.trim_start();
47 if let Some(rest) = trimmed.strip_prefix("eslint-disable-next-line ") {
48 return rule_names
49 .iter()
50 .any(|name| rest.starts_with(name.as_str()));
51 }
52 false
53 }
54
55 /// Check if a comment value matches `eslint-disable <rule>` for any rule in `rule_names`.
56 fn matches_eslint_disable(value: &str, rule_names: &[String]) -> bool {
57 if let Some(rest) = value.strip_prefix("eslint-disable ") {
58 return rule_names
59 .iter()
60 .any(|name| rest.starts_with(name.as_str()));
61 }
62 let trimmed = value.trim_start();
63 if let Some(rest) = trimmed.strip_prefix("eslint-disable ") {
64 return rule_names
65 .iter()
66 .any(|name| rest.starts_with(name.as_str()));
67 }
68 false
69 }
70
71 /// Check if a comment value matches `eslint-enable <rule>` for any rule in `rule_names`.
72 fn matches_eslint_enable(value: &str, rule_names: &[String]) -> bool {
73 if let Some(rest) = value.strip_prefix("eslint-enable ") {
74 return rule_names
75 .iter()
76 .any(|name| rest.starts_with(name.as_str()));
77 }
78 let trimmed = value.trim_start();
79 if let Some(rest) = trimmed.strip_prefix("eslint-enable ") {
80 return rule_names
81 .iter()
82 .any(|name| rest.starts_with(name.as_str()));
83 }
84 false
85 }
86
87 /// Check if a comment value matches a Flow suppression pattern.
88 /// Matches: $FlowFixMe[react-rule, $FlowFixMe_xxx[react-rule,
89 /// $FlowExpectedError[react-rule, $FlowIssue[react-rule
90 fn matches_flow_suppression(value: &str) -> bool {
91 // Find "$Flow" anywhere in the value
92 let Some(idx) = value.find("$Flow") else {
93 return false;
94 };
95 let after_dollar_flow = &value[idx + "$Flow".len()..];
96
97 // Match FlowFixMe (with optional word chars), FlowExpectedError, or FlowIssue
98 let after_kind = if after_dollar_flow.starts_with("FixMe") {
99 // Skip "FixMe" + any word characters
100 let rest = &after_dollar_flow["FixMe".len()..];
101 let word_end = rest
102 .find(|c: char| !c.is_alphanumeric() && c != '_')
103 .unwrap_or(rest.len());
104 &rest[word_end..]
105 } else if after_dollar_flow.starts_with("ExpectedError") {
106 &after_dollar_flow["ExpectedError".len()..]
107 } else if after_dollar_flow.starts_with("Issue") {
108 &after_dollar_flow["Issue".len()..]
109 } else {
110 return false;
111 };
112
113 // Must be followed by "[react-rule"
114 after_kind.starts_with("[react-rule")
115 }
116
117 /// Parse eslint-disable/enable and Flow suppression comments from program comments.
118 /// Equivalent to findProgramSuppressions in Suppression.ts
119 pub fn find_program_suppressions(
120 comments: &[Comment],
121 rule_names: Option<&[String]>,
122 flow_suppressions: bool,
123 ) -> Vec<SuppressionRange> {
124 let mut suppression_ranges: Vec<SuppressionRange> = Vec::new();
125 let mut disable_comment: Option<CommentData> = None;
126 let mut enable_comment: Option<CommentData> = None;
127 let mut source: Option<SuppressionSource> = None;
128
129 let has_rules = matches!(rule_names, Some(names) if !names.is_empty());
130
131 for comment in comments {
132 let data = comment_data(comment);
133
134 if data.start.is_none() || data.end.is_none() {
135 continue;
136 }
137
138 // Check for eslint-disable-next-line (only if not already within a block)
139 if disable_comment.is_none() && has_rules {
140 if let Some(names) = rule_names {
141 if matches_eslint_disable_next_line(&data.value, names) {
142 disable_comment = Some(data.clone());
143 enable_comment = Some(data.clone());
144 source = Some(SuppressionSource::Eslint);
145 }
146 }
147 }
148
149 // Check for Flow suppression (only if not already within a block)
150 if flow_suppressions && disable_comment.is_none() && matches_flow_suppression(&data.value) {
151 disable_comment = Some(data.clone());
152 enable_comment = Some(data.clone());
153 source = Some(SuppressionSource::Flow);
154 }
155
156 // Check for eslint-disable (block start)
157 if has_rules {
158 if let Some(names) = rule_names {
159 if matches_eslint_disable(&data.value, names) {
160 disable_comment = Some(data.clone());
161 source = Some(SuppressionSource::Eslint);
162 }
163 }
164 }
165
166 // Check for eslint-enable (block end)
167 if has_rules {
168 if let Some(names) = rule_names {
169 if matches_eslint_enable(&data.value, names) {
170 if matches!(source, Some(SuppressionSource::Eslint)) {
171 enable_comment = Some(data.clone());
172 }
173 }
174 }
175 }
176
177 // If we have a complete suppression, push it
178 if disable_comment.is_some() && source.is_some() {
179 suppression_ranges.push(SuppressionRange {
180 disable_comment: disable_comment.take().unwrap(),
181 enable_comment: enable_comment.take(),
182 source: source.take().unwrap(),
183 });
184 }
185 }
186
187 suppression_ranges
188 }
189
190 /// Check if suppression ranges overlap with a function's source range.
191 /// A suppression affects a function if:
192 /// 1. The suppression is within the function's body
193 /// 2. The suppression wraps the function
194 pub fn filter_suppressions_that_affect_function(
195 suppressions: &[SuppressionRange],
196 fn_start: u32,
197 fn_end: u32,
198 ) -> Vec<&SuppressionRange> {
199 let mut suppressions_in_scope: Vec<&SuppressionRange> = Vec::new();
200
201 for suppression in suppressions {
202 let disable_start = match suppression.disable_comment.start {
203 Some(s) => s,
204 None => continue,
205 };
206
207 // The suppression is within the function
208 if disable_start > fn_start
209 && (suppression.enable_comment.is_none()
210 || suppression
211 .enable_comment
212 .as_ref()
213 .and_then(|c| c.end)
214 .map_or(false, |end| end < fn_end))
215 {
216 suppressions_in_scope.push(suppression);
217 }
218
219 // The suppression wraps the function
220 if disable_start < fn_start
221 && (suppression.enable_comment.is_none()
222 || suppression
223 .enable_comment
224 .as_ref()
225 .and_then(|c| c.end)
226 .map_or(false, |end| end > fn_end))
227 {
228 suppressions_in_scope.push(suppression);
229 }
230 }
231
232 suppressions_in_scope
233 }
234
235 /// Convert suppression ranges to a CompilerError.
236 pub fn suppressions_to_compiler_error(suppressions: &[SuppressionRange]) -> CompilerError {
237 assert!(
238 !suppressions.is_empty(),
239 "Expected at least one suppression comment source range"
240 );
241
242 let mut error = CompilerError::new();
243
244 for suppression in suppressions {
245 let (disable_start, disable_end) = match (
246 suppression.disable_comment.start,
247 suppression.disable_comment.end,
248 ) {
249 (Some(s), Some(e)) => (s, e),
250 _ => continue,
251 };
252
253 let (reason, suggestion) = match suppression.source {
254 SuppressionSource::Eslint => (
255 "React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled",
256 "Remove the ESLint suppression and address the React error",
257 ),
258 SuppressionSource::Flow => (
259 "React Compiler has skipped optimizing this component because one or more React rule violations were reported by Flow",
260 "Remove the Flow suppression and address the React error",
261 ),
262 };
263
264 let description = format!(
265 "React Compiler only works when your components follow all the rules of React, disabling them may result in unexpected or incorrect behavior. Found suppression `{}`",
266 suppression.disable_comment.value.trim()
267 );
268
269 let mut diagnostic =
270 CompilerDiagnostic::new(ErrorCategory::Suppression, reason, Some(description));
271
272 diagnostic.suggestions = Some(vec![CompilerSuggestion {
273 description: suggestion.to_string(),
274 range: (disable_start as usize, disable_end as usize),
275 op: CompilerSuggestionOperation::Remove,
276 text: None,
277 }]);
278
279 // Add error detail with location info
280 let loc = suppression.disable_comment.loc.as_ref().map(|l| {
281 react_compiler_diagnostics::SourceLocation {
282 start: react_compiler_diagnostics::Position {
283 line: l.start.line,
284 column: l.start.column,
285 index: l.start.index,
286 },
287 end: react_compiler_diagnostics::Position {
288 line: l.end.line,
289 column: l.end.column,
290 index: l.end.index,
291 },
292 }
293 });
294
295 diagnostic = diagnostic.with_detail(CompilerDiagnosticDetail::Error {
296 loc,
297 message: Some("Found React rule suppression".to_string()),
298 identifier_name: None,
299 });
300
301 error.push_diagnostic(diagnostic);
302 }
303
304 error
305 }