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..3bdacc05261df
--- /dev/null
+++ b/examples/rust_doc_merge/bin/BUCK
@@ -0,0 +1,10 @@
+rust_binary(
+ name = "bin",
+ 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
new file mode 100644
index 0000000000000..5ac057106990c
--- /dev/null
+++ b/examples/rust_doc_merge/bin/src/main.rs
@@ -0,0 +1,18 @@
+//! 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/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..d2496e6e8efb3
--- /dev/null
+++ b/examples/rust_doc_merge/toolchains/BUCK
@@ -0,0 +1,53 @@
+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",
+ ],
+ # 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"],
+)
+
+remote_test_execution_toolchain(
+ name = "remote_test_execution",
+ visibility = ["PUBLIC"],
+)
+
+noop_test_toolchain(
+ name = "test",
+ 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 */
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 a71e1855b5b61..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,145 @@ 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,
+)
+
+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,
+ # 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",
+ 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,
@@ -479,12 +620,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
new file mode 100644
index 0000000000000..6425f604cd5a5
--- /dev/null
+++ b/prelude/rust/doc_merge.bxl
@@ -0,0 +1,91 @@
+# 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")
+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.
+ 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)
+
+ 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)
+ 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..f313c05891a21
--- /dev/null
+++ b/prelude/rust/doc_merge.bzl
@@ -0,0 +1,101 @@
+# 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")
+load("@prelude//utils:argfile.bzl", "at_argfile")
+
+_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)
+
+ 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],
+ )
+ if rust_toolchain.default_edition != None:
+ argfile_content.add(cmd_args(rust_toolchain.default_edition, format = "--edition={}"))
+
+ cmd = cmd_args(
+ merge_tool,
+ at_argfile(
+ actions = actions,
+ name = "rustdoc_merge.argfile",
+ args = argfile_content,
+ allow_args = True,
+ ),
+ )
+
+ actions.run(cmd, category = "rustdoc_merge")
+ return out_dir
diff --git a/prelude/rust/outputs.bzl b/prelude/rust/outputs.bzl
index 05cada435f1bd..92f260cceff6f 100644
--- a/prelude/rust/outputs.bzl
+++ b/prelude/rust/outputs.bzl
@@ -64,5 +64,20 @@ 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,
+ # 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 764833770a2c8..d6ace73a75d99 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,23 @@ 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,
+ rustdoc_externed_url = getattr(ctx.attrs, "rustdoc_html_root_url", None),
)]
incr_enabled = ctx.attrs.incremental_enabled
@@ -497,6 +509,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..acce8857154f9 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,16 @@ 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,
+ rustdoc_externed_url = getattr(ctx.attrs, "rustdoc_html_root_url", None),
)
if ctx.attrs.proc_macro:
@@ -717,6 +731,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 +740,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 +806,10 @@ 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,
+ rustdoc_externed_url: str | None) -> list[Provider]:
return [
RustcExtraOutputsInfo(
metadata = diag_artifacts[False],
@@ -797,6 +817,9 @@ 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,
+ rustdoc_externed_url = rustdoc_externed_url,
),
]
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/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..82cb7e3469132
--- /dev/null
+++ b/prelude/rust/tools/rustdoc_merge.py
@@ -0,0 +1,167 @@
+# 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.
+
+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
+import tempfile
+from pathlib import Path
+
+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:
+ src = Path(html_dir)
+ 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)
+
+
+def main() -> int:
+ 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=[])
+ p.add_argument("--parts-dir", action="append", default=[])
+ p.add_argument(
+ "--rustdoc-flag",
+ action="append",
+ 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).",
+ )
+ p.add_argument("--edition", default="2021")
+ 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"
+
+ # 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"--edition={args.edition}",
+ f"--crate-name={DUMMY_CRATE_NAME}",
+ f"--out-dir={args.out_dir}",
+ ]
+ if emit_arg is not None:
+ rustdoc_args.append(emit_arg)
+ for pd in args.parts_dir:
+ rustdoc_args.append(f"--include-parts-dir={pd}")
+ for theme in args.theme:
+ 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",
+ suffix=".argfile",
+ dir=os.environ.get("BUCK_SCRATCH_PATH"),
+ ) as f:
+ f.write("\n".join(rustdoc_args))
+ f.flush()
+ 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__":
+ 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())
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,