From fc7bc2fef85557b7e1b911185c69e151864d560a Mon Sep 17 00:00:00 2001 From: Amr Gharbeia Date: Wed, 13 May 2026 12:02:52 -0400 Subject: [PATCH] check-parens: standalone paren balance checker for lisp blocks in org files Scans #+begin_src lisp ... #+end_src blocks, strips strings and comments, reports unbalanced parens per-block with line numbers. Detects unterminated blocks (no matching #+end_src). Zero dependencies (stdlib Python). Called from AGENTS.md step: projects/check-parens/check-parens org/file.org --- projects/AGENTS.md | 2 + projects/check-parens/README.org | 42 ++++++++ projects/check-parens/check-parens | 150 +++++++++++++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 projects/check-parens/README.org create mode 100755 projects/check-parens/check-parens diff --git a/projects/AGENTS.md b/projects/AGENTS.md index 0983c21..f9d8cc6 100644 --- a/projects/AGENTS.md +++ b/projects/AGENTS.md @@ -167,6 +167,8 @@ when the runtime itself cannot start. ## Rules +- After copying code from the REPL to the .org file, run ~../check-parens/check-parens ~ to verify all Lisp blocks have balanced parentheses. This catches mismatched parens before tangling: + ~projects/check-parens/check-parens org/channel-tui-view.org~ - .org is source of truth; .lisp is generated — never edit .lisp directly - Every code change starts with a contract and a failing test - Prove RED before writing implementation diff --git a/projects/check-parens/README.org b/projects/check-parens/README.org new file mode 100644 index 0000000..921cf3e --- /dev/null +++ b/projects/check-parens/README.org @@ -0,0 +1,42 @@ +#+TITLE: check-parens +#+FILETAGS: :tool:lisp:org: + +Standalone parentheseis checker for Lisp source blocks in Org files. + +Scans all ~#+begin_src lisp … #+end_src~ blocks in an Org file, strips +string and comment content, and reports unbalanced parentheses per block. + +== Usage + +#+begin_src shell + check-parens [ ...] + check-parens -v +#+end_src + +Exit 0 if all blocks are balanced and terminated, 1 otherwise. + +== Output + + ~file.org: OK~ — all blocks balanced + ~file.org: Block at line 27: +2 (missing 2 closes) — near …~ + ~file.org: Block at line 103: unterminated — no matching #+end_src~ + +The ~-v~ flag prints the full block content for each issue. + +== Integration + +Pre-commit hook: + +#+begin_src shell + cat > .git/hooks/pre-commit <<'HOOK' + #!/bin/sh + for f in $(git diff --cached --name-only --diff-filter=ACM | grep '\.org$'); do + projects/check-parens/check-parens "$f" || exit 1 + done + HOOK + chmod +x .git/hooks/pre-commit +#+end_src + +== Dependencies + +None (stdlib Python 3). diff --git a/projects/check-parens/check-parens b/projects/check-parens/check-parens new file mode 100755 index 0000000..a746a5c --- /dev/null +++ b/projects/check-parens/check-parens @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Check paren balance in #+begin_src lisp blocks of .org files. + +Usage: check-parens [ ...] + check-parens projects/**/*.org + +Exit 0 if all blocks balanced and terminated, 1 otherwise. +""" + +import sys +import re + + +def check_file(path, verbose): + lines = read_lines(path) + if lines is None: + return False + + blocks = extract_blocks(lines) + ok = True + + for block_start, block_lines in blocks: + # Check termination + if not is_terminated(block_lines): + print(f"{path}: Block starting at line {block_start} — no matching #+end_src") + ok = False + continue + + # Extract block body (between begin and end markers) + body = block_lines[1:-1] + if not body: + continue + + stripped = strip_strings_and_comments(body) + open_parens = stripped.count("(") + close_parens = stripped.count(")") + diff = open_parens - close_parens + + if diff != 0: + show = body[0][:60] + if diff > 0: + print(f"{path}: Block at line {block_start}: +{diff} (missing {diff} close{'s' if diff > 1 else ''}) — near {show!r}") + else: + print(f"{path}: Block at line {block_start}: {diff} (extra {-diff} close{'s' if -diff > 1 else ''}) — near {show!r}") + if verbose: + for l in body: + print(f" | {l}") + ok = False + + return ok + + +def read_lines(path): + try: + with open(path, encoding="utf-8") as f: + return f.readlines() + except FileNotFoundError: + print(f"{path}: file not found", file=sys.stderr) + return None + except Exception as e: + print(f"{path}: error reading file — {e}", file=sys.stderr) + return None + + +LISP_BEGIN = re.compile(r"#\+begin_src\s+lisp\b", re.IGNORECASE) +END_SRC = re.compile(r"#\+end_src\b", re.IGNORECASE) + + +def extract_blocks(lines): + blocks = [] + start = None + block_lines = [] + for i, line in enumerate(lines, start=1): + if start is None: + if LISP_BEGIN.match(line.lstrip()): + start = i + block_lines = [line.rstrip("\n")] + else: + block_lines.append(line.rstrip("\n")) + if END_SRC.match(line.lstrip()): + blocks.append((start, block_lines)) + start = None + block_lines = [] + + if start is not None: + blocks.append((start, block_lines)) + + return blocks + + +def is_terminated(block_lines): + return END_SRC.match(block_lines[-1].lstrip()) if block_lines else False + + +def strip_strings_and_comments(lines): + parens = [] + for line in lines: + i = 0 + while i < len(line): + c = line[i] + if c == '"': + i += 1 + while i < len(line): + ec = line[i] + if ec == '\\' and i + 1 < len(line): + i += 2 + elif ec == '"': + i += 1 + break + else: + i += 1 + elif c == ';': + break + elif c == '(': + parens.append(c) + i += 1 + elif c == ')': + parens.append(c) + i += 1 + else: + i += 1 + return "".join(parens) + + +def main(): + verbose = False + files = [] + for arg in sys.argv[1:]: + if arg == "-v" or arg == "--verbose": + verbose = True + elif arg.startswith("-"): + print(f"Usage: {sys.argv[0]} [-v] [...]", file=sys.stderr) + return 2 + else: + files.append(arg) + + if not files: + print(f"Usage: {sys.argv[0]} [-v] [...]", file=sys.stderr) + return 2 + + all_ok = True + for path in files: + if not check_file(path, verbose): + all_ok = False + + return 0 if all_ok else 1 + + +if __name__ == "__main__": + sys.exit(main())