rutile-game/tools/shuriken.py

836 lines
32 KiB
Python
Raw Permalink Normal View History

2025-10-22 00:20:48 +02:00
#!/usr/bin/env python3
############################################
# shuriken - A ninja-based C++ build tool
# ------------------------------------------
2026-05-11 01:41:25 +02:00
# Version: 0.1.0 (modified)
2025-10-22 00:20:48 +02:00
# Author: binaryDiv
# License: MIT License
# https://git.0xbd.space/binaryDiv/shuriken
############################################
import argparse
import json
import shlex
import subprocess
import sys
from collections import namedtuple
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Self
2025-10-22 00:20:48 +02:00
import yaml
class ConfigValidationException(Exception):
pass
@dataclass(kw_only=True)
class TargetConfig:
cpp_compiler: str | None = None
cpp_standard: str | None = None
cpp_flags: str | None = None
cpp_flags_extra: str | None = None
linker_flags: str | None = None
linker_flags_extra: str | None = None
linker_args: str | None = None
linker_args_extra: str | None = None
output_file: str | None = None
run_command: str | None = None
def update(self, data: dict) -> None:
for key, value in data.items():
if hasattr(self, key) is True and value is not None:
setattr(self, key, str(value))
@property
def merged_cpp_flags(self) -> str:
return f"{self.cpp_flags or ''} {self.cpp_flags_extra or ''}".strip()
@property
def merged_linker_flags(self) -> str:
return f"{self.linker_flags or ''} {self.linker_flags_extra or ''}".strip()
@property
def merged_linker_args(self) -> str:
return f"{self.linker_args or ''} {self.linker_args_extra or ''}".strip()
@dataclass(kw_only=True)
class MergedTargetConfig(TargetConfig):
cpp_compiler: str = ''
cpp_standard: str = ''
cpp_flags: str = ''
cpp_flags_extra: str = ''
linker_flags: str = ''
linker_flags_extra: str = ''
linker_args: str = ''
linker_args_extra: str = ''
output_file: str = ''
run_command: str = ''
@classmethod
def create_from(cls, target_defaults: TargetConfig, target_config: TargetConfig) -> Self:
merged_config = cls()
merged_config.update(asdict(target_defaults))
merged_config.update(asdict(target_config))
return merged_config
def validate(self) -> None:
# Validate that all required fields are set
if not self.cpp_compiler:
raise ConfigValidationException('cpp_compiler must be set')
if not self.cpp_standard:
raise ConfigValidationException('cpp_standard must be set')
if not self.output_file:
raise ConfigValidationException('output_file must be set')
2025-10-22 00:20:48 +02:00
@dataclass(kw_only=True)
class ShurikenConfig:
__path_fields = ['source_dir', 'build_dir']
__list_fields = []
2026-05-15 01:49:53 +02:00
__bool_fields = ['enable_import_std']
2025-10-22 00:20:48 +02:00
source_dir: Path = Path('src')
build_dir: Path = Path('build')
defaults: TargetConfig = field(default_factory=lambda: TargetConfig(
cpp_compiler='clang++',
cpp_standard='c++20',
))
2026-05-15 01:49:53 +02:00
enable_import_std: bool = False
2025-10-22 00:20:48 +02:00
default_target: str = ''
targets: dict[str, TargetConfig] = field(default_factory=dict)
# Merged target configs, generated from defaults and targets
_merged_target_configs = {}
2025-10-22 00:20:48 +02:00
def update(self, data: dict) -> None:
for key, value in data.items():
# Ignore unknown config keys
if not hasattr(self, key):
continue
if key == 'defaults':
2026-05-15 01:49:53 +02:00
assert type(value) is dict
2025-10-22 00:20:48 +02:00
self.defaults.update(value)
elif key == 'targets':
2026-05-15 01:49:53 +02:00
assert type(value) is dict
2025-10-22 00:20:48 +02:00
for target_name, target_config in value.items():
2026-05-15 01:49:53 +02:00
assert type(target_name) is str and len(target_name) > 0
assert type(target_config) is dict or target_config is None
2025-10-22 00:20:48 +02:00
# Ignore "hidden" targets starting with a dot (can be used for YAML anchors)
if target_name[0] == '.':
continue
if target_name not in self.targets:
self.targets[target_name] = TargetConfig()
if target_config is not None:
self.targets[target_name].update(target_config)
elif key in self.__path_fields:
setattr(self, key, Path(value))
elif key in self.__list_fields:
2026-05-15 01:49:53 +02:00
assert type(value) is list
2025-10-22 00:20:48 +02:00
setattr(self, key, list(str(item) for item in value))
2026-05-15 01:49:53 +02:00
elif key in self.__bool_fields:
assert type(value) is bool
setattr(self, key, value)
2025-10-22 00:20:48 +02:00
else:
setattr(self, key, str(value))
# Generate merged target configs (we generate all of them so that we can validate the full config)
for target_name, target_config in self.targets.items():
self._merged_target_configs[target_name] = MergedTargetConfig.create_from(self.defaults, target_config)
2025-10-22 00:20:48 +02:00
def update_from_yaml(self, file_path: Path) -> None:
with file_path.open('r') as file:
self.update(yaml.safe_load(file))
def validate(self) -> None:
# Validate root elements
if not self.default_target:
raise ConfigValidationException('default_target must be set')
if not self.source_dir or not self.source_dir.is_dir():
raise ConfigValidationException('source_dir must be set to an existing directory')
if not self.build_dir:
# build_dir will be automatically created if it doesn't exist
raise ConfigValidationException('build_dir must be set')
# Validate that targets and default target are defined
if not self.targets:
raise ConfigValidationException('At least one target must be defined')
if self.default_target not in self.targets:
raise ConfigValidationException('default_target is not defined in targets')
# Validate individual build targets
for target_name, merged_target_config in self._merged_target_configs.items():
try:
merged_target_config.validate()
except ConfigValidationException as exc:
raise ConfigValidationException(f'Target "{target_name}": {str(exc)}')
2025-10-22 00:20:48 +02:00
def get_ninja_file_path(self, target: str) -> Path:
return self.build_dir / f'build.{target}.ninja'
def get_merged_target_config(self, target: str) -> MergedTargetConfig:
2025-10-22 00:20:48 +02:00
assert target in self.targets, f'Target "{target}" not defined!'
return self._merged_target_configs[target]
2025-10-22 00:20:48 +02:00
class ShurikenArgumentParser:
argument_parser: argparse.ArgumentParser
default_config_path: Path = Path('shuriken.yaml')
default_config_override_path: Path = Path('shuriken.override.yaml')
def __init__(self):
self.argument_parser = argparse.ArgumentParser()
self.argument_parser.add_argument(
'-c', '--config',
type=Path,
help=f'Config file (default: {self.default_config_path})',
metavar='FILE',
)
self.argument_parser.add_argument(
'-o', '--config-override',
type=Path,
help=f'Config file to override config values (default: {self.default_config_override_path})',
metavar='FILE',
)
self.argument_parser.add_argument(
'-t', '--target',
help=f'Build target to use (default: see "default_target" in config)',
)
# Define subcommands
subparsers = self.argument_parser.add_subparsers(dest='command', title='Subcommands')
subparser_generate = subparsers.add_parser('generate', help='Generate Ninja build files')
subparser_generate.add_argument(
'--stdout',
action='store_true',
dest='generate_stdout',
help='Print generated Ninja file to stdout instead of writing to a file',
)
2026-05-11 01:41:25 +02:00
_subparser_build = subparsers.add_parser('build', help='Build project (default command)')
_subparser_compdb = subparsers.add_parser('compdb', help='Generate compilation database')
2025-10-22 00:20:48 +02:00
subparser_run = subparsers.add_parser('run', help='Build and run project')
subparser_run.add_argument(
'run_args',
nargs='*',
help='Arguments that are passed to the application',
)
2026-05-11 01:41:25 +02:00
subparser_lint = subparsers.add_parser('lint', help='Lint the project')
subparser_lint.add_argument(
'lint_files',
nargs='*',
help='Files to lint (defaults to all source files)',
)
2025-10-22 00:20:48 +02:00
subparser_clean = subparsers.add_parser('clean', help='Remove all build files of the current target')
subparser_clean.add_argument(
'--all',
action='store_true',
dest='clean_all',
help='Remove all generated files from all targets',
)
2026-05-11 01:41:25 +02:00
_subparser_dump_config = subparsers.add_parser(
2025-10-22 00:20:48 +02:00
'dump-config',
help='Dumps the parsed config as well as the effective build target config (for debugging)',
)
2026-05-11 01:41:25 +02:00
_subparser_dyndep = subparsers.add_parser(
2025-10-22 00:20:48 +02:00
'p1689-to-dyndeps',
help='(Internal command) Parse p1689 file and generate Ninja dyndeps',
)
def parse_args(self, args: list[str] | None = None) -> argparse.Namespace:
parsed_args = self.argument_parser.parse_args(args)
parsed_args.config = self.ensure_valid_path_or_default(
parsed_args.config,
self.default_config_path,
required=True,
)
parsed_args.config_override = self.ensure_valid_path_or_default(
parsed_args.config_override,
self.default_config_override_path,
)
return parsed_args
def error(self, message: str) -> None:
self.argument_parser.error(message)
def ensure_valid_path_or_default(self, path: Path | None, default: Path, *, required: bool = False) -> Path | None:
if path is None:
if required or default.exists():
path = default
if path is not None and not path.exists():
self.error(f'File not found: {path}')
return path
class ShurikenCli:
args: argparse.Namespace
config: ShurikenConfig
def __init__(self, input_args: list[str] | None = None):
# Parse command line arguments
argument_parser = ShurikenArgumentParser()
self.args = argument_parser.parse_args(input_args)
# Parse config files
self.config = ShurikenConfig()
if self.args.config:
self.config.update_from_yaml(self.args.config)
if self.args.config_override:
self.config.update_from_yaml(self.args.config_override)
# Validate parsed config
try:
self.config.validate()
except ConfigValidationException as exc:
self.log_error(f'Config validation error: {exc}')
exit(1)
# Check that --target is a valid target
if self.args.target is not None and self.args.target not in self.config.targets:
argument_parser.error(f'Target "{self.args.target}" not defined in config')
def get_selected_target(self) -> str:
return self.args.target if self.args.target is not None else self.config.default_target
@staticmethod
def log_info(message: str) -> None:
print(f'shuriken: {message}')
@staticmethod
def log_error(message: str) -> None:
print(f'shuriken: Error: {message}')
def run(self) -> None:
# Get build target (specified with --target or default_target from config)
selected_target = self.get_selected_target()
# Default command: build
command = self.args.command or 'build'
match command:
case 'generate':
self.generate_ninja_file(selected_target, write_to_stdout=self.args.generate_stdout)
case 'compdb':
# Always generate compilation database for default target
# (avoids issues with clang-scan-deps in different environments like emscripten)
self.generate_compdb(self.config.default_target)
case 'build':
self.build_project(selected_target)
case 'run':
self.run_project(selected_target)
2026-05-11 01:41:25 +02:00
case 'lint':
self.lint_project(selected_target)
2025-10-22 00:20:48 +02:00
case 'clean':
if self.args.clean_all:
self.log_info('Cleaning up *all* build targets')
for target in self.config.targets:
self.clean_project(target)
else:
self.clean_project(selected_target)
case 'dump-config':
self.dump_config()
case 'p1689-to-dyndeps':
self.generate_dyndeps_from_p1689(selected_target)
case _:
raise Exception(f'Unknown subcommand "{self.args.command}"')
def call_ninja(self, target: str, *args: str) -> None:
run_result = subprocess.run([
'ninja',
'-f',
self.config.get_ninja_file_path(target),
'-v',
*args,
])
if run_result.returncode > 0:
self.log_error(f'Ninja exited with return code {run_result.returncode}')
exit(1)
def generate_ninja_file(self, target: str, *, write_to_stdout: bool = False) -> None:
generator = NinjaFileGenerator(config=self.config, target=target)
ninja_file_content = generator.generate_file()
if write_to_stdout:
print(ninja_file_content)
return
# Create build directory for target if it doesn't exist yet
self.config.build_dir.joinpath(target).mkdir(parents=True, exist_ok=True)
ninja_file_path = self.config.get_ninja_file_path(target)
ninja_file_content += '\n'
# Check if file content has changed before writing it (to preserve the modified timestamp if unmodified)
if ninja_file_path.exists() and ninja_file_path.read_text() == ninja_file_content:
self.log_info(f'Ninja file for target {target} is up to date')
else:
# Write Ninja file
self.log_info(f'Generating Ninja file for target {target}')
with ninja_file_path.open('w') as ninja_file:
ninja_file.write(ninja_file_content)
# Create symlink build.ninja to default target ninja file for convenience
if target == self.config.default_target:
self.symlink_build_ninja_file(ninja_file_path)
def symlink_build_ninja_file(self, symlink_target: Path) -> None:
symlink_path = Path('build.ninja')
if symlink_path.exists() and not symlink_path.is_symlink():
self.log_error('Cannot symlink build.ninja: File exists and is not a symlink')
return
if symlink_path.is_symlink() and symlink_path.resolve() == symlink_target.absolute():
# Symlink already correct
return
# Remove existing symlink if it exists and create a new symlink
self.log_info(f'Updating build.ninja symlink to {symlink_target}')
symlink_path.unlink(missing_ok=True)
symlink_path.symlink_to(symlink_target)
def generate_compdb(self, target: str) -> None:
# Regenerate Ninja file if necessary
self.generate_ninja_file(target)
self.log_info('Generating compilation database')
self.call_ninja(target, 'compdb')
def build_project(self, target: str) -> None:
# Regenerate Ninja file if necessary
self.generate_ninja_file(target)
self.log_info(f'Building project for target {target}')
self.call_ninja(target)
def run_project(self, target: str) -> None:
# Build project using Ninja if necessary
self.build_project(target)
merged_target_config = self.config.get_merged_target_config(target)
output_file_path = self.config.build_dir / target / merged_target_config.output_file
# Get run command from config or default to running the output file
run_command = merged_target_config.run_command or './{out} {args}'
# Replace placeholders in run command
run_command = run_command.format(
out=output_file_path,
args=shlex.join(self.args.run_args),
)
try:
# Run command
self.log_info(f'Running command for target {target}:')
self.log_info(f' {run_command}')
run_result = subprocess.run(run_command, shell=True)
if run_result.returncode > 0:
self.log_error(f'Run command exited with return code {run_result.returncode}')
exit(1)
except KeyboardInterrupt:
self.log_info('Keyboard interrupt')
2026-05-11 01:41:25 +02:00
def lint_project(self, target: str) -> None:
# Build project using Ninja (necessary before linting)
self.build_project(target)
# Collect files to lint
if self.args.lint_files:
files_args = shlex.join(self.args.lint_files)
else:
files_args = f"$(find {self.config.source_dir} -name '*.cpp' -or -name '*.cppm')"
self.log_info('Running clangd-tidy')
run_result = subprocess.run(
f'clangd-tidy -p {self.config.build_dir} --allow-extensions cpp,cppm {files_args}',
shell=True,
)
if run_result.returncode > 0:
self.log_error(f'clangd-tidy exited with return code {run_result.returncode}')
exit(1)
2025-10-22 00:20:48 +02:00
def clean_project(self, target: str) -> None:
ninja_file_path = self.config.get_ninja_file_path(target)
if not ninja_file_path.exists():
self.log_info(f'Ninja file {ninja_file_path} not found, nothing to clean up')
return
self.log_info(f'Cleaning up build files for target {target}')
self.call_ninja(target, '-t', 'clean')
self.log_info(f'Removing Ninja file {ninja_file_path}')
ninja_file_path.unlink(missing_ok=True)
# Remove build.ninja symlink if it points to the same Ninja file
ninja_symlink = Path('build.ninja')
if ninja_symlink.is_symlink() and ninja_symlink.resolve() == ninja_file_path.absolute():
self.log_info(f'Removing build.ninja symlink')
ninja_symlink.unlink()
def dump_config(self) -> None:
target = self.get_selected_target()
self.log_info('Dumping fully parsed config:')
print(json.dumps(asdict(self.config), indent=4, default=str))
self.log_info(f'Dumping effective build target config for target "{target}":')
merged_target_config = self.config.get_merged_target_config(target)
print(json.dumps(asdict(merged_target_config), indent=4, default=str))
def generate_dyndeps_from_p1689(self, target: str) -> None:
# Parse P1689 build dependency JSON file from stdin (generated by clang-scan-deps)
parsed_p1689 = json.load(sys.stdin)
# Parse rules and get module dependencies
build_dependency_parser = BuildDependencyParser(config=self.config, target=target)
build_dependencies = build_dependency_parser.parse_build_dependencies(parsed_p1689)
# Generate Ninja dyndep file and write to stdout
print('ninja_dyndep_version = 1\n')
for out, deps in build_dependencies.items():
if deps:
2026-05-15 01:49:53 +02:00
print(f'build {out}: dyndep | {" ".join(str(dep) for dep in deps)}')
2025-10-22 00:20:48 +02:00
else:
print(f'build {out}: dyndep')
CompileTargetCpp = namedtuple('CompileTargetCpp', ['src', 'obj'])
CompileTargetCppm = namedtuple('CompileTargetCppm', ['src', 'obj', 'pcm'])
CompileTargetStdModule = namedtuple('CompileTargetStdModule', ['original_src', 'generated_src', 'pcm'])
2025-10-22 00:20:48 +02:00
class NinjaFileGenerator:
config: ShurikenConfig
target: str
target_config: TargetConfig
compile_targets_cpp: list[CompileTargetCpp]
compile_targets_cppm: list[CompileTargetCppm]
2026-05-15 01:49:53 +02:00
compile_targets_std_module: list[CompileTargetStdModule]
2025-10-22 00:20:48 +02:00
link_target_obj_files: list[str]
def __init__(self, *, config: ShurikenConfig, target: str):
self.config = config
self.target = target
self.target_config = config.get_merged_target_config(target)
def generate_file(self) -> str:
self.collect_compile_targets()
return self.render_file()
def find_source_files(self, glob: str) -> list[Path]:
return sorted(self.config.source_dir.glob(glob))
def collect_compile_targets(self) -> None:
self.compile_targets_cpp = []
self.compile_targets_cppm = []
2026-05-15 01:49:53 +02:00
self.compile_targets_std_module = []
2025-10-22 00:20:48 +02:00
# Compile targets for .cpp files
for src_file in self.find_source_files('**/*.cpp'):
relative_name = src_file.relative_to(self.config.source_dir)
self.compile_targets_cpp.append(CompileTargetCpp(
src=str(src_file),
obj=f"$builddir/obj/{relative_name.with_suffix('.o')}",
))
# Compile targets for .cppm files
for src_file in self.find_source_files('**/*.cppm'):
relative_name = src_file.relative_to(self.config.source_dir)
2026-05-15 01:49:53 +02:00
module_name = '.'.join(relative_name.with_suffix('.pcm').parts)
2025-10-22 00:20:48 +02:00
self.compile_targets_cppm.append(CompileTargetCppm(
src=str(src_file),
obj=f"$builddir/obj/{relative_name.with_suffix('.o')}",
2026-05-15 01:49:53 +02:00
pcm=f"$builddir/pcm/{module_name}",
2025-10-22 00:20:48 +02:00
))
2026-05-15 01:49:53 +02:00
# Compile targets for standard library modules ("import std")
if self.config.enable_import_std:
# NOTE: Add std.compat to the list (here and a few other places) if we ever need it. For now, std is enough.
std_module_source_finder = StdModuleSourceFinder(config=self.config, target=self.target)
std_module_sources = std_module_source_finder.find_module_sources(['std'])
for module_name, module_src_path in std_module_sources.items():
self.compile_targets_std_module.append(CompileTargetStdModule(
original_src=str(module_src_path),
generated_src=f"$builddir/generated/stdlib/{module_name}.cppm",
2026-05-15 01:49:53 +02:00
pcm=f"$builddir/pcm/{module_name}.pcm",
))
2025-10-22 00:20:48 +02:00
# Link target for output file (executable): gather all object files
self.link_target_obj_files = sorted(
target.obj for target in self.compile_targets_cpp + self.compile_targets_cppm
)
def render_file(self) -> str:
# Get script path to run shuriken
shuriken_script_path = sys.argv[0]
# Shortcut variables
config = self.config
target = self.target
target_config = self.target_config
build_dir = config.build_dir
ninja_file_path = config.get_ninja_file_path(config.default_target)
cpp_compiler = target_config.cpp_compiler
# Generate list of all source input files to be used as implicit dependencies for the dyndep file
dyndep_file_implicit_inputs = ' '.join(sorted(
t.src for t in self.compile_targets_cpp + self.compile_targets_cppm
))
# Generate list of all .dep files that are generated implicitly during dyndep file generation.
# This is mostly necessary so that Ninja can create the directories for these files beforehand.
dyndep_file_implicit_outputs = ' '.join(sorted(
f'{t.obj}.dep' for t in self.compile_targets_cpp + self.compile_targets_cppm
))
# Generate list of generated source files (currently just the copied std module file)
generated_source_files = ' '.join(sorted(
t.generated_src for t in self.compile_targets_std_module
))
2025-10-22 00:20:48 +02:00
# Generate Ninja file
return f"""
ninja_required_version = 1.10
# Build directory and paths
builddir = {build_dir / target}
compdb_file = {build_dir}/compile_commands.json
dyndep_file = $builddir/.ninja_dyndep
# Compiler flags
2026-05-15 01:49:53 +02:00
cpp_base_flags = -std={target_config.cpp_standard} -fprebuilt-module-path=$builddir/pcm/
cpp_flags = $cpp_base_flags {target_config.merged_cpp_flags}
cpp_std_module_flags = $cpp_base_flags -Wno-reserved-module-identifier -x c++-module
2025-10-22 00:20:48 +02:00
linker_flags = {target_config.merged_linker_flags}
linker_args = {target_config.merged_linker_args}
# -- RULES
# Rule to copy an arbitrary file (or multiple) from in to out
rule copy
command = cp $in $out
2025-10-22 00:20:48 +02:00
# Rule to compile a .cpp file to a .o file
rule cpp
depfile = $out.dep
command = {cpp_compiler} $cpp_flags -MMD -MF $out.dep -c $in -o $out
2025-10-22 00:20:48 +02:00
# Rule to compile a .cppm file (C++20 module) to a .o file ($out) and a .pcm file ($pcm_out)
rule cppm
depfile = $out.dep
command = {cpp_compiler} $cpp_flags -MMD -MF $out.dep -c $in -o $out -fmodule-output=$pcm_out
2025-10-22 00:20:48 +02:00
2026-05-15 01:49:53 +02:00
# Rule to compile a standard library module to a .pcm file (e.g. "import std")
rule std_module
command = {cpp_compiler} $cpp_std_module_flags --precompile $in -o $out
2026-05-15 01:49:53 +02:00
2025-10-22 00:20:48 +02:00
# Rule to link several .o files to an executable
rule link
command = {cpp_compiler} $linker_flags -o $out $in $linker_args
2025-10-22 00:20:48 +02:00
# Rule to generate a compilation database (JSON file) from the build targets defined in a Ninja file
rule generate_compdb
command = ninja -f $in -t compdb cpp cppm std_module > $out
2025-10-22 00:20:48 +02:00
# Rule to generate a Ninja dyndep file for C++ module dependencies based on a compilation database
rule generate_dyndeps
command = clang-scan-deps -format=p1689 -compilation-database=$in | {shuriken_script_path} -t {target} p1689-to-dyndeps > $out
# -- STATIC BUILD TARGETS
# Generate compilation database from default target Ninja file
build $compdb_file: generate_compdb {ninja_file_path} | generate_source_files
2025-10-22 00:20:48 +02:00
# Shortcut alias to generate compilation database
build compdb: phony $compdb_file
# Generate Ninja dyndep file from compilation database
build $dyndep_file | {dyndep_file_implicit_outputs}: generate_dyndeps $compdb_file | {dyndep_file_implicit_inputs}
2025-10-22 00:20:48 +02:00
# -- GENERATED BUILD TARGETS
# Link output file (default target)
build $builddir/{target_config.output_file}: link {' '.join(self.link_target_obj_files)}
default $builddir/{target_config.output_file}
# Phony target that depends on all generated source files
build generate_source_files: phony {generated_source_files}
2025-10-22 00:20:48 +02:00
{self.render_compile_targets()}
# End of build.{target}.ninja
""".strip()
def render_compile_targets(self) -> str:
2026-05-15 01:49:53 +02:00
rendered_targets = list(map(self.render_compile_target_std_module, self.compile_targets_std_module))
rendered_targets += list(map(self.render_compile_target_cpp, self.compile_targets_cpp))
rendered_targets += list(map(self.render_compile_target_cppm, self.compile_targets_cppm))
return '\n\n'.join(rendered_targets)
2025-10-22 00:20:48 +02:00
@staticmethod
def render_compile_target_cpp(target: CompileTargetCpp) -> str:
return f"""
build {target.obj}: cpp {target.src} || $dyndep_file
dyndep = $dyndep_file
""".strip()
@staticmethod
def render_compile_target_cppm(target: CompileTargetCppm) -> str:
return f"""
build {target.obj} | {target.pcm}: cppm {target.src} || $dyndep_file
dyndep = $dyndep_file
pcm_out = {target.pcm}
""".strip()
2026-05-15 01:49:53 +02:00
@staticmethod
def render_compile_target_std_module(target: CompileTargetStdModule) -> str:
return f"""
build {target.generated_src}: copy {target.original_src}
build {target.pcm}: std_module {target.generated_src} || $dyndep_file
2026-05-15 01:49:53 +02:00
dyndep = $dyndep_file
""".strip()
2025-10-22 00:20:48 +02:00
class BuildDependencyParser:
config: ShurikenConfig
target: str
2026-05-15 01:49:53 +02:00
def __init__(self, *, config: ShurikenConfig, target: str):
2025-10-22 00:20:48 +02:00
self.config = config
self.target = target
2026-05-15 01:49:53 +02:00
def parse_build_dependencies(self, parsed_p1689: dict[str, Any]) -> dict[Path, list[Path]]:
2025-10-22 00:20:48 +02:00
# Shortcut variables
source_dir = self.config.source_dir
target_build_dir = self.config.build_dir / self.target
default_target_build_dir = self.config.build_dir / self.config.default_target
2026-05-15 01:49:53 +02:00
# Map of module names to PCM file paths
module_map: dict[str, Path] = {}
2025-10-22 00:20:48 +02:00
# Validate module name and path convention, construct map of module names to PCM file paths
for rule in parsed_p1689['rules']:
provides = rule.get('provides', [])
if provides:
assert len(provides) == 1, f"Rule provides more than one module: {rule}"
assert provides[0]['is-interface'] is True, f"Rule provides non-interface module: {rule}"
module_name: str = provides[0]['logical-name']
2026-05-15 01:49:53 +02:00
assert module_name not in module_map, f'Module "{module_name}" is provided more than once: {rule}'
2025-10-22 00:20:48 +02:00
if module_name != 'std':
expected_source_path = str(Path(source_dir, module_name.replace('.', '/') + '.cppm'))
assert provides[0]['source-path'] == expected_source_path, \
f"Module name does not match source path: {rule}"
2026-05-15 01:49:53 +02:00
module_map[module_name] = Path(target_build_dir, 'pcm', module_name + '.pcm')
# Collect all module dependencies to generate dyndeps
build_dependencies: dict[Path, list[Path]] = {}
2025-10-22 00:20:48 +02:00
# Parse rules and get module dependencies
for rule in parsed_p1689['rules']:
# Compilation database contains "build/{default_target}" paths, we need to map them to "build/{target}"
out = target_build_dir / Path(rule['primary-output']).relative_to(default_target_build_dir)
build_dependencies[out] = []
for require in rule.get('requires', []):
module_name = require['logical-name']
2026-05-15 01:49:53 +02:00
assert module_name in module_map, f'Module "{module_name}" not found in module map'
2025-10-22 00:20:48 +02:00
build_dependencies[out].append(module_map[module_name])
return build_dependencies
2026-05-15 01:49:53 +02:00
class StdModuleSourceFinder:
cpp_compiler: str
def __init__(self, *, config: ShurikenConfig, target: str):
self.cpp_compiler = config.get_merged_target_config(target).cpp_compiler
def find_module_sources(self, module_names: list[str]) -> dict[str, Path]:
# Get normalized path of standard library "module manifest" (JSON file containing source paths for std modules)
module_manifest_path = self.get_module_manifest_path()
module_manifest_base_dir = module_manifest_path.parent
# Parse module manifest JSON file
with module_manifest_path.open('r') as f:
module_manifest_json = json.load(f)
# Collect the module source paths
std_module_sources: dict[str, Path] = {}
for modules_item in module_manifest_json['modules']:
# Ignore modules we don't need (probably just std.compat)
module_name = modules_item['logical-name']
if module_name not in module_names:
continue
# The source path is usually relative to the module manifest file, convert to absolute path
module_src_path = Path(modules_item['source-path'])
if not module_src_path.is_absolute():
module_src_path = module_manifest_base_dir / module_src_path
if not module_src_path.is_file():
raise Exception(f'Source path for module "{module_name}" is not a file: {module_src_path}')
# Resolve symlinks and normalize path
std_module_sources[module_name] = module_src_path.resolve()
return std_module_sources
def get_module_manifest_path(self) -> Path:
# Use compiler to get the path to the module manifest JSON file
clang_cmd = [self.cpp_compiler, '-print-library-module-manifest-path']
clang_cmd_result = subprocess.run(clang_cmd, capture_output=True)
if clang_cmd_result.returncode != 0:
cmd_stderr = clang_cmd_result.stderr.decode().strip()
raise Exception(f'Subprocess "{" ".join(clang_cmd)}" failed. stderr:\n{cmd_stderr}')
# Read path from subprocess stdout
module_manifest_path = Path(clang_cmd_result.stdout.decode().strip())
if not module_manifest_path.is_file():
raise Exception(f'Could not locate stdlib module manifest: {module_manifest_path}')
# Resolve symlinks and normalize path
return module_manifest_path.resolve()
2025-10-22 00:20:48 +02:00
if __name__ == '__main__':
ShurikenCli().run()