Skip to main content

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}