master
John Lauer 0.9.279: Wiki #71 second cause, measured on ConfRoomROG with KiCad's own parser: one malformed symbol makes KiCad refus… bad5c5b 2mo ago
"""KiCad's OWN verdict on a symbol library, via `kicad-cli sym export svg`.

Why this module exists (wiki #71, 2026-09-03, measured on ConfRoomROG):
the bridge's structural reader (`adom_library`, `kicad_lint_library`) said a
five-symbol Adom.kicad_sym "parsed cleanly", while KiCad itself answered
"Unable to load library" and the Symbol Editor showed the Adom library EMPTY.
Three minimal test symbols carried `hide yes` as bare tokens inside
`(effects ...)`; our reader only balances parentheses, KiCad's parser does
not accept the tokens, and ONE bad symbol makes KiCad drop the WHOLE library.
The two well-formed symbols alone loaded fine.

So: our parser is not KiCad's parser. Anything that writes into a library
KiCad reads must ask KiCad first. `sym export svg` is the one kicad-cli
command that parses a .kicad_sym read-only (the `upgrade` command rewrites
the file) and prints one "Plotting symbol 'NAME' unit N" line per unit it
loaded, so it doubles as a symbol inventory from KiCad's point of view.
"""
from __future__ import annotations

import os
import re
import shutil
import subprocess
import tempfile
import time
from pathlib import Path

_PLOT_RE = re.compile(r"Plotting symbol '([^']+)' unit (\d+)")


def cli_exe(kicad_info: dict) -> str | None:
    cli = (kicad_info or {}).get("kicad_cli_exe")
    if cli and Path(cli).exists():
        return cli
    base = (kicad_info or {}).get("base_dir")
    if base:
        for cand in (Path(base) / "bin" / "kicad-cli.exe", Path(base) / "bin" / "kicad-cli"):
            if cand.exists():
                return str(cand)
    return None


def verdict_file(kicad_info: dict, lib_path: str | Path, timeout_s: float = 60.0) -> dict:
    """KiCad's verdict on an existing .kicad_sym.

    Returns {available, ok, symbols[], units, error, exitCode, durationMs}.
    available=False means kicad-cli was not found: no verdict either way, and
    callers must SAY that rather than treat it as a pass."""
    cli = cli_exe(kicad_info)
    if not cli:
        return {"available": False, "ok": None, "symbols": [], "units": 0,
                "error": "kicad-cli not found; KiCad's parser could not be asked"}
    lib_path = str(lib_path)
    if not Path(lib_path).exists():
        return {"available": True, "ok": False, "symbols": [], "units": 0,
                "error": f"file not found: {lib_path}"}
    out_dir = tempfile.mkdtemp(prefix="adom-kicad-parse-")
    t0 = time.monotonic()
    try:
        proc = subprocess.run([cli, "sym", "export", "svg", "-o", out_dir, lib_path],
                              capture_output=True, text=True, timeout=timeout_s,
                              creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
        text = (proc.stdout or "") + "\n" + (proc.stderr or "")
        names: list[str] = []
        units = 0
        for m in _PLOT_RE.finditer(text):
            units += 1
            if m.group(1) not in names:
                names.append(m.group(1))
        ok = proc.returncode == 0
        err = ""
        if not ok:
            lines = [ln.strip() for ln in text.splitlines() if ln.strip() and not _PLOT_RE.search(ln)]
            err = " | ".join(lines[:3])[:400] or f"kicad-cli exit {proc.returncode}"
        return {"available": True, "ok": ok, "symbols": names, "units": units,
                "error": err, "exitCode": proc.returncode,
                "durationMs": int((time.monotonic() - t0) * 1000)}
    except subprocess.TimeoutExpired:
        return {"available": True, "ok": False, "symbols": [], "units": 0,
                "error": f"kicad-cli did not answer within {timeout_s:.0f}s"}
    except Exception as e:  # pylint: disable=broad-except
        return {"available": True, "ok": False, "symbols": [], "units": 0,
                "error": f"{type(e).__name__}: {e}"[:300]}
    finally:
        shutil.rmtree(out_dir, ignore_errors=True)


def verdict_text(kicad_info: dict, lib_text: str, timeout_s: float = 60.0) -> dict:
    """Same verdict for library TEXT that has not been written anywhere yet:
    the gate an install runs BEFORE it touches the user's file."""
    fd, tmp = tempfile.mkstemp(suffix=".kicad_sym", prefix="adom-candidate-")
    os.close(fd)
    try:
        Path(tmp).write_text(lib_text, encoding="utf-8")
        return verdict_file(kicad_info, tmp, timeout_s)
    finally:
        try:
            os.unlink(tmp)
        except OSError:
            pass


def wrap_symbol_blocks(blocks: list[str]) -> str:
    return ('(kicad_symbol_lib (version 20231120) (generator "adom-parse-check")\n'
            + "\n".join(blocks) + "\n)\n")


def refused_symbols(kicad_info: dict, symbol_blocks: list[tuple[str, str]],
                    timeout_s: float = 60.0) -> dict:
    """Which of these (name, block) symbols does KiCad refuse ON THEIR OWN?

    One bad symbol hides a whole library, so this is how a repair finds the
    culprit(s): each block is wrapped as a one-symbol library and parsed alone.
    Returns {available, accepted[], refused{name: error}}."""
    cli = cli_exe(kicad_info)
    if not cli:
        return {"available": False, "accepted": [], "refused": {}}
    accepted, refused = [], {}
    for name, blk in symbol_blocks:
        v = verdict_text(kicad_info, wrap_symbol_blocks([blk]), timeout_s)
        if v.get("ok"):
            accepted.append(name)
        else:
            refused[name] = v.get("error") or "refused"
    return {"available": True, "accepted": accepted, "refused": refused}