diff --git a/src/doc/rustdoc/src/unstable-features.md b/src/doc/rustdoc/src/unstable-features.md index d1d42e4732258..7eb1b8df2333d 100644 --- a/src/doc/rustdoc/src/unstable-features.md +++ b/src/doc/rustdoc/src/unstable-features.md @@ -524,6 +524,8 @@ use `-o -`. ## `-w`/`--output-format`: output format +### json + `--output-format json` emits documentation in the experimental [JSON format](https://doc.rust-lang.org/nightly/nightly-rustc/rustdoc_json_types/). `--output-format html` has no effect, and is also accepted on stable toolchains. @@ -542,6 +544,68 @@ It can also be used with `--show-coverage`. Take a look at its [documentation](#--show-coverage-calculate-the-percentage-of-items-with-documentation) for more information. +### doctest + +`--output-format doctest` emits JSON on stdout which gives you information about doctests in the +provided crate. + +Tracking issue: [#134529](https://github.com/rust-lang/rust/issues/134529) + +You can use this option like this: + +```bash +rustdoc -Zunstable-options --output-format=doctest src/lib.rs +``` + +For this rust code: + +```rust +/// ``` +/// let x = 12; +/// ``` +pub trait Trait {} +``` + +The generated output (formatted) will look like this: + +```json +{ + "format_version": 1, + "doctests": [ + { + "file": "foo.rs", + "line": 1, + "doctest_attributes": { + "original": "", + "should_panic": false, + "no_run": false, + "ignore": "None", + "rust": true, + "test_harness": false, + "compile_fail": false, + "standalone_crate": false, + "error_codes": [], + "edition": null, + "added_css_classes": [], + "unknown": [] + }, + "original_code": "let x = 12;", + "doctest_code": "#![allow(unused)]\nfn main() {\nlet x = 12;\n}", + "name": "foo.rs - Trait (line 1)" + } + ] +} +``` + + * `format_version` gives you the current version of the generated JSON. If we change the output in any way, the number will increase. + * `doctests` contains the list of doctests present in the crate. + * `file` is the file path where the doctest is located. + * `line` is the line where the doctest starts (so where the \`\`\` is located in the current code). + * `doctest_attributes` contains computed information about the attributes used on the doctests. For more information about doctest attributes, take a look [here](write-documentation/documentation-tests.html#attributes). + * `original_code` is the code as written in the source code before rustdoc modifies it. + * `doctest_code` is the code modified by rustdoc that will be run. If there is a fatal syntax error, this field will not be present. + * `name` is the name generated by rustdoc which represents this doctest. + ## `--enable-per-target-ignores`: allow `ignore-foo` style filters for doctests * Tracking issue: [#64245](https://github.com/rust-lang/rust/issues/64245) diff --git a/src/librustdoc/config.rs b/src/librustdoc/config.rs index 80bc6cebd2aa9..cfba78952085c 100644 --- a/src/librustdoc/config.rs +++ b/src/librustdoc/config.rs @@ -33,6 +33,7 @@ pub(crate) enum OutputFormat { Json, #[default] Html, + Doctest, } impl OutputFormat { @@ -48,6 +49,7 @@ impl TryFrom<&str> for OutputFormat { match value { "json" => Ok(OutputFormat::Json), "html" => Ok(OutputFormat::Html), + "doctest" => Ok(OutputFormat::Doctest), _ => Err(format!("unknown output format `{value}`")), } } @@ -445,14 +447,42 @@ impl Options { } } + let show_coverage = matches.opt_present("show-coverage"); + let output_format_s = matches.opt_str("output-format"); + let output_format = match output_format_s { + Some(ref s) => match OutputFormat::try_from(s.as_str()) { + Ok(out_fmt) => out_fmt, + Err(e) => dcx.fatal(e), + }, + None => OutputFormat::default(), + }; + // check for `--output-format=json` - if !matches!(matches.opt_str("output-format").as_deref(), None | Some("html")) - && !matches.opt_present("show-coverage") - && !nightly_options::is_unstable_enabled(matches) - { - dcx.fatal( - "the -Z unstable-options flag must be passed to enable --output-format for documentation generation (see https://github.com/rust-lang/rust/issues/76578)", - ); + match ( + output_format_s.as_ref().map(|_| output_format), + show_coverage, + nightly_options::is_unstable_enabled(matches), + ) { + (None | Some(OutputFormat::Json), true, _) => {} + (_, true, _) => { + dcx.fatal(format!( + "`--output-format={}` is not supported for the `--show-coverage` option", + output_format_s.unwrap_or_default(), + )); + } + // If `-Zunstable-options` is used, nothing to check after this point. + (_, false, true) => {} + (None | Some(OutputFormat::Html), false, _) => {} + (Some(OutputFormat::Json), false, false) => { + dcx.fatal( + "the -Z unstable-options flag must be passed to enable --output-format for documentation generation (see https://github.com/rust-lang/rust/issues/76578)", + ); + } + (Some(OutputFormat::Doctest), false, false) => { + dcx.fatal( + "the -Z unstable-options flag must be passed to enable --output-format for documentation generation (see https://github.com/rust-lang/rust/issues/134529)", + ); + } } let to_check = matches.opt_strs("check-theme"); @@ -704,8 +734,6 @@ impl Options { }) .collect(); - let show_coverage = matches.opt_present("show-coverage"); - let crate_types = match parse_crate_types_from_list(matches.opt_strs("crate-type")) { Ok(types) => types, Err(e) => { @@ -713,20 +741,6 @@ impl Options { } }; - let output_format = match matches.opt_str("output-format") { - Some(s) => match OutputFormat::try_from(s.as_str()) { - Ok(out_fmt) => { - if !out_fmt.is_json() && show_coverage { - dcx.fatal( - "html output format isn't supported for the --show-coverage option", - ); - } - out_fmt - } - Err(e) => dcx.fatal(e), - }, - None => OutputFormat::default(), - }; let crate_name = matches.opt_str("crate-name"); let bin_crate = crate_types.contains(&CrateType::Executable); let proc_macro_crate = crate_types.contains(&CrateType::ProcMacro); diff --git a/src/librustdoc/doctest.rs b/src/librustdoc/doctest.rs index 8c3e28ecec38c..8b522e614b813 100644 --- a/src/librustdoc/doctest.rs +++ b/src/librustdoc/doctest.rs @@ -1,3 +1,4 @@ +mod extracted; mod make; mod markdown; mod runner; @@ -30,7 +31,7 @@ use tempfile::{Builder as TempFileBuilder, TempDir}; use tracing::debug; use self::rust::HirCollector; -use crate::config::Options as RustdocOptions; +use crate::config::{Options as RustdocOptions, OutputFormat}; use crate::html::markdown::{ErrorCodes, Ignore, LangString, MdRelLine}; use crate::lint::init_lints; @@ -209,15 +210,8 @@ pub(crate) fn run(dcx: DiagCtxtHandle<'_>, input: Input, options: RustdocOptions let args_path = temp_dir.path().join("rustdoc-cfgs"); crate::wrap_return(dcx, generate_args_file(&args_path, &options)); - let CreateRunnableDocTests { - standalone_tests, - mergeable_tests, - rustdoc_options, - opts, - unused_extern_reports, - compiling_test_count, - .. - } = interface::run_compiler(config, |compiler| { + let extract_doctests = options.output_format == OutputFormat::Doctest; + let result = interface::run_compiler(config, |compiler| { let krate = rustc_interface::passes::parse(&compiler.sess); let collector = rustc_interface::create_and_enter_global_ctxt(compiler, krate, |tcx| { @@ -226,22 +220,53 @@ pub(crate) fn run(dcx: DiagCtxtHandle<'_>, input: Input, options: RustdocOptions let opts = scrape_test_config(crate_name, crate_attrs, args_path); let enable_per_target_ignores = options.enable_per_target_ignores; - let mut collector = CreateRunnableDocTests::new(options, opts); let hir_collector = HirCollector::new( ErrorCodes::from(compiler.sess.opts.unstable_features.is_nightly_build()), enable_per_target_ignores, tcx, ); let tests = hir_collector.collect_crate(); - tests.into_iter().for_each(|t| collector.add_test(t)); + if extract_doctests { + let mut collector = extracted::ExtractedDocTests::new(); + tests.into_iter().for_each(|t| collector.add_test(t, &opts, &options)); + + let stdout = std::io::stdout(); + let mut stdout = stdout.lock(); + if let Err(error) = serde_json::ser::to_writer(&mut stdout, &collector) { + eprintln!(); + Err(format!("Failed to generate JSON output for doctests: {error:?}")) + } else { + Ok(None) + } + } else { + let mut collector = CreateRunnableDocTests::new(options, opts); + tests.into_iter().for_each(|t| collector.add_test(t)); - collector + Ok(Some(collector)) + } }); compiler.sess.dcx().abort_if_errors(); collector }); + let CreateRunnableDocTests { + standalone_tests, + mergeable_tests, + rustdoc_options, + opts, + unused_extern_reports, + compiling_test_count, + .. + } = match result { + Ok(Some(collector)) => collector, + Ok(None) => return, + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); + } + }; + run_tests(opts, &rustdoc_options, &unused_extern_reports, standalone_tests, mergeable_tests); let compiling_test_count = compiling_test_count.load(Ordering::SeqCst); diff --git a/src/librustdoc/doctest/extracted.rs b/src/librustdoc/doctest/extracted.rs new file mode 100644 index 0000000000000..03c8814a4c960 --- /dev/null +++ b/src/librustdoc/doctest/extracted.rs @@ -0,0 +1,141 @@ +//! Rustdoc's doctest extraction. +//! +//! This module contains the logic to extract doctests and output a JSON containing this +//! information. + +use serde::Serialize; + +use super::{DocTestBuilder, ScrapedDocTest}; +use crate::config::Options as RustdocOptions; +use crate::html::markdown; + +/// The version of JSON output that this code generates. +/// +/// This integer is incremented with every breaking change to the API, +/// and is returned along with the JSON blob into the `format_version` root field. +/// Consuming code should assert that this value matches the format version(s) that it supports. +const FORMAT_VERSION: u32 = 1; + +#[derive(Serialize)] +pub(crate) struct ExtractedDocTests { + format_version: u32, + doctests: Vec, +} + +impl ExtractedDocTests { + pub(crate) fn new() -> Self { + Self { format_version: FORMAT_VERSION, doctests: Vec::new() } + } + + pub(crate) fn add_test( + &mut self, + scraped_test: ScrapedDocTest, + opts: &super::GlobalTestOptions, + options: &RustdocOptions, + ) { + let edition = scraped_test.edition(&options); + + let ScrapedDocTest { filename, line, langstr, text, name } = scraped_test; + + let doctest = DocTestBuilder::new( + &text, + Some(&opts.crate_name), + edition, + false, + None, + Some(&langstr), + ); + let (full_test_code, size) = doctest.generate_unique_doctest( + &text, + langstr.test_harness, + &opts, + Some(&opts.crate_name), + ); + self.doctests.push(ExtractedDocTest { + file: filename.prefer_remapped_unconditionaly().to_string(), + line, + doctest_attributes: langstr.into(), + doctest_code: if size != 0 { Some(full_test_code) } else { None }, + original_code: text, + name, + }); + } +} + +#[derive(Serialize)] +pub(crate) struct ExtractedDocTest { + file: String, + line: usize, + doctest_attributes: LangString, + original_code: String, + /// `None` if the code syntax is invalid. + doctest_code: Option, + name: String, +} + +#[derive(Serialize)] +pub(crate) enum Ignore { + All, + None, + Some(Vec), +} + +impl From for Ignore { + fn from(original: markdown::Ignore) -> Self { + match original { + markdown::Ignore::All => Self::All, + markdown::Ignore::None => Self::None, + markdown::Ignore::Some(values) => Self::Some(values), + } + } +} + +#[derive(Serialize)] +struct LangString { + pub(crate) original: String, + pub(crate) should_panic: bool, + pub(crate) no_run: bool, + pub(crate) ignore: Ignore, + pub(crate) rust: bool, + pub(crate) test_harness: bool, + pub(crate) compile_fail: bool, + pub(crate) standalone_crate: bool, + pub(crate) error_codes: Vec, + pub(crate) edition: Option, + pub(crate) added_css_classes: Vec, + pub(crate) unknown: Vec, +} + +impl From for LangString { + fn from(original: markdown::LangString) -> Self { + let markdown::LangString { + original, + should_panic, + no_run, + ignore, + rust, + test_harness, + compile_fail, + standalone_crate, + error_codes, + edition, + added_classes, + unknown, + } = original; + + Self { + original, + should_panic, + no_run, + ignore: ignore.into(), + rust, + test_harness, + compile_fail, + standalone_crate, + error_codes, + edition: edition.map(|edition| edition.to_string()), + added_css_classes: added_classes, + unknown, + } + } +} diff --git a/src/librustdoc/lib.rs b/src/librustdoc/lib.rs index bb954a31891ad..44adf92ff0eef 100644 --- a/src/librustdoc/lib.rs +++ b/src/librustdoc/lib.rs @@ -814,7 +814,12 @@ fn main_args(early_dcx: &mut EarlyDiagCtxt, at_args: &[String]) { } }; - match (options.should_test, config::markdown_input(&input)) { + let output_format = options.output_format; + + match ( + options.should_test || output_format == config::OutputFormat::Doctest, + config::markdown_input(&input), + ) { (true, Some(_)) => return wrap_return(dcx, doctest::test_markdown(&input, options)), (true, None) => return doctest::run(dcx, input, options), (false, Some(md_input)) => { @@ -849,7 +854,6 @@ fn main_args(early_dcx: &mut EarlyDiagCtxt, at_args: &[String]) { // plug/cleaning passes. let crate_version = options.crate_version.clone(); - let output_format = options.output_format; let scrape_examples_options = options.scrape_examples_options.clone(); let bin_crate = options.bin_crate; @@ -899,6 +903,8 @@ fn main_args(early_dcx: &mut EarlyDiagCtxt, at_args: &[String]) { config::OutputFormat::Json => sess.time("render_json", || { run_renderer::>(krate, render_opts, cache, tcx) }), + // Already handled above with doctest runners. + config::OutputFormat::Doctest => unreachable!(), } }) }) diff --git a/tests/rustdoc-ui/coverage/html.stderr b/tests/rustdoc-ui/coverage/html.stderr index adca375d4bce5..764179820c5ba 100644 --- a/tests/rustdoc-ui/coverage/html.stderr +++ b/tests/rustdoc-ui/coverage/html.stderr @@ -1,2 +1,2 @@ -error: html output format isn't supported for the --show-coverage option +error: `--output-format=html` is not supported for the `--show-coverage` option diff --git a/tests/rustdoc-ui/doctest-output.rs b/tests/rustdoc-ui/doctest-output.rs new file mode 100644 index 0000000000000..720f2952980ed --- /dev/null +++ b/tests/rustdoc-ui/doctest-output.rs @@ -0,0 +1 @@ +//@ compile-flags:-Z unstable-options --show-coverage --output-format=doctest diff --git a/tests/rustdoc-ui/doctest-output.stderr b/tests/rustdoc-ui/doctest-output.stderr new file mode 100644 index 0000000000000..20c618dc61b16 --- /dev/null +++ b/tests/rustdoc-ui/doctest-output.stderr @@ -0,0 +1,2 @@ +error: `--output-format=doctest` is not supported for the `--show-coverage` option + diff --git a/tests/rustdoc-ui/extract-doctests.rs b/tests/rustdoc-ui/extract-doctests.rs new file mode 100644 index 0000000000000..06bd35969d0c4 --- /dev/null +++ b/tests/rustdoc-ui/extract-doctests.rs @@ -0,0 +1,15 @@ +// Test to ensure that it generates expected output for `--output-format=doctest` command-line +// flag. + +//@ compile-flags:-Z unstable-options --output-format=doctest +//@ normalize-stdout: "tests/rustdoc-ui" -> "$$DIR" +//@ check-pass + +//! ```ignore (checking attributes) +//! let x = 12; +//! let y = 14; +//! ``` +//! +//! ```edition2018,compile_fail +//! let +//! ``` diff --git a/tests/rustdoc-ui/extract-doctests.stdout b/tests/rustdoc-ui/extract-doctests.stdout new file mode 100644 index 0000000000000..fa8604cae948a --- /dev/null +++ b/tests/rustdoc-ui/extract-doctests.stdout @@ -0,0 +1 @@ +{"format_version":1,"doctests":[{"file":"$DIR/extract-doctests.rs","line":8,"doctest_attributes":{"original":"ignore (checking attributes)","should_panic":false,"no_run":false,"ignore":"All","rust":true,"test_harness":false,"compile_fail":false,"standalone_crate":false,"error_codes":[],"edition":null,"added_css_classes":[],"unknown":[]},"original_code":"let x = 12;\nlet y = 14;","doctest_code":"#![allow(unused)]\nfn main() {\nlet x = 12;\nlet y = 14;\n}","name":"$DIR/extract-doctests.rs - (line 8)"},{"file":"$DIR/extract-doctests.rs","line":13,"doctest_attributes":{"original":"edition2018,compile_fail","should_panic":false,"no_run":true,"ignore":"None","rust":true,"test_harness":false,"compile_fail":true,"standalone_crate":false,"error_codes":[],"edition":"2018","added_css_classes":[],"unknown":[]},"original_code":"let","doctest_code":"#![allow(unused)]\nfn main() {\nlet\n}","name":"$DIR/extract-doctests.rs - (line 13)"}]} \ No newline at end of file