rustdoc_json/
builder.rs

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