Rollup merge of #141399 - GuillaumeGomez:extracted-doctest, r=aDotInTheVoid

[rustdoc] Give more information into extracted doctest information

Follow-up of https://github.com/rust-lang/rust/pull/134531.

This update fragment the doctest code into its sub-parts to give more control to the end users on how they want to use it.

The new JSON looks like this:

```json
{
  "format_version":2,
  "doctests":[
    {
      "file":"$DIR/extract-doctests-result.rs",
      "line":8,
      "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;\nOk(())",
      "doctest_code":{
        "crate_level":"#![allow(unused)]\n",
        "code":"let x = 12;\nOk(())",
        "wrapper":{
          "before":"fn main() { fn _inner() -> core::result::Result<(), impl core::fmt::Debug> {\n",
          "after":"\n} _inner().unwrap() }",
          "returns_result":true
        }
      },
      "name":"$DIR/extract-doctests-result.rs - (line 8)"
    }
  ]
}
```

for this doctest:

```rust
let x = 12;
Ok(())
```

With this, I think it matches what you need ``@ojeda?`` If so, once merged I'll update the patch I sent to RfL.

r? ``@aDotInTheVoid``
This commit is contained in:
Matthias Krüger 2025-06-14 11:27:09 +02:00 committed by GitHub
commit 9bdf5d371f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 251 additions and 56 deletions

View file

