| # Copyright 2025 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. |
| |
| """Utilities related to managing Bazel runfiles manifests and directories.""" |
| |
| import typing as T |
| from pathlib import Path |
| |
| # Starting with Bazel 8, the runfiles sub-directory that matches |
| # the current workspace is *always* named "_main", independent of |
| # the workspace name provided in WORKSPACE, or the module name |
| # provided in MODULE.bazel. |
| # |
| # On the other hand, the _repo_mapping file will contain a line |
| # like this one (where the first character is a comma): |
| # |
| # ,workspace_name,_main |
| # |
| # To indicate that files from the main workspace are mapped to |
| # that directory. |
| # |
| _BAZEL_MAIN_WORKSPACE_NAME = "_main" |
| |
| RepoMappingTree: T.TypeAlias = dict[str, dict[str, str]] |
| |
| |
| class RepoMapping(object): |
| """Models the content of a repository mapping file.""" |
| |
| def __init__(self, tree: RepoMappingTree) -> None: |
| self._tree = tree |
| |
| @staticmethod |
| def CreateFrom(content: str) -> "RepoMapping": |
| tree = RepoMapping.parse_repo_mapping(content) |
| return RepoMapping(tree) |
| |
| def map_apparent_name( |
| self, apparent_name: str, source_name: str = "" |
| ) -> str: |
| """Convert a repository/module apparent name to its path in the runfiles directory. |
| |
| Args: |
| apparent_name: Apparent name (workspace or repository name without a @ prefix). |
| source_name: Optional canonical repository name where the apparent_name appears, |
| without a @@ prefix. Default value is the empty string, corresponding to the |
| main workspace. |
| Returns: |
| A directory name under the root runfiles directory, or an empty string |
| if this apparent name is unknown. |
| """ |
| top_map = self._tree.get(source_name, {}) |
| return top_map.get(apparent_name, "") |
| |
| def map_input_path(self, path: str, source_name: str = "") -> str: |
| """Convert an rlocation input path to the corresponding manifest source path. |
| |
| This must replace the first path segment, and apparent workspace or repository |
| name, by a canonical one. For example, assuming Bazel 8.0 + BzlMod enabled: |
| |
| my_project/src/foo -> _main/src/foo |
| my_repo/data/file -> +custom_repo_ext+some_repo/data/file |
| |
| Args: |
| path: An input runfiles path, such as <apparent_name>/something/else |
| source_name: Optional canonical name of the source repository or workspace, |
| without a @@ or @ prefix, where this input path is used. |
| Default is empty string, which corresponds to the main workspace. |
| Returns: |
| The actual location relative to the runfiles directory root, e.g. |
| <canonical_name>/something/else for a repository artifact, or _main/something/else |
| for a workspace one. |
| """ |
| apparent_name, sep, remainder = path.partition("/") |
| if sep != "/": |
| # single-segment paths can be symlinks and must not be mapped. |
| # This is true for the .runfiles/_repo_mapping for example. |
| return path |
| |
| mapped_dir = self.map_apparent_name(apparent_name, source_name) |
| if not mapped_dir: |
| # This apparent name is not in the mapping file, return path as-is. |
| return path |
| |
| return f"{mapped_dir}/{remainder}" |
| |
| @staticmethod |
| def parse_repo_mapping(content: str) -> RepoMappingTree: |
| result: RepoMappingTree = {} |
| for line in content.splitlines(): |
| # Each line contains three comma-separated names: |
| # <source_name>,<apparent_name>,<runfile_name> |
| # |
| # Where <source_name> is either a canonical repository name, without a @@ prefix, |
| # or the empty string for the main workspace. |
| # |
| # Where <apparent_name> is an apparent module/repository name, without a @ prefix, |
| # that appears inside of <source_name> |
| # |
| # Where <runfile_name> is where the corresponding files are located in the |
| # runfiles directory. It is usually a canonical name, or the special value "_main" |
| # for the workspace (starting with Bazel8), or the module/workspace name (before Bazel 8) |
| # |
| # See runfiles_utils_test.py for concrete examples. |
| # |
| items = line.split(",") |
| if len(items) != 3: |
| raise ValueError(f"Invalid line in repo_mapping file: [{line}]") |
| |
| source_name, apparent_name, canonical_name = items |
| |
| # Save the value into a nested map {source_name -> { apparent_name -> canonical_name} } |
| # and check for duplicate definitions. |
| top_map = result.setdefault(source_name, {}) |
| cur_canonical = top_map.setdefault(apparent_name, canonical_name) |
| if cur_canonical is not canonical_name: |
| raise ValueError( |
| f"Duplicate canonical values for @{apparent_name} used in '{source_name}': {canonical_name} vs {cur_canonical}" |
| ) |
| |
| return result |
| |
| |
| class RunfilesManifest(object): |
| """A class grouping method to manage Bazel runfiles manifests.""" |
| |
| def __init__(self, file_map: dict[str, str]) -> None: |
| self._map = file_map |
| |
| @staticmethod |
| def CreateFrom(content: str) -> "RunfilesManifest": |
| """Create new instance from manifest file content.""" |
| file_map = RunfilesManifest.parse_manifest(content) |
| return RunfilesManifest(file_map) |
| |
| @staticmethod |
| def CreateFromDirectory(runfiles_dir: Path) -> "RunfilesManifest": |
| """Create new instance from a given directory.""" |
| assert ( |
| runfiles_dir.exists() |
| ), f"Runfiles directory does not exist: {runfiles_dir}" |
| # If the directory contains a manifest, use it. |
| probe_manifest = runfiles_dir / "MANIFEST" |
| if probe_manifest.exists(): |
| return RunfilesManifest.CreateFrom(probe_manifest.read_text()) |
| |
| # Otherwise simple walk the directory for files and symlinks. |
| file_map: dict[str, str] = {} |
| for root, dirnames, filenames in os.walk(runfiles_dir): |
| for filename in filenames: |
| path = Path(os.path.join(root, filename)) |
| source_path = os.path.relpath(path, runfiles_dir) |
| if path.is_symlink(): |
| target = path.readlink() |
| file_map[source_path] = target |
| else: |
| # Empty files do not need a target path after the space separator. |
| # For non-empty files though, convert the source path to an |
| # absolute one. |
| if path.stat().st_size == 0: |
| file_map[source_path] = "" |
| else: |
| file_map[source_path] = path.resolve() |
| |
| return RunfilesManifest(file_map) |
| |
| def lookup(self, runfile_path: str) -> str: |
| """Lookup a runfile's target location, or return an empty string on error. |
| |
| Args: |
| runfile_path: A runfiles-root-relative source path. |
| Note that this is *not* the path passed to an Rlocation() |
| function at runtime, but can be the result of a previous |
| RepoMapping.map_input_path() call. |
| |
| Returns: |
| The target path recorded in the manifest. An empty string if not found. |
| """ |
| return self._map.get(runfile_path, "") |
| |
| def find_source_path_for(self, target_path: str) -> T.Optional[str]: |
| """Find the source path that matches a given target path in the manifest. |
| |
| This can be used to locate a real executable from a Bazel output_base layout. |
| For example, with: |
| |
| bazel-bin/src/foo |
| bazel-bin/src/foo.runfiles/ |
| main_/ |
| src/ |
| foo -----> absolute path of bazel-bin/src/foo |
| |
| find_source_path("<absolute_bazel-bin>/src/foo") will return |
| "main_/src/foo". |
| """ |
| for source, target in self._map.items(): |
| if target == target_path: |
| return source |
| |
| return None |
| |
| @staticmethod |
| def unescape(path: str) -> str: |
| """Unescape a path that appears in a runfile manifest.""" |
| # Reference code from: |
| # https://github.com/bazelbuild/rules_cc/blob/main/cc/runfiles/runfiles.cc#L182 |
| result = "" |
| prev_ch = "\0" |
| ch = "\0" |
| for ch in path: |
| result += ch |
| if prev_ch == "\\": |
| if ch == "s": |
| # Replace \s with a space in result. |
| result = result[:-2] + " " |
| elif ch == "n": |
| # Replace \n with a newline in result. |
| result = result[:-2] + "\n" |
| elif ch == "b": |
| # Replace \\ with a single backslash |
| result = result[:-1] |
| |
| prev_ch = ch |
| return result |
| |
| @staticmethod |
| def escape(path: str) -> str: |
| """Escape a path for the runfile manifest.""" |
| result = "" |
| for ch in path: |
| if ch == "\\": |
| result += "\\b" |
| elif ch == " ": |
| result += "\\s" |
| elif ch == "\n": |
| result += "\\n" |
| else: |
| result += ch |
| return result |
| |
| @staticmethod |
| def path_needs_escaping(path: str) -> bool: |
| """Return True if a path needs escaping.""" |
| return any(ch in " \n\\" for ch in path) |
| |
| @staticmethod |
| def parse_manifest(content: str) -> dict[str, str]: |
| """Parse the content of a manifest file. |
| |
| Args: |
| content: The file's content as a string. |
| Returns: |
| A dictionary mapping runfiles relative source paths |
| to their target location as recorded in the manifest. |
| """ |
| result: dict[str, str] = {} |
| for line in content.splitlines(): |
| if not line: |
| # Stop parsing on empty line. This mimics the behavior |
| # of the C runfiles library parser. |
| break |
| if line[0] == " ": |
| # A line that begins with a space contains escaped paths |
| # separated by a space, e.g.: |
| # <SPACE> <ESCAPED_RUNFILE_PATH> <SPACE> <ESCAPED_FILE_PATH> |
| escaped_source, sep, escaped_target = line[1:].partition(" ") |
| if sep != " ": |
| raise ValueError( |
| f"No space separator in manifest line: [{line}]" |
| ) |
| source = RunfilesManifest.unescape(escaped_source) |
| target = RunfilesManifest.unescape(escaped_target) |
| else: |
| # Otherwise, the line contains two paths separated by spaces. |
| # Note that it is possible for the target path to be empty, but the |
| # space separator will always be present. |
| source, sep, target = line.partition(" ") |
| if sep != " ": |
| raise ValueError( |
| f"No space separator in manifest line: [{line}]" |
| ) |
| |
| result[source] = target |
| return result |
| |
| def remove_legacy_external_runfiles(self, workspace_name: str) -> None: |
| """Remove legacy <project_name>/external/<repo_name>/..." entries. |
| |
| Starting with Bazel 8.0, --legacy_external_runfiles is off and these entries |
| are no longer created, but before that it was enabled by default. These were |
| already omitted when running `bazel test` since Bazel 7.1, but still kept for |
| `bazel build` for legacy reasons. |
| |
| Args: |
| workspace_name: Name of the workspace directory name at the root of the |
| runfiles directory / collection. When BzlMod is enabled, this |
| must always be `_main`. Otherwise this should match the name |
| given in the workspace() directive of the project's WORKSPACE file, |
| or "__main__" if none is provided. |
| """ |
| external_prefix = f"{workspace_name}/external/" |
| paths_to_remove = [ |
| p for p in self._map.keys() if p.startswith(external_prefix) |
| ] |
| for p in paths_to_remove: |
| self._map.pop(p, None) |
| |
| def as_dict(self) -> dict[str, str]: |
| """Return the content of this manifest as a {source_path -> target_path} dictionary.""" |
| return self._map.copy() |
| |
| def generate_content(self) -> str: |
| """Generate a manifest file content from the current instance.""" |
| result = "" |
| for source, target in sorted(self._map.items()): |
| if self.path_needs_escaping(source) or self.path_needs_escaping( |
| target |
| ): |
| result += f" {self.escape(source)} {self.escape(target)}\n" |
| else: |
| result += f"{source} {target}\n" |
| return result |