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,