aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/cj-remove-block.py
blob: 71c7b3d29d5c600897c234c8a6170ddf20fc45ec (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
#!/usr/bin/env python3
"""cj-remove-block — Remove a cj annotation block from an org file by line range.

Idempotently deletes lines [start, end] (1-indexed, inclusive) from the file,
but only after validating that those lines actually look like a cj annotation
(either a `#+begin_src cj: ... #+end_src` fence pair or a single `cj:` line).
The validation step is the point — it protects against accidentally trimming
the wrong block when line numbers drift between a `cj-scan` call and a remove call.

Usage:
    cj-remove-block --file FILE.org --start N --end M

Companion to the /respond-to-cj-comments skill and to cj-scan.py.
"""

from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path

SRC_OPEN_RE = re.compile(r"^\s*#\+begin_src\s+cj:", re.IGNORECASE)
SRC_CLOSE_RE = re.compile(r"^\s*#\+end_src\s*$", re.IGNORECASE)
LEGACY_CJ_RE = re.compile(r"^\s*cj:\s")


def looks_like_cj_range(lines: list[str], start: int, end: int) -> tuple[bool, str]:
    """Return (ok, reason). Validates start..end (1-indexed, inclusive) is a cj range."""
    if end < start:
        return False, f"Range end ({end}) is before start ({start})"
    if start < 1 or end > len(lines):
        return False, (
            f"Range {start}..{end} is out of bounds for a file with {len(lines)} lines"
        )

    first = lines[start - 1]
    last = lines[end - 1]

    if start == end:
        # Single-line removal must look like legacy inline.
        if LEGACY_CJ_RE.match(first):
            return True, ""
        return False, (
            f"Line {start} does not look like a legacy inline cj: line "
            f"(got: {first[:60]!r})"
        )

    # Multi-line removal must look like a source-block fence pair.
    if not SRC_OPEN_RE.match(first):
        return False, (
            f"Line {start} does not look like a #+begin_src cj: opening fence "
            f"(got: {first[:60]!r})"
        )
    if not SRC_CLOSE_RE.match(last):
        return False, (
            f"Line {end} does not look like a #+end_src closing fence "
            f"(got: {last[:60]!r})"
        )
    return True, ""


def remove_range(path: Path, start: int, end: int) -> None:
    """Read path, validate range looks like cj content, remove the range, write back."""
    text = path.read_text()
    had_trailing_newline = text.endswith("\n")
    lines = text.splitlines(keepends=False)

    ok, reason = looks_like_cj_range(lines, start, end)
    if not ok:
        print(f"cj-remove-block: refusing to remove — {reason}", file=sys.stderr)
        sys.exit(1)

    new_lines = lines[: start - 1] + lines[end:]
    new_text = "\n".join(new_lines)
    if new_lines and had_trailing_newline:
        new_text += "\n"
    elif not new_lines and had_trailing_newline:
        new_text = ""
    path.write_text(new_text)


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Remove a cj annotation block from an org file by line range.",
    )
    parser.add_argument("--file", required=True, type=Path, help="Path to the org file.")
    parser.add_argument("--start", required=True, type=int, help="Start line (1-indexed, inclusive).")
    parser.add_argument("--end", required=True, type=int, help="End line (1-indexed, inclusive).")
    args = parser.parse_args()

    if not args.file.is_file():
        print(f"Not a file: {args.file}", file=sys.stderr)
        return 2

    remove_range(args.file, args.start, args.end)
    return 0


if __name__ == "__main__":
    sys.exit(main())