blob: 0a7f685da83e736e8f2f5688be654e529ba6e91f [file]
# Copyright 2026 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.
"""
Path normalization utilities for GN and Bazel build command comparisons.
"""
import os
import pathlib
import sys
import typing as T
_FUCHSIA_DIR = pathlib.Path(__file__).parent.parent.parent.parent
sys.path.insert(0, str(_FUCHSIA_DIR / "build/bazel/scripts"))
import build_utils
class PathNormalizer(T.Protocol):
"""Base protocol for all path normalizer classes."""
def normalize_path(self, path: str) -> str:
...
class GnPathNormalizer(PathNormalizer):
"""Normalizes paths appearing in GN / Ninja build commands.
This is used to transform input paths as they appear in GN build
command, and replacing well-known locations with {SOURCE_ROOT}
or {BUILD_DIR} expressions.
"""
def __init__(
self,
fuchsia_dir: pathlib.Path | str,
build_dir: pathlib.Path | str,
) -> None:
"""Create instance."""
self._fuchsia_dir = pathlib.Path(fuchsia_dir).resolve()
self._build_dir = pathlib.Path(build_dir).resolve()
# Compute relative path from build_dir to fuchsia_dir (e.g. "../..")
self._rel_source_root = os.path.relpath(
self._fuchsia_dir, self._build_dir
)
# Compute relative path from build_dir to parent of build_dir (e.g. "..")
self._rel_out_root = os.path.relpath(
self._build_dir.parent, self._build_dir
)
@property
def fuchsia_dir(self) -> pathlib.Path:
"""Absolute path to the Fuchsia source directory path."""
return self._fuchsia_dir
@property
def build_dir(self) -> pathlib.Path:
"""Absolute path to the Ninja build directory."""
return self._build_dir
def normalize_path(self, path: str) -> str:
"""Normalize a path from a GN command to a canonical string.
Args:
path: A path as it appears in a GN build command.
Returns:
A path string, where the absolute or relative values
of the following paths are replaced by expressions such as:
- The Fuchsia source directory is mapped to "{SOURCE_ROOT}"
- The Ninja build directory is mapped to "{BUILD_DIR}"
- The out/ directory is mapped to "{OUT_ROOT}"
This also removes leading './' path components.
Raises:
ValueError if the path begins with '../' but cannot
be resolved to a known expression.
"""
if not path:
return ""
# Normalize slashes and remove leading ./
p = path.strip()
while p.startswith("./"):
p = p[2:]
# Check absolute path matches
if os.path.isabs(p):
resolved = pathlib.Path(p).resolve()
if resolved == self._build_dir:
return "{BUILD_DIR}"
if resolved == self._build_dir.parent:
return "{OUT_ROOT}"
if resolved == self._fuchsia_dir:
return "{SOURCE_ROOT}"
try:
rel_to_build = resolved.relative_to(self._build_dir)
return self.normalize_path(str(rel_to_build))
except ValueError:
pass
try:
rel_to_src = resolved.relative_to(self._fuchsia_dir)
return f"{{SOURCE_ROOT}}/{rel_to_src}"
except ValueError:
pass
return str(resolved)
# Handle exact relative roots
if p in (".", ""):
return "{BUILD_DIR}"
if p == self._rel_out_root:
return "{OUT_ROOT}"
if p == self._rel_source_root:
return "{SOURCE_ROOT}"
# Handle prefix matching with rel_source_root (e.g. "../../src/foo.cc")
src_prefix = f"{self._rel_source_root}/"
if p.startswith(src_prefix):
remainder = p[len(src_prefix) :]
if remainder.startswith("../") or "/../" in remainder:
raise ValueError(
f"Unexpected unresolved relative path in GN command: {path}"
)
return f"{{SOURCE_ROOT}}/{remainder}"
out_prefix = f"{self._rel_out_root}/"
if p.startswith(out_prefix):
remainder = p[len(out_prefix) :]
if remainder.startswith("../") or "/../" in remainder:
raise ValueError(
f"Unexpected unresolved relative path in GN command: {path}"
)
return f"{{OUT_ROOT}}/{remainder}"
# If a leading '../' persists, it's an unrecognized path layout
if p.startswith("../"):
raise ValueError(
f"Unexpected unresolved relative path in GN command: {path}"
)
return p
class BazelPathNormalizer(PathNormalizer):
"""Normalizes paths appearing in Bazel build commands."""
def __init__(self, bazel_paths: build_utils.BazelPaths) -> None:
"""Create instance."""
self._bazel_paths = bazel_paths
self._execroot = bazel_paths.execroot.resolve()
self._workspace = bazel_paths.workspace.resolve()
self._fuchsia_dir = bazel_paths.fuchsia_dir.resolve()
self._output_base = bazel_paths.output_base.resolve()
@property
def bazel_paths(self) -> build_utils.BazelPaths:
"""A BazelPaths instance."""
return self._bazel_paths
def normalize_path(self, path: str) -> str:
"""Normalize a path from a Bazel command to a canonical string.
Args:
path: A path string, as it appears in a Bazel command.
Returns:
The input path string, with known path prefixes replaced
with substitution expressions. Where:
- external/<canonical_repo_name>/... -> {OUTPUT_BASE}/external/<canonical_repo_name>/...
- bazel-out/... -> {BAZEL_OUT}/...
- execroot/workspace/fuchsia_dir -> {SOURCE_ROOT}
Removes leading './'.
Raises:
ValueError if input path begins with '../' and cannot
be mapped to a known expression.
"""
if not path:
return ""
p = path.strip()
while p.startswith("./"):
p = p[2:]
# Check absolute path matches
if os.path.isabs(p):
resolved = pathlib.Path(p).resolve()
if resolved == self._execroot or resolved == self._workspace:
return "{SOURCE_ROOT}"
if resolved == self._fuchsia_dir:
return "{SOURCE_ROOT}"
if resolved == self._output_base:
return "{OUTPUT_BASE}"
try:
rel_to_exec = resolved.relative_to(self._execroot)
return self.normalize_path(str(rel_to_exec))
except ValueError:
pass
try:
rel_to_output_base = resolved.relative_to(self._output_base)
return self.normalize_path(str(rel_to_output_base))
except ValueError:
pass
try:
rel_to_src = resolved.relative_to(self._fuchsia_dir)
return f"{{SOURCE_ROOT}}/{rel_to_src}"
except ValueError:
pass
return str(resolved)
if p in (".", ""):
return "{SOURCE_ROOT}"
if p.startswith("external/"):
remainder = p[len("external/") :]
if remainder.startswith("../") or "/../" in remainder:
raise ValueError(
f"Unexpected unresolved relative path in Bazel command: {path}"
)
return f"{{OUTPUT_BASE}}/external/{remainder}"
if p == "external":
return "{OUTPUT_BASE}/external"
if p.startswith("bazel-out/"):
remainder = p[len("bazel-out/") :]
if remainder.startswith("../") or "/../" in remainder:
raise ValueError(
f"Unexpected unresolved relative path in Bazel command: {path}"
)
return f"{{BAZEL_OUT}}/{remainder}"
if p == "bazel-out":
return "{BAZEL_OUT}"
if p.startswith("../") or "/../" in p:
raise ValueError(
f"Unexpected unresolved relative path in Bazel command: {path}"
)
return f"{{SOURCE_ROOT}}/{p}"
class MockPathNormalizer(PathNormalizer):
"""A mock PathNormalizer for tests that doesn't do anything."""
def normalize_path(self, path: str) -> str:
return path