1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
//! The module tree of a crate: which source files the compiler reads from the crate roots, and which of them are
//! compiled for tests only.
//! A file is test-only when every declaration that reaches it sits under a `cfg` that compiles only when testing, so
//! a nested helper inherits the gate of the module that declares it. Identity is the path relative to the source
//! root, never the file name, because two modules in different directories may share a file name.
use std::collections::BTreeMap;
use std::path::Path;
use super::declaration::{Declaration, declarations};
use super::walk::discover;
/// What:
/// `pub(crate) struct CrateRoot { ... }` names one file the compiler starts from, relative to the source root, and
/// whether it is built only for tests.
/// Why:
/// Roots come from the manifest (`lib`, `bin`, `test` targets); everything else is reached by `mod` declarations.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type CrateRoot = { path: string; testOnly: boolean }
/// ```
#[derive(Debug)]
pub(crate) struct CrateRoot {
/// The root file, `/`-separated, relative to the source root.
pub(crate) path: String,
/// Whether the target is built only when testing.
pub(crate) test_only: bool,
}
/// What:
/// `pub(crate) struct SourceFile { ... }` is one file of the tree: its relative identity and its classification.
/// Why:
/// The scans read the identity for findings and exceptions, and the classification to skip test-only support.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type SourceFile = { path: string; testOnly: boolean }
/// ```
#[derive(Debug)]
pub(crate) struct SourceFile {
/// The file, `/`-separated, relative to the source root.
pub(crate) path: String,
/// Whether the file is compiled only when testing.
pub(crate) test_only: bool,
}
/// What:
/// `fn join_names(directory: &str, part: &str) -> Result<String, String>` joins a relative `directory` and a
/// relative `part` (as written in a `#[path]` attribute), folding `.` and `..` away.
/// Why:
/// Identities must be one spelling per file, and a path that climbs above the source root leaves the tree.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function joinNames(directory: string, part: string): string // throws Error
/// ```
fn join_names(directory: &str, part: &str) -> Result<String, String> {
let mut names: Vec<&str> = Vec::new();
for piece in directory.split('/').chain(part.split('/')) {
if piece.is_empty() || piece == "." {
continue;
}
if piece == ".." {
// `pop()` gives `None` on an empty list, which means the path climbs out of the root.
if names.pop().is_none() {
return Err(format!("`{part}` in `{directory}` leaves the source root"));
}
continue;
}
names.push(piece);
}
return Ok(names.join("/"));
}
/// What:
/// `fn parent_of(file: &str) -> &str` is the directory part of a `/`-separated name (`""` for a top-level file).
/// Why:
/// A `mod` declaration resolves relative to the directory of the file that holds it.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function parentOf(file: string): string
/// ```
fn parent_of(file: &str) -> &str {
return file.rsplit_once('/').map_or("", |(parent, _)| return parent);
}
/// What:
/// `fn stem_of(file: &str) -> &str` is the file name without its directory and `.rs` suffix.
/// Why:
/// A file `engine.rs` that is not a `mod.rs` keeps its child modules in a directory named `engine`.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function stemOf(file: string): string
/// ```
fn stem_of(file: &str) -> &str {
let name: &str = file.rsplit_once('/').map_or(file, |(_, name)| return name);
return name.strip_suffix(".rs").unwrap_or(name);
}
/// What:
/// `fn locate(root: &Path, file: &str, owns_directory: bool, declaration: &Declaration)` finds the file a `mod`
/// declaration names, and whether that file keeps its own children beside it (`true`, like `mod.rs` or a file
/// loaded through `#[path]`) or in a directory named after it (`false`).
/// Why:
/// This is the compiler's own lookup rule, so a nested layout is resolved the way the build resolves it; a
/// declaration with no file, or with two candidate files, is an error because the tree would not be what compiles.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function locate(root: string, file: string, ownsDirectory: boolean, d: Declaration): { path: string; ownsDirectory: boolean }
/// ```
fn locate(root: &Path, file: &str, owns_directory: bool, declaration: &Declaration) -> Result<(String, bool), String> {
let directory: &str = parent_of(file);
if let Some(written) = &declaration.path_attribute {
let target: String = join_names(directory, written.as_str())?;
if !root.join(target.as_str()).is_file() {
return Err(format!("{file}: `#[path = \"{written}\"]` names {target}, which is not a file"));
}
return Ok((target, true));
}
let module_directory: String = if owns_directory {
directory.to_owned()
} else {
join_names(directory, stem_of(file))?
};
let plain: String = join_names(module_directory.as_str(), format!("{}.rs", declaration.name).as_str())?;
let nested: String = join_names(module_directory.as_str(), format!("{}/mod.rs", declaration.name).as_str())?;
let plain_exists: bool = root.join(plain.as_str()).is_file();
let nested_exists: bool = root.join(nested.as_str()).is_file();
if plain_exists && nested_exists {
return Err(format!("{file}: `mod {};` matches both {plain} and {nested}", declaration.name));
}
if plain_exists {
return Ok((plain, false));
}
if nested_exists {
return Ok((nested, true));
}
return Err(format!("{file}: `mod {};` has neither {plain} nor {nested}", declaration.name));
}
/// What:
/// `pub(crate) fn module_tree(root, roots)` follows every `mod` declaration from the crate roots and returns each
/// reached file once, sorted, with its test-only classification.
/// Why:
/// A file reached by a release path and by a test-only path is release (the build compiles it either way, and the
/// scans must read it). A file reached only through gates is test-only.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// export function moduleTree(root: string, roots: CrateRoot[]): SourceFile[] // throws Error
/// ```
pub(crate) fn module_tree(root: &Path, roots: &[CrateRoot]) -> Result<Vec<SourceFile>, String> {
// `BTreeMap<String, bool>` is a sorted map (TS: `Map`, but iterated in key order) from file to "test-only".
let mut known: BTreeMap<String, bool> = BTreeMap::new();
// The work list holds (file, test-only, owns-directory) for files still to read.
let mut pending: Vec<(String, bool, bool)> = Vec::new();
for crate_root in roots {
pending.push((crate_root.path.clone(), crate_root.test_only, true));
}
while let Some((file, test_only, owns_directory)) = pending.pop() {
// Read a file again only when it was first reached as test-only and is now reached as release.
if known.get(file.as_str()).is_some_and(|earlier| return !*earlier || test_only) {
continue;
}
known.insert(file.clone(), test_only);
let text: String = std::fs::read_to_string(root.join(file.as_str()))
.map_err(|error| return format!("cannot read {file}: {error}"))?;
let found: Vec<Declaration> = declarations(text.as_str()).map_err(|error| return format!("{file}: {error}"))?;
for declaration in found {
let (child, child_owns): (String, bool) = locate(root, file.as_str(), owns_directory, &declaration)?;
pending.push((child, test_only || declaration.test_only, child_owns));
}
}
let mut tree: Vec<SourceFile> = Vec::new();
for (path, test_only) in known {
tree.push(SourceFile { path, test_only });
}
return Ok(tree);
}
/// What:
/// `pub(crate) fn classified_sources(root, roots)` is the whole classified listing of a source root: the module
/// tree, proven to cover every `.rs` file the strict walk finds.
/// Why:
/// A source file that no `mod` declaration reaches is either dead text or a registration that moved without its
/// file; either way a scan could not say whether it is release code, so the listing fails instead.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// export function classifiedSources(root: string, roots: CrateRoot[]): SourceFile[] // throws Error
/// ```
pub(crate) fn classified_sources(root: &Path, roots: &[CrateRoot]) -> Result<Vec<SourceFile>, String> {
let walked: Vec<String> = discover(root)?;
let tree: Vec<SourceFile> = module_tree(root, roots)?;
let mut unreached: Vec<String> = Vec::new();
for path in walked {
if !tree.iter().any(|file| return file.path == path) {
unreached.push(path);
}
}
if !unreached.is_empty() {
return Err(format!("no `mod` declaration reaches: {}", unreached.join(", ")));
}
return Ok(tree);
}
/// What:
/// `pub(crate) fn manifest_roots(manifest: &str)` reads the crate roots out of `Cargo.toml` text: the `[lib]` and
/// `[[bin]]` paths are release roots, the `[[test]]`, `[[bench]]` and `[[example]]` paths are test-only roots. Only
/// paths under `src_prefix` count, and the prefix is cut off.
/// Why:
/// The scans start from the targets the build has, not from a list that could drift from the manifest.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// export function manifestRoots(manifest: string, srcPrefix: string): CrateRoot[] // throws Error
/// ```
pub(crate) fn manifest_roots(manifest: &str, src_prefix: &str) -> Result<Vec<CrateRoot>, String> {
let mut roots: Vec<CrateRoot> = Vec::new();
let mut section: &str = "";
for line in manifest.lines() {
let trimmed: &str = line.trim();
if trimmed.starts_with('[') {
section = trimmed;
continue;
}
let Some(value) = trimmed.strip_prefix("path = \"").and_then(|rest| return rest.strip_suffix('"')) else {
continue;
};
let test_only: bool = matches!(section, "[[test]]" | "[[bench]]" | "[[example]]");
let release: bool = matches!(section, "[lib]" | "[[bin]]");
let Some(relative) = value.strip_prefix(src_prefix) else {
if test_only || release {
return Err(format!("{section} path `{value}` is not under {src_prefix}"));
}
continue;
};
if test_only || release {
roots.push(CrateRoot { path: relative.to_owned(), test_only });
}
}
if roots.is_empty() {
return Err(String::from("the manifest names no target under the source prefix"));
}
return Ok(roots);
}