diff --git a/SLS_C/build_system/config.py b/SLS_C/build_system/config.py new file mode 100644 index 0000000..d7eb122 --- /dev/null +++ b/SLS_C/build_system/config.py @@ -0,0 +1,293 @@ +""" +Build system configuration. + +This module centralizes all build configuration including paths, +compiler flags, and build settings. +""" + +import os +from pathlib import Path +from typing import List, Dict, Optional + + +class Config: + """ + Centralized build configuration. + + All paths, flags, and settings are defined here for easy modification. + """ + + def __init__(self, project_root: Optional[Path] = None): + """ + Initialize build configuration. + + Args: + project_root: Project root directory (default: current directory) + """ + # Project structure + self.project_root = project_root or Path.cwd() + self.src_dir = self.project_root / "src" + self.test_dir = self.project_root / "tests" + self.include_dir = self.project_root / "include" + self.obj_dir = self.project_root / "obj" + self.bin_dir = self.project_root / "bin" + self.build_dir = self.project_root / "build" + + # Target executables + self.main_target = self.bin_dir / "sls" + self.test_target = self.bin_dir / "sls_tests" + + # Build settings + self.parallel_jobs = os.cpu_count() or 1 + self.verbose = False + self.debug = True + + # Git integration + self.git_hash = self._get_git_hash() + + # Compiler flags by category + self._init_compiler_flags() + + # Platform-specific settings + self._init_platform_settings() + + def _get_git_hash(self) -> str: + """Get git commit hash for version info.""" + from .utils import git_commit_hash + return git_commit_hash() + + def _init_compiler_flags(self): + """Initialize compiler flag sets.""" + + # Common GCC/Clang flags + self.gcc_common_flags = [ + "-std=c99", + "-Wall", + "-Wextra", + "-Werror", + "-g", # Debug symbols + ] + + self.gcc_test_flags = [ + "-std=c99", + "-Wall", + "-Wextra", + "-Wno-unused-function", + "-Werror", + "-g", + "-O0", # No optimization for tests + ] + + # MSVC flags + self.msvc_common_flags = [ + "/std:c11", + "/W4", + "/WX", + "/Zi", + ] + + self.msvc_test_flags = [ + "/std:c11", + "/W4", + "/WX", + "/Zi", + "/Od", # No optimization + ] + + # ARM GCC flags for RP2040 + self.arm_gcc_flags = [ + "-mcpu=cortex-m0plus", + "-mthumb", + ] + + def _init_platform_settings(self): + """Initialize platform-specific settings.""" + + # Pico SDK settings + self.pico_sdk_path = Path(os.environ.get( + "PICO_SDK_PATH", + Path.home() / "pico" / "pico-sdk" + )) + self.pico_build_dir = self.project_root / "build_pico" + self.pico_toolchain_file = self.project_root / "pico_arm_gcc_toolchain.cmake" + + # macOS settings + self.macos_min_version = "10.13" + + def get_defines(self, extra: Optional[Dict] = None) -> Dict: + """ + Get preprocessor defines. + + Args: + extra: Additional defines to include + + Returns: + Dictionary of defines + """ + defines = { + "GIT_COMMIT_HASH": self.git_hash, + } + + if extra: + defines.update(extra) + + return defines + + def get_includes(self, extra: Optional[List[Path]] = None) -> List[Path]: + """ + Get include directories. + + Args: + extra: Additional include directories + + Returns: + List of include directory paths + """ + includes = [self.include_dir] + + if extra: + includes.extend(extra) + + return includes + + def get_libraries(self, target: str = "linux") -> List[str]: + """ + Get libraries to link based on target. + + Args: + target: Target platform + + Returns: + List of library names + """ + if target in ("linux", "macos"): + return ["m"] # Math library + elif target == "windows": + return [] # No special libraries needed + elif target == "rp2040": + return [] # Handled by Pico SDK + else: + return [] + + def get_source_files(self, exclude: Optional[List[str]] = None) -> List[Path]: + """ + Get list of source files. + + Args: + exclude: List of filenames to exclude + + Returns: + List of source file paths + """ + from .utils import find_sources + + exclude = exclude or [] + return find_sources(self.src_dir, "*.c", exclude) + + def get_test_files(self) -> List[Path]: + """ + Get list of test source files. + + Returns: + List of test file paths + """ + from .utils import find_sources + return find_sources(self.test_dir, "*.c") + + def get_main_sources(self) -> List[Path]: + """ + Get main program source files (excludes main.c and pico_main.c). + + Returns: + List of source files for linking with tests + """ + return self.get_source_files(exclude=["main.c", "pico_main.c"]) + + def get_rp2040_sources(self) -> List[Path]: + """ + Get source files for RP2040 build. + + Returns: + List of source files (excludes main.c, repl.c, file.c, test files) + """ + sources = self.get_source_files( + exclude=["main.c", "repl.c", "file.c", "test"] + ) + + # Add pico_main.c if it exists + pico_main = self.src_dir / "pico_main.c" + if pico_main.exists(): + sources.append(pico_main) + + return sources + + def set_verbose(self, verbose: bool = True): + """ + Enable/disable verbose output. + + Args: + verbose: Whether to enable verbose output + """ + self.verbose = verbose + return self + + def set_debug(self, debug: bool = True): + """ + Enable/disable debug build. + + Args: + debug: Whether to build with debug symbols + """ + self.debug = debug + return self + + def set_parallel_jobs(self, jobs: int): + """ + Set number of parallel build jobs. + + Args: + jobs: Number of parallel jobs (0 = auto-detect) + """ + if jobs <= 0: + self.parallel_jobs = os.cpu_count() or 1 + else: + self.parallel_jobs = jobs + return self + + def __repr__(self) -> str: + """String representation of config.""" + return ( + f"Config(\n" + f" project_root={self.project_root}\n" + f" src_dir={self.src_dir}\n" + f" bin_dir={self.bin_dir}\n" + f" git_hash={self.git_hash}\n" + f" parallel_jobs={self.parallel_jobs}\n" + f")" + ) + + +# Global configuration instance +_config = None + + +def get_config(project_root: Optional[Path] = None) -> Config: + """ + Get the global configuration instance. + + Args: + project_root: Project root directory (only used on first call) + + Returns: + Config instance + """ + global _config + if _config is None: + _config = Config(project_root) + return _config + + +def reset_config(): + """Reset the global configuration (mainly for testing).""" + global _config + _config = None diff --git a/SLS_C/build_system/platform.py b/SLS_C/build_system/platform.py new file mode 100644 index 0000000..1178d36 --- /dev/null +++ b/SLS_C/build_system/platform.py @@ -0,0 +1,241 @@ +""" +Platform detection and information module. + +This module provides utilities for detecting the current platform +and getting platform-specific information. +""" + +import platform +import sys +from typing import Optional, Tuple + + +class Platform: + """Platform information and detection.""" + + # Supported platforms + LINUX = "linux" + WINDOWS = "windows" + MACOS = "macos" + RP2040 = "rp2040" + + # Platform aliases + ALIASES = { + "darwin": MACOS, + "win32": WINDOWS, + "cygwin": WINDOWS, + "msys": WINDOWS, + "pico": RP2040, + } + + def __init__(self): + """Initialize platform detection.""" + self._system = None + self._machine = None + self._detected = None + + @property + def system(self) -> str: + """Get the system name (Linux, Windows, Darwin, etc.).""" + if self._system is None: + self._system = platform.system() + return self._system + + @property + def machine(self) -> str: + """Get the machine architecture (x86_64, arm, etc.).""" + if self._machine is None: + self._machine = platform.machine() + return self._machine + + def detect(self) -> str: + """ + Detect the current platform. + + Returns: + Platform string ('linux', 'windows', 'macos') + """ + if self._detected is not None: + return self._detected + + system = self.system.lower() + + # Check aliases + for alias, platform_name in self.ALIASES.items(): + if alias in system: + self._detected = platform_name + return self._detected + + # Direct mapping + if system == "linux": + self._detected = self.LINUX + elif system == "darwin": + self._detected = self.MACOS + elif system == "windows": + self._detected = self.WINDOWS + else: + # Unknown platform, default to linux + self._detected = self.LINUX + + return self._detected + + def is_linux(self) -> bool: + """Check if current platform is Linux.""" + return self.detect() == self.LINUX + + def is_windows(self) -> bool: + """Check if current platform is Windows.""" + return self.detect() == self.WINDOWS + + def is_macos(self) -> bool: + """Check if current platform is macOS.""" + return self.detect() == self.MACOS + + def is_64bit(self) -> bool: + """Check if running on 64-bit architecture.""" + return sys.maxsize > 2**32 + + def get_cpu_count(self) -> int: + """Get number of CPU cores.""" + import os + return os.cpu_count() or 1 + + def get_exe_extension(self) -> str: + """ + Get the executable file extension for this platform. + + Returns: + '.exe' on Windows, '' on Unix-like systems + """ + return ".exe" if self.is_windows() else "" + + def get_shared_lib_extension(self) -> str: + """ + Get the shared library extension for this platform. + + Returns: + '.dll' on Windows, '.dylib' on macOS, '.so' on Linux + """ + if self.is_windows(): + return ".dll" + elif self.is_macos(): + return ".dylib" + else: + return ".so" + + def normalize_platform_name(self, name: str) -> str: + """ + Normalize a platform name to canonical form. + + Args: + name: Platform name (possibly an alias) + + Returns: + Canonical platform name + """ + name = name.lower() + + # Check if it's already canonical + if name in [self.LINUX, self.WINDOWS, self.MACOS, self.RP2040]: + return name + + # Check aliases + for alias, platform_name in self.ALIASES.items(): + if alias in name: + return platform_name + + # Return as-is if unknown + return name + + def get_platform_info(self) -> dict: + """ + Get detailed platform information. + + Returns: + Dictionary with platform details + """ + return { + 'platform': self.detect(), + 'system': self.system, + 'machine': self.machine, + 'architecture': platform.architecture()[0], + 'python_version': platform.python_version(), + 'is_64bit': self.is_64bit(), + 'cpu_count': self.get_cpu_count(), + } + + def __str__(self) -> str: + """String representation of platform.""" + return self.detect() + + def __repr__(self) -> str: + """Detailed representation.""" + return f"Platform(system='{self.system}', detected='{self.detect()}')" + + +# Global platform instance +_platform = Platform() + + +def detect_platform() -> str: + """ + Detect the current platform. + + Returns: + Platform string ('linux', 'windows', 'macos') + """ + return _platform.detect() + + +def get_platform() -> Platform: + """ + Get the global Platform instance. + + Returns: + Platform instance + """ + return _platform + + +def is_linux() -> bool: + """Check if current platform is Linux.""" + return _platform.is_linux() + + +def is_windows() -> bool: + """Check if current platform is Windows.""" + return _platform.is_windows() + + +def is_macos() -> bool: + """Check if current platform is macOS.""" + return _platform.is_macos() + + +def normalize_target(target: Optional[str]) -> str: + """ + Normalize a target platform name. + + Args: + target: Target name or 'self' for current platform + + Returns: + Normalized platform name + """ + if target is None or target.lower() == "self": + return detect_platform() + + return _platform.normalize_platform_name(target) + + +def print_platform_info(): + """Print detailed platform information.""" + info = _platform.get_platform_info() + print("Platform Information:") + print(f" Platform: {info['platform']}") + print(f" System: {info['system']}") + print(f" Machine: {info['machine']}") + print(f" Architecture: {info['architecture']}") + print(f" Python: {info['python_version']}") + print(f" 64-bit: {info['is_64bit']}") + print(f" CPU Cores: {info['cpu_count']}") diff --git a/SLS_C/build_system/targets/base.py b/SLS_C/build_system/targets/base.py new file mode 100644 index 0000000..a8132a7 --- /dev/null +++ b/SLS_C/build_system/targets/base.py @@ -0,0 +1,327 @@ +""" +Abstract base class for build targets. + +This module defines the interface that all build target implementations +must follow. A target represents a platform/architecture combination +(e.g., Linux, Windows, RP2040) and knows how to build for that platform. +""" + +from abc import ABC, abstractmethod +from pathlib import Path +from typing import List, Optional +from ..config import Config +from ..compilers.base import Compiler + + +class Target(ABC): + """ + Abstract base class for all build targets. + + A target encapsulates the knowledge of how to build for a specific + platform, including which compiler to use, what flags to set, and + what build steps to execute. + """ + + def __init__(self, config: Config, compiler: Optional[Compiler] = None): + """ + Initialize the target. + + Args: + config: Build configuration + compiler: Compiler to use (if None, target will auto-detect) + """ + self.config = config + self.compiler = compiler or self._get_default_compiler() + self._compiled_objects = [] + + @abstractmethod + def _get_default_compiler(self) -> Compiler: + """ + Get the default compiler for this target. + + Returns: + Compiler instance appropriate for this target + """ + pass + + @abstractmethod + def get_name(self) -> str: + """ + Get the target name. + + Returns: + Target name (e.g., 'linux', 'windows', 'rp2040') + """ + pass + + def build(self): + """ + Build the main executable for this target. + + This is the main entry point for building. It compiles all sources + and links them into an executable. + """ + print(f"\n=== Building for {self.get_name()} ===\n") + + # Get source files + sources = self.get_sources() + + # Compile each source file + objects = [] + for source in sources: + obj = self.compile_source(source) + objects.append(obj) + + # Link into executable + output = self.get_output_path() + self.link_executable(objects, output) + + print(f"\n✓ Build successful: {output}\n") + + def build_tests(self): + """ + Build the test executable for this target. + + Compiles test sources and links them with non-main sources. + """ + print(f"\n=== Building tests for {self.get_name()} ===\n") + + # Generate test code if needed + self.generate_tests() + + # Get test sources + test_sources = self.config.get_test_files() + + # Get main sources (exclude main.c) + main_sources = self.config.get_main_sources() + + # Compile test files + test_objects = [] + for source in test_sources: + obj = self.compile_source(source, is_test=True) + test_objects.append(obj) + + # Compile shared source files + shared_objects = [] + for source in main_sources: + obj = self.compile_source(source, is_test=False) + shared_objects.append(obj) + + # Link into test executable + output = self.get_test_output_path() + self.link_executable(test_objects + shared_objects, output) + + print(f"\n✓ Test build successful: {output}\n") + + def run(self): + """ + Run the built executable. + + Raises: + NotImplementedError: If target doesn't support running + """ + import subprocess + + executable = self.get_output_path() + + if not executable.exists(): + print(f"Error: Executable not found: {executable}") + print("Run 'build' first to create the executable.") + return + + print(f"\n--- Running {executable} ---\n") + subprocess.call([str(executable)]) + + def run_tests(self): + """ + Run the test executable. + + Raises: + NotImplementedError: If target doesn't support running tests + """ + import subprocess + + executable = self.get_test_output_path() + + if not executable.exists(): + print(f"Error: Test executable not found: {executable}") + print("Run 'test' first to build tests.") + return + + print(f"\n--- Running tests ---\n") + subprocess.call([str(executable)]) + + def debug(self): + """ + Debug the test executable using GDB or platform debugger. + + Raises: + NotImplementedError: If target doesn't support debugging + """ + import subprocess + import shutil + import os + + executable = self.get_test_output_path() + + if not executable.exists(): + print(f"Error: Test executable not found: {executable}") + print("Run 'test' first to build tests.") + return + + if os.name == "nt": + print("Debugging on Windows requires Visual Studio debugger.") + print(f"Load {executable} in Visual Studio to debug.") + elif shutil.which("gdb"): + subprocess.call(["gdb", str(executable)]) + elif shutil.which("lldb"): + subprocess.call(["lldb", str(executable)]) + else: + print("No debugger found (tried gdb, lldb)") + + def clean(self): + """ + Clean build artifacts for this target. + + Removes object files, executables, and build directories. + """ + from ..utils import rm_tree + + print(f"\n=== Cleaning {self.get_name()} ===\n") + + # Remove object directory + if self.config.obj_dir.exists(): + rm_tree(self.config.obj_dir) + print(f"Removed {self.config.obj_dir}") + + # Remove binary directory + if self.config.bin_dir.exists(): + rm_tree(self.config.bin_dir) + print(f"Removed {self.config.bin_dir}") + + print("\n✓ Clean complete\n") + + def compile_source(self, source: Path, is_test: bool = False) -> Path: + """ + Compile a single source file. + + Args: + source: Source file path + is_test: Whether this is a test compilation + + Returns: + Path to compiled object file + """ + from ..utils import needs_rebuild, mkdir + + # Determine output path + mkdir(self.config.obj_dir) + obj_ext = self.compiler.get_object_extension() + obj = self.config.obj_dir / (source.stem + obj_ext) + deps = self.compiler.get_dependency_file(obj) + + # Check if rebuild is needed + if not needs_rebuild(source, obj, deps): + return obj + + # Get compilation parameters + includes = self.config.get_includes() + defines = self.config.get_defines() + flags = self.get_compile_flags(is_test) + + # Compile + self.compiler.compile( + source, + obj, + includes=includes, + defines=defines, + flags=flags, + generate_deps=True, + is_test=is_test + ) + + return obj + + def link_executable(self, objects: List[Path], output: Path): + """ + Link object files into an executable. + + Args: + objects: List of object files + output: Output executable path + """ + from ..utils import mkdir + + mkdir(output.parent) + + libraries = self.config.get_libraries(self.get_name()) + flags = self.get_link_flags() + + self.compiler.link( + objects, + output, + libraries=libraries, + flags=flags + ) + + def get_sources(self) -> List[Path]: + """ + Get list of source files for this target. + + Returns: + List of source file paths + """ + return self.config.get_source_files() + + def get_output_path(self) -> Path: + """ + Get the output executable path. + + Returns: + Path to output executable + """ + exe_ext = self.compiler.get_executable_extension() + return self.config.main_target.with_suffix(exe_ext) + + def get_test_output_path(self) -> Path: + """ + Get the test executable path. + + Returns: + Path to test executable + """ + exe_ext = self.compiler.get_executable_extension() + return self.config.test_target.with_suffix(exe_ext) + + def get_compile_flags(self, is_test: bool = False) -> List[str]: + """ + Get compilation flags for this target. + + Args: + is_test: Whether this is a test compilation + + Returns: + List of compiler flags + """ + return [] + + def get_link_flags(self) -> List[str]: + """ + Get linker flags for this target. + + Returns: + List of linker flags + """ + return [] + + def generate_tests(self): + """ + Generate test code if needed. + + Override this to implement test generation logic. + """ + pass + + def __repr__(self) -> str: + """String representation of target.""" + return f"{self.__class__.__name__}(compiler={self.compiler})" diff --git a/SLS_C/build_system/targets/linux.py b/SLS_C/build_system/targets/linux.py new file mode 100644 index 0000000..1c2c4d7 --- /dev/null +++ b/SLS_C/build_system/targets/linux.py @@ -0,0 +1,88 @@ +""" +Linux build target. + +This module implements the build target for native Linux builds. +Uses GCC as the default compiler. +""" + +from pathlib import Path +from typing import List, Optional +from .base import Target +from ..compilers import GCCCompiler, detect_compiler +from ..config import Config + + +class LinuxTarget(Target): + """ + Linux native build target. + + Builds executables for Linux using GCC or Clang. + """ + + def __init__(self, config: Config, compiler: Optional[GCCCompiler] = None): + """ + Initialize Linux target. + + Args: + config: Build configuration + compiler: Compiler to use (default: auto-detect GCC/Clang) + """ + super().__init__(config, compiler) + + def _get_default_compiler(self) -> GCCCompiler: + """ + Get the default compiler for Linux. + + Returns: + GCC or Clang compiler instance + """ + return detect_compiler("linux") # type: ignore + + def get_name(self) -> str: + """Get the target name.""" + return "linux" + + def get_compile_flags(self, is_test: bool = False) -> List[str]: + """ + Get compilation flags for Linux. + + Args: + is_test: Whether this is a test compilation + + Returns: + List of compiler flags + """ + if is_test: + return self.config.gcc_test_flags.copy() + else: + return self.config.gcc_common_flags.copy() + + def get_link_flags(self) -> List[str]: + """ + Get linker flags for Linux. + + Returns: + List of linker flags + """ + return [] + + def generate_tests(self): + """Generate test code from YAML if available.""" + from ..utils import detect_python, run_command + + script = Path("../SLS_Tests/yaml_to_c_tests.py") + yaml = Path("../SLS_Tests/cases.yaml") + out = self.config.test_dir / "lexer_tests.c" + + if not script.exists() or not yaml.exists(): + if self.config.verbose: + print("Test generation skipped (missing files).") + return + + # Check if regeneration is needed + if out.exists() and out.stat().st_mtime > yaml.stat().st_mtime: + return + + print("Generating tests from YAML...") + python = detect_python() + run_command([python, str(script), str(yaml), str(out)]) diff --git a/SLS_C/build_system/utils.py b/SLS_C/build_system/utils.py new file mode 100644 index 0000000..fdebae3 --- /dev/null +++ b/SLS_C/build_system/utils.py @@ -0,0 +1,256 @@ +""" +Utility functions for the build system. + +This module provides common utilities used across the build system: +- File and directory operations +- Git integration +- Dependency tracking +- Process execution helpers +""" + +import os +import subprocess +import shutil +from pathlib import Path +from typing import Optional + + +def mkdir(path: Path): + """ + Create a directory if it doesn't exist. + + Args: + path: Directory path to create + """ + path.mkdir(parents=True, exist_ok=True) + + +def rm_tree(path: Path): + """ + Remove a directory tree if it exists. + + Args: + path: Directory path to remove + """ + if path.exists(): + shutil.rmtree(path, ignore_errors=True) + + +def run_command(cmd: list, cwd: Optional[Path] = None, capture_output: bool = False): + """ + Run a command and optionally capture its output. + + Args: + cmd: Command and arguments as list + cwd: Working directory (default: current directory) + capture_output: If True, return output; if False, print to console + + Returns: + Command output as string if capture_output=True, else None + + Raises: + subprocess.CalledProcessError: If command fails + """ + if not capture_output: + print(">>", " ".join(str(c) for c in cmd)) + subprocess.check_call(cmd, cwd=cwd) + return None + else: + result = subprocess.check_output( + cmd, + cwd=cwd, + stderr=subprocess.DEVNULL, + text=True + ) + return result.strip() + + +def git_commit_hash() -> str: + """ + Get the current git commit hash and date. + + Returns: + String like "a1b2c3d 2024-12-18 10:30:45 -0700" or "unknown" if not a git repo + """ + try: + # Get short hash with dirty flag + hash_result = subprocess.check_output( + ["git", "describe", "--always", "--dirty", "--abbrev=7"], + stderr=subprocess.DEVNULL, + text=True + ).strip() + + # Get commit date + date_result = subprocess.check_output( + ["git", "show", "-s", "--format=%ci"], + stderr=subprocess.DEVNULL, + text=True + ).strip() + + return f"{hash_result} {date_result}" + except (subprocess.CalledProcessError, FileNotFoundError): + return "unknown" + + +def is_up_to_date(source: Path, output: Path, deps: Optional[Path] = None) -> bool: + """ + Check if an output file is up to date relative to its source. + + Args: + source: Source file path + output: Output file path + deps: Optional dependency file path + + Returns: + True if output is newer than source (and deps), False otherwise + """ + # Special case: always rebuild files with "meta" in name + if "meta" in source.stem or "meta" in output.stem: + return False + + # If output doesn't exist, not up to date + if not output.exists(): + return False + + # Check if dependency file is newer than output + if deps and deps.exists(): + if deps.stat().st_mtime > output.stat().st_mtime: + return False + + # Check if source is newer than output + return source.stat().st_mtime < output.stat().st_mtime + + +def find_sources(directory: Path, pattern: str = "*.c", exclude: Optional[list] = None) -> list: + """ + Find source files in a directory with optional exclusions. + + Args: + directory: Directory to search + pattern: Glob pattern for files (default: "*.c") + exclude: List of filenames or patterns to exclude + + Returns: + List of Path objects for matching files + """ + exclude = exclude or [] + sources = list(directory.glob(pattern)) + + # Filter out excluded files + filtered = [] + for src in sources: + # Check exact filename matches + if src.name in exclude: + continue + + # Check pattern matches in stem + if any(excl.lower() in src.stem.lower() for excl in exclude): + continue + + filtered.append(src) + + return filtered + + +def detect_python() -> str: + """ + Detect the appropriate Python executable. + + Returns: + 'python3' on Unix-like systems, 'python' on Windows + """ + if os.name == "nt": + return "python" + return "python3" + + +def file_hash(path: Path) -> Optional[str]: + """ + Calculate SHA256 hash of a file. + + Args: + path: File path + + Returns: + Hex string of file hash, or None if file doesn't exist + """ + import hashlib + + if not path.exists(): + return None + + sha256 = hashlib.sha256() + with open(path, 'rb') as f: + for chunk in iter(lambda: f.read(4096), b''): + sha256.update(chunk) + + return sha256.hexdigest() + + +def needs_rebuild(source: Path, output: Path, deps_file: Optional[Path] = None) -> bool: + """ + Advanced dependency checking including header dependencies. + + Args: + source: Source file + output: Output file + deps_file: Dependency file (.d) generated by compiler + + Returns: + True if rebuild is needed + """ + # Basic up-to-date check + if not is_up_to_date(source, output, deps_file): + return True + + # If we have a dependency file, check all dependencies + if deps_file and deps_file.exists(): + try: + with open(deps_file, 'r') as f: + content = f.read() + + # Parse Make-style dependencies + # Format: target: dep1 dep2 dep3 + if ':' in content: + _, deps_str = content.split(':', 1) + deps = deps_str.strip().split() + + output_mtime = output.stat().st_mtime + + # Check if any dependency is newer than output + for dep in deps: + dep_path = Path(dep.strip('\\').strip()) + if dep_path.exists(): + if dep_path.stat().st_mtime > output_mtime: + return True + except Exception: + # If we can't parse deps, rebuild to be safe + return True + + return False + + +def copy_file(src: Path, dst: Path): + """ + Copy a file, creating destination directory if needed. + + Args: + src: Source file + dst: Destination file + """ + mkdir(dst.parent) + shutil.copy2(src, dst) + + +def which(executable: str) -> Optional[Path]: + """ + Find an executable in PATH. + + Args: + executable: Name of executable + + Returns: + Path to executable if found, None otherwise + """ + result = shutil.which(executable) + return Path(result) if result else None