Auto merge of #125798 - camelid:refactor-doctest, r=GuillaumeGomez

rustdoc: Refactor doctest collection and running code

This code previously had a quite confusing structure, mixing the collection,
processing, and running of doctests with multiple layers of indirection. There
are also many cases where tons of parameters are passed to functions with little
typing information (e.g., booleans or strings are often used).

As a result, the source of bugs is obfuscated (e.g. #81070) and large changes
(e.g.  #123974) become unnecessarily complicated. This PR is a first step to try
to simplify the code and make it easier to follow and less bug-prone.

r? `@GuillaumeGomez`
This commit is contained in:
bors 2024-06-07 18:02:40 +00:00
commit 4dc24ae394
9 changed files with 1020 additions and 890 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,393 @@
//! Logic for transforming the raw code given by the user into something actually
//! runnable, e.g. by adding a `main` function if it doesn't already exist.
use std::io;
use rustc_ast as ast;
use rustc_data_structures::sync::Lrc;
use rustc_errors::emitter::stderr_destination;
use rustc_errors::{ColorConfig, FatalError};
use rustc_parse::new_parser_from_source_str;
use rustc_parse::parser::attr::InnerAttrPolicy;
use rustc_session::parse::ParseSess;
use rustc_span::edition::Edition;
use rustc_span::source_map::SourceMap;
use rustc_span::symbol::sym;
use rustc_span::FileName;
use super::GlobalTestOptions;
/// Transforms a test into code that can be compiled into a Rust binary, and returns the number of
/// lines before the test code begins as well as if the output stream supports colors or not.
pub(crate) fn make_test(
s: &str,
crate_name: Option<&str>,
dont_insert_main: bool,
opts: &GlobalTestOptions,
edition: Edition,
test_id: Option<&str>,
) -> (String, usize, bool) {
let (crate_attrs, everything_else, crates) = partition_source(s, edition);
let everything_else = everything_else.trim();
let mut line_offset = 0;
let mut prog = String::new();
let mut supports_color = false;
if opts.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");
line_offset += 1;
}
// Next, any attributes that came from the crate root via #![doc(test(attr(...)))].
for attr in &opts.attrs {
prog.push_str(&format!("#![{attr}]\n"));
line_offset += 1;
}
// Now push any outer attributes from the example, assuming they
// are intended to be crate attributes.
prog.push_str(&crate_attrs);
prog.push_str(&crates);
// Uses librustc_ast to parse the doctest and find if there's a main fn and the extern
// crate already is included.
let Ok((already_has_main, already_has_extern_crate)) =
check_for_main_and_extern_crate(crate_name, s.to_owned(), edition, &mut supports_color)
else {
// If the parser panicked due to a fatal error, pass the test code through unchanged.
// The error will be reported during compilation.
return (s.to_owned(), 0, false);
};
// Don't inject `extern crate std` because it's already injected by the
// compiler.
if !already_has_extern_crate && !opts.no_crate_inject && crate_name != Some("std") {
if let Some(crate_name) = crate_name {
// Don't inject `extern crate` if the crate is never used.
// NOTE: this is terribly inaccurate because it doesn't actually
// parse the source, but only has false positives, not false
// negatives.
if s.contains(crate_name) {
// 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");
prog.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 || already_has_main || prog.contains("![no_std]") {
prog.push_str(everything_else);
} else {
let returns_result = everything_else.trim_end().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(test_id) = test_id {
format!("_doctest_main_{test_id}")
} else {
"_inner".into()
};
let inner_attr = if test_id.is_some() { "#[allow(non_snake_case)] " } else { "" };
let (main_pre, main_post) = if returns_result {
(
format!(
"fn main() {{ {inner_attr}fn {inner_fn_name}() -> Result<(), impl core::fmt::Debug> {{\n",
),
format!("\n}} {inner_fn_name}().unwrap() }}"),
)
} else if test_id.is_some() {
(
format!("fn main() {{ {inner_attr}fn {inner_fn_name}() {{\n",),
format!("\n}} {inner_fn_name}() }}"),
)
} else {
("fn main() {\n".into(), "\n}".into())
};
// Note on newlines: We insert a line/newline *before*, and *after*
// the doctest and adjust the `line_offset` accordingly.
// In the case of `-C instrument-coverage`, this means that the generated
// inner `main` function spans from the doctest opening codeblock to the
// closing one. For example
// /// ``` <- start of the inner main
// /// <- code under doctest
// /// ``` <- end of the inner main
line_offset += 1;
// add extra 4 spaces for each line to offset the code block
let content = if opts.insert_indent_space {
everything_else
.lines()
.map(|line| format!(" {}", line))
.collect::<Vec<String>>()
.join("\n")
} else {
everything_else.to_string()
};
prog.extend([&main_pre, content.as_str(), &main_post].iter().cloned());
}
debug!("final doctest:\n{prog}");
(prog, line_offset, supports_color)
}
fn check_for_main_and_extern_crate(
crate_name: Option<&str>,
source: String,
edition: Edition,
supports_color: &mut bool,
) -> Result<(bool, bool), FatalError> {
let result = rustc_driver::catch_fatal_errors(|| {
rustc_span::create_session_if_not_set_then(edition, |_| {
use rustc_errors::emitter::{Emitter, HumanEmitter};
use rustc_errors::DiagCtxt;
use rustc_parse::parser::ForceCollect;
use rustc_span::source_map::FilePathMapping;
let filename = FileName::anon_source_code(&source);
// Any errors in parsing should also appear when the doctest is compiled for real, so just
// send all the errors that librustc_ast emits directly into a `Sink` instead of stderr.
let sm = Lrc::new(SourceMap::new(FilePathMapping::empty()));
let fallback_bundle = rustc_errors::fallback_fluent_bundle(
rustc_driver::DEFAULT_LOCALE_RESOURCES.to_vec(),
false,
);
*supports_color =
HumanEmitter::new(stderr_destination(ColorConfig::Auto), fallback_bundle.clone())
.supports_color();
let emitter = HumanEmitter::new(Box::new(io::sink()), fallback_bundle);
// FIXME(misdreavus): pass `-Z treat-err-as-bug` to the doctest parser
let dcx = DiagCtxt::new(Box::new(emitter)).disable_warnings();
let psess = ParseSess::with_dcx(dcx, sm);
let mut found_main = false;
let mut found_extern_crate = crate_name.is_none();
let mut found_macro = false;
let mut parser = match new_parser_from_source_str(&psess, filename, source.clone()) {
Ok(p) => p,
Err(errs) => {
errs.into_iter().for_each(|err| err.cancel());
return (found_main, found_extern_crate, found_macro);
}
};
loop {
match parser.parse_item(ForceCollect::No) {
Ok(Some(item)) => {
if !found_main
&& let ast::ItemKind::Fn(..) = item.kind
&& item.ident.name == sym::main
{
found_main = true;
}
if !found_extern_crate
&& let ast::ItemKind::ExternCrate(original) = item.kind
{
// This code will never be reached if `crate_name` is none because
// `found_extern_crate` is initialized to `true` if it is none.
let crate_name = crate_name.unwrap();
match original {
Some(name) => found_extern_crate = name.as_str() == crate_name,
None => found_extern_crate = item.ident.as_str() == crate_name,
}
}
if !found_macro && let ast::ItemKind::MacCall(..) = item.kind {
found_macro = true;
}
if found_main && found_extern_crate {
break;
}
}
Ok(None) => break,
Err(e) => {
e.cancel();
break;
}
}
// The supplied item is only used for diagnostics,
// which are swallowed here anyway.
parser.maybe_consume_incorrect_semicolon(None);
}
// Reset errors so that they won't be reported as compiler bugs when dropping the
// dcx. Any errors in the tests will be reported when the test file is compiled,
// Note that we still need to cancel the errors above otherwise `Diag` will panic on
// drop.
psess.dcx.reset_err_count();
(found_main, found_extern_crate, found_macro)
})
});
let (already_has_main, already_has_extern_crate, found_macro) = result?;
// If a doctest's `fn main` is being masked by a wrapper macro, the parsing loop above won't
// see it. In that case, run the old text-based scan to see if they at least have a main
// function written inside a macro invocation. See
// https://github.com/rust-lang/rust/issues/56898
let already_has_main = if found_macro && !already_has_main {
source
.lines()
.map(|line| {
let comment = line.find("//");
if let Some(comment_begins) = comment { &line[0..comment_begins] } else { line }
})
.any(|code| code.contains("fn main"))
} else {
already_has_main
};
Ok((already_has_main, already_has_extern_crate))
}
fn check_if_attr_is_complete(source: &str, edition: Edition) -> bool {
if source.is_empty() {
// Empty content so nothing to check in here...
return true;
}
rustc_driver::catch_fatal_errors(|| {
rustc_span::create_session_if_not_set_then(edition, |_| {
use rustc_errors::emitter::HumanEmitter;
use rustc_errors::DiagCtxt;
use rustc_span::source_map::FilePathMapping;
let filename = FileName::anon_source_code(source);
// Any errors in parsing should also appear when the doctest is compiled for real, so just
// send all the errors that librustc_ast emits directly into a `Sink` instead of stderr.
let sm = Lrc::new(SourceMap::new(FilePathMapping::empty()));
let fallback_bundle = rustc_errors::fallback_fluent_bundle(
rustc_driver::DEFAULT_LOCALE_RESOURCES.to_vec(),
false,
);
let emitter = HumanEmitter::new(Box::new(io::sink()), fallback_bundle);
let dcx = DiagCtxt::new(Box::new(emitter)).disable_warnings();
let psess = ParseSess::with_dcx(dcx, sm);
let mut parser = match new_parser_from_source_str(&psess, filename, source.to_owned()) {
Ok(p) => p,
Err(errs) => {
errs.into_iter().for_each(|err| err.cancel());
// If there is an unclosed delimiter, an error will be returned by the
// tokentrees.
return false;
}
};
// If a parsing error happened, it's very likely that the attribute is incomplete.
if let Err(e) = parser.parse_attribute(InnerAttrPolicy::Permitted) {
e.cancel();
return false;
}
true
})
})
.unwrap_or(false)
}
fn partition_source(s: &str, edition: Edition) -> (String, String, String) {
#[derive(Copy, Clone, PartialEq)]
enum PartitionState {
Attrs,
Crates,
Other,
}
let mut state = PartitionState::Attrs;
let mut before = String::new();
let mut crates = String::new();
let mut after = String::new();
let mut mod_attr_pending = String::new();
for line in s.lines() {
let trimline = line.trim();
// FIXME(misdreavus): if a doc comment is placed on an extern crate statement, it will be
// shunted into "everything else"
match state {
PartitionState::Attrs => {
state = if trimline.starts_with("#![") {
if !check_if_attr_is_complete(line, edition) {
mod_attr_pending = line.to_owned();
} else {
mod_attr_pending.clear();
}
PartitionState::Attrs
} else if trimline.chars().all(|c| c.is_whitespace())
|| (trimline.starts_with("//") && !trimline.starts_with("///"))
{
PartitionState::Attrs
} else if trimline.starts_with("extern crate")
|| trimline.starts_with("#[macro_use] extern crate")
{
PartitionState::Crates
} else {
// First we check if the previous attribute was "complete"...
if !mod_attr_pending.is_empty() {
// If not, then we append the new line into the pending attribute to check
// if this time it's complete...
mod_attr_pending.push_str(line);
if !trimline.is_empty()
&& check_if_attr_is_complete(&mod_attr_pending, edition)
{
// If it's complete, then we can clear the pending content.
mod_attr_pending.clear();
}
// In any case, this is considered as `PartitionState::Attrs` so it's
// prepended before rustdoc's inserts.
PartitionState::Attrs
} else {
PartitionState::Other
}
};
}
PartitionState::Crates => {
state = if trimline.starts_with("extern crate")
|| trimline.starts_with("#[macro_use] extern crate")
|| trimline.chars().all(|c| c.is_whitespace())
|| (trimline.starts_with("//") && !trimline.starts_with("///"))
{
PartitionState::Crates
} else {
PartitionState::Other
};
}
PartitionState::Other => {}
}
match state {
PartitionState::Attrs => {
before.push_str(line);
before.push('\n');
}
PartitionState::Crates => {
crates.push_str(line);
crates.push('\n');
}
PartitionState::Other => {
after.push_str(line);
after.push('\n');
}
}
}
debug!("before:\n{before}");
debug!("crates:\n{crates}");
debug!("after:\n{after}");
(before, after, crates)
}

View File

@ -0,0 +1,125 @@
//! Doctest functionality used only for doctests in `.md` Markdown files.
use std::fs::read_to_string;
use rustc_span::FileName;
use tempfile::tempdir;
use super::{
generate_args_file, CreateRunnableDoctests, DoctestVisitor, GlobalTestOptions, ScrapedDoctest,
};
use crate::config::Options;
use crate::html::markdown::{find_testable_code, ErrorCodes, LangString, MdRelLine};
struct MdCollector {
tests: Vec<ScrapedDoctest>,
cur_path: Vec<String>,
filename: FileName,
}
impl DoctestVisitor for MdCollector {
fn visit_test(&mut self, test: String, config: LangString, rel_line: MdRelLine) {
let filename = self.filename.clone();
// First line of Markdown is line 1.
let line = 1 + rel_line.offset();
self.tests.push(ScrapedDoctest {
filename,
line,
logical_path: self.cur_path.clone(),
langstr: config,
text: test,
});
}
fn visit_header(&mut self, name: &str, level: u32) {
// We use these headings as test names, so it's good if
// they're valid identifiers.
let name = name
.chars()
.enumerate()
.map(|(i, c)| {
if (i == 0 && rustc_lexer::is_id_start(c))
|| (i != 0 && rustc_lexer::is_id_continue(c))
{
c
} else {
'_'
}
})
.collect::<String>();
// Here we try to efficiently assemble the header titles into the
// test name in the form of `h1::h2::h3::h4::h5::h6`.
//
// Suppose that originally `self.cur_path` contains `[h1, h2, h3]`...
let level = level as usize;
if level <= self.cur_path.len() {
// ... Consider `level == 2`. All headers in the lower levels
// are irrelevant in this new level. So we should reset
// `self.names` to contain headers until <h2>, and replace that
// slot with the new name: `[h1, name]`.
self.cur_path.truncate(level);
self.cur_path[level - 1] = name;
} else {
// ... On the other hand, consider `level == 5`. This means we
// need to extend `self.names` to contain five headers. We fill
// in the missing level (<h4>) with `_`. Thus `self.names` will
// become `[h1, h2, h3, "_", name]`.
if level - 1 > self.cur_path.len() {
self.cur_path.resize(level - 1, "_".to_owned());
}
self.cur_path.push(name);
}
}
}
/// Runs any tests/code examples in the markdown file `options.input`.
pub(crate) fn test(options: Options) -> Result<(), String> {
use rustc_session::config::Input;
let input_str = match &options.input {
Input::File(path) => {
read_to_string(&path).map_err(|err| format!("{}: {err}", path.display()))?
}
Input::Str { name: _, input } => input.clone(),
};
// Obviously not a real crate name, but close enough for purposes of doctests.
let crate_name = options.input.filestem().to_string();
let temp_dir =
tempdir().map_err(|error| format!("failed to create temporary directory: {error:?}"))?;
let args_file = temp_dir.path().join("rustdoc-cfgs");
generate_args_file(&args_file, &options)?;
let opts = GlobalTestOptions {
crate_name,
no_crate_inject: true,
insert_indent_space: false,
attrs: vec![],
args_file,
};
let mut md_collector = MdCollector {
tests: vec![],
cur_path: vec![],
filename: options
.input
.opt_path()
.map(ToOwned::to_owned)
.map(FileName::from)
.unwrap_or(FileName::Custom("input".to_owned())),
};
let codes = ErrorCodes::from(options.unstable_features.is_nightly_build());
find_testable_code(
&input_str,
&mut md_collector,
codes,
options.enable_per_target_ignores,
None,
);
let mut collector = CreateRunnableDoctests::new(options.clone(), opts);
md_collector.tests.into_iter().for_each(|t| collector.add_test(t));
crate::doctest::run_tests(options.test_args, options.nocapture, collector.tests);
Ok(())
}

View File

@ -0,0 +1,198 @@
//! Doctest functionality used only for doctests in `.rs` source files.
use std::env;
use rustc_data_structures::{fx::FxHashSet, sync::Lrc};
use rustc_hir::def_id::{LocalDefId, CRATE_DEF_ID};
use rustc_hir::{self as hir, intravisit, CRATE_HIR_ID};
use rustc_middle::hir::map::Map;
use rustc_middle::hir::nested_filter;
use rustc_middle::ty::TyCtxt;
use rustc_resolve::rustdoc::span_of_fragments;
use rustc_session::Session;
use rustc_span::source_map::SourceMap;
use rustc_span::{BytePos, FileName, Pos, Span, DUMMY_SP};
use super::{DoctestVisitor, ScrapedDoctest};
use crate::clean::{types::AttributesExt, Attributes};
use crate::html::markdown::{self, ErrorCodes, LangString, MdRelLine};
struct RustCollector {
source_map: Lrc<SourceMap>,
tests: Vec<ScrapedDoctest>,
cur_path: Vec<String>,
position: Span,
}
impl RustCollector {
fn get_filename(&self) -> FileName {
let filename = self.source_map.span_to_filename(self.position);
if let FileName::Real(ref filename) = filename
&& let Ok(cur_dir) = env::current_dir()
&& let Some(local_path) = filename.local_path()
&& let Ok(path) = local_path.strip_prefix(&cur_dir)
{
return path.to_owned().into();
}
filename
}
fn get_base_line(&self) -> usize {
let sp_lo = self.position.lo().to_usize();
let loc = self.source_map.lookup_char_pos(BytePos(sp_lo as u32));
loc.line
}
}
impl DoctestVisitor for RustCollector {
fn visit_test(&mut self, test: String, config: LangString, rel_line: MdRelLine) {
let line = self.get_base_line() + rel_line.offset();
self.tests.push(ScrapedDoctest {
filename: self.get_filename(),
line,
logical_path: self.cur_path.clone(),
langstr: config,
text: test,
});
}
fn visit_header(&mut self, _name: &str, _level: u32) {}
}
pub(super) struct HirCollector<'a, 'tcx> {
sess: &'a Session,
map: Map<'tcx>,
codes: ErrorCodes,
tcx: TyCtxt<'tcx>,
enable_per_target_ignores: bool,
collector: RustCollector,
}
impl<'a, 'tcx> HirCollector<'a, 'tcx> {
pub fn new(
sess: &'a Session,
map: Map<'tcx>,
codes: ErrorCodes,
enable_per_target_ignores: bool,
tcx: TyCtxt<'tcx>,
) -> Self {
let collector = RustCollector {
source_map: sess.psess.clone_source_map(),
cur_path: vec![],
position: DUMMY_SP,
tests: vec![],
};
Self { sess, map, codes, enable_per_target_ignores, tcx, collector }
}
pub fn collect_crate(mut self) -> Vec<ScrapedDoctest> {
let tcx = self.tcx;
self.visit_testable("".to_string(), CRATE_DEF_ID, tcx.hir().span(CRATE_HIR_ID), |this| {
tcx.hir().walk_toplevel_module(this)
});
self.collector.tests
}
}
impl<'a, 'tcx> HirCollector<'a, 'tcx> {
fn visit_testable<F: FnOnce(&mut Self)>(
&mut self,
name: String,
def_id: LocalDefId,
sp: Span,
nested: F,
) {
let ast_attrs = self.tcx.hir().attrs(self.tcx.local_def_id_to_hir_id(def_id));
if let Some(ref cfg) = ast_attrs.cfg(self.tcx, &FxHashSet::default()) {
if !cfg.matches(&self.sess.psess, Some(self.tcx.features())) {
return;
}
}
let has_name = !name.is_empty();
if has_name {
self.collector.cur_path.push(name);
}
// The collapse-docs pass won't combine sugared/raw doc attributes, or included files with
// anything else, this will combine them for us.
let attrs = Attributes::from_ast(ast_attrs);
if let Some(doc) = attrs.opt_doc_value() {
// Use the outermost invocation, so that doctest names come from where the docs were written.
let span = ast_attrs
.iter()
.find(|attr| attr.doc_str().is_some())
.map(|attr| attr.span.ctxt().outer_expn().expansion_cause().unwrap_or(attr.span))
.unwrap_or(DUMMY_SP);
self.collector.position = span;
markdown::find_testable_code(
&doc,
&mut self.collector,
self.codes,
self.enable_per_target_ignores,
Some(&crate::html::markdown::ExtraInfo::new(
self.tcx,
def_id.to_def_id(),
span_of_fragments(&attrs.doc_strings).unwrap_or(sp),
)),
);
}
nested(self);
if has_name {
self.collector.cur_path.pop();
}
}
}
impl<'a, 'tcx> intravisit::Visitor<'tcx> for HirCollector<'a, 'tcx> {
type NestedFilter = nested_filter::All;
fn nested_visit_map(&mut self) -> Self::Map {
self.map
}
fn visit_item(&mut self, item: &'tcx hir::Item<'_>) {
let name = match &item.kind {
hir::ItemKind::Impl(impl_) => {
rustc_hir_pretty::id_to_string(&self.map, impl_.self_ty.hir_id)
}
_ => item.ident.to_string(),
};
self.visit_testable(name, item.owner_id.def_id, item.span, |this| {
intravisit::walk_item(this, item);
});
}
fn visit_trait_item(&mut self, item: &'tcx hir::TraitItem<'_>) {
self.visit_testable(item.ident.to_string(), item.owner_id.def_id, item.span, |this| {
intravisit::walk_trait_item(this, item);
});
}
fn visit_impl_item(&mut self, item: &'tcx hir::ImplItem<'_>) {
self.visit_testable(item.ident.to_string(), item.owner_id.def_id, item.span, |this| {
intravisit::walk_impl_item(this, item);
});
}
fn visit_foreign_item(&mut self, item: &'tcx hir::ForeignItem<'_>) {
self.visit_testable(item.ident.to_string(), item.owner_id.def_id, item.span, |this| {
intravisit::walk_foreign_item(this, item);
});
}
fn visit_variant(&mut self, v: &'tcx hir::Variant<'_>) {
self.visit_testable(v.ident.to_string(), v.def_id, v.span, |this| {
intravisit::walk_variant(this, v);
});
}
fn visit_field_def(&mut self, f: &'tcx hir::FieldDef<'_>) {
self.visit_testable(f.ident.to_string(), f.def_id, f.span, |this| {
intravisit::walk_field_def(this, f);
});
}
}

View File

@ -1,10 +1,23 @@
use std::path::PathBuf;
use super::{make_test, GlobalTestOptions};
use rustc_span::edition::DEFAULT_EDITION;
/// Default [`GlobalTestOptions`] for these unit tests.
fn default_global_opts(crate_name: impl Into<String>) -> GlobalTestOptions {
GlobalTestOptions {
crate_name: crate_name.into(),
no_crate_inject: false,
insert_indent_space: false,
attrs: vec![],
args_file: PathBuf::new(),
}
}
#[test]
fn make_test_basic() {
//basic use: wraps with `fn main`, adds `#![allow(unused)]`
let opts = GlobalTestOptions::default();
let opts = default_global_opts("");
let input = "assert_eq!(2+2, 4);";
let expected = "#![allow(unused)]
fn main() {
@ -19,7 +32,7 @@ assert_eq!(2+2, 4);
fn make_test_crate_name_no_use() {
// If you give a crate name but *don't* use it within the test, it won't bother inserting
// the `extern crate` statement.
let opts = GlobalTestOptions::default();
let opts = default_global_opts("asdf");
let input = "assert_eq!(2+2, 4);";
let expected = "#![allow(unused)]
fn main() {
@ -34,7 +47,7 @@ assert_eq!(2+2, 4);
fn make_test_crate_name() {
// If you give a crate name and use it within the test, it will insert an `extern crate`
// statement before `fn main`.
let opts = GlobalTestOptions::default();
let opts = default_global_opts("asdf");
let input = "use asdf::qwop;
assert_eq!(2+2, 4);";
let expected = "#![allow(unused)]
@ -53,8 +66,7 @@ assert_eq!(2+2, 4);
fn make_test_no_crate_inject() {
// Even if you do use the crate within the test, setting `opts.no_crate_inject` will skip
// adding it anyway.
let opts =
GlobalTestOptions { no_crate_inject: true, attrs: vec![], insert_indent_space: false };
let opts = GlobalTestOptions { no_crate_inject: true, ..default_global_opts("asdf") };
let input = "use asdf::qwop;
assert_eq!(2+2, 4);";
let expected = "#![allow(unused)]
@ -72,7 +84,7 @@ fn make_test_ignore_std() {
// Even if you include a crate name, and use it in the doctest, we still won't include an
// `extern crate` statement if the crate is "std" -- that's included already by the
// compiler!
let opts = GlobalTestOptions::default();
let opts = default_global_opts("std");
let input = "use std::*;
assert_eq!(2+2, 4);";
let expected = "#![allow(unused)]
@ -89,7 +101,7 @@ assert_eq!(2+2, 4);
fn make_test_manual_extern_crate() {
// When you manually include an `extern crate` statement in your doctest, `make_test`
// assumes you've included one for your own crate too.
let opts = GlobalTestOptions::default();
let opts = default_global_opts("asdf");
let input = "extern crate asdf;
use asdf::qwop;
assert_eq!(2+2, 4);";
@ -106,7 +118,7 @@ assert_eq!(2+2, 4);
#[test]
fn make_test_manual_extern_crate_with_macro_use() {
let opts = GlobalTestOptions::default();
let opts = default_global_opts("asdf");
let input = "#[macro_use] extern crate asdf;
use asdf::qwop;
assert_eq!(2+2, 4);";
@ -125,7 +137,7 @@ assert_eq!(2+2, 4);
fn make_test_opts_attrs() {
// If you supplied some doctest attributes with `#![doc(test(attr(...)))]`, it will use
// those instead of the stock `#![allow(unused)]`.
let mut opts = GlobalTestOptions::default();
let mut opts = default_global_opts("asdf");
opts.attrs.push("feature(sick_rad)".to_string());
let input = "use asdf::qwop;
assert_eq!(2+2, 4);";
@ -159,7 +171,7 @@ assert_eq!(2+2, 4);
fn make_test_crate_attrs() {
// Including inner attributes in your doctest will apply them to the whole "crate", pasting
// them outside the generated main function.
let opts = GlobalTestOptions::default();
let opts = default_global_opts("");
let input = "#![feature(sick_rad)]
assert_eq!(2+2, 4);";
let expected = "#![allow(unused)]
@ -175,7 +187,7 @@ assert_eq!(2+2, 4);
#[test]
fn make_test_with_main() {
// Including your own `fn main` wrapper lets the test use it verbatim.
let opts = GlobalTestOptions::default();
let opts = default_global_opts("");
let input = "fn main() {
assert_eq!(2+2, 4);
}";
@ -191,7 +203,7 @@ fn main() {
#[test]
fn make_test_fake_main() {
// ... but putting it in a comment will still provide a wrapper.
let opts = GlobalTestOptions::default();
let opts = default_global_opts("");
let input = "//Ceci n'est pas une `fn main`
assert_eq!(2+2, 4);";
let expected = "#![allow(unused)]
@ -207,7 +219,7 @@ assert_eq!(2+2, 4);
#[test]
fn make_test_dont_insert_main() {
// Even with that, if you set `dont_insert_main`, it won't create the `fn main` wrapper.
let opts = GlobalTestOptions::default();
let opts = default_global_opts("");
let input = "//Ceci n'est pas une `fn main`
assert_eq!(2+2, 4);";
let expected = "#![allow(unused)]
@ -219,8 +231,8 @@ assert_eq!(2+2, 4);"
}
#[test]
fn make_test_issues_21299_33731() {
let opts = GlobalTestOptions::default();
fn make_test_issues_21299() {
let opts = default_global_opts("");
let input = "// fn main
assert_eq!(2+2, 4);";
@ -234,6 +246,11 @@ assert_eq!(2+2, 4);
let (output, len, _) = make_test(input, None, false, &opts, DEFAULT_EDITION, None);
assert_eq!((output, len), (expected, 2));
}
#[test]
fn make_test_issues_33731() {
let opts = default_global_opts("asdf");
let input = "extern crate hella_qwop;
assert_eq!(asdf::foo, 4);";
@ -253,7 +270,7 @@ assert_eq!(asdf::foo, 4);
#[test]
fn make_test_main_in_macro() {
let opts = GlobalTestOptions::default();
let opts = default_global_opts("my_crate");
let input = "#[macro_use] extern crate my_crate;
test_wrapper! {
fn main() {}
@ -272,7 +289,7 @@ test_wrapper! {
#[test]
fn make_test_returns_result() {
// creates an inner function and unwraps it
let opts = GlobalTestOptions::default();
let opts = default_global_opts("");
let input = "use std::io;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
@ -292,7 +309,7 @@ Ok::<(), io:Error>(())
#[test]
fn make_test_named_wrapper() {
// creates an inner function with a specific name
let opts = GlobalTestOptions::default();
let opts = default_global_opts("");
let input = "assert_eq!(2+2, 4);";
let expected = "#![allow(unused)]
fn main() { #[allow(non_snake_case)] fn _doctest_main__some_unique_name() {
@ -307,8 +324,7 @@ assert_eq!(2+2, 4);
#[test]
fn make_test_insert_extra_space() {
// will insert indent spaces in the code block if `insert_indent_space` is true
let opts =
GlobalTestOptions { no_crate_inject: false, attrs: vec![], insert_indent_space: true };
let opts = GlobalTestOptions { insert_indent_space: true, ..default_global_opts("") };
let input = "use std::*;
assert_eq!(2+2, 4);
eprintln!(\"hello anan\");
@ -327,8 +343,7 @@ fn main() {
#[test]
fn make_test_insert_extra_space_fn_main() {
// if input already has a fn main, it should insert a space before it
let opts =
GlobalTestOptions { no_crate_inject: false, attrs: vec![], insert_indent_space: true };
let opts = GlobalTestOptions { insert_indent_space: true, ..default_global_opts("") };
let input = "use std::*;
fn main() {
assert_eq!(2+2, 4);

View File

@ -39,6 +39,7 @@ use std::collections::VecDeque;
use std::fmt::Write;
use std::iter::Peekable;
use std::ops::{ControlFlow, Range};
use std::path::PathBuf;
use std::str::{self, CharIndices};
use std::sync::OnceLock;
@ -287,8 +288,15 @@ impl<'a, I: Iterator<Item = Event<'a>>> Iterator for CodeBlocks<'_, 'a, I> {
.collect::<String>();
let krate = krate.as_ref().map(|s| s.as_str());
let mut opts: GlobalTestOptions = Default::default();
opts.insert_indent_space = true;
// FIXME: separate out the code to make a code block into runnable code
// from the complicated doctest logic
let opts = GlobalTestOptions {
crate_name: krate.map(String::from).unwrap_or_default(),
no_crate_inject: false,
insert_indent_space: true,
attrs: vec![],
args_file: PathBuf::new(),
};
let (test, _, _) = doctest::make_test(&test, krate, false, &opts, edition, None);
let channel = if test.contains("#![feature(") { "&amp;version=nightly" } else { "" };
@ -710,7 +718,29 @@ impl<'a, I: Iterator<Item = SpannedEvent<'a>>> Iterator for Footnotes<'a, I> {
}
}
pub(crate) fn find_testable_code<T: doctest::Tester>(
/// A newtype that represents a relative line number in Markdown.
///
/// In other words, this represents an offset from the first line of Markdown
/// in a doc comment or other source. If the first Markdown line appears on line 32,
/// and the `MdRelLine` is 3, then the absolute line for this one is 35. I.e., it's
/// a zero-based offset.
pub(crate) struct MdRelLine {
offset: usize,
}
impl MdRelLine {
/// See struct docs.
pub(crate) const fn new(offset: usize) -> Self {
Self { offset }
}
/// See struct docs.
pub(crate) const fn offset(self) -> usize {
self.offset
}
}
pub(crate) fn find_testable_code<T: doctest::DoctestVisitor>(
doc: &str,
tests: &mut T,
error_codes: ErrorCodes,
@ -720,7 +750,7 @@ pub(crate) fn find_testable_code<T: doctest::Tester>(
find_codes(doc, tests, error_codes, enable_per_target_ignores, extra_info, false)
}
pub(crate) fn find_codes<T: doctest::Tester>(
pub(crate) fn find_codes<T: doctest::DoctestVisitor>(
doc: &str,
tests: &mut T,
error_codes: ErrorCodes,
@ -772,8 +802,8 @@ pub(crate) fn find_codes<T: doctest::Tester>(
if nb_lines != 0 && !&doc[prev_offset..offset.start].ends_with('\n') {
nb_lines -= 1;
}
let line = tests.get_line() + nb_lines + 1;
tests.add_test(text, block_info, line);
let line = MdRelLine::new(nb_lines);
tests.visit_test(text, block_info, line);
prev_offset = offset.start;
}
Event::Start(Tag::Heading(level, _, _)) => {
@ -781,7 +811,7 @@ pub(crate) fn find_codes<T: doctest::Tester>(
}
Event::Text(ref s) if register_header.is_some() => {
let level = register_header.unwrap();
tests.register_header(s, level);
tests.visit_header(s, level);
register_header = None;
}
_ => {}

View File

@ -728,7 +728,7 @@ fn main_args(
core::new_dcx(options.error_format, None, options.diagnostic_width, &options.unstable_opts);
match (options.should_test, options.markdown_input()) {
(true, Some(_)) => return wrap_return(&diag, markdown::test(options)),
(true, Some(_)) => return wrap_return(&diag, doctest::test_markdown(options)),
(true, None) => return doctest::run(&diag, options),
(false, Some(input)) => {
let input = input.to_owned();

View File

@ -3,18 +3,12 @@ use std::fs::{create_dir_all, read_to_string, File};
use std::io::prelude::*;
use std::path::Path;
use tempfile::tempdir;
use rustc_span::edition::Edition;
use rustc_span::DUMMY_SP;
use crate::config::{Options, RenderOptions};
use crate::doctest::{generate_args_file, Collector, GlobalTestOptions};
use crate::config::RenderOptions;
use crate::html::escape::Escape;
use crate::html::markdown;
use crate::html::markdown::{
find_testable_code, ErrorCodes, HeadingOffset, IdMap, Markdown, MarkdownWithToc,
};
use crate::html::markdown::{ErrorCodes, HeadingOffset, IdMap, Markdown, MarkdownWithToc};
/// Separate any lines at the start of the file that begin with `# ` or `%`.
fn extract_leading_metadata(s: &str) -> (Vec<&str>, &str) {
@ -137,41 +131,3 @@ pub(crate) fn render<P: AsRef<Path>>(
Ok(_) => Ok(()),
}
}
/// Runs any tests/code examples in the markdown file `input`.
pub(crate) fn test(options: Options) -> Result<(), String> {
use rustc_session::config::Input;
let input_str = match &options.input {
Input::File(path) => {
read_to_string(&path).map_err(|err| format!("{}: {err}", path.display()))?
}
Input::Str { name: _, input } => input.clone(),
};
let mut opts = GlobalTestOptions::default();
opts.no_crate_inject = true;
let temp_dir =
tempdir().map_err(|error| format!("failed to create temporary directory: {error:?}"))?;
let file_path = temp_dir.path().join("rustdoc-cfgs");
generate_args_file(&file_path, &options)?;
let mut collector = Collector::new(
options.input.filestem().to_string(),
options.clone(),
true,
opts,
None,
options.input.opt_path().map(ToOwned::to_owned),
options.enable_per_target_ignores,
file_path,
);
collector.set_position(DUMMY_SP);
let codes = ErrorCodes::from(options.unstable_features.is_nightly_build());
// For markdown files, custom code classes will be disabled until the feature is enabled by default.
find_testable_code(&input_str, &mut collector, codes, options.enable_per_target_ignores, None);
crate::doctest::run_tests(options.test_args, options.nocapture, collector.tests);
Ok(())
}

View File

@ -10,7 +10,7 @@ use crate::clean;
use crate::clean::utils::inherits_doc_hidden;
use crate::clean::*;
use crate::core::DocContext;
use crate::html::markdown::{find_testable_code, ErrorCodes, Ignore, LangString};
use crate::html::markdown::{find_testable_code, ErrorCodes, Ignore, LangString, MdRelLine};
use crate::visit::DocVisitor;
use rustc_hir as hir;
use rustc_middle::lint::LintLevelSource;
@ -44,8 +44,8 @@ pub(crate) struct Tests {
pub(crate) found_tests: usize,
}
impl crate::doctest::Tester for Tests {
fn add_test(&mut self, _: String, config: LangString, _: usize) {
impl crate::doctest::DoctestVisitor for Tests {
fn visit_test(&mut self, _: String, config: LangString, _: MdRelLine) {
if config.rust && config.ignore == Ignore::None {
self.found_tests += 1;
}