blob: 4f6154dc58ab868132f226eda3469aa38aaae4f7 [file] [log] [blame]
#!/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.
import json
from argparse import ArgumentParser, BooleanOptionalAction, Namespace
from collections import defaultdict
from dataclasses import dataclass
from os import environ, link, symlink
from pathlib import Path
from shutil import copy, copytree
from subprocess import CalledProcessError, run
from sys import argv, stderr
from tempfile import TemporaryDirectory
from typing import Any, Callable, Optional
from rust import HOST_PLATFORM
# https://fxbug.dev/360942359: This script works, but will not generate cross-crate
# information. The search index, cross-crate trait impls, etc. are broken.
# Functionality using the flags from rust-lang/rfcs#3662 will be added in a
# future patch.
# run ninja to build all rustdoc targets. --no-build will break this
# script unless the third party rust targets are already manually built
@dataclass(frozen=True)
class Metadata:
"""
Metadata collected for one of our rustdoc targets from the
rust_target_mapping walk.
"""
crate_name: str # according to --extern
actual_label: str
rustdoc_label: str
target: str
rustdoc_out_dir: Path
touch: Path
searchdir: str
extern: str # looks like --extern=CRATE_NAME=PATH
disable_rustdoc: bool
@property
def extern_arg_as_rmeta(self) -> str:
"""Returns modified extern argument that points to the .rmeta"""
flag, crate_name, path = self.extern.split("=")
return "=".join(
(flag, crate_name, str(Path(path).with_suffix(".rmeta")))
)
@staticmethod
def parse(m: dict[str, Any], args: Namespace) -> Optional["Metadata"]:
if "extern" not in m:
return None
_, crate_name, _ = m["extern"].split("=")
rustdoc_out_dir = Path(args.build_dir, m["rustdoc_out_dir"])
return Metadata(
crate_name=crate_name,
actual_label=m["actual_label"],
rustdoc_label=m["rustdoc_label"],
target=m["target"],
touch=Path(f"{rustdoc_out_dir}.touch"),
rustdoc_out_dir=rustdoc_out_dir,
extern=m["extern"],
searchdir=m["searchdir"],
disable_rustdoc=m["disable_rustdoc"],
)
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) for m in meta} - {None}
def fx_build_all(meta: list[Metadata], args: Namespace):
"""
Build docs for all rustdoc targets, including
third_party crates. These may not have been documented
during a normal `fx build` run.
Raises:
CalledProcessError if the the build fails.
"""
rustdoc_labels = [m.rustdoc_label for m in meta if not m.disable_rustdoc]
[m.actual_label for m in meta if not m.disable_rustdoc]
# if `fx build` has no arguments, it will build everything. Avoid that.
if len(rustdoc_labels) > 0:
completed = run([args.build_executable] + rustdoc_labels)
completed.check_returncode()
else:
print("did not find any !disable_rustdoc targets to build", file=stderr)
def group_by(items: list, group: Callable) -> dict[str, list]:
"""
groups items by the hashable group returned by `group`
"""
grouped = defaultdict(list)
for m in items:
grouped[group(m)].append(m)
return grouped
def dedup_crate_name(meta: list[Metadata]) -> list[Metadata]:
"""remove metadata for duplicate crate names"""
ret = defaultdict(list)
for m in meta:
ret[m.crate_name].append(m)
for name, l in ret.items():
if len(l) > 1 and not args.quiet:
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 [sorted(l, key=lambda m: m.actual_label)[0] for l in ret.values()]
def copy_tree(
src: Path,
dst: Path,
src_touch: Path,
copy_function: Callable,
dst_touch: Path,
):
"""
Wrap shutil.copytree, ignores exceptions and only copies upon stamp file changing.
"""
dst_touch.mkdir(parents=True, exist_ok=True)
dst_touch = dst_touch / str(src).replace("/", "%")
dst_stat = None
try:
dst_stat = dst_touch.stat()
except FileNotFoundError:
# This file will not exist if it's the first time
# running the copy operation for this target.
# Will be created in this invocation of copy_tree.
# Do not skip the copy.
pass
should_copy = (
dst_stat is None or dst_stat.st_mtime < src_touch.stat().st_mtime
)
if not should_copy:
return
def remove_existing(s, d):
"""os.link and os.symlink fail if the target exists"""
Path(d).unlink(missing_ok=True)
copy_function(s, d)
try:
copytree(
src=src,
dst=dst,
copy_function=remove_existing,
dirs_exist_ok=True,
)
dst_touch.touch()
except Exception as e: # pylint: disable=broad-except
# exceptions become warnings because
# partially complete docs are better than no docs
print(f"copying {src} to {dst} failed: {e}", file=stderr)
def rustdoc_link(meta: list[Metadata], args: Namespace):
"""Runs the rustdodc link phase for all targets."""
assert (
args.touch is None or args.destination is not None
), "--destination must be provided if --touch is provided"
copy_function = {
"symlink": symlink,
"link": link,
"copy": copy,
}[args.copy_function]
searchdir = set(m.searchdir for m in meta)
for target, meta in group_by(meta, lambda m: m.target).items():
meta = dedup_crate_name(meta)
# filter disable_rustdoc late because we still want to
# build deps marked disable_rustdoc for !disable_rustdoc targets
meta = [m for m in meta if not m.disable_rustdoc]
extra_rustdoc_args = (
args.extra_rustdoc_args
if args.extra_rustdoc_args is not None
else []
)
dst = (
args.destination
if "fuchsia" in target
else args.destination / "host"
)
extern = set(m.extern_arg_as_rmeta for m in meta)
if not args.quiet:
print("running rustdoc to document index for", target, file=stderr)
with TemporaryDirectory() as tmp:
crate_root = Path(tmp, "lib.rs")
crate_names = [f"extern crate {m.crate_name};" for m in meta]
crate_root.write_text("\n".join(crate_names))
argfile = Path(tmp, "argfile")
flags = [
str(crate_root),
*extern,
*searchdir,
"--crate-name=fuchsia_rustdoc_index",
"--crate-type=lib",
"--edition=2021",
f"--target={target}",
"-Zunstable-options",
f"--out-dir={dst}",
"--enable-index-page",
*extra_rustdoc_args,
]
argfile.write_text("\n".join(flags))
completed = run(
[args.rustdoc_executable, f"@{argfile}"], cwd=args.build_dir
)
completed.check_returncode()
if not args.quiet:
print("copying docs to destination", dst, file=stderr)
for m in meta:
copy_tree(
src=m.rustdoc_out_dir,
dst=dst,
src_touch=m.touch,
copy_function=copy_function,
dst_touch=args.touch,
)
def main(args: Namespace):
set_arg_defaults(args)
meta = read_metadata_file(args)
# remove "shared" toolchain variants
meta = list(m for m in meta if not m.actual_label.endswith("-shared)"))
if args.build:
try:
fx_build_all(meta, args)
except CalledProcessError:
print(
"Build failed... pass --no-build to still attempt to link the rustdoc outputs",
file=stderr,
)
return
rustdoc_link(meta, args)
def set_arg_defaults(args: Namespace):
"""
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"
assert (
args.build_dir is not None
), "must provide build dir through --build-dir or FUCHSIA_BUILD_DIR environment var"
if args.touch is None:
args.touch = Path(args.build_dir, "docs/rust/touch")
if args.rust_target_mapping is None:
args.rust_target_mapping = Path(
args.build_dir, "rust_target_mapping.json"
)
if args.destination is None:
args.destination = Path(args.build_dir, "docs/rust/doc")
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")
def _main_arg_parser() -> ArgumentParser:
parser = ArgumentParser(
description="Documents all (subject to `build_only_labels`) rust crates, and merges the docs to a single destination."
)
parser.add_argument(
"--fuchsia-dir",
default=environ.get("FUCHSIA_DIR"),
type=str,
help="root fuchsia directory, i.e. //",
)
parser.add_argument(
"--build-dir",
default=environ.get("FUCHSIA_BUILD_DIR"),
type=str,
help="build directory to rebase path to, i.e. out/default",
)
parser.add_argument(
"--touch",
type=Path,
help="Directory to place timestamp files to mark how up-to-date copy operations are. Provide this option to save work copying files.",
)
parser.add_argument(
"--rust-target-mapping",
type=Path,
help="path to json array of rustdoc target info, i.e. out/default/rust_target_mapping.json",
)
parser.add_argument(
"--destination",
type=Path,
help="Directory to place merged documentation. Consider using --touch if you run this with a consistent --destination.",
)
parser.add_argument(
"--copy-function",
default="copy",
choices=["symlink", "link", "copy"],
)
parser.add_argument(
"--rustdoc-executable",
help="path directly to the rustdoc executable",
)
parser.add_argument(
"--build",
default=True,
action=BooleanOptionalAction,
help="run fx build to build extra rustdoc targets. --no-build will break this script unless the third party rust targets are already manually built",
)
parser.add_argument(
"--quiet",
default=False,
action=BooleanOptionalAction,
help="run without printing status messages. failure messages still printed",
)
parser.add_argument(
"--build-executable",
type=Path,
help="path to fx build program",
)
parser.add_argument(
"--verbose",
default=False,
action=BooleanOptionalAction,
help="add additional context to status messages",
)
parser.add_argument(
"--out-dir",
help="ignored",
)
parser.add_argument(
"--extra-rustdoc-args",
nargs="*",
help="forward extra arguments after -- to rustdoc",
)
parser.set_defaults(func=main)
return parser
if __name__ == "__main__":
parser = _main_arg_parser()
args = parser.parse_args(argv[1:])
args.func(args)