blob: ff854b7ceb4350deffa22df978b6c034bccfcf96 [file]
# 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.
"""A simple data structure for generating simple Ninja build files."""
import dataclasses
import os
import pathlib
import shlex
import sys
def escape_path_ninja(path):
return str(path).replace('$ ', '$$ ').replace(' ', '$ ').replace(':', '$:')
def escape_path_command(path):
# shlex.quote is designed for posix shells and thus won't work on windows.
# Assume there are no double quotes in the path - seems pretty reasonable.
return f'"{path}"'
@dataclasses.dataclass
class Action:
rule: 'DummyRule'
output: pathlib.Path
inputs: list[pathlib.Path] = dataclasses.field(default_factory=list)
implicit_inputs: list[pathlib.Path] = dataclasses.field(default_factory=list)
variables: dict[str, str] = dataclasses.field(default_factory=dict)
def __str__(self):
return str(self.output)
class DummyRule:
def __init__(self, name, ninja_file, inputs=None):
self.name = name
self.ninja_file = ninja_file
self.inputs = inputs or []
def __call__(self, output, *, inputs=None, implicit_inputs=None, **kwargs):
action = Action(
rule=self,
output=pathlib.Path(output),
inputs=inputs or [],
implicit_inputs=self.inputs + (implicit_inputs or []),
variables=kwargs,
)
self.ninja_file.actions.append(action)
return action.output
class Rule(DummyRule):
def __init__(self, name, ninja_file, command, description=None, inputs=None):
super().__init__(name, ninja_file, inputs)
self.command = command
self.description = description
self.ninja_file.rules.append(self)
class NinjaFile:
def __init__(self, platform, source_root, out_dir):
self.platform = platform
self.out_dir = pathlib.Path(out_dir).resolve()
# source_root is relative to out_dir
self.source_root = pathlib.Path(os.path.relpath(source_root, self.out_dir))
self.regen_triggers = []
self.rules = []
self.actions = []
self._gn_exe = pathlib.Path('gn' + self.platform.exe_suffix)
build_prefix = '' if self.platform.is_windows() else './'
def python(path, args):
return (
f'{escape_path_command(sys.executable)}'
f' {escape_path_command(self.source_file(path))} {args}'
)
# Define standard/dummy rules (no rule block generated in build.ninja)
self.Phony = DummyRule('phony', self)
self.Cxx = DummyRule('cxx', self)
self.Link = DummyRule('link', self)
self.RunBinary = Rule(
name='run_binary',
ninja_file=self,
command=self.chain(
f'{build_prefix}$in $args', python('tools/touch.py', '$out')
),
description='RUN BINARY $in',
)
# Define custom rules (rule block generated in build.ninja)
self._run_gn = Rule(
name='run_gn',
ninja_file=self,
command=self.chain(
# For golden tests it's very important that if a ninja file is no
# longer generated, it is actually deleted.
python('tools/clean.py', '$out.actual'),
f'{build_prefix}{self._gn_exe} gen $out.actual --quiet'
' --root=$path',
python('tools/touch.py', '$out'),
),
description='RUN GN $out',
inputs=[self._gn_exe],
)
compare_script = self.source_file('tools/compare_goldens.py')
self._compare_goldens = Rule(
name='compare_goldens',
ninja_file=self,
command=self.chain(
python('tools/compare_goldens.py', '$path $goldens'),
python('tools/touch.py', '$out'),
),
description='COMPARE $out',
inputs=[compare_script],
)
def chain(self, *commands):
joined = ' && '.join(commands)
if self.platform.is_windows():
return f'cmd.exe /s /c "{joined}"'
return joined
def source_file(self, path):
return self.source_root / path
def directory(self, dir_path, exclude_dirs):
# Join out_dir with dir_path (which is relative to out_dir) to get absolute path for filesystem walk
full_dir_path = (self.out_dir / dir_path).resolve()
inputs = []
for root, dirs, files in os.walk(full_dir_path):
for exc in exclude_dirs:
if exc in dirs:
dirs.remove(exc)
root = pathlib.Path(os.path.relpath(root, self.out_dir))
self.regen_triggers.append(root)
inputs.append(root)
for file in files:
inputs.append(root / file)
return sorted(inputs)
# IntegrationTest is a macro more so than a rule.
def IntegrationTest(self, name):
path = self.source_file(f'integration_tests/{name}')
inputs = self.directory(path, ['out', 'goldens'])
golden_path = path / 'goldens'
stamp = self._run_gn(name, path=path, inputs=inputs)
return self._compare_goldens(
name + '_integration_test',
inputs=[stamp] + self.directory(golden_path, []),
path=f'{name}.actual',
goldens=golden_path,
)
def write_ninja(self):
out = []
for rule in self.rules:
out.append(f'rule {rule.name}')
out.append(f' command = {getattr(rule, "command", "")}')
out.append(f' description = {getattr(rule, "description", "")}')
out.append('')
for action in self.actions:
inputs = ' '.join([escape_path_ninja(p) for p in action.inputs])
inputs = f' {inputs}' if inputs else ''
implicit = ' '.join(
[escape_path_ninja(p) for p in action.implicit_inputs]
)
implicit = f' | {implicit}' if implicit else ''
out.append(f'build {action.output}: {action.rule.name}{inputs}{implicit}')
for k, v in action.variables.items():
out.append(f' {k} = {v}')
out.append('')
return '\n'.join(out)