src/audit.rs
691 lines · 25565 bytes
1//! Corpus audit (spec §5, Phase 0): measure which org constructs a real corpus actually
2//! uses, and classify each against the supported/unsupported line.
3//!
4//! This exists because the scope was recommended rather than measured — a guess about
5//! which slice of org matters. A guess about a corpus is a hypothesis, and this is the
6//! experiment. It answers two questions:
7//!
8//! 1. **Coverage** — of the constructs this corpus uses, which do we handle? A construct
9//! that is common here and out of scope is a scope bug, not a corpus quirk.
10//! 2. **Blind spots** — which constructs are here that the implementation has no opinion
11//! about at all? These are the dangerous ones: not "known unsupported" but unknown.
12//!
13//! The audit is deliberately a *separate, line-oriented scanner* rather than a reuse of
14//! [`crate::parser`]. Auditing with the parser could only ever find constructs the parser
15//! already knows about, which is precisely the wrong instrument for question 2 — it would
16//! report a blind spot as clean.
17//!
18//! Nothing here reports document *text*. Counts, construct names, and `file:line`
19//! locations only, so an audit of private notes stays publishable.
20
21use std::collections::BTreeMap;
22
23use anyhow::{Context, Result};
24use camino::{Utf8Path, Utf8PathBuf};
25use walkdir::WalkDir;
26
27use crate::util::is_iso_date_shape;
28
29/// Where a construct sits relative to the supported set, which
30/// `docs/guide/05-org-support.org` defines.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
32pub enum Scope {
33 /// orgo handles this.
34 In,
35 /// orgo deliberately excludes this; it degrades predictably.
36 Out,
37}
38
39impl Scope {
40 fn label(self) -> &'static str {
41 match self {
42 Scope::In => "IN ",
43 Scope::Out => "OUT",
44 }
45 }
46}
47
48/// One construct's tally across the corpus.
49#[derive(Debug, Default, Clone)]
50pub struct Tally {
51 pub occurrences: usize,
52 pub files: usize,
53 /// First `file:line` the construct was seen at, to make a finding actionable.
54 pub first_seen: Option<String>,
55 /// Set while scanning one file, to count each file once.
56 seen_in_current_file: bool,
57}
58
59/// The audit result: the fixed construct catalog plus the dynamic name censuses.
60#[derive(Debug, Default)]
61pub struct Audit {
62 pub files: usize,
63 pub lines: usize,
64 /// Catalogued constructs → tally.
65 pub constructs: BTreeMap<(Scope, &'static str), Tally>,
66 /// Every distinct `#+KEYWORD:` seen, by name.
67 pub keywords: BTreeMap<String, Tally>,
68 /// Every distinct `#+BEGIN_<TYPE>` seen, by type.
69 pub blocks: BTreeMap<String, Tally>,
70 /// Every distinct `:DRAWER:` seen, by name.
71 pub drawers: BTreeMap<String, Tally>,
72 /// Every distinct link scheme seen (`https`, `file`, `id`, `denote`, ...).
73 pub link_schemes: BTreeMap<String, Tally>,
74}
75
76/// Names the implementation reads by name, so the census can mark everything else. These
77/// are the *recognized* sets, not the supported ones: `INCLUDE` is recognized and
78/// deliberately inert.
79const KNOWN_KEYWORDS: &[&str] = &[
80 "TITLE", "AUTHOR", "DATE", "EMAIL", "LANGUAGE", "OPTIONS", "FILETAGS", "DESCRIPTION",
81 "KEYWORDS", "CAPTION", "NAME", "ATTR_HTML", "RESULTS", "TBLFM", "INCLUDE", "TODO",
82 "STARTUP", "SUBTITLE", "SETUPFILE", "MACRO", "PROPERTY", "HTML_HEAD", "EXCLUDE_TAGS",
83 // orgo's own keywords, each read by name: `SLUG` names the output file
84 // (`util::output_path`), `DRAFT` decides whether the page publishes at all
85 // (`util::is_draft`), and `TEMPLATE` picks the template (`config::page_template`).
86 // Leaving them out reported the corpus's most-used keyword as unrecognized.
87 "SLUG", "DRAFT", "TEMPLATE",
88];
89const KNOWN_DRAWERS: &[&str] = &["PROPERTIES", "LOGBOOK", "END"];
90/// Keyword names conventional enough to be worth flagging when they lead a heading.
91/// A custom sequence is only *real* if some `#+TODO:` declares it, which the census
92/// reports separately — this list keeps the heading-level signal honest.
93const CONVENTIONAL_TODO_KEYWORDS: &[&str] = &[
94 "NEXT", "WAITING", "HOLD", "CANCELLED", "CANCELED", "STARTED", "SOMEDAY", "PROJ",
95 "IN-PROGRESS", "BLOCKED", "REVIEW",
96];
97const KNOWN_SCHEMES: &[&str] = &[
98 "http", "https", "mailto", "ftp", "news", "tel", "file", "id", "custom-id", "heading",
99 "relative",
100];
101
102/// The census markers. A name with dedicated handling carries none; `PASS_THROUGH` says
103/// nothing reads the name but it works anyway; `UNKNOWN` is the blind-spot signal.
104const HANDLED: &str = "";
105const PASS_THROUGH: &str = "\u{2014}";
106const UNKNOWN: &str = "???";
107
108impl Audit {
109 /// How the report marks a census name: the recognized set to look the name up in,
110 /// and what an unlisted name means for that census.
111 pub fn marker(kind: Census, name: &str) -> &'static str {
112 let (known, unlisted) = match kind {
113 // An unlisted keyword is not a blind spot. Reaching templates as
114 // `page.keywords.<name>` is the designed behaviour, so every keyword name
115 // works; what the census reports is which ones have dedicated handling.
116 // `LEDE`, read by the shipped docs theme's template, was the case that made
117 // this concrete: `???` on it was a claim about orgo's source, not a gap.
118 Census::Keyword => (KNOWN_KEYWORDS, PASS_THROUGH),
119 // Every block name renders, and renders as org renders it: the names in
120 // `block_construct` through dedicated handling, every other name as a special
121 // block — a div carrying the name, holding parsed org, which is exactly what
122 // org's exporter emits. No block name is a blind spot.
123 Census::Block => return HANDLED,
124 Census::Drawer => (KNOWN_DRAWERS, UNKNOWN),
125 Census::Scheme => (KNOWN_SCHEMES, UNKNOWN),
126 };
127 if known.iter().any(|k| k.eq_ignore_ascii_case(name)) {
128 HANDLED
129 } else {
130 unlisted
131 }
132 }
133}
134
135/// Which dynamic census a name belongs to.
136#[derive(Debug, Clone, Copy)]
137pub enum Census {
138 Keyword,
139 Block,
140 Drawer,
141 Scheme,
142}
143
144/// Walk `root`, auditing every `.org` file.
145pub fn audit(root: &Utf8Path) -> Result<Audit> {
146 let mut audit = Audit::default();
147 let mut paths: Vec<Utf8PathBuf> = Vec::new();
148
149 if root.is_file() {
150 paths.push(root.to_owned());
151 } else {
152 for entry in WalkDir::new(root).sort_by_file_name() {
153 let entry = entry.with_context(|| format!("walking {root}"))?;
154 if !entry.file_type().is_file() {
155 continue;
156 }
157 let path = Utf8PathBuf::from_path_buf(entry.into_path())
158 .map_err(|p| anyhow::anyhow!("non-UTF-8 path: {}", p.display()))?;
159 if path.extension() == Some("org") {
160 paths.push(path);
161 }
162 }
163 }
164
165 for path in &paths {
166 // A file that cannot be read is reported and skipped: an audit of 179 files
167 // should not be lost to one unreadable one.
168 let source = match std::fs::read_to_string(path) {
169 Ok(s) => s,
170 Err(e) => {
171 eprintln!("warning: skipping {path}: {e}");
172 continue;
173 }
174 };
175 let rel = path.strip_prefix(root).unwrap_or(path).to_owned();
176 audit.scan_file(&rel, &source);
177 audit.files += 1;
178 }
179 Ok(audit)
180}
181
182impl Audit {
183 fn scan_file(&mut self, path: &Utf8Path, source: &str) {
184 self.start_file();
185 let mut in_block = false;
186 for (idx, line) in source.lines().enumerate() {
187 self.lines += 1;
188 let at = format!("{path}:{}", idx + 1);
189 let trimmed = line.trim_start();
190
191 // Inside a block only the terminator matters — a `*` in a source block is
192 // not a heading, and counting it as one would corrupt the audit.
193 if in_block {
194 in_block = !trimmed.to_ascii_uppercase().starts_with("#+END_");
195 continue;
196 }
197 if let Some(rest) = trimmed.to_ascii_uppercase().strip_prefix("#+BEGIN_") {
198 self.scan_block_begin(rest, &at);
199 in_block = true;
200 continue;
201 }
202
203 self.scan_line(line, trimmed, &at);
204 }
205 }
206
207 /// Reset the per-file flags so each construct counts this file at most once.
208 fn start_file(&mut self) {
209 for tally in self.constructs.values_mut() {
210 tally.seen_in_current_file = false;
211 }
212 for map in [
213 &mut self.keywords,
214 &mut self.blocks,
215 &mut self.drawers,
216 &mut self.link_schemes,
217 ] {
218 for tally in map.values_mut() {
219 tally.seen_in_current_file = false;
220 }
221 }
222 }
223
224 /// `rest` is an uppercased `#+BEGIN_` line after that prefix.
225 fn scan_block_begin(&mut self, rest: &str, at: &str) {
226 let kind = rest.split_whitespace().next().unwrap_or("");
227 self.count_census(Census::Block, kind, at);
228 self.count(Scope::In, block_construct(kind), at);
229 if rest.contains(":RESULTS") {
230 self.count(Scope::Out, "babel header args (:results)", at);
231 }
232 }
233
234 fn scan_line(&mut self, line: &str, trimmed: &str, at: &str) {
235 if let Some(stars) = heading_stars(line) {
236 self.scan_heading(line[stars..].trim(), at);
237 return;
238 }
239
240 // --- planning and clocking ---
241 for marker in ["SCHEDULED:", "DEADLINE:", "CLOSED:"] {
242 if trimmed.starts_with(marker) {
243 self.count(Scope::Out, "planning line", at);
244 }
245 }
246 if trimmed.starts_with("CLOCK:") {
247 self.count(Scope::Out, "clock entry", at);
248 }
249
250 // --- keywords and drawers ---
251 if let Some(rest) = trimmed.strip_prefix("#+") {
252 self.scan_keyword(rest, at);
253 } else if is_drawer(trimmed) {
254 self.scan_drawer(&trimmed[1..trimmed.len() - 1].to_ascii_uppercase(), at);
255 }
256
257 // --- lists, tables, rules ---
258 if let Some(bullet) = list_bullet(trimmed) {
259 self.scan_list_item(bullet, line.len() - trimmed.len(), trimmed, at);
260 }
261 if trimmed.starts_with('|') {
262 self.count(Scope::In, "table row", at);
263 }
264 if trimmed.starts_with(": ") {
265 self.count(Scope::Out, "fixed-width line", at);
266 }
267
268 // --- footnotes ---
269 if trimmed.starts_with("[fn:") {
270 self.count(Scope::In, "footnote definition", at);
271 } else if line.contains("[fn:") {
272 self.count(Scope::In, "footnote reference", at);
273 }
274
275 // --- inline objects ---
276 self.scan_inline(line, at);
277 }
278
279 /// `rest` is a heading line after its stars, trimmed.
280 fn scan_heading(&mut self, rest: &str, at: &str) {
281 self.count(Scope::In, "heading", at);
282 let word = rest.split_whitespace().next().unwrap_or("");
283 if word == "TODO" || word == "DONE" {
284 self.count(Scope::In, "TODO keyword (default set)", at);
285 } else if CONVENTIONAL_TODO_KEYWORDS.contains(&word) {
286 // Only conventional keyword names count. "Any all-caps first word" is
287 // the tempting rule and it is wrong: it reads `* CSS Variables` as the
288 // keyword `CSS`, which on this corpus produced 23 false positives and
289 // zero true ones. An audit that overstates a gap is worse than no audit,
290 // because it argues for work nobody needs.
291 self.count(Scope::Out, "TODO keyword (custom sequence)", at);
292 }
293 if rest.contains("[#") {
294 self.count(Scope::In, "priority cookie", at);
295 }
296 if rest.trim_end().ends_with(':') && rest.trim_end().matches(':').count() >= 2 {
297 self.count(Scope::In, "heading tags", at);
298 }
299 if rest.contains("[/") || rest.contains("[%") {
300 self.count(Scope::Out, "statistics cookie", at);
301 }
302 }
303
304 /// `rest` is a `#+` line after that prefix.
305 fn scan_keyword(&mut self, rest: &str, at: &str) {
306 let Some(colon) = rest.find(':') else { return };
307 let key = rest[..colon].trim().to_ascii_uppercase();
308 if key.is_empty() || key.contains(char::is_whitespace) {
309 return;
310 }
311 self.count_census(Census::Keyword, &key, at);
312 match key.as_str() {
313 "CAPTION" | "NAME" | "ATTR_HTML" => self.count(Scope::In, "affiliated keyword", at),
314 // Not a gap: org's HTML exporter does not recalculate `#+TBLFM:`
315 // either, so the cells as written are what both exporters emit.
316 // `fixtures/tblfm.org` holds the oracle to that (`tests/oracle.rs`).
317 // Unlike `#+INCLUDE:`, nothing is lost by leaving it inert.
318 "TBLFM" => self.count(Scope::In, "table formula (#+TBLFM:)", at),
319 "INCLUDE" => self.count(Scope::Out, "#+INCLUDE:", at),
320 "RESULTS" => self.count(Scope::Out, "babel results block", at),
321 "TODO" => self.count(Scope::Out, "#+TODO: keyword sequence", at),
322 "MACRO" => self.count(Scope::Out, "macro definition", at),
323 _ => self.count(Scope::In, "#+ keyword", at),
324 }
325 }
326
327 /// `name` is a drawer line's name, uppercased.
328 fn scan_drawer(&mut self, name: &str, at: &str) {
329 if name == "END" {
330 return;
331 }
332 self.count_census(Census::Drawer, name, at);
333 match name {
334 "PROPERTIES" => self.count(Scope::In, "property drawer", at),
335 _ => self.count(Scope::Out, "non-PROPERTIES drawer", at),
336 }
337 }
338
339 fn scan_list_item(&mut self, bullet: Bullet, indent: usize, trimmed: &str, at: &str) {
340 self.count(Scope::In, "list item", at);
341 if bullet == Bullet::Ordered {
342 self.count(Scope::In, "ordered list", at);
343 }
344 if indent > 0 {
345 self.count(Scope::In, "nested list item", at);
346 }
347 if trimmed.contains(" :: ") {
348 self.count(Scope::In, "description list", at);
349 }
350 let after = trimmed.trim_start_matches(['-', '+', '*', ' ']);
351 if ["[ ]", "[X]", "[-]"].iter().any(|cb| after.starts_with(cb)) {
352 self.count(Scope::In, "checkbox", at);
353 }
354 }
355
356 fn scan_inline(&mut self, line: &str, at: &str) {
357 // Links: count each `[[target]]`, censusing its scheme.
358 let mut rest = line;
359 while let Some(start) = rest.find("[[") {
360 let after = &rest[start + 2..];
361 let Some(end) = after.find("]]") else { break };
362 let inner = &after[..end];
363 let target = inner.split("][").next().unwrap_or(inner);
364 self.count(Scope::In, "link", at);
365 self.count_census(Census::Scheme, &link_scheme(target), at);
366 rest = &after[end..];
367 }
368
369 // The rest read a line that has had its `=verbatim=` and `~code~` spans blanked:
370 // a construct shown inside verbatim is displayed rather than rendered, so a page
371 // documenting org syntax is not a use of the syntax it names. Emphasis pairs
372 // below deliberately still see the raw line — `=` is one of the markers scanned.
373 let bare = without_literal_spans(line);
374
375 if has_timestamp(&bare) {
376 self.count(Scope::In, "timestamp", at);
377 }
378 if bare.contains("{{{") {
379 self.count(Scope::Out, "macro call", at);
380 }
381 if bare.contains("<<<") {
382 self.count(Scope::Out, "radio target", at);
383 } else if bare.contains("<<") && bare.contains(">>") {
384 self.count(Scope::Out, "internal target", at);
385 }
386 if bare.contains("\\begin{") || latex_inline(&bare) {
387 self.count(Scope::Out, "LaTeX fragment", at);
388 }
389 if entity_ref(&bare) {
390 // Rendered, and rendered as org renders it: `\alpha` becomes `α` in
391 // both exporters. `fixtures/audit-entities.org` holds the oracle to that.
392 self.count(Scope::In, "entity (\\name)", at);
393 }
394 for (marker, name) in [
395 ('*', "bold"),
396 ('/', "italic"),
397 ('_', "underline"),
398 ('+', "strike-through"),
399 ('=', "verbatim"),
400 ('~', "code"),
401 ] {
402 if emphasis_pair(line, marker) {
403 self.count(Scope::In, name, at);
404 }
405 }
406 }
407
408 fn count(&mut self, scope: Scope, name: &'static str, at: &str) {
409 let tally = self.constructs.entry((scope, name)).or_default();
410 bump(tally, at);
411 }
412
413 fn count_census(&mut self, kind: Census, name: &str, at: &str) {
414 let map = match kind {
415 Census::Keyword => &mut self.keywords,
416 Census::Block => &mut self.blocks,
417 Census::Drawer => &mut self.drawers,
418 Census::Scheme => &mut self.link_schemes,
419 };
420 let tally = map.entry(name.to_string()).or_default();
421 bump(tally, at);
422 }
423}
424
425fn bump(tally: &mut Tally, at: &str) {
426 tally.occurrences += 1;
427 if !tally.seen_in_current_file {
428 tally.seen_in_current_file = true;
429 tally.files += 1;
430 }
431 if tally.first_seen.is_none() {
432 tally.first_seen = Some(at.to_string());
433 }
434}
435
436// ---------------------------------------------------------------------------
437// Line-level detectors. Deliberately independent of the parser (see module docs).
438// ---------------------------------------------------------------------------
439
440fn heading_stars(line: &str) -> Option<usize> {
441 if !line.starts_with('*') {
442 return None;
443 }
444 let stars = line.chars().take_while(|c| *c == '*').count();
445 let after = &line[stars..];
446 (after.starts_with(' ') || after.is_empty()).then_some(stars)
447}
448
449#[derive(PartialEq)]
450enum Bullet {
451 Unordered,
452 Ordered,
453}
454
455fn list_bullet(trimmed: &str) -> Option<Bullet> {
456 let bytes = trimmed.as_bytes();
457 if bytes.is_empty() {
458 return None;
459 }
460 if (bytes[0] == b'-' || bytes[0] == b'+') && (bytes.len() == 1 || bytes[1] == b' ') {
461 return Some(Bullet::Unordered);
462 }
463 let digits = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
464 if digits > 0 {
465 let after = &trimmed[digits..];
466 if (after.starts_with('.') || after.starts_with(')'))
467 && (after.len() == 1 || after.as_bytes()[1] == b' ')
468 {
469 return Some(Bullet::Ordered);
470 }
471 }
472 None
473}
474
475fn is_drawer(trimmed: &str) -> bool {
476 let t = trimmed.trim_end();
477 t.len() >= 3
478 && t.starts_with(':')
479 && t.ends_with(':')
480 && t[1..t.len() - 1]
481 .chars()
482 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
483}
484
485fn block_construct(kind: &str) -> &'static str {
486 match kind.to_ascii_uppercase().as_str() {
487 "SRC" => "source block",
488 "QUOTE" => "quote block",
489 "EXAMPLE" => "example block",
490 "CENTER" => "center block",
491 "EXPORT" => "export block",
492 "VERSE" => "verse block",
493 "COMMENT" => "comment block",
494 _ => "special block",
495 }
496}
497
498/// The scheme of a link target, normalized into the census's vocabulary.
499fn link_scheme(target: &str) -> String {
500 if let Some(rest) = target.split_once(':') {
501 let scheme = rest.0;
502 if !scheme.is_empty()
503 && scheme
504 .chars()
505 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '+')
506 {
507 return scheme.to_ascii_lowercase();
508 }
509 }
510 if target.starts_with('#') {
511 return "custom-id".to_string();
512 }
513 if target.starts_with('*') {
514 return "heading".to_string();
515 }
516 "relative".to_string()
517}
518
519/// A `<...>`/`[...]` span opening with an ISO date is a timestamp.
520fn has_timestamp(line: &str) -> bool {
521 let bytes = line.as_bytes();
522 for (i, c) in line.char_indices() {
523 if c != '<' && c != '[' {
524 continue;
525 }
526 if bytes[i + 1..].get(..10).is_some_and(is_iso_date_shape) {
527 return true;
528 }
529 }
530 false
531}
532
533/// `$x$` or `\(x\)` inline math. `$` alone (a price, a shell prompt) is not math.
534fn latex_inline(line: &str) -> bool {
535 if line.contains("\\(") && line.contains("\\)") {
536 return true;
537 }
538 let dollars = line.matches('$').count();
539 dollars >= 2 && line.contains("$\\")
540}
541
542/// A `\name` entity reference such as `\alpha`.
543///
544/// Only names org knows count, matching [`crate::parser`]'s rule for rendering one: a
545/// Windows path (`C:\Users\me`) and a namespaced identifier (`Tumblr\API\Client`) are not
546/// entity references.
547fn entity_ref(line: &str) -> bool {
548 let chars: Vec<char> = line.chars().collect();
549 for (i, c) in chars.iter().enumerate() {
550 if *c != '\\' {
551 continue;
552 }
553 let name: String = chars[i + 1..].iter().take_while(|c| c.is_ascii_alphabetic()).collect();
554 if crate::entities::lookup(&name).is_some() {
555 return true;
556 }
557 }
558 false
559}
560
561/// Blank out `=verbatim=` and `~code~` spans. Deliberately looser than the parser's
562/// border rules — the audit measures prevalence, and erring toward blanking keeps it
563/// from overstating a gap.
564fn without_literal_spans(line: &str) -> String {
565 let mut out = String::with_capacity(line.len());
566 let mut open: Option<char> = None;
567 for c in line.chars() {
568 match open {
569 Some(marker) => {
570 out.push(' ');
571 if c == marker {
572 open = None;
573 }
574 }
575 None if c == '=' || c == '~' => {
576 open = Some(c);
577 out.push(' ');
578 }
579 None => out.push(c),
580 }
581 }
582 out
583}
584
585/// A plausible `*bold*`-style emphasis pair: two markers on one line with non-space
586/// content between them. Approximate by design — the audit measures prevalence, and the
587/// parser owns the exact pre/post-character rules.
588fn emphasis_pair(line: &str, marker: char) -> bool {
589 let positions: Vec<usize> = line
590 .char_indices()
591 .filter(|(_, c)| *c == marker)
592 .map(|(i, _)| i)
593 .collect();
594 if positions.len() < 2 {
595 return false;
596 }
597 // A leading `*` is a heading, and `-`/`+` at line start is a bullet.
598 let trimmed = line.trim_start();
599 if trimmed.starts_with(marker) {
600 return false;
601 }
602 positions.windows(2).any(|w| w[1] > w[0] + 1)
603}
604
605// ---------------------------------------------------------------------------
606// Report
607// ---------------------------------------------------------------------------
608
609/// Render the audit as a readable report. Names, counts and locations only — never
610/// document text, so an audit of private notes is safe to paste into an issue.
611pub fn report(audit: &Audit) -> String {
612 let mut out = String::new();
613 out.push_str(&format!(
614 "corpus: {} file(s), {} line(s)\n",
615 audit.files, audit.lines
616 ));
617
618 let mut rows: Vec<(&(Scope, &str), &Tally)> = audit.constructs.iter().collect();
619 rows.sort_by(|a, b| {
620 b.1.occurrences
621 .cmp(&a.1.occurrences)
622 .then_with(|| a.0 .1.cmp(b.0 .1))
623 });
624
625 out.push_str("\nCONSTRUCTS (by frequency)\n");
626 out.push_str(&format!(
627 "{:<4} {:<32} {:>8} {:>7} {}\n",
628 "", "construct", "uses", "files", "first seen"
629 ));
630 for ((scope, name), tally) in &rows {
631 out.push_str(&format!(
632 "{:<4} {:<32} {:>8} {:>7} {}\n",
633 scope.label(),
634 name,
635 tally.occurrences,
636 tally.files,
637 tally.first_seen.as_deref().unwrap_or("")
638 ));
639 }
640
641 let in_uses: usize = rows
642 .iter()
643 .filter(|((s, _), _)| *s == Scope::In)
644 .map(|(_, t)| t.occurrences)
645 .sum();
646 let out_uses: usize = rows
647 .iter()
648 .filter(|((s, _), _)| *s == Scope::Out)
649 .map(|(_, t)| t.occurrences)
650 .sum();
651 let total = in_uses + out_uses;
652 let pct = |n: usize| {
653 if total == 0 {
654 0.0
655 } else {
656 100.0 * n as f64 / total as f64
657 }
658 };
659 out.push_str(&format!(
660 "\ncoverage: {in_uses} in-scope use(s) ({:.1}%), {out_uses} out-of-scope ({:.1}%)\n",
661 pct(in_uses),
662 pct(out_uses)
663 ));
664
665 for (title, kind, map) in [
666 ("KEYWORDS", Census::Keyword, &audit.keywords),
667 ("BLOCK TYPES", Census::Block, &audit.blocks),
668 ("DRAWERS", Census::Drawer, &audit.drawers),
669 ("LINK SCHEMES", Census::Scheme, &audit.link_schemes),
670 ] {
671 let mut names: Vec<(&String, &Tally)> = map.iter().collect();
672 names.sort_by(|a, b| b.1.occurrences.cmp(&a.1.occurrences).then(a.0.cmp(b.0)));
673 out.push_str(&format!("\n{title}\n"));
674 for (name, tally) in names {
675 out.push_str(&format!(
676 "{:<4}{:<32} {:>8} {:>7} {}\n",
677 Audit::marker(kind, name),
678 name,
679 tally.occurrences,
680 tally.files,
681 tally.first_seen.as_deref().unwrap_or("")
682 ));
683 }
684 }
685 out.push_str(
686 "\n`???` marks a name the implementation does not recognize at all.\n\
687 `\u{2014}` marks a keyword with no dedicated handling; it reaches templates \
688 as `page.keywords.<name>`.\n",
689 );
690 out
691}