| #!/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 contextlib |
| import os |
| from pathlib import Path |
| import re |
| import shutil |
| import subprocess |
| import sys |
| import tempfile |
| |
| _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. |
| |
| 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. |
| |
| # Note that the layout of the output directory of cargo has changed. Thus, we |
| # must match both: |
| # Old layout: deps/<crate>-<hash>(.exe) |
| # New layout: build/<crate>/<hash>/out/<crate>-<hash>(.exe) |
| layout_pattern = re.compile( |
| r'^(?:' |
| r'deps' |
| r'|' |
| r'build/[^/]+/[0-9a-f]{16}/out' |
| r')/(?P<crate_name>[a-zA-Z0-9_-]+)-[0-9a-f]{16}(?:\.exe)?$' |
| ) |
| |
| groups = {} |
| for root, _, files in os.walk(cargo_out_dir): |
| root_path = Path(root) |
| for name in files: |
| c = root_path / name |
| rel_path = c.relative_to(cargo_out_dir).as_posix() |
| m = layout_pattern.search(rel_path) |
| if not m: |
| continue |
| |
| # Ensure it is actually an executable test binary |
| if ( |
| c.suffix.lower() != '.exe' |
| if sys.platform == 'win32' |
| else not os.access(c, os.X_OK) |
| ): |
| continue |
| |
| crate_name = m.group('crate_name') |
| # Cargo can cache your test binary under different configurations. |
| # E.g. 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. |
| existing = groups.get(crate_name) |
| if not existing or c.stat().st_mtime > existing.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) < 10: |
| print( |
| 'Usage: run_cargo.py <test|lib> <out> <cargo_out_dir> <cxx> ' |
| '<cxxflags> <ldflags> <target_triple> <ld> <command...>', |
| file=sys.stderr, |
| ) |
| sys.exit(1) |
| |
| ( |
| target_type, |
| out_path_str, |
| cargo_out_dir_str, |
| cxx, |
| cxxflags, |
| ldflags, |
| target_triple, |
| linker, |
| *cmd_args, |
| ) = sys.argv[1:] |
| out_path = Path(out_path_str) |
| cargo_out_dir = Path(cargo_out_dir_str) |
| |
| if sys.platform == 'win32': |
| # Strip /GL from Cargo C++ dependencies (like the cxx crate). |
| # Rust code compiles to standard COFF objects. If C++ FFI shims generated by Cargo |
| # are compiled with /GL, the MSVC linker (running with /LTCG) fails to match the |
| # standard COFF Rust references to these C++ symbols during the LTCG phase, |
| # causing it to discard the shims as unused and leading to LNK2001 link errors. |
| cxxflags = cxxflags.replace('/GL', '') |
| |
| # Disable -Wunused-template for cargo C++ dependencies compiled with Clang. |
| # Some third-party dependencies (like the `cxx` crate) contain templates that |
| # are only used inside asserts. In release builds (with -DNDEBUG), these asserts |
| # are compiled away, triggering -Wunused-template causing failures with -Werror. |
| if 'clang' in cxx: |
| cxxflags += ' -Wno-unused-template' |
| |
| 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 |
| if sys.platform == 'win32': |
| # Linker flags in GN can contain relative paths (e.g. /MANIFESTINPUT) |
| # which are relative to the Ninja output directory. Since Cargo runs the |
| # linker from different build directories, we must normalize these paths |
| # to be absolute. |
| def replace_path(match): |
| prefix = match.group('prefix') |
| path_val = Path(match.group('path')) |
| if not path_val.is_absolute(): |
| abs_path = os.path.normpath(Path(ninja_out_dir) / path_val) |
| return f"{prefix}{abs_path}" |
| return match.group(0) |
| |
| pattern = re.compile( |
| r'(?i)(?P<prefix>[-/](?:manifestinput|natvis|pdb|def|implib|libpath):)' |
| r'(?P<path>[^\s]+)' |
| ) |
| ldflags = pattern.sub(replace_path, ldflags) |
| |
| target_rustflags = [f"-C linker={linker}", f"-L {ninja_out_dir}"] + [ |
| f"-C link-arg={flag}" for flag in ldflags.split() |
| ] |
| |
| if sys.platform == 'win32': |
| # GN compiles C++ code on Windows with the static C runtime (/MT or /MTd) by |
| # default. We must configure rustc to also link statically to prevent linker |
| # runtime library mismatch errors (LNK2038). |
| target_rustflags.append("-C target-feature=+crt-static") |
| |
| # Optimize the ffi crate in debug mode to strip unused cxx shims and |
| # resolve MSVC link failures. |
| os.environ['CARGO_PROFILE_DEV_PACKAGE_FFI_OPT_LEVEL'] = '1' |
| |
| # Compiling the cxx crate requires symlink support. |
| # Cargo's internal Git client (libgit2) does not respect environment |
| # variables like GIT_CONFIG_COUNT. Instead, we override HOME/USERPROFILE |
| # to point to a temporary directory containing a .gitconfig with |
| # core.symlinks = true. |
| with contextlib.suppress(RuntimeError): |
| os.environ.setdefault('CARGO_HOME', str(Path.home() / '.cargo')) |
| |
| temp_home = tempfile.mkdtemp() |
| with open(os.path.join(temp_home, '.gitconfig'), 'w') as f: |
| f.write('[core]\n\tsymlinks = true\n') |
| |
| os.environ['USERPROFILE'] = temp_home |
| os.environ['HOME'] = temp_home |
| os.environ['HOMEDRIVE'] = '' |
| os.environ['HOMEPATH'] = '' |
| |
| # When linking C++ objects, we must allow the linker to link its default |
| # libraries so it pulls in libc++ and any potential sanititzers. |
| target_rustflags.append("-C default-linker-libraries=yes") |
| |
| env_var = f"CARGO_TARGET_{target_triple.upper().replace('-', '_')}_RUSTFLAGS" |
| os.environ[env_var] = ' '.join(target_rustflags) |
| # The link flags are specifically for the target. When cross-compiling, the |
| # target and host platforms are the same, but we don't want the link flags |
| # when building build tools. |
| os.environ['CARGO_TARGET_APPLIES_TO_HOST'] = 'false' |
| |
| # 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 == 'clippy': |
| out_path.parent.mkdir(parents=True, exist_ok=True) |
| out_path.touch() |
| sys.exit(0) |
| elif 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() |