@ -581,7 +581,9 @@ For this rust code:
```rust
/// ```
/// #![allow(dead_code)]
/// let x = 12;
/// Ok(())
/// ```
pub trait Trait {}
```
@ -590,10 +592,10 @@ The generated output (formatted) will look like this:
```json
{
"format_version": 1,
"format_version": 2,
"doctests": [
{
"file": "foo.rs",
"file": "src/lib.rs",
"line": 1,
"doctest_attributes": {
"original": "",
@ -609,9 +611,17 @@ The generated output (formatted) will look like this:
"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)"
"original_code": "#![allow(dead_code)]\nlet x = 12;\nOk(())",
"doctest_code": {
"crate_level": "#![allow(unused)]\n#![allow(dead_code)]\n\n",
"code": "let x = 12;\nOk(())",
"wrapper": {
"before": "fn main() { fn _inner() -> core::result::Result<(), impl core::fmt::Debug> {\n",
"after": "\n} _inner().unwrap() }",
"returns_result": true
}
},
"name": "src/lib.rs - (line 1)"
}
]
}
@ -624,6 +634,10 @@ The generated output (formatted) will look like this:
* `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.
* `crate_level` is the crate level code (like attributes or `extern crate`) that will be added at the top-level of the generated doctest.
* `code` is "naked" doctest without anything from `crate_level` and `wrapper` content.
* `wrapper` contains extra code that will be added before and after `code`.
* `returns_result` is a boolean. If `true`, it means that the doctest returns a `Result` type.
* `name` is the name generated by rustdoc which represents this doctest.
### html

View file

@ -1053,14 +1053,14 @@ fn doctest_run_fn(
let report_unused_externs = |uext| {
unused_externs.lock().unwrap().push(uext);
};
let (full_test_code, full_test_line_offset) = doctest.generate_unique_doctest(
let (wrapped, full_test_line_offset) = doctest.generate_unique_doctest(
&scraped_test.text,
scraped_test.langstr.test_harness,
&global_opts,
Some(&global_opts.crate_name),
);
let runnable_test = RunnableDocTest {
full_test_code,
full_test_code: wrapped.to_string(),
full_test_line_offset,
test_opts,
global_opts,

View file

@ -3,8 +3,10 @@
//! This module contains the logic to extract doctests and output a JSON containing this
//! information.
use rustc_span::edition::Edition;
use serde::Serialize;
use super::make::DocTestWrapResult;
use super::{BuildDocTestBuilder, ScrapedDocTest};
use crate::config::Options as RustdocOptions;
use crate::html::markdown;
@ -14,7 +16,7 @@ use crate::html::markdown;
/// 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;
const FORMAT_VERSION: u32 = 2;
#[derive(Serialize)]
pub(crate) struct ExtractedDocTests {
@ -34,7 +36,16 @@ impl ExtractedDocTests {
options: &RustdocOptions,
) {
let edition = scraped_test.edition(options);
self.add_test_with_edition(scraped_test, opts, edition)
}
/// This method is used by unit tests to not have to provide a `RustdocOptions`.
pub(crate) fn add_test_with_edition(
&mut self,
scraped_test: ScrapedDocTest,
opts: &super::GlobalTestOptions,
edition: Edition,
) {
let ScrapedDocTest { filename, line, langstr, text, name, global_crate_attrs, .. } =
scraped_test;
@ -44,8 +55,7 @@ impl ExtractedDocTests {
.edition(edition)
.lang_str(&langstr)
.build(None);
let (full_test_code, size) = doctest.generate_unique_doctest(
let (wrapped, _size) = doctest.generate_unique_doctest(
&text,
langstr.test_harness,
opts,
@ -55,11 +65,46 @@ impl ExtractedDocTests {
file: filename.prefer_remapped_unconditionaly().to_string(),
line,
doctest_attributes: langstr.into(),
doctest_code: if size != 0 { Some(full_test_code) } else { None },
doctest_code: match wrapped {
DocTestWrapResult::Valid { crate_level_code, wrapper, code } => Some(DocTest {
crate_level: crate_level_code,
code,
wrapper: wrapper.map(
|super::make::WrapperInfo { before, after, returns_result, .. }| {
WrapperInfo { before, after, returns_result }
},
),
}),
DocTestWrapResult::SyntaxError { .. } => None,
},
original_code: text,
name,
});
}
#[cfg(test)]
pub(crate) fn doctests(&self) -> &[ExtractedDocTest] {
&self.doctests
}
}
#[derive(Serialize)]
pub(crate) struct WrapperInfo {
before: String,
after: String,
returns_result: bool,
}
#[derive(Serialize)]
pub(crate) struct DocTest {
crate_level: String,
code: String,
/// This field can be `None` if one of the following conditions is true:
///
/// * The doctest's codeblock has the `test_harness` attribute.
/// * The doctest has a `main` function.
/// * The doctest has the `![no_std]` attribute.
pub(crate) wrapper: Option<WrapperInfo>,
}
#[derive(Serialize)]
@ -69,7 +114,7 @@ pub(crate) struct ExtractedDocTest {
doctest_attributes: LangString,
original_code: String,
/// `None` if the code syntax is invalid.
doctest_code: Option<String>,
pub(crate) doctest_code: Option<DocTest>,
name: String,
}

View file

@ -196,6 +196,80 @@ pub(crate) struct DocTestBuilder {
pub(crate) can_be_merged: bool,
}
/// Contains needed information for doctest to be correctly generated with expected "wrapping".
pub(crate) struct WrapperInfo {
pub(crate) before: String,
pub(crate) after: String,
pub(crate) returns_result: bool,
insert_indent_space: bool,
}
impl WrapperInfo {
fn len(&self) -> usize {
self.before.len() + self.after.len()
}
}
/// Contains a doctest information. Can be converted into code with the `to_string()` method.
pub(crate) enum DocTestWrapResult {
Valid {
crate_level_code: String,
/// This field can be `None` if one of the following conditions is true:
///
/// * The doctest's codeblock has the `test_harness` attribute.
/// * The doctest has a `main` function.
/// * The doctest has the `![no_std]` attribute.
wrapper: Option<WrapperInfo>,
/// Contains the doctest processed code without the wrappers (which are stored in the
/// `wrapper` field).
code: String,
},
/// Contains the original source code.
SyntaxError(String),
}
impl std::string::ToString for DocTestWrapResult {
fn to_string(&self) -> String {
match self {
Self::SyntaxError(s) => s.clone(),
Self::Valid { crate_level_code, wrapper, code } => {
let mut prog_len = code.len() + crate_level_code.len();
if let Some(wrapper) = wrapper {
prog_len += wrapper.len();
if wrapper.insert_indent_space {
prog_len += code.lines().count() * 4;
}
}
let mut prog = String::with_capacity(prog_len);
prog.push_str(crate_level_code);
if let Some(wrapper) = wrapper {
prog.push_str(&wrapper.before);
// add extra 4 spaces for each line to offset the code block
if wrapper.insert_indent_space {
write!(
prog,
"{}",
fmt::from_fn(|f| code
.lines()
.map(|line| fmt::from_fn(move |f| write!(f, " {line}")))
.joined("\n", f))
)
.unwrap();
} else {
prog.push_str(code);
}
prog.push_str(&wrapper.after);
} else {
prog.push_str(code);
}
prog
}
}
}
}
impl DocTestBuilder {
fn invalid(
global_crate_attrs: Vec<String>,
@ -228,50 +302,49 @@ impl DocTestBuilder {
dont_insert_main: bool,
opts: &GlobalTestOptions,
crate_name: Option<&str>,
) -> (String, usize) {
) -> (DocTestWrapResult, usize) {
if self.invalid_ast {
// If the AST failed to compile, no need to go generate a complete doctest, the error
// will be better this way.
debug!("invalid AST:\n{test_code}");
return (test_code.to_string(), 0);
return (DocTestWrapResult::SyntaxError(test_code.to_string()), 0);
}
let mut line_offset = 0;
let mut prog = String::new();
let everything_else = self.everything_else.trim();
let mut crate_level_code = String::new();
let processed_code = self.everything_else.trim();
if self.global_crate_attrs.is_empty() {
// If there aren't any attributes supplied by #![doc(test(attr(...)))], then allow some
// lints that are commonly triggered in doctests. The crate-level test attributes are
// commonly used to make tests fail in case they trigger warnings, so having this there in
// that case may cause some tests to pass when they shouldn't have.
prog.push_str("#![allow(unused)]\n");
crate_level_code.push_str("#![allow(unused)]\n");
line_offset += 1;
}
// Next, any attributes that came from #![doc(test(attr(...)))].
for attr in &self.global_crate_attrs {
prog.push_str(&format!("#![{attr}]\n"));
crate_level_code.push_str(&format!("#![{attr}]\n"));
line_offset += 1;
}
// Now push any outer attributes from the example, assuming they
// are intended to be crate attributes.
if !self.crate_attrs.is_empty() {
prog.push_str(&self.crate_attrs);
crate_level_code.push_str(&self.crate_attrs);
if !self.crate_attrs.ends_with('\n') {
prog.push('\n');
crate_level_code.push('\n');
}
}
if !self.maybe_crate_attrs.is_empty() {
prog.push_str(&self.maybe_crate_attrs);
crate_level_code.push_str(&self.maybe_crate_attrs);
if !self.maybe_crate_attrs.ends_with('\n') {
prog.push('\n');
crate_level_code.push('\n');
}
}
if !self.crates.is_empty() {
prog.push_str(&self.crates);
crate_level_code.push_str(&self.crates);
if !self.crates.ends_with('\n') {
prog.push('\n');
crate_level_code.push('\n');
}
}
@ -289,17 +362,20 @@ impl DocTestBuilder {
{
// rustdoc implicitly inserts an `extern crate` item for the own crate
// which may be unused, so we need to allow the lint.
prog.push_str("#[allow(unused_extern_crates)]\n");
crate_level_code.push_str("#[allow(unused_extern_crates)]\n");
prog.push_str(&format!("extern crate r#{crate_name};\n"));
crate_level_code.push_str(&format!("extern crate r#{crate_name};\n"));
line_offset += 1;
}
// FIXME: This code cannot yet handle no_std test cases yet
if dont_insert_main || self.has_main_fn || prog.contains("![no_std]") {
prog.push_str(everything_else);
let wrapper = if dont_insert_main
|| self.has_main_fn
|| crate_level_code.contains("![no_std]")
{
None
} else {
let returns_result = everything_else.ends_with("(())");
let returns_result = processed_code.ends_with("(())");
// Give each doctest main function a unique name.
// This is for example needed for the tooling around `-C instrument-coverage`.
let inner_fn_name = if let Some(ref test_id) = self.test_id {
@ -333,28 +409,22 @@ impl DocTestBuilder {
// /// ``` <- end of the inner main
line_offset += 1;
prog.push_str(&main_pre);
Some(WrapperInfo {
before: main_pre,
after: main_post,
returns_result,
insert_indent_space: opts.insert_indent_space,
})
};
// add extra 4 spaces for each line to offset the code block
if opts.insert_indent_space {
write!(
prog,
"{}",
fmt::from_fn(|f| everything_else
.lines()
.map(|line| fmt::from_fn(move |f| write!(f, " {line}")))
.joined("\n", f))
)
.unwrap();
} else {
prog.push_str(everything_else);
};
prog.push_str(&main_post);
}
debug!("final doctest:\n{prog}");
(prog, line_offset)
(
DocTestWrapResult::Valid {
code: processed_code.to_string(),
wrapper,
crate_level_code,
},
line_offset,
)
}
}

