| #!/usr/bin/env python3 | |
| # Copyright 2026 The Chromium Authors. All rights reserved. | |
| # Use of this source code is governed by a BSD-style license that can be | |
| # found in the LICENSE file. | |
| """Helper script to integrate Cargo builds into the Ninja build system. | |
| It is invoked by the Ninja 'cargo' rule to: | |
| 1. Run the Cargo build command with a portably configured environment. | |
| 2. Locate the compiled artifacts (libraries or test binaries) from Cargo's | |
| profile directory. | |
| 3. Symlink or copy the compiled artifacts to the requested Ninja output path. | |
| 4. For test targets: generate a Python test runner script that invokes all unit | |
| test binaries found in the workspace. | |
| 5. Translate and merge Cargo-generated `.d` dependency files (depfiles) into | |
| a format that Ninja can parse, mapping dependencies relative to the Ninja | |
| build directory. | |
| """ | |
| import os | |
| from pathlib import Path | |
| import re | |
| import shutil | |
| import subprocess | |
| import sys | |
| _ESC = '\u001e' | |
| def parse_depfile(src_depfile: Path) -> list[Path]: | |
| """Parses depfile and returns a flattened list of resolved dependency Paths. | |
| Reads the file, extracts CARGO_MANIFEST_DIR, strips all comment lines, | |
| cleans up line continuations, and parses and resolves dependencies. | |
| """ | |
| content = src_depfile.read_text() | |
| # Strip comments | |
| content = re.sub(r'(?m)^#.*$', '', content) | |
| # Strip the output files ("a: b c" -> "b c") | |
| content = re.sub(r'^[^:]*:\s*', '', content) | |
| content = content.replace('\\\n', ' ') | |
| content = content.replace('\\ ', _ESC) | |
| deps = [] | |
| for f in re.split(r'\s+', content.strip()): | |
| f = Path(f.replace('\\\\', '\\').replace(_ESC, ' ')) | |
| deps.append(f) | |
| return deps | |
| def to_depfile_path(p: Path, out_dir: Path): | |
| # Can't use Path.relative_to because we require walk_up=True, which doesn't | |
| # exist on preinstalled python on macos. | |
| rel = os.path.relpath(p, out_dir) | |
| return rel.replace('\\', '\\\\').replace(' ', '\\ ') | |
| def rewrite_depfile( | |
| src_depfiles: list[Path], dest_depfile: Path, target_name: str | |
| ): | |
| """Orchestrates reading, parsing, merging, and writing the depfile.""" | |
| all_deps = set() | |
| for src in src_depfiles: | |
| if src.exists(): | |
| all_deps.update(parse_depfile(src)) | |
| out_dir = dest_depfile.parent.resolve() | |
| deps_str = ' '.join(to_depfile_path(d, out_dir) for d in sorted(all_deps)) | |
| new_content = f'{target_name}: {deps_str}\n' | |
| dest_depfile.parent.mkdir(parents=True, exist_ok=True) | |
| dest_depfile.write_text(new_content) | |
| def process_lib_target(out_path: Path, cargo_out_dir: Path) -> list[Path]: | |
| """Maps the static library artifact and returns its source depfiles.""" | |
| lib_name = out_path.name | |
| depfile_name = f'{out_path.stem}.d' | |
| src_path = cargo_out_dir / lib_name | |
| out_path.parent.mkdir(parents=True, exist_ok=True) | |
| if out_path.exists() or out_path.is_symlink(): | |
| out_path.unlink() | |
| rel_src = os.path.relpath(src_path, out_path.parent) | |
| try: | |
| out_path.symlink_to(rel_src) | |
| except OSError: | |
| shutil.copy2(src_path, out_path) | |
| return [cargo_out_dir / depfile_name] | |
| def process_test_target(out_path: Path, cargo_out_dir: Path) -> list[Path]: | |
| """Generates a test runner script and returns all test binary source depfiles.""" | |
| # When cargo builds tests, it builds one test binary per crate. | |
| # So we find all those test binaries, then make the generated "test binary" | |
| # just a script that invokes each of those binaries one by one. | |
| groups = {} | |
| for c in (cargo_out_dir / 'deps').iterdir(): | |
| is_executable = c.suffix.lower() == '.exe' if sys.platform == 'win32' else os.access(c, os.X_OK) | |
| if c.is_file() and is_executable and c.suffix.lower() not in ('.so', '.dylib', '.dll'): | |
| parts = c.name.split('-') | |
| # Cargo can cache your test binary under different configurations. | |
| # Eg. The test binary might be called `mytest-hash1`, then after updating your lockfile, | |
| # it might keep that binary but future tests would be called `mytest-hash2`. | |
| # When this happens, we should use the newest one. | |
| if len(parts) >= 2: | |
| crate_name = '-'.join(parts[:-1]) | |
| if crate_name not in groups or c.stat().st_mtime > groups[crate_name].stat().st_mtime: | |
| groups[crate_name] = c | |
| newest_binaries = list(groups.values()) | |
| out_path.parent.mkdir(parents=True, exist_ok=True) | |
| if out_path.exists() or out_path.is_symlink(): | |
| out_path.unlink() | |
| runner_content = [ | |
| '#!/usr/bin/env python3', | |
| 'import subprocess', | |
| 'import sys', | |
| 'import os', | |
| '', | |
| 'tests = [', | |
| ] | |
| for b in sorted(newest_binaries): | |
| rel_path = os.path.relpath(b, out_path.parent) | |
| runner_content.append( | |
| f' os.path.join(os.path.dirname(__file__), {repr(rel_path)}),' | |
| ) | |
| runner_content.extend([ | |
| ']', | |
| '', | |
| 'failed = False', | |
| 'for test in tests:', | |
| ' res = subprocess.run([test] + sys.argv[1:])', | |
| ' if res.returncode != 0:', | |
| ' failed = True', | |
| 'if failed:', | |
| ' sys.exit(1)', | |
| ]) | |
| out_path.write_text('\n'.join(runner_content) + '\n') | |
| out_path.chmod(0o755) | |
| return [b.with_suffix('.d') for b in newest_binaries] | |
| def main(): | |
| if len(sys.argv) < 7: | |
| print( | |
| 'Usage: run_cargo.py <test|lib> <out> <cargo_out_dir> <cxx> <cxxflags> <command...>', | |
| file=sys.stderr, | |
| ) | |
| sys.exit(1) | |
| target_type, out_path_str, cargo_out_dir_str, cxx, cxxflags, *cmd_args = sys.argv[1:] | |
| out_path = Path(out_path_str) | |
| cargo_out_dir = Path(cargo_out_dir_str) | |
| os.environ['CXX'] = cxx | |
| os.environ['CXXFLAGS'] = cxxflags | |
| # Since Ninja runs commands from the build output directory, CWD is the ninja out dir. | |
| ninja_out_dir = os.getcwd() | |
| os.environ['NINJA_OUT_DIR'] = ninja_out_dir | |
| os.environ['RUSTFLAGS'] = f"-L {ninja_out_dir}" | |
| # Now we run the `cargo build` command | |
| res = subprocess.run(cmd_args) | |
| if res.returncode != 0: | |
| sys.exit(res.returncode) | |
| # Cargo build doesn't output files in a format ninja can use. So we now need to convert them. | |
| if target_type == 'lib': | |
| src_depfiles = process_lib_target(out_path, cargo_out_dir) | |
| elif target_type == 'test': | |
| src_depfiles = process_test_target(out_path, cargo_out_dir) | |
| else: | |
| raise ValueError(f'Unknown target type: {target_type}') | |
| dest_depfile = out_path.parent / f'{out_path.name}.d' | |
| rewrite_depfile(src_depfiles, dest_depfile, out_path) | |
| if __name__ == '__main__': | |
| main() |