Compare commits

..

3 Commits

Author SHA1 Message Date
Kyler Olsen df070b080e Created files 2025-12-19 13:45:01 -07:00
Kyler Olsen 627fadfea1 Some changes 2025-12-19 13:44:52 -07:00
Kyler Olsen d7c23225f3 Worked on build system 2025-12-19 13:44:24 -07:00
11 changed files with 1283 additions and 0 deletions

View File

View File

View File

@ -0,0 +1,67 @@
{
"paths": {
"src_dir": "src",
"test_dir": "tests",
"include_dir": "include",
"obj_dir": "obj",
"bin_dir": "bin",
"build_dir": "build"
},
"targets": {
"main": "sls",
"test": "sls_tests"
},
"parallel_jobs": 0,
"verbose": false,
"debug": true,
"gcc_common_flags": [
"-std=c99",
"-Wall",
"-Wextra",
"-Werror",
"-g"
],
"gcc_test_flags": [
"-std=c99",
"-Wall",
"-Wextra",
"-Wno-unused-function",
"-Werror",
"-g",
"-O0"
],
"msvc_common_flags": [
"/std:c11",
"/W4",
"/WX",
"/Zi"
],
"msvc_test_flags": [
"/std:c11",
"/W4",
"/WX",
"/Zi",
"/Od"
],
"arm_gcc_flags": [
"-mcpu=cortex-m0plus",
"-mthumb"
],
"pico": {
"sdk_path_env": "PICO_SDK_PATH",
"sdk_path_default": "~/pico/pico-sdk",
"build_dir": "build_pico",
"toolchain_file": "pico_arm_gcc_toolchain.cmake"
},
"macos": {
"min_version": "10.13"
},
"source_excludes": {
"rp2040": [
"main.c",
"repl.c",
"file.c",
"test"
]
}
}

View File

@ -0,0 +1,301 @@
"""
Build system configuration.
This module centralizes all build configuration including paths,
compiler flags, and build settings. Configuration can be loaded from
a default JSON file or overridden programmatically.
"""
import os
import json
from pathlib import Path
from typing import List, Dict, Optional
from importlib import resources
# TODO: This should be utilized more throughout the build system.
class Config:
"""
Centralized build configuration.
All paths, flags, and settings are defined here for easy modification.
Configuration is loaded from a default JSON file and can be overridden.
"""
def __init__(self, project_root: Optional[Path] = None, config_file: Optional[Path] = None):
"""
Initialize build configuration.
Args:
project_root: Project root directory (default: current directory)
config_file: Custom config file path (default: uses built-in config.json)
"""
# Load default configuration
self._load_default_config(config_file)
# Project structure
self.project_root = project_root or Path.cwd()
self._setup_paths()
# Build settings
self.parallel_jobs = self._data.get("parallel_jobs", os.cpu_count() or 1)
self.verbose = self._data.get("verbose", False)
self.debug = self._data.get("debug", True)
# Git integration
self.git_hash = self._get_git_hash()
# Compiler flags
self.gcc_common_flags = self._data.get("gcc_common_flags", [])
self.gcc_test_flags = self._data.get("gcc_test_flags", [])
self.msvc_common_flags = self._data.get("msvc_common_flags", [])
self.msvc_test_flags = self._data.get("msvc_test_flags", [])
self.arm_gcc_flags = self._data.get("arm_gcc_flags", [])
# Platform-specific settings
self._setup_platform_settings()
def _load_default_config(self, config_file: Optional[Path] = None):
"""
Load configuration from JSON file.
Args:
config_file: Custom config file, or None to use default
"""
if config_file and config_file.exists():
# Load custom config file
with open(config_file, 'r') as f:
self._data = json.load(f)
else:
# Load default config from package resources
config_text = resources.files("build_system").joinpath("config.json").read_text()
self._data = json.loads(config_text)
def _setup_paths(self):
"""Setup directory paths from configuration."""
paths = self._data.get("paths", {})
self.src_dir = self.project_root / paths.get("src_dir", "src")
self.test_dir = self.project_root / paths.get("test_dir", "tests")
self.include_dir = self.project_root / paths.get("include_dir", "include")
self.obj_dir = self.project_root / paths.get("obj_dir", "obj")
self.bin_dir = self.project_root / paths.get("bin_dir", "bin")
self.build_dir = self.project_root / paths.get("build_dir", "build")
# Target executables
targets = self._data.get("targets", {})
self.main_target = self.bin_dir / targets.get("main", "sls")
self.test_target = self.bin_dir / targets.get("test", "sls_tests")
def _setup_platform_settings(self):
"""Setup platform-specific settings from configuration."""
pico_config = self._data.get("pico", {})
# Pico SDK settings
sdk_env = pico_config.get("sdk_path_env", "PICO_SDK_PATH")
sdk_default = pico_config.get("sdk_path_default", "~/pico/pico-sdk")
sdk_default = Path(sdk_default).expanduser()
self.pico_sdk_path = Path(os.environ.get(sdk_env, sdk_default))
self.pico_build_dir = self.project_root / pico_config.get("build_dir", "build_pico")
self.pico_toolchain_file = self.project_root / pico_config.get(
"toolchain_file", "pico_arm_gcc_toolchain.cmake"
)
# macOS settings
macos_config = self._data.get("macos", {})
self.macos_min_version = macos_config.get("min_version", "10.13")
def _get_git_hash(self) -> str:
"""Get git commit hash for version info."""
from .utils import git_commit_hash
return git_commit_hash()
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)
"""
excludes = self._data.get("source_excludes", {}).get("rp2040", [])
sources = self.get_source_files(exclude=excludes)
# 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 save_config(self, output_file: Path):
"""
Save current configuration to a JSON file.
Args:
output_file: Path to output JSON file
"""
with open(output_file, 'w') as f:
json.dump(self._data, f, indent=2)
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_file: Optional[Path] = None) -> Config:
"""
Get the global configuration instance.
Args:
project_root: Project root directory (only used on first call)
config_file: Custom config file (only used on first call)
Returns:
Config instance
"""
global _config
if _config is None:
_config = Config(project_root, config_file)
return _config
def reset_config():
"""Reset the global configuration (mainly for testing)."""
global _config
_config = None

View File

@ -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']}")

View File

View File

@ -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"\nBuild 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"\nTest 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("\nClean 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})"

View File

@ -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)])

View File

View File

259
SLS_C/build_system/utils.py Normal file
View File

@ -0,0 +1,259 @@
"""
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
# TODO: Is this needed?
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)
# TODO: Is this needed?
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)
# TODO: Is this needed?
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