View file

@ -1,6 +1,11 @@
use std::path::PathBuf;
use super::{BuildDocTestBuilder, GlobalTestOptions};
use rustc_span::edition::Edition;
use rustc_span::{DUMMY_SP, FileName};
use super::extracted::ExtractedDocTests;
use super::{BuildDocTestBuilder, GlobalTestOptions, ScrapedDocTest};
use crate::html::markdown::LangString;
fn make_test(
test_code: &str,
@ -19,9 +24,9 @@ fn make_test(
builder = builder.test_id(test_id.to_string());
}
let doctest = builder.build(None);
let (code, line_offset) =
let (wrapped, line_offset) =
doctest.generate_unique_doctest(test_code, dont_insert_main, opts, crate_name);
(code, line_offset)
(wrapped.to_string(), line_offset)
}
/// Default [`GlobalTestOptions`] for these unit tests.
@ -461,3 +466,51 @@ pub mod outer_module {
let (output, len) = make_test(input, None, false, &opts, Vec::new(), None);
assert_eq!((output, len), (expected, 2));
}
fn get_extracted_doctests(code: &str) -> ExtractedDocTests {
let opts = default_global_opts("");
let mut extractor = ExtractedDocTests::new();
extractor.add_test_with_edition(
ScrapedDocTest::new(
FileName::Custom(String::new()),
0,
Vec::new(),
LangString::default(),
code.to_string(),
DUMMY_SP,
Vec::new(),
),
&opts,
Edition::Edition2018,
);
extractor
}
// Test that `extracted::DocTest::wrapper` is `None` if the doctest has a `main` function.
#[test]
fn test_extracted_doctest_wrapper_field() {
let extractor = get_extracted_doctests("fn main() {}");
assert_eq!(extractor.doctests().len(), 1);
let doctest_code = extractor.doctests()[0].doctest_code.as_ref().unwrap();
assert!(doctest_code.wrapper.is_none());
}
// Test that `ExtractedDocTest::doctest_code` is `None` if the doctest has syntax error.
#[test]
fn test_extracted_doctest_doctest_code_field() {
let extractor = get_extracted_doctests("let x +=");
assert_eq!(extractor.doctests().len(), 1);
assert!(extractor.doctests()[0].doctest_code.is_none());
}
// Test that `extracted::DocTest::wrapper` is `Some` if the doctest needs wrapping.
#[test]
fn test_extracted_doctest_wrapper_field_with_info() {
let extractor = get_extracted_doctests("let x = 12;");
assert_eq!(extractor.doctests().len(), 1);
let doctest_code = extractor.doctests()[0].doctest_code.as_ref().unwrap();
assert!(doctest_code.wrapper.is_some());
}

