From 60b9859419086f8e67251e973f66ab4d0317fcc8 Mon Sep 17 00:00:00 2001 From: David Havas Date: Wed, 15 Jul 2026 18:32:50 +0000 Subject: [PATCH] Add rust_workspace_doc for merged workspace documentation (#1837) Adds a `rust_workspace_doc` rule that documents every workspace crate transitively reachable from its `deps` and merges the results into a single documentation tree with a unified search index, like `cargo doc` in a Cargo workspace. The implementation uses the rustdoc cross-crate merge flags from RFC 3662 (`--merge`, `--parts-out-dir`, `--include-parts-dir`), which are nightly-only until rust-lang/rust#130676 stabilizes: - A `rust_workspace_doc_aspect` walks the dependency graph and runs one independently cached `rustdoc --merge=none` action per crate, producing an HTML directory and a cross-crate-info parts directory. - A finalize action documents a stub crate with `--merge=finalize` to produce the merged search index and (by default) a root index page listing all crates (`--enable-index-page`). - A new `doc_merger` helper assembles the final tree, exported both as a directory and as a `.zip` suitable for archiving. A `tools/rustdoc_workspace:gen_workspace_docs` binary offers a rule-free alternative in the style of `gen_rust_project`: it applies the aspect to target patterns, discovers the per-crate outputs from the build event stream (including under remote execution, where output paths are reconstructed from BEP `pathPrefix` entries and downloads are forced with `--remote_download_regex`), runs the finalize step and assembles the tree into a chosen output directory. Also: - Binary crates are documented like `cargo doc --bins`: their outputs use a distinct `_bin` directory suffix and a binary whose crate name collides with another documented crate yields to it. Colliding library crates remain an analysis error. - `process_wrapper` gains a repeated `--mkdir` flag, used to pre-create dependency directories in each crate's out-dir so rustdoc emits relative cross-crate links into the merged tree. - `rustdoc_compile_action` accepts `attr`/`file`/`files` overrides so aspects can reuse it, and tolerates synthetic crates without output. - External crates are skipped by default; the new `//rust/settings:rustdoc_workspace_include_external` flag opts in. - The repeatable `//rust/settings:rustdoc_workspace_extra_flag` setting adds flags (e.g. `-Dwarnings`, `--document-private-items`) to every per-crate rustdoc invocation. - On non-nightly toolchains the rule fails with a descriptive error and the aspect produces no outputs, so example targets can be gated with `target_compatible_with` on the toolchain channel. --- docs/BUILD.bazel | 2 + rust/defs.bzl | 11 + rust/private/rustdoc.bzl | 53 +- rust/private/rustdoc/doc_merger/BUILD.bazel | 8 + rust/private/rustdoc/doc_merger/doc_merger.rs | 121 ++++ rust/private/rustdoc_workspace.bzl | 598 ++++++++++++++++++ rust/settings/BUILD.bazel | 6 + rust/settings/settings.bzl | 31 + test/rustdoc_workspace/BUILD.bazel | 45 ++ test/rustdoc_workspace/alpha.rs | 14 + test/rustdoc_workspace/beta.rs | 9 + test/rustdoc_workspace/gamma.rs | 5 + test/rustdoc_workspace/workspace_docs_test.rs | 108 ++++ test/unit/rustdoc_workspace/BUILD.bazel | 3 + .../rustdoc_workspace_unit_test.bzl | 508 +++++++++++++++ test/unit/rustdoc_workspace/wd_base.rs | 4 + test/unit/rustdoc_workspace/wd_base_bin.rs | 3 + test/unit/rustdoc_workspace/wd_mid_alpha.rs | 6 + test/unit/rustdoc_workspace/wd_mid_beta.rs | 6 + test/unit/rustdoc_workspace/wd_root.rs | 5 + tools/rustdoc_workspace/BUILD.bazel | 25 + .../bin/gen_workspace_docs.rs | 490 ++++++++++++++ util/process_wrapper/main.rs | 6 + util/process_wrapper/options.rs | 10 + 24 files changed, 2060 insertions(+), 17 deletions(-) create mode 100644 rust/private/rustdoc/doc_merger/BUILD.bazel create mode 100644 rust/private/rustdoc/doc_merger/doc_merger.rs create mode 100644 rust/private/rustdoc_workspace.bzl create mode 100644 test/rustdoc_workspace/BUILD.bazel create mode 100644 test/rustdoc_workspace/alpha.rs create mode 100644 test/rustdoc_workspace/beta.rs create mode 100644 test/rustdoc_workspace/gamma.rs create mode 100644 test/rustdoc_workspace/workspace_docs_test.rs create mode 100644 test/unit/rustdoc_workspace/BUILD.bazel create mode 100644 test/unit/rustdoc_workspace/rustdoc_workspace_unit_test.bzl create mode 100644 test/unit/rustdoc_workspace/wd_base.rs create mode 100644 test/unit/rustdoc_workspace/wd_base_bin.rs create mode 100644 test/unit/rustdoc_workspace/wd_mid_alpha.rs create mode 100644 test/unit/rustdoc_workspace/wd_mid_beta.rs create mode 100644 test/unit/rustdoc_workspace/wd_root.rs create mode 100644 tools/rustdoc_workspace/BUILD.bazel create mode 100644 tools/rustdoc_workspace/bin/gen_workspace_docs.rs diff --git a/docs/BUILD.bazel b/docs/BUILD.bazel index e0dcce339c..f652857308 100644 --- a/docs/BUILD.bazel +++ b/docs/BUILD.bazel @@ -121,6 +121,8 @@ stardoc( symbol_names = [ "rust_doc", "rust_doc_test", + "rust_workspace_doc", + "rust_workspace_doc_aspect", ], table_of_contents_template = "@stardoc//stardoc:templates/markdown_tables/table_of_contents.vm", deps = [":all_docs"], diff --git a/rust/defs.bzl b/rust/defs.bzl index 4f2ef72582..293026ad12 100644 --- a/rust/defs.bzl +++ b/rust/defs.bzl @@ -67,6 +67,11 @@ load( "//rust/private:rustdoc_test.bzl", _rust_doc_test = "rust_doc_test", ) +load( + "//rust/private:rustdoc_workspace.bzl", + _rust_workspace_doc = "rust_workspace_doc", + _rust_workspace_doc_aspect = "rust_workspace_doc_aspect", +) load( "//rust/private:rustfmt.bzl", _rustfmt_aspect = "rustfmt_aspect", @@ -108,6 +113,12 @@ rust_doc = _rust_doc rust_doc_test = _rust_doc_test # See @rules_rust//rust/private:rustdoc_test.bzl for a complete description. +rust_workspace_doc = _rust_workspace_doc +# See @rules_rust//rust/private:rustdoc_workspace.bzl for a complete description. + +rust_workspace_doc_aspect = _rust_workspace_doc_aspect +# See @rules_rust//rust/private:rustdoc_workspace.bzl for a complete description. + clippy_flag = _clippy_flag clippy_flags = _clippy_flags # See @rules_rust//rust/private:clippy.bzl for a complete description. diff --git a/rust/private/rustdoc.bzl b/rust/private/rustdoc.bzl index b3c21b8057..32097dfd6e 100644 --- a/rust/private/rustdoc.bzl +++ b/rust/private/rustdoc.bzl @@ -65,7 +65,10 @@ def rustdoc_compile_action( output = None, rustdoc_flags = [], is_test = False, - force_depend_on_objects = None): + force_depend_on_objects = None, + attr = None, + file = None, + files = None): """Create a struct of information needed for a `rustdoc` compile action based on crate passed to the rustdoc rule. Args: @@ -78,6 +81,12 @@ def rustdoc_compile_action( is_test (bool, optional): If True, the action will be configured for `rust_doc_test` targets force_depend_on_objects (bool, optional): If set, overrides is_test for controlling whether to depend on .rlib files instead of .rmeta. Defaults to is_test. + attr (struct, optional): The attributes to read target info from. Defaults + to `ctx.attr`. Aspects should pass `ctx.rule.attr`. + file (struct, optional): The files struct to read single-file attributes + from. Defaults to `ctx.file`. Aspects should pass `ctx.rule.file`. + files (struct, optional): The files struct to read multi-file attributes + from. Defaults to `ctx.files`. Aspects should pass `ctx.rule.files`. Returns: struct: A struct of some `ctx.actions.run` arguments. @@ -93,6 +102,13 @@ def rustdoc_compile_action( expand_directories = False, ) + if attr == None: + attr = ctx.attr + if file == None: + file = ctx.file + if files == None: + files = ctx.files + # Specify rustc flags for lints, if they were provided. lint_files = [] if lints_info: @@ -101,14 +117,14 @@ def rustdoc_compile_action( # Collect HTML customization files html_input_files = [] - if hasattr(ctx.file, "html_in_header") and ctx.file.html_in_header: - html_input_files.append(ctx.file.html_in_header) - if hasattr(ctx.file, "html_before_content") and ctx.file.html_before_content: - html_input_files.append(ctx.file.html_before_content) - if hasattr(ctx.file, "html_after_content") and ctx.file.html_after_content: - html_input_files.append(ctx.file.html_after_content) - if hasattr(ctx.files, "markdown_css"): - html_input_files.extend(ctx.files.markdown_css) + if hasattr(file, "html_in_header") and file.html_in_header: + html_input_files.append(file.html_in_header) + if hasattr(file, "html_before_content") and file.html_before_content: + html_input_files.append(file.html_before_content) + if hasattr(file, "html_after_content") and file.html_after_content: + html_input_files.append(file.html_after_content) + if hasattr(files, "markdown_css"): + html_input_files.extend(files.markdown_css) cc_toolchain, feature_configuration = find_cc_toolchain(ctx) @@ -120,8 +136,8 @@ def rustdoc_compile_action( compile_inputs, out_dir, build_env_files, build_flags_files, linkstamp_outs, ambiguous_libs = collect_inputs( ctx = ctx, - file = ctx.file, - files = ctx.files, + file = file, + files = files, linkstamps = depset([]), toolchain = toolchain, cc_toolchain = cc_toolchain, @@ -169,8 +185,8 @@ def rustdoc_compile_action( args, env = construct_arguments( ctx = ctx, - attr = ctx.attr, - file = ctx.file, + attr = attr, + file = file, toolchain = toolchain, tool_path = toolchain.rust_doc.short_path if is_test else toolchain.rust_doc.path, cc_toolchain = cc_toolchain, @@ -201,8 +217,11 @@ def rustdoc_compile_action( if "OUT_DIR" in env: env.update({"OUT_DIR": "${{pwd}}/{}".format(build_info.out_dir.short_path)}) - # Create the combined inputs including HTML customization files - all_inputs = depset([crate_info.output], transitive = [compile_inputs, depset(html_input_files)]) + # Create the combined inputs including HTML customization files. Synthetic + # crates (e.g. the merge-finalize stub of `rust_workspace_doc`) have no + # compiled output to depend on. + direct_inputs = [crate_info.output] if crate_info.output else [] + all_inputs = depset(direct_inputs, transitive = [compile_inputs, depset(html_input_files)]) return struct( executable = ctx.executable._process_wrapper, @@ -213,7 +232,7 @@ def rustdoc_compile_action( tools = [toolchain.rust_doc], ) -def _zip_action(ctx, input_dir, output_zip, crate_label): +def zip_action(ctx, input_dir, output_zip, crate_label): """Creates an archive of the generated documentation from `rustdoc` Args: @@ -301,7 +320,7 @@ def _rust_doc_impl(ctx): ) # This rule does nothing without a single-file output, though the directory should've sufficed. - _zip_action(ctx, output_dir, ctx.outputs.rust_doc_zip, crate.label) + zip_action(ctx, output_dir, ctx.outputs.rust_doc_zip, crate.label) return [ DefaultInfo( diff --git a/rust/private/rustdoc/doc_merger/BUILD.bazel b/rust/private/rustdoc/doc_merger/BUILD.bazel new file mode 100644 index 0000000000..bd1ef4fe8e --- /dev/null +++ b/rust/private/rustdoc/doc_merger/BUILD.bazel @@ -0,0 +1,8 @@ +load("//rust/private:rust.bzl", "rust_binary") + +rust_binary( + name = "doc_merger", + srcs = ["doc_merger.rs"], + edition = "2024", + visibility = ["//visibility:public"], +) diff --git a/rust/private/rustdoc/doc_merger/doc_merger.rs b/rust/private/rustdoc/doc_merger/doc_merger.rs new file mode 100644 index 0000000000..2beb2fde13 --- /dev/null +++ b/rust/private/rustdoc/doc_merger/doc_merger.rs @@ -0,0 +1,121 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +const USAGE: &str = r#"usage: doc_merger --output --inputs ... + +Merges multiple rustdoc output directories into a single documentation tree. + +Args: + --output: Directory to write the merged documentation to. + --inputs: Rustdoc output directories to copy into the output directory, in + order. Later directories overwrite colliding files from earlier ones, so + the directory produced by `rustdoc --merge=finalize` must be passed last. +"#; + +macro_rules! die { + ($($arg:tt)*) => { + { + eprintln!($($arg)*); + std::process::exit(1); + } + }; +} + +struct Args { + output: PathBuf, + inputs: Vec, +} + +fn parse_args() -> Args { + let mut output = None; + let mut inputs = Vec::new(); + + #[derive(PartialEq)] + enum State { + None, + Output, + Inputs, + } + + let mut state = State::None; + for arg in std::env::args().skip(1) { + match arg.as_str() { + "--output" => state = State::Output, + "--inputs" => state = State::Inputs, + _ => match state { + State::Output => { + output = Some(PathBuf::from(&arg)); + state = State::None; + } + State::Inputs => inputs.push(PathBuf::from(&arg)), + State::None => die!("unexpected argument `{}`\n{}", arg, USAGE), + }, + } + } + + let output = output.unwrap_or_else(|| die!("missing --output\n{}", USAGE)); + if inputs.is_empty() { + die!("missing --inputs\n{}", USAGE); + } + + Args { output, inputs } +} + +/// Recursively copy the contents of `src` into `dst`, overwriting existing files. +fn copy_tree(src: &Path, dst: &Path) { + let entries = fs::read_dir(src) + .unwrap_or_else(|e| die!("fatal: failed to read directory {}: {}", src.display(), e)); + for entry in entries { + let entry = entry + .unwrap_or_else(|e| die!("fatal: failed to read entry in {}: {}", src.display(), e)); + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + let file_type = entry + .file_type() + .unwrap_or_else(|e| die!("fatal: failed to stat {}: {}", src_path.display(), e)); + + // rustdoc leaves a write-only `.lock` flock file in its output + // directory which is not part of the documentation. + if entry.file_name() == ".lock" { + continue; + } + + // Tree artifact inputs may be exposed as symlinks; resolve them. + let is_dir = if file_type.is_symlink() { + src_path.is_dir() + } else { + file_type.is_dir() + }; + + if is_dir { + fs::create_dir_all(&dst_path) + .unwrap_or_else(|e| die!("fatal: failed to create {}: {}", dst_path.display(), e)); + copy_tree(&src_path, &dst_path); + } else { + // Copies preserve permissions, so an earlier copy of a read-only + // file must be removed before it can be overwritten. + if let Err(first_error) = fs::copy(&src_path, &dst_path) { + let _ = fs::remove_file(&dst_path); + fs::copy(&src_path, &dst_path).unwrap_or_else(|_| { + die!( + "fatal: failed to copy {} to {}: {}", + src_path.display(), + dst_path.display(), + first_error + ) + }); + } + } + } +} + +fn main() { + let args = parse_args(); + + fs::create_dir_all(&args.output) + .unwrap_or_else(|e| die!("fatal: failed to create {}: {}", args.output.display(), e)); + + for input in &args.inputs { + copy_tree(input, &args.output); + } +} diff --git a/rust/private/rustdoc_workspace.bzl b/rust/private/rustdoc_workspace.bzl new file mode 100644 index 0000000000..4ac5576a01 --- /dev/null +++ b/rust/private/rustdoc_workspace.bzl @@ -0,0 +1,598 @@ +# Copyright 2026 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Rules for generating merged `rustdoc` documentation for a workspace of crates""" + +load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo") +load("//rust/private:common.bzl", "rust_common") +load("//rust/private:providers.bzl", "LintsInfo") +load("//rust/private:rustdoc.bzl", "rustdoc_compile_action", "zip_action") +load( + "//rust/private:utils.bzl", + "dedent", + "find_toolchain", +) + +RustWorkspaceDocInfo = provider( + doc = "A provider containing rustdoc outputs gathered by `rust_workspace_doc_aspect`.", + fields = { + "crate_docs": ( + "depset[struct]: Transitively collected per-crate rustdoc outputs. Each entry " + + "has the fields `name` (str, the crate name), `crate_root_path` (str, the path " + + "of the crate root source file, used for deduplication), `label_str` (str, the " + + "label of the documented target), `is_bin` (bool, whether the crate is a " + + "binary), `html_dir` (File, the crate's rustdoc output directory) and " + + "`parts_dir` (File, the crate's `--parts-out-dir` cross-crate information " + + "directory)." + ), + "html_dirs": "depset[File]: The `html_dir`s of `crate_docs`.", + "parts_dirs": "depset[File]: The `parts_dir`s of `crate_docs`.", + }, +) + +ExtraRustdocFlagsInfo = provider( + doc = "Extra flags to pass to every per-crate rustdoc invocation of `rust_workspace_doc_aspect`.", + fields = {"flags": "List[string]: Flags to pass to rustdoc"}, +) + +def _rustdoc_workspace_extra_flag_impl(ctx): + return ExtraRustdocFlagsInfo(flags = [f for f in ctx.build_setting_value if f != ""]) + +rustdoc_workspace_extra_flag = rule( + doc = ( + "Add a flag to every per-crate rustdoc invocation of `rust_workspace_doc_aspect` " + + "from the command line with " + + "`--@rules_rust//rust/settings:rustdoc_workspace_extra_flag`. Multiple uses are " + + "accumulated. Use the `rustdoc_flags` attribute of `rust_workspace_doc` to pass " + + "flags to the finalizing invocation instead." + ), + implementation = _rustdoc_workspace_extra_flag_impl, + build_setting = config.string_list(flag = True, repeatable = True), +) + +_NIGHTLY_ERROR = ( + "{} requires a nightly Rust toolchain: the `rustdoc` cross-crate merge flags " + + "(`--merge`, `--parts-out-dir`, `--include-parts-dir` from RFC 3662) are unstable " + + "and gated behind `-Zunstable-options`. Configure a nightly toolchain, e.g. with " + + "`--@rules_rust//rust/toolchain/channel=nightly`. See " + + "https://github.com/rust-lang/rust/issues/130676 for the stabilization status." +) + +# Targets with any of these tags are not documented. +_IGNORE_TAGS = [ + "no_docs", + "nodocs", + "no_rustdoc", + "norustdoc", +] + +def _get_docable_crate_info(target, ctx, include_external): + """Determine whether a target should be documented and return its `CrateInfo`. + + Args: + target (Target): The target the aspect is running on. + ctx (ctx): The aspect's context object. + include_external (bool): Whether crates from external repositories should + be documented. + + Returns: + CrateInfo, optional: The target's `CrateInfo` if it should be documented. + """ + if not include_external and target.label.workspace_root.startswith("external"): + return None + + for tag in ctx.rule.attr.tags: + if tag.replace("-", "_").lower() in _IGNORE_TAGS: + return None + + # Test crates are intentionally not documented, matching `cargo doc`. + if rust_common.test_crate_info in target: + return None + + if rust_common.crate_info not in target: + return None + + crate_info = target[rust_common.crate_info] + if crate_info.is_test: + return None + + return crate_info + +def _collect_transitive_crate_docs(ctx): + """Gather `RustWorkspaceDocInfo` depsets from all dependency attributes. + + Args: + ctx (ctx): The aspect's context object. + + Returns: + list[RustWorkspaceDocInfo]: The providers of all dependencies. + """ + infos = [] + for attr_name in ("deps", "proc_macro_deps", "crate", "actual"): + dep_or_deps = getattr(ctx.rule.attr, attr_name, None) + deps = dep_or_deps if type(dep_or_deps) == "list" else [dep_or_deps] + for dep in deps: + if dep != None and RustWorkspaceDocInfo in dep: + infos.append(dep[RustWorkspaceDocInfo]) + return infos + +def _rust_workspace_doc_aspect_impl(target, ctx): + """The implementation of the `rust_workspace_doc_aspect` aspect + + Args: + target (Target): The target the aspect is running on. + ctx (ctx): The aspect's context object. + + Returns: + list: A list of providers. + """ + dep_infos = _collect_transitive_crate_docs(ctx) + transitive_docs = [info.crate_docs for info in dep_infos] + transitive_html = [info.html_dirs for info in dep_infos] + transitive_parts = [info.parts_dirs for info in dep_infos] + + include_external = ctx.attr._include_external[BuildSettingInfo].value + crate_info = _get_docable_crate_info(target, ctx, include_external) + + toolchain = find_toolchain(ctx) + + # The `rustdoc` merge flags require a nightly toolchain. Produce no + # documentation instead of failing so that targets which gracefully + # handle the missing toolchain (e.g. via `target_compatible_with`) + # do not break dependency analysis; `rust_workspace_doc` itself + # fails with a descriptive error. + if not crate_info or toolchain.channel != "nightly": + info = RustWorkspaceDocInfo( + crate_docs = depset(transitive = transitive_docs), + html_dirs = depset(transitive = transitive_html), + parts_dirs = depset(transitive = transitive_parts), + ) + return [ + info, + OutputGroupInfo( + rustdoc_crate_dir = info.html_dirs, + rustdoc_crate_parts = info.parts_dirs, + ), + ] + + # Binary crates get a distinct directory suffix: like `cargo doc`, a + # binary whose crate name collides with another documented crate must + # yield to it, which the consumers of these outputs resolve when + # assembling the merged tree. + dir_name = "{}.rustdoc_workspace{}".format( + ctx.label.name, + "_bin" if crate_info.type == "bin" else "", + ) + html_dir = ctx.actions.declare_directory("{}/html".format(dir_name)) + parts_dir = ctx.actions.declare_directory("{}/parts".format(dir_name)) + + rustdoc_flags = ctx.actions.args() + rustdoc_flags.add_all( + [crate_info.output], + format_each = "--extern={}=%s".format(crate_info.name), + expand_directories = False, + ) + rustdoc_flags.add("-Zunstable-options") + rustdoc_flags.add("--merge=none") + rustdoc_flags.add_all( + [parts_dir], + before_each = "--parts-out-dir", + expand_directories = False, + ) + + # User-provided flags come last so they can override anything above. + rustdoc_flags.add_all(ctx.attr._extra_rustdoc_flag[ExtraRustdocFlagsInfo].flags) + + lints_info = target[LintsInfo] if LintsInfo in target else None + + action = rustdoc_compile_action( + ctx = ctx, + toolchain = toolchain, + crate_info = crate_info, + lints_info = lints_info, + output = html_dir, + rustdoc_flags = rustdoc_flags, + attr = ctx.rule.attr, + file = ctx.rule.file, + files = ctx.rule.files, + ) + + # rustdoc only generates links into another crate's documentation if that + # crate's directory already exists in the output directory. Pre-create a + # directory for every dependency that is part of the merged documentation + # so cross-crate links resolve within the merged tree. + mkdir_args = ctx.actions.args() + dep_names = {doc.name: None for doc in depset(transitive = transitive_docs).to_list()} + for dep_name in dep_names: + mkdir_args.add_all( + [html_dir], + before_each = "--mkdir", + format_each = "%s/" + dep_name, + expand_directories = False, + ) + + ctx.actions.run( + mnemonic = "RustdocMerge", + progress_message = "Generating mergeable Rustdoc for {}".format(ctx.label), + outputs = [html_dir, parts_dir], + executable = action.executable, + inputs = action.inputs, + env = action.env, + arguments = [mkdir_args] + action.arguments, + tools = action.tools, + toolchain = Label("//rust:toolchain_type"), + execution_requirements = {"supports-path-mapping": ""} if action.supports_path_mapping else None, + ) + + crate_doc = struct( + name = crate_info.name, + crate_root_path = crate_info.root.path, + label_str = str(ctx.label), + is_bin = crate_info.type == "bin", + html_dir = html_dir, + parts_dir = parts_dir, + ) + + # The output groups are transitive so that documentation for the full + # dependency closure is built no matter which targets a pattern matches. + info = RustWorkspaceDocInfo( + crate_docs = depset([crate_doc], transitive = transitive_docs), + html_dirs = depset([html_dir], transitive = transitive_html), + parts_dirs = depset([parts_dir], transitive = transitive_parts), + ) + return [ + info, + OutputGroupInfo( + rustdoc_crate_dir = info.html_dirs, + rustdoc_crate_parts = info.parts_dirs, + ), + ] + +# Example: Generate mergeable rustdoc outputs for all crates in the workspace. +# bazel build --aspects=@rules_rust//rust:defs.bzl%rust_workspace_doc_aspect \ +# --output_groups=rustdoc_crate_dir \ +# //... +rust_workspace_doc_aspect = aspect( + fragments = ["cpp"], + attr_aspects = ["deps", "proc_macro_deps", "crate", "actual"], + attrs = { + "_error_format": attr.label( + default = Label("//rust/settings:error_format"), + ), + "_extra_rustdoc_flag": attr.label( + doc = "Extra flags to pass to every per-crate rustdoc invocation.", + default = Label("//rust/settings:rustdoc_workspace_extra_flag"), + ), + "_include_external": attr.label( + doc = "Whether crates from external repositories are documented.", + default = Label("//rust/settings:rustdoc_workspace_include_external"), + ), + "_process_wrapper": attr.label( + doc = "A process wrapper for running rustdoc on all platforms", + default = Label("@rules_rust//util/process_wrapper"), + executable = True, + allow_single_file = True, + cfg = "exec", + ), + }, + provides = [RustWorkspaceDocInfo], + toolchains = [ + str(Label("//rust:toolchain_type")), + config_common.toolchain_type("@bazel_tools//tools/cpp:toolchain_type", mandatory = False), + ], + implementation = _rust_workspace_doc_aspect_impl, + doc = dedent("""\ + Generates rustdoc documentation for a crate and all its transitive + dependencies in a form that can be merged into a single documentation + tree by `rust_workspace_doc`. + + Each documented crate produces two directories: the crate's rendered + HTML documentation and its cross-crate information "parts" + (`--parts-out-dir`), which `rust_workspace_doc` merges into a unified + search index. + + The `rustdoc` merge flags are unstable + (https://github.com/rust-lang/rust/issues/130676), so this aspect + requires a nightly Rust toolchain and produces no documentation + outputs on other toolchain channels. + """), +) + +def _sanitize_crate_name(name): + """Convert a target name into a valid crate name for the finalize stub. + + Args: + name (str): The name to sanitize. + + Returns: + str: A valid crate name. + """ + sanitized = "".join([c if c.isalnum() else "_" for c in name.elems()]) + if sanitized[0].isdigit(): + sanitized = "_" + sanitized + return sanitized + +def _rust_workspace_doc_impl(ctx): + """The implementation of the `rust_workspace_doc` rule + + Args: + ctx (ctx): The rule's context object + + Returns: + list: A list of providers. + """ + toolchain = find_toolchain(ctx) + if toolchain.channel != "nightly": + fail(_NIGHTLY_ERROR.format("rust_workspace_doc target '{}'".format(ctx.label))) + + all_docs = depset(transitive = [ + dep[RustWorkspaceDocInfo].crate_docs + for dep in ctx.attr.deps + if RustWorkspaceDocInfo in dep + ]).to_list() + + # The same crate may be reachable in multiple configurations (e.g. both the + # target and exec configuration via proc-macro dependencies). Deduplicate + # by the path of the crate root source file. + docs_by_root = {} + for doc in all_docs: + if doc.crate_root_path not in docs_by_root: + docs_by_root[doc.crate_root_path] = doc + crate_docs = docs_by_root.values() + + if not crate_docs: + fail( + "rust_workspace_doc target '{}' found no crates to document. ".format(ctx.label) + + "Ensure `deps` contains Rust targets (or targets that transitively depend on " + + "them) from the current workspace, or enable " + + "`--@rules_rust//rust/settings:rustdoc_workspace_include_external` to " + + "document crates from external repositories.", + ) + + # Merged documentation places every crate in a directory named after the + # crate, so crates sharing a name would silently collide. Resolve them the + # way `cargo doc` does: library crates win over binaries, and a binary + # whose name is already taken is skipped. Colliding libraries remain an + # error. + lib_docs = [doc for doc in crate_docs if not doc.is_bin] + bin_docs = [doc for doc in crate_docs if doc.is_bin] + docs_by_name = {} + for doc in lib_docs: + if doc.name in docs_by_name: + fail( + "rust_workspace_doc target '{}' found multiple crates named '{}': {} and {}. ".format( + ctx.label, + doc.name, + docs_by_name[doc.name].label_str, + doc.label_str, + ) + "Exclude one of them from documentation by tagging it with 'no_docs'.", + ) + docs_by_name[doc.name] = doc + for doc in bin_docs: + if doc.name not in docs_by_name: + docs_by_name[doc.name] = doc + crate_docs = docs_by_name.values() + + # `rustdoc --merge=finalize` writes the merged cross-crate information + # while documenting a crate, so document a stub crate that doubles as a + # place-holder for the workspace itself. + stub_root = ctx.actions.declare_file("{}.finalize.rs".format(ctx.label.name)) + ctx.actions.write( + output = stub_root, + content = "//! Merged documentation for all crates in the workspace.\n", + ) + stub_crate_info = rust_common.create_crate_info( + name = _sanitize_crate_name(ctx.label.name), + type = "lib", + root = stub_root, + srcs = depset([stub_root]), + deps = depset([]), + proc_macro_deps = depset([]), + aliases = {}, + output = None, + metadata = None, + edition = "2024", + rustc_env = {}, + rustc_env_files = [], + is_test = False, + compile_data = depset([]), + compile_data_targets = depset([]), + data = depset([]), + ) + + finalize_dir = ctx.actions.declare_directory("{}.rustdoc_finalize".format(ctx.label.name)) + + rustdoc_flags = ctx.actions.args() + rustdoc_flags.add("-Zunstable-options") + rustdoc_flags.add("--merge=finalize") + parts_dirs = [doc.parts_dir for doc in crate_docs] + rustdoc_flags.add_all( + parts_dirs, + before_each = "--include-parts-dir", + expand_directories = False, + ) + if ctx.attr.generate_index_page: + # Generate a rustdoc-styled landing page listing all crates. + rustdoc_flags.add("--enable-index-page") + rustdoc_flags.add_all(ctx.attr.rustdoc_flags) + + action = rustdoc_compile_action( + ctx = ctx, + toolchain = toolchain, + crate_info = stub_crate_info, + output = finalize_dir, + rustdoc_flags = rustdoc_flags, + ) + + ctx.actions.run( + mnemonic = "RustdocMergeFinalize", + progress_message = "Merging Rustdoc cross-crate information for {}".format(ctx.label), + outputs = [finalize_dir], + executable = action.executable, + inputs = depset(parts_dirs, transitive = [action.inputs]), + env = action.env, + arguments = action.arguments, + tools = action.tools, + toolchain = Label("//rust:toolchain_type"), + execution_requirements = {"supports-path-mapping": ""} if action.supports_path_mapping else None, + ) + + # Combine the per-crate documentation and the merged cross-crate + # information into a single documentation tree. + output_dir = ctx.actions.declare_directory("{}.rustdoc".format(ctx.label.name)) + + html_dirs = [doc.html_dir for doc in crate_docs] + + merge_args = ctx.actions.args() + merge_args.add("--output") + merge_args.add_all([output_dir], expand_directories = False) + merge_args.add("--inputs") + merge_args.add_all(html_dirs, expand_directories = False) + + # The finalize directory is passed last so its merged shared files + # (search index, crate list, static files) win over the per-crate copies. + merge_args.add_all([finalize_dir], expand_directories = False) + + ctx.actions.run( + mnemonic = "RustdocMergeCopy", + progress_message = "Assembling merged Rustdoc tree for {}".format(ctx.label), + outputs = [output_dir], + executable = ctx.executable._doc_merger, + inputs = html_dirs + [finalize_dir], + arguments = [merge_args], + ) + + zip_action(ctx, output_dir, ctx.outputs.rust_doc_zip, ctx.label) + + return [ + DefaultInfo( + files = depset([output_dir]), + ), + OutputGroupInfo( + crate_docs = depset(html_dirs), + rustdoc_dir = depset([output_dir]), + rustdoc_zip = depset([ctx.outputs.rust_doc_zip]), + ), + ] + +rust_workspace_doc = rule( + doc = dedent("""\ + Generates merged documentation for all Rust crates reachable from a set + of targets, similar to running `cargo doc` in a Cargo workspace. + + Unlike `rust_doc`, which documents a single crate, this rule walks the + transitive dependencies of the targets listed in `deps`, documents every + crate from the current workspace it finds, and merges the results into + a single documentation tree with a unified search index and cross-crate + links. Individual crates do not need to be listed: adding a few + top-level targets is enough to document the whole workspace. + + Binary crates are documented like `cargo doc --bins`: a binary whose + crate name collides with another documented crate yields to it and is + skipped. Test crates are not documented, matching `cargo doc`. + + Each crate is documented by its own action, so unchanged crates are + served from Bazel's cache. Crates from external repositories are + skipped unless the + `--@rules_rust//rust/settings:rustdoc_workspace_include_external` + flag is enabled. + + The merged documentation is produced both as a directory (the default + output) and as a zip archive suitable for archiving or publishing, + available as the `.zip` output. + + NOTE: This rule requires a nightly Rust toolchain as the `rustdoc` + merge flags are unstable + (https://github.com/rust-lang/rust/issues/130676). Configure one with + `--@rules_rust//rust/toolchain/channel=nightly`. + + Example: + + ```python + load("@rules_rust//rust:defs.bzl", "rust_workspace_doc") + + rust_workspace_doc( + name = "workspace_docs", + deps = [ + "//app:server", + "//tools/cli", + ], + ) + ``` + + Running `bazel build --@rules_rust//rust/toolchain/channel=nightly \\ + //:workspace_docs` documents `//app:server`, `//tools/cli` and every + workspace crate they transitively depend on. + """), + implementation = _rust_workspace_doc_impl, + attrs = { + "deps": attr.label_list( + doc = ( + "Targets to generate merged documentation for. All Rust crates " + + "transitively reachable from these targets are documented, so " + + "listing a workspace's top-level targets is sufficient." + ), + aspects = [rust_workspace_doc_aspect], + ), + "generate_index_page": attr.bool( + doc = ( + "Whether to generate a root `index.html` listing all documented " + + "crates (rustdoc's `--enable-index-page`)." + ), + default = True, + ), + "rustdoc_flags": attr.string_list( + doc = dedent("""\ + List of flags passed to the `rustdoc` invocation that merges the + cross-crate information (`--merge=finalize`). + """), + ), + "_dir_zipper": attr.label( + doc = "A tool that orchestrates the creation of zip archives for rustdoc outputs.", + default = Label("//rust/private/rustdoc/dir_zipper"), + cfg = "exec", + executable = True, + ), + "_doc_merger": attr.label( + doc = "A tool that merges rustdoc output directories into a single tree.", + default = Label("//rust/private/rustdoc/doc_merger"), + cfg = "exec", + executable = True, + ), + "_error_format": attr.label( + default = Label("//rust/settings:error_format"), + ), + "_process_wrapper": attr.label( + doc = "A process wrapper for running rustdoc on all platforms", + default = Label("@rules_rust//util/process_wrapper"), + executable = True, + allow_single_file = True, + cfg = "exec", + ), + "_zipper": attr.label( + doc = "A Bazel provided tool for creating archives", + default = Label("@bazel_tools//tools/zip:zipper"), + cfg = "exec", + executable = True, + ), + }, + fragments = ["cpp"], + outputs = { + "rust_doc_zip": "%{name}.zip", + }, + toolchains = [ + str(Label("//rust:toolchain_type")), + config_common.toolchain_type("@bazel_tools//tools/cpp:toolchain_type", mandatory = False), + ], +) diff --git a/rust/settings/BUILD.bazel b/rust/settings/BUILD.bazel index 375ea537ee..eb82932d48 100644 --- a/rust/settings/BUILD.bazel +++ b/rust/settings/BUILD.bazel @@ -35,6 +35,8 @@ load( "rename_first_party_crates", "require_explicit_unstable_features", "rustc_output_diagnostics", + "rustdoc_workspace_extra_flag", + "rustdoc_workspace_include_external", "rustfmt_toml", "third_party_dir", "toolchain_generated_sysroot", @@ -129,6 +131,10 @@ require_explicit_unstable_features() rustc_output_diagnostics() +rustdoc_workspace_extra_flag() + +rustdoc_workspace_include_external() + rustfmt_toml() third_party_dir() diff --git a/rust/settings/settings.bzl b/rust/settings/settings.bzl index b6d3494264..9c5922a606 100644 --- a/rust/settings/settings.bzl +++ b/rust/settings/settings.bzl @@ -32,6 +32,10 @@ load( _rustc_output_diagnostics = "rustc_output_diagnostics", _zself_profile_events = "zself_profile_events", ) +load( + "//rust/private:rustdoc_workspace.bzl", + _rustdoc_workspace_extra_flag = "rustdoc_workspace_extra_flag", +) load("//rust/private:unpretty.bzl", "UNPRETTY_MODES", "rust_unpretty_flag") load(":incompatible.bzl", "incompatible_flag") @@ -258,6 +262,33 @@ def experimental_compile_rustdoc_tests(): build_setting_default = False, ) +def rustdoc_workspace_extra_flag(): + """A repeatable flag adding a rustdoc flag to every per-crate invocation of `rust_workspace_doc_aspect`. + + Use it to apply documentation-wide options such as `--document-private-items` or \ + lint levels like `-Dwarnings`, e.g. \ + `--@rules_rust//rust/settings:rustdoc_workspace_extra_flag=-Dwarnings`. Multiple \ + uses are accumulated. Flags for the finalizing invocation are set with the \ + `rustdoc_flags` attribute of `rust_workspace_doc` instead. + """ + _rustdoc_workspace_extra_flag( + name = "rustdoc_workspace_extra_flag", + build_setting_default = [], + ) + +def rustdoc_workspace_include_external(): + """A flag to control whether `rust_workspace_doc` documents crates from external repositories. + + When disabled (the default), only crates from the current workspace are documented, \ + matching `cargo doc --no-deps`. When enabled, external crates (e.g. `crate_universe` \ + dependencies) reachable from the `deps` of `rust_workspace_doc` targets are documented \ + and merged into the documentation tree as well. + """ + bool_flag( + name = "rustdoc_workspace_include_external", + build_setting_default = False, + ) + def toolchain_generated_sysroot(): """A flag to set rustc --sysroot flag to the sysroot generated by rust_toolchain.""" bool_flag( diff --git a/test/rustdoc_workspace/BUILD.bazel b/test/rustdoc_workspace/BUILD.bazel new file mode 100644 index 0000000000..6f11199b17 --- /dev/null +++ b/test/rustdoc_workspace/BUILD.bazel @@ -0,0 +1,45 @@ +load("//rust:defs.bzl", "rust_binary", "rust_library", "rust_test", "rust_workspace_doc") + +rust_library( + name = "alpha", + srcs = ["alpha.rs"], + edition = "2021", +) + +rust_library( + name = "beta", + srcs = ["beta.rs"], + edition = "2021", + deps = [":alpha"], +) + +rust_binary( + name = "gamma", + srcs = ["gamma.rs"], + edition = "2021", + deps = [":beta"], +) + +# `rust_workspace_doc` requires a nightly toolchain, so these targets are +# skipped unless the toolchain channel is set to nightly. +NIGHTLY_ONLY = select({ + "//rust/toolchain/channel:nightly": [], + "//conditions:default": ["@platforms//:incompatible"], +}) + +# Documents `alpha`, `beta` and `gamma` transitively from the single top-level +# target and merges the results into one documentation tree. +rust_workspace_doc( + name = "workspace_docs", + target_compatible_with = NIGHTLY_ONLY, + deps = [":gamma"], +) + +rust_test( + name = "workspace_docs_test", + srcs = ["workspace_docs_test.rs"], + data = [":workspace_docs"], + edition = "2021", + env = {"WORKSPACE_DOCS_DIR": "$(rootpath :workspace_docs)"}, + target_compatible_with = NIGHTLY_ONLY, +) diff --git a/test/rustdoc_workspace/alpha.rs b/test/rustdoc_workspace/alpha.rs new file mode 100644 index 0000000000..69d1239564 --- /dev/null +++ b/test/rustdoc_workspace/alpha.rs @@ -0,0 +1,14 @@ +//! The `alpha` crate, the bottom of the test dependency graph. + +/// A greeting produced by [`greeting`]. +pub struct Greeting { + /// The rendered greeting text. + pub text: String, +} + +/// Returns a [`Greeting`] for the given name. +pub fn greeting(name: &str) -> Greeting { + Greeting { + text: format!("Hello {}!", name), + } +} diff --git a/test/rustdoc_workspace/beta.rs b/test/rustdoc_workspace/beta.rs new file mode 100644 index 0000000000..b46358705c --- /dev/null +++ b/test/rustdoc_workspace/beta.rs @@ -0,0 +1,9 @@ +//! The `beta` crate, which builds on [`alpha`]. + +/// Produces an enthusiastic [`alpha::Greeting`]. +pub fn loud_greeting(name: &str) -> alpha::Greeting { + let greeting = alpha::greeting(name); + alpha::Greeting { + text: greeting.text.to_uppercase(), + } +} diff --git a/test/rustdoc_workspace/gamma.rs b/test/rustdoc_workspace/gamma.rs new file mode 100644 index 0000000000..541e8186c5 --- /dev/null +++ b/test/rustdoc_workspace/gamma.rs @@ -0,0 +1,5 @@ +//! The `gamma` binary, the top of the test dependency graph. + +fn main() { + println!("{}", beta::loud_greeting("world").text); +} diff --git a/test/rustdoc_workspace/workspace_docs_test.rs b/test/rustdoc_workspace/workspace_docs_test.rs new file mode 100644 index 0000000000..fe6180a200 --- /dev/null +++ b/test/rustdoc_workspace/workspace_docs_test.rs @@ -0,0 +1,108 @@ +//! Integration test asserting the shape of the merged `rust_workspace_doc` output. + +use std::fs; +use std::path::{Path, PathBuf}; + +fn docs_dir() -> PathBuf { + PathBuf::from(std::env::var("WORKSPACE_DOCS_DIR").expect("WORKSPACE_DOCS_DIR is not set")) +} + +/// Recursively collect all file paths under `dir`. +fn collect_files(dir: &Path, files: &mut Vec) { + for entry in + fs::read_dir(dir).unwrap_or_else(|e| panic!("failed to read {}: {}", dir.display(), e)) + { + let path = entry.expect("failed to read directory entry").path(); + if path.is_dir() { + collect_files(&path, files); + } else { + files.push(path); + } + } +} + +#[test] +fn contains_docs_for_every_workspace_crate() { + for crate_name in ["alpha", "beta", "gamma"] { + let index = docs_dir().join(crate_name).join("index.html"); + assert!( + index.is_file(), + "expected documentation for crate `{}` at {}", + crate_name, + index.display() + ); + } +} + +#[test] +fn contains_merged_search_index() { + let mut files = Vec::new(); + collect_files(&docs_dir(), &mut files); + + // The merged search index is a `search-index*.js` file (or a + // `search.index/` directory in newer rustdoc versions) that mentions + // every crate. + let search_files: Vec<&PathBuf> = files + .iter() + .filter(|path| { + let name = path.file_name().unwrap().to_string_lossy(); + let in_search_dir = path + .parent() + .map(|parent| { + parent.components().any(|component| { + component + .as_os_str() + .to_string_lossy() + .starts_with("search") + }) + }) + .unwrap_or(false); + name.starts_with("search-index") || in_search_dir + }) + .collect(); + assert!( + !search_files.is_empty(), + "expected a merged search index in the documentation tree" + ); + + let search_content = search_files + .iter() + .map(|path| fs::read_to_string(path).unwrap_or_default()) + .collect::(); + for crate_name in ["alpha", "beta", "gamma"] { + assert!( + search_content.contains(crate_name), + "expected the merged search index to mention crate `{}`", + crate_name + ); + } +} + +#[test] +fn contains_root_index_page() { + let index = docs_dir().join("index.html"); + assert!( + index.is_file(), + "expected a root index.html listing all crates" + ); + + let content = fs::read_to_string(&index).expect("failed to read root index.html"); + for crate_name in ["alpha", "beta", "gamma"] { + assert!( + content.contains(&format!("{}/index.html", crate_name)), + "expected the root index.html to link to crate `{}`", + crate_name + ); + } +} + +#[test] +fn cross_crate_links_resolve() { + let beta_fn = docs_dir().join("beta").join("fn.loud_greeting.html"); + let content = fs::read_to_string(&beta_fn) + .unwrap_or_else(|e| panic!("failed to read {}: {}", beta_fn.display(), e)); + assert!( + content.contains("alpha/struct.Greeting.html"), + "expected `beta::loud_greeting` docs to link to `alpha::Greeting`" + ); +} diff --git a/test/unit/rustdoc_workspace/BUILD.bazel b/test/unit/rustdoc_workspace/BUILD.bazel new file mode 100644 index 0000000000..3eed5b8d08 --- /dev/null +++ b/test/unit/rustdoc_workspace/BUILD.bazel @@ -0,0 +1,3 @@ +load(":rustdoc_workspace_unit_test.bzl", "rustdoc_workspace_test_suite") + +rustdoc_workspace_test_suite(name = "rustdoc_workspace_test_suite") diff --git a/test/unit/rustdoc_workspace/rustdoc_workspace_unit_test.bzl b/test/unit/rustdoc_workspace/rustdoc_workspace_unit_test.bzl new file mode 100644 index 0000000000..f0cd5bcbb3 --- /dev/null +++ b/test/unit/rustdoc_workspace/rustdoc_workspace_unit_test.bzl @@ -0,0 +1,508 @@ +"""Unittests to verify properties of the `rust_workspace_doc` rule and aspect""" + +load("@bazel_skylib//lib:unittest.bzl", "analysistest", "asserts") +load( + "//rust:defs.bzl", + "rust_binary", + "rust_library", + "rust_test", + "rust_workspace_doc", + "rust_workspace_doc_aspect", +) +load( + "//test/unit:common.bzl", + "assert_argv_contains", + "assert_argv_contains_not", + "assert_argv_contains_prefix", +) + +_NIGHTLY_CONFIG_SETTINGS = { + str(Label("//rust/toolchain/channel:channel")): "nightly", +} + +def _get_action(env, mnemonic): + """Find the single action with the given mnemonic on the target under test.""" + tut = analysistest.target_under_test(env) + actions = [action for action in tut.actions if action.mnemonic == mnemonic] + asserts.equals( + env, + 1, + len(actions), + "Expected exactly one `{}` action, got {}".format( + mnemonic, + [action.mnemonic for action in tut.actions], + ), + ) + return actions[0] + +def _count_argv(action, flag): + return len([arg for arg in action.argv if arg == flag]) + +def _workspace_doc_aspect_on_lib_test_impl(ctx): + env = analysistest.begin(ctx) + action = _get_action(env, "RustdocMerge") + + assert_argv_contains(env, action, "--merge=none") + assert_argv_contains(env, action, "-Zunstable-options") + assert_argv_contains(env, action, "--parts-out-dir") + + # `wd_mid_alpha` has one documented dependency (`wd_base`), whose crate + # directory is pre-created for cross-crate link generation. + assert_argv_contains(env, action, "--mkdir") + + return analysistest.end(env) + +_workspace_doc_aspect_on_lib_test = analysistest.make( + _workspace_doc_aspect_on_lib_test_impl, + config_settings = _NIGHTLY_CONFIG_SETTINGS, + extra_target_under_test_aspects = [rust_workspace_doc_aspect], +) + +def _workspace_doc_aspect_on_leaf_test_impl(ctx): + env = analysistest.begin(ctx) + action = _get_action(env, "RustdocMerge") + + # A crate without dependencies has no crate directories to pre-create. + assert_argv_contains_not(env, action, "--mkdir") + + return analysistest.end(env) + +_workspace_doc_aspect_on_leaf_test = analysistest.make( + _workspace_doc_aspect_on_leaf_test_impl, + config_settings = _NIGHTLY_CONFIG_SETTINGS, + extra_target_under_test_aspects = [rust_workspace_doc_aspect], +) + +def _workspace_doc_aspect_stable_noop_test_impl(ctx): + env = analysistest.begin(ctx) + tut = analysistest.target_under_test(env) + + # On non-nightly toolchains the aspect produces no documentation actions. + actions = [action for action in tut.actions if action.mnemonic == "RustdocMerge"] + asserts.equals( + env, + 0, + len(actions), + "Expected no `RustdocMerge` actions on a stable toolchain", + ) + + return analysistest.end(env) + +_workspace_doc_aspect_stable_noop_test = analysistest.make( + _workspace_doc_aspect_stable_noop_test_impl, + config_settings = { + str(Label("//rust/toolchain/channel:channel")): "stable", + }, + extra_target_under_test_aspects = [rust_workspace_doc_aspect], +) + +def _workspace_doc_finalize_test_impl(ctx): + env = analysistest.begin(ctx) + action = _get_action(env, "RustdocMergeFinalize") + + assert_argv_contains(env, action, "--merge=finalize") + assert_argv_contains(env, action, "-Zunstable-options") + + # The default is to generate a root index page listing all crates. + assert_argv_contains(env, action, "--enable-index-page") + + # The diamond dependency graph (root -> mid_alpha, mid_beta -> base) + # contains four distinct crates (including the binary root), each + # contributing exactly one parts directory despite `base` being reachable + # through two paths. + asserts.equals( + env, + 4, + _count_argv(action, "--include-parts-dir"), + "Expected one `--include-parts-dir` per documented crate", + ) + + return analysistest.end(env) + +_workspace_doc_finalize_test = analysistest.make( + _workspace_doc_finalize_test_impl, + config_settings = _NIGHTLY_CONFIG_SETTINGS, +) + +def _workspace_doc_copy_test_impl(ctx): + env = analysistest.begin(ctx) + action = _get_action(env, "RustdocMergeCopy") + + assert_argv_contains(env, action, "--output") + assert_argv_contains(env, action, "--inputs") + + # Four per-crate html directories (including the binary root) plus the + # finalize directory. The action inputs additionally contain the merge + # tool and its runfiles. + input_dirs = [input for input in action.inputs.to_list() if input.is_directory] + asserts.equals( + env, + 5, + len(input_dirs), + "Expected the merge-copy action to consume every html directory and the finalize directory", + ) + + return analysistest.end(env) + +_workspace_doc_copy_test = analysistest.make( + _workspace_doc_copy_test_impl, + config_settings = _NIGHTLY_CONFIG_SETTINGS, +) + +def _workspace_doc_no_index_test_impl(ctx): + env = analysistest.begin(ctx) + action = _get_action(env, "RustdocMergeFinalize") + + assert_argv_contains_not(env, action, "--enable-index-page") + + return analysistest.end(env) + +_workspace_doc_no_index_test = analysistest.make( + _workspace_doc_no_index_test_impl, + config_settings = _NIGHTLY_CONFIG_SETTINGS, +) + +def _workspace_doc_extra_flag_test_impl(ctx): + env = analysistest.begin(ctx) + action = _get_action(env, "RustdocMerge") + + assert_argv_contains(env, action, "--document-private-items") + + return analysistest.end(env) + +_workspace_doc_extra_flag_test = analysistest.make( + _workspace_doc_extra_flag_test_impl, + config_settings = _NIGHTLY_CONFIG_SETTINGS | { + str(Label("//rust/settings:rustdoc_workspace_extra_flag")): ["--document-private-items"], + }, + extra_target_under_test_aspects = [rust_workspace_doc_aspect], +) + +def _workspace_doc_bin_collision_test_impl(ctx): + env = analysistest.begin(ctx) + action = _get_action(env, "RustdocMergeFinalize") + + # `wd_base_bin` produces a binary crate named `wd_base`, which collides + # with the `wd_base` library. Like `cargo doc`, the library wins and the + # binary is skipped, leaving the four crates of the main diamond. + asserts.equals( + env, + 4, + _count_argv(action, "--include-parts-dir"), + "Expected a binary colliding with a library crate name to be skipped", + ) + + return analysistest.end(env) + +_workspace_doc_bin_collision_test = analysistest.make( + _workspace_doc_bin_collision_test_impl, + config_settings = _NIGHTLY_CONFIG_SETTINGS, +) + +def _workspace_doc_skips_tagged_and_test_crates_test_impl(ctx): + env = analysistest.begin(ctx) + action = _get_action(env, "RustdocMergeFinalize") + + # The dependency graph is root (tagged `no_docs`) -> base plus a + # `rust_test` target. Only `base` is documented, but crates reachable + # through skipped targets are still collected. + asserts.equals( + env, + 1, + _count_argv(action, "--include-parts-dir"), + "Expected `no_docs`-tagged and test crates to be skipped", + ) + + return analysistest.end(env) + +_workspace_doc_skips_tagged_and_test_crates_test = analysistest.make( + _workspace_doc_skips_tagged_and_test_crates_test_impl, + config_settings = _NIGHTLY_CONFIG_SETTINGS, +) + +def _workspace_doc_zip_output_test_impl(ctx): + env = analysistest.begin(ctx) + tut = analysistest.target_under_test(env) + + files = tut[DefaultInfo].files.to_list() + asserts.equals(env, 1, len(files)) + asserts.true(env, files[0].is_directory, "Expected the default output to be a directory") + + output_groups = tut[OutputGroupInfo] + zips = output_groups.rustdoc_zip.to_list() + asserts.equals(env, 1, len(zips)) + asserts.equals(env, "zip", zips[0].extension) + + asserts.equals( + env, + 4, + len(output_groups.crate_docs.to_list()), + "Expected one entry in the `crate_docs` output group per documented crate", + ) + + return analysistest.end(env) + +_workspace_doc_zip_output_test = analysistest.make( + _workspace_doc_zip_output_test_impl, + config_settings = _NIGHTLY_CONFIG_SETTINGS, +) + +def _workspace_doc_duplicate_crate_name_test_impl(ctx): + env = analysistest.begin(ctx) + asserts.expect_failure(env, "found multiple crates named") + return analysistest.end(env) + +_workspace_doc_duplicate_crate_name_test = analysistest.make( + _workspace_doc_duplicate_crate_name_test_impl, + config_settings = _NIGHTLY_CONFIG_SETTINGS, + expect_failure = True, +) + +def _workspace_doc_no_crates_test_impl(ctx): + env = analysistest.begin(ctx) + asserts.expect_failure(env, "found no crates to document") + return analysistest.end(env) + +_workspace_doc_no_crates_test = analysistest.make( + _workspace_doc_no_crates_test_impl, + config_settings = _NIGHTLY_CONFIG_SETTINGS, + expect_failure = True, +) + +def _workspace_doc_requires_nightly_test_impl(ctx): + env = analysistest.begin(ctx) + asserts.expect_failure(env, "requires a nightly Rust toolchain") + return analysistest.end(env) + +_workspace_doc_requires_nightly_test = analysistest.make( + _workspace_doc_requires_nightly_test_impl, + config_settings = { + str(Label("//rust/toolchain/channel:channel")): "stable", + }, + expect_failure = True, +) + +def _define_targets(): + """Define the targets under test. + + The main dependency graph is a diamond: + + ``` + wd_root + / \\ + wd_mid_alpha wd_mid_beta + \\ / + wd_base + ``` + """ + rust_library( + name = "wd_base", + srcs = ["wd_base.rs"], + edition = "2021", + ) + + rust_library( + name = "wd_mid_alpha", + srcs = ["wd_mid_alpha.rs"], + edition = "2021", + deps = [":wd_base"], + ) + + rust_library( + name = "wd_mid_beta", + srcs = ["wd_mid_beta.rs"], + edition = "2021", + deps = [":wd_base"], + ) + + rust_binary( + name = "wd_root", + srcs = ["wd_root.rs"], + edition = "2021", + deps = [ + ":wd_mid_alpha", + ":wd_mid_beta", + ], + ) + + # All `rust_workspace_doc` fixtures are tagged `manual`: they are only + # analyzed through the analysis tests below (which force the toolchain + # channel they need) and would fail to analyze in wildcard builds. + rust_workspace_doc( + name = "wd_docs", + tags = ["manual"], + deps = [":wd_root"], + ) + + rust_workspace_doc( + name = "wd_docs_no_index", + generate_index_page = False, + tags = ["manual"], + deps = [":wd_root"], + ) + + # Targets which are expected to be skipped by the aspect. + rust_library( + name = "wd_root_no_docs", + srcs = ["wd_mid_alpha.rs"], + crate_name = "wd_root_no_docs", + edition = "2021", + tags = ["no_docs"], + deps = [":wd_base"], + ) + + rust_test( + name = "wd_base_test", + crate = ":wd_base", + edition = "2021", + ) + + rust_workspace_doc( + name = "wd_docs_with_skipped_crates", + tags = ["manual"], + testonly = True, + deps = [ + ":wd_base_test", + ":wd_root_no_docs", + ], + ) + + # A binary crate whose name collides with the `wd_base` library. + rust_binary( + name = "wd_base_bin", + srcs = ["wd_base_bin.rs"], + crate_name = "wd_base", + edition = "2021", + ) + + rust_workspace_doc( + name = "wd_docs_bin_collision", + tags = ["manual"], + deps = [ + ":wd_base_bin", + ":wd_root", + ], + ) + + # Two targets producing crates with the same name. + rust_library( + name = "wd_duplicate_alpha", + srcs = ["wd_base.rs"], + crate_name = "wd_duplicate", + edition = "2021", + ) + + rust_library( + name = "wd_duplicate_beta", + srcs = ["wd_mid_beta.rs"], + crate_name = "wd_duplicate", + edition = "2021", + deps = [":wd_base"], + ) + + rust_workspace_doc( + name = "wd_docs_duplicate_names", + tags = ["manual"], + deps = [ + ":wd_duplicate_alpha", + ":wd_duplicate_beta", + ], + ) + + rust_workspace_doc( + name = "wd_docs_empty", + tags = ["manual"], + deps = [], + ) + +def rustdoc_workspace_test_suite(name): + """Entry-point macro called from the BUILD file. + + Args: + name (str): Name of the macro. + """ + _define_targets() + + _workspace_doc_aspect_on_lib_test( + name = "workspace_doc_aspect_on_lib_test", + target_under_test = ":wd_mid_alpha", + ) + + _workspace_doc_aspect_on_leaf_test( + name = "workspace_doc_aspect_on_leaf_test", + target_under_test = ":wd_base", + ) + + _workspace_doc_aspect_stable_noop_test( + name = "workspace_doc_aspect_stable_noop_test", + target_under_test = ":wd_mid_alpha", + ) + + _workspace_doc_finalize_test( + name = "workspace_doc_finalize_test", + target_under_test = ":wd_docs", + ) + + _workspace_doc_copy_test( + name = "workspace_doc_copy_test", + target_under_test = ":wd_docs", + ) + + _workspace_doc_no_index_test( + name = "workspace_doc_no_index_test", + target_under_test = ":wd_docs_no_index", + ) + + _workspace_doc_extra_flag_test( + name = "workspace_doc_extra_flag_test", + target_under_test = ":wd_mid_alpha", + ) + + _workspace_doc_bin_collision_test( + name = "workspace_doc_bin_collision_test", + target_under_test = ":wd_docs_bin_collision", + ) + + _workspace_doc_skips_tagged_and_test_crates_test( + name = "workspace_doc_skips_tagged_and_test_crates_test", + target_under_test = ":wd_docs_with_skipped_crates", + ) + + _workspace_doc_zip_output_test( + name = "workspace_doc_zip_output_test", + target_under_test = ":wd_docs", + ) + + _workspace_doc_duplicate_crate_name_test( + name = "workspace_doc_duplicate_crate_name_test", + target_under_test = ":wd_docs_duplicate_names", + ) + + _workspace_doc_no_crates_test( + name = "workspace_doc_no_crates_test", + target_under_test = ":wd_docs_empty", + ) + + _workspace_doc_requires_nightly_test( + name = "workspace_doc_requires_nightly_test", + target_under_test = ":wd_docs", + ) + + native.test_suite( + name = name, + tests = [ + ":workspace_doc_aspect_on_leaf_test", + ":workspace_doc_aspect_on_lib_test", + ":workspace_doc_aspect_stable_noop_test", + ":workspace_doc_bin_collision_test", + ":workspace_doc_copy_test", + ":workspace_doc_duplicate_crate_name_test", + ":workspace_doc_extra_flag_test", + ":workspace_doc_finalize_test", + ":workspace_doc_no_crates_test", + ":workspace_doc_no_index_test", + ":workspace_doc_requires_nightly_test", + ":workspace_doc_skips_tagged_and_test_crates_test", + ":workspace_doc_zip_output_test", + ], + ) diff --git a/test/unit/rustdoc_workspace/wd_base.rs b/test/unit/rustdoc_workspace/wd_base.rs new file mode 100644 index 0000000000..44b2360515 --- /dev/null +++ b/test/unit/rustdoc_workspace/wd_base.rs @@ -0,0 +1,4 @@ +//! The base crate of the test diamond dependency graph. + +/// A base value. +pub const BASE: u32 = 1; diff --git a/test/unit/rustdoc_workspace/wd_base_bin.rs b/test/unit/rustdoc_workspace/wd_base_bin.rs new file mode 100644 index 0000000000..8cd54bb041 --- /dev/null +++ b/test/unit/rustdoc_workspace/wd_base_bin.rs @@ -0,0 +1,3 @@ +//! A binary crate sharing its crate name with the `wd_base` library. + +fn main() {} diff --git a/test/unit/rustdoc_workspace/wd_mid_alpha.rs b/test/unit/rustdoc_workspace/wd_mid_alpha.rs new file mode 100644 index 0000000000..ba6eec514d --- /dev/null +++ b/test/unit/rustdoc_workspace/wd_mid_alpha.rs @@ -0,0 +1,6 @@ +//! The first middle crate of the test diamond dependency graph. + +/// Twice the base value. +pub fn double() -> u32 { + wd_base::BASE * 2 +} diff --git a/test/unit/rustdoc_workspace/wd_mid_beta.rs b/test/unit/rustdoc_workspace/wd_mid_beta.rs new file mode 100644 index 0000000000..222f60780d --- /dev/null +++ b/test/unit/rustdoc_workspace/wd_mid_beta.rs @@ -0,0 +1,6 @@ +//! The second middle crate of the test diamond dependency graph. + +/// Three times the base value. +pub fn triple() -> u32 { + wd_base::BASE * 3 +} diff --git a/test/unit/rustdoc_workspace/wd_root.rs b/test/unit/rustdoc_workspace/wd_root.rs new file mode 100644 index 0000000000..542034b02f --- /dev/null +++ b/test/unit/rustdoc_workspace/wd_root.rs @@ -0,0 +1,5 @@ +//! The root binary of the test diamond dependency graph. + +fn main() { + println!("{}", wd_mid_alpha::double() + wd_mid_beta::triple()); +} diff --git a/tools/rustdoc_workspace/BUILD.bazel b/tools/rustdoc_workspace/BUILD.bazel new file mode 100644 index 0000000000..e62d2f0579 --- /dev/null +++ b/tools/rustdoc_workspace/BUILD.bazel @@ -0,0 +1,25 @@ +load("//rust:defs.bzl", "rust_binary") +load("//tools/private:tool_utils.bzl", "aspect_repository") + +rust_binary( + name = "gen_workspace_docs", + srcs = ["bin/gen_workspace_docs.rs"], + data = [ + "//rust/private/rustdoc/doc_merger", + "//rust/toolchain:current_rustdoc_files", + ], + edition = "2024", + rustc_env = { + "ASPECT_REPOSITORY": aspect_repository(), + "DOC_MERGER_RLOCATIONPATH": "$(rlocationpath //rust/private/rustdoc/doc_merger)", + "RUSTDOC_RLOCATIONPATH": "$(rlocationpath //rust/toolchain:current_rustdoc_files)", + }, + toolchains = ["//rust/toolchain:current_rust_toolchain"], + visibility = ["//visibility:public"], + deps = [ + "//rust/runfiles", + "//tools/rust_analyzer/3rdparty/crates:anyhow", + "//tools/rust_analyzer/3rdparty/crates:clap", + "//tools/rust_analyzer/3rdparty/crates:serde_json", + ], +) diff --git a/tools/rustdoc_workspace/bin/gen_workspace_docs.rs b/tools/rustdoc_workspace/bin/gen_workspace_docs.rs new file mode 100644 index 0000000000..6a0296ab0e --- /dev/null +++ b/tools/rustdoc_workspace/bin/gen_workspace_docs.rs @@ -0,0 +1,490 @@ +//! Generates merged rustdoc documentation for every crate matched by a set of +//! Bazel target patterns — no rule with an explicit `deps` list is required. +//! +//! ```text +//! bazel run @rules_rust//tools/rustdoc_workspace:gen_workspace_docs -- \ +//! --output [--config ]... [...] +//! ``` +//! +//! The tool applies `rust_workspace_doc_aspect` to the matched targets, which +//! documents each crate with `rustdoc --merge=none` as regular (cached) build +//! actions. It then merges the cross-crate information with `rustdoc +//! --merge=finalize` and assembles the final documentation tree. +//! +//! The `rustdoc` merge flags are unstable, so both the tool and the aspect +//! build must use a nightly toolchain, e.g. run with +//! `--@rules_rust//rust/toolchain/channel=nightly` (or a `--config` that +//! sets it, forwarded to the aspect build via this tool's `--config` flag). + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, bail}; +use clap::Parser; + +/// The repository name to load `rust_workspace_doc_aspect` from. +const ASPECT_REPOSITORY: &str = env!("ASPECT_REPOSITORY"); + +/// Runfiles location of the current toolchain's `rustdoc` binary. +const RUSTDOC_RLOCATIONPATH: &str = env!("RUSTDOC_RLOCATIONPATH"); + +/// Runfiles location of the `doc_merger` tree assembly tool. +const DOC_MERGER_RLOCATIONPATH: &str = env!("DOC_MERGER_RLOCATIONPATH"); + +/// Output directory suffixes produced by `rust_workspace_doc_aspect`. +/// Binary crates use a distinct suffix so name collisions with library +/// crates can be resolved the way `cargo doc` does. +const HTML_SUFFIX: &str = ".rustdoc_workspace/html"; +const PARTS_SUFFIX: &str = ".rustdoc_workspace/parts"; +const BIN_HTML_SUFFIX: &str = ".rustdoc_workspace_bin/html"; +const BIN_PARTS_SUFFIX: &str = ".rustdoc_workspace_bin/parts"; + +/// Directories in a rustdoc output tree which are not crate documentation. +const NON_CRATE_DIRS: &[&str] = &["src", "static.files", "search.desc", "search.index"]; + +#[derive(Debug, Parser)] +struct Args { + /// The directory to write the merged documentation to. + #[clap(long)] + output: PathBuf, + + /// A markdown file to render as the documentation landing page instead of + /// the default list of all crates. + #[clap(long)] + index_page: Option, + + /// An extra flag to pass to the finalizing rustdoc invocation. Flags for + /// the per-crate invocations are set with + /// `--@rules_rust//rust/settings:rustdoc_workspace_extra_flag` on the + /// aspect build instead. + #[clap(long)] + rustdoc_flag: Vec, + + /// The path to the Bazel workspace directory. If not specified, uses the + /// result of `bazel info workspace`. + #[clap(long, env = "BUILD_WORKSPACE_DIRECTORY")] + workspace: Option, + + /// The path to a Bazel binary. + #[clap(long, default_value = "bazel")] + bazel: PathBuf, + + /// A config to pass to Bazel invocations with `--config=`. + #[clap(long)] + config: Vec, + + /// Space separated list of target patterns to document. + #[clap(default_value = "@//...")] + targets: Vec, +} + +fn main() -> anyhow::Result<()> { + let args = Args::parse(); + let bazel_args: Vec = args + .config + .iter() + .map(|config| format!("--config={config}")) + .collect(); + + let output = absolute_path(&args.output)?; + + let workspace = match &args.workspace { + Some(workspace) => workspace.clone(), + None => bazel_info(&args.bazel, None, "workspace")?.into(), + }; + let output_base: PathBuf = bazel_info(&args.bazel, Some(&workspace), "output_base")?.into(); + let execution_root: PathBuf = + bazel_info(&args.bazel, Some(&workspace), "execution_root")?.into(); + + let temp_dir = std::env::temp_dir().join(format!("gen_workspace_docs_{}", std::process::id())); + fs::create_dir_all(&temp_dir) + .with_context(|| format!("failed to create {}", temp_dir.display()))?; + let result = generate_docs( + &args, + &bazel_args, + &workspace, + &output_base, + &execution_root, + &temp_dir, + &output, + ); + let _ = fs::remove_dir_all(&temp_dir); + result +} + +fn generate_docs( + args: &Args, + bazel_args: &[String], + workspace: &Path, + output_base: &Path, + execution_root: &Path, + temp_dir: &Path, + output: &Path, +) -> anyhow::Result<()> { + // Document every matched crate via the aspect. The per-crate outputs are + // ordinary build actions: cached, remote-executable and shared with any + // other consumer of the aspect. + let bep_file = temp_dir.join("bep.json"); + eprintln!("Building per-crate documentation for {:?}...", args.targets); + let status = bazel_command(&args.bazel, workspace, output_base) + .arg("build") + .args(bazel_args) + .arg(format!( + "--aspects={ASPECT_REPOSITORY}//rust:defs.bzl%rust_workspace_doc_aspect" + )) + .arg("--output_groups=rustdoc_crate_dir,rustdoc_crate_parts") + .arg(format!("--build_event_json_file={}", bep_file.display())) + // Remote builds may default to --remote_download_minimal; the merge + // steps below read the documentation directories locally, so force + // them to be downloaded. + .arg("--remote_download_regex=.*[.]rustdoc_workspace(_bin)?/.*") + .args(&args.targets) + .status() + .with_context(|| format!("failed to spawn `{}`", args.bazel.display()))?; + if !status.success() { + bail!("the documentation build failed; see the Bazel output above"); + } + + let (doc_dirs, missing) = collect_doc_dirs(&bep_file, execution_root)?; + if doc_dirs.is_empty() { + if missing > 0 { + bail!( + "the build produced documentation for {missing} crates but the output \ + directories are not present locally; they were probably not downloaded \ + from the remote cache", + ); + } + bail!( + "no crate documentation was produced for {:?}. The rustdoc merge flags require a \ + nightly toolchain: build with `--@rules_rust//rust/toolchain/channel=nightly` \ + (e.g. via a --config forwarded with this tool's --config flag).", + args.targets, + ); + } + if missing > 0 { + eprintln!( + "Warning: skipping {missing} crates whose documentation directories are not present locally" + ); + } + + let (html_dirs, parts_dirs, crate_names) = select_crates(doc_dirs)?; + eprintln!("Collected documentation for {} crates", crate_names.len()); + + // Merge the cross-crate information (search index, crate list, ...) by + // documenting a stub crate with `--merge=finalize`. + let mut stub_name = String::from("workspace_docs"); + while crate_names.contains(&stub_name) { + stub_name.push('_'); + } + let stub_file = temp_dir.join("workspace_docs_stub.rs"); + fs::write(&stub_file, "//! Merged workspace documentation.\n") + .with_context(|| format!("failed to write {}", stub_file.display()))?; + let finalize_dir = temp_dir.join("finalize"); + + let rustdoc = rlocation(RUSTDOC_RLOCATIONPATH)?; + eprintln!("Merging cross-crate information..."); + let mut finalize = Command::new(rustdoc); + finalize + .current_dir(workspace) + .arg(&stub_file) + .arg(format!("--crate-name={stub_name}")) + .arg("--edition=2024") + .arg("-Zunstable-options") + .arg("--merge=finalize") + // Generate a rustdoc-styled landing page listing all crates. + .arg("--enable-index-page") + .arg("--out-dir") + .arg(&finalize_dir); + if let Some(index_page) = &args.index_page { + finalize.arg("--index-page").arg(absolute_path(index_page)?); + } + finalize.args(&args.rustdoc_flag); + for parts_dir in &parts_dirs { + finalize.arg("--include-parts-dir").arg(parts_dir); + } + let status = finalize.status().context("failed to spawn rustdoc")?; + if !status.success() { + bail!("rustdoc --merge=finalize failed"); + } + + // Assemble the final tree: every crate's documentation plus the merged + // shared files, which are copied last so they win over per-crate copies. + prepare_output_dir(output)?; + let doc_merger = rlocation(DOC_MERGER_RLOCATIONPATH)?; + let mut merge = Command::new(doc_merger); + merge.arg("--output").arg(output); + merge.arg("--inputs").args(&html_dirs).arg(&finalize_dir); + let status = merge.status().context("failed to spawn doc_merger")?; + if !status.success() { + bail!("failed to assemble the merged documentation tree"); + } + + println!( + "Merged documentation for {} crates written to {}", + crate_names.len(), + output.display(), + ); + Ok(()) +} + +/// The documentation output of one crate. +struct CrateDocs { + html_dir: PathBuf, + parts_dir: PathBuf, + is_bin: bool, +} + +/// Find the html/parts directory pairs produced by the aspect in the build's +/// BEP output. Returns the pairs present on disk and the number of crates +/// whose directories were reported but are not present locally. +fn collect_doc_dirs( + bep_file: &Path, + execution_root: &Path, +) -> anyhow::Result<(Vec, usize)> { + let file = fs::File::open(bep_file) + .with_context(|| format!("failed to open {}", bep_file.display()))?; + + let mut pairs: BTreeMap, Option, bool)> = BTreeMap::new(); + for line in BufReader::new(file).lines() { + let line = line.context("failed to read the build event stream")?; + let Ok(event) = serde_json::from_str::(&line) else { + continue; + }; + let Some(files) = event + .get("namedSetOfFiles") + .and_then(|set| set.get("files")) + .and_then(|files| files.as_array()) + else { + continue; + }; + for file in files { + // Reconstruct the local path from the file's name and path prefix + // rather than its URI: remote builds report bytestream:// URIs. + let Some(name) = file.get("name").and_then(|name| name.as_str()) else { + continue; + }; + let mut path = execution_root.to_path_buf(); + if let Some(prefix) = file.get("pathPrefix").and_then(|prefix| prefix.as_array()) { + for component in prefix { + if let Some(component) = component.as_str() { + path.push(component); + } + } + } + path.push(name); + let path = path.to_string_lossy().into_owned(); + + // Tree artifacts appear in the BEP as their individual files, so + // truncate each path to the html/parts directory containing it. + for (html_suffix, parts_suffix, is_bin) in [ + (HTML_SUFFIX, PARTS_SUFFIX, false), + (BIN_HTML_SUFFIX, BIN_PARTS_SUFFIX, true), + ] { + if let Some(base) = doc_dir_base(&path, html_suffix) { + let dir = format!("{base}{html_suffix}"); + pairs.entry(base).or_insert((None, None, is_bin)).0 = Some(PathBuf::from(dir)); + break; + } else if let Some(base) = doc_dir_base(&path, parts_suffix) { + let dir = format!("{base}{parts_suffix}"); + pairs.entry(base).or_insert((None, None, is_bin)).1 = Some(PathBuf::from(dir)); + break; + } + } + } + } + + // The same crate can be documented in both the target and the exec + // configuration (proc-macro dependencies). Prefer the target + // configuration's copy when both exist. + let mut chosen: BTreeMap = BTreeMap::new(); + for base in pairs.keys() { + let (rel, is_exec) = split_configuration(base); + match chosen.get(&rel) { + Some((_, false)) => {} + _ => { + chosen.insert(rel, (base.clone(), is_exec)); + } + } + } + + let mut doc_dirs = Vec::new(); + let mut missing = 0; + for (base, _) in chosen.into_values() { + let (html_dir, parts_dir, is_bin) = &pairs[&base]; + if let (Some(html_dir), Some(parts_dir)) = (html_dir, parts_dir) { + if html_dir.is_dir() && parts_dir.is_dir() { + doc_dirs.push(CrateDocs { + html_dir: html_dir.clone(), + parts_dir: parts_dir.clone(), + is_bin: *is_bin, + }); + } else { + missing += 1; + } + } + } + Ok((doc_dirs, missing)) +} + +/// Split a path below `bazel-out` into its configuration-independent remainder +/// and whether the configuration is an exec configuration. +fn split_configuration(base: &str) -> (String, bool) { + if let Some(index) = base.find("/bazel-out/") { + let rest = &base[index + "/bazel-out/".len()..]; + if let Some(slash) = rest.find('/') { + let configuration = &rest[..slash]; + return (rest[slash..].to_owned(), configuration.contains("-exec")); + } + } + (base.to_owned(), false) +} + +/// If `path` is a doc directory with the given suffix or a file inside one, +/// return the path prefix preceding the suffix. +fn doc_dir_base(path: &str, suffix: &str) -> Option { + if let Some(base) = path.strip_suffix(suffix) { + return Some(base.to_owned()); + } + let inner = format!("{suffix}/"); + path.find(&inner).map(|pos| path[..pos].to_owned()) +} + +/// Resolve crate name collisions the way `cargo doc` does: library crates +/// win, and a binary whose name collides with an already documented crate is +/// skipped with a warning. +fn select_crates( + doc_dirs: Vec, +) -> anyhow::Result<(Vec, Vec, BTreeSet)> { + let (bins, libs): (Vec<_>, Vec<_>) = doc_dirs.into_iter().partition(|docs| docs.is_bin); + + let mut html_dirs = Vec::new(); + let mut parts_dirs = Vec::new(); + let mut names = BTreeSet::new(); + for docs in libs.into_iter().chain(bins) { + let Some(name) = crate_name_of(&docs.html_dir)? else { + continue; + }; + if !names.insert(name.clone()) { + if docs.is_bin { + eprintln!( + "Warning: not documenting binary crate `{name}`: its output would collide \ + with another documented crate" + ); + } else { + eprintln!("Warning: duplicate library crate name `{name}`; keeping the first"); + } + continue; + } + html_dirs.push(docs.html_dir); + parts_dirs.push(docs.parts_dir); + } + Ok((html_dirs, parts_dirs, names)) +} + +/// Determine the crate documented in a rustdoc output tree: the subdirectory +/// which contains an `index.html`. (Directories pre-created for cross-crate +/// links are empty, so at most one such subdirectory exists.) +fn crate_name_of(html_dir: &Path) -> anyhow::Result> { + for entry in + fs::read_dir(html_dir).with_context(|| format!("failed to read {}", html_dir.display()))? + { + let entry = entry?; + let name = entry.file_name().to_string_lossy().into_owned(); + if NON_CRATE_DIRS.contains(&name.as_str()) { + continue; + } + if entry.path().join("index.html").is_file() { + return Ok(Some(name)); + } + } + eprintln!( + "Warning: no crate documentation found in {}; skipping", + html_dir.display() + ); + Ok(None) +} + +/// Resolve a path argument against the directory `bazel run` was invoked from. +fn absolute_path(path: &Path) -> anyhow::Result { + if path.is_absolute() { + return Ok(path.to_owned()); + } + let base = match std::env::var_os("BUILD_WORKING_DIRECTORY") { + Some(cwd) => PathBuf::from(cwd), + None => std::env::current_dir()?, + }; + Ok(base.join(path)) +} + +/// Clear the output directory, refusing to delete a directory that does not +/// look like previously generated documentation. +fn prepare_output_dir(output: &Path) -> anyhow::Result<()> { + if output.exists() { + let is_empty = output + .read_dir() + .with_context(|| format!("failed to read {}", output.display()))? + .next() + .is_none(); + if !is_empty && !output.join("crates.js").exists() { + bail!( + "refusing to overwrite {}: it is not empty and does not look like generated \ + documentation (no crates.js)", + output.display(), + ); + } + fs::remove_dir_all(output) + .with_context(|| format!("failed to remove {}", output.display()))?; + } + Ok(()) +} + +fn rlocation(rlocationpath: &str) -> anyhow::Result { + let runfiles = runfiles::Runfiles::create() + .map_err(|e| anyhow::anyhow!("failed to locate runfiles: {e:?}"))?; + let path = runfiles::rlocation!(runfiles, rlocationpath) + .with_context(|| format!("runfile not found: {rlocationpath}"))?; + if !path.exists() { + bail!("runfile does not exist: {}", path.display()); + } + Ok(path) +} + +fn bazel_info(bazel: &Path, workspace: Option<&Path>, key: &str) -> anyhow::Result { + let mut command = Command::new(bazel); + if let Some(workspace) = workspace { + command.current_dir(workspace); + } + command + .env_remove("BAZELISK_SKIP_WRAPPER") + .env_remove("BUILD_WORKING_DIRECTORY") + .env_remove("BUILD_WORKSPACE_DIRECTORY"); + let output = command + .arg("info") + .arg(key) + .output() + .with_context(|| format!("failed to spawn `{}`", bazel.display()))?; + if !output.status.success() { + bail!( + "`bazel info {key}` failed:\n{}", + String::from_utf8_lossy(&output.stderr), + ); + } + Ok(String::from_utf8(output.stdout)?.trim().to_owned()) +} + +/// A Bazel command sharing the server of the invocation that launched this +/// tool: same workspace, explicit `--output_base`, and without the +/// `bazel run` environment which would otherwise confuse the nested client. +fn bazel_command(bazel: &Path, workspace: &Path, output_base: &Path) -> Command { + let mut command = Command::new(bazel); + command + .current_dir(workspace) + .env_remove("BAZELISK_SKIP_WRAPPER") + .env_remove("BUILD_WORKING_DIRECTORY") + .env_remove("BUILD_WORKSPACE_DIRECTORY") + .arg(format!("--output_base={}", output_base.display())); + command +} diff --git a/util/process_wrapper/main.rs b/util/process_wrapper/main.rs index 39a6d6db16..75fc19d651 100644 --- a/util/process_wrapper/main.rs +++ b/util/process_wrapper/main.rs @@ -120,6 +120,12 @@ fn process_line( fn main() -> Result<(), ProcessWrapperError> { let opts = options().map_err(|e| ProcessWrapperError(e.to_string()))?; + for dir in &opts.create_dirs { + std::fs::create_dir_all(dir).map_err(|e| { + ProcessWrapperError(format!("failed to create directory {}: {}", dir, e)) + })?; + } + let mut command = Command::new(opts.executable); command .args(opts.child_arguments) diff --git a/util/process_wrapper/options.rs b/util/process_wrapper/options.rs index cca227bb0a..803d78e0c4 100644 --- a/util/process_wrapper/options.rs +++ b/util/process_wrapper/options.rs @@ -32,6 +32,9 @@ pub(crate) struct Options { pub(crate) child_arguments: Vec, // Contains environment variables for the child process fetched from files. pub(crate) child_environment: HashMap, + // If set, create the specified directories (and their parents) before + // spawning the child process. + pub(crate) create_dirs: Vec, // If set, create the specified file after the child process successfully // terminated its execution. pub(crate) touch_file: Option, @@ -60,6 +63,7 @@ pub(crate) fn options() -> Result { let mut env_file_raw = None; let mut out_dir_raw = None; let mut arg_file_raw = None; + let mut create_dirs_raw = None; let mut touch_file = None; let mut copy_output_raw = None; let mut stdout_file = None; @@ -92,6 +96,11 @@ pub(crate) fn options() -> Result { "File(s) containing command line arguments to pass to the child process.", &mut arg_file_raw, ); + flags.define_repeated_flag( + "--mkdir", + "Directory(s) to create (including parents) before spawning the child process.", + &mut create_dirs_raw, + ); flags.define_flag( "--touch-file", "Create this file after the child process runs successfully.", @@ -294,6 +303,7 @@ pub(crate) fn options() -> Result { executable: exec_path.to_owned(), child_arguments: args.to_vec(), child_environment: vars, + create_dirs: create_dirs_raw.unwrap_or_default(), touch_file, copy_output, stdout_file,