krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
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 `α` 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}