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 is_configured(&self) -> bool {
135 true
136 }
137}