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}