Skip to main content

stern4rust/rules/source/
declared_by_name_rule.rs

1// Copyright 2025 Umberto Gotti <umberto.gotti@umbertogotti.dev>
2// Licensed under the MIT License
3// SPDX-License-Identifier: MIT
4
5use std::iter::once;
6
7use syn::Attribute;
8use syn::Expr;
9use syn::Item;
10use syn::ItemMod;
11use syn::Lit;
12use syn::Meta;
13use syn::parse_file;
14
15use crate::reporting::offence::Offence;
16use crate::reporting::rule_explanation::RuleExplanation;
17use crate::rule::Rule;
18use crate::source_file::SourceFile;
19
20// A module is declared by name: `mod alpha;` reaches `alpha.rs` or
21// `alpha/mod.rs`, and nothing else decides which file that is.
22//
23// This is not a rule about taste. `#[path = "..."]` is the one attribute that
24// makes another rule here give a **confident wrong answer**:
25// `registry-completeness` resolves a declaration to the file it names by
26// convention, so a file reached through an explicit path is reported as never
27// compiled when it compiles perfectly well. That rule accepted the gap on the
28// grounds that the house standard forbids `#[path]` and nothing in the family
29// uses it -- a convention nothing enforced. This enforces it.
30//
31// It applies to the whole package rather than to registries. The house standard
32// names `all_tests.rs` because that is where the temptation is, but the harm is
33// the same wherever the attribute appears: a `mod` in an ordinary source file is
34// resolved by the same convention and misread in the same way.
35//
36// `#[cfg_attr(unix, path = "...")]` is deliberately left alone. A platform-gated
37// module is the one honest use of the attribute, it cannot resolve by name on
38// every platform anyway, and reporting it would accuse correct code -- the
39// direction every rule here refuses to lean.
40pub struct DeclaredByNameRule;
41
42impl DeclaredByNameRule {
43    pub const ATTRIBUTE: &'static str = "path";
44
45    pub fn new() -> Self {
46        Self
47    }
48
49    // Descends into inline modules: an attribute one level down is as invisible
50    // to name resolution as one at the top.
51    fn declarations(items: &[Item]) -> Vec<&ItemMod> {
52        items
53            .iter()
54            .flat_map(|item| match item {
55                Item::Mod(module) => once(module)
56                    .chain(
57                        Self::inside(module)
58                            .map(Self::declarations)
59                            .unwrap_or_default(),
60                    )
61                    .collect::<Vec<&ItemMod>>(),
62                _ => Vec::new(),
63            })
64            .collect()
65    }
66
67    fn inside(module: &ItemMod) -> Option<&[Item]> {
68        module.content.as_ref().map(|(_, items)| items.as_slice())
69    }
70
71    // Only the bare `#[path = "..."]`. A `cfg_attr` wrapping one is a
72    // `Meta::List` named `cfg_attr`, so it never matches here.
73    fn target_of(attrs: &[Attribute]) -> Option<String> {
74        attrs.iter().find_map(|attr| match &attr.meta {
75            Meta::NameValue(pair) if pair.path.is_ident(Self::ATTRIBUTE) => match &pair.value {
76                Expr::Lit(literal) => match &literal.lit {
77                    Lit::Str(text) => Some(text.value()),
78                    _ => None,
79                },
80                _ => None,
81            },
82            _ => None,
83        })
84    }
85
86    // The correction names both files, because the fix is a move and a deletion
87    // rather than an edit to the declaration.
88    fn offence(&self, file: &SourceFile, module: &ItemMod, target: &str) -> Offence {
89        let name = module.ident.to_string();
90        let expected = format!("{name}.rs");
91        Offence::new(
92            file.relative_path(),
93            module.ident.span().start().line,
94            self.name(),
95            format!(
96                "`mod {name}` is reached through `#[path = \"{target}\"]`, so the file it \
97                 declares cannot be found from its name"
98            ),
99            format!(
100                "move `{target}` to `{expected}` beside this file and drop the `#[path]` attribute"
101            ),
102        )
103        .with_subject(&name)
104        .with_expected(&expected)
105    }
106}
107
108impl Default for DeclaredByNameRule {
109    fn default() -> Self {
110        Self::new()
111    }
112}
113
114impl Rule for DeclaredByNameRule {
115    fn name(&self) -> &'static str {
116        "declared-by-name"
117    }
118
119    fn check(&self, file: &SourceFile) -> Vec<Offence> {
120        let Ok(syntax) = parse_file(&file.contents()) else {
121            return Vec::new();
122        };
123        Self::declarations(&syntax.items)
124            .into_iter()
125            .filter_map(|module| {
126                Self::target_of(&module.attrs).map(|target| self.offence(file, module, &target))
127            })
128            .collect()
129    }
130
131    fn check_workspace(&self, _files: &[SourceFile]) -> Vec<Offence> {
132        Vec::new()
133    }
134
135    fn requirement(&self) -> Option<&'static str> {
136        None
137    }
138
139    fn is_configured(&self) -> bool {
140        true
141    }
142
143    fn explanation(&self) -> RuleExplanation {
144        RuleExplanation::new(
145            self.name(),
146            "A module is declared by name: `mod alpha;` reaches alpha.rs or alpha/mod.rs.",
147            "#[path = \"other.rs\"]\nmod alpha;",
148            "mod alpha;",
149        )
150    }
151}