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 {
160 if let Some(RuleFix::RustLine(fix)) = rule.fix {
161 collect_fixes(&mut analysis, file.rel, &violations, |violation| {
162 fix(&line_file, violation)
163 });
164 }
165 }
166
167 analysis.violations.extend(violations);
168 }
169
170 if let Some(root) = ra_root {
171 let line_index = LineIndex::new(file.contents);
172 let ctx = AstCtx {
173 file,
174 root: &root,
175 line_index: &line_index,
176 };
177
178 for rule in rules {
179 let RuleCheck::RustAst(check) = rule.check else {
180 continue;
181 };
182
183 if is_suppressed(file, rule, &suppressed) {
184 continue;
185 }
186
187 let violations = ignore::filter(&suppressed, tagged(rule, check(&ctx)));
188
189 if fix_mode {
190 match rule.fix {
191 Some(RuleFix::RustAst(fix)) => {
192 collect_fixes(&mut analysis, file.rel, &violations, |violation| {
193 fix(&ctx, violation)
194 });
195 }
196 Some(RuleFix::RustAstTree(fix)) => {
197 if !violations.is_empty()
198 && let Some(replacement) = fix(&ctx, &violations)
199 {
200 analysis.tree_fixes.push(crate::infra::fix::TreeFix {
201 rel: file.rel.to_owned(),
202 rule: rule.info.name,
203 replacement,
204 });
205 }
206 }
207 _ => {}
208 }
209 }
210
211 analysis.violations.extend(violations);
212 }
213
214 if rules.iter().any(|rule| {
215 matches!(
216 rule.check,
217 RuleCheck::RustWorkspace(_) | RuleCheck::Workspace(_)
218 )
219 }) {
220 analysis
221 .workspace_files
222 .push(crate::languages::workspace::extract(
223 file,
224 &root,
225 workspace_suppressions,
226 ));
227 }
228 }
229
230 analysis
231}
232
233pub(crate) fn audit_suppressions(
235 file: &FileCtx<'_>,
236 rules: &[&Rule],
237 registered_names: &HashSet<&str>,
238) -> Result<SuppressionAudit, SuppressionAuditError> {
239 let ra_parse = SourceFile::parse(file.contents, Edition::Edition2024);
240
241 if !ra_parse.errors().is_empty() {
242 return Err(SuppressionAuditError::RustSyntax);
243 }
244
245 let root = ra_parse.tree();
246 let visible_lines = line_rule_view(file.lines, &root, file.contents, file.rel);
247 let audit_lines = scanner::directive_source_lines(file.contents, &visible_lines);
248 let audit_file = FileCtx {
249 rel: file.rel,
250 path: file.path,
251 lines: &audit_lines,
252 contents: file.contents,
253 config: file.config,
254 };
255 let mut directive_errors = Vec::new();
256 let suppressions = ignore::suppressed_lines(
257 file.rel,
258 &audit_lines,
259 &mut directive_errors,
260 Some(registered_names),
261 );
262
263 if !directive_errors.is_empty() {
264 return Err(SuppressionAuditError::InvalidDirectives(directive_errors));
265 }
266
267 let ast = AstCtx {
268 file: &audit_file,
269 root: &root,
270 line_index: &LineIndex::new(file.contents),
271 };
272 let mut raw_violations = Vec::new();
273
274 for rule in rules {
275 let violations = match rule.check {
276 RuleCheck::RustLine(check) => check(&audit_file),
277 RuleCheck::RustAst(check) => check(&ast),
278 RuleCheck::RustWorkspace(_) | RuleCheck::Workspace(_) | RuleCheck::Toml(_) => continue,
279 };
280
281 raw_violations.extend(tagged(rule, violations));
282 }
283
284 raw_violations.sort_by(|a, b| a.line.cmp(&b.line).then(a.rule.cmp(&b.rule)));
285 let workspace_file = crate::languages::workspace::extract(file, &root, suppressions.clone());
286
287 Ok(SuppressionAudit {
288 suppressions,
289 raw_violations,
290 workspace_file,
291 })
292}
293
294pub(crate) fn audit_workspace_suppressions(
296 files: &[crate::languages::workspace::WorkspaceRustFile],
297 rules: &[&Rule],
298 config: &crate::Config,
299) -> Vec<Violation> {
300 let ctx = crate::languages::workspace::WorkspaceCtx {
301 files,
302 manifests: &[],
303 config,
304 };
305 let mut violations = Vec::new();
306
307 for rule in rules {
308 let RuleCheck::RustWorkspace(check) = rule.check else {
309 continue;
310 };
311
312 violations.extend(
313 check(&ctx)
314 .into_iter()
315 .map(|violation| violation.with_rule(rule.info.name)),
316 );
317 }
318
319 violations.sort_by(|a, b| {
320 a.rel
321 .cmp(&b.rel)
322 .then(a.line.cmp(&b.line))
323 .then(a.rule.cmp(&b.rule))
324 });
325
326 violations
327}
328
329fn line_rule_view<'a>(
330 lines: &[&'a str],
331 syntax: &SourceFile,
332 contents: &str,
333 rel: &str,
334) -> Vec<&'a str> {
335 let mut excluded = vec![false; lines.len()];
336 let line_index = LineIndex::new(contents);
337 let rule_implementation =
338 rel.starts_with("crates/tidy/src/languages/") && rel.contains("/rules/");
339
340 for item in syntax.syntax().descendants().filter_map(ast::Item::cast) {
341 if is_line_rule_metadata(&item, rule_implementation)
342 || item.attrs().any(|attr| is_test_attribute(&attr))
343 {
344 exclude_item(&mut excluded, &line_index, item.syntax());
345 }
346 }
347
348 lines
349 .iter()
350 .zip(excluded)
351 .map(|(&line, excluded)| if excluded { "" } else { line })
352 .collect()
353}
354
355fn exclude_item(excluded: &mut [bool], line_index: &LineIndex, node: &SyntaxNode) {
356 let start = line_index.line_col(node.text_range().start()).line as usize;
357 let end = line_index.line_col(node.text_range().end()).line as usize + 1;
358 let range_start = start.min(excluded.len());
359 let range_end = end.min(excluded.len());
360
361 if let Some(region) = excluded.get_mut(range_start..range_end) {
362 region.fill(true);
363 }
364}
365
366fn is_line_rule_metadata(item: &ast::Item, rule_implementation: bool) -> bool {
367 if let ast::Item::Const(item_const) = item {
368 if item_const.name().is_some_and(|name| {
369 let text = name.text();
370
371 text == "EXAMPLES" || rule_implementation && text.contains("PATTERN")
372 }) {
373 return true;
374 }
375 }
376
377 let ast::Item::MacroCall(item_macro) = item else {
378 return false;
379 };
380
381 let macro_token = item_macro
382 .path()
383 .and_then(|path| path.segment())
384 .and_then(|segment| segment.syntax().last_token());
385
386 macro_token.is_some_and(|token| {
387 matches!(
388 token.text(),
389 "line_rule"
390 | "ast_rule"
391 | "toml_rule"
392 | "tidy_test"
393 | "tidy_ast_test"
394 | "tidy_toml_test"
395 )
396 })
397}
398
399fn is_test_attribute(attr: &ast::Attr) -> bool {
400 match attr.simple_name().as_deref() {
401 Some("test") => true,
402 Some("cfg" | "cfg_attr") => attr
403 .syntax()
404 .descendants_with_tokens()
405 .filter_map(ra_ap_syntax::NodeOrToken::into_token)
406 .any(|token| token.text() == "test"),
407 _ => false,
408 }
409}
410
411fn is_suppressed(file: &FileCtx<'_>, rule: &Rule, suppressed: &ignore::Suppressions) -> bool {
412 matches_ignore(file.rel, file.config.ignore_patterns(rule.info.name))
413 || ignore::is_file_suppressed(suppressed, rule.info.name)
414}
415
416fn tagged(rule: &Rule, violations: Vec<Violation>) -> Vec<Violation> {
417 violations
418 .into_iter()
419 .map(|violation| violation.with_rule(rule.info.name))
420 .collect()
421}
422
423fn collect_fixes(
424 analysis: &mut Analysis,
425 rel: &str,
426 violations: &[Violation],
427 fix: impl Fn(&Violation) -> Option<crate::Fix>,
428) {
429 analysis.fixes.extend(
430 violations
431 .iter()
432 .filter_map(fix)
433 .map(|fix| (rel.to_owned(), fix)),
434 );
435}
436
437#[cfg(test)]
438mod tests {
439 use wowlab_fs::path::Path;
440
441 use super::*;
442 use crate::infra::config::Config;
443
444 fn analyze_all(source: &str) -> Analysis {
445 let metadata = crate::all_rules();
446 let registered_meta: Vec<_> = metadata
447 .iter()
448 .map(|rule| (rule.name, rule.params))
449 .collect();
450 let config = Config::generate_default(®istered_meta);
451 let lines: Vec<&str> = source.lines().collect();
452 let file = FileCtx {
453 rel: "adapter.rs",
454 path: Path::new("adapter.rs"),
455 lines: &lines,
456 contents: source,
457 config: &config,
458 };
459 let rules: Vec<&Rule> = inventory::iter::<Rule>
460 .into_iter()
461 .filter(|rule| config.is_enabled(rule.info.name))
462 .collect();
463 let registered = rules.iter().map(|rule| rule.info.name).collect();
464
465 analyze(&file, &rules, ®istered, false)
466 }
467
468 #[gtest]
469 fn clean_fixture_has_no_violations() -> Result<()> {
470 let analysis = analyze_all(include_str!("../../../tests/fixtures/clean.rs"));
471
472 verify_true!(analysis.violations.is_empty())?;
473
474 Ok(())
475 }
476
477 #[gtest]
478 fn dirty_source_reports_registered_rules() -> Result<()> {
479 let analysis = analyze_all("fn f() { dbg!(1); let value = 42; }");
480 let rules: Vec<_> = analysis
481 .violations
482 .iter()
483 .filter_map(|violation| violation.rule)
484 .collect();
485
486 verify_true!(rules.contains(&"rust_dbg"))?;
487 verify_true!(rules.contains(&"rust_magic_numbers"))?;
488
489 Ok(())
490 }
491
492 #[gtest]
493 fn adapter_applies_line_suppressions_after_tagging() -> Result<()> {
494 let analysis =
495 analyze_all("fn f() {\n // #t(rust_dbg) intentional diagnostic\n dbg!(1);\n}\n");
496
497 verify_true!(
498 analysis
499 .violations
500 .iter()
501 .all(|violation| violation.rule != Some("rust_dbg"))
502 )?;
503
504 Ok(())
505 }
506
507 #[gtest]
508 fn directive_lookalikes_do_not_suppress_ast_rules() -> Result<()> {
509 let source = concat!(
510 "const RAW: &str = r#\"\n",
511 "// #t(file: rust_panic) raw string lookalike\n",
512 "\"#;\n",
513 "/*\n",
514 "// #t(file: rust_panic) block comment lookalike\n",
515 "*/\n",
516 "fn production() { panic!(); }\n",
517 );
518 let analysis = analyze_all(source);
519
520 verify_true!(
521 analysis
522 .violations
523 .iter()
524 .any(|violation| violation.rule == Some("rust_panic"))
525 )?;
526
527 Ok(())
528 }
529
530 #[gtest]
531 fn line_rules_ignore_examples_and_test_literals() -> Result<()> {
532 let source = r#"
533use crate::Example;
534
535const EXAMPLES: &[Example] = &[
536 Example { label: "path", code: "const P: &str = \"/home/dev/file\";", pass: false },
537 Example { label: "box", code: "type T = Box<Vec<u8>>;", pass: false },
538 Example { label: "directive", code: "// #t(rust_unknown) fixture", pass: false },
539];
540
541#[cfg(test)]
542mod tests {
543 const URL: &str = "https://example.com";
544 const BOXED: &str = "Box<Vec<u8>>";
545}
546"#;
547 let config = Config::generate_default(&[]);
548 let lines: Vec<&str> = source.lines().collect();
549 let file = FileCtx {
550 rel: "fixture_regions.rs",
551 path: Path::new("fixture_regions.rs"),
552 lines: &lines,
553 contents: source,
554 config: &config,
555 };
556 let rules: Vec<&Rule> = inventory::iter::<Rule>
557 .into_iter()
558 .filter(|rule| {
559 matches!(
560 rule.info.name,
561 "rust_abs_home_path"
562 | "rust_box_vec"
563 | "rust_hardcoded_url"
564 | "rust_tidy_directives"
565 )
566 })
567 .collect();
568 let registered: HashSet<&str> = inventory::iter::<Rule>
569 .into_iter()
570 .map(|rule| rule.info.name)
571 .collect();
572
573 let analysis = analyze(&file, &rules, ®istered, true);
574
575 verify_true!(analysis.violations.is_empty())?;
576 verify_true!(analysis.fixes.is_empty())?;
577
578 Ok(())
579 }
580
581 #[gtest]
582 fn line_rules_still_analyze_production_source() -> Result<()> {
583 let source = "const PATH: &str = \"/home/dev/file\";";
584 let config = Config::generate_default(&[]);
585 let lines: Vec<&str> = source.lines().collect();
586 let file = FileCtx {
587 rel: "production.rs",
588 path: Path::new("production.rs"),
589 lines: &lines,
590 contents: source,
591 config: &config,
592 };
593 let rules: Vec<&Rule> = inventory::iter::<Rule>
594 .into_iter()
595 .filter(|rule| rule.info.name == "rust_abs_home_path")
596 .collect();
597 let registered: HashSet<&str> = inventory::iter::<Rule>
598 .into_iter()
599 .map(|rule| rule.info.name)
600 .collect();
601
602 let analysis = analyze(&file, &rules, ®istered, false);
603
604 verify_eq!(analysis.violations.len(), 1)?;
605
606 Ok(())
607 }
608}