186 lines
5.7 KiB
Python
186 lines
5.7 KiB
Python
"""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
|