Skip to main content

wowlab_tidy/languages/rust/
mod.rs

1// #t(file: rust_default_hasher) trusted rule-name keys in a cold dispatch set; fast-hasher dependency not warranted
2
3//! Rust source parsing and rule execution.
4
5#[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
111// #t(fn: rust_cyclomatic_complexity) the adapter coordinates parse, suppression, line, AST, workspace, and fix paths
112pub(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
231/// Analyze directive usage without applying source suppressions or config path ignores.
232pub(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
292/// Evaluate raw Rust workspace-rule violations once without applying source suppressions.
293pub(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(&registered_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, &registered, 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, &registered, 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, &registered, false);
601
602        verify_eq!(analysis.violations.len(), 1)?;
603
604        Ok(())
605    }
606}