| #!/usr/bin/env fuchsia-vendored-python | |
| # 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. | |
| """Builds all Fuchsia Rust documentation. | |
| Please use `include_rustdoc = true`, as in `$ fx set --args=include_rustdoc=true | |
| core.x64`. | |
| This script does three things: | |
| 1. Runs `fx build` to output Rust documentation to target-specific | |
| directories | |
| 2. Runs rustdoc to render files that depend on multiple crates | |
| (e.g. the search index) | |
| 3. Merges all documentation to a doc/ directory | |
| You can skip 1. by passing `--no-build`. You can skip 1., 2., and 3. by passing | |
| `--dry-run`, which instead saves a documentation plan to an actions.json file. | |
| When you provide both `--no-build` and `--dry-run`, we will exclude build | |
| actions from that documentation plan. | |
| Use `--verbose` and `--quiet` to control this script's stderr. | |
| Some options are documented "test only." These are exclusively for use by | |
| //build/rust/tests/rustdoc-link/*. | |
| If you pass specific labels to rustdoc-link, the script will document only | |
| those specific crates. | |
| This script reads out/default/rust_target_mapping.json, which has a list of | |
| rustdoc targets. This script references the FUCHSIA_DIR and FUCHSIA_BUILD_DIR | |
| environment variables, which are typically provided by the devshell wrapper. | |
| This script generates documentation in the path provided as --output-base, | |
| which defaults to out/default/docs/rust. | |
| # Examples | |
| Build docs specifically for fuchsia-async, and put the generated docs at | |
| out/default/docs/rust/doc/fuchsia-async/: | |
| `$ fx rustdoc-link //src/lib/fuchsia-async` | |
| Build docs for all Rust crates in the build graph, and put the generated docs | |
| at out/default/docs/rust/doc/: | |
| `$ fx rustdoc-link` | |
| Save a documentation plan at out/default/docs/rust/actions.json describing how | |
| to document every Rust crate in the build graph. This plan will assume that you | |
| have already built the docs and just need to link and merge them: | |
| `$ fx rustdoc-link --dry-run --verbose --no-build` | |
| # More info | |
| See //tools/devshell/contrib/lib/rust/rustdoc_link_actions.py for a description | |
| of the JSON generated by --dry-run. | |
| See //build/rust/rust_auxiliary.gni for a description of how rustdoc targets | |
| are built in GN. | |
| See <https://doc.rust-lang.org/rustdoc/> for the rustdoc book. | |
| See //BUILD.gn for more about the rust_target_mapping.json. | |
| """ | |
| import json | |
| import os | |
| import shutil | |
| import sys | |
| from argparse import ( | |
| SUPPRESS, | |
| ArgumentParser, | |
| BooleanOptionalAction, | |
| Namespace, | |
| RawTextHelpFormatter, | |
| ) | |
| from collections import defaultdict | |
| from dataclasses import asdict, dataclass | |
| from os import environ | |
| from os.path import relpath | |
| from pathlib import Path | |
| from subprocess import CalledProcessError, run | |
| from sys import argv, stderr | |
| from typing import Any, Optional | |
| from rust import HOST_PLATFORM, GnTarget | |
| from rustdoc_link_actions import ( | |
| Action, | |
| ActionPath, | |
| BuildAction, | |
| CopyAction, | |
| RustdocAction, | |
| TargetAction, | |
| VerifyAction, | |
| ZipAction, | |
| ) | |
| @dataclass(frozen=True) | |
| class Metadata: | |
| """ | |
| Metadata collected for one of our rustdoc targets from the | |
| rust_target_mapping walk. | |
| """ | |
| crate_name: str | |
| actual_label: str | |
| rustdoc_label: str | |
| rustdoc_out_dir: Path | |
| original_label: str | |
| rustdoc_parts_dir: str | |
| target_is_fuchsia: bool | |
| def is_shared_target(self) -> bool: | |
| return self.actual_label.endswith("-shared)") | |
| @staticmethod | |
| def parse(m: dict[str, Any], build_dir: Path) -> Optional["Metadata"]: | |
| if m["disable_rustdoc"]: | |
| return None | |
| rustdoc_out_dir = Path(build_dir, m["rustdoc_out_dir"]) | |
| return Metadata( | |
| crate_name=m["crate_name"], | |
| actual_label=m["actual_label"], | |
| rustdoc_label=m["rustdoc_label"], | |
| rustdoc_out_dir=rustdoc_out_dir, | |
| original_label=m["original_label"], | |
| rustdoc_parts_dir=m["rustdoc_parts_dir"], | |
| target_is_fuchsia=m["target_is_fuchsia"], | |
| ) | |
| def read_metadata_file(args: Namespace) -> set[Metadata]: | |
| """Reads rust_target_mapping.json to get metadata""" | |
| meta = args.rust_target_mapping.read_bytes() | |
| meta = json.loads(meta) | |
| return {Metadata.parse(m, args.build_dir) for m in meta} - {None} | |
| def filter_build_labels( | |
| meta: list[Metadata], | |
| build_dir: Path, | |
| user_labels: list[str], | |
| print_build_labels: bool, | |
| ) -> list[Metadata]: | |
| """ | |
| If the user provides specific labels to build, we should only build those. | |
| """ | |
| assert ( | |
| len(user_labels) > 0 | |
| ), "only call this method if the user provided specific labels to build" | |
| user_labels = set(str(GnTarget(l, build_dir)) for l in user_labels) | |
| filtered_meta = [] | |
| for m in meta: | |
| possible_labels = { | |
| str(GnTarget(m.rustdoc_label, build_dir)), | |
| str(GnTarget(m.original_label, build_dir)), | |
| str(GnTarget(m.actual_label, build_dir)), | |
| } | |
| matching = not possible_labels.isdisjoint(user_labels) | |
| if matching: | |
| filtered_meta.append(m) | |
| if print_build_labels: | |
| filter_output = sorted(m.rustdoc_label for m in filtered_meta) | |
| print("building specific labels", *filter_output, file=stderr) | |
| return filtered_meta | |
| def dedup_crate_name( | |
| meta: list[Metadata], | |
| print_collisions: bool, | |
| ) -> list[Metadata]: | |
| """remove metadata for duplicate crate names""" | |
| ret = defaultdict(list) | |
| for m in meta: | |
| ret[m.crate_name, m.target_is_fuchsia].append(m) | |
| for (name, _target_is_fuchsia), l in ret.items(): | |
| if len(l) > 1 and print_collisions: | |
| print(f"crate_name collision: {name}", file=stderr) | |
| for m in sorted(l, key=lambda m: m.actual_label): | |
| print(f" {m.actual_label}", file=stderr) | |
| # Return alphabetically greatest crates because these are somewhat more likely to be the latest | |
| # version. We can only choose one version of a crate to document due to rustdoc restrictions | |
| # about having multiple crates with the same name. | |
| return [max(l, key=lambda m: m.actual_label) for l in ret.values()] | |
| def generate_target_action( | |
| dst: Path, | |
| build: bool, | |
| argfile: Path, | |
| extra_rustdoc_args: list[str], | |
| meta: list[Metadata], | |
| build_dir: Path, | |
| ) -> TargetAction: | |
| include_parts_dir_args = sorted( | |
| f"--include-parts-dir={m.rustdoc_parts_dir}" for m in meta | |
| ) | |
| # let's document an empty crate to avoid calling rustdoc with no input crate | |
| target_crate_root = "/dev/null" | |
| flags = [ | |
| f"--out-dir={dst}", | |
| "--edition=2021", | |
| "-Zunstable-options", | |
| "--merge=finalize", | |
| # TODO(https://fxbug.dev/441377312): This flag currently does not work | |
| # to generate an index page. Proactively request it anyway. | |
| "--enable-index-page", | |
| *include_parts_dir_args, | |
| *extra_rustdoc_args, | |
| target_crate_root, | |
| ] | |
| argfile.write_text("\n".join(flags)) | |
| # Use a trailing slash dot to copy the contents of the doc | |
| # directory instead of the directory itself. | |
| copy_action = CopyAction( | |
| srcs=sorted( | |
| ActionPath(f"{relpath(m.rustdoc_out_dir, build_dir)}/.") | |
| for m in meta | |
| ), | |
| dst=ActionPath(relpath(dst, build_dir)), | |
| ) | |
| rustdoc_action = RustdocAction( | |
| argfile=ActionPath(relpath(argfile, build_dir)) | |
| ) | |
| if build and len(meta) > 0: | |
| build_action = BuildAction(labels=sorted(m.rustdoc_label for m in meta)) | |
| else: | |
| build_action = None | |
| return TargetAction( | |
| copy_action=copy_action, | |
| rustdoc_action=rustdoc_action, | |
| build_action=build_action, | |
| ) | |
| def execute_build_action( | |
| build_action: BuildAction, | |
| build_executable: Path, | |
| build_dir: Path, | |
| use_xargs: bool, | |
| ) -> None: | |
| if use_xargs: | |
| # We use xargs to avoid calling fx build with too many arguments. xargs checks | |
| # the environment for maximum argument list size and may potentially make | |
| # several calls to fx build in the case that we have too many to fit in one. | |
| labels = "".join(f"{l}\0" for l in build_action.labels) | |
| result = run( | |
| ["xargs", "--null", build_executable], | |
| text=True, | |
| input=labels, | |
| cwd=build_dir, | |
| ) | |
| else: | |
| result = run( | |
| [build_executable, *build_action.labels], | |
| cwd=build_dir, | |
| ) | |
| result.check_returncode() | |
| def execute_rustdoc_action( | |
| rustdoc_action: RustdocAction, rustdoc_executable: Path, build_dir: Path | |
| ) -> None: | |
| result = run( | |
| [rustdoc_executable, f"@{rustdoc_action.argfile}"], cwd=build_dir | |
| ) | |
| result.check_returncode() | |
| def execute_copy_action( | |
| copy_action: CopyAction, | |
| use_xargs: bool, | |
| merge_parallelism: int, | |
| build_dir: Path, | |
| ) -> None: | |
| if use_xargs: | |
| # Let's merge the files into our destination. We use xargs here to take | |
| # advantage of parallelism. It is much (35%) faster than packing everything | |
| # into a single cp invocation. It is much much (75%) faster than shutil.copytree. | |
| result = run( | |
| [ | |
| "xargs", | |
| "--no-run-if-empty", | |
| "--null", | |
| "--max-args=1", | |
| "--max-procs", | |
| str(merge_parallelism), | |
| "cp", | |
| "--recursive", | |
| "--force", | |
| "--target-directory", | |
| copy_action.dst, | |
| ], | |
| cwd=build_dir, | |
| text=True, | |
| input="".join(f"{s}\0" for s in copy_action.srcs), | |
| ) | |
| else: | |
| result = run( | |
| [ | |
| "cp", | |
| "--recursive", | |
| "--force", | |
| "--target-directory", | |
| copy_action.dst, | |
| *copy_action.srcs, | |
| ], | |
| cwd=build_dir, | |
| ) | |
| result.check_returncode() | |
| def execute_target_action( | |
| target_action: TargetAction, | |
| build_executable: Path, | |
| rustdoc_executable: Path, | |
| build_dir: Path, | |
| use_xargs: bool, | |
| merge_parallelism: int, | |
| quiet: bool, | |
| ) -> None: | |
| if target_action.build_action is not None: | |
| execute_build_action( | |
| target_action.build_action, build_executable, build_dir, use_xargs | |
| ) | |
| if not quiet: | |
| print( | |
| "fx rustdoc-link: running rustdoc to build files that depend on multiple crates", | |
| file=stderr, | |
| ) | |
| execute_rustdoc_action( | |
| target_action.rustdoc_action, | |
| rustdoc_executable=rustdoc_executable, | |
| build_dir=build_dir, | |
| ) | |
| if not quiet: | |
| dst = Path(build_dir, target_action.copy_action.dst) | |
| print("fx rustdoc-link: copying docs to", dst, file=stderr) | |
| execute_copy_action( | |
| target_action.copy_action, | |
| use_xargs, | |
| merge_parallelism, | |
| build_dir=build_dir, | |
| ) | |
| def execute_zip_action(zip_action: ZipAction) -> None: | |
| zip_to_without_suffix = Path(zip_action.zip_to).with_suffix("") | |
| shutil.make_archive( | |
| str(zip_to_without_suffix), format="zip", root_dir=zip_action.zip_from | |
| ) | |
| assert Path( | |
| zip_action.zip_to | |
| ).is_file(), "should have created .zip with our intended name" | |
| def execute_verify_action( | |
| verify_action: VerifyAction, | |
| build_dir: Path, | |
| ) -> None: | |
| result = run( | |
| [verify_action.executable, *verify_action.args], | |
| cwd=build_dir, | |
| ) | |
| try: | |
| result.check_returncode() | |
| except CalledProcessError as e: | |
| print( | |
| "fx rustdoc-link: The generated documentation appears broken.", | |
| file=stderr, | |
| ) | |
| print( | |
| "fx rustdoc-link: Check stderr of the above command for more.", | |
| file=stderr, | |
| ) | |
| print(f"fx rustdoc-link: see: {e}", file=stderr) | |
| sys.exit(1) | |
| def execute_action( | |
| action: Action, | |
| build_executable: Path, | |
| rustdoc_executable: Path, | |
| build_dir: Path, | |
| fuchsia_dir: Path, | |
| use_xargs: bool, | |
| merge_parallelism: int, | |
| quiet: bool, | |
| ) -> None: | |
| execute_target_action( | |
| action.host_action, | |
| build_executable=build_executable, | |
| rustdoc_executable=rustdoc_executable, | |
| build_dir=build_dir, | |
| use_xargs=use_xargs, | |
| merge_parallelism=merge_parallelism, | |
| quiet=quiet, | |
| ) | |
| execute_target_action( | |
| action.fuchsia_action, | |
| build_executable=build_executable, | |
| rustdoc_executable=rustdoc_executable, | |
| build_dir=build_dir, | |
| use_xargs=use_xargs, | |
| merge_parallelism=merge_parallelism, | |
| quiet=quiet, | |
| ) | |
| if action.zip_action is not None: | |
| if not quiet: | |
| zip_to = action.zip_action.zip_to | |
| print("fx rustdoc-link: zipping docs to", zip_to, file=stderr) | |
| execute_zip_action(action.zip_action) | |
| execute_verify_action( | |
| verify_action=action.verify_action, | |
| build_dir=build_dir, | |
| ) | |
| def set_arg_defaults(args: Namespace) -> None: | |
| """ | |
| need to set defaults here because they depend on the fuchsia_dir and build_dir args | |
| """ | |
| assert ( | |
| args.fuchsia_dir is not None | |
| ), "must provide fuchsia dir through --fuchsia-dir or FUCHSIA_DIR envirnoment var. This is normally done through the devshell wrapper." | |
| assert ( | |
| args.build_dir is not None | |
| ), "must provide build dir through --build-dir or FUCHSIA_BUILD_DIR environment var. This is normally done through the devshell wrapper." | |
| if args.rust_target_mapping is None: | |
| args.rust_target_mapping = Path( | |
| args.build_dir, "rust_target_mapping.json" | |
| ) | |
| if args.output_base is None: | |
| args.output_base = Path(args.build_dir) / "docs" / "rust" | |
| if args.zip_to is not None: | |
| args.zip_to = Path(args.build_dir, args.zip_to) | |
| if args.rustdoc_executable is None: | |
| args.rustdoc_executable = Path( | |
| args.fuchsia_dir, | |
| "prebuilt/third_party/rust", | |
| HOST_PLATFORM, | |
| "bin/rustdoc", | |
| ) | |
| if args.build_executable is None: | |
| args.build_executable = Path(args.fuchsia_dir, "tools/devshell/build") | |
| if args.extra_rustdoc_arg is None: | |
| args.extra_rustdoc_arg = [] | |
| def make_output_directories(args: Namespace) -> None: | |
| """This type of operation is often done by the build system, but since | |
| we are running outside of that we have to do it manually. | |
| """ | |
| if not args.dry_run: | |
| # remove the destination to ensure that we always document into a fresh | |
| # directory | |
| shutil.rmtree(args.output_base, ignore_errors=True) | |
| # In addition to rustdocs, extra files are stored in output_base: | |
| # | |
| # * actions.json | |
| # * argfiles directory | |
| # | |
| args.output_base.mkdir(parents=True, exist_ok=True) | |
| # Make argfiles directory for `rustdoc @argfiles` invocations. | |
| (args.output_base / "argfiles").mkdir(exist_ok=True) | |
| # Make destination for fuchsia-side docs. | |
| (args.output_base / "doc").mkdir(exist_ok=True) | |
| # Make destination for host-side docs. | |
| (args.output_base / "doc" / "host").mkdir(exist_ok=True) | |
| def generate_action(meta: list[Metadata], args: Namespace) -> Action: | |
| argfiles_dir = args.output_base / "argfiles" | |
| doc_out_dir = args.output_base / "doc" | |
| host_action = generate_target_action( | |
| meta=[m for m in meta if not m.target_is_fuchsia], | |
| dst=doc_out_dir / "host", | |
| argfile=Path(argfiles_dir, "host.args"), | |
| extra_rustdoc_args=args.extra_rustdoc_arg, | |
| build=args.build, | |
| build_dir=args.build_dir, | |
| ) | |
| fuchsia_action = generate_target_action( | |
| meta=[m for m in meta if m.target_is_fuchsia], | |
| dst=doc_out_dir, | |
| argfile=Path(argfiles_dir, "fuchsia.args"), | |
| extra_rustdoc_args=args.extra_rustdoc_arg, | |
| build=args.build, | |
| build_dir=args.build_dir, | |
| ) | |
| zip_action = ( | |
| ZipAction( | |
| zip_from=ActionPath(relpath(doc_out_dir, args.build_dir)), | |
| zip_to=ActionPath(relpath(args.zip_to, args.build_dir)), | |
| ) | |
| if args.zip_to | |
| else None | |
| ) | |
| executable = ( | |
| Path(args.fuchsia_dir) | |
| / "tools" | |
| / "devshell" | |
| / "contrib" | |
| / "lib" | |
| / "rust" | |
| / "rustdoc_link_verify.py" | |
| ) | |
| verify_action = VerifyAction( | |
| executable=ActionPath( | |
| os.path.relpath(sys.executable, start=args.build_dir) | |
| ), | |
| args=[ | |
| ActionPath(relpath(executable, args.build_dir)), | |
| ActionPath(relpath(doc_out_dir, args.build_dir)), | |
| ], | |
| ) | |
| return Action( | |
| host_action=host_action, | |
| fuchsia_action=fuchsia_action, | |
| zip_action=zip_action, | |
| verify_action=verify_action, | |
| ) | |
| def main(args: Namespace) -> None: | |
| set_arg_defaults(args) | |
| make_output_directories(args) | |
| if args.verbose: | |
| print("fx rustdoc-link: using arguments", vars(args), file=stderr) | |
| meta = read_metadata_file(args) | |
| # remove shared targets | |
| meta = [m for m in meta if not m.is_shared_target()] | |
| # filter according to the specific targets that the user wants to build | |
| if args.build_labels: | |
| meta = filter_build_labels( | |
| meta, | |
| args.build_dir, | |
| args.build_labels, | |
| print_build_labels=args.verbose, | |
| ) | |
| meta = dedup_crate_name(meta, print_collisions=args.verbose) | |
| action = generate_action(meta, args) | |
| if args.dry_run: | |
| save_actions_to = Path(args.output_base, "actions.json") | |
| if not args.quiet: | |
| print( | |
| f"fx rustdoc-link: dry run, saving actions to {save_actions_to}" | |
| ) | |
| with save_actions_to.open("w") as save_actions_to: | |
| json.dump(asdict(action), save_actions_to, indent=4) | |
| else: | |
| try: | |
| execute_action( | |
| action=action, | |
| build_executable=args.build_executable, | |
| rustdoc_executable=args.rustdoc_executable, | |
| build_dir=args.build_dir, | |
| fuchsia_dir=args.fuchsia_dir, | |
| use_xargs=args.use_xargs, | |
| merge_parallelism=args.merge_parallelism, | |
| quiet=args.quiet, | |
| ) | |
| except CalledProcessError as e: | |
| print( | |
| f"rustdoc-link: Failed to build and link docs. The call to {e.cmd[0]} was not successful.", | |
| file=stderr, | |
| ) | |
| print( | |
| f"rustdoc-link: We attempted to run", | |
| *e.cmd, | |
| f"and got code {e.returncode}. Check stdout and stderr above.", | |
| file=stderr, | |
| ) | |
| def _main_arg_parser() -> ArgumentParser: | |
| # __doc__ refers to the doc comment at the top of this file. Use the raw formatter | |
| # so that newlines in the doc comment are newlines in the --help text. | |
| parser = ArgumentParser( | |
| description=__doc__, | |
| formatter_class=RawTextHelpFormatter, | |
| ) | |
| # we don't want consumers to rely on these being present, since they are | |
| # implementation details that may be removed at any time. | |
| help_debug_only = SUPPRESS | |
| # path directly to the rustdoc executable. Can be relative to the current working directory. | |
| parser.add_argument( | |
| "--rustdoc-executable", | |
| help=help_debug_only, | |
| ) | |
| parser.add_argument( | |
| "--build", | |
| default=True, | |
| action=BooleanOptionalAction, | |
| help="run fx build to build extra rustdoc targets. manually run fx build beforehand if you use --no-build", | |
| ) | |
| parser.add_argument( | |
| "--quiet", | |
| default=False, | |
| action=BooleanOptionalAction, | |
| help="do not print status messages. failure messages still printed", | |
| ) | |
| parser.add_argument( | |
| "--verbose", | |
| default=False, | |
| action=BooleanOptionalAction, | |
| help="add additional context to status messages", | |
| ) | |
| parser.add_argument( | |
| "--dry-run", | |
| default=False, | |
| action=BooleanOptionalAction, | |
| help="do not build, copy, run rustdoc, or zip anything. creates an actions.json file instead", | |
| ) | |
| parser.add_argument( | |
| "build_labels", | |
| nargs="*", | |
| help="build only these labels if supplied", | |
| ) | |
| # Path to fx build program. Defaults to the in-tree source of `fx build`. | |
| parser.add_argument( | |
| "--build-executable", | |
| type=Path, | |
| help=help_debug_only, | |
| ) | |
| # How much parallelism to use in scheduling the tasks that merge docs. | |
| parser.add_argument( | |
| "--merge-parallelism", | |
| default=os.cpu_count(), | |
| type=int, | |
| help=help_debug_only, | |
| ) | |
| parser.add_argument( | |
| "--out-dir", | |
| help="ignored for compatibility with rust devshell wrapper", | |
| ) | |
| parser.add_argument( | |
| "--use-xargs", | |
| default=shutil.which("xargs") is not None, | |
| action=BooleanOptionalAction, | |
| help=help_debug_only, | |
| ) | |
| parser.add_argument( | |
| "--rust-target-mapping", | |
| type=Path, | |
| help="Path to rust_target_mapping.json, defaults to arg.build_dir/rust_target_mapping.json", | |
| ) | |
| parser.add_argument( | |
| "--output-base", | |
| type=Path, | |
| help="Base output directory for writing merged doc and an optional zip, defaults to arg.build_dir/docs/rust.", | |
| ) | |
| parser.add_argument( | |
| "--zip-to", | |
| type=Path, | |
| help="When set, creates a zip file at the specified location.", | |
| ) | |
| parser.add_argument( | |
| "--fuchsia-dir", | |
| default=environ.get("FUCHSIA_DIR"), | |
| type=str, | |
| help="test only. fuchsia checkout directory, e.g. ~/fuchsia. typically provided by the devshell wrapper. Can be relative to the current working directory", | |
| ) | |
| parser.add_argument( | |
| "--build-dir", | |
| default=environ.get("FUCHSIA_BUILD_DIR"), | |
| type=str, | |
| help="test only. build directory to rebase path to, e.g. $FUCHSIA_DIR/out/default. typically provided by the devshell wrapper. can be relative to the current working directory", | |
| ) | |
| parser.add_argument( | |
| "--extra-rustdoc-arg", | |
| action="append", | |
| help=help_debug_only, | |
| ) | |
| # generates the actions.json documentation plan at this location | |
| parser.add_argument( | |
| "--save-actions-to", | |
| type=Path, | |
| help=help_debug_only, | |
| ) | |
| parser.set_defaults(func=main) | |
| return parser | |
| if __name__ == "__main__": | |
| parser = _main_arg_parser() | |
| args = parser.parse_args(argv[1:]) | |
| args.func(args) |