#!/usr/bin/env python3
"""Enforce the two akerror control-flow rules whose failure mode is silent.

AGENTS.md states both, and both have already cost this library a leak:

  1. Never use a ``*_RETURN`` macro inside an ``ATTEMPT`` block. It returns past
     ``CLEANUP``, so every release, ``fclose`` and free in ``CLEANUP`` is
     skipped. ``akgl_get_json_tilemap_property`` leaked two pooled strings on
     every *successful* lookup this way.

  2. Never ``return`` from inside a ``HANDLE`` block. ``FINISH`` is what carries
     ``RELEASE_ERROR``, so leaving early never gives the handled context back to
     ``AKERR_ARRAY_ERROR``. That cost ``akgl_path_relative`` one slot per call
     and killed the process on the 129th.

Neither produces a compiler diagnostic, and neither shows up in a passing test
run until the pool it drains is empty. Hence a linter.

Usage: check_error_protocol.py <file-or-directory> [...]

Exits 0 when clean, 1 on a violation.
"""

import os
import re
import sys

# A ``*_RETURN`` macro invocation: SUCCEED_RETURN, FAIL_RETURN, FAIL_ZERO_RETURN,
# FAIL_NONZERO_RETURN, and anything later shaped like them.
RETURN_MACRO = re.compile(r"\b[A-Z][A-Z0-9_]*_RETURN\s*\(")
BARE_RETURN = re.compile(r"\breturn\b")
ATTEMPT_OPEN = re.compile(r"\bATTEMPT\s*\{")
CLEANUP_OPEN = re.compile(r"\}\s*CLEANUP\s*\{")
HANDLE_OPEN = re.compile(r"\}\s*HANDLE(?:_GROUP)?\s*\(")
FINISH_ANY = re.compile(r"\}\s*FINISH(?:_NORETURN)?\s*\(")


def strip_comments(text):
    """Blank out comments, keeping line numbering intact.

    Comments are removed rather than skipped because several of these keywords
    appear in prose -- "which should never return AKERR_KEY" is a sentence, not
    a violation.
    """
    out = []
    i = 0
    n = len(text)
    while i < n:
        ch = text[i]
        if ch == '"' or ch == "'":
            quote = ch
            out.append(ch)
            i += 1
            while i < n:
                if text[i] == "\\" and (i + 1) < n:
                    out.append("  ")
                    i += 2
                    continue
                out.append(text[i])
                if text[i] == quote:
                    i += 1
                    break
                i += 1
            continue
        if text.startswith("/*", i):
            j = text.find("*/", i + 2)
            j = n if j == -1 else (j + 2)
            out.append("".join(c if c == "\n" else " " for c in text[i:j]))
            i = j
            continue
        if text.startswith("//", i):
            j = text.find("\n", i)
            j = n if j == -1 else j
            out.append(" " * (j - i))
            i = j
            continue
        out.append(ch)
        i += 1
    return "".join(out)


def check(path):
    findings = []
    lines = strip_comments(open(path, encoding="utf-8", errors="replace").read()).split("\n")
    in_attempt = False
    in_handle = False

    for number, line in enumerate(lines, 1):
        if ATTEMPT_OPEN.search(line):
            in_attempt = True
            continue
        if CLEANUP_OPEN.search(line):
            in_attempt = False
        if HANDLE_OPEN.search(line):
            in_handle = True
            continue
        if FINISH_ANY.search(line):
            in_handle = False
            continue

        if in_attempt and RETURN_MACRO.search(line):
            findings.append(
                (number, "a *_RETURN macro inside an ATTEMPT block returns past CLEANUP; "
                         "use the matching *_BREAK variant"))
        if in_handle and BARE_RETURN.search(line):
            findings.append(
                (number, "returning from inside a HANDLE block skips the RELEASE_ERROR that "
                         "FINISH carries; set a flag and act on it after FINISH"))
    return findings


def sources(targets):
    for target in targets:
        if os.path.isdir(target):
            for root, _dirs, files in os.walk(target):
                for name in sorted(files):
                    if name.endswith(".c") and not name.endswith("~"):
                        yield os.path.join(root, name)
        else:
            yield target


def main(argv):
    if len(argv) < 2:
        print(__doc__, file=sys.stderr)
        return 1

    total = 0
    for path in sources(argv[1:]):
        for number, message in check(path):
            print("%s:%d: %s" % (path, number, message), file=sys.stderr)
            total += 1

    if total:
        print("", file=sys.stderr)
        print("check_error_protocol: %d violation(s). See the Error-Handling Protocol "
              "section of AGENTS.md." % total, file=sys.stderr)
        return 1

    print("check_error_protocol: clean")
    return 0


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