View file

@ -307,7 +307,8 @@ impl<'a, I: Iterator<Item = Event<'a>>> Iterator for CodeBlocks<'_, 'a, I> {
builder = builder.crate_name(krate);
}
let doctest = builder.build(None);
let (test, _) = doctest.generate_unique_doctest(&test, false, &opts, krate);
let (wrapped, _) = doctest.generate_unique_doctest(&test, false, &opts, krate);
let test = wrapped.to_string();
let channel = if test.contains("#![feature(") { "&amp;version=nightly" } else { "" };
let test_escaped = small_url_encode(test);

View file

@ -0,0 +1,11 @@
// 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
//! ```
//! let x = 12;
//! Ok(())
//! ```

View file

@ -0,0 +1 @@
{"format_version":2,"doctests":[{"file":"$DIR/extract-doctests-result.rs","line":8,"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;\nOk(())","doctest_code":{"crate_level":"#![allow(unused)]\n","code":"let x = 12;\nOk(())","wrapper":{"before":"fn main() { fn _inner() -> core::result::Result<(), impl core::fmt::Debug> {\n","after":"\n} _inner().unwrap() }","returns_result":true}},"name":"$DIR/extract-doctests-result.rs - (line 8)"}]}

View file

@ -1 +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":null,"name":"$DIR/extract-doctests.rs - (line 13)"}]}
{"format_version":2,"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":{"crate_level":"#![allow(unused)]\n","code":"let x = 12;\nlet y = 14;","wrapper":{"before":"fn main() {\n","after":"\n}","returns_result":false}},"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":null,"name":"$DIR/extract-doctests.rs - (line 13)"}]}