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