Skip to main content

react_compiler/entrypoint/
suppression.rs

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