diff mbox series

[v4,5/5] tools: Add check-confusables pre-commit hook

Message ID 20260728163816.17230-6-niko.mauno@vaisala.com
State New
Headers show
Series documentation: minor fixes, plus a confusables check | expand

Commit Message

Niko Mauno July 28, 2026, 4:38 p.m. UTC
From: Niko Mauno <niko.mauno@vaisala.com>

Add a check-confusables script, in the same fashion as
check-glossaries, that scans the documentation .rst sources for
non-ASCII "confusable" characters (curly quotes, en/em dashes,
non-breaking and zero-width spaces, etc.) and reports each occurrence
with its location and suggested ASCII replacement, exiting non-zero if
any are found. This guards against the class of breakage fixed in the
preceding commit, e.g. curly quotes causing recipe ParseErrors.

Legitimate non-ASCII such as box-drawing characters used in directory
trees, accented letters in contributor names and CJK characters are
intentionally left untouched. No-break spaces are likewise tolerated
on lines containing box-drawing characters, since the tree command
emits them as indentation in directory listings.

The set of flagged characters is intentionally small and curated
rather than exhaustive. A general-purpose dependency such as the
confusables PyPI package targets Unicode homoglyph detection against
the full confusables table; it would also flag the accented names, CJK
and box-drawing characters we deliberately keep, so we would still need
our own allow-list and replacement policy on top of it. A short,
dependency-free table kept in-tree matches check-glossaries and is
trivial to extend if a new problematic character shows up.

Wire it up both as a local pre-commit hook, which checks the changed
files, and in the Makefile "checks" target, which scans the whole
tree, alongside check-glossaries.

Suggested-by: Quentin Schulz <quentin.schulz@cherry.de>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Niko Mauno <niko.mauno@vaisala.com>
---
 .pre-commit-config.yaml               |   5 ++
 documentation/Makefile                |   1 +
 documentation/tools/check-confusables | 123 ++++++++++++++++++++++++++
 3 files changed, 129 insertions(+)
 create mode 100755 documentation/tools/check-confusables
diff mbox series

Patch

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index f2b73a481..876546f9a 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -6,3 +6,8 @@  repos:
         entry: ./documentation/tools/check-glossaries
         language: python
         pass_filenames: false
+      - id: check-confusables
+        name: Check for non-ASCII confusable characters
+        entry: ./documentation/tools/check-confusables
+        language: python
+        files: \.rst$
diff --git a/documentation/Makefile b/documentation/Makefile
index fe0574537..87a6f8a8b 100644
--- a/documentation/Makefile
+++ b/documentation/Makefile
@@ -37,6 +37,7 @@  clean:
 
 checks:
 	$(SOURCEDIR)/tools/check-glossaries --docs-dir $(SOURCEDIR)
+	$(SOURCEDIR)/tools/check-confusables --docs-dir $(SOURCEDIR)
 
 stylecheck:
 	vale sync
