krz/orgo

Lightning fast org-mode static site generator. fast go org-mode static-site-generator

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 `&alpha;` 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}