majordomus_cli/quality/source.rs
1//! The crate read as a syntax tree: which items this crate actually exports, where each
2//! one is declared, what its documentation says, and which of its doc examples run.
3//!
4//! # Why a parser and not a search
5//!
6//! Rust is not line-oriented text and the question this subsystem asks cannot be answered
7//! by matching lines. `pub fn` in a private module exports nothing; `pub mod` under a
8//! `pub(crate) mod` exports nothing; a doc comment is attached to the item that follows
9//! it, an inner doc comment to the item that contains it, and a fenced block inside a doc
10//! comment is executed by `cargo test --doc` only when it is a Rust block without
11//! `ignore`. Every one of those is a syntactic fact, so the inventory is built from
12//! [`syn`]'s syntax tree of every file the module graph reaches, walked from the crate
13//! root exactly as `rustc` walks it.
14//!
15//! # What it produces
16//!
17//! [`Inventory::of_crate`] returns the crate's [effectively public](Item::exported)
18//! surface: one [`Item`] per module, type, function, trait, macro, constant and
19//! associated item, each carrying its crate path, its declaration site, its documentation
20//! and its [`Example`]s. It also records, per module, the in-file `#[cfg(test)]` tests
21//! that stand as behavioural evidence beside a declaration, and the module paths named by
22//! the crate's integration tests, which is how a module's behavioural coverage is found
23//! without any list being kept by hand.
24//!
25//! Nothing here judges: the policy that decides which items owe an example lives in
26//! [`super::policy`], and the codes a finding carries are [`super::ViolationCode`]. This
27//! module answers only *what is there*.
28//!
29//! # Determinism
30//!
31//! The walk is a depth-first traversal of the module graph in declaration order, and file
32//! reads are the only I/O. Two runs over the same tree produce equal inventories, which is
33//! what lets the report be compared, cached and committed.
34//!
35//! ```
36//! use majordomus_cli::quality::source::Inventory;
37//!
38//! // A crate root that declares one exported module with one exported function in it.
39//! let dir = tempfile::tempdir().unwrap();
40//! let src = dir.path().join("src");
41//! std::fs::create_dir_all(&src).unwrap();
42//! std::fs::write(src.join("lib.rs"), "//! Root.\npub mod thing;\n").unwrap();
43//! std::fs::write(src.join("thing.rs"), "//! A thing.\n/// Answers.\npub fn go() {}\n").unwrap();
44//!
45//! let inventory = Inventory::of_crate(dir.path()).unwrap();
46//! let go = inventory.item("majordomus_cli::thing::go").unwrap();
47//! assert!(go.exported);
48//! assert_eq!(go.file, "src/thing.rs");
49//! ```
50
51use std::collections::{BTreeMap, BTreeSet};
52use std::path::{Path, PathBuf};
53
54use serde::{Deserialize, Serialize};
55
56use crate::error::Error;
57
58/// The crate name every path in an inventory starts with. The library target's name, which
59/// is what a doc example writes in its `use`.
60pub const CRATE: &str = "majordomus_cli";
61
62/// What an item is, in the vocabulary rustdoc and the reference use. The kind decides what
63/// the policy may ask of an item, so it is recorded rather than inferred later.
64#[derive(
65 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
66)]
67#[serde(rename_all = "snake_case")]
68pub enum ItemKind {
69 /// A module: `mod`, inline or in its own file.
70 Module,
71 /// A `struct`.
72 Struct,
73 /// An `enum`.
74 Enum,
75 /// A `union`.
76 Union,
77 /// A `trait`.
78 Trait,
79 /// A free function, or an associated function with no receiver.
80 Function,
81 /// A method: an associated function taking a receiver.
82 Method,
83 /// A `const`.
84 Constant,
85 /// A `static`.
86 Static,
87 /// A `type` alias.
88 TypeAlias,
89 /// A `macro_rules!` carrying `#[macro_export]`.
90 Macro,
91 /// A field of a public struct.
92 Field,
93 /// A variant of a public enum.
94 Variant,
95 /// An associated type of a trait: `type Output;`.
96 ///
97 /// Kept apart from [`ItemKind::AssociatedConstant`] because rustdoc keeps them apart:
98 /// one is anchored `associatedtype.` on its trait's page and the other
99 /// `associatedconstant.`, so a single kind could not say where either is documented.
100 AssociatedType,
101 /// An associated constant of a trait: `const LIMIT: usize;`, with a default or without.
102 AssociatedConstant,
103}
104
105impl ItemKind {
106 /// The word a finding uses for an item of this kind.
107 ///
108 /// ```
109 /// use majordomus_cli::quality::source::ItemKind;
110 /// assert_eq!(ItemKind::Method.noun(), "method");
111 /// assert_eq!(ItemKind::TypeAlias.noun(), "type alias");
112 /// ```
113 pub fn noun(self) -> &'static str {
114 match self {
115 ItemKind::Module => "module",
116 ItemKind::Struct => "struct",
117 ItemKind::Enum => "enum",
118 ItemKind::Union => "union",
119 ItemKind::Trait => "trait",
120 ItemKind::Function => "function",
121 ItemKind::Method => "method",
122 ItemKind::Constant => "constant",
123 ItemKind::Static => "static",
124 ItemKind::TypeAlias => "type alias",
125 ItemKind::Macro => "macro",
126 ItemKind::Field => "field",
127 ItemKind::Variant => "variant",
128 ItemKind::AssociatedType => "associated type",
129 ItemKind::AssociatedConstant => "associated constant",
130 }
131 }
132
133 /// Does an item of this kind carry behaviour a reader could be shown running?
134 ///
135 /// A module, a type, a function, a method and an exported macro do. A field, a
136 /// variant, a constant, a static, a type alias and an associated item are values and
137 /// names: an example of one is an example of whatever uses it, and demanding one buys
138 /// the placeholder this subsystem exists to refuse.
139 ///
140 /// ```
141 /// use majordomus_cli::quality::source::ItemKind;
142 /// assert!(ItemKind::Function.carries_behaviour());
143 /// assert!(!ItemKind::Constant.carries_behaviour());
144 /// ```
145 pub fn carries_behaviour(self) -> bool {
146 matches!(
147 self,
148 ItemKind::Module
149 | ItemKind::Struct
150 | ItemKind::Enum
151 | ItemKind::Union
152 | ItemKind::Trait
153 | ItemKind::Function
154 | ItemKind::Method
155 | ItemKind::Macro
156 )
157 }
158}
159
160/// One fenced block of a doc comment, classified by what `cargo test --doc` does with it.
161///
162/// The distinction matters and is the whole reason the block is modelled rather than
163/// counted: a ```` ```text ```` block is prose in a box, a ```` ```ignore ```` block is a
164/// example that is compiled by nobody, and only a Rust block without `ignore` is a test.
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
166pub struct Example {
167 /// The info string of the fence, as written (`""`, `"no_run"`, `"ignore"`, `"text"`).
168 pub info: String,
169 /// The lines of the block, hidden `#`-prefixed lines included, as written.
170 pub lines: Vec<String>,
171 /// Compiled and run by `cargo test --doc`.
172 pub runs: bool,
173 /// Compiled but not run: `no_run`, and `compile_fail` which must fail to compile.
174 pub compiles: bool,
175}
176
177impl Example {
178 /// Is this block executable evidence — compiled by the toolchain, either run or
179 /// deliberately not run?
180 ///
181 /// ```
182 /// use majordomus_cli::quality::source::Example;
183 /// let runs = Example::parse("", &["assert_eq!(1 + 1, 2);".into()]);
184 /// assert!(runs.executable());
185 /// let ignored = Example::parse("ignore", &["nothing();".into()]);
186 /// assert!(!ignored.executable(), "an ignored block is compiled by nobody");
187 /// ```
188 pub fn executable(&self) -> bool {
189 self.runs || self.compiles
190 }
191
192 /// Classify a fenced block from its info string and its lines.
193 ///
194 /// The rules are rustdoc's: a block whose info string is empty, or names only Rust
195 /// attributes, is Rust; `ignore` is neither compiled nor run; `no_run` and
196 /// `compile_fail` are compiled; anything else (`text`, `json`, `console`) is prose.
197 ///
198 /// ```
199 /// use majordomus_cli::quality::source::Example;
200 /// assert!(Example::parse("", &["let x = 1;".into()]).runs);
201 /// assert!(!Example::parse("text", &["a diagram".into()]).executable());
202 /// assert!(Example::parse("no_run", &["serve();".into()]).compiles);
203 /// assert!(!Example::parse("no_run", &["serve();".into()]).runs);
204 /// ```
205 pub fn parse(info: &str, lines: &[String]) -> Self {
206 let tokens: Vec<&str> = info
207 .split(|c: char| c == ',' || c.is_whitespace())
208 .filter(|t| !t.is_empty())
209 .collect();
210 // rustdoc's own set; anything outside it names a language and makes the block prose
211 const ATTRS: [&str; 8] = [
212 "rust",
213 "ignore",
214 "should_panic",
215 "no_run",
216 "compile_fail",
217 "edition2015",
218 "edition2018",
219 "edition2021",
220 ];
221 let is_rust = tokens
222 .iter()
223 .all(|t| ATTRS.contains(t) || t.starts_with("edition") || t.starts_with("ignore-"));
224 let ignored = tokens
225 .iter()
226 .any(|t| *t == "ignore" || t.starts_with("ignore-"));
227 let no_run = tokens
228 .iter()
229 .any(|t| *t == "no_run" || *t == "compile_fail");
230 Example {
231 info: info.to_string(),
232 lines: lines.to_vec(),
233 runs: is_rust && !ignored && !no_run,
234 compiles: is_rust && !ignored,
235 }
236 }
237
238 /// The lines a reader sees: rustdoc hides a line whose first non-space character is
239 /// `#` followed by a space or nothing.
240 ///
241 /// ```
242 /// use majordomus_cli::quality::source::Example;
243 /// let e = Example::parse("", &["# use std::fmt;".into(), "let x = 1;".into()]);
244 /// assert_eq!(e.visible_lines(), vec!["let x = 1;".to_string()]);
245 /// ```
246 pub fn visible_lines(&self) -> Vec<String> {
247 self.lines
248 .iter()
249 .filter(|l| {
250 let t = l.trim_start();
251 !(t == "#" || t.starts_with("# ") || t.starts_with("#\t"))
252 })
253 .cloned()
254 .collect()
255 }
256
257 /// The block's code with the hidden marker stripped: what the toolchain compiles.
258 ///
259 /// ```
260 /// use majordomus_cli::quality::source::Example;
261 /// let e = Example::parse("", &["# let a = 1;".into(), "assert_eq!(a, 1);".into()]);
262 /// assert_eq!(e.code(), "let a = 1;\nassert_eq!(a, 1);");
263 /// ```
264 pub fn code(&self) -> String {
265 self.lines
266 .iter()
267 .map(|l| {
268 let t = l.trim_start();
269 if t == "#" {
270 ""
271 } else if let Some(rest) = t.strip_prefix("# ") {
272 rest
273 } else if let Some(rest) = t.strip_prefix("#\t") {
274 rest
275 } else {
276 l.as_str()
277 }
278 })
279 .collect::<Vec<_>>()
280 .join("\n")
281 }
282}
283
284/// One item of the crate's surface, as declared.
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
286pub struct Item {
287 /// The full path a doc example would write: `majordomus_cli::capability::model::Capability`.
288 pub path: String,
289 /// The last segment: `Capability`.
290 pub name: String,
291 /// What it is.
292 pub kind: ItemKind,
293 /// Reachable from outside the crate: declared `pub`, in a module chain that is `pub`
294 /// all the way to the crate root, or re-exported by one. This is what `missing_docs`
295 /// calls public and what a consumer can actually name.
296 pub exported: bool,
297 /// Declared `pub` on the item itself, whatever the modules above it are. A `pub use`
298 /// of a type carries its `pub` members out of a private module and leaves the private
299 /// ones behind, so the two facts have to be kept apart.
300 pub declared_pub: bool,
301 /// The file it is declared in, repository-relative to the crate directory.
302 pub file: String,
303 /// The line of the declaration, 1-based.
304 pub line: usize,
305 /// The documentation attached to it, doc-comment markers stripped, fences included.
306 pub doc: String,
307 /// The fenced blocks of that documentation, in order.
308 pub examples: Vec<Example>,
309 /// The path of the item that owns this one: the module of a free item, the type of a
310 /// method, the enum of a variant.
311 pub owner: String,
312 /// A method or function whose body is one expression that only hands back what the
313 /// receiver already holds. Recorded because an example of one shows nothing that the
314 /// signature does not; see [`ItemKind::carries_behaviour`].
315 pub trivial_accessor: bool,
316 /// A member of a trait that the trait supplies itself: a method with a default body, a
317 /// constant with a default value. `false` for everything that is not a trait's member.
318 ///
319 /// Recorded because rustdoc anchors the two kinds of trait method differently — a
320 /// required one as `tymethod.` and a provided one as `method.` — and the route of a
321 /// method is therefore a fact about its declaration, not something a reader of the
322 /// inventory could guess.
323 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
324 pub provided: bool,
325 /// The `#[deprecated]` note, when the item carries one.
326 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub deprecated: Option<String>,
328}
329
330impl Item {
331 /// The words of the documentation, fenced blocks and the markers around them removed:
332 /// the prose a reader is actually given.
333 ///
334 /// ```
335 /// use majordomus_cli::quality::source::Inventory;
336 /// let dir = tempfile::tempdir().unwrap();
337 /// let src = dir.path().join("src");
338 /// std::fs::create_dir_all(&src).unwrap();
339 /// std::fs::write(src.join("lib.rs"),
340 /// "//! Root.\n/// Answers, and says with what.\n///\n/// ```\n/// let x = 1;\n/// ```\npub fn go() {}\n").unwrap();
341 /// let item = Inventory::of_crate(dir.path()).unwrap().item("majordomus_cli::go").unwrap().clone();
342 /// assert_eq!(item.prose_words(), 5, "the fenced block is not prose");
343 /// ```
344 pub fn prose_words(&self) -> usize {
345 let mut words = 0usize;
346 let mut fenced = false;
347 for line in self.doc.lines() {
348 let t = line.trim();
349 if t.starts_with("```") {
350 fenced = !fenced;
351 continue;
352 }
353 if !fenced {
354 words += t.split_whitespace().count();
355 }
356 }
357 words
358 }
359
360 /// The examples of this item that the toolchain compiles.
361 ///
362 /// ```
363 /// use majordomus_cli::quality::source::Inventory;
364 /// let dir = tempfile::tempdir().unwrap();
365 /// let src = dir.path().join("src");
366 /// std::fs::create_dir_all(&src).unwrap();
367 /// std::fs::write(src.join("lib.rs"),
368 /// "//! Root.\n/// Answers.\n///\n/// ```ignore\n/// go();\n/// ```\npub fn go() {}\n").unwrap();
369 /// let item = Inventory::of_crate(dir.path()).unwrap().item("majordomus_cli::go").unwrap().clone();
370 /// assert_eq!(item.examples.len(), 1);
371 /// assert!(item.executable_examples().is_empty(), "an ignored block is not evidence");
372 /// ```
373 pub fn executable_examples(&self) -> Vec<&Example> {
374 self.examples.iter().filter(|e| e.executable()).collect()
375 }
376}
377
378/// The in-file evidence a module carries: the `#[cfg(test)]` tests declared beside the
379/// declarations they are about.
380#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
381pub struct InFileTests {
382 /// The names of the `#[test]` functions declared in the module's own file.
383 pub names: Vec<String>,
384}
385
386/// Everything the walk found: the items, the in-file tests per module, and what the
387/// crate's integration tests name.
388#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
389pub struct Inventory {
390 /// Every item found, exported or not, in declaration order of the walk.
391 pub items: Vec<Item>,
392 /// Module path to the in-file tests declared in that module's file.
393 pub in_file_tests: BTreeMap<String, InFileTests>,
394 /// Module paths named by the crate's integration tests and benches: every
395 /// `majordomus_cli::a::b` path those files mention, expanded to every prefix.
396 pub named_by_tests: BTreeSet<String>,
397 /// The test files that were read, crate-relative, so a report can say what it looked at.
398 pub test_files: Vec<String>,
399 /// The crate paths a `pub use` in an exported module makes reachable, resolved to the
400 /// item they name. An item in a private module that a public module re-exports is part
401 /// of the crate's surface, which is what `missing_docs` also thinks, so the inventory
402 /// has to think it too.
403 pub reexported: BTreeSet<String>,
404 /// The name a re-export offers an item under, mapped to the item it names:
405 /// `majordomus_cli::capability::builtin::GetInput` to
406 /// `majordomus_cli::capability::builtin::objects::GetInput`.
407 ///
408 /// A consumer writes the alias, so a test that exercises a module through one would
409 /// otherwise look like a test that names no module at all. This is what carries the
410 /// credit back to the module that declares the item. `pub use x as y` offers `y`; a glob,
411 /// `pub use m::*`, offers every item of `m` in the re-exporting module and is recorded
412 /// as `<module>::*` to `m`.
413 ///
414 /// The same map is where rustdoc documents a re-exported item whose own module is
415 /// private: under the alias, which is why the offered name is the one `as` chose.
416 pub reexport_aliases: BTreeMap<String, String>,
417}
418
419impl Inventory {
420 /// Walk a crate directory: `src/lib.rs` and every file its module graph reaches, then
421 /// `tests/` and `benches/` for the module paths they name.
422 ///
423 /// The directory is the crate's, the one holding `Cargo.toml`. A crate whose root is
424 /// missing is an error rather than an empty inventory: a quality report over nothing
425 /// is the one result that must never be mistaken for a clean one.
426 ///
427 /// ```
428 /// use majordomus_cli::quality::source::Inventory;
429 /// let dir = tempfile::tempdir().unwrap();
430 /// assert!(Inventory::of_crate(dir.path()).is_err(), "no crate root, no inventory");
431 /// ```
432 pub fn of_crate(crate_dir: &Path) -> Result<Self, Error> {
433 let mut inv = Inventory::of_target(crate_dir, "src/lib.rs", CRATE)?;
434 collect_test_references(crate_dir, &mut inv)?;
435 Ok(inv)
436 }
437
438 /// Walk one target of a crate from its own root file, as the crate named `krate`: what
439 /// [`Inventory::of_crate`] does for the library, offered for a binary target too.
440 ///
441 /// A binary is a crate of its own, with its own root and its own name, and rustdoc
442 /// documents it beside the library; a check of the documentation tree has to know what
443 /// is in it, and reading it by the same walk is what keeps that knowledge from being a
444 /// second parser. The tests and benches are not read: they name the library.
445 pub(crate) fn of_target(crate_dir: &Path, root: &str, krate: &str) -> Result<Self, Error> {
446 let file = crate_dir.join(root);
447 if !file.is_file() {
448 return Err(Error::InvalidSource {
449 path: root.into(),
450 reason: format!(
451 "not a file under {}; the quality inventory needs the crate directory, the one holding Cargo.toml",
452 crate_dir.display()
453 ),
454 });
455 }
456 let mut inv = Inventory::default();
457 let mut walker = Walker {
458 crate_dir: crate_dir.to_path_buf(),
459 krate: krate.to_string(),
460 inv: &mut inv,
461 };
462 walker.file(&file, krate, true, true)?;
463 apply_reexports(&mut inv);
464 Ok(inv)
465 }
466
467 /// One item by its full path.
468 ///
469 /// ```
470 /// use majordomus_cli::quality::source::Inventory;
471 /// let dir = tempfile::tempdir().unwrap();
472 /// let src = dir.path().join("src");
473 /// std::fs::create_dir_all(&src).unwrap();
474 /// std::fs::write(src.join("lib.rs"), "//! Root.\n/// A type.\npub struct T;\n").unwrap();
475 /// let inv = Inventory::of_crate(dir.path()).unwrap();
476 /// assert!(inv.item("majordomus_cli::T").is_some());
477 /// assert!(inv.item("majordomus_cli::Nope").is_none());
478 /// ```
479 pub fn item(&self, path: &str) -> Option<&Item> {
480 self.items.iter().find(|i| i.path == path)
481 }
482
483 /// One item by its path and its kind.
484 ///
485 /// A struct's field and one of its methods can share a path — `T::n` names both — because
486 /// Rust keeps them in different namespaces and a path does not say which. Every caller
487 /// that cares says so.
488 ///
489 /// ```
490 /// use majordomus_cli::quality::source::{Inventory, ItemKind};
491 /// let dir = tempfile::tempdir().unwrap();
492 /// let src = dir.path().join("src");
493 /// std::fs::create_dir_all(&src).unwrap();
494 /// std::fs::write(src.join("lib.rs"), concat!(
495 /// "//! Root.\n",
496 /// "/// A counter.\n",
497 /// "pub struct T { n: usize }\n",
498 /// "impl T { /// The count.\n pub fn n(&self) -> usize { self.n } }\n",
499 /// )).unwrap();
500 /// let inv = Inventory::of_crate(dir.path()).unwrap();
501 /// let method = inv.item_of("majordomus_cli::T::n", ItemKind::Method).unwrap();
502 /// assert!(method.trivial_accessor);
503 /// assert!(inv.item_of("majordomus_cli::T::n", ItemKind::Field).is_some());
504 /// ```
505 pub fn item_of(&self, path: &str, kind: ItemKind) -> Option<&Item> {
506 self.items.iter().find(|i| i.path == path && i.kind == kind)
507 }
508
509 /// Every exported item, in walk order.
510 ///
511 /// ```
512 /// use majordomus_cli::quality::source::Inventory;
513 /// let dir = tempfile::tempdir().unwrap();
514 /// let src = dir.path().join("src");
515 /// std::fs::create_dir_all(&src).unwrap();
516 /// std::fs::write(src.join("lib.rs"), "//! Root.\nmod hidden;\n").unwrap();
517 /// std::fs::write(src.join("hidden.rs"), "/// Not reachable.\npub fn inner() {}\n").unwrap();
518 /// let inv = Inventory::of_crate(dir.path()).unwrap();
519 /// assert!(inv.item("majordomus_cli::hidden::inner").unwrap().exported == false);
520 /// assert!(inv.exported().iter().all(|i| i.path != "majordomus_cli::hidden::inner"));
521 /// ```
522 pub fn exported(&self) -> Vec<&Item> {
523 self.items.iter().filter(|i| i.exported).collect()
524 }
525
526 /// Does any test of this crate exercise the module at `path`?
527 ///
528 /// A test counts when it names the module, names something under it, or names an item
529 /// through the alias a `pub use` offers it under — which is how these tests are actually
530 /// written: `use majordomus_cli::capability::builtin::GetInput` exercises
531 /// `capability::builtin::objects`, and nothing in the text says so.
532 ///
533 /// ```
534 /// use majordomus_cli::quality::source::Inventory;
535 /// let dir = tempfile::tempdir().unwrap();
536 /// let src = dir.path().join("src");
537 /// std::fs::create_dir_all(src.join("outer")).unwrap();
538 /// std::fs::write(src.join("lib.rs"), "//! Root.\npub mod outer;\n").unwrap();
539 /// std::fs::write(src.join("outer/mod.rs"),
540 /// "//! Outer.\npub mod inner;\npub use inner::Thing;\n").unwrap();
541 /// std::fs::write(src.join("outer/inner.rs"), "//! Inner.\n/// A thing.\npub struct Thing;\n").unwrap();
542 /// std::fs::create_dir_all(dir.path().join("tests")).unwrap();
543 /// std::fs::write(dir.path().join("tests/it.rs"),
544 /// "use majordomus_cli::outer::Thing;\n#[test]\nfn t() { let _ = Thing; }\n").unwrap();
545 ///
546 /// let inv = Inventory::of_crate(dir.path()).unwrap();
547 /// assert!(inv.exercised_by_a_test("majordomus_cli::outer::inner"),
548 /// "the test reaches inner through the alias outer::Thing");
549 /// ```
550 pub fn exercised_by_a_test(&self, path: &str) -> bool {
551 if self
552 .in_file_tests
553 .get(path)
554 .is_some_and(|t| !t.names.is_empty())
555 {
556 return true;
557 }
558 if self.named_by_tests.contains(path) {
559 return true;
560 }
561 let under = format!("{path}::");
562 self.reexport_aliases.iter().any(|(alias, target)| {
563 target.starts_with(&under) && self.named_by_tests.contains(alias)
564 })
565 }
566
567 /// Every exported module, in walk order. The crate root is one of them.
568 pub fn exported_modules(&self) -> Vec<&Item> {
569 self.items
570 .iter()
571 .filter(|i| i.exported && i.kind == ItemKind::Module)
572 .collect()
573 }
574}
575
576struct Walker<'a> {
577 crate_dir: PathBuf,
578 /// The crate every path this walk records starts with: the library's [`CRATE`], or a
579 /// binary's own name.
580 krate: String,
581 inv: &'a mut Inventory,
582}
583
584impl Walker<'_> {
585 /// Parse one file and walk the items in it as the module `mod_path`.
586 ///
587 /// `declared_pub` is the visibility its parent's `mod` declaration gives it — the crate
588 /// root is `pub` by definition — because a file does not carry its own: the same fact an
589 /// inline `pub mod x { .. }` records, so the two ways of writing a module read alike.
590 fn file(
591 &mut self,
592 path: &Path,
593 mod_path: &str,
594 exported: bool,
595 declared_pub: bool,
596 ) -> Result<(), Error> {
597 let rel = self.relative(path);
598 let text = std::fs::read_to_string(path).map_err(|e| Error::InvalidSource {
599 path: rel.clone(),
600 reason: e.to_string(),
601 })?;
602 let ast = syn::parse_file(&text).map_err(|e| Error::InvalidSource {
603 path: rel.clone(),
604 reason: format!("does not parse: {e}"),
605 })?;
606 // the module itself, from the file's inner attributes
607 let doc = doc_of(&ast.attrs);
608 let owner = mod_path
609 .rsplit_once("::")
610 .map(|(o, _)| o.to_string())
611 .unwrap_or_default();
612 self.inv.items.push(Item {
613 path: mod_path.to_string(),
614 name: mod_path.rsplit("::").next().unwrap_or(mod_path).to_string(),
615 kind: ItemKind::Module,
616 exported,
617 declared_pub,
618 file: rel.clone(),
619 line: 1,
620 doc: doc.clone(),
621 examples: examples_of(&doc),
622 owner,
623 trivial_accessor: false,
624 provided: false,
625 deprecated: deprecation(&ast.attrs),
626 });
627 self.items(&ast.items, mod_path, exported, path, &rel)
628 }
629
630 /// Walk a list of items belonging to `mod_path`, declared in `file`.
631 fn items(
632 &mut self,
633 items: &[syn::Item],
634 mod_path: &str,
635 exported: bool,
636 file: &Path,
637 rel: &str,
638 ) -> Result<(), Error> {
639 for item in items {
640 match item {
641 syn::Item::Mod(m) => {
642 let name = m.ident.to_string();
643 let child_path = format!("{mod_path}::{name}");
644 let child_exported = exported && is_pub(&m.vis);
645 if is_test_only(&m.attrs) {
646 // evidence, not surface: the tests declared beside the declarations
647 if let Some((_, inner)) = &m.content {
648 let names = test_fn_names(inner);
649 if !names.is_empty() {
650 self.inv
651 .in_file_tests
652 .entry(mod_path.to_string())
653 .or_default()
654 .names
655 .extend(names);
656 }
657 }
658 continue;
659 }
660 match &m.content {
661 Some((_, inner)) => {
662 let doc = doc_of(&m.attrs);
663 self.inv.items.push(Item {
664 path: child_path.clone(),
665 name,
666 kind: ItemKind::Module,
667 exported: child_exported,
668 declared_pub: is_pub(&m.vis),
669 file: rel.to_string(),
670 line: line_of(m.mod_token.span),
671 doc: doc.clone(),
672 examples: examples_of(&doc),
673 owner: mod_path.to_string(),
674 trivial_accessor: false,
675 provided: false,
676 deprecated: deprecation(&m.attrs),
677 });
678 self.items(inner, &child_path, child_exported, file, rel)?;
679 }
680 None => {
681 let child = self.resolve(file, &m.ident.to_string())?;
682 self.file(&child, &child_path, child_exported, is_pub(&m.vis))?;
683 }
684 }
685 }
686 other => self.item(other, mod_path, exported, rel),
687 }
688 }
689 Ok(())
690 }
691
692 /// One non-module item.
693 fn item(&mut self, item: &syn::Item, mod_path: &str, exported: bool, rel: &str) {
694 let push = |name: String,
695 kind: ItemKind,
696 vis_pub: bool,
697 attrs: &[syn::Attribute],
698 line: usize,
699 inv: &mut Inventory| {
700 let doc = doc_of(attrs);
701 inv.items.push(Item {
702 path: format!("{mod_path}::{name}"),
703 name,
704 kind,
705 exported: exported && vis_pub,
706 declared_pub: vis_pub,
707 file: rel.to_string(),
708 line,
709 examples: examples_of(&doc),
710 doc,
711 owner: mod_path.to_string(),
712 trivial_accessor: false,
713 provided: false,
714 deprecated: deprecation(attrs),
715 });
716 };
717 match item {
718 syn::Item::Struct(s) => {
719 push(
720 s.ident.to_string(),
721 ItemKind::Struct,
722 is_pub(&s.vis),
723 &s.attrs,
724 line_of(s.struct_token.span),
725 self.inv,
726 );
727 let owner = format!("{mod_path}::{}", s.ident);
728 let owner_pub = exported && is_pub(&s.vis);
729 for f in s.fields.iter() {
730 let Some(id) = &f.ident else { continue };
731 let doc = doc_of(&f.attrs);
732 self.inv.items.push(Item {
733 path: format!("{owner}::{id}"),
734 name: id.to_string(),
735 kind: ItemKind::Field,
736 exported: owner_pub && is_pub(&f.vis),
737 declared_pub: is_pub(&f.vis),
738 file: rel.to_string(),
739 line: line_of(id.span()),
740 examples: examples_of(&doc),
741 doc,
742 owner: owner.clone(),
743 trivial_accessor: false,
744 provided: false,
745 deprecated: deprecation(&f.attrs),
746 });
747 }
748 }
749 syn::Item::Enum(e) => {
750 push(
751 e.ident.to_string(),
752 ItemKind::Enum,
753 is_pub(&e.vis),
754 &e.attrs,
755 line_of(e.enum_token.span),
756 self.inv,
757 );
758 let owner = format!("{mod_path}::{}", e.ident);
759 let owner_pub = exported && is_pub(&e.vis);
760 for v in &e.variants {
761 let doc = doc_of(&v.attrs);
762 self.inv.items.push(Item {
763 path: format!("{owner}::{}", v.ident),
764 name: v.ident.to_string(),
765 kind: ItemKind::Variant,
766 exported: owner_pub,
767 declared_pub: true,
768 file: rel.to_string(),
769 line: line_of(v.ident.span()),
770 examples: examples_of(&doc),
771 doc,
772 owner: owner.clone(),
773 trivial_accessor: false,
774 provided: false,
775 deprecated: deprecation(&v.attrs),
776 });
777 }
778 }
779 syn::Item::Union(u) => push(
780 u.ident.to_string(),
781 ItemKind::Union,
782 is_pub(&u.vis),
783 &u.attrs,
784 line_of(u.union_token.span),
785 self.inv,
786 ),
787 syn::Item::Trait(t) => {
788 push(
789 t.ident.to_string(),
790 ItemKind::Trait,
791 is_pub(&t.vis),
792 &t.attrs,
793 line_of(t.trait_token.span),
794 self.inv,
795 );
796 let owner = format!("{mod_path}::{}", t.ident);
797 let owner_pub = exported && is_pub(&t.vis);
798 for i in &t.items {
799 let (name, kind, attrs, line, provided) = match i {
800 syn::TraitItem::Fn(f) => (
801 f.sig.ident.to_string(),
802 receiver_kind(&f.sig),
803 &f.attrs,
804 line_of(f.sig.ident.span()),
805 f.default.is_some(),
806 ),
807 syn::TraitItem::Const(c) => (
808 c.ident.to_string(),
809 ItemKind::AssociatedConstant,
810 &c.attrs,
811 line_of(c.ident.span()),
812 c.default.is_some(),
813 ),
814 syn::TraitItem::Type(ty) => (
815 ty.ident.to_string(),
816 ItemKind::AssociatedType,
817 &ty.attrs,
818 line_of(ty.ident.span()),
819 ty.default.is_some(),
820 ),
821 _ => continue,
822 };
823 let doc = doc_of(attrs);
824 self.inv.items.push(Item {
825 path: format!("{owner}::{name}"),
826 name,
827 kind,
828 exported: owner_pub,
829 // a trait's items are as public as the trait
830 declared_pub: true,
831 file: rel.to_string(),
832 line,
833 examples: examples_of(&doc),
834 doc,
835 owner: owner.clone(),
836 trivial_accessor: false,
837 provided,
838 deprecated: deprecation(attrs),
839 });
840 }
841 }
842 syn::Item::Fn(f) => {
843 let doc = doc_of(&f.attrs);
844 self.inv.items.push(Item {
845 path: format!("{mod_path}::{}", f.sig.ident),
846 name: f.sig.ident.to_string(),
847 kind: ItemKind::Function,
848 exported: exported && is_pub(&f.vis),
849 declared_pub: is_pub(&f.vis),
850 file: rel.to_string(),
851 line: line_of(f.sig.ident.span()),
852 examples: examples_of(&doc),
853 doc,
854 owner: mod_path.to_string(),
855 trivial_accessor: false,
856 provided: false,
857 deprecated: deprecation(&f.attrs),
858 });
859 }
860 syn::Item::Const(c) => push(
861 c.ident.to_string(),
862 ItemKind::Constant,
863 is_pub(&c.vis),
864 &c.attrs,
865 line_of(c.ident.span()),
866 self.inv,
867 ),
868 syn::Item::Static(s) => push(
869 s.ident.to_string(),
870 ItemKind::Static,
871 is_pub(&s.vis),
872 &s.attrs,
873 line_of(s.ident.span()),
874 self.inv,
875 ),
876 syn::Item::Type(t) => push(
877 t.ident.to_string(),
878 ItemKind::TypeAlias,
879 is_pub(&t.vis),
880 &t.attrs,
881 line_of(t.ident.span()),
882 self.inv,
883 ),
884 syn::Item::Macro(m) => {
885 // `macro_rules!` with `#[macro_export]` is exported from the crate root,
886 // whatever module it is written in — which is exactly how the crate's
887 // `capability!` and `module!` are reached.
888 let exported_macro = m.attrs.iter().any(|a| a.path().is_ident("macro_export"));
889 let Some(id) = &m.ident else { return };
890 if !exported_macro {
891 return;
892 }
893 let doc = doc_of(&m.attrs);
894 self.inv.items.push(Item {
895 path: format!("{}::{id}", self.krate),
896 name: id.to_string(),
897 kind: ItemKind::Macro,
898 exported: true,
899 declared_pub: true,
900 file: rel.to_string(),
901 line: line_of(id.span()),
902 examples: examples_of(&doc),
903 doc,
904 owner: self.krate.clone(),
905 trivial_accessor: false,
906 provided: false,
907 deprecated: deprecation(&m.attrs),
908 });
909 }
910 syn::Item::Impl(i) => {
911 // an inherent impl of a named type in this module; a trait impl adds no
912 // API of its own, because the trait already declares every item in it
913 if i.trait_.is_some() {
914 return;
915 }
916 let Some(ty) = type_name(&i.self_ty) else {
917 return;
918 };
919 let owner = format!("{mod_path}::{ty}");
920 for it in &i.items {
921 let syn::ImplItem::Fn(f) = it else { continue };
922 let doc = doc_of(&f.attrs);
923 self.inv.items.push(Item {
924 path: format!("{owner}::{}", f.sig.ident),
925 name: f.sig.ident.to_string(),
926 kind: receiver_kind(&f.sig),
927 // the impl cannot widen what the type offers: a method is exported
928 // when it is `pub` and its type is
929 exported: exported && is_pub(&f.vis),
930 declared_pub: is_pub(&f.vis),
931 file: rel.to_string(),
932 line: line_of(f.sig.ident.span()),
933 examples: examples_of(&doc),
934 doc,
935 owner: owner.clone(),
936 trivial_accessor: is_trivial_accessor(&f.sig, &f.block),
937 provided: false,
938 deprecated: deprecation(&f.attrs),
939 });
940 }
941 }
942 // only a `pub use` in an exported module carries anything out of the crate
943 syn::Item::Use(u) if exported && is_pub(&u.vis) => {
944 let mut targets = Vec::new();
945 resolve_use(&u.tree, &self.krate, mod_path, String::new(), &mut targets);
946 for (target, offered) in targets {
947 // the name the re-export offers it under: this module, then the name
948 // the `use` gives it — its own last segment, or the one `as` chose
949 self.inv
950 .reexport_aliases
951 .insert(format!("{mod_path}::{offered}"), target.clone());
952 self.inv.reexported.insert(target);
953 }
954 }
955 _ => {}
956 }
957 }
958
959 /// The file a `mod name;` in `parent` refers to, by rustc's rules.
960 fn resolve(&self, parent: &Path, name: &str) -> Result<PathBuf, Error> {
961 let dir = if parent.file_name().and_then(|n| n.to_str()) == Some("lib.rs")
962 || parent.file_name().and_then(|n| n.to_str()) == Some("mod.rs")
963 {
964 parent.parent().unwrap_or(Path::new(".")).to_path_buf()
965 } else {
966 parent.with_extension("")
967 };
968 let flat = dir.join(format!("{name}.rs"));
969 if flat.is_file() {
970 return Ok(flat);
971 }
972 let nested = dir.join(name).join("mod.rs");
973 if nested.is_file() {
974 return Ok(nested);
975 }
976 Err(Error::InvalidSource {
977 path: self.relative(parent),
978 reason: format!(
979 "`mod {name};` resolves to neither {} nor {}",
980 self.relative(&flat),
981 self.relative(&nested)
982 ),
983 })
984 }
985
986 fn relative(&self, path: &Path) -> String {
987 path.strip_prefix(&self.crate_dir)
988 .unwrap_or(path)
989 .to_string_lossy()
990 .replace('\\', "/")
991 }
992}
993
994/// Resolve one `use` tree into the crate paths it re-exports, each with the name the `use`
995/// offers it under.
996///
997/// `krate` is the crate the walk is reading; `at` is the module the `use` is written in;
998/// `prefix` is what has been resolved so far. A leading `crate` or `self` anchors at the
999/// crate root or at `at`; `super` climbs one; anything else is taken relative to `at`, which
1000/// is how `pub use docs::tree;` reads. A glob contributes the module itself, offered as
1001/// `*`, and [`apply_reexports`] takes everything under it.
1002fn resolve_use(
1003 tree: &syn::UseTree,
1004 krate: &str,
1005 at: &str,
1006 prefix: String,
1007 out: &mut Vec<(String, String)>,
1008) {
1009 let base = |seg: &str| -> String {
1010 if !prefix.is_empty() {
1011 return format!("{prefix}::{seg}");
1012 }
1013 match seg {
1014 "crate" => krate.to_string(),
1015 "self" => at.to_string(),
1016 "super" => at
1017 .rsplit_once("::")
1018 .map(|(o, _)| o.to_string())
1019 .unwrap_or(krate.to_string()),
1020 other => format!("{at}::{other}"),
1021 }
1022 };
1023 match tree {
1024 syn::UseTree::Path(p) => {
1025 // `base` already handles `crate`, `self` and `super` at the head of a path
1026 let next = base(&p.ident.to_string());
1027 resolve_use(&p.tree, krate, at, next, out);
1028 }
1029 syn::UseTree::Name(n) => out.push((base(&n.ident.to_string()), n.ident.to_string())),
1030 // `pub use x as y` re-exports the item under a new name: the item itself is what
1031 // becomes reachable, and `y` is the name a consumer writes and rustdoc documents it
1032 // under — `Matrix as ProjectionMatrix` is `ProjectionMatrix` to everybody outside
1033 syn::UseTree::Rename(r) => out.push((base(&r.ident.to_string()), r.rename.to_string())),
1034 // `pub use m::*` offers every item of `m` under its own name in this module, and
1035 // the module itself under none: the offered name is `*`, which a reader of the
1036 // aliases replaces with each item's own remainder
1037 syn::UseTree::Glob(_) if !prefix.is_empty() => out.push((prefix, "*".to_string())),
1038 syn::UseTree::Glob(_) => {}
1039 syn::UseTree::Group(g) => {
1040 for t in &g.items {
1041 resolve_use(t, krate, at, prefix.clone(), out);
1042 }
1043 }
1044 }
1045}
1046
1047/// Mark as exported every item a `pub use` made reachable, and everything under it: a
1048/// re-exported module brings its public items with it.
1049fn apply_reexports(inv: &mut Inventory) {
1050 if inv.reexported.is_empty() {
1051 return;
1052 }
1053 let targets = inv.reexported.clone();
1054 let reached = |path: &str| {
1055 targets.contains(path) || targets.iter().any(|t| path.starts_with(&format!("{t}::")))
1056 };
1057 // Indices and not paths: a struct's private field and one of its public methods can
1058 // share a path — `Served::topology` names both — so marking every item *at* a path
1059 // would carry the private one out along with the public one.
1060 let reachable: Vec<usize> = inv
1061 .items
1062 .iter()
1063 .enumerate()
1064 .filter(|(_, i)| {
1065 // a re-export carries out what is declared `pub`; a private member of a
1066 // re-exported type stays private, which is what rustc thinks too
1067 !i.exported && i.declared_pub && reached(&i.path)
1068 })
1069 .map(|(n, _)| n)
1070 .collect();
1071 // the owner chain has to be public for the item to be, and a re-export is exactly what
1072 // makes it so: the item is exported, its private module is not. One pass is enough
1073 // because reachability is decided against the re-export targets, not against what this
1074 // pass has already marked.
1075 for n in reachable {
1076 inv.items[n].exported = true;
1077 }
1078}
1079
1080/// Every `majordomus_cli::a::b` path the crate's integration tests and benches name, and
1081/// every prefix of it, so a module counts as covered when a test names it or anything
1082/// under it.
1083fn collect_test_references(crate_dir: &Path, inv: &mut Inventory) -> Result<(), Error> {
1084 for sub in ["tests", "benches"] {
1085 let dir = crate_dir.join(sub);
1086 let Ok(entries) = std::fs::read_dir(&dir) else {
1087 continue;
1088 };
1089 let mut files: Vec<PathBuf> = Vec::new();
1090 for e in entries.flatten() {
1091 let p = e.path();
1092 if p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("rs") {
1093 files.push(p);
1094 } else if p.is_dir() {
1095 if let Ok(inner) = std::fs::read_dir(&p) {
1096 for e in inner.flatten() {
1097 let p = e.path();
1098 if p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("rs") {
1099 files.push(p);
1100 }
1101 }
1102 }
1103 }
1104 }
1105 files.sort();
1106 for p in files {
1107 let rel = p
1108 .strip_prefix(crate_dir)
1109 .unwrap_or(&p)
1110 .to_string_lossy()
1111 .replace('\\', "/");
1112 let text = std::fs::read_to_string(&p).map_err(|e| Error::InvalidSource {
1113 path: rel.clone(),
1114 reason: e.to_string(),
1115 })?;
1116 inv.test_files.push(rel);
1117 for path in crate_paths_in(&text) {
1118 let mut prefix = String::new();
1119 for seg in path.split("::") {
1120 if prefix.is_empty() {
1121 prefix.push_str(seg);
1122 } else {
1123 prefix.push_str("::");
1124 prefix.push_str(seg);
1125 }
1126 inv.named_by_tests.insert(prefix.clone());
1127 }
1128 }
1129 }
1130 }
1131 Ok(())
1132}
1133
1134/// Every `majordomus_cli::…` path mentioned in a source text, including the members of a
1135/// `use majordomus_cli::a::{b, c}` group, which is how these tests are written.
1136fn crate_paths_in(text: &str) -> Vec<String> {
1137 let mut out = Vec::new();
1138 let bytes = text.as_bytes();
1139 let mut i = 0usize;
1140 while let Some(found) = text[i..].find(CRATE) {
1141 let start = i + found;
1142 // not part of a longer identifier
1143 let before_ok = start == 0 || !is_ident_byte(bytes[start - 1]);
1144 i = start + CRATE.len();
1145 if !before_ok {
1146 continue;
1147 }
1148 let mut path = String::from(CRATE);
1149 let mut j = i;
1150 loop {
1151 let rest = &text[j..];
1152 let trimmed = rest.trim_start();
1153 let skipped = rest.len() - trimmed.len();
1154 if let Some(after) = trimmed.strip_prefix("::") {
1155 let after_trim = after.trim_start();
1156 let lead = after.len() - after_trim.len();
1157 if after_trim.starts_with('{') {
1158 // a group: every member is a path under what we have so far
1159 let close = match_brace(&text[j + skipped + 2 + lead..]);
1160 let inner = &text[j + skipped + 2 + lead + 1..j + skipped + 2 + lead + close];
1161 for member in inner.split(',') {
1162 let m = member.trim().trim_start_matches("self").trim();
1163 let m = m.split(" as ").next().unwrap_or(m).trim();
1164 if m.is_empty() {
1165 continue;
1166 }
1167 out.push(format!("{path}::{m}"));
1168 }
1169 j = j + skipped + 2 + lead + close + 1;
1170 break;
1171 }
1172 let ident_len = after_trim.bytes().take_while(|b| is_ident_byte(*b)).count();
1173 if ident_len == 0 {
1174 break;
1175 }
1176 path.push_str("::");
1177 path.push_str(&after_trim[..ident_len]);
1178 j = j + skipped + 2 + lead + ident_len;
1179 continue;
1180 }
1181 break;
1182 }
1183 out.push(path);
1184 i = j.max(i);
1185 }
1186 out
1187}
1188
1189fn match_brace(text: &str) -> usize {
1190 let mut depth = 0usize;
1191 for (idx, c) in text.char_indices() {
1192 match c {
1193 '{' => depth += 1,
1194 '}' => {
1195 depth -= 1;
1196 if depth == 0 {
1197 return idx;
1198 }
1199 }
1200 _ => {}
1201 }
1202 }
1203 text.len().saturating_sub(1)
1204}
1205
1206fn is_ident_byte(b: u8) -> bool {
1207 b.is_ascii_alphanumeric() || b == b'_'
1208}
1209
1210/// `pub` and nothing narrower. `pub(crate)`, `pub(super)` and `pub(in path)` are the
1211/// crate's own business and export nothing.
1212fn is_pub(vis: &syn::Visibility) -> bool {
1213 matches!(vis, syn::Visibility::Public(_))
1214}
1215
1216fn line_of(span: proc_macro2::Span) -> usize {
1217 span.start().line
1218}
1219
1220/// A `#[cfg(test)]` item: evidence rather than surface.
1221fn is_test_only(attrs: &[syn::Attribute]) -> bool {
1222 attrs.iter().any(|a| {
1223 a.path().is_ident("cfg")
1224 && a.meta
1225 .require_list()
1226 .ok()
1227 .map(|l| l.tokens.to_string().replace(' ', "") == "test")
1228 .unwrap_or(false)
1229 })
1230}
1231
1232fn test_fn_names(items: &[syn::Item]) -> Vec<String> {
1233 items
1234 .iter()
1235 .filter_map(|i| match i {
1236 syn::Item::Fn(f)
1237 if f.attrs.iter().any(|a| {
1238 a.path().is_ident("test")
1239 || a.path()
1240 .segments
1241 .last()
1242 .is_some_and(|s| s.ident == "test" || s.ident == "proptest")
1243 }) =>
1244 {
1245 Some(f.sig.ident.to_string())
1246 }
1247 syn::Item::Mod(m) => m
1248 .content
1249 .as_ref()
1250 .map(|(_, inner)| test_fn_names(inner))
1251 .unwrap_or_default()
1252 .first()
1253 .cloned(),
1254 _ => None,
1255 })
1256 .collect()
1257}
1258
1259/// The doc comment of an item, markers stripped, one line per `///`.
1260fn doc_of(attrs: &[syn::Attribute]) -> String {
1261 let mut lines: Vec<String> = Vec::new();
1262 for a in attrs {
1263 if !a.path().is_ident("doc") {
1264 continue;
1265 }
1266 let syn::Meta::NameValue(nv) = &a.meta else {
1267 continue;
1268 };
1269 let syn::Expr::Lit(syn::ExprLit {
1270 lit: syn::Lit::Str(s),
1271 ..
1272 }) = &nv.value
1273 else {
1274 continue;
1275 };
1276 let raw = s.value();
1277 lines.push(raw.strip_prefix(' ').unwrap_or(&raw).to_string());
1278 }
1279 lines.join("\n")
1280}
1281
1282fn deprecation(attrs: &[syn::Attribute]) -> Option<String> {
1283 attrs
1284 .iter()
1285 .find(|a| a.path().is_ident("deprecated"))
1286 .map(|a| match &a.meta {
1287 syn::Meta::Path(_) => String::new(),
1288 other => other
1289 .require_list()
1290 .map(|l| l.tokens.to_string())
1291 .unwrap_or_default(),
1292 })
1293}
1294
1295/// The fenced blocks of a doc comment, in order.
1296fn examples_of(doc: &str) -> Vec<Example> {
1297 let mut out = Vec::new();
1298 let mut open: Option<(String, Vec<String>)> = None;
1299 for line in doc.lines() {
1300 let trimmed = line.trim_start();
1301 if let Some(rest) = trimmed.strip_prefix("```") {
1302 match open.take() {
1303 Some((info, lines)) => out.push(Example::parse(&info, &lines)),
1304 None => open = Some((rest.trim().to_string(), Vec::new())),
1305 }
1306 } else if let Some((_, lines)) = open.as_mut() {
1307 lines.push(line.to_string());
1308 }
1309 }
1310 if let Some((info, lines)) = open {
1311 out.push(Example::parse(&info, &lines));
1312 }
1313 out
1314}
1315
1316fn receiver_kind(sig: &syn::Signature) -> ItemKind {
1317 if sig.receiver().is_some() {
1318 ItemKind::Method
1319 } else {
1320 ItemKind::Function
1321 }
1322}
1323
1324fn type_name(ty: &syn::Type) -> Option<String> {
1325 match ty {
1326 syn::Type::Path(p) => p.path.segments.last().map(|s| s.ident.to_string()),
1327 syn::Type::Reference(r) => type_name(&r.elem),
1328 _ => None,
1329 }
1330}
1331
1332/// A function whose body hands back what the receiver already holds and takes nothing but
1333/// the receiver: `self.0`, `&self.name`, `self.field.as_str()`, `self.len() == 0`.
1334///
1335/// Syntactic and deliberately narrow. It exists so that the example policy can be
1336/// demanded of everything else without exception: an accessor's example shows a reader
1337/// nothing its signature has not already said, and a rule that demands one is a rule that
1338/// buys placeholders.
1339fn is_trivial_accessor(sig: &syn::Signature, block: &syn::Block) -> bool {
1340 if sig.receiver().is_none() || sig.inputs.len() != 1 {
1341 return false;
1342 }
1343 if block.stmts.len() != 1 {
1344 return false;
1345 }
1346 let syn::Stmt::Expr(expr, None) = &block.stmts[0] else {
1347 return false;
1348 };
1349 fn simple(expr: &syn::Expr, depth: usize) -> bool {
1350 if depth > 3 {
1351 return false;
1352 }
1353 match expr {
1354 syn::Expr::Field(f) => simple(&f.base, depth + 1),
1355 syn::Expr::Path(p) => p.path.is_ident("self"),
1356 syn::Expr::Reference(r) => simple(&r.expr, depth + 1),
1357 syn::Expr::Unary(u) => simple(&u.expr, depth + 1),
1358 syn::Expr::Paren(p) => simple(&p.expr, depth + 1),
1359 syn::Expr::Lit(_) => true,
1360 // `self.field.as_str()`, `self.0.clone()`: a no-argument call on something simple
1361 syn::Expr::MethodCall(m) => m.args.is_empty() && simple(&m.receiver, depth + 1),
1362 // `self.len() == 0`, `!self.done`
1363 syn::Expr::Binary(b) => simple(&b.left, depth + 1) && simple(&b.right, depth + 1),
1364 _ => false,
1365 }
1366 }
1367 simple(expr, 0)
1368}
1369
1370#[cfg(test)]
1371mod tests {
1372 use super::*;
1373
1374 fn fixture(files: &[(&str, &str)]) -> tempfile::TempDir {
1375 let dir = tempfile::tempdir().unwrap();
1376 for (path, content) in files {
1377 let p = dir.path().join(path);
1378 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1379 std::fs::write(p, content).unwrap();
1380 }
1381 dir
1382 }
1383
1384 #[test]
1385 fn a_pub_item_under_a_private_module_is_not_exported() {
1386 let dir = fixture(&[
1387 ("src/lib.rs", "//! Root.\npub mod open;\nmod closed;\n"),
1388 ("src/open/mod.rs", "//! Open.\npub fn seen() {}\n"),
1389 ("src/closed.rs", "//! Closed.\npub fn unseen() {}\n"),
1390 ]);
1391 let inv = Inventory::of_crate(dir.path()).unwrap();
1392 assert!(inv.item("majordomus_cli::open::seen").unwrap().exported);
1393 assert!(!inv.item("majordomus_cli::closed::unseen").unwrap().exported);
1394 // and the private module itself is not part of the surface
1395 assert!(!inv.item("majordomus_cli::closed").unwrap().exported);
1396 }
1397
1398 #[test]
1399 fn pub_crate_is_not_exported_and_neither_is_what_it_contains() {
1400 let dir = fixture(&[
1401 ("src/lib.rs", "//! Root.\npub(crate) mod internal;\n"),
1402 ("src/internal.rs", "//! Internal.\npub struct T;\n"),
1403 ]);
1404 let inv = Inventory::of_crate(dir.path()).unwrap();
1405 assert!(!inv.item("majordomus_cli::internal::T").unwrap().exported);
1406 assert!(inv.exported().iter().all(|i| i.name != "T"));
1407 }
1408
1409 #[test]
1410 fn only_a_rust_block_without_ignore_counts_as_an_executable_example() {
1411 let doc = "Prose.\n\n```text\na diagram\n```\n\n```ignore\nnever_compiled();\n```\n\n```no_run\nserve();\n```\n";
1412 let found = examples_of(doc);
1413 assert_eq!(found.len(), 3);
1414 assert!(!found[0].executable(), "text");
1415 assert!(!found[1].executable(), "ignore");
1416 assert!(
1417 found[2].compiles && !found[2].runs,
1418 "no_run compiles and does not run"
1419 );
1420 }
1421
1422 #[test]
1423 fn an_in_file_test_module_is_evidence_and_not_surface() {
1424 let dir = fixture(&[(
1425 "src/lib.rs",
1426 "//! Root.\npub fn go() {}\n#[cfg(test)]\nmod tests {\n #[test]\n fn it_goes() {}\n}\n",
1427 )]);
1428 let inv = Inventory::of_crate(dir.path()).unwrap();
1429 assert!(inv.item("majordomus_cli::tests").is_none());
1430 assert_eq!(
1431 inv.in_file_tests["majordomus_cli"].names,
1432 vec!["it_goes".to_string()]
1433 );
1434 }
1435
1436 #[test]
1437 fn a_trivial_accessor_is_recognised_and_a_computation_is_not() {
1438 let dir = fixture(&[(
1439 "src/lib.rs",
1440 r#"//! Root.
1441/// A type.
1442pub struct T { n: usize }
1443impl T {
1444 /// The count.
1445 pub fn n(&self) -> usize { self.n }
1446 /// Whether it is empty.
1447 pub fn empty(&self) -> bool { self.n == 0 }
1448 /// Doubled.
1449 pub fn doubled(&self) -> usize { let m = self.n; m * 2 }
1450}
1451"#,
1452 )]);
1453 let inv = Inventory::of_crate(dir.path()).unwrap();
1454 let method = |name: &str| {
1455 inv.item_of(&format!("majordomus_cli::T::{name}"), ItemKind::Method)
1456 .unwrap_or_else(|| panic!("no method {name}"))
1457 };
1458 assert!(method("n").trivial_accessor);
1459 assert!(method("empty").trivial_accessor);
1460 assert!(!method("doubled").trivial_accessor);
1461 }
1462
1463 #[test]
1464 fn a_test_file_naming_a_module_covers_it_and_every_prefix() {
1465 let dir = fixture(&[
1466 ("src/lib.rs", "//! Root.\npub mod a;\n"),
1467 ("src/a/mod.rs", "//! A.\npub mod b;\n"),
1468 ("src/a/b.rs", "//! B.\npub fn go() {}\n"),
1469 (
1470 "tests/it.rs",
1471 "use majordomus_cli::a::b::go;\n#[test]\nfn t() { go(); }\n",
1472 ),
1473 ]);
1474 let inv = Inventory::of_crate(dir.path()).unwrap();
1475 assert!(inv.named_by_tests.contains("majordomus_cli::a"));
1476 assert!(inv.named_by_tests.contains("majordomus_cli::a::b"));
1477 assert!(inv.named_by_tests.contains("majordomus_cli::a::b::go"));
1478 }
1479
1480 #[test]
1481 fn a_use_group_names_every_member_of_it() {
1482 let found = crate_paths_in("use majordomus_cli::capability::{Capability, Exposure};");
1483 assert!(found.contains(&"majordomus_cli::capability::Capability".to_string()));
1484 assert!(found.contains(&"majordomus_cli::capability::Exposure".to_string()));
1485 }
1486
1487 #[test]
1488 fn a_macro_export_is_reached_from_the_crate_root_wherever_it_is_written() {
1489 let dir = fixture(&[
1490 ("src/lib.rs", "//! Root.\npub mod deep;\n"),
1491 (
1492 "src/deep.rs",
1493 "//! Deep.\n/// Does a thing.\n#[macro_export]\nmacro_rules! thing { () => {} }\n",
1494 ),
1495 ]);
1496 let inv = Inventory::of_crate(dir.path()).unwrap();
1497 let m = inv.item("majordomus_cli::thing").unwrap();
1498 assert_eq!(m.kind, ItemKind::Macro);
1499 assert!(m.exported);
1500 }
1501}