diff --git a/documentation/tools/check-confusables b/documentation/tools/check-confusables
new file mode 100755
index 000000000..359386e7c
--- /dev/null
+++ b/documentation/tools/check-confusables
@@ -0,0 +1,123 @@ 
+#!/usr/bin/env python3
+#
+# Check documentation sources for non-ASCII typographic characters that
+# should be plain ASCII.
+#
+# Copyright (c) Vaisala Oyj. All rights reserved.
+#
+# SPDX-License-Identifier: MIT
+#
+
+import argparse
+import sys
+
+from pathlib import Path
+
+
+def parse_arguments() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(
+        description="Check documentation sources for non-ASCII typographic "
+                    "characters that should be plain ASCII")
+
+    parser.add_argument("files",
+                        nargs="*",
+                        type=Path,
+                        help="Specific files to check; if none are given, "
+                             "all *.rst files under --docs-dir are scanned")
+
+    parser.add_argument("-d", "--docs-dir",
+                        type=Path,
+                        default=Path(__file__).resolve().parent.parent,
+                        help="Path to documentation/ directory in yocto-docs")
+
+    return parser.parse_args()
+
+
+# Map of "confusable" characters that are frequently introduced by editors,
+# word processors or copy-pasting, to their plain ASCII replacement. These
+# look almost identical to regular ASCII but break tooling, e.g. a curly
+# quote in a recipe example causes:
+#
+#   ERROR: ParseError ...: unparsed line: 'RDEPENDS:${PN} = “foo”'
+#
+# Only these characters are flagged; legitimate non-ASCII such as box-drawing
+# characters used in directory trees, accented letters in contributor names
+# and CJK characters are intentionally left alone.
+confusables = {
+    "\u2018": "'",      # LEFT SINGLE QUOTATION MARK
+    "\u2019": "'",      # RIGHT SINGLE QUOTATION MARK
+    "\u201c": '"',      # LEFT DOUBLE QUOTATION MARK
+    "\u201d": '"',      # RIGHT DOUBLE QUOTATION MARK
+    "\u2032": "'",      # PRIME
+    "\u2033": '"',      # DOUBLE PRIME
+    "\u2013": "-",      # EN DASH
+    "\u2014": "--",     # EM DASH
+    "\u2010": "-",      # HYPHEN
+    "\u2011": "-",      # NON-BREAKING HYPHEN
+    "\u2212": "-",      # MINUS SIGN
+    "\u00a0": " ",      # NO-BREAK SPACE
+    "\u202f": " ",      # NARROW NO-BREAK SPACE
+    "\u200b": "",       # ZERO WIDTH SPACE
+    "\ufeff": "",       # ZERO WIDTH NO-BREAK SPACE / BOM
+    "\u00ad": "",       # SOFT HYPHEN
+}
+
+NO_BREAK_SPACE = "\u00a0"
+
+
+def is_box_drawing(char: str) -> bool:
+    # Box Drawing Unicode block (U+2500..U+257F), used for the directory
+    # trees rendered in the manuals.
+    return "\u2500" <= char <= "\u257f"
+
+
+def check_file(path: Path, display: str) -> bool:
+    found = False
+
+    with open(path, "r", encoding="utf-8") as f:
+        for lineno, line in enumerate(f, start=1):
+            # The tree(1) command indents its directory listings with
+            # no-break spaces; such listings are embedded verbatim in the
+            # manuals. A no-break space is therefore tolerated on any line
+            # that also contains box-drawing characters (i.e. inside a
+            # rendered directory tree), but still flagged elsewhere.
+            in_tree = any(is_box_drawing(c) for c in line)
+            for col, char in enumerate(line, start=1):
+                if char not in confusables:
+                    continue
+                if char == NO_BREAK_SPACE and in_tree:
+                    continue
+                replacement = confusables[char]
+                hint = f"'{replacement}'" if replacement else "(remove)"
+                print(f"WARNING: {display}:{lineno}:{col}: non-ASCII "
+                      f"character U+{ord(char):04X} should be "
+                      f"replaced with {hint}")
+                found = True
+
+    return found
+
+
+def main():
+
+    args = parse_arguments()
+
+    # When invoked with explicit files (e.g. by pre-commit, which passes the
+    # staged filenames) only those are checked; otherwise the whole tree of
+    # *.rst files under --docs-dir is scanned (e.g. by "make checks").
+    if args.files:
+        targets = [(path, str(path)) for path in args.files]
+    else:
+        docs_dir = Path(args.docs_dir)
+        targets = [(path, str(path.relative_to(docs_dir)))
+                   for path in sorted(docs_dir.rglob("*.rst"))]
+
+    exit_code = 0
+    for path, display in targets:
+        if check_file(path, display):
+            exit_code = 1
+
+    sys.exit(exit_code)
+
+
+if __name__ == "__main__":
+    main()