krz/orgo

Lightning fast org-mode static site generator.

clone: git clone https://gitbay.org/krz/orgo.git

main: src/audit.rs · raw

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