blob: 69a8738c8e290b0d0a49a17d70ccea1ac19a0681 [file] [log] [blame]
# Copyright 2024 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Handle the list of Ninja artifacts corresponding to the last build command."""
import subprocess
import typing as T
from pathlib import Path
# The name of the file listing the dependencies of the Ninja build plan,
# as generated by GN. This contains a single statement that looks like:
#
# ```
# build.ninja.stamp: <dep1> <dep2> <dep3> ...
# ```
#
# Where each <dep> points to a BUILD.gn or .gni file, using paths relative
# to the build directory (e.g. `../../src/BUILD.gn`).
#
NINJA_BUILD_PLAN_DEPS_FILE = "build.ninja.d"
# The name of the file that contains the list of targets passed to the
# last `fx build` or `fint build` invocation.
LAST_NINJA_TARGETS_FILE = "last_ninja_build_targets.txt"
# The name of the file that will contain the cached value of all artifacts
# that are transitive inputs for the targets of the last build.
LAST_NINJA_ARTIFACTS_FILE = "last_ninja_artifacts.txt"
# The name of the file that will contain the cached value of all source files
# that are transitive inputs for the targets of the last build.
LAST_NINJA_SOURCES_FILE = "last_ninja_inputs.txt"
class NinjaRunner(object):
"""Wrapper class to invoke Ninja, can be overridden for tests."""
def __init__(self, ninja: Path):
self._ninja = ninja
def run_and_extract_output(self, build_dir: str, cmd: T.List[str]) -> str:
ret = subprocess.run(
[str(self._ninja), "-C", str(build_dir)] + cmd,
capture_output=True,
text=True,
)
ret.check_returncode()
return ret.stdout
def get_last_build_targets_path(build_dir: Path) -> Path:
"""Return the path of the file listing the targets passed to the last Ninja build."""
return build_dir / LAST_NINJA_TARGETS_FILE
def get_last_build_targets(build_dir: Path) -> T.Sequence[str]:
"""Return the list of targets passed to the last Ninja build."""
last_ninja_targets_path = get_last_build_targets_path(build_dir)
last_ninja_targets = []
if last_ninja_targets_path.exists():
data = last_ninja_targets_path.read_text().strip()
if data:
last_ninja_targets = data.split(" ")
if last_ninja_targets:
return last_ninja_targets
# Fallback if the file doesn't exist of is empty.
return [":default"]
def get_build_plan_deps(build_dir: Path) -> T.Sequence[str]:
"""Return the list of Ninja build plan dependencies.
This is the list of BUILD.gn or .gni files that, if changed, would trigger
a regeneration of the Ninja build plan.
Args:
build_dir: Ninja build directory path.
Returns:
A list of path strings, relative to build_dir.
"""
deps_path = build_dir / NINJA_BUILD_PLAN_DEPS_FILE
deps_content = deps_path.read_text().strip()
# For faster parsing, assert that there are no quoted dependency paths
# in that file.
assert (
deps_content.find("'") < 0
), f"Unexpected quoted path in {deps_path.resolve()}"
deps = deps_content.split(" ")
assert deps, f"Empty file: {deps_path.resolve()}"
assert (
deps[0] == "build.ninja.stamp:"
), f"Unexpected {NINJA_BUILD_PLAN_DEPS_FILE} content: {deps[0:4]}"
return deps[1:]
def check_output_needs_update(
output_file: Path, input_files: T.Sequence[Path]
) -> bool:
"""Returns True if an output file needs to be updated.
Args:
output_file: Output file path.
input_files: A list of input file paths.
Returns:
True if the output_file needs to be re-generated.
"""
if not output_file.exists():
return True
output_timestamp = output_file.stat(follow_symlinks=False).st_mtime
for input_file in input_files:
if (
not input_file.exists()
or input_file.stat(follow_symlinks=False).st_mtime
> output_timestamp
):
return True
return False
def get_last_build_artifacts(
build_dir: Path, ninja_runner: NinjaRunner
) -> T.Sequence[str]:
"""Return the list of all Ninja artifacts corresponding to the last `fx` or `fint` build.
Args:
build_dir: Path to the build directory.
ninja_runner: A NinjaRunner instance, used to invoke Ninja if needed.
Returns:
A list of Ninja output paths, relative to the build directory.
"""
last_ninja_targets = get_last_build_targets(build_dir)
# Determine whether ninja_artifacts.txt needs to be re-generated.
# This happens when last_ninja_build_targets.txt is modified, or when
# any of the build plan inputs (e.g. BUILD.gn files) are modified
# since the last call. These are listed in build.ninja.d which starts
# with `build.ninja.stamp: ` followed by paths, relative to the
# build directory.
ninja_artifacts_path = build_dir / LAST_NINJA_ARTIFACTS_FILE
ninja_artifacts_deps = [get_last_build_targets_path(build_dir)] + [
build_dir / dep for dep in get_build_plan_deps(build_dir)
]
if check_output_needs_update(ninja_artifacts_path, ninja_artifacts_deps):
# Invoke Ninja to regenerate a new set of inputs. Then write results to disk.
ninja_output = ninja_runner.run_and_extract_output(
str(build_dir), ["-t", "outputs"] + last_ninja_targets
)
ninja_artifacts = ninja_output.splitlines()
ninja_artifacts_path.write_text("\n".join(ninja_artifacts))
else:
# Read previous results from disk.
ninja_artifacts = ninja_artifacts_path.read_text().splitlines()
return ninja_artifacts
def get_last_build_sources(
build_dir: Path, ninja_runner: NinjaRunner
) -> T.Sequence[str]:
"""Return the list of all Ninja sources to the last `fx` or `fint` build.
Args:
build_dir: Path to the build directory.
ninja_runner: A NinjaRunner instance, used to invoke Ninja if needed.
Returns:
A list of Ninja input paths, relative to the build directory.
Note that they will all start with ../.
"""
last_ninja_targets = get_last_build_targets(build_dir)
# Determine whether ninja_artifacts.txt needs to be re-generated.
# This happens when last_ninja_build_targets.txt is modified, or when
# any of the build plan sources (e.g. BUILD.gn files) are modified
# since the last call. These are listed in build.ninja.d which starts
# with `build.ninja.stamp: ` followed by paths, relative to the
# build directory.
ninja_sources_path = build_dir / LAST_NINJA_SOURCES_FILE
ninja_sources_deps = [get_last_build_targets_path(build_dir)] + [
build_dir / dep for dep in get_build_plan_deps(build_dir)
]
if check_output_needs_update(ninja_sources_path, ninja_sources_deps):
# Invoke Ninja to regenerate a new set of sources. Then write results to disk.
ninja_output = ninja_runner.run_and_extract_output(
str(build_dir),
["-t", "inputs", "--no-shell-escape", "--dependency-order"]
+ last_ninja_targets,
)
ninja_sources = [
line for line in ninja_output.splitlines() if line.startswith("../")
]
ninja_sources_path.write_text("\n".join(ninja_sources))
else:
# Read previous results from disk.
ninja_sources = ninja_sources_path.read_text().splitlines()
return ninja_sources