| 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 | } |