Files
klubhaus-doorbell/libraries/FastLED/ci/compile-commands.py
2026-02-12 00:45:31 -08:00

291 lines
8.6 KiB
Python

#!/usr/bin/env python
"""
Compile Commands Generator
Generates a compile_commands.json for clangd/clang-tidy based on explicit
tools/flags configuration. See FEATURE.md for usage and rationale.
Key behaviors:
- Reads configuration from ci/build_commands.toml
- Enumerates library source files under src/ (respecting optional filters.exclude_dirs)
- Emits one entry per translation unit with accurate arguments/command fields
Notes:
- This script intentionally avoids project-unit-test driven flags to keep
IntelliSense consistent with the core FastLED library build.
"""
from __future__ import annotations
import _thread
import argparse
import json
import os
import subprocess
import sys
# Require Python 3.11+ environment for TOML parsing
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional, cast
# Require typeguard; no try-import fallbacks
from typeguard import typechecked
# Use the canonical BuildFlags/Compiler tooling
from ci.compiler.clang_compiler import commands_json
PROJECT_ROOT: Path = Path(__file__).resolve().parent.parent
# Default to bespoke config for compile-commands generation
DEFAULT_CONFIG_PATH: Path = PROJECT_ROOT / "ci" / "build_commands.toml"
@typechecked
@dataclass
class CliConfig:
build_flags_toml: Path
exclude_dirs: list[str]
@typechecked
@dataclass
class Args:
output: Path
clean: bool
verbose: bool
config: Path
exclude: list[str]
def parse_args(args: Optional[list[str]] = None) -> Args:
parser = argparse.ArgumentParser(
description=(
"Generate compile_commands.json for clangd/clang-tidy based on explicit build configuration."
)
)
parser.add_argument(
"--output",
dest="output",
metavar="PATH",
help="Output path for compile_commands.json (default: project root)",
)
parser.add_argument(
"--config",
dest="config",
metavar="PATH",
help=(
"Path to BuildFlags-style TOML (default: ci/build_example.toml). "
"Must be parseable by BuildFlags.parse()."
),
)
parser.add_argument(
"--clean",
dest="clean",
action="store_true",
help="Rebuild command set from scratch (ignored currently; reserved)",
)
parser.add_argument(
"--verbose",
dest="verbose",
action="store_true",
help="Print collected flags/files",
)
parser.add_argument(
"--exclude-dir",
dest="exclude",
action="append",
default=[],
help="Relative directory under project root to exclude (can be used multiple times)",
)
parsed = parser.parse_args(args)
output_path_str: Optional[str] = None
if hasattr(parsed, "output"):
output_path_str = parsed.output
if output_path_str is None or len(output_path_str) == 0:
# Default to project root compile_commands.json
output_path = PROJECT_ROOT / "compile_commands.json"
else:
output_path = Path(output_path_str).resolve()
clean_value: bool = False
if hasattr(parsed, "clean"):
clean_value = bool(parsed.clean)
verbose_value: bool = False
if hasattr(parsed, "verbose"):
verbose_value = bool(parsed.verbose)
# Config path
config_path: Path
if getattr(parsed, "config", None):
config_path = Path(parsed.config).resolve()
else:
config_path = DEFAULT_CONFIG_PATH
# Excluded directories
exclude_list: list[str] = list(getattr(parsed, "exclude", []) or [])
return Args(
output=output_path,
clean=clean_value,
verbose=verbose_value,
config=config_path,
exclude=exclude_list,
)
def _assert_config_exists(config_path: Path) -> None:
if not config_path.exists():
raise RuntimeError(
f"CRITICAL: Required BuildFlags TOML not found at {config_path}. "
f"Please provide a valid configuration parseable by BuildFlags.parse()."
)
def _create_cli_config(build_flags_toml: Path, exclude: list[str]) -> CliConfig:
return CliConfig(build_flags_toml=build_flags_toml, exclude_dirs=exclude)
def _collect_source_files(root: Path, exclude: list[str], verbose: bool) -> list[Path]:
source_files: list[Path] = []
# Compute excluded directories as absolute paths for quick prefix checks
excluded_dirs_abs: list[Path] = []
for ex in exclude:
p = (PROJECT_ROOT / ex).resolve()
excluded_dirs_abs.append(p)
src_root: Path = PROJECT_ROOT / "src"
if not src_root.exists():
raise RuntimeError(
f"CRITICAL: Expected source root at {src_root} not found. "
f"This path is required to enumerate translation units."
)
for dirpath, dirnames, filenames in os.walk(src_root):
current_dir = Path(dirpath).resolve()
# Apply directory exclusions
skip_dir: bool = False
for ex in excluded_dirs_abs:
try:
current_dir.relative_to(ex)
skip_dir = True
break
except ValueError:
# Not under this excluded path
pass
if skip_dir:
# Prune search by clearing dirnames to avoid descending further
del dirnames[:]
continue
for fname in filenames:
if fname.endswith(".cpp") or fname.endswith(".c"):
source_files.append(current_dir / fname)
if verbose:
print(f"Discovered {len(source_files)} source files under {src_root}")
if len(source_files) == 0:
raise RuntimeError(
"CRITICAL: No source files discovered under 'src/'. "
"Please verify the repository layout and filters.exclude_dirs configuration."
)
return source_files
# No per-file argument composition here; delegated to commands_json()
# This function intentionally removed to rely on canonical toolchain
# Emission handled by commands_json()
def main(entry_args: Optional[list[str]] = None) -> int:
args = parse_args(entry_args)
# Log startup context
print("[compile-commands] Starting generation")
print(f"[compile-commands] Project root: {PROJECT_ROOT}")
print(f"[compile-commands] Config (BuildFlags TOML): {args.config}")
print(f"[compile-commands] Output path: {args.output}")
if args.exclude:
print(f"[compile-commands] Excluding directories: {args.exclude}")
else:
print("[compile-commands] No excluded directories configured")
# Determine BuildFlags TOML path
_assert_config_exists(args.config)
# Collect source files
source_root = PROJECT_ROOT / "src"
print(f"[compile-commands] Source root: {source_root}")
sources = _collect_source_files(
source_root,
exclude=args.exclude,
verbose=args.verbose,
)
print(f"[compile-commands] Discovered {len(sources)} source files")
# Delegate to canonical commands_json() using BuildFlags toolchain
print("[compile-commands] Generating compile_commands.json entries...")
commands_json(
config_path=args.config,
include_root=source_root,
sources=sources,
output_json=args.output,
quick_build=True,
strict_mode=False,
)
# Post-generation verification
if not args.output.exists():
print(
f"CRITICAL: Generation completed but output file was not found at {args.output}"
)
return 2
try:
text = args.output.read_text(encoding="utf-8")
# Best-effort count of entries to report status
import json as _json
from typing import cast
entries = cast(list[dict[str, Any]], _json.loads(text))
num_entries = len(entries) if isinstance(entries, list) else 0
print(
f"[compile-commands] Wrote {num_entries} entries to {args.output} (size: {args.output.stat().st_size} bytes)"
)
except Exception as e: # noqa: BLE001
print(
f"WARNING: Output written to {args.output} but could not parse JSON for summary: {e}"
)
if args.verbose:
print("[compile-commands] Done.")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("Operation interrupted by user")
_thread.interrupt_main()
raise
except FileNotFoundError as e:
print(f"CRITICAL: Required file not found: {e}")
sys.exit(1)
except ValueError as e:
print(f"CRITICAL: Invalid configuration value: {e}")
sys.exit(1)
except Exception as e: # noqa: BLE001
print(f"CRITICAL: Unexpected error: {e}")
raise