#!/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