"""Isolated cmake / ctest subprocess helpers. Each track gets its own worktree + build directory, so workers never share a build state and async concurrency is safe. """ from __future__ import annotations import asyncio import json import re import subprocess from pathlib import Path from typing import Tuple # --------------------------------------------------------------------------- # Type alias # --------------------------------------------------------------------------- RunResult = Tuple[bool, str, str] # (success, stdout, stderr) # --------------------------------------------------------------------------- # Low-level sync helpers (run in thread-pool via run_in_executor) # --------------------------------------------------------------------------- def _run(cmd: list[str], cwd: Path | None = None) -> Tuple[int, str, str]: result = subprocess.run( cmd, capture_output=True, text=True, cwd=str(cwd) if cwd else None, ) return result.returncode, result.stdout, result.stderr # --------------------------------------------------------------------------- # Async wrappers # --------------------------------------------------------------------------- async def cmake_configure(source_dir: Path, build_dir: Path) -> RunResult: """Run cmake configure, enabling compile_commands.json export.""" build_dir.mkdir(parents=True, exist_ok=True) loop = asyncio.get_running_loop() rc, out, err = await loop.run_in_executor( None, lambda: _run( [ "cmake", "-B", str(build_dir), str(source_dir), "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", "-DCMAKE_BUILD_TYPE=Debug", ] ), ) return rc == 0, out, err async def cmake_build(build_dir: Path, jobs: int = 4) -> RunResult: """Full (incremental) build of the tree in *build_dir*.""" loop = asyncio.get_running_loop() rc, out, err = await loop.run_in_executor( None, lambda: _run( ["cmake", "--build", str(build_dir), "--", f"-j{jobs}"] ), ) return rc == 0, out, err async def syntax_check_file(file_path: Path, build_dir: Path) -> RunResult: """Fast per-file syntax check using the compile command from compile_commands.json. Falls back to a plain clang++ call when the JSON is not available. """ loop = asyncio.get_running_loop() cmd = _compile_cmd_for_file(file_path, build_dir) if cmd is None: # Fallback: bare syntax-only compile with C++20 cmd = [ "clang++", "-std=c++20", "-fsyntax-only", "-w", # suppress all warnings for the fast check str(file_path), ] rc, out, err = await loop.run_in_executor(None, lambda: _run(cmd)) return rc == 0, out, err async def run_ctest( build_dir: Path, test_filter: str | None = None, ) -> RunResult: """Execute CTest in *build_dir*. Pass *test_filter* to run only a subset (regex forwarded to ``-R``). """ cmd = ["ctest", "--test-dir", str(build_dir), "--output-on-failure"] if test_filter: cmd += ["-R", test_filter] loop = asyncio.get_running_loop() rc, out, err = await loop.run_in_executor(None, lambda: _run(cmd)) return rc == 0, out, err # --------------------------------------------------------------------------- # Warning parsing # --------------------------------------------------------------------------- _WARNING_RE = re.compile(r":\s*warning\s*:", re.IGNORECASE) def parse_warnings(text: str) -> list[str]: """Return all lines that contain a compiler/linker warning.""" return [ln.strip() for ln in text.splitlines() if _WARNING_RE.search(ln)] # --------------------------------------------------------------------------- # compile_commands.json helpers # --------------------------------------------------------------------------- def _load_compile_commands(build_dir: Path) -> dict[str, dict]: """Return {absolute_file_path: entry} from compile_commands.json.""" cc_path = build_dir / "compile_commands.json" if not cc_path.exists(): return {} try: with cc_path.open() as fh: entries = json.load(fh) return {e["file"]: e for e in entries if "file" in e} except Exception: return {} def _compile_cmd_for_file( file_path: Path, build_dir: Path, ) -> list[str] | None: """Build a ``-fsyntax-only`` command for *file_path* from ``compile_commands.json``. Returns None if no entry is found. """ db = _load_compile_commands(build_dir) if not db: return None # Try both the resolved absolute path and as-is string. key = str(file_path.resolve()) entry = db.get(key) or db.get(str(file_path)) if entry is None: return None raw_cmd: str = entry.get("command", "") if not raw_cmd: # Some generators use "arguments" (list) instead of "command" (string). args: list[str] = entry.get("arguments", []) if not args: return None raw_cmd = " ".join(args) parts = raw_cmd.split() result: list[str] = [] skip_next = False for part in parts: if skip_next: skip_next = False continue if part == "-c": result.append("-fsyntax-only") elif part == "-o": skip_next = True # drop the output-file argument else: result.append(part) # Ensure the entry's source file is replaced with the migrated file path. orig_src = entry.get("file", "") if orig_src and result and result[-1] == orig_src: result[-1] = str(file_path) return result