#!/usr/bin/env python3
"""async_check.py: heuristic scan of JS/TS files for promise and async/await hazards.

Usage: python3 async_check.py [--strict] FILE_OR_DIR [...]
Reads files, writes nothing, makes no network calls. Standard library only.
Exit 0 = no errors (and no warnings with --strict). Exit 1 = findings. Exit 2 = bad input.
It reads text, not a syntax tree: a hit inside a string or comment is a false positive.
"""
import os
import re
import sys

EXT = (".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts", ".vue", ".svelte")
SKIP = {"node_modules", ".git", "dist", "build", "coverage", ".next", ".nuxt"}
LOOP_RE = re.compile(r"^\s*(for\b(?!\s+await)|while\b|do\b)")
TRY_RE = re.compile(r"\btry\s*\{")
STMT_THEN = re.compile(r"^\s*(?!return\b|await\b|void\b|const\b|let\b|var\b|export\b|yield\b)[\w$.\[\]()'\"`]+.*\.then\(")
EMPTY_CATCH = [
    re.compile(r"\.catch\(\s*(\(\s*\w*\s*\)|\w*)\s*=>\s*\{\s*\}\s*\)"),
    re.compile(r"\.catch\(\s*function\s*\(\s*\w*\s*\)\s*\{\s*\}\s*\)"),
    re.compile(r"\bcatch\s*(\(\s*\w*\s*\))?\s*\{\s*\}"),
]


def strip(line):
    line = re.sub(r"//.*$", "", line)
    return re.sub(r"/\*.*?\*/", "", line)


def scan(path):
    out = []
    try:
        lines = open(path, encoding="utf-8", errors="replace").read().splitlines()
    except OSError as e:
        print(f"{path}: cannot read ({e})", file=sys.stderr)
        sys.exit(2)
    code = [strip(l) for l in lines]
    depth = 0
    loops = []  # brace depths at which a loop body opened
    tries = []
    for i, l in enumerate(code):
        n = i + 1
        if LOOP_RE.match(l) and l.rstrip().endswith("{"):
            loops.append(depth)
        if TRY_RE.search(l):
            tries.append(depth)
        if re.search(r"\.forEach\(\s*async\b", l):
            out.append((n, "error", "foreach-async", ".forEach(async ...) starts every callback and waits for none. Use for...of, or await Promise.all(x.map(...))"))
        if re.search(r"new\s+Promise\(\s*async\b", l):
            out.append((n, "error", "executor-async", "async Promise executor: a throw inside it is lost. Use an async function or a plain executor"))
        if re.search(r"\b(setInterval|setTimeout)\(\s*async\b", l) and not re.search(r"\btry\s*\{", " ".join(code[i:i + 8])):
            out.append((n, "warn", "timer-async", "async timer callback: rejection is unhandled and runs can overlap. Wrap the body in try/catch and guard re-entry"))
        if re.search(r"\.map\(\s*async\b", l) and re.match(r"^\s*[\w$.\[\]()]+\.map\(\s*async\b", l) and "Promise." not in l:
            out.append((n, "warn", "map-async-statement", "result of .map(async ...) is dropped: nothing waits and rejections float. Wrap in await Promise.all or Promise.allSettled"))
        if re.search(r"await\b", l) and loops and not re.search(r"for\s+await", l):
            out.append((n, "info", "await-in-loop", "await inside a loop runs one at a time. Fine if order or rate matters; use Promise.all or a limiter if the items are independent"))
        if re.search(r"Promise\.race\(", l) and re.search(r"setTimeout|timeout|sleep|delay", " ".join(code[i:i + 4]), re.I):
            out.append((n, "warn", "race-timeout", "Promise.race with a timer: the loser keeps running and the timer is never cleared. Pass AbortSignal.timeout to the operation (see abortcontroller-cancellation.md)"))
        if tries and re.match(r"^\s*return\s+(?!await\b)[\w$.]+\(", l):
            out.append((n, "info", "return-in-try", "return of a call inside try: if it returns a Promise, a rejection skips this catch. Add await if the catch must handle it"))
        for rx in EMPTY_CATCH:
            if rx.search(l):
                out.append((n, "warn", "empty-catch", "empty catch swallows the error. Log it, rethrow it, or comment why it is safe to ignore"))
                break
        if STMT_THEN.match(l):
            block = " ".join(code[i:i + 7])
            end = block.find(";")
            block = block if end < 0 else block[: end + 1]
            if not re.search(r"\.catch\(|\.then\([^()]*,\s*[^()]+\)|\.finally\(.*\.catch", block):
                out.append((n, "warn", "floating-then", ".then() with no .catch(): a rejection is unhandled. Add .catch, await it, or return it"))
        if re.search(r"resolve\(\s*await\b|\.then\(\s*resolve\b", l):
            out.append((n, "info", "explicit-construction", "new Promise wrapping something already a Promise. Return that Promise directly"))
        if re.search(r"Promise\.all\(.*\)\s*;?\s*$", l) and not re.search(r"await|return|=|void|\.then|\.catch", l.split("Promise.all")[0]):
            out.append((n, "warn", "all-not-awaited", "Promise.all result is dropped. Await it or return it"))
        depth += l.count("{") - l.count("}")
        while loops and depth <= loops[-1]:
            loops.pop()
        while tries and depth <= tries[-1]:
            tries.pop()
    return out


def walk(args):
    for a in args:
        if os.path.isdir(a):
            for root, dirs, files in os.walk(a):
                dirs[:] = [d for d in dirs if d not in SKIP]
                for f in sorted(files):
                    if f.endswith(EXT):
                        yield os.path.join(root, f)
        elif os.path.isfile(a):
            yield a
        else:
            print(f"{a}: not found", file=sys.stderr)
            sys.exit(2)


def main():
    argv = sys.argv[1:]
    strict = "--strict" in argv
    argv = [a for a in argv if a != "--strict"]
    if not argv:
        print(__doc__)
        sys.exit(2)
    rows, nfiles = [], 0
    for p in walk(argv):
        nfiles += 1
        rows += [(p,) + r for r in scan(p)]
    c = {"error": 0, "warn": 0, "info": 0}
    for r in rows:
        c[r[2]] += 1
    print(f"{c['error']} errors, {c['warn']} warnings, {c['info']} info, {nfiles} files")
    for p, n, lvl, code, msg in sorted(rows, key=lambda r: (r[0], r[1])):
        print(f"{p}:{n} {lvl:5} {code:20} {msg}")
    sys.exit(1 if c["error"] or (strict and c["warn"]) else 0)


if __name__ == "__main__":
    main()
