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
|
## 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
|
- .org is source of truth; .lisp is generated — never edit .lisp directly
|
||||||
- Every code change starts with a contract and a failing test
|
- Every code change starts with a contract and a failing test
|
||||||
- Prove RED before writing implementation
|
- 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