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 {
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
233/// Analyze directive usage without applying source suppressions or config path ignores.
234pub(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
294/// Evaluate raw Rust workspace-rule violations once without applying source suppressions.
295pub(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(&registered_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, &registered, 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, &registered, 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, &registered, false);
603
604        verify_eq!(analysis.violations.len(), 1)?;
605
606        Ok(())
607    }
608}