1#[cfg(test)]
6use googletest::prelude::*;
7
8mod rules;
9
10use std::collections::HashSet;
11
12use line_index::LineIndex;
13use ra_ap_syntax::{
14 AstNode, Edition, SourceFile, SyntaxNode,
15 ast::{self, HasAttrs, HasName},
16};
17
18use crate::{
19 FileCtx, Rule, RuleCheck, RuleFix, Violation,
20 infra::{ignore, scanner},
21 languages::Analysis,
22 matches_ignore,
23};
24
25const DIRECTIVE_RULE: &str = "rust_tidy_directives";
26
27#[derive(Debug)]
28pub(crate) struct SuppressionAudit {
29 pub suppressions: ignore::Suppressions,
30 pub raw_violations: Vec<Violation>,
31 pub workspace_file: crate::languages::workspace::WorkspaceRustFile,
32}
33
34#[derive(Debug)]
35pub(crate) enum SuppressionAuditError {
36 InvalidDirectives(Vec<Violation>),
37 RustSyntax,
38}
39
40pub(crate) struct AstCtx<'a> {
41 pub(crate) file: &'a FileCtx<'a>,
42 pub(crate) root: &'a SourceFile,
43 pub(crate) line_index: &'a LineIndex,
44}
45
46pub(crate) trait RustLocation {
47 fn line_in(&self, ctx: &AstCtx<'_>) -> usize;
48}
49
50impl<N> RustLocation for &N
51where
52 N: AstNode,
53{
54 fn line_in(&self, ctx: &AstCtx<'_>) -> usize {
55 ctx.line_index
56 .line_col(self.syntax().text_range().start())
57 .line as usize
58 + 1
59 }
60}
61
62#[expect(
63 clippy::unused_self,
64 reason = "context helper methods keep syntax analysis call sites cohesive"
65)]
66impl AstCtx<'_> {
67 pub(crate) fn nodes<'a, N>(&'a self) -> impl Iterator<Item = N> + 'a
68 where
69 N: AstNode + 'a,
70 {
71 self.root.syntax().descendants().filter_map(N::cast)
72 }
73
74 pub(crate) fn is_in_test<N>(&self, node: &N) -> bool
75 where
76 N: AstNode,
77 {
78 node.syntax()
79 .ancestors()
80 .filter_map(ast::Item::cast)
81 .any(|item| item.attrs().any(|attr| is_test_attribute(&attr)))
82 }
83
84 #[expect(
85 clippy::needless_pass_by_value,
86 reason = "RustLocation is implemented for AST references, so the generic value is already a cheap reference"
87 )]
88 pub(crate) fn line_of(&self, location: impl RustLocation) -> usize {
89 location.line_in(self)
90 }
91
92 pub(crate) fn violation(
93 &self,
94 location: impl RustLocation,
95 msg: impl Into<String>,
96 ) -> Violation {
97 crate::violation(self.file.rel, self.line_of(location), msg)
98 }
99}
100
101impl std::fmt::Debug for AstCtx<'_> {
102 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103 f.debug_struct("AstCtx")
104 .field("file", &self.file)
105 .field("root", &"<ra_ap_syntax::SourceFile>")
106 .field("line_index", &self.line_index)
107 .finish()
108 }
109}
110
111pub(crate) fn analyze(
113 file: &FileCtx<'_>,
114 rules: &[&Rule],
115 registered_names: &HashSet<&str>,
116 fix_mode: bool,
117) -> Analysis {
118 let mut analysis = Analysis::default();
119 let mut ignore_errors = Vec::new();
120 let ra_parse = SourceFile::parse(file.contents, Edition::Edition2024);
121 let ra_root = ra_parse.errors().is_empty().then(|| ra_parse.tree());
122 let visible_lines = ra_root.as_ref().map_or_else(
123 || file.lines.to_vec(),
124 |syntax| line_rule_view(file.lines, syntax, file.contents, file.rel),
125 );
126 let line_file = FileCtx {
127 rel: file.rel,
128 path: file.path,
129 lines: &visible_lines,
130 contents: file.contents,
131 config: file.config,
132 };
133 let directive_lines = scanner::directive_source_lines(file.contents, &visible_lines);
134 let suppressed = ignore::suppressed_lines(
135 line_file.rel,
136 &directive_lines,
137 &mut ignore_errors,
138 Some(registered_names),
139 );
140 let workspace_suppressions = suppressed.clone();
141
142 analysis.violations.extend(
143 ignore_errors
144 .into_iter()
145 .map(|violation| violation.with_rule(DIRECTIVE_RULE)),
146 );
147
148 for rule in rules {
149 let RuleCheck::RustLine(check) = rule.check else {
150 continue;
151 };
152
153 if is_suppressed(&line_file, rule, &suppressed) {
154 continue;
155 }
156
157 let violations = ignore::filter(&suppressed, tagged(rule, check(&line_file)));
158
159 if fix_mode && let Some(RuleFix::RustLine(fix)) = rule.fix {
160 collect_fixes(&mut analysis, file.rel, &violations, |violation| {
161 fix(&line_file, violation)
162 });
163 }
164
165 analysis.violations.extend(violations);
166 }
167
168 if let Some(root) = ra_root {
169 let line_index = LineIndex::new(file.contents);
170 let ctx = AstCtx {
171 file,
172 root: &root,
173 line_index: &line_index,
174 };
175
176 for rule in rules {
177 let RuleCheck::RustAst(check) = rule.check else {
178 continue;
179 };
180
181 if is_suppressed(file, rule, &suppressed) {
182 continue;
183 }
184
185 let violations = ignore::filter(&suppressed, tagged(rule, check(&ctx)));
186
187 if fix_mode {
188 match rule.fix {
189 Some(RuleFix::RustAst(fix)) => {
190 collect_fixes(&mut analysis, file.rel, &violations, |violation| {
191 fix(&ctx, violation)
192 });
193 }
194 Some(RuleFix::RustAstTree(fix)) => {
195 if !violations.is_empty()
196 && let Some(replacement) = fix(&ctx, &violations)
197 {
198 analysis.tree_fixes.push(crate::infra::fix::TreeFix {
199 rel: file.rel.to_owned(),
200 rule: rule.info.name,
201 replacement,
202 });
203 }
204 }
205 _ => {}
206 }
207 }
208
209 analysis.violations.extend(violations);
210 }
211
212 if rules.iter().any(|rule| {
213 matches!(
214 rule.check,
215 RuleCheck::RustWorkspace(_) | RuleCheck::Workspace(_)
216 )
217 }) {
218 analysis
219 .workspace_files
220 .push(crate::languages::workspace::extract(
221 file,
222 &root,
223 workspace_suppressions,
224 ));
225 }
226 }
227
228 analysis
229}
230
231pub(crate) fn audit_suppressions(
233 file: &FileCtx<'_>,
234 rules: &[&Rule],
235 registered_names: &HashSet<&str>,
236) -> Result<SuppressionAudit, SuppressionAuditError> {
237 let ra_parse = SourceFile::parse(file.contents, Edition::Edition2024);
238
239 if !ra_parse.errors().is_empty() {
240 return Err(SuppressionAuditError::RustSyntax);
241 }
242
243 let root = ra_parse.tree();
244 let visible_lines = line_rule_view(file.lines, &root, file.contents, file.rel);
245 let audit_lines = scanner::directive_source_lines(file.contents, &visible_lines);
246 let audit_file = FileCtx {
247 rel: file.rel,
248 path: file.path,
249 lines: &audit_lines,
250 contents: file.contents,
251 config: file.config,
252 };
253 let mut directive_errors = Vec::new();
254 let suppressions = ignore::suppressed_lines(
255 file.rel,
256 &audit_lines,
257 &mut directive_errors,
258 Some(registered_names),
259 );
260
261 if !directive_errors.is_empty() {
262 return Err(SuppressionAuditError::InvalidDirectives(directive_errors));
263 }
264
265 let ast = AstCtx {
266 file: &audit_file,
267 root: &root,
268 line_index: &LineIndex::new(file.contents),
269 };
270 let mut raw_violations = Vec::new();
271
272 for rule in rules {
273 let violations = match rule.check {
274 RuleCheck::RustLine(check) => check(&audit_file),
275 RuleCheck::RustAst(check) => check(&ast),
276 RuleCheck::RustWorkspace(_) | RuleCheck::Workspace(_) | RuleCheck::Toml(_) => continue,
277 };
278
279 raw_violations.extend(tagged(rule, violations));
280 }
281
282 raw_violations.sort_by(|a, b| a.line.cmp(&b.line).then(a.rule.cmp(&b.rule)));
283 let workspace_file = crate::languages::workspace::extract(file, &root, suppressions.clone());
284
285 Ok(SuppressionAudit {
286 suppressions,
287 raw_violations,
288 workspace_file,
289 })
290}
291
292pub(crate) fn audit_workspace_suppressions(
294 files: &[crate::languages::workspace::WorkspaceRustFile],
295 rules: &[&Rule],
296 config: &crate::Config,
297) -> Vec<Violation> {
298 let ctx = crate::languages::workspace::WorkspaceCtx {
299 files,
300 manifests: &[],
301 config,
302 };
303 let mut violations = Vec::new();
304
305 for rule in rules {
306 let RuleCheck::RustWorkspace(check) = rule.check else {
307 continue;
308 };
309
310 violations.extend(
311 check(&ctx)
312 .into_iter()
313 .map(|violation| violation.with_rule(rule.info.name)),
314 );
315 }
316
317 violations.sort_by(|a, b| {
318 a.rel
319 .cmp(&b.rel)
320 .then(a.line.cmp(&b.line))
321 .then(a.rule.cmp(&b.rule))
322 });
323
324 violations
325}
326
327fn line_rule_view<'a>(
328 lines: &[&'a str],
329 syntax: &SourceFile,
330 contents: &str,
331 rel: &str,
332) -> Vec<&'a str> {
333 let mut excluded = vec![false; lines.len()];
334 let line_index = LineIndex::new(contents);
335 let rule_implementation =
336 rel.starts_with("crates/tidy/src/languages/") && rel.contains("/rules/");
337
338 for item in syntax.syntax().descendants().filter_map(ast::Item::cast) {
339 if is_line_rule_metadata(&item, rule_implementation)
340 || item.attrs().any(|attr| is_test_attribute(&attr))
341 {
342 exclude_item(&mut excluded, &line_index, item.syntax());
343 }
344 }
345
346 lines
347 .iter()
348 .zip(excluded)
349 .map(|(&line, excluded)| if excluded { "" } else { line })
350 .collect()
351}
352
353fn exclude_item(excluded: &mut [bool], line_index: &LineIndex, node: &SyntaxNode) {
354 let start = line_index.line_col(node.text_range().start()).line as usize;
355 let end = line_index.line_col(node.text_range().end()).line as usize + 1;
356 let range_start = start.min(excluded.len());
357 let range_end = end.min(excluded.len());
358
359 if let Some(region) = excluded.get_mut(range_start..range_end) {
360 region.fill(true);
361 }
362}
363
364fn is_line_rule_metadata(item: &ast::Item, rule_implementation: bool) -> bool {
365 if let ast::Item::Const(item_const) = item
366 && item_const.name().is_some_and(|name| {
367 let text = name.text();
368
369 text == "EXAMPLES" || rule_implementation && text.contains("PATTERN")
370 })
371 {
372 return true;
373 }
374
375 let ast::Item::MacroCall(item_macro) = item else {
376 return false;
377 };
378
379 let macro_token = item_macro
380 .path()
381 .and_then(|path| path.segment())
382 .and_then(|segment| segment.syntax().last_token());
383
384 macro_token.is_some_and(|token| {
385 matches!(
386 token.text(),
387 "line_rule"
388 | "ast_rule"
389 | "toml_rule"
390 | "tidy_test"
391 | "tidy_ast_test"
392 | "tidy_toml_test"
393 )
394 })
395}
396
397fn is_test_attribute(attr: &ast::Attr) -> bool {
398 match attr.simple_name().as_deref() {
399 Some("test") => true,
400 Some("cfg" | "cfg_attr") => attr
401 .syntax()
402 .descendants_with_tokens()
403 .filter_map(ra_ap_syntax::NodeOrToken::into_token)
404 .any(|token| token.text() == "test"),
405 _ => false,
406 }
407}
408
409fn is_suppressed(file: &FileCtx<'_>, rule: &Rule, suppressed: &ignore::Suppressions) -> bool {
410 matches_ignore(file.rel, file.config.ignore_patterns(rule.info.name))
411 || ignore::is_file_suppressed(suppressed, rule.info.name)
412}
413
414fn tagged(rule: &Rule, violations: Vec<Violation>) -> Vec<Violation> {
415 violations
416 .into_iter()
417 .map(|violation| violation.with_rule(rule.info.name))
418 .collect()
419}
420
421fn collect_fixes(
422 analysis: &mut Analysis,
423 rel: &str,
424 violations: &[Violation],
425 fix: impl Fn(&Violation) -> Option<crate::Fix>,
426) {
427 analysis.fixes.extend(
428 violations
429 .iter()
430 .filter_map(fix)
431 .map(|fix| (rel.to_owned(), fix)),
432 );
433}
434
435#[cfg(test)]
436mod tests {
437 use wowlab_fs::path::Path;
438
439 use super::*;
440 use crate::infra::config::Config;
441
442 fn analyze_all(source: &str) -> Analysis {
443 let metadata = crate::all_rules();
444 let registered_meta: Vec<_> = metadata
445 .iter()
446 .map(|rule| (rule.name, rule.params))
447 .collect();
448 let config = Config::generate_default(®istered_meta);
449 let lines: Vec<&str> = source.lines().collect();
450 let file = FileCtx {
451 rel: "adapter.rs",
452 path: Path::new("adapter.rs"),
453 lines: &lines,
454 contents: source,
455 config: &config,
456 };
457 let rules: Vec<&Rule> = inventory::iter::<Rule>
458 .into_iter()
459 .filter(|rule| config.is_enabled(rule.info.name))
460 .collect();
461 let registered = rules.iter().map(|rule| rule.info.name).collect();
462
463 analyze(&file, &rules, ®istered, false)
464 }
465
466 #[gtest]
467 fn clean_fixture_has_no_violations() -> Result<()> {
468 let analysis = analyze_all(include_str!("../../../tests/fixtures/clean.rs"));
469
470 verify_true!(analysis.violations.is_empty())?;
471
472 Ok(())
473 }
474
475 #[gtest]
476 fn dirty_source_reports_registered_rules() -> Result<()> {
477 let analysis = analyze_all("fn f() { dbg!(1); let value = 42; }");
478 let rules: Vec<_> = analysis
479 .violations
480 .iter()
481 .filter_map(|violation| violation.rule)
482 .collect();
483
484 verify_true!(rules.contains(&"rust_dbg"))?;
485 verify_true!(rules.contains(&"rust_magic_numbers"))?;
486
487 Ok(())
488 }
489
490 #[gtest]
491 fn adapter_applies_line_suppressions_after_tagging() -> Result<()> {
492 let analysis =
493 analyze_all("fn f() {\n // #t(rust_dbg) intentional diagnostic\n dbg!(1);\n}\n");
494
495 verify_true!(
496 analysis
497 .violations
498 .iter()
499 .all(|violation| violation.rule != Some("rust_dbg"))
500 )?;
501
502 Ok(())
503 }
504
505 #[gtest]
506 fn directive_lookalikes_do_not_suppress_ast_rules() -> Result<()> {
507 let source = concat!(
508 "const RAW: &str = r#\"\n",
509 "// #t(file: rust_panic) raw string lookalike\n",
510 "\"#;\n",
511 "/*\n",
512 "// #t(file: rust_panic) block comment lookalike\n",
513 "*/\n",
514 "fn production() { panic!(); }\n",
515 );
516 let analysis = analyze_all(source);
517
518 verify_true!(
519 analysis
520 .violations
521 .iter()
522 .any(|violation| violation.rule == Some("rust_panic"))
523 )?;
524
525 Ok(())
526 }
527
528 #[gtest]
529 fn line_rules_ignore_examples_and_test_literals() -> Result<()> {
530 let source = r#"
531use crate::Example;
532
533const EXAMPLES: &[Example] = &[
534 Example { label: "path", code: "const P: &str = \"/home/dev/file\";", pass: false },
535 Example { label: "box", code: "type T = Box<Vec<u8>>;", pass: false },
536 Example { label: "directive", code: "// #t(rust_unknown) fixture", pass: false },
537];
538
539#[cfg(test)]
540mod tests {
541 const URL: &str = "https://example.com";
542 const BOXED: &str = "Box<Vec<u8>>";
543}
544"#;
545 let config = Config::generate_default(&[]);
546 let lines: Vec<&str> = source.lines().collect();
547 let file = FileCtx {
548 rel: "fixture_regions.rs",
549 path: Path::new("fixture_regions.rs"),
550 lines: &lines,
551 contents: source,
552 config: &config,
553 };
554 let rules: Vec<&Rule> = inventory::iter::<Rule>
555 .into_iter()
556 .filter(|rule| {
557 matches!(
558 rule.info.name,
559 "rust_abs_home_path"
560 | "rust_box_vec"
561 | "rust_hardcoded_url"
562 | "rust_tidy_directives"
563 )
564 })
565 .collect();
566 let registered: HashSet<&str> = inventory::iter::<Rule>
567 .into_iter()
568 .map(|rule| rule.info.name)
569 .collect();
570
571 let analysis = analyze(&file, &rules, ®istered, true);
572
573 verify_true!(analysis.violations.is_empty())?;
574 verify_true!(analysis.fixes.is_empty())?;
575
576 Ok(())
577 }
578
579 #[gtest]
580 fn line_rules_still_analyze_production_source() -> Result<()> {
581 let source = "const PATH: &str = \"/home/dev/file\";";
582 let config = Config::generate_default(&[]);
583 let lines: Vec<&str> = source.lines().collect();
584 let file = FileCtx {
585 rel: "production.rs",
586 path: Path::new("production.rs"),
587 lines: &lines,
588 contents: source,
589 config: &config,
590 };
591 let rules: Vec<&Rule> = inventory::iter::<Rule>
592 .into_iter()
593 .filter(|rule| rule.info.name == "rust_abs_home_path")
594 .collect();
595 let registered: HashSet<&str> = inventory::iter::<Rule>
596 .into_iter()
597 .map(|rule| rule.info.name)
598 .collect();
599
600 let analysis = analyze(&file, &rules, ®istered, false);
601
602 verify_eq!(analysis.violations.len(), 1)?;
603
604 Ok(())
605 }
606}