Skip to main content

rustdoc_json/
builder.rs

1use super::BuildError;
2use cargo_metadata::TargetKind;
3use tracing::*;
4
5use std::collections::HashMap;
6use std::ffi::{OsStr, OsString};
7use std::io::Write;
8use std::{
9    path::{Path, PathBuf},
10    process::Command,
11};
12
13/// For development purposes only. Sometimes when you work on this project you
14/// want to quickly use a different toolchain to build rustdoc JSON. You can
15/// specify what toolchain, by temporarily changing this.
16const OVERRIDDEN_TOOLCHAIN: Option<&str> = option_env!("RUSTDOC_JSON_OVERRIDDEN_TOOLCHAIN_HACK"); // Some("nightly-2022-07-16");
17
18struct CaptureOutput<O, E> {
19    stdout: O,
20    stderr: E,
21}
22
23fn run_cargo_rustdoc<O, E>(
24    options: Builder,
25    capture_output: Option<CaptureOutput<O, E>>,
26) -> Result<PathBuf, BuildError>
27where
28    O: Write,
29    E: Write,
30{
31    let mut cmd = cargo_rustdoc_command(&options)?;
32    info!("Running {cmd:?}");
33
34    let status = match capture_output {
35        Some(CaptureOutput {
36            mut stdout,
37            mut stderr,
38        }) => {
39            let output = cmd.output().map_err(|e| {
40                BuildError::CommandExecutionError(format!("Failed to run `{cmd:?}`: {e}"))
41            })?;
42            stdout.write_all(&output.stdout).map_err(|e| {
43                BuildError::CapturedOutputError(format!("Failed to write stdout: {e}"))
44            })?;
45            stderr.write_all(&output.stderr).map_err(|e| {
46                BuildError::CapturedOutputError(format!("Failed to write stderr: {e}"))
47            })?;
48            output.status
49        }
50        None => cmd.status().map_err(|e| {
51            BuildError::CommandExecutionError(format!("Failed to run `{cmd:?}`: {e}"))
52        })?,
53    };
54
55    if status.success() {
56        rustdoc_json_path_for_manifest_path(
57            &options.manifest_path,
58            options.package.as_deref(),
59            &options.package_target,
60            options.target_dir.as_deref(),
61            options.target.as_deref(),
62        )
63    } else {
64        let manifest = cargo_manifest::Manifest::from_path(&options.manifest_path)?;
65        if manifest.package.is_none() && manifest.workspace.is_some() {
66            Err(BuildError::VirtualManifest(options.manifest_path))
67        } else {
68            Err(BuildError::BuildRustdocJsonError)
69        }
70    }
71}
72
73/// Construct the `cargo rustdoc` command to use for building rustdoc JSON. The
74/// command typically ends up looks something like this:
75/// ```bash
76/// cargo +nightly rustdoc --lib --manifest-path Cargo.toml -- -Z unstable-options --output-format json --cap-lints warn
77/// ```
78fn cargo_rustdoc_command(options: &Builder) -> Result<Command, BuildError> {
79    let Builder {
80        toolchain: requested_toolchain,
81        manifest_path,
82        target_dir,
83        target,
84        quiet,
85        silent,
86        color,
87        no_default_features,
88        all_features,
89        features,
90        package,
91        package_target,
92        document_private_items,
93        cap_lints,
94        envs,
95    } = options;
96
97    let mut command = match OVERRIDDEN_TOOLCHAIN.or(requested_toolchain.as_deref()) {
98        None => Command::new("cargo"),
99        Some(toolchain) => {
100            if !rustup_installed() {
101                return Err(BuildError::General(String::from(
102                    "required program rustup not found in PATH. Is it installed?",
103                )));
104            }
105            let mut cmd = Command::new("rustup");
106            cmd.args(["run", toolchain, "cargo"]);
107            cmd
108        }
109    };
110
111    command.arg("rustdoc");
112    match package_target {
113        PackageTarget::Lib => command.arg("--lib"),
114        PackageTarget::Bin(target) => command.args(["--bin", target]),
115        PackageTarget::Example(target) => command.args(["--example", target]),
116        PackageTarget::Test(target) => command.args(["--test", target]),
117        PackageTarget::Bench(target) => command.args(["--bench", target]),
118    };
119    if let Some(target_dir) = target_dir {
120        command.arg("--target-dir");
121        command.arg(target_dir);
122    }
123    if *quiet {
124        command.arg("--quiet");
125    }
126    if *silent {
127        command.stdout(std::process::Stdio::null());
128        command.stderr(std::process::Stdio::null());
129    }
130    match *color {
131        Color::Always => command.arg("--color").arg("always"),
132        Color::Never => command.arg("--color").arg("never"),
133        Color::Auto => command.arg("--color").arg("auto"),
134    };
135    command.arg("--manifest-path");
136    command.arg(manifest_path);
137    if let Some(target) = target {
138        command.arg("--target");
139        command.arg(target);
140    }
141    if *no_default_features {
142        command.arg("--no-default-features");
143    }
144    if *all_features {
145        command.arg("--all-features");
146    }
147    for feature in features {
148        command.args(["--features", feature]);
149    }
150    if let Some(package) = package {
151        command.args(["--package", package]);
152    }
153    command.arg("--");
154    command.args(["-Z", "unstable-options"]);
155    command.args(["--output-format", "json"]);
156    if *document_private_items {
157        command.arg("--document-private-items");
158    }
159    if let Some(cap_lints) = cap_lints {
160        command.args(["--cap-lints", cap_lints]);
161    }
162    command.envs(envs);
163    Ok(command)
164}
165
166/// Returns `./target/doc/crate_name.json`. Also takes care of transforming
167/// `crate-name` to `crate_name`. Also handles `[lib] name = "foo"`.
168#[instrument(ret(level = Level::DEBUG))]
169fn rustdoc_json_path_for_manifest_path(
170    manifest_path: &Path,
171    package: Option<&str>,
172    package_target: &PackageTarget,
173    target_dir: Option<&Path>,
174    target: Option<&str>,
175) -> Result<PathBuf, BuildError> {
176    let target_dir = match target_dir {
177        Some(target_dir) => target_dir.to_owned(),
178        None => target_directory(manifest_path)?,
179    };
180
181    // get the name of the crate/binary/example/test/bench
182    let package_target_name = match package_target {
183        PackageTarget::Lib => library_name(manifest_path, package)?,
184        PackageTarget::Bin(name)
185        | PackageTarget::Example(name)
186        | PackageTarget::Test(name)
187        | PackageTarget::Bench(name) => name.clone(),
188    }
189    .replace('-', "_");
190
191    let mut rustdoc_json_path = target_dir;
192    // if one has specified a target explicitly then Cargo appends that target triple name as a subfolder
193    if let Some(target) = target {
194        rustdoc_json_path.push(target);
195    }
196    rustdoc_json_path.push("doc");
197    rustdoc_json_path.push(package_target_name);
198    rustdoc_json_path.set_extension("json");
199    Ok(rustdoc_json_path)
200}
201
202/// Checks if the `rustup` program can be found in `PATH`.
203pub fn rustup_installed() -> bool {
204    let mut check_rustup = std::process::Command::new("rustup");
205    check_rustup.arg("--version");
206    check_rustup.stdout(std::process::Stdio::null());
207    check_rustup.stderr(std::process::Stdio::null());
208    check_rustup.status().map(|s| s.success()).unwrap_or(false)
209}
210
211/// Typically returns the absolute path to the regular cargo `./target`
212/// directory. But also handles packages part of workspaces.
213fn target_directory(manifest_path: impl AsRef<Path>) -> Result<PathBuf, BuildError> {
214    let mut metadata_cmd = cargo_metadata::MetadataCommand::new();
215    metadata_cmd.manifest_path(manifest_path.as_ref());
216    let metadata = metadata_cmd.exec()?;
217    Ok(metadata.target_directory.as_std_path().to_owned())
218}
219
220/// Figures out the name of the library crate corresponding to the given
221/// `Cargo.toml` and `package_name` (in case Cargo.toml is a workspace root).
222fn library_name(
223    manifest_path: impl AsRef<Path>,
224    package_name: Option<&str>,
225) -> Result<String, BuildError> {
226    let package_name = if let Some(package_name) = package_name {
227        package_name.to_owned()
228    } else {
229        // We must figure out the package name ourselves from the manifest.
230        let manifest = cargo_manifest::Manifest::from_path(manifest_path.as_ref())?;
231        manifest
232            .package
233            .ok_or_else(|| BuildError::VirtualManifest(manifest_path.as_ref().to_owned()))?
234            .name
235            .to_owned()
236    };
237
238    let mut metadata_cmd = cargo_metadata::MetadataCommand::new();
239    metadata_cmd.manifest_path(manifest_path.as_ref());
240    let metadata = metadata_cmd.exec()?;
241
242    let package = metadata
243        .packages
244        .into_iter()
245        .find(|p| p.name.as_str() == package_name)
246        .ok_or_else(|| BuildError::VirtualManifest(manifest_path.as_ref().to_owned()))?;
247
248    for target in &package.targets {
249        if target.kind.iter().any(is_library_target_kind) {
250            return Ok(target.name.to_owned());
251        }
252    }
253
254    Ok(package.name.into_inner())
255}
256
257fn is_library_target_kind(target_kind: &TargetKind) -> bool {
258    matches!(
259        target_kind,
260        TargetKind::Lib
261            | TargetKind::RLib
262            | TargetKind::DyLib
263            | TargetKind::CDyLib
264            | TargetKind::StaticLib
265    )
266}
267
268/// Color configuration for the output of `cargo rustdoc`.
269#[derive(Clone, Copy, Debug)]
270pub enum Color {
271    /// Always output colors.
272    Always,
273    /// Never output colors.
274    Never,
275    /// Cargo will decide whether to output colors based on the tty type.
276    Auto,
277}
278
279/// Builds rustdoc JSON. There are many build options. Refer to the docs to
280/// learn about them all. See [top-level docs](crate) for an example on how to use this builder.
281#[derive(Clone, Debug)]
282pub struct Builder {
283    toolchain: Option<String>,
284    manifest_path: PathBuf,
285    target_dir: Option<PathBuf>,
286    target: Option<String>,
287    quiet: bool,
288    silent: bool,
289    color: Color,
290    no_default_features: bool,
291    all_features: bool,
292    features: Vec<String>,
293    package: Option<String>,
294    package_target: PackageTarget,
295    document_private_items: bool,
296    cap_lints: Option<String>,
297    envs: HashMap<OsString, OsString>,
298}
299
300impl Default for Builder {
301    fn default() -> Self {
302        Self {
303            toolchain: None,
304            manifest_path: PathBuf::from("Cargo.toml"),
305            target_dir: None,
306            target: None,
307            quiet: false,
308            silent: false,
309            color: Color::Auto,
310            no_default_features: false,
311            all_features: false,
312            features: vec![],
313            package: None,
314            package_target: PackageTarget::default(),
315            document_private_items: false,
316            cap_lints: Some(String::from("warn")),
317            envs: HashMap::new(),
318        }
319    }
320}
321
322impl Builder {
323    /// Set the toolchain. Default: `None`.
324    /// Until rustdoc JSON has stabilized, you will want to set this to
325    /// be `"nightly"` or similar.
326    ///
327    /// If the toolchain is set as `None`, the current active toolchain will be used.
328    ///
329    /// # Notes
330    ///
331    /// The currently active toolchain is typically specified by the
332    /// `RUSTUP_TOOLCHAIN` environment variable, which the rustup proxy
333    /// mechanism sets. See <https://rust-lang.github.io/rustup/overrides.html>
334    /// for more info on how the active toolchain is determined.
335    #[must_use]
336    pub fn toolchain(mut self, toolchain: impl Into<String>) -> Self {
337        self.toolchain = Some(toolchain.into());
338        self
339    }
340
341    /// Clear a toolchain previously set with [`Self::toolchain`].
342    #[must_use]
343    pub fn clear_toolchain(mut self) -> Self {
344        self.toolchain = None;
345        self
346    }
347
348    /// Set the relative or absolute path to `Cargo.toml`. Default: `Cargo.toml`
349    #[must_use]
350    pub fn manifest_path(mut self, manifest_path: impl AsRef<Path>) -> Self {
351        manifest_path.as_ref().clone_into(&mut self.manifest_path);
352        self
353    }
354
355    /// Set what `--target-dir` to pass to `cargo`. Typically only needed if you
356    /// want to be able to build rustdoc JSON for the same crate concurrently,
357    /// for example to parallelize regression tests.
358    #[must_use]
359    pub fn target_dir(mut self, target_dir: impl AsRef<Path>) -> Self {
360        self.target_dir = Some(target_dir.as_ref().to_owned());
361        self
362    }
363
364    /// Clear a target dir previously set with [`Self::target_dir`].
365    #[must_use]
366    pub fn clear_target_dir(mut self) -> Self {
367        self.target_dir = None;
368        self
369    }
370
371    /// Whether or not to pass `--quiet` to `cargo rustdoc`. Default: `false`
372    #[must_use]
373    pub const fn quiet(mut self, quiet: bool) -> Self {
374        self.quiet = quiet;
375        self
376    }
377
378    /// Whether or not to redirect stdout and stderr to /dev/null. Default: `false`
379    #[must_use]
380    pub const fn silent(mut self, silent: bool) -> Self {
381        self.silent = silent;
382        self
383    }
384
385    /// Color configuration for the output of `cargo rustdoc`.
386    #[must_use]
387    pub const fn color(mut self, color: Color) -> Self {
388        self.color = color;
389        self
390    }
391
392    /// Whether or not to pass `--target` to `cargo rustdoc`. Default: `None`
393    #[must_use]
394    pub fn target(mut self, target: String) -> Self {
395        self.target = Some(target);
396        self
397    }
398
399    /// Whether to pass `--no-default-features` to `cargo rustdoc`. Default: `false`
400    #[must_use]
401    pub const fn no_default_features(mut self, no_default_features: bool) -> Self {
402        self.no_default_features = no_default_features;
403        self
404    }
405
406    /// Whether to pass `--all-features` to `cargo rustdoc`. Default: `false`
407    #[must_use]
408    pub const fn all_features(mut self, all_features: bool) -> Self {
409        self.all_features = all_features;
410        self
411    }
412
413    /// Features to pass to `cargo rustdoc` via `--features`. Default to an empty vector
414    #[must_use]
415    pub fn features<I: IntoIterator<Item = S>, S: AsRef<str>>(mut self, features: I) -> Self {
416        self.features = features
417            .into_iter()
418            .map(|item| item.as_ref().to_owned())
419            .collect();
420        self
421    }
422
423    /// Package to use for `cargo rustdoc` via `-p`. Default: `None`
424    #[must_use]
425    pub fn package(mut self, package: impl AsRef<str>) -> Self {
426        self.package = Some(package.as_ref().to_owned());
427        self
428    }
429
430    /// What part of the package to document. Default: `PackageTarget::Lib`
431    #[must_use]
432    pub fn package_target(mut self, package_target: PackageTarget) -> Self {
433        self.package_target = package_target;
434        self
435    }
436
437    /// Whether to pass `--document-private-items` to `cargo rustdoc`. Default: `false`
438    #[must_use]
439    pub fn document_private_items(mut self, document_private_items: bool) -> Self {
440        self.document_private_items = document_private_items;
441        self
442    }
443
444    /// What to pass as `--cap-lints` to rustdoc JSON build command
445    #[must_use]
446    pub fn cap_lints(mut self, cap_lints: Option<impl AsRef<str>>) -> Self {
447        self.cap_lints = cap_lints.map(|c| c.as_ref().to_owned());
448        self
449    }
450
451    /// Environment variable mapping to pass to the spawned `cargo rustdoc` process.
452    ///
453    /// # Notes
454    ///
455    /// - Environment variable names are case-insensitive (but case-preserving) on Windows and case-sensitive on all other platforms.
456    /// - Spawned `cargo rustdoc` processes will inherit environment variables from their parent process by default.
457    ///   Environment variables explicitly set using this take precedence over inherited variables.
458    #[must_use]
459    pub fn env(mut self, key: impl AsRef<OsStr>, val: impl AsRef<OsStr>) -> Self {
460        self.envs
461            .insert(key.as_ref().to_owned(), val.as_ref().to_owned());
462        self
463    }
464
465    /// Generate rustdoc JSON for a crate. Returns the path to the freshly
466    /// built rustdoc JSON file.
467    ///
468    /// This method will print the stdout and stderr of the `cargo rustdoc` command to the stdout
469    /// and stderr of the calling process. If you want to capture the output, use
470    /// [`Builder::build_with_captured_output()`].
471    ///
472    /// See [top-level docs](crate) for an example on how to use it.
473    ///
474    /// # Errors
475    ///
476    /// E.g. if building the JSON fails or if the manifest path does not exist or is
477    /// invalid.
478    pub fn build(self) -> Result<PathBuf, BuildError> {
479        run_cargo_rustdoc::<std::io::Sink, std::io::Sink>(self, None)
480    }
481
482    /// Generate rustdoc JSON for a crate. This works like [`Builder::build()`], but will
483    /// capture the stdout and stderr of the `cargo rustdoc` command. The output will be written to
484    /// the `stdout` and `stderr` parameters. In particular, potential warnings and errors emitted
485    /// by `cargo rustdoc` will be captured to `stderr`. This can be useful if you want to present
486    /// these errors to the user only when the build failed. Here's an example of how that might
487    /// look like:
488    ///
489    /// ```no_run
490    /// # use std::path::PathBuf;
491    /// # use rustdoc_json::BuildError;
492    /// #
493    /// let mut stderr: Vec<u8> = Vec::new();
494    ///
495    /// let result: Result<PathBuf, BuildError> = rustdoc_json::Builder::default()
496    ///     .toolchain("nightly")
497    ///     .manifest_path("Cargo.toml")
498    ///     .build_with_captured_output(std::io::sink(), &mut stderr);
499    ///
500    /// match result {
501    ///     Err(BuildError::BuildRustdocJsonError) => {
502    ///         eprintln!("Crate failed to build:\n{}", String::from_utf8_lossy(&stderr));
503    ///     }
504    ///     Err(e) => {
505    ///        eprintln!("Error generating the rustdoc json: {}", e);
506    ///     }
507    ///     Ok(json_path) => {
508    ///         // Do something with the json_path.
509    ///     }
510    /// }
511    /// ```
512    pub fn build_with_captured_output(
513        self,
514        stdout: impl Write,
515        stderr: impl Write,
516    ) -> Result<PathBuf, BuildError> {
517        let capture_output = CaptureOutput { stdout, stderr };
518        run_cargo_rustdoc(self, Some(capture_output))
519    }
520}
521
522/// The part of the package to document
523#[derive(Default, Debug, Clone)]
524#[non_exhaustive]
525pub enum PackageTarget {
526    /// Document the package as a library, i.e. pass `--lib`
527    #[default]
528    Lib,
529    /// Document the given binary, i.e. pass `--bin <name>`
530    Bin(String),
531    /// Document the given binary, i.e. pass `--example <name>`
532    Example(String),
533    /// Document the given binary, i.e. pass `--test <name>`
534    Test(String),
535    /// Document the given binary, i.e. pass `--bench <name>`
536    Bench(String),
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    #[test]
544    fn ensure_toolchain_not_overridden() {
545        // The override is only meant to be changed locally, do not git commit!
546        // If the var is set from the env var, that's OK, so skip the check in
547        // that case.
548        if option_env!("RUSTDOC_JSON_OVERRIDDEN_TOOLCHAIN_HACK").is_none() {
549            assert!(OVERRIDDEN_TOOLCHAIN.is_none());
550        }
551    }
552}