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
This commit is contained in:
@@ -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 <file.org>~ 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
|
||||
|
||||
42
projects/check-parens/README.org
Normal file
42
projects/check-parens/README.org
Normal file
@@ -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 <file.org> [<file.org> ...]
|
||||
check-parens -v <file.org>
|
||||
#+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).
|
||||
150
projects/check-parens/check-parens
Executable file
150
projects/check-parens/check-parens
Executable file
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check paren balance in #+begin_src lisp blocks of .org files.
|
||||
|
||||
Usage: check-parens <file.org> [<file.org> ...]
|
||||
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.org> [...]", file=sys.stderr)
|
||||
return 2
|
||||
else:
|
||||
files.append(arg)
|
||||
|
||||
if not files:
|
||||
print(f"Usage: {sys.argv[0]} [-v] <file.org> [...]", 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())
|
||||
Reference in New Issue
Block a user