krz/orgo

Lightning fast org-mode static site generator.

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

82639e554bcf4a7465c41e6c47effceb501d91d3

signed_unknown_key

author: Christian Cleberg <hello@cleberg.net> · 2026-08-23T03:43:51Z
committer: <noreply@github.com>

audit: count only entity names org knows (#26)

`entity_ref` matched any backslash followed by three or more letters, so
`C:\Users\youruser\Downloads` and `Tumblr\API\Client` were reported as org
entity references. It now consults `entities::lookup` over the full alphabetic
run, which is the rule `parser::try_entity` applies when deciding whether to
render one.

Inline `=verbatim=` and `~code~` spans are blanked before the scan. `=\alpha=`
shows the name rather than rendering the character, so it is not a use of the
feature — source blocks were already skipped for the same reason.

cleberg.net's content drops from 8 out-of-scope uses to 5, and orgo's own docs
from 1 entity hit to none; both corpora contain no real entities.

Closes #23
 fixtures/audit-entities.org    |  3 +++
 fixtures/audit-nonentities.org |  7 +++++++
 src/audit.rs                   | 44 ++++++++++++++++++++++++++++++++++++------
 tests/constructs.rs            | 31 +++++++++++++++++++++++++++++
 4 files changed, 79 insertions(+), 6 deletions(-)

diff --git a/fixtures/audit-entities.org b/fixtures/audit-entities.org
new file mode 100644
index 0000000..5d7ef51
--- /dev/null
+++ b/fixtures/audit-entities.org
@@ -0,0 +1,3 @@
+#+TITLE: Real entities
+
+The angle \alpha is small, and the arrow \rarr points right.
diff --git a/fixtures/audit-nonentities.org b/fixtures/audit-nonentities.org
new file mode 100644
index 0000000..c766fbd
--- /dev/null
+++ b/fixtures/audit-nonentities.org
@@ -0,0 +1,7 @@
+#+TITLE: Backslashes that are not entities
+
+Windows paths and namespaced identifiers are not org entities:
+=C:\Users\youruser\Downloads= and Tumblr\API\Client.
+
+Names shown inside verbatim or code are displayed, not rendered, so they are
+not a use of the feature either: =\alpha=, ~\rarr~, =20\deg=.
diff --git a/src/audit.rs b/src/audit.rs
index b5e1486..7bd4538 100644
--- a/src/audit.rs
+++ b/src/audit.rs
@@ -506,21 +506,53 @@ fn latex_inline(line: &str) -> bool {
     dollars >= 2 && line.contains("$\\")
 }
 
-/// A `\name` entity reference such as `\alpha`, excluding LaTeX environment commands.
+/// A `\name` entity reference such as `\alpha`.
+///
+/// Only names org knows count, matching [`crate::parser`]'s rule for rendering one: a
+/// Windows path (`C:\Users\me`) and a namespaced identifier (`Tumblr\API\Client`) are not
+/// entity references.
+///
+/// Verbatim and code spans are skipped: `=\alpha=` shows the name rather than rendering
+/// the character, so it is not a use of the feature.
 fn entity_ref(line: &str) -> bool {
-    for (i, c) in line.char_indices() {
-        if c != '\\' {
+    let line = without_literal_spans(line);
+    let chars: Vec<char> = line.chars().collect();
+    for (i, c) in chars.iter().enumerate() {
+        if *c != '\\' {
             continue;
         }
-        let rest = &line[i + 1..];
-        let name: String = rest.chars().take_while(|c| c.is_ascii_alphabetic()).collect();
-        if name.len() >= 3 && !matches!(name.as_str(), "begin" | "end") {
+        let name: String = chars[i + 1..].iter().take_while(|c| c.is_ascii_alphabetic()).collect();
+        if crate::entities::lookup(&name).is_some() {
             return true;
         }
     }
     false
 }
 
+/// Blank out `=verbatim=` and `~code~` spans. Deliberately looser than the parser's
+/// border rules — the audit measures prevalence, and erring toward blanking keeps it
+/// from overstating a gap.
+fn without_literal_spans(line: &str) -> String {
+    let mut out = String::with_capacity(line.len());
+    let mut open: Option<char> = None;
+    for c in line.chars() {
+        match open {
+            Some(marker) => {
+                out.push(' ');
+                if c == marker {
+                    open = None;
+                }
+            }
+            None if c == '=' || c == '~' => {
+                open = Some(c);
+                out.push(' ');
+            }
+            None => out.push(c),
+        }
+    }
+    out
+}
+
 /// A plausible `*bold*`-style emphasis pair: two markers on one line with non-space
 /// content between them. Approximate by design — the audit measures prevalence, and the
 /// parser owns the exact pre/post-character rules.
diff --git a/tests/constructs.rs b/tests/constructs.rs
index 838fa7f..147bc19 100644
--- a/tests/constructs.rs
+++ b/tests/constructs.rs
@@ -769,3 +769,34 @@ fn footnote_links_and_section_are_labelled() {
         "each back-link says where it goes:\n{html}"
     );
 }
+
+// ---------------------------------------------------------------------------
+// Audit: the entity check counts only what org would actually render
+// ---------------------------------------------------------------------------
+
+fn audit_fixture(name: &str) -> String {
+    let path = Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures").join(name);
+    let audit = orgo::audit::audit(&path).expect("audit fixture");
+    orgo::audit::report(&audit)
+}
+
+/// `\Users` and `\API` are not names org knows, and an entity inside verbatim is shown
+/// rather than rendered. Counting either overstates what the corpus needs.
+#[test]
+fn audit_ignores_backslashes_that_are_not_entities() {
+    let report = audit_fixture("audit-nonentities.org");
+    assert!(
+        !report.contains("entity (\\name)"),
+        "a Windows path or a verbatim-quoted name was counted as an entity:\n{report}"
+    );
+}
+
+/// A bare entity outside verbatim still counts.
+#[test]
+fn audit_counts_real_entities() {
+    let report = audit_fixture("audit-entities.org");
+    assert!(
+        report.contains("entity (\\name)"),
+        "a real entity was not counted:\n{report}"
+    );
+}