#!/usr/bin/env python3 """Report real-world parse health as the share of bytes inside ERROR nodes. A file either having or not having an ERROR node is too blunt to steer by: a one-token slip and a file the parser could not enter at all both score zero. Byte coverage separates them, and it tracks what a user actually sees in an editor, since an ERROR node is the span that loses its highlighting. Usage: scripts/parse-health.py [dir-or-file ...] Defaults to additional_examples/ and errors/, which are local scratch and not part of the published grammar. Run it before and after any grammar change. """ import os import re import statistics import subprocess import sys # Nested ERROR nodes are common, so spans are unioned rather than summed. ERROR_SPAN = re.compile(r"\(ERROR \[(\d+), (\d+)\] - \[(\d+), (\d+)\]") MISSING = re.compile(r"\(MISSING\b") DEFAULT_PATHS = ["additional_examples", "errors"] def line_offsets(text): """Byte offset of the start of each line, plus one past the end.""" offsets = [0] for line in text.split("\n"): offsets.append(offsets[-1] + len(line.encode()) + 1) return offsets def measure(path): """Return (size, error_bytes, missing_count), or None if the parse failed.""" source = open(path, encoding="utf-8", errors="replace").read() size = len(source.encode()) if size == 0: return None result = subprocess.run( ["tree-sitter", "parse", path], capture_output=True, text=True ) tree = result.stdout if not tree: return None offsets = line_offsets(source) covered = set() for row1, col1, row2, col2 in ERROR_SPAN.findall(tree): start = offsets[int(row1)] + int(col1) end = min(offsets[int(row2)] + int(col2), offsets[-1]) covered.update(range(start, end)) return size, len(covered), len(MISSING.findall(tree)) def collect(paths): for path in paths: if os.path.isfile(path): yield path for root, _, names in os.walk(path): for name in sorted(names): if name.endswith(".applescript"): yield os.path.join(root, name) def main(): paths = sys.argv[1:] or [p for p in DEFAULT_PATHS if os.path.isdir(p)] if not paths: sys.exit(f"no corpus found; looked for {', '.join(DEFAULT_PATHS)}") # The same script appears in both default directories, so measure each # basename once. seen, rows = set(), [] for path in collect(paths): name = os.path.basename(path) if name in seen: continue seen.add(name) result = measure(path) if result is None: continue size, error_bytes, missing = result rows.append((100 * error_bytes / size, path, size, missing)) if not rows: sys.exit("no files measured") total = sum(size for _, _, size, _ in rows) total_error = sum(pct * size / 100 for pct, _, size, _ in rows) bad = sorted((r for r in rows if r[0] > 0), reverse=True) print(f"files {len(rows)}") print(f"clean {len(rows) - len(bad)}") print(f"error bytes {100 * total_error / total:.1f}% of {total} bytes") if bad: median = statistics.median(pct for pct, _, _, _ in bad) print(f"median, files w/err {median:.1f}%") print(f"under 5% of file {sum(1 for pct, _, _, _ in bad if pct < 5)}/{len(bad)}") # Total-loss files dominate the byte share and hide how the rest are # doing, so report the split. A file above 25% has usually lost a whole # block to one unsupported construct rather than degraded gradually. lost = [r for r in bad if r[0] > 25] if lost: lost_bytes = sum(pct * size / 100 for pct, _, size, _ in lost) rest = total_error - lost_bytes print( f"of which {len(lost)} total losses " f"{100 * lost_bytes / total_error:.0f}% of all ERROR bytes" ) print(f"the other {len(rows) - len(lost):<10d} {100 * rest / total:.1f}% of bytes") print("\nworst:") for pct, path, _, missing in bad[:10]: note = f" ({missing} MISSING)" if missing else "" print(f" {pct:5.1f}% {os.path.basename(path)}{note}") if __name__ == "__main__": main()