From 30a52a7b854b57d8fed0ffdf8ac7e1d14669af10 Mon Sep 17 00:00:00 2001 From: Cormac Relf Date: Fri, 17 Apr 2026 14:33:16 +1000 Subject: [PATCH 1/8] Add RFC 3662 mergeable rustdoc BXL Implements RFC 3662 mergeable rustdoc in the rust prelude with a BXL that merges per-crate docs into one HTML tree. The reusable rustdoc_merge helper lives in doc_merge.bzl so downstream BXL can bring its own target discovery and filtering. Uses --emit=html-non-static-files. This avoids writing shared js/css assets in every crate build. Since the flag has recently been renamed, we have a compat layer for the old flag names. This all means we don't have to touch this for another year hopefully. --- examples/rust_doc_merge/.buckconfig | 23 ++++ examples/rust_doc_merge/.buckroot | 0 examples/rust_doc_merge/README.md | 31 +++++ examples/rust_doc_merge/bin/BUCK | 9 ++ examples/rust_doc_merge/bin/src/main.rs | 8 ++ examples/rust_doc_merge/crate_a/BUCK | 6 + examples/rust_doc_merge/crate_a/src/lib.rs | 50 +++++++ examples/rust_doc_merge/crate_b/BUCK | 7 + examples/rust_doc_merge/crate_b/src/lib.rs | 42 ++++++ examples/rust_doc_merge/toolchains/BUCK | 48 +++++++ prelude/rust/build.bzl | 129 ++++++++++++++++++ prelude/rust/doc_merge.bxl | 73 ++++++++++ prelude/rust/doc_merge.bzl | 89 ++++++++++++ prelude/rust/outputs.bzl | 10 ++ prelude/rust/rust_binary.bzl | 13 ++ prelude/rust/rust_library.bzl | 22 ++- prelude/rust/tools/BUCK | 24 ++++ prelude/rust/tools/attrs.bzl | 1 + prelude/rust/tools/rustdoc_emit_compat.py | 65 +++++++++ prelude/rust/tools/rustdoc_merge.py | 81 +++++++++++ .../rust/tools/rustdoc_parts_emit_argfile.py | 46 +++++++ 21 files changed, 776 insertions(+), 1 deletion(-) create mode 100644 examples/rust_doc_merge/.buckconfig create mode 100644 examples/rust_doc_merge/.buckroot create mode 100644 examples/rust_doc_merge/README.md create mode 100644 examples/rust_doc_merge/bin/BUCK create mode 100644 examples/rust_doc_merge/bin/src/main.rs create mode 100644 examples/rust_doc_merge/crate_a/BUCK create mode 100644 examples/rust_doc_merge/crate_a/src/lib.rs create mode 100644 examples/rust_doc_merge/crate_b/BUCK create mode 100644 examples/rust_doc_merge/crate_b/src/lib.rs create mode 100644 examples/rust_doc_merge/toolchains/BUCK create mode 100644 prelude/rust/doc_merge.bxl create mode 100644 prelude/rust/doc_merge.bzl create mode 100644 prelude/rust/tools/rustdoc_emit_compat.py create mode 100644 prelude/rust/tools/rustdoc_merge.py create mode 100644 prelude/rust/tools/rustdoc_parts_emit_argfile.py diff --git a/examples/rust_doc_merge/.buckconfig b/examples/rust_doc_merge/.buckconfig new file mode 100644 index 0000000000000..7f64bc3d4ceba --- /dev/null +++ b/examples/rust_doc_merge/.buckconfig @@ -0,0 +1,23 @@ +[cells] +root = . +prelude = prelude +toolchains = toolchains +none = none + +[cell_aliases] +config = prelude +ovr_config = prelude +fbcode = none +fbsource = none +buck = none + +[external_cells] +prelude = bundled + +[build] +execution_platforms = prelude//platforms:default + +[parser] +target_platform_detector_spec = target:root//...->prelude//platforms:default \ + target:prelude//...->prelude//platforms:default \ + target:toolchains//...->prelude//platforms:default diff --git a/examples/rust_doc_merge/.buckroot b/examples/rust_doc_merge/.buckroot new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/examples/rust_doc_merge/README.md b/examples/rust_doc_merge/README.md new file mode 100644 index 0000000000000..6d3cfbf877c1c --- /dev/null +++ b/examples/rust_doc_merge/README.md @@ -0,0 +1,31 @@ +# rust_doc_merge example + +Exercises RFC 3662 mergeable rustdoc support +(`prelude//rust:doc_merge.bxl`). Three crates: `crate_a` (leaf), +`crate_b` (depends on A), and `bin` (depends on both). + +Uses the bundled prelude (`[external_cells] prelude = bundled`), so +edits to `../../prelude/` show up after the next `cargo build --bin +buck2`. + +## Build per-crate HTML (existing behaviour) + +``` +buck2 build '//crate_a:crate_a[doc]' --show-output +``` + +## Produce a single merged HTML tree across all three crates + +``` +buck2 bxl prelude//rust:doc_merge.bxl:merge -- --targets //... +``` + +The BXL prints an absolute path to a directory containing merged HTML +with a cross-crate index. Serve it with e.g.: + +```bash +env -C "$(buck2 bxl prelude//rust:doc_merge.bxl:merge -- --targets //...)" python3 -m http.server +``` + +Pass `--include-deps=true` to additionally pull in transitive rust +dependencies of the listed targets. diff --git a/examples/rust_doc_merge/bin/BUCK b/examples/rust_doc_merge/bin/BUCK new file mode 100644 index 0000000000000..6b7491d1940ab --- /dev/null +++ b/examples/rust_doc_merge/bin/BUCK @@ -0,0 +1,9 @@ +rust_binary( + name = "bin", + srcs = glob(["src/**/*.rs"]), + crate_root = "src/main.rs", + deps = [ + "//crate_a:crate_a", + "//crate_b:crate_b", + ], +) diff --git a/examples/rust_doc_merge/bin/src/main.rs b/examples/rust_doc_merge/bin/src/main.rs new file mode 100644 index 0000000000000..ecb7ecf2df34e --- /dev/null +++ b/examples/rust_doc_merge/bin/src/main.rs @@ -0,0 +1,8 @@ +//! A tiny binary that exercises both library crates. + +use crate_b::WidgetB; + +fn main() { + let w = WidgetB::new("world", 3); + println!("{} ({})", w.greet(), w.count); +} diff --git a/examples/rust_doc_merge/crate_a/BUCK b/examples/rust_doc_merge/crate_a/BUCK new file mode 100644 index 0000000000000..d6c178deab965 --- /dev/null +++ b/examples/rust_doc_merge/crate_a/BUCK @@ -0,0 +1,6 @@ +rust_library( + name = "crate_a", + srcs = glob(["src/**/*.rs"]), + crate_root = "src/lib.rs", + visibility = ["PUBLIC"], +) diff --git a/examples/rust_doc_merge/crate_a/src/lib.rs b/examples/rust_doc_merge/crate_a/src/lib.rs new file mode 100644 index 0000000000000..092e49fe064a2 --- /dev/null +++ b/examples/rust_doc_merge/crate_a/src/lib.rs @@ -0,0 +1,50 @@ +//! Crate A: a demo crate for testing RFC 3662 merged rustdoc. + +use std::fmt; + +/// Greet someone from crate A. +/// +/// # Examples +/// +/// ``` +/// assert_eq!(crate_a::greet("world"), "hello world"); +/// ``` +pub fn greet(who: &str) -> String { + format!("hello {}", who) +} + +/// A simple struct defined in crate A. +#[derive(Debug, Default)] +pub struct WidgetA { + /// The widget's display name. + pub name: String, +} + +impl WidgetA { + /// Construct a new [`WidgetA`]. + pub fn new(name: impl Into) -> Self { + Self { name: name.into() } + } +} + +impl fmt::Display for WidgetA { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "WidgetA({})", self.name) + } +} + +/// A trait for things that can produce a greeting. +/// +/// Implemented by [`WidgetA`] here and by `WidgetB` in `crate_b` — the +/// rustdoc merge step has to stitch both impls into the same +/// `trait.impl/crate_a/trait.Greeter.js` file. +pub trait Greeter { + /// Return a greeting string. + fn greeting(&self) -> String; +} + +impl Greeter for WidgetA { + fn greeting(&self) -> String { + greet(&self.name) + } +} diff --git a/examples/rust_doc_merge/crate_b/BUCK b/examples/rust_doc_merge/crate_b/BUCK new file mode 100644 index 0000000000000..e0f48f2ae9e61 --- /dev/null +++ b/examples/rust_doc_merge/crate_b/BUCK @@ -0,0 +1,7 @@ +rust_library( + name = "crate_b", + srcs = glob(["src/**/*.rs"]), + crate_root = "src/lib.rs", + deps = ["//crate_a:crate_a"], + visibility = ["PUBLIC"], +) diff --git a/examples/rust_doc_merge/crate_b/src/lib.rs b/examples/rust_doc_merge/crate_b/src/lib.rs new file mode 100644 index 0000000000000..43812d0528e02 --- /dev/null +++ b/examples/rust_doc_merge/crate_b/src/lib.rs @@ -0,0 +1,42 @@ +//! Crate B: depends on crate A, re-exports and extends it. + +use std::fmt; + +use crate_a::Greeter; +use crate_a::WidgetA; + +/// A widget that wraps a [`WidgetA`] with extra metadata. +#[derive(Debug, Default)] +pub struct WidgetB { + /// Inner widget from crate A. + pub inner: WidgetA, + /// Count of something. + pub count: u32, +} + +impl WidgetB { + /// Construct a new [`WidgetB`] wrapping a freshly-made [`WidgetA`]. + pub fn new(name: &str, count: u32) -> Self { + Self { + inner: WidgetA::new(name), + count, + } + } + + /// Produce a greeting via [`crate_a::greet`]. + pub fn greet(&self) -> String { + crate_a::greet(&self.inner.name) + } +} + +impl fmt::Display for WidgetB { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "WidgetB({}, {})", self.inner.name, self.count) + } +} + +impl Greeter for WidgetB { + fn greeting(&self) -> String { + format!("{} (x{})", self.inner.greeting(), self.count) + } +} diff --git a/examples/rust_doc_merge/toolchains/BUCK b/examples/rust_doc_merge/toolchains/BUCK new file mode 100644 index 0000000000000..8970145ee4d96 --- /dev/null +++ b/examples/rust_doc_merge/toolchains/BUCK @@ -0,0 +1,48 @@ +load("@prelude//tests:test_toolchain.bzl", "noop_test_toolchain") +load("@prelude//toolchains:cxx.bzl", "system_cxx_toolchain") +load("@prelude//toolchains:python.bzl", "remote_python_toolchain", "system_python_wheel_toolchain") +load("@prelude//toolchains:remote_test_execution.bzl", "remote_test_execution_toolchain") +load("@prelude//toolchains:rust.bzl", "system_rust_toolchain") + +# Minimal expansion of `system_demo_toolchains()` from +# `prelude//toolchains:demo.bzl`, trimmed to just what this rust-only example +# needs, so we can pass custom `rustdoc_flags` to `:rust`. Those flags are +# forwarded to per-crate rustdoc and to the `rustdoc --merge=finalize` step +# by `doc_merge.bzl`. + +system_cxx_toolchain( + name = "cxx", + visibility = ["PUBLIC"], +) + +remote_python_toolchain( + name = "python", + visibility = ["PUBLIC"], +) + +system_python_wheel_toolchain( + name = "python_wheel", + visibility = ["PUBLIC"], +) + +system_rust_toolchain( + name = "rust", + default_edition = "2021", + # Harmless rustdoc flags. Forwarded to per-crate rustdoc and also to + # the `rustdoc --merge=finalize` step by `doc_merge.bzl`. + rustdoc_flags = [ + "--default-theme=ayu", + "--cap-lints=warn", + ], + visibility = ["PUBLIC"], +) + +remote_test_execution_toolchain( + name = "remote_test_execution", + visibility = ["PUBLIC"], +) + +noop_test_toolchain( + name = "test", + visibility = ["PUBLIC"], +) diff --git a/prelude/rust/build.bzl b/prelude/rust/build.bzl index a71e1855b5b61..b90f2dcae072b 100644 --- a/prelude/rust/build.bzl +++ b/prelude/rust/build.bzl @@ -204,6 +204,135 @@ def generate_rustdoc( return output +RustdocPartsOutputs = record( + parts = Artifact, + html = Artifact, +) + +def _rustdoc_emit_argfile( + ctx: AnalysisContext, + compile_ctx: CompileContext, + argfile_name: str, + emit: list[str]) -> Artifact: + """Write an argfile containing one `--emit=` line per value in + `emit`. Handles the 2026-03 rustdoc rename of `invocation-specific` + / `toolchain-shared-resources` → `html-non-static-files` / + `html-static-files`: pass the post-rename names and the helper + substitutes the older name when the current rustdoc doesn't know the + new one. See `tools/rustdoc_parts_emit_argfile.py`. + """ + toolchain_info = compile_ctx.toolchain_info + use_cbp = getattr(ctx.attrs, "use_content_based_paths", False) + argfile = ctx.actions.declare_output(argfile_name, has_content_based_path = use_cbp) + ctx.actions.run( + cmd_args( + compile_ctx.internal_tools_info.rustdoc_parts_emit_argfile, + cmd_args(toolchain_info.rustdoc, format = "--rustdoc={}"), + cmd_args(argfile.as_output(), format = "--out={}"), + "--emit={}".format(",".join(emit)), + ), + category = "rustdoc_parts_emit_argfile", + identifier = argfile_name, + ) + return argfile + +def generate_rustdoc_parts( + ctx: AnalysisContext, + compile_ctx: CompileContext, + # link style doesn't matter, but caller should pass in build params + # with static-pic (to get best cache hits for deps) + params: BuildParams, + default_roots: list[str], + document_private_items: bool) -> RustdocPartsOutputs: + """Build per-crate rustdoc "parts" via RFC3662 (`--parts-out-dir`). + + These parts can be collected from multiple crates and combined into a + single merged HTML tree using `rustdoc --merge=finalize + --include-parts-dir=...`. See `prelude//rust:doc_merge.bxl`. + + Uses `-Zunstable-options`, so requires `RUSTC_BOOTSTRAP=1` in the rustdoc environment. + """ + toolchain_info = compile_ctx.toolchain_info + + common_args = _compute_common_args( + ctx = ctx, + compile_ctx = compile_ctx, + dep_ctx = compile_ctx.dep_ctx, + emit = Emit("metadata-fast"), + params = params, + default_roots = default_roots, + infallible_diagnostics = False, + incremental_enabled = False, + is_rustdoc_test = False, + profile_mode = None, + ) + + subdir = common_args.subdir + "-rustdoc-parts" + use_cbp = getattr(ctx.attrs, "use_content_based_paths", False) + + # `--merge=none` writes per-crate HTML into `--out-dir` and the sidecar + # "parts" metadata into `--parts-out-dir`. Both are needed to later run + # `rustdoc --merge=finalize` against a combined tree: the parts drive the + # cross-crate index and the HTML provides the per-crate docs. + html_out = ctx.actions.declare_output(subdir + "-html", dir = True, has_content_based_path = use_cbp) + parts_out = ctx.actions.declare_output(subdir, dir = True, has_content_based_path = use_cbp) + + plain_env, path_env = process_env(compile_ctx, toolchain_info.rustdoc_env | ctx.attrs.env) + plain_env["RUSTDOC_BUCK_TARGET"] = cmd_args(str(ctx.label.raw_target())) + + # `--parts-out-dir`, `--include-parts-dir`, and `--merge=` are behind + # `-Zunstable-options` as of rustc 1.90 (RFC 3662 is not yet stabilised). + plain_env["RUSTC_BOOTSTRAP"] = cmd_args("1") + + if toolchain_info.rust_target_path != None: + path_env["RUST_TARGET_PATH"] = toolchain_info.rust_target_path[DefaultInfo].default_outputs[0] + + # Skip emitting shared toolchain assets (CSS/JS) in each per-crate tree; + # the merge step produces them once in the combined output. Rustdoc + # renamed the emit value here in 2026-03 — `_rustdoc_emit_argfile` + # handles the rename transparently. + emit_argfile = _rustdoc_emit_argfile( + ctx = ctx, + compile_ctx = compile_ctx, + argfile_name = subdir + "-emit.args", + emit = ["html-non-static-files"], + ) + + rustdoc_cmd = cmd_args( + toolchain_info.rustdoc, + "--rustc-action-separator", + toolchain_info.rustdoc_flags, + ctx.attrs.rustdoc_flags, + common_args.args, + "-Zunstable-options", + "--merge=none", + cmd_args("@", emit_argfile, delimiter = ""), + cmd_args(html_out.as_output(), format = "--out-dir={}"), + cmd_args(parts_out.as_output(), format = "--parts-out-dir={}"), + hidden = [toolchain_info.rustdoc, compile_ctx.symlinked_srcs], + ) + + if document_private_items: + rustdoc_cmd.add("--document-private-items") + + rustdoc_cmd_action = cmd_args( + [cmd_args("--env=", k, "=", v, delimiter = "") for k, v in plain_env.items()], + [cmd_args("--path-env=", k, "=", v, delimiter = "") for k, v in path_env.items()], + rustdoc_cmd, + ) + + rustdoc_cmd = _long_command( + ctx = ctx, + exe = compile_ctx.internal_tools_info.rustc_action, + args = rustdoc_cmd_action, + argfile_name = "{}.args".format(subdir), + has_content_based_path = use_cbp, + ) + + ctx.actions.run(rustdoc_cmd, category = "rustdoc_parts") + + return RustdocPartsOutputs(parts = parts_out, html = html_out) + def generate_rustdoc_coverage( ctx: AnalysisContext, compile_ctx: CompileContext, diff --git a/prelude/rust/doc_merge.bxl b/prelude/rust/doc_merge.bxl new file mode 100644 index 0000000000000..d325af4fdd54f --- /dev/null +++ b/prelude/rust/doc_merge.bxl @@ -0,0 +1,73 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is dual-licensed under either the MIT license found in the +# LICENSE-MIT file in the root directory of this source tree or the Apache +# License, Version 2.0 found in the LICENSE-APACHE file in the root directory +# of this source tree. You may select, at your option, one of the +# above-listed licenses. + +"""Produce a unified rustdoc HTML tree across a set of rust crates. + +Uses RFC 3662 (`rustdoc --merge=finalize`) to combine the per-crate HTML +and sidecar "parts" outputs produced by the `[doc-html-parts]` / +`[doc-parts]` sub-targets of `rust_library` / `rust_binary`. + +Usage: + + buck2 bxl prelude//rust:doc_merge.bxl:merge \\ + -- --targets //my:lib --targets //my:bin + +Pass `--include-deps=true` to also include all transitive rust +dependencies in the merged tree. Without that flag, only the explicitly +listed targets are merged. + +For customised workflows (different target discovery, filtering, later +opting specific crates out to link to docs.rs, etc.) import +`rustdoc_merge` from `@prelude//rust:doc_merge.bzl` directly and write +your own BXL around it. +""" + +load("@prelude//rust:doc_merge.bzl", "rustdoc_merge") + +# rust_test intentionally excluded — test targets don't emit user-facing +# docs worth merging. +_RUST_DOC_RULE_KINDS = "^(rust_binary|rust_library)$" + +def _collect_targets(ctx: bxl.Context) -> list[bxl.ConfiguredTargetNode]: + # `cli_args.list(cli_args.target_expr())` produces a list of lists (one + # sublist per `--targets` occurrence). Flatten before handing to buck. + targets = [] + for group in ctx.cli_args.targets: + targets.extend(group) + + if ctx.cli_args.include_deps: + universe = ctx.target_universe(targets) + configured_targets = ctx.cquery().deps(universe.target_set()) + else: + configured_targets = ctx.configured_targets(targets) + + return list(ctx.cquery().kind(_RUST_DOC_RULE_KINDS, configured_targets)) + +def _merge_impl(ctx: bxl.Context) -> None: + targets = _collect_targets(ctx) + if not targets: + fail("doc_merge.bxl: no rust targets resolved from --targets") + + out_dir = rustdoc_merge(ctx, targets) + ctx.output.print(ctx.output.ensure(out_dir).abs_path()) + +merge = bxl_main( + doc = "Produce a unified rustdoc HTML tree across a set of rust crates.", + impl = _merge_impl, + cli_args = { + "include-deps": cli_args.bool( + default = False, + doc = "Also merge all transitive rust dependencies of the given targets.", + ), + "targets": cli_args.list( + cli_args.target_expr( + doc = "Rust target(s) whose docs to merge.", + ), + ), + }, +) diff --git a/prelude/rust/doc_merge.bzl b/prelude/rust/doc_merge.bzl new file mode 100644 index 0000000000000..fb17030f42bc3 --- /dev/null +++ b/prelude/rust/doc_merge.bzl @@ -0,0 +1,89 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is dual-licensed under either the MIT license found in the +# LICENSE-MIT file in the root directory of this source tree or the Apache +# License, Version 2.0 found in the LICENSE-APACHE file in the root directory +# of this source tree. You may select, at your option, one of the +# above-listed licenses. + +"""Reusable building block for producing a merged rustdoc HTML tree +across a set of rust crates, using RFC 3662's `rustdoc --merge=finalize`. + +`prelude//rust:doc_merge.bxl` is a thin wrapper around `rustdoc_merge` +for the common case. Import `rustdoc_merge` from your own BXL when you +want to customise *which* crates go into the merged tree — e.g. bespoke +target-universe handling, filtering by package, or (in future) skipping +crates that should link out to docs.rs instead of being bundled. + +Typical custom BXL: + + load("@prelude//rust:doc_merge.bzl", "rustdoc_merge") + + def _impl(ctx: bxl.Context) -> None: + universe = ctx.target_universe("root//my/lib/...") + rust = ctx.cquery().kind("^rust_library$", universe.target_set()) + targets = [t for t in rust if should_include(t)] + out = rustdoc_merge(ctx, targets) + ctx.output.print(ctx.output.ensure(out).abs_path()) +""" + +load("@prelude//rust:outputs.bzl", "RustcExtraOutputsInfo") +load("@prelude//rust:rust_toolchain.bzl", "RustToolchainInfo") + +_MERGE_TOOL = "prelude//rust/tools:rustdoc_merge" + +def rustdoc_merge( + ctx: bxl.Context, + targets: list[bxl.ConfiguredTargetNode]) -> Artifact: + """Produce a merged rustdoc HTML tree for `targets`. + + `targets` must be configured rust rules whose `RustcExtraOutputsInfo` + provider carries non-None `rustdoc_parts` / `rustdoc_html` artifacts + (i.e. `rust_library` or `rust_binary`). Targets that don't carry the + provider are skipped silently; callers should filter in advance if + they want stricter behaviour. + + Returns the merged output directory as an Artifact — the caller is + responsible for `ctx.output.ensure(...)` or any further use. + """ + if not targets: + fail("rustdoc_merge: targets list is empty") + + analyses = ctx.analysis(targets) + parts_artifacts = [] + html_artifacts = [] + for _label, analysis in analyses.items(): + providers = analysis.providers() + if RustcExtraOutputsInfo not in providers: + continue + extra = providers[RustcExtraOutputsInfo] + if extra.rustdoc_parts != None: + parts_artifacts.append(extra.rustdoc_parts) + if extra.rustdoc_html != None: + html_artifacts.append(extra.rustdoc_html) + + if not parts_artifacts: + fail("rustdoc_merge: no rust targets with rustdoc_parts found") + + # Every rust rule has `_rust_toolchain` wired via `toolchains_common.rust()`, + # so we can read the rustdoc binary off any of the resolved targets. + rust_toolchain = targets[0].resolved_attrs_eager(ctx)._rust_toolchain[RustToolchainInfo] + + bxl_actions = ctx.bxl_actions(exec_deps = [_MERGE_TOOL]) + actions = bxl_actions.actions + merge_tool = bxl_actions.exec_deps[bxl_actions.exec_deps.keys()[0]][RunInfo] + + out_dir = actions.declare_output("merged-rustdoc", dir = True) + + cmd = cmd_args( + merge_tool, + cmd_args(out_dir.as_output(), format = "--out-dir={}"), + cmd_args(rust_toolchain.rustdoc, format = "--rustdoc={}"), + ) + for html in html_artifacts: + cmd.add(cmd_args(html, format = "--html-dir={}")) + for parts in parts_artifacts: + cmd.add(cmd_args(parts, format = "--parts-dir={}")) + + actions.run(cmd, category = "rustdoc_merge") + return out_dir diff --git a/prelude/rust/outputs.bzl b/prelude/rust/outputs.bzl index 05cada435f1bd..f167a8a313cea 100644 --- a/prelude/rust/outputs.bzl +++ b/prelude/rust/outputs.bzl @@ -64,5 +64,15 @@ RustcExtraOutputsInfo = provider( "metadata": RustcOutput, "metadata_incr": RustcOutput, "remarks": RustcOutput | None, + # Directory of rustdoc "parts" produced by rustdoc with + # `--merge=none --parts-out-dir=...`. See RFC 3662 and + # `generate_rustdoc_parts` in build.bzl. Consumed by + # `prelude//rust:doc_merge.bxl` to produce a merged HTML tree. + "rustdoc_parts": Artifact | None, + # Directory of per-crate rustdoc HTML produced alongside the parts + # artifacts (the `--out-dir` argument to `rustdoc --merge=none`). + # Paired with `rustdoc_parts`: both are needed to assemble a merged + # cross-crate HTML tree. + "rustdoc_html": Artifact | None, }, ) diff --git a/prelude/rust/rust_binary.bzl b/prelude/rust/rust_binary.bzl index 764833770a2c8..513550b71e82b 100644 --- a/prelude/rust/rust_binary.bzl +++ b/prelude/rust/rust_binary.bzl @@ -74,6 +74,7 @@ load("@prelude//utils:utils.bzl", "flatten_dict") load( ":build.bzl", "generate_rustdoc", + "generate_rustdoc_parts", "rust_compile", ) load( @@ -397,12 +398,22 @@ def _rust_binary_common( transformation_spec_context = transformation_spec_context, ) + doc_parts_output = generate_rustdoc_parts( + ctx = ctx, + compile_ctx = compile_ctx, + params = strategy_param[DEFAULT_STATIC_LINK_STRATEGY], + default_roots = default_roots, + document_private_items = True, + ) + providers = [RustcExtraOutputsInfo( metadata = diag_artifacts[False], metadata_incr = diag_artifacts[True], clippy = clippy_artifacts[False], clippy_incr = clippy_artifacts[True], remarks = None, # Exposed via subtargets, not this provider + rustdoc_parts = doc_parts_output.parts, + rustdoc_html = doc_parts_output.html, )] incr_enabled = ctx.attrs.incremental_enabled @@ -497,6 +508,8 @@ def _rust_binary_common( document_private_items = True, ) extra_compiled_targets["doc"] = doc_output + extra_compiled_targets["doc-parts"] = doc_parts_output.parts + extra_compiled_targets["doc-html-parts"] = doc_parts_output.html named_deps_names = write_named_deps_names(ctx, compile_ctx) if named_deps_names: diff --git a/prelude/rust/rust_library.bzl b/prelude/rust/rust_library.bzl index 8c8ecb2a3e6ad..29283c87fa9ed 100644 --- a/prelude/rust/rust_library.bzl +++ b/prelude/rust/rust_library.bzl @@ -74,8 +74,10 @@ load( load("@prelude//unix:providers.bzl", "UnixEnv", "create_unix_env_info") load( ":build.bzl", + "RustdocPartsOutputs", # @unused Used as a type "generate_rustdoc", "generate_rustdoc_coverage", + "generate_rustdoc_parts", "generate_rustdoc_test", "rust_compile", "rust_link_shared", @@ -305,6 +307,14 @@ def rust_library_impl(ctx: AnalysisContext) -> list[Provider]: default_roots = _DEFAULT_ROOTS, ) + rustdoc_parts = generate_rustdoc_parts( + ctx = ctx, + compile_ctx = compile_ctx, + params = static_library_params, + default_roots = _DEFAULT_ROOTS, + document_private_items = False, + ) + expand = rust_compile( ctx = ctx, compile_ctx = compile_ctx, @@ -442,12 +452,15 @@ def rust_library_impl(ctx: AnalysisContext) -> list[Provider]: sources = compile_ctx.symlinked_srcs, transitive_srcs = compile_ctx.transitive_srcs, rustdoc_coverage = rustdoc_coverage, + rustdoc_parts = rustdoc_parts, named_deps_names = write_named_deps_names(ctx, compile_ctx), profiles = profiles, ) providers += _rust_metadata_providers( diag_artifacts = diag_artifacts, clippy_artifacts = clippy_artifacts, + rustdoc_parts = rustdoc_parts.parts, + rustdoc_html = rustdoc_parts.html, ) if ctx.attrs.proc_macro: @@ -717,6 +730,7 @@ def _default_providers( sources: Artifact, transitive_srcs: RustSourcesTSet, rustdoc_coverage: Artifact, + rustdoc_parts: RustdocPartsOutputs, named_deps_names: Artifact | None, profiles: list[Provider]) -> list[Provider]: targets = {} @@ -725,6 +739,8 @@ def _default_providers( targets["expand"] = expand targets["doc"] = rustdoc targets["doc-coverage"] = rustdoc_coverage + targets["doc-parts"] = rustdoc_parts.parts + targets["doc-html-parts"] = rustdoc_parts.html targets["remarks.txt"] = remarks_artifact.compile_output.remarks_txt targets["remarks.json"] = remarks_artifact.compile_output.remarks_json if named_deps_names: @@ -789,7 +805,9 @@ def _default_providers( def _rust_metadata_providers( diag_artifacts: dict[bool, RustcOutput], - clippy_artifacts: dict[bool, RustcOutput]) -> list[Provider]: + clippy_artifacts: dict[bool, RustcOutput], + rustdoc_parts: Artifact | None, + rustdoc_html: Artifact | None) -> list[Provider]: return [ RustcExtraOutputsInfo( metadata = diag_artifacts[False], @@ -797,6 +815,8 @@ def _rust_metadata_providers( clippy = clippy_artifacts[False], clippy_incr = clippy_artifacts[True], remarks = None, # Exposed via subtargets, not this provider + rustdoc_parts = rustdoc_parts, + rustdoc_html = rustdoc_html, ), ] diff --git a/prelude/rust/tools/BUCK b/prelude/rust/tools/BUCK index b862e12031d20..1f9d267421fa3 100644 --- a/prelude/rust/tools/BUCK +++ b/prelude/rust/tools/BUCK @@ -91,6 +91,27 @@ prelude.python_bootstrap_binary( visibility = ["PUBLIC"], ) +prelude.python_bootstrap_library( + name = "rustdoc_emit_compat", + srcs = ["rustdoc_emit_compat.py"], +) + +prelude.python_bootstrap_binary( + name = "rustdoc_merge", + has_content_based_path = True, + main = "rustdoc_merge.py", + visibility = ["PUBLIC"], + deps = [":rustdoc_emit_compat"], +) + +prelude.python_bootstrap_binary( + name = "rustdoc_parts_emit_argfile", + has_content_based_path = True, + main = "rustdoc_parts_emit_argfile.py", + visibility = ["PUBLIC"], + deps = [":rustdoc_emit_compat"], +) + prelude.python_bootstrap_binary( name = "redirect_stdout", has_content_based_path = True, @@ -153,6 +174,9 @@ prelude.python_library( "redirect_stdout.py", "rustc_action.py", "rustdoc_coverage.py", + "rustdoc_emit_compat.py", + "rustdoc_merge.py", + "rustdoc_parts_emit_argfile.py", "rustdoc_test_with_resources.py", "shared_libraries_symlink_tree.py", "symlink_only_dir_entry.py", diff --git a/prelude/rust/tools/attrs.bzl b/prelude/rust/tools/attrs.bzl index 2db2d66bccff2..c54a513d7df51 100644 --- a/prelude/rust/tools/attrs.bzl +++ b/prelude/rust/tools/attrs.bzl @@ -21,6 +21,7 @@ _internal_tool_attrs = { "redirect_stdout": _internal_tool("prelude//rust/tools:redirect_stdout"), "rustc_action": _internal_tool("prelude//rust/tools:rustc_action"), "rustdoc_coverage": _internal_tool("prelude//rust/tools:rustdoc_coverage"), + "rustdoc_parts_emit_argfile": _internal_tool("prelude//rust/tools:rustdoc_parts_emit_argfile"), "rustdoc_test_with_resources": _internal_tool("prelude//rust/tools:rustdoc_test_with_resources"), "shared_libraries_symlink_tree": _internal_tool("prelude//rust/tools:shared_libraries_symlink_tree"), "symlink_only_dir_entry": _internal_tool("prelude//rust/tools:symlink_only_dir_entry"), diff --git a/prelude/rust/tools/rustdoc_emit_compat.py b/prelude/rust/tools/rustdoc_emit_compat.py new file mode 100644 index 0000000000000..882046da1fbf3 --- /dev/null +++ b/prelude/rust/tools/rustdoc_emit_compat.py @@ -0,0 +1,65 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is dual-licensed under either the MIT license found in the +# LICENSE-MIT file in the root directory of this source tree or the Apache +# License, Version 2.0 found in the LICENSE-APACHE file in the root directory +# of this source tree. You may select, at your option, one of the +# above-listed licenses. + +"""Shared helper for rustdoc's 2026-03 `--emit=` kind rename +(https://github.com/rust-lang/rust/commit/c6b7f630a5365ccfe2d4f24c7f5a4cc8499c5a55): + + invocation-specific -> html-non-static-files + toolchain-shared-resources -> html-static-files + +Used by `rustdoc_parts_emit_argfile.py` (per-crate parts step) and +`rustdoc_merge.py` (finalize step). +""" + +import subprocess + + +RENAMES = { + "html-non-static-files": "invocation-specific", + "html-static-files": "toolchain-shared-resources", +} + + +def resolve_emits(rustdoc: str, emits: list) -> str | None: + """Return a `--emit=...` value with each post-rename name in `emits` + substituted to its pre-rename equivalent if the current rustdoc + doesn't recognise it. + + Returns `None` if rustdoc has no `--emit` line at all, or if every + requested name (and its pre-rename equivalent) is absent from that + line — callers should omit the flag rather than feed rustdoc a name + it'll reject. + """ + help_out = subprocess.run( + [rustdoc, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=True, + ).stdout.decode("utf-8", errors="replace") + # `rustdoc --help` prints the valid emit kinds on the `--emit` line, + # e.g. `--emit [toolchain-shared-resources,invocation-specific,dep-info]`. + # Scope the substring check to that line so we don't false-match on + # `html-non-static-files` appearing in some other description. + emit_line = next( + (line for line in help_out.splitlines() if "--emit" in line), + None, + ) + if emit_line is None: + return None + + resolved = [] + for e in emits: + if e in emit_line: + resolved.append(e) + elif e in RENAMES and RENAMES[e] in emit_line: + resolved.append(RENAMES[e]) + # else: rustdoc knows neither name — drop this emit silently. + + if not resolved: + return None + return "--emit=" + ",".join(resolved) diff --git a/prelude/rust/tools/rustdoc_merge.py b/prelude/rust/tools/rustdoc_merge.py new file mode 100644 index 0000000000000..8799d767c54c4 --- /dev/null +++ b/prelude/rust/tools/rustdoc_merge.py @@ -0,0 +1,81 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is dual-licensed under either the MIT license found in the +# LICENSE-MIT file in the root directory of this source tree or the Apache +# License, Version 2.0 found in the LICENSE-APACHE file in the root directory +# of this source tree. You may select, at your option, one of the +# above-listed licenses. + +"""Stage per-crate rustdoc HTML into a shared directory, then run +`rustdoc --merge=finalize` to write cross-crate index pages over the +top. + +RFC 3662 splits rustdoc into a "per-crate" pass (`--merge=none` writing +HTML plus a sidecar parts directory) and a "finalize" pass +(`--merge=finalize` reading the parts dirs). Finalize only writes the +top-level index/search pages; the per-crate HTML must be present +alongside for the merged tree to be browseable. +""" + +import argparse +import os +import shutil +import subprocess +import sys +from pathlib import Path + +from rustdoc_emit_compat import resolve_emits + + +def stage_html(out_dir: Path, html_dirs: list[str]) -> None: + for html_dir in html_dirs: + src = Path(html_dir) + if not src.is_dir(): + continue + for entry in src.iterdir(): + dest = out_dir / entry.name + if dest.exists() or dest.is_symlink(): + dest.unlink() + os.symlink(entry.resolve(), dest) + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--out-dir", required=True) + p.add_argument("--rustdoc", required=True) + p.add_argument("--html-dir", action="append", default=[]) + p.add_argument("--parts-dir", action="append", default=[]) + args = p.parse_args() + + out = Path(args.out_dir) + if out.exists(): + shutil.rmtree(out) + out.mkdir(parents=True) + + stage_html(out, args.html_dir) + + env = dict(os.environ) + env["RUSTC_BOOTSTRAP"] = "1" + + cmd = [ + args.rustdoc, + "-Zunstable-options", + "--merge=finalize", + "--enable-index-page", + f"--out-dir={args.out_dir}", + ] + # Finalize is the one step that should emit the shared CSS/JS for + # the whole tree (the per-crate parts step skipped them); also + # emit the non-static files (index.html, search.index, etc.) that + # finalize itself produces. + emit_arg = resolve_emits(args.rustdoc, ["html-static-files", "html-non-static-files"]) + if emit_arg is not None: + cmd.append(emit_arg) + for pd in args.parts_dir: + cmd.append(f"--include-parts-dir={pd}") + + return subprocess.run(cmd, env=env).returncode + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/prelude/rust/tools/rustdoc_parts_emit_argfile.py b/prelude/rust/tools/rustdoc_parts_emit_argfile.py new file mode 100644 index 0000000000000..77f3a417bcd70 --- /dev/null +++ b/prelude/rust/tools/rustdoc_parts_emit_argfile.py @@ -0,0 +1,46 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is dual-licensed under either the MIT license found in the +# LICENSE-MIT file in the root directory of this source tree or the Apache +# License, Version 2.0 found in the LICENSE-APACHE file in the root directory +# of this source tree. You may select, at your option, one of the +# above-listed licenses. + +"""Write a rustdoc argfile with the right `--emit=` value. + +Callers pass post-rename emit names via `--emit=...`; see +`rustdoc_emit_compat` for the rename story. The resolved value is +written into an argfile that `build.bzl` feeds to rustdoc via +`@argfile`. +""" + +import argparse +import sys + +from rustdoc_emit_compat import resolve_emits + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--rustdoc", required=True) + parser.add_argument("--out", required=True) + parser.add_argument( + "--emit", + required=True, + help="Comma-separated post-rename emit names; each is substituted to the " + "old name if the current rustdoc doesn't recognise the new one.", + ) + args = parser.parse_args() + + resolved = resolve_emits(args.rustdoc, args.emit.split(",")) + with open(args.out, "w") as f: + # `None` => rustdoc recognises no requested emit name; write a + # completely empty argfile so `@argfile` contributes nothing. + if resolved is not None: + f.write(resolved + "\n") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 5674b07efe0fa8e5ec29e007c6e5a61e4bf91e6a Mon Sep 17 00:00:00 2001 From: Cormac Relf Date: Fri, 17 Apr 2026 16:32:37 +1000 Subject: [PATCH 2/8] Add rustdoc_html_root_url And now that we know which ones are externed, we exclude them from the bxl target sets. --- examples/rust_doc_merge/bin/BUCK | 1 + examples/rust_doc_merge/bin/src/main.rs | 12 ++++++- .../rust_doc_merge/buck_resources_stub/BUCK | 8 +++++ .../buck_resources_stub/src/lib.rs | 17 ++++++++++ prelude/decls/rust_rules.bzl | 34 +++++++++++++++++++ prelude/rust/build.bzl | 8 +++++ prelude/rust/doc_merge.bxl | 20 ++++++++++- prelude/rust/doc_merge.bzl | 6 ++++ prelude/rust/outputs.bzl | 5 +++ prelude/rust/rust_binary.bzl | 1 + prelude/rust/rust_library.bzl | 5 ++- prelude/rust/tools/rustdoc_merge.py | 7 ++++ 12 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 examples/rust_doc_merge/buck_resources_stub/BUCK create mode 100644 examples/rust_doc_merge/buck_resources_stub/src/lib.rs diff --git a/examples/rust_doc_merge/bin/BUCK b/examples/rust_doc_merge/bin/BUCK index 6b7491d1940ab..3bdacc05261df 100644 --- a/examples/rust_doc_merge/bin/BUCK +++ b/examples/rust_doc_merge/bin/BUCK @@ -3,6 +3,7 @@ rust_binary( srcs = glob(["src/**/*.rs"]), crate_root = "src/main.rs", deps = [ + "//buck_resources_stub:buck_resources", "//crate_a:crate_a", "//crate_b:crate_b", ], diff --git a/examples/rust_doc_merge/bin/src/main.rs b/examples/rust_doc_merge/bin/src/main.rs index ecb7ecf2df34e..5ac057106990c 100644 --- a/examples/rust_doc_merge/bin/src/main.rs +++ b/examples/rust_doc_merge/bin/src/main.rs @@ -1,8 +1,18 @@ -//! A tiny binary that exercises both library crates. +//! A tiny binary that exercises all three library crates. use crate_b::WidgetB; +/// Return a resource path from [`buck_resources::get`], demonstrating a +/// cross-crate reference whose docs link out to +/// . +pub fn resource_path() -> Option { + buck_resources::get("root//some:target").ok() +} + fn main() { let w = WidgetB::new("world", 3); println!("{} ({})", w.greet(), w.count); + if let Some(p) = resource_path() { + println!("resource at {:?}", p); + } } diff --git a/examples/rust_doc_merge/buck_resources_stub/BUCK b/examples/rust_doc_merge/buck_resources_stub/BUCK new file mode 100644 index 0000000000000..a549991d28d24 --- /dev/null +++ b/examples/rust_doc_merge/buck_resources_stub/BUCK @@ -0,0 +1,8 @@ +rust_library( + name = "buck_resources", + srcs = glob(["src/**/*.rs"]), + crate_root = "src/lib.rs", + crate = "buck_resources", + rustdoc_html_root_url = "https://docs.rs/buck-resources/1.0.0/", + visibility = ["PUBLIC"], +) diff --git a/examples/rust_doc_merge/buck_resources_stub/src/lib.rs b/examples/rust_doc_merge/buck_resources_stub/src/lib.rs new file mode 100644 index 0000000000000..e68c559886c71 --- /dev/null +++ b/examples/rust_doc_merge/buck_resources_stub/src/lib.rs @@ -0,0 +1,17 @@ +//! Minimal stub of the `buck-resources` crate. The real crate lives on +//! [docs.rs](https://docs.rs/buck-resources); this local stub exists only +//! so that the example workspace has a target to compile. In the merged +//! rustdoc tree, cross-crate references to [`get`] from consumer crates +//! resolve to `https://docs.rs/buck-resources/1.0.0/` +//! thanks to `rustdoc_html_root_url` on the `rust_library` target. + +use std::path::PathBuf; + +/// Resolve a resource path by its buck2 target label. +pub fn get(_name: &str) -> Result { + Ok(PathBuf::new()) +} + +/// Error returned by [`get`] when a resource cannot be located. +#[derive(Debug)] +pub struct Error; diff --git a/prelude/decls/rust_rules.bzl b/prelude/decls/rust_rules.bzl index 0b8453ab1c897..daa797a84eb7b 100644 --- a/prelude/decls/rust_rules.bzl +++ b/prelude/decls/rust_rules.bzl @@ -30,6 +30,40 @@ def _rust_common_attributes(is_binary: bool): "incremental_enabled": attrs.bool(default = False), "resources": attrs.named_set(attrs.one_of(attrs.dep(), attrs.source()), sorted = True, default = []), "rustdoc_flags": attrs.list(attrs.arg(), default = []), + "rustdoc_html_root_url": attrs.option(attrs.string(), default = None, doc = """ + If set, injects `#![doc(html_root_url = "...")]` into the crate via + `-Zcrate-attr=doc(html_root_url="...")`. This controls how *consumers*' + rustdoc renders cross-crate links to items in this crate: instead of + bundling this crate's HTML alongside the consumer's, rustdoc emits a + link pointing at the given URL. + + The canonical use case is third-party / reindeer-generated libraries + in a buck2 workspace: set this to the crate's `https://docs.rs///` + URL so that `prelude//rust:doc_merge.bxl` can produce a merged tree + containing only your first-party crates, with outbound links to + docs.rs for everything else. + + Once set, the URL lives inside the crate's metadata and there is no + way to override it at doc-merge time to get a local link instead. + Rustdoc's extern-crate link resolution order is: + + 1. Local dir wins first: e.g. under `cargo doc`, if + `target/doc//` exists at the time the consumer's + rustdoc runs, it's used and nothing else is consulted. Buck's + per-crate rustdoc actions write into isolated output dirs + before the merge step, so this branch never fires for us in + practice. + 2. `--extern-html-root-url` + `--extern-html-root-takes-precedence` + when documenting a dependent crate. + 3. `#[doc(html_root_url = "...")]` from crate metadata — what this + attr sets. + 4. `--extern-html-root-url` when documenting dependent as fallback. + + So a crate is either externed or it isn't: we can only bake the URL + into metadata, and callers who want local links must leave the attr + unset. Accurate as of rustc 2026-04. + + """), "separate_debug_info": attrs.bool(default = False), "use_content_based_paths": attrs.bool(default = True), "uses_restricted_rustc_flags": attrs.bool(default = False), diff --git a/prelude/rust/build.bzl b/prelude/rust/build.bzl index b90f2dcae072b..ce45fcf7b42d4 100644 --- a/prelude/rust/build.bzl +++ b/prelude/rust/build.bzl @@ -608,12 +608,20 @@ def rust_compile( # deferred_link_action deferred_link_enabled = requires_linking and _deferred_link_enabled(compile_ctx, params, emit) + rustdoc_html_root_url = getattr(ctx.attrs, "rustdoc_html_root_url", None) + rustdoc_html_root_url_flag = ( + # Injected at compile time so the URL is recorded in crate metadata; + # downstream rustdoc reads it from there to resolve cross-crate links. + ['-Zcrate-attr=doc(html_root_url="{}")'.format(rustdoc_html_root_url)] if rustdoc_html_root_url else [] + ) + rustc_cmd = cmd_args( # Lints go first to allow other args to override them. lints, # Report unused --extern crates in the notification stream. ["--json=unused-externs-silent", "-Wunused-crate-dependencies"] if toolchain_info.report_unused_deps else [], common_args.args, + rustdoc_html_root_url_flag, cmd_args("--remap-path-prefix=", compile_ctx.symlinked_srcs, compile_ctx.path_sep, "=", compile_ctx.symlinked_srcs.owner.path, compile_ctx.path_sep, delimiter = ""), ["-Zremap-cwd-prefix=."] if toolchain_info.nightly_features else [], extra_flags, diff --git a/prelude/rust/doc_merge.bxl b/prelude/rust/doc_merge.bxl index d325af4fdd54f..6425f604cd5a5 100644 --- a/prelude/rust/doc_merge.bxl +++ b/prelude/rust/doc_merge.bxl @@ -28,11 +28,27 @@ your own BXL around it. """ load("@prelude//rust:doc_merge.bzl", "rustdoc_merge") +load("@prelude//rust:outputs.bzl", "RustcExtraOutputsInfo") # rust_test intentionally excluded — test targets don't emit user-facing # docs worth merging. _RUST_DOC_RULE_KINDS = "^(rust_binary|rust_library)$" +def _drop_externed( + ctx: bxl.Context, + targets: list[bxl.ConfiguredTargetNode]) -> list[bxl.ConfiguredTargetNode]: + # Crates with `rustdoc_html_root_url` set have their docs hosted + # elsewhere (e.g. docs.rs); downstream rustdoc links out to that URL, + # so there's no point bundling their HTML into the merged tree. + kept = [] + for t in targets: + providers = ctx.analysis(t).providers() + if RustcExtraOutputsInfo in providers: + if providers[RustcExtraOutputsInfo].rustdoc_externed_url != None: + continue + kept.append(t) + return kept + def _collect_targets(ctx: bxl.Context) -> list[bxl.ConfiguredTargetNode]: # `cli_args.list(cli_args.target_expr())` produces a list of lists (one # sublist per `--targets` occurrence). Flatten before handing to buck. @@ -46,7 +62,9 @@ def _collect_targets(ctx: bxl.Context) -> list[bxl.ConfiguredTargetNode]: else: configured_targets = ctx.configured_targets(targets) - return list(ctx.cquery().kind(_RUST_DOC_RULE_KINDS, configured_targets)) + rust_nodes = list(ctx.cquery().kind(_RUST_DOC_RULE_KINDS, configured_targets)) + + return _drop_externed(ctx, rust_nodes) def _merge_impl(ctx: bxl.Context) -> None: targets = _collect_targets(ctx) diff --git a/prelude/rust/doc_merge.bzl b/prelude/rust/doc_merge.bzl index fb17030f42bc3..420425c2e6558 100644 --- a/prelude/rust/doc_merge.bzl +++ b/prelude/rust/doc_merge.bzl @@ -84,6 +84,12 @@ def rustdoc_merge( cmd.add(cmd_args(html, format = "--html-dir={}")) for parts in parts_artifacts: cmd.add(cmd_args(parts, format = "--parts-dir={}")) + # Forward the toolchain's `rustdoc_flags` to the finalize invocation so + # that flags like `--theme FILE.css`, `--default-theme`, etc. apply + # consistently to both per-crate rustdoc steps (which already see these + # via `toolchain_info.rustdoc_flags` in build.bzl) and the merged tree. + for flag in rust_toolchain.rustdoc_flags: + cmd.add(cmd_args(flag, format = "--rustdoc-flag={}")) actions.run(cmd, category = "rustdoc_merge") return out_dir diff --git a/prelude/rust/outputs.bzl b/prelude/rust/outputs.bzl index f167a8a313cea..92f260cceff6f 100644 --- a/prelude/rust/outputs.bzl +++ b/prelude/rust/outputs.bzl @@ -74,5 +74,10 @@ RustcExtraOutputsInfo = provider( # Paired with `rustdoc_parts`: both are needed to assemble a merged # cross-crate HTML tree. "rustdoc_html": Artifact | None, + # If set, this crate's docs live at this URL (via its + # `rustdoc_html_root_url` attr) and are not meant to be bundled into + # a merged tree — downstream rustdoc links out to this URL instead. + # Consumers like `prelude//rust:doc_merge.bxl` skip such crates. + "rustdoc_externed_url": str | None, }, ) diff --git a/prelude/rust/rust_binary.bzl b/prelude/rust/rust_binary.bzl index 513550b71e82b..d6ace73a75d99 100644 --- a/prelude/rust/rust_binary.bzl +++ b/prelude/rust/rust_binary.bzl @@ -414,6 +414,7 @@ def _rust_binary_common( remarks = None, # Exposed via subtargets, not this provider rustdoc_parts = doc_parts_output.parts, rustdoc_html = doc_parts_output.html, + rustdoc_externed_url = getattr(ctx.attrs, "rustdoc_html_root_url", None), )] incr_enabled = ctx.attrs.incremental_enabled diff --git a/prelude/rust/rust_library.bzl b/prelude/rust/rust_library.bzl index 29283c87fa9ed..acce8857154f9 100644 --- a/prelude/rust/rust_library.bzl +++ b/prelude/rust/rust_library.bzl @@ -461,6 +461,7 @@ def rust_library_impl(ctx: AnalysisContext) -> list[Provider]: clippy_artifacts = clippy_artifacts, rustdoc_parts = rustdoc_parts.parts, rustdoc_html = rustdoc_parts.html, + rustdoc_externed_url = getattr(ctx.attrs, "rustdoc_html_root_url", None), ) if ctx.attrs.proc_macro: @@ -807,7 +808,8 @@ def _rust_metadata_providers( diag_artifacts: dict[bool, RustcOutput], clippy_artifacts: dict[bool, RustcOutput], rustdoc_parts: Artifact | None, - rustdoc_html: Artifact | None) -> list[Provider]: + rustdoc_html: Artifact | None, + rustdoc_externed_url: str | None) -> list[Provider]: return [ RustcExtraOutputsInfo( metadata = diag_artifacts[False], @@ -817,6 +819,7 @@ def _rust_metadata_providers( remarks = None, # Exposed via subtargets, not this provider rustdoc_parts = rustdoc_parts, rustdoc_html = rustdoc_html, + rustdoc_externed_url = rustdoc_externed_url, ), ] diff --git a/prelude/rust/tools/rustdoc_merge.py b/prelude/rust/tools/rustdoc_merge.py index 8799d767c54c4..93fa8c8d21a0b 100644 --- a/prelude/rust/tools/rustdoc_merge.py +++ b/prelude/rust/tools/rustdoc_merge.py @@ -45,6 +45,12 @@ def main() -> int: p.add_argument("--rustdoc", required=True) p.add_argument("--html-dir", action="append", default=[]) p.add_argument("--parts-dir", action="append", default=[]) + p.add_argument( + "--rustdoc-flag", + action="append", + default=[], + help="Extra flag forwarded verbatim to rustdoc (repeatable).", + ) args = p.parse_args() out = Path(args.out_dir) @@ -73,6 +79,7 @@ def main() -> int: cmd.append(emit_arg) for pd in args.parts_dir: cmd.append(f"--include-parts-dir={pd}") + cmd.extend(args.rustdoc_flag) return subprocess.run(cmd, env=env).returncode From d93a0e6060f29f83575ac35655692bdceede118d Mon Sep 17 00:00:00 2001 From: Cormac Relf Date: Fri, 17 Apr 2026 22:20:34 +1000 Subject: [PATCH 3/8] Special handling of rustdoc themes on toolchain Rustdoc themes have to be provided when generating HTML for all crates. That means the naive way of adding themes results in full crate graph rebuilds every time you tweak the theme. This way is nicer. --- prelude/rust/build.bzl | 12 ++++++++++++ prelude/rust/doc_merge.bzl | 7 ++++++- prelude/rust/rust_toolchain.bzl | 17 +++++++++++++++++ prelude/rust/tools/rustdoc_merge.py | 9 +++++++++ prelude/toolchains/rust.bzl | 11 +++++++++++ 5 files changed, 55 insertions(+), 1 deletion(-) diff --git a/prelude/rust/build.bzl b/prelude/rust/build.bzl index ce45fcf7b42d4..d637128d4e180 100644 --- a/prelude/rust/build.bzl +++ b/prelude/rust/build.bzl @@ -178,6 +178,8 @@ def generate_rustdoc( "--rustc-action-separator", toolchain_info.rustdoc_flags, ctx.attrs.rustdoc_flags, + # Standalone `[doc]` emits full static files, so pass real theme CSS. + _theme_flags(toolchain_info.rustdoc_themes), common_args.args, cmd_args(output.as_output(), format = "--out-dir={}"), hidden = [toolchain_info.rustdoc, compile_ctx.symlinked_srcs], @@ -204,6 +206,12 @@ def generate_rustdoc( return output +def _theme_flags(themes: list[Artifact]) -> cmd_args: + # Expand a list of CSS files into repeated `--theme ` args for + # rustdoc. Rustdoc uses each file's basename-minus-`.css` as the theme + # name in the generated HTML. + return cmd_args([cmd_args("--theme", t) for t in themes]) + RustdocPartsOutputs = record( parts = Artifact, html = Artifact, @@ -303,6 +311,10 @@ def generate_rustdoc_parts( "--rustc-action-separator", toolchain_info.rustdoc_flags, ctx.attrs.rustdoc_flags, + # This step skips static-file emission, so feed rustdoc the stub CSS + # per theme — same basename as the real file, empty contents. This + # keeps per-crate rustdoc actions independent of theme content edits. + _theme_flags(toolchain_info.rustdoc_theme_stubs), common_args.args, "-Zunstable-options", "--merge=none", diff --git a/prelude/rust/doc_merge.bzl b/prelude/rust/doc_merge.bzl index 420425c2e6558..53aa35f5e9fd7 100644 --- a/prelude/rust/doc_merge.bzl +++ b/prelude/rust/doc_merge.bzl @@ -85,11 +85,16 @@ def rustdoc_merge( for parts in parts_artifacts: cmd.add(cmd_args(parts, format = "--parts-dir={}")) # Forward the toolchain's `rustdoc_flags` to the finalize invocation so - # that flags like `--theme FILE.css`, `--default-theme`, etc. apply + # that flags like `--default-theme`, `--cap-lints`, etc. apply # consistently to both per-crate rustdoc steps (which already see these # via `toolchain_info.rustdoc_flags` in build.bzl) and the merged tree. for flag in rust_toolchain.rustdoc_flags: cmd.add(cmd_args(flag, format = "--rustdoc-flag={}")) + # Real theme CSS goes to the finalize step (which emits static files); + # per-crate steps use matching-basename stubs. See `rustdoc_themes` / + # `rustdoc_theme_stubs` in `rust_toolchain.bzl`. + for theme in rust_toolchain.rustdoc_themes: + cmd.add(cmd_args(theme, format = "--theme={}")) actions.run(cmd, category = "rustdoc_merge") return out_dir diff --git a/prelude/rust/rust_toolchain.bzl b/prelude/rust/rust_toolchain.bzl index 4cdf02d4aeee2..04d6e585f759f 100644 --- a/prelude/rust/rust_toolchain.bzl +++ b/prelude/rust/rust_toolchain.bzl @@ -64,6 +64,23 @@ rust_toolchain_attrs = { "rustdoc_env": provider_field(dict[str, typing.Any], default = {}), # Extra flags for rustdoc invocations "rustdoc_flags": provider_field(list[typing.Any], default = []), + # Real theme CSS files to pass to rustdoc's `--theme`. These are applied to: + # + # - the standalone `[doc]` subtarget (`generate_rustdoc`, which emits a + # full static-files tree) + # - the `rustdoc --merge=finalize` step in `doc_merge.bzl` (the merged + # tree's static files) + # + # Per-crate rustdoc actions for the merged tree (`generate_rustdoc_parts`, + # which skips static files) instead consume `rustdoc_theme_stubs` below — + # empty CSS files with matching basenames — so that a tweak to the real + # theme content doesn't invalidate every crate's rustdoc action. + "rustdoc_themes": provider_field(list[Artifact], default = []), + # Stub (empty) CSS files paired 1:1 with `rustdoc_themes` above and sharing + # their basenames. Passed as `--theme ` in per-crate rustdoc parts + # actions: rustdoc only needs the basename (for the theme name referenced + # in the generated HTML) at that step, not the real contents. + "rustdoc_theme_stubs": provider_field(list[Artifact], default = []), # Extra flags to pass to the linker "linker_flags": provider_field(list[typing.Any], default = []), # When you `buck test` a library, also compile and run example code in its diff --git a/prelude/rust/tools/rustdoc_merge.py b/prelude/rust/tools/rustdoc_merge.py index 93fa8c8d21a0b..bf6119442f472 100644 --- a/prelude/rust/tools/rustdoc_merge.py +++ b/prelude/rust/tools/rustdoc_merge.py @@ -51,6 +51,13 @@ def main() -> int: default=[], help="Extra flag forwarded verbatim to rustdoc (repeatable).", ) + p.add_argument( + "--theme", + action="append", + default=[], + help="Path to a real theme CSS file to register with rustdoc at " + "finalize time (repeatable).", + ) args = p.parse_args() out = Path(args.out_dir) @@ -79,6 +86,8 @@ def main() -> int: cmd.append(emit_arg) for pd in args.parts_dir: cmd.append(f"--include-parts-dir={pd}") + for theme in args.theme: + cmd.extend(["--theme", theme]) cmd.extend(args.rustdoc_flag) return subprocess.run(cmd, env=env).returncode diff --git a/prelude/toolchains/rust.bzl b/prelude/toolchains/rust.bzl index b9becf37f3d2d..ee7ccabf8f6f0 100644 --- a/prelude/toolchains/rust.bzl +++ b/prelude/toolchains/rust.bzl @@ -36,6 +36,14 @@ _DEFAULT_TRIPLE = select({ }) def _system_rust_toolchain_impl(ctx): + # Stub out each real theme CSS with an empty file of the same basename. + # Per-crate rustdoc parts actions only need the basename (for the theme + # name baked into generated HTML), not the contents — so feeding them the + # real CSS would pointlessly invalidate every crate on theme edits. + theme_stubs = [ + ctx.actions.write("rustdoc_theme_stubs/" + theme.basename, "") + for theme in ctx.attrs.rustdoc_themes + ] return [ DefaultInfo(), RustToolchainInfo( @@ -55,6 +63,8 @@ def _system_rust_toolchain_impl(ctx): rustc_test_flags = ctx.attrs.rustc_test_flags, rustdoc = RunInfo(args = ["rustdoc"]), rustdoc_flags = ctx.attrs.rustdoc_flags, + rustdoc_themes = ctx.attrs.rustdoc_themes, + rustdoc_theme_stubs = theme_stubs, warn_lints = ctx.attrs.warn_lints, ), ] @@ -74,6 +84,7 @@ system_rust_toolchain = rule( "rustc_target_triple": attrs.string(default = _DEFAULT_TRIPLE), "rustc_test_flags": attrs.list(attrs.arg(), default = []), "rustdoc_flags": attrs.list(attrs.arg(), default = []), + "rustdoc_themes": attrs.list(attrs.source(), default = []), "warn_lints": attrs.list(attrs.string(), default = []), }, is_toolchain_rule = True, From 62d3bb4aa348266ffb34c571aab14cc50de15252 Mon Sep 17 00:00:00 2001 From: Cormac Relf Date: Fri, 17 Apr 2026 22:23:38 +1000 Subject: [PATCH 4/8] Use rustdoc_themes in the example --- examples/rust_doc_merge/toolchains/BUCK | 5 + .../toolchains/example_theme.css | 107 ++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 examples/rust_doc_merge/toolchains/example_theme.css diff --git a/examples/rust_doc_merge/toolchains/BUCK b/examples/rust_doc_merge/toolchains/BUCK index 8970145ee4d96..d2496e6e8efb3 100644 --- a/examples/rust_doc_merge/toolchains/BUCK +++ b/examples/rust_doc_merge/toolchains/BUCK @@ -34,6 +34,11 @@ system_rust_toolchain( "--default-theme=ayu", "--cap-lints=warn", ], + # Real theme CSS used by `[doc]` and `rustdoc --merge=finalize`. The + # per-crate rustdoc actions that feed the merge step instead see an + # empty stub with the same basename, so edits to this file don't + # invalidate every crate's rustdoc output. + rustdoc_themes = ["example_theme.css"], visibility = ["PUBLIC"], ) diff --git a/examples/rust_doc_merge/toolchains/example_theme.css b/examples/rust_doc_merge/toolchains/example_theme.css new file mode 100644 index 0000000000000..dd63c85f689f6 --- /dev/null +++ b/examples/rust_doc_merge/toolchains/example_theme.css @@ -0,0 +1,107 @@ +/* https://github.com/rust-lang/rust/blob/main/src/librustdoc/html/static/css/rustdoc.css */ +/* Begin theme: example_theme */ +:root[data-theme="example_theme"] { + --main-background-color: white; + --main-color: black; + --settings-input-color: #2196f3; + --settings-input-border-color: #717171; + --settings-button-color: #000; + --settings-button-border-focus: #717171; + --sidebar-background-color: #f5f5f5; + --sidebar-background-color-hover: #e0e0e0; + --sidebar-border-color: #ddd; + --code-block-background-color: #f5f5f5; + --scrollbar-track-background-color: #dcdcdc; + --scrollbar-thumb-background-color: rgba(36, 37, 39, 0.6); + --scrollbar-color: rgba(36, 37, 39, 0.6) #d9d9d9; + --headings-border-bottom-color: #ddd; + --border-color: #e0e0e0; + --button-background-color: #fff; + --right-side-color: grey; + --code-attribute-color: #999; + --toggles-color: #999; + --toggle-filter: none; + --mobile-sidebar-menu-filter: none; + --search-input-focused-border-color: #66afe9; + --copy-path-button-color: #999; + --copy-path-img-filter: invert(50%); + --copy-path-img-hover-filter: invert(35%); + --code-example-button-color: #7f7f7f; + --code-example-button-hover-color: #595959; + --settings-menu-filter: invert(50%); + --settings-menu-hover-filter: invert(35%); + --codeblock-error-hover-color: rgb(255, 0, 0); + --codeblock-error-color: rgba(255, 0, 0, .5); + --codeblock-ignore-hover-color: rgb(255, 142, 0); + --codeblock-ignore-color: rgba(255, 142, 0, .6); + --warning-border-color: #ff8e00; + --type-link-color: red; + --trait-link-color: red; + --assoc-item-link-color: red; + --function-link-color: red; + --macro-link-color: red; + --keyword-link-color: red; + --attribute-link-color: red; + --mod-link-color: red; + --link-color: red; + --sidebar-link-color: red; + --sidebar-current-link-background-color: #fff; + --search-result-link-focus-background-color: #ccc; + --search-result-border-color: #aaa3; + --search-color: #000; + --search-error-code-background-color: #d0cccc; + --search-results-alias-color: #000; + --search-results-grey-color: #999; + --search-tab-title-count-color: #888; + --search-tab-button-not-selected-border-top-color: #e6e6e6; + --search-tab-button-not-selected-background: #e6e6e6; + --search-tab-button-selected-border-top-color: #0089ff; + --search-tab-button-selected-background: #fff; + --stab-background-color: #fff5d6; + --stab-code-color: #000; + --code-highlight-kw-color: #8959a8; + --code-highlight-kw-2-color: #4271ae; + --code-highlight-lifetime-color: #b76514; + --code-highlight-prelude-color: #4271ae; + --code-highlight-prelude-val-color: #c82829; + --code-highlight-number-color: #718c00; + --code-highlight-string-color: #718c00; + --code-highlight-literal-color: #c82829; + --code-highlight-attribute-color: #c82829; + --code-highlight-self-color: #c82829; + --code-highlight-macro-color: #3e999f; + --code-highlight-question-mark-color: #ff9011; + --code-highlight-comment-color: #8e908c; + --code-highlight-doc-comment-color: #4d4d4c; + --src-line-numbers-span-color: #c67e2d; + --src-line-number-highlighted-background-color: #fdffd3; + --target-background-color: #fdffd3; + --target-border-color: #ad7c37; + --kbd-color: #000; + --kbd-background: #fafbfc; + --kbd-box-shadow-color: #c6cbd1; + --rust-logo-filter: initial; + /* match border-color; uses https://codepen.io/sosuke/pen/Pjoqqp */ + --crate-search-div-filter: invert(100%) sepia(0%) saturate(4223%) hue-rotate(289deg) + brightness(114%) contrast(76%); + --crate-search-div-hover-filter: invert(44%) sepia(18%) saturate(23%) hue-rotate(317deg) + brightness(96%) contrast(93%); + --crate-search-hover-border: #717171; + --src-sidebar-background-selected: #fff; + --src-sidebar-background-hover: #e0e0e0; + --table-alt-row-background-color: #f5f5f5; + --codeblock-link-background: #eee; + --scrape-example-toggle-line-background: #ccc; + --scrape-example-toggle-line-hover-background: #999; + --scrape-example-code-line-highlight: #fcffd6; + --scrape-example-code-line-highlight-focus: #f6fdb0; + --scrape-example-help-border-color: #555; + --scrape-example-help-color: #333; + --scrape-example-help-hover-border-color: #000; + --scrape-example-help-hover-color: #000; + --scrape-example-code-wrapper-background-start: rgba(255, 255, 255, 1); + --scrape-example-code-wrapper-background-end: rgba(255, 255, 255, 0); + --sidebar-resizer-hover: hsl(207, 90%, 66%); + --sidebar-resizer-active: hsl(207, 90%, 54%); +} +/* End theme: example_theme */ From fc1d0b729d0be214d11c49145a21637f21c2c844 Mon Sep 17 00:00:00 2001 From: Cormac Relf Date: Fri, 17 Apr 2026 23:09:42 +1000 Subject: [PATCH 5/8] Allow for more crates than fits in argv --- prelude/rust/doc_merge.bzl | 32 +++++++++++++------------- prelude/rust/tools/rustdoc_merge.py | 35 ++++++++++++++++++----------- 2 files changed, 39 insertions(+), 28 deletions(-) diff --git a/prelude/rust/doc_merge.bzl b/prelude/rust/doc_merge.bzl index 53aa35f5e9fd7..85af37b495ceb 100644 --- a/prelude/rust/doc_merge.bzl +++ b/prelude/rust/doc_merge.bzl @@ -75,26 +75,28 @@ def rustdoc_merge( out_dir = actions.declare_output("merged-rustdoc", dir = True) + argfile_content = cmd_args() + for html in html_artifacts: + argfile_content.add(cmd_args(html, format = "--html-dir={}")) + for parts in parts_artifacts: + argfile_content.add(cmd_args(parts, format = "--parts-dir={}")) + for flag in rust_toolchain.rustdoc_flags: + argfile_content.add(cmd_args(flag, format = "--rustdoc-flag={}")) + for theme in rust_toolchain.rustdoc_themes: + argfile_content.add(cmd_args(theme, format = "--theme={}")) + + argfile, hidden = actions.write( + "rustdoc_merge.argfile", + argfile_content, + allow_args = True, + ) + cmd = cmd_args( merge_tool, cmd_args(out_dir.as_output(), format = "--out-dir={}"), cmd_args(rust_toolchain.rustdoc, format = "--rustdoc={}"), + cmd_args(argfile, format = "@{}", hidden = hidden), ) - for html in html_artifacts: - cmd.add(cmd_args(html, format = "--html-dir={}")) - for parts in parts_artifacts: - cmd.add(cmd_args(parts, format = "--parts-dir={}")) - # Forward the toolchain's `rustdoc_flags` to the finalize invocation so - # that flags like `--default-theme`, `--cap-lints`, etc. apply - # consistently to both per-crate rustdoc steps (which already see these - # via `toolchain_info.rustdoc_flags` in build.bzl) and the merged tree. - for flag in rust_toolchain.rustdoc_flags: - cmd.add(cmd_args(flag, format = "--rustdoc-flag={}")) - # Real theme CSS goes to the finalize step (which emits static files); - # per-crate steps use matching-basename stubs. See `rustdoc_themes` / - # `rustdoc_theme_stubs` in `rust_toolchain.bzl`. - for theme in rust_toolchain.rustdoc_themes: - cmd.add(cmd_args(theme, format = "--theme={}")) actions.run(cmd, category = "rustdoc_merge") return out_dir diff --git a/prelude/rust/tools/rustdoc_merge.py b/prelude/rust/tools/rustdoc_merge.py index bf6119442f472..20d4403b92025 100644 --- a/prelude/rust/tools/rustdoc_merge.py +++ b/prelude/rust/tools/rustdoc_merge.py @@ -22,6 +22,7 @@ import shutil import subprocess import sys +import tempfile from pathlib import Path from rustdoc_emit_compat import resolve_emits @@ -40,7 +41,7 @@ def stage_html(out_dir: Path, html_dirs: list[str]) -> None: def main() -> int: - p = argparse.ArgumentParser() + p = argparse.ArgumentParser(fromfile_prefix_chars="@") p.add_argument("--out-dir", required=True) p.add_argument("--rustdoc", required=True) p.add_argument("--html-dir", action="append", default=[]) @@ -70,27 +71,35 @@ def main() -> int: env = dict(os.environ) env["RUSTC_BOOTSTRAP"] = "1" - cmd = [ - args.rustdoc, - "-Zunstable-options", - "--merge=finalize", - "--enable-index-page", - f"--out-dir={args.out_dir}", - ] # Finalize is the one step that should emit the shared CSS/JS for # the whole tree (the per-crate parts step skipped them); also # emit the non-static files (index.html, search.index, etc.) that # finalize itself produces. emit_arg = resolve_emits(args.rustdoc, ["html-static-files", "html-non-static-files"]) + + rustdoc_args = [ + "-Zunstable-options", + "--merge=finalize", + "--enable-index-page", + f"--out-dir={args.out_dir}", + ] if emit_arg is not None: - cmd.append(emit_arg) + rustdoc_args.append(emit_arg) for pd in args.parts_dir: - cmd.append(f"--include-parts-dir={pd}") + rustdoc_args.append(f"--include-parts-dir={pd}") for theme in args.theme: - cmd.extend(["--theme", theme]) - cmd.extend(args.rustdoc_flag) + rustdoc_args.append("--theme") + rustdoc_args.append(theme) + rustdoc_args.extend(args.rustdoc_flag) - return subprocess.run(cmd, env=env).returncode + with tempfile.NamedTemporaryFile( + mode="w", + suffix=".argfile", + dir=os.environ.get("BUCK_SCRATCH_PATH"), + ) as f: + f.write("\n".join(rustdoc_args)) + f.flush() + return subprocess.run([args.rustdoc, f"@{f.name}"], env=env).returncode if __name__ == "__main__": From d0381288620728e3ac2c3e0d3e63ce9ab5417988 Mon Sep 17 00:00:00 2001 From: Cormac Relf Date: Fri, 17 Apr 2026 23:21:18 +1000 Subject: [PATCH 6/8] Fix missing src and trait impls in rustdoc merge output We were getting a random crate in merge_output/src, as src itself was present in all crates' html outputs. We need to do a recursive directory merge, basically. --- prelude/rust/tools/rustdoc_merge.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/prelude/rust/tools/rustdoc_merge.py b/prelude/rust/tools/rustdoc_merge.py index 20d4403b92025..44d679f9ab90c 100644 --- a/prelude/rust/tools/rustdoc_merge.py +++ b/prelude/rust/tools/rustdoc_merge.py @@ -31,11 +31,27 @@ def stage_html(out_dir: Path, html_dirs: list[str]) -> None: for html_dir in html_dirs: src = Path(html_dir) - if not src.is_dir(): - continue - for entry in src.iterdir(): - dest = out_dir / entry.name - if dest.exists() or dest.is_symlink(): + if src.is_dir(): + _merge_into(out_dir, src) + + +def _merge_into(dest_dir: Path, src_dir: Path) -> None: + # Per-crate html artifacts share top-level dirs (`src/`, `trait.impl/`), + # so collisions have to be unioned recursively rather than replaced. + # RFC 3662 was supposed to handle cross-crate html assembly itself; + # hopefully a future rustdoc makes this manual merge unnecessary. + for entry in src_dir.iterdir(): + dest = dest_dir / entry.name + if entry.is_dir() and (dest.exists() or dest.is_symlink()): + if dest.is_symlink(): + prior = dest.resolve() + dest.unlink() + dest.mkdir() + if prior.is_dir(): + _merge_into(dest, prior) + _merge_into(dest, entry) + else: + if dest.is_symlink() or dest.exists(): dest.unlink() os.symlink(entry.resolve(), dest) From d05a6355964755922686b1eb1eb7f41d0e1c9cb6 Mon Sep 17 00:00:00 2001 From: Cormac Relf Date: Fri, 17 Apr 2026 23:45:09 +1000 Subject: [PATCH 7/8] Use at_argfile helper to write an argfile This handles hidden args better --- prelude/rust/doc_merge.bzl | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/prelude/rust/doc_merge.bzl b/prelude/rust/doc_merge.bzl index 85af37b495ceb..2eaebec354fcc 100644 --- a/prelude/rust/doc_merge.bzl +++ b/prelude/rust/doc_merge.bzl @@ -29,6 +29,7 @@ Typical custom BXL: load("@prelude//rust:outputs.bzl", "RustcExtraOutputsInfo") load("@prelude//rust:rust_toolchain.bzl", "RustToolchainInfo") +load("@prelude//utils:argfile.bzl", "at_argfile") _MERGE_TOOL = "prelude//rust/tools:rustdoc_merge" @@ -75,27 +76,23 @@ def rustdoc_merge( out_dir = actions.declare_output("merged-rustdoc", dir = True) - argfile_content = cmd_args() - for html in html_artifacts: - argfile_content.add(cmd_args(html, format = "--html-dir={}")) - for parts in parts_artifacts: - argfile_content.add(cmd_args(parts, format = "--parts-dir={}")) - for flag in rust_toolchain.rustdoc_flags: - argfile_content.add(cmd_args(flag, format = "--rustdoc-flag={}")) - for theme in rust_toolchain.rustdoc_themes: - argfile_content.add(cmd_args(theme, format = "--theme={}")) - - argfile, hidden = actions.write( - "rustdoc_merge.argfile", - argfile_content, - allow_args = True, + argfile_content = cmd_args( + cmd_args(out_dir.as_output(), format = "--out-dir={}"), + cmd_args(rust_toolchain.rustdoc, format = "--rustdoc={}"), + [cmd_args(h, format = "--html-dir={}") for h in html_artifacts], + [cmd_args(p, format = "--parts-dir={}") for p in parts_artifacts], + [cmd_args(f, format = "--rustdoc-flag={}") for f in rust_toolchain.rustdoc_flags], + [cmd_args(t, format = "--theme={}") for t in rust_toolchain.rustdoc_themes], ) cmd = cmd_args( merge_tool, - cmd_args(out_dir.as_output(), format = "--out-dir={}"), - cmd_args(rust_toolchain.rustdoc, format = "--rustdoc={}"), - cmd_args(argfile, format = "@{}", hidden = hidden), + at_argfile( + actions = actions, + name = "rustdoc_merge.argfile", + args = argfile_content, + allow_args = True, + ), ) actions.run(cmd, category = "rustdoc_merge") From 6615164109a6c7b3ab660894548d554db2586c19 Mon Sep 17 00:00:00 2001 From: Cormac Relf Date: Sat, 18 Apr 2026 00:08:22 +1000 Subject: [PATCH 8/8] Add dummy crate to make --enable-index-page work --- prelude/rust/doc_merge.bzl | 2 ++ prelude/rust/tools/rustdoc_merge.py | 47 ++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/prelude/rust/doc_merge.bzl b/prelude/rust/doc_merge.bzl index 2eaebec354fcc..f313c05891a21 100644 --- a/prelude/rust/doc_merge.bzl +++ b/prelude/rust/doc_merge.bzl @@ -84,6 +84,8 @@ def rustdoc_merge( [cmd_args(f, format = "--rustdoc-flag={}") for f in rust_toolchain.rustdoc_flags], [cmd_args(t, format = "--theme={}") for t in rust_toolchain.rustdoc_themes], ) + if rust_toolchain.default_edition != None: + argfile_content.add(cmd_args(rust_toolchain.default_edition, format = "--edition={}")) cmd = cmd_args( merge_tool, diff --git a/prelude/rust/tools/rustdoc_merge.py b/prelude/rust/tools/rustdoc_merge.py index 44d679f9ab90c..82cb7e3469132 100644 --- a/prelude/rust/tools/rustdoc_merge.py +++ b/prelude/rust/tools/rustdoc_merge.py @@ -15,10 +15,18 @@ (`--merge=finalize` reading the parts dirs). Finalize only writes the top-level index/search pages; the per-crate HTML must be present alongside for the merged tree to be browseable. + +Rustdoc's `--enable-index-page` only emits `index.html` when a crate +input is provided, so we feed finalize an empty dummy crate on stdin +(`-`) with a deliberately unguessable name (see DUMMY_CRATE_NAME) and +strip its entry from the rendered `index.html` afterwards. The dummy +still ends up in `crates.js` and as an empty `/` subdir, which is +harmless. """ import argparse import os +import re import shutil import subprocess import sys @@ -27,6 +35,26 @@ from rustdoc_emit_compat import resolve_emits +# Long, unique name so we can grep it out of the generated index.html +# without risking a collision with a real user crate. +DUMMY_CRATE_NAME = "doc_merge_dummy_crate_a8f3k2m9s_zzz" + + +def strip_dummy_from_index(index_html: Path, dummy: str) -> None: + """Remove the `
  • {dummy}
  • ` entry + that rustdoc writes into the cross-crate index page. + """ + if not index_html.is_file(): + return + html = index_html.read_text() + pattern = re.compile( + r'
  • ' + + re.escape(dummy) + r'
  • ' + ) + new_html = pattern.sub("", html) + if new_html != html: + index_html.write_text(new_html) + def stage_html(out_dir: Path, html_dirs: list[str]) -> None: for html_dir in html_dirs: @@ -75,6 +103,7 @@ def main() -> int: help="Path to a real theme CSS file to register with rustdoc at " "finalize time (repeatable).", ) + p.add_argument("--edition", default="2021") args = p.parse_args() out = Path(args.out_dir) @@ -97,6 +126,8 @@ def main() -> int: "-Zunstable-options", "--merge=finalize", "--enable-index-page", + f"--edition={args.edition}", + f"--crate-name={DUMMY_CRATE_NAME}", f"--out-dir={args.out_dir}", ] if emit_arg is not None: @@ -107,6 +138,10 @@ def main() -> int: rustdoc_args.append("--theme") rustdoc_args.append(theme) rustdoc_args.extend(args.rustdoc_flag) + # Feed an empty crate source on stdin so rustdoc has an input to hang + # `--enable-index-page` off of, without having to materialise a dummy .rs + # file on disk. + rustdoc_args.append("-") with tempfile.NamedTemporaryFile( mode="w", @@ -115,7 +150,17 @@ def main() -> int: ) as f: f.write("\n".join(rustdoc_args)) f.flush() - return subprocess.run([args.rustdoc, f"@{f.name}"], env=env).returncode + rc = subprocess.run( + [args.rustdoc, f"@{f.name}"], + env=env, + input="", + text=True, + ).returncode + if rc != 0: + return rc + + strip_dummy_from_index(Path(args.out_dir) / "index.html", DUMMY_CRATE_NAME) + return 0 if __name__ == "__main__":