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