Skip to main content

majordomus_cli/quality/
policy.rs

1//! What the repository demands of an exported item, and what counts as having met it.
2//!
3//! # The two questions
4//!
5//! *Does this item owe an example?* — [`ExampleRequirement::of`] answers from the item's
6//! kind and shape alone. A module, a type, a function, a method and an exported macro owe
7//! one. A field, a variant, a constant, a static, a type alias, an associated item and a
8//! trivial accessor do not, each for a reason the report prints: an example of a value is
9//! an example of whatever uses it, and a rule that demands one buys the placeholder this
10//! module exists to refuse. The classification is derived, never declared, so there is no
11//! exemption list for anyone to add a line to.
12//!
13//! *Does this example count?* — [`Verdict::of`] answers with the signals a machine can
14//! check: the block is compiled by the toolchain, it names the item it documents, and it
15//! asserts something that is not true of every program. That is deliberately not a
16//! judgement about prose. It is the smallest set of tests that makes the obvious ways of
17//! satisfying a counter fail.
18//!
19//! # What it refuses
20//!
21//! ```
22//! use majordomus_cli::quality::policy::Verdict;
23//! use majordomus_cli::quality::source::Example;
24//!
25//! // the classic: the item named, a fence, and an assertion true of every program
26//! let placeholder = Example::parse("", &["let _: Foo;".into(), "assert!(true);".into()]);
27//! assert_eq!(Verdict::of(&placeholder, "Foo"), Verdict::Placeholder);
28//!
29//! // compiled by nobody
30//! let ignored = Example::parse("ignore", &["Foo::new().run();".into()]);
31//! assert_eq!(Verdict::of(&ignored, "Foo"), Verdict::NotExecutable);
32//!
33//! // runs, asserts, and never mentions what it is documenting
34//! let elsewhere = Example::parse("", &["let n = 2 + 2;".into(), "assert_eq!(n, 4);".into()]);
35//! assert_eq!(Verdict::of(&elsewhere, "Foo"), Verdict::DoesNotNameSubject);
36//!
37//! // and what it accepts
38//! let real = Example::parse("", &["assert_eq!(Foo::new().len(), 0);".into()]);
39//! assert_eq!(Verdict::of(&real, "Foo"), Verdict::Counts);
40//! ```
41
42use serde::{Deserialize, Serialize};
43
44use super::source::{Example, Item, ItemKind};
45
46/// The fewest words of prose the documentation of a behaviour-bearing exported item must
47/// carry, below which it is repeating the signature.
48///
49/// Six is not a measurement of quality; it is the point below which a doc comment is
50/// demonstrably `Gets the config.` and nothing else. The lint the compiler already runs
51/// catches the absence of documentation; this catches its ceremonial form.
52///
53/// It applies to what [`crate::quality::ItemKind::carries_behaviour`] is true of, and not
54/// to a field, a variant or a constant: those are named and typed, a four-word gloss of
55/// one is often complete, and a longer bar would buy padding rather than meaning.
56pub const MIN_PROSE_WORDS: usize = 6;
57
58/// The fewest words a module's `//!` header must carry. A module is a conceptual boundary
59/// and explaining one costs more than a sentence.
60pub const MIN_MODULE_PROSE_WORDS: usize = 25;
61
62/// Whether an item owes an executable example, and when it does not, why not.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
64#[serde(tag = "requirement", rename_all = "snake_case")]
65pub enum ExampleRequirement {
66    /// It owes one.
67    Required,
68    /// It does not, for this reason. The reason is printed in the report, so that what the
69    /// policy does not ask for is as visible as what it does.
70    NotApplicable {
71        /// Why not, in the report's own words.
72        reason: &'static str,
73    },
74}
75
76impl ExampleRequirement {
77    /// Classify an item. Derived from the item's kind and shape; nothing declares this.
78    ///
79    /// ```
80    /// use majordomus_cli::quality::policy::ExampleRequirement;
81    /// use majordomus_cli::quality::source::Inventory;
82    ///
83    /// let dir = tempfile::tempdir().unwrap();
84    /// let src = dir.path().join("src");
85    /// std::fs::create_dir_all(&src).unwrap();
86    /// std::fs::write(src.join("lib.rs"), r#"//! Root.
87    /// /// A limit that matters.
88    /// pub const LIMIT: usize = 10;
89    /// /// Counts.
90    /// pub fn count() -> usize { LIMIT }
91    /// "#).unwrap();
92    /// let inv = Inventory::of_crate(dir.path()).unwrap();
93    ///
94    /// let f = ExampleRequirement::of(inv.item("majordomus_cli::count").unwrap());
95    /// assert_eq!(f, ExampleRequirement::Required);
96    ///
97    /// let c = ExampleRequirement::of(inv.item("majordomus_cli::LIMIT").unwrap());
98    /// assert!(matches!(c, ExampleRequirement::NotApplicable { .. }));
99    /// ```
100    pub fn of(item: &Item) -> Self {
101        if !item.kind.carries_behaviour() {
102            return ExampleRequirement::NotApplicable {
103                reason: match item.kind {
104                    ItemKind::Field => "a field is shown by an example of the type that holds it",
105                    ItemKind::Variant => "a variant is shown by an example of the enum",
106                    ItemKind::Constant | ItemKind::Static => {
107                        "a value is shown by an example of what reads it"
108                    }
109                    ItemKind::TypeAlias => "an alias has the behaviour of the type it names",
110                    _ => "an associated name is shown by an example of its trait",
111                },
112            };
113        }
114        if item.trivial_accessor {
115            return ExampleRequirement::NotApplicable {
116                reason: "an accessor that hands back what the receiver holds shows nothing its signature has not",
117            };
118        }
119        ExampleRequirement::Required
120    }
121
122    /// Does this item owe an example?
123    ///
124    /// ```
125    /// use majordomus_cli::quality::policy::ExampleRequirement;
126    /// assert!(ExampleRequirement::Required.is_required());
127    /// assert!(!ExampleRequirement::NotApplicable { reason: "x" }.is_required());
128    /// ```
129    pub fn is_required(&self) -> bool {
130        matches!(self, ExampleRequirement::Required)
131    }
132
133    /// The reason it does not, or `None` when it does.
134    pub fn reason(&self) -> Option<&'static str> {
135        match self {
136            ExampleRequirement::Required => None,
137            ExampleRequirement::NotApplicable { reason } => Some(reason),
138        }
139    }
140}
141
142/// What one fenced block is worth as evidence about one item.
143///
144/// The variants are ordered by how far the block is from counting, and the report turns
145/// the best verdict among an item's blocks into the finding it raises — so an item with
146/// one real example and three diagrams is clean, and an item with three ignored blocks is
147/// told which of its problems to fix first.
148#[derive(
149    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
150)]
151#[serde(rename_all = "snake_case")]
152pub enum Verdict {
153    /// Evidence: compiled by the toolchain, names the item, asserts something real.
154    Counts,
155    /// Compiled and names the item, and asserts nothing, or nothing that could be false.
156    Placeholder,
157    /// Compiled, asserts something, and never mentions the item it documents.
158    DoesNotNameSubject,
159    /// `ignore`d, or a fenced box of prose: compiled by nobody.
160    NotExecutable,
161}
162
163impl Verdict {
164    /// Judge one block as evidence about the item named `subject`.
165    ///
166    /// `subject` is the item's own name — `Capability`, `classify`, `parse` — because that
167    /// is what a caller writes. A method's example may name the method or the type it is
168    /// on; both are the caller naming the thing being documented.
169    ///
170    /// ```
171    /// use majordomus_cli::quality::policy::Verdict;
172    /// use majordomus_cli::quality::source::Example;
173    ///
174    /// let e = Example::parse("", &[
175    ///     "let id = CapabilityId::parse(\"a.b\").unwrap();".into(),
176    ///     "assert_eq!(id.namespace(), \"a\");".into(),
177    /// ]);
178    /// assert_eq!(Verdict::of(&e, "CapabilityId"), Verdict::Counts);
179    /// ```
180    pub fn of(example: &Example, subject: &str) -> Verdict {
181        if !example.executable() {
182            return Verdict::NotExecutable;
183        }
184        let code = example.code();
185        if !names(&code, subject) {
186            return Verdict::DoesNotNameSubject;
187        }
188        if !asserts_something(&code) {
189            return Verdict::Placeholder;
190        }
191        Verdict::Counts
192    }
193
194    /// The best verdict among an item's blocks: what the report acts on.
195    ///
196    /// An item with no block at all has no verdict, which the caller reports as the
197    /// missing example it is.
198    ///
199    /// ```
200    /// use majordomus_cli::quality::policy::Verdict;
201    /// use majordomus_cli::quality::source::Example;
202    ///
203    /// let diagram = Example::parse("text", &["a picture".into()]);
204    /// let real = Example::parse("", &["assert!(Thing::new().ready());".into()]);
205    /// assert_eq!(Verdict::best(&[&diagram, &real], "Thing"), Some(Verdict::Counts));
206    /// assert_eq!(Verdict::best(&[], "Thing"), None);
207    /// ```
208    pub fn best(examples: &[&Example], subject: &str) -> Option<Verdict> {
209        examples.iter().map(|e| Verdict::of(e, subject)).min()
210    }
211
212    /// The finding this verdict raises, or `None` when the example counts.
213    ///
214    /// ```
215    /// use majordomus_cli::quality::policy::Verdict;
216    /// use majordomus_cli::quality::ViolationCode;
217    /// assert_eq!(Verdict::Counts.code(), None);
218    /// assert_eq!(Verdict::Placeholder.code(), Some(ViolationCode::RustExamplePlaceholder));
219    /// ```
220    pub fn code(self) -> Option<super::model::ViolationCode> {
221        use super::model::ViolationCode as C;
222        match self {
223            Verdict::Counts => None,
224            Verdict::Placeholder => Some(C::RustExamplePlaceholder),
225            Verdict::DoesNotNameSubject => Some(C::RustExampleDoesNotNameSubject),
226            Verdict::NotExecutable => Some(C::RustExampleNotExecutable),
227        }
228    }
229}
230
231/// Does the code name the subject as an identifier, rather than inside a longer word?
232///
233/// ```
234/// use majordomus_cli::quality::policy::names;
235/// assert!(names("let x = Thing::new();", "Thing"));
236/// assert!(!names("let x = ThingBuilder::new();", "Thing"), "not a prefix of another name");
237/// assert!(names("thing.parse();", "parse"));
238/// ```
239pub fn names(code: &str, subject: &str) -> bool {
240    if subject.is_empty() {
241        return false;
242    }
243    let bytes = code.as_bytes();
244    let mut from = 0usize;
245    while let Some(found) = code[from..].find(subject) {
246        let start = from + found;
247        let end = start + subject.len();
248        let before = start == 0 || !is_ident(bytes[start - 1]);
249        let after = end == bytes.len() || !is_ident(bytes[end]);
250        if before && after {
251            return true;
252        }
253        from = start + 1;
254    }
255    false
256}
257
258fn is_ident(b: u8) -> bool {
259    b.is_ascii_alphanumeric() || b == b'_'
260}
261
262/// Does the code assert anything that could be false?
263///
264/// An `assert!`/`assert_eq!`/`assert_ne!` whose arguments are all literals is true of
265/// every program and is not one; `?` and `unwrap`/`expect` are assertions, because a
266/// failure of either fails the doctest. A block with none of those documents a shape and
267/// proves nothing about behaviour.
268///
269/// ```
270/// use majordomus_cli::quality::policy::asserts_something;
271/// assert!(!asserts_something("assert!(true);"));
272/// assert!(!asserts_something("assert_eq!(1, 1);"));
273/// assert!(asserts_something("assert_eq!(thing.len(), 3);"));
274/// assert!(asserts_something("let v = parse(\"x\")?;"));
275/// assert!(asserts_something("let v = parse(\"x\").unwrap();"));
276/// ```
277pub fn asserts_something(code: &str) -> bool {
278    for (idx, _) in code.match_indices("assert") {
279        // `assert!`, `assert_eq!`, `assert_ne!`, and `debug_assert*`
280        let rest = &code[idx..];
281        let Some(open) = rest.find('(') else { continue };
282        let head = &rest[..open];
283        if !head.ends_with('!') {
284            continue;
285        }
286        let args = balanced(&rest[open..]);
287        if !all_literal(args) {
288            return true;
289        }
290    }
291    // a failing `?` or `unwrap`/`expect` fails the doctest, which makes either an assertion
292    // about the call that produced the value
293    code.contains(".unwrap()")
294        || code.contains(".expect(")
295        || code.contains("?;")
296        || code.contains("?\n")
297}
298
299/// The text between a leading `(` and the `)` that closes it.
300fn balanced(text: &str) -> &str {
301    let mut depth = 0usize;
302    for (i, c) in text.char_indices() {
303        match c {
304            '(' | '[' | '{' => depth += 1,
305            ')' | ']' | '}' => {
306                depth -= 1;
307                if depth == 0 {
308                    return &text[1..i];
309                }
310            }
311            _ => {}
312        }
313    }
314    text
315}
316
317/// Are all the arguments of an assertion literals, so that it is true of every program?
318///
319/// `true`, `1`, `"a"`, `1 + 1`, and a message argument that is a string are literals; a
320/// call, a path or a variable is not.
321fn all_literal(args: &str) -> bool {
322    let mut any = false;
323    for part in split_top(args) {
324        let t = part.trim();
325        if t.is_empty() {
326            continue;
327        }
328        any = true;
329        let literal = t.chars().all(|c| {
330            c.is_ascii_digit()
331                || c.is_whitespace()
332                || "+-*/%.\"'_".contains(c)
333                || c == 'e'
334                || c == 'E'
335        }) || t == "true"
336            || t == "false"
337            || (t.starts_with('"') && t.ends_with('"'));
338        if !literal {
339            return false;
340        }
341    }
342    any
343}
344
345/// Split on top-level commas.
346fn split_top(text: &str) -> Vec<&str> {
347    let mut out = Vec::new();
348    let mut depth = 0usize;
349    let mut start = 0usize;
350    let mut in_str = false;
351    let mut prev = '\0';
352    for (i, c) in text.char_indices() {
353        match c {
354            '"' if prev != '\\' => in_str = !in_str,
355            '(' | '[' | '{' if !in_str => depth += 1,
356            ')' | ']' | '}' if !in_str => depth = depth.saturating_sub(1),
357            ',' if !in_str && depth == 0 => {
358                out.push(&text[start..i]);
359                start = i + 1;
360            }
361            _ => {}
362        }
363        prev = c;
364    }
365    out.push(&text[start..]);
366    out
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    fn ex(info: &str, code: &str) -> Example {
374        Example::parse(info, &code.lines().map(str::to_string).collect::<Vec<_>>())
375    }
376
377    #[test]
378    fn the_documented_ways_of_gaming_the_counter_all_fail() {
379        // section by section, the shapes a policy like this is usually satisfied with
380        // naming the item and asserting nothing about it: the shape a counter invites
381        assert_eq!(
382            Verdict::of(&ex("", "let _: Foo;\nassert!(true);"), "Foo"),
383            Verdict::Placeholder
384        );
385        assert_eq!(
386            Verdict::of(&ex("", "let _: Foo;\nassert_eq!(1, 1);"), "Foo"),
387            Verdict::Placeholder
388        );
389        // and without even that, the finding is the more basic one
390        assert_eq!(
391            Verdict::of(&ex("", "assert!(true);"), "Foo"),
392            Verdict::DoesNotNameSubject
393        );
394        assert_eq!(
395            Verdict::of(
396                &ex("", "let _ = Foo::A;\nassert_eq!(2 + 2, 4, \"maths\");"),
397                "Foo"
398            ),
399            Verdict::Placeholder,
400            "an arithmetic identity is not evidence about Foo"
401        );
402        assert_eq!(
403            Verdict::of(&ex("ignore", "Foo::new().run().unwrap();"), "Foo"),
404            Verdict::NotExecutable
405        );
406        assert_eq!(
407            Verdict::of(&ex("text", "Foo::new()"), "Foo"),
408            Verdict::NotExecutable
409        );
410        // an empty block asserts nothing and names nothing
411        assert_eq!(Verdict::of(&ex("", ""), "Foo"), Verdict::DoesNotNameSubject);
412        // a real one
413        assert_eq!(
414            Verdict::of(&ex("", "assert_eq!(Foo::new().len(), 0);"), "Foo"),
415            Verdict::Counts
416        );
417    }
418
419    #[test]
420    fn an_example_that_only_constructs_the_item_still_counts_when_it_can_fail() {
421        // `unwrap` is an assertion: the doctest fails if the call does
422        let e = ex("", "let f = Foo::parse(\"a.b\").unwrap();");
423        assert_eq!(Verdict::of(&e, "Foo"), Verdict::Counts);
424        // without it, the block proves only that the shape compiles
425        let e = ex("", "let f = Foo::new();");
426        assert_eq!(Verdict::of(&e, "Foo"), Verdict::Placeholder);
427    }
428
429    #[test]
430    fn no_run_is_evidence_and_ignore_is_not() {
431        let e = ex("no_run", "Server::bind(\"127.0.0.1:0\").unwrap().serve();");
432        assert_eq!(Verdict::of(&e, "Server"), Verdict::Counts);
433        let e = ex("ignore", "Server::bind(\"127.0.0.1:0\").unwrap().serve();");
434        assert_eq!(Verdict::of(&e, "Server"), Verdict::NotExecutable);
435    }
436
437    #[test]
438    fn the_best_block_decides_and_the_worst_does_not() {
439        let diagram = ex("text", "a picture");
440        let real = ex("", "assert_eq!(Thing::of(1).n(), 1);");
441        assert_eq!(
442            Verdict::best(&[&diagram, &real], "Thing"),
443            Some(Verdict::Counts)
444        );
445        assert_eq!(
446            Verdict::best(&[&diagram], "Thing"),
447            Some(Verdict::NotExecutable)
448        );
449    }
450
451    #[test]
452    fn a_hidden_line_is_code_and_counts_as_naming_the_subject() {
453        // rustdoc hides it from the reader and compiles it all the same
454        let e = ex("", "# let t = Thing::new();\nassert!(t.ready());");
455        assert_eq!(Verdict::of(&e, "Thing"), Verdict::Counts);
456    }
457}