about summary refs log tree commit diff
diff options
context:
space:
mode:
authorbors <bors@rust-lang.org>2024-08-13 22:10:35 +0000
committerbors <bors@rust-lang.org>2024-08-13 22:10:35 +0000
commite5b3e68abf170556b9d56c6f9028318e53c9f06b (patch)
treee760cc3efced2c64d55200a5f319850936baa75b
parent80eb5a8e910e5185d47cdefe3732d839c78a5e7e (diff)
parent05fb8ff4f5602ebf470f6c870906aef9df16d8db (diff)
downloadrust-e5b3e68abf170556b9d56c6f9028318e53c9f06b.tar.gz
rust-e5b3e68abf170556b9d56c6f9028318e53c9f06b.zip
Auto merge of #126245 - GuillaumeGomez:doctest-improvement-v2, r=t-rustdoc
Greatly speed up doctests by compiling compatible doctests in one file

Fixes #75341.

Take 2 at https://github.com/rust-lang/rust/pull/123974. It should now be much simpler to review since it's based on https://github.com/rust-lang/rust/pull/125798.

I split the changes as much as possible to make it as easy as possible to review (even though it's a huge lot of code changes...).

The following tests are not included into the combined doctests:
 * `compile_fail`
 * If there are crate attributes (`deny`/`allow`/`warn` are ok)
 * have invalid AST
 * `test_harness`
 * no capture
 * `--show-output` (because the output is nicer without the extra code surrounding it)

Everything else is merged into one file. If this one file fails to compile, we go back to the current strategy: compile each doctest separately.

Because of the `edition` codeblock attribute, I couldn't make them all into one file directly so I grouped them by edition, but it should be pretty anecdotic.

In case the users want a specific doctest to be opted-out from this doctest merge, I added the `standalone` codeblock attribute:

```rust
/// ```rust,standalone
/// // This doctest will be run in its own process!
/// ```
```

Now the interesting part, I ran it on a few crates and here are the results (with `cargo test --doc` to only include doctests):

| crate | nb doctests | before this PR | with this PR |
| - | - | - | - |
| sysinfo | 227 | 4.6s | 1.11s |
| geos | 157 | 3.95s | 0.45s |
| core | 4604 | 54.08s | 13.5s (merged: 0.9s, standalone: 12.6s) |
| std | 1147 | 12s | 3.56s (merged: 2.1s, standalone: 1.46s) |

r? `@camelid`

try-job: x86_64-msvc
try-job: aarch64-apple
-rw-r--r--library/core/src/panic/location.rs2
-rw-r--r--library/test/src/types.rs34
-rw-r--r--src/doc/rustdoc/src/write-documentation/documentation-tests.md51
-rw-r--r--src/librustdoc/doctest.rs493
-rw-r--r--src/librustdoc/doctest/make.rs683
-rw-r--r--src/librustdoc/doctest/markdown.rs27
-rw-r--r--src/librustdoc/doctest/runner.rs269
-rw-r--r--src/librustdoc/doctest/rust.rs20
-rw-r--r--src/librustdoc/doctest/tests.rs63
-rw-r--r--src/librustdoc/html/markdown.rs13
-rw-r--r--src/librustdoc/passes/check_doc_test_visibility.rs2
-rw-r--r--tests/run-make/doctests-keep-binaries-2024/rmake.rs67
-rw-r--r--tests/run-make/doctests-keep-binaries-2024/t.rs11
-rw-r--r--tests/run-make/doctests-merge/doctest-2021.stdout7
-rw-r--r--tests/run-make/doctests-merge/doctest-2024.stdout7
-rw-r--r--tests/run-make/doctests-merge/doctest-standalone.rs18
-rw-r--r--tests/run-make/doctests-merge/doctest-standalone.stdout7
-rw-r--r--tests/run-make/doctests-merge/doctest.rs18
-rw-r--r--tests/run-make/doctests-merge/rmake.rs39
-rw-r--r--tests/rustdoc-ui/2024-doctests-checks.rs27
-rw-r--r--tests/rustdoc-ui/2024-doctests-checks.stdout12
-rw-r--r--tests/rustdoc-ui/2024-doctests-crate-attribute.rs22
-rw-r--r--tests/rustdoc-ui/2024-doctests-crate-attribute.stdout12
-rw-r--r--tests/rustdoc-ui/doctest/failed-doctest-should-panic-2021.rs12
-rw-r--r--tests/rustdoc-ui/doctest/failed-doctest-should-panic-2021.stdout14
-rw-r--r--tests/rustdoc-ui/doctest/failed-doctest-should-panic.rs2
-rw-r--r--tests/rustdoc-ui/doctest/failed-doctest-should-panic.stdout4
-rw-r--r--tests/rustdoc-ui/doctest/merged-ignore-no_run.rs14
-rw-r--r--tests/rustdoc-ui/doctest/merged-ignore-no_run.stdout7
-rw-r--r--tests/rustdoc-ui/doctest/wrong-ast-2024.rs20
-rw-r--r--tests/rustdoc-ui/doctest/wrong-ast-2024.stdout41
-rw-r--r--tests/rustdoc-ui/doctest/wrong-ast.rs19
-rw-r--r--tests/rustdoc-ui/doctest/wrong-ast.stdout36
33 files changed, 1635 insertions, 438 deletions
diff --git a/library/core/src/panic/location.rs b/library/core/src/panic/location.rs
index 8c04994ac0f..930edffd505 100644
--- a/library/core/src/panic/location.rs
+++ b/library/core/src/panic/location.rs
@@ -44,7 +44,7 @@ impl<'a> Location<'a> {
     ///
     /// # Examples
     ///
-    /// ```
+    /// ```standalone
     /// use std::panic::Location;
     ///
     /// /// Returns the [`Location`] at which it is called.
diff --git a/library/test/src/types.rs b/library/test/src/types.rs
index c3be3466cb9..802cab989c6 100644
--- a/library/test/src/types.rs
+++ b/library/test/src/types.rs
@@ -250,3 +250,37 @@ pub struct TestDescAndFn {
     pub desc: TestDesc,
     pub testfn: TestFn,
 }
+
+impl TestDescAndFn {
+    pub const fn new_doctest(
+        test_name: &'static str,
+        ignore: bool,
+        source_file: &'static str,
+        start_line: usize,
+        no_run: bool,
+        should_panic: bool,
+        testfn: TestFn,
+    ) -> Self {
+        Self {
+            desc: TestDesc {
+                name: StaticTestName(test_name),
+                ignore,
+                ignore_message: None,
+                source_file,
+                start_line,
+                start_col: 0,
+                end_line: 0,
+                end_col: 0,
+                compile_fail: false,
+                no_run,
+                should_panic: if should_panic {
+                    options::ShouldPanic::Yes
+                } else {
+                    options::ShouldPanic::No
+                },
+                test_type: TestType::DocTest,
+            },
+            testfn,
+        }
+    }
+}
diff --git a/src/doc/rustdoc/src/write-documentation/documentation-tests.md b/src/doc/rustdoc/src/write-documentation/documentation-tests.md
index 9526f33359e..7ed2e9720fe 100644
--- a/src/doc/rustdoc/src/write-documentation/documentation-tests.md
+++ b/src/doc/rustdoc/src/write-documentation/documentation-tests.md
@@ -376,6 +376,57 @@ that the code sample should be compiled using the respective edition of Rust.
 # fn foo() {}
 ```
 
+Starting in the 2024 edition[^edition-note], compatible doctests are merged as one before being
+run. We combine doctests for performance reasons: the slowest part of doctests is to compile them.
+Merging all of them into one file and compiling this new file, then running the doctests is much
+faster. Whether doctests are merged or not, they are run in their own process.
+
+An example of time spent when running doctests:
+
+[sysinfo crate](https://crates.io/crates/sysinfo):
+
+```text
+wall-time duration: 4.59s
+total compile time: 27.067s
+total runtime: 3.969s
+```
+
+Rust core library:
+
+```text
+wall-time duration: 102s
+total compile time: 775.204s
+total runtime: 15.487s
+```
+
+[^edition-note]: This is based on the edition of the whole crate, not the edition of the individual
+test case that may be specified in its code attribute.
+
+In some cases, doctests cannot be merged. For example, if you have:
+
+```rust
+//! ```
+//! let location = std::panic::Location::caller();
+//! assert_eq!(location.line(), 4);
+//! ```
+```
+
+The problem with this code is that, if you change any other doctests, it'll likely break when
+runing `rustdoc --test`, making it tricky to maintain.
+
+This is where the `standalone` attribute comes in: it tells `rustdoc` that a doctest
+should not be merged with the others. So the previous code should use it:
+
+```rust
+//! ```standalone
+//! let location = std::panic::Location::caller();
+//! assert_eq!(location.line(), 4);
+//! ```
+```
+
+In this case, it means that the line information will not change if you add/remove other
+doctests.
+
 ### Custom CSS classes for code blocks
 
 ```rust
diff --git a/src/librustdoc/doctest.rs b/src/librustdoc/doctest.rs
index 08d6a5a52b2..743c1ed507e 100644
--- a/src/librustdoc/doctest.rs
+++ b/src/librustdoc/doctest.rs
@@ -1,5 +1,6 @@
 mod make;
 mod markdown;
+mod runner;
 mod rust;
 
 use std::fs::File;
@@ -10,7 +11,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
 use std::sync::{Arc, Mutex};
 use std::{panic, str};
 
-pub(crate) use make::make_test;
+pub(crate) use make::DocTestBuilder;
 pub(crate) use markdown::test as test_markdown;
 use rustc_ast as ast;
 use rustc_data_structures::fx::{FxHashMap, FxHashSet};
@@ -150,8 +151,6 @@ pub(crate) fn run(dcx: DiagCtxtHandle<'_>, options: RustdocOptions) -> Result<()
         expanded_args: options.expanded_args.clone(),
     };
 
-    let test_args = options.test_args.clone();
-    let nocapture = options.nocapture;
     let externs = options.externs.clone();
     let json_unused_externs = options.json_unused_externs;
 
@@ -164,39 +163,46 @@ pub(crate) fn run(dcx: DiagCtxtHandle<'_>, options: RustdocOptions) -> Result<()
     let args_path = temp_dir.path().join("rustdoc-cfgs");
     crate::wrap_return(dcx, generate_args_file(&args_path, &options))?;
 
-    let (tests, unused_extern_reports, compiling_test_count) =
-        interface::run_compiler(config, |compiler| {
-            compiler.enter(|queries| {
-                let collector = queries.global_ctxt()?.enter(|tcx| {
-                    let crate_name = tcx.crate_name(LOCAL_CRATE).to_string();
-                    let crate_attrs = tcx.hir().attrs(CRATE_HIR_ID);
-                    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(
-                        &compiler.sess,
-                        tcx.hir(),
-                        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));
-
-                    collector
-                });
-                if compiler.sess.dcx().has_errors().is_some() {
-                    FatalError.raise();
-                }
+    let CreateRunnableDocTests {
+        standalone_tests,
+        mergeable_tests,
+        rustdoc_options,
+        opts,
+        unused_extern_reports,
+        compiling_test_count,
+        ..
+    } = interface::run_compiler(config, |compiler| {
+        compiler.enter(|queries| {
+            let collector = queries.global_ctxt()?.enter(|tcx| {
+                let crate_name = tcx.crate_name(LOCAL_CRATE).to_string();
+                let crate_attrs = tcx.hir().attrs(CRATE_HIR_ID);
+                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(
+                    &compiler.sess,
+                    tcx.hir(),
+                    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));
+
+                collector
+            });
+            if compiler.sess.dcx().has_errors().is_some() {
+                FatalError.raise();
+            }
+
+            Ok(collector)
+        })
+    })?;
 
-                let unused_extern_reports = collector.unused_extern_reports.clone();
-                let compiling_test_count = collector.compiling_test_count.load(Ordering::SeqCst);
-                Ok((collector.tests, unused_extern_reports, compiling_test_count))
-            })
-        })?;
+    run_tests(opts, &rustdoc_options, &unused_extern_reports, standalone_tests, mergeable_tests);
 
-    run_tests(test_args, nocapture, tests);
+    let compiling_test_count = compiling_test_count.load(Ordering::SeqCst);
 
     // Collect and warn about unused externs, but only if we've gotten
     // reports for each doctest
@@ -240,16 +246,83 @@ pub(crate) fn run(dcx: DiagCtxtHandle<'_>, options: RustdocOptions) -> Result<()
 }
 
 pub(crate) fn run_tests(
-    mut test_args: Vec<String>,
-    nocapture: bool,
-    mut tests: Vec<test::TestDescAndFn>,
+    opts: GlobalTestOptions,
+    rustdoc_options: &Arc<RustdocOptions>,
+    unused_extern_reports: &Arc<Mutex<Vec<UnusedExterns>>>,
+    mut standalone_tests: Vec<test::TestDescAndFn>,
+    mergeable_tests: FxHashMap<Edition, Vec<(DocTestBuilder, ScrapedDocTest)>>,
 ) {
+    let mut test_args = Vec::with_capacity(rustdoc_options.test_args.len() + 1);
     test_args.insert(0, "rustdoctest".to_string());
-    if nocapture {
+    test_args.extend_from_slice(&rustdoc_options.test_args);
+    if rustdoc_options.nocapture {
         test_args.push("--nocapture".to_string());
     }
-    tests.sort_by(|a, b| a.desc.name.as_slice().cmp(&b.desc.name.as_slice()));
-    test::test_main(&test_args, tests, None);
+
+    let mut nb_errors = 0;
+    let mut ran_edition_tests = 0;
+    let target_str = rustdoc_options.target.to_string();
+
+    for (edition, mut doctests) in mergeable_tests {
+        if doctests.is_empty() {
+            continue;
+        }
+        doctests.sort_by(|(_, a), (_, b)| a.name.cmp(&b.name));
+
+        let mut tests_runner = runner::DocTestRunner::new();
+
+        let rustdoc_test_options = IndividualTestOptions::new(
+            &rustdoc_options,
+            &Some(format!("merged_doctest_{edition}")),
+            PathBuf::from(format!("doctest_{edition}.rs")),
+        );
+
+        for (doctest, scraped_test) in &doctests {
+            tests_runner.add_test(doctest, scraped_test, &target_str);
+        }
+        if let Ok(success) = tests_runner.run_merged_tests(
+            rustdoc_test_options,
+            edition,
+            &opts,
+            &test_args,
+            rustdoc_options,
+        ) {
+            ran_edition_tests += 1;
+            if !success {
+                nb_errors += 1;
+            }
+            continue;
+        }
+        // We failed to compile all compatible tests as one so we push them into the
+        // `standalone_tests` doctests.
+        debug!("Failed to compile compatible doctests for edition {} all at once", edition);
+        for (doctest, scraped_test) in doctests {
+            doctest.generate_unique_doctest(
+                &scraped_test.text,
+                scraped_test.langstr.test_harness,
+                &opts,
+                Some(&opts.crate_name),
+            );
+            standalone_tests.push(generate_test_desc_and_fn(
+                doctest,
+                scraped_test,
+                opts.clone(),
+                Arc::clone(&rustdoc_options),
+                unused_extern_reports.clone(),
+            ));
+        }
+    }
+
+    // We need to call `test_main` even if there is no doctest to run to get the output
+    // `running 0 tests...`.
+    if ran_edition_tests == 0 || !standalone_tests.is_empty() {
+        standalone_tests.sort_by(|a, b| a.desc.name.as_slice().cmp(&b.desc.name.as_slice()));
+        test::test_main(&test_args, standalone_tests, None);
+    }
+    if nb_errors != 0 {
+        // libtest::ERROR_EXIT_CODE is not public but it's the same value.
+        std::process::exit(101);
+    }
 }
 
 // Look for `#![doc(test(no_crate_inject))]`, used by crates in the std facade.
@@ -330,7 +403,7 @@ impl DirState {
 // We could unify this struct the one in rustc but they have different
 // ownership semantics, so doing so would create wasteful allocations.
 #[derive(serde::Serialize, serde::Deserialize)]
-struct UnusedExterns {
+pub(crate) struct UnusedExterns {
     /// Lint level of the unused_crate_dependencies lint
     lint_level: String,
     /// List of unused externs by their names.
@@ -359,22 +432,41 @@ fn wrapped_rustc_command(rustc_wrappers: &[PathBuf], rustc_binary: &Path) -> Com
     command
 }
 
-struct RunnableDoctest {
+/// Information needed for running a bundle of doctests.
+///
+/// This data structure contains the "full" test code, including the wrappers
+/// (if multiple doctests are merged), `main` function,
+/// and everything needed to calculate the compiler's command-line arguments.
+/// The `# ` prefix on boring lines has also been stripped.
+pub(crate) struct RunnableDocTest {
     full_test_code: String,
     full_test_line_offset: usize,
     test_opts: IndividualTestOptions,
     global_opts: GlobalTestOptions,
-    scraped_test: ScrapedDoctest,
+    langstr: LangString,
+    line: usize,
+    edition: Edition,
+    no_run: bool,
+    is_multiple_tests: bool,
 }
 
+impl RunnableDocTest {
+    fn path_for_merged_doctest(&self) -> PathBuf {
+        self.test_opts.outdir.path().join(&format!("doctest_{}.rs", self.edition))
+    }
+}
+
+/// Execute a `RunnableDoctest`.
+///
+/// This is the function that calculates the compiler command line, invokes the compiler, then
+/// invokes the test or tests in a separate executable (if applicable).
 fn run_test(
-    doctest: RunnableDoctest,
+    doctest: RunnableDocTest,
     rustdoc_options: &RustdocOptions,
     supports_color: bool,
     report_unused_externs: impl Fn(UnusedExterns),
 ) -> Result<(), TestFailure> {
-    let scraped_test = &doctest.scraped_test;
-    let langstr = &scraped_test.langstr;
+    let langstr = &doctest.langstr;
     // Make sure we emit well-formed executable names for our target.
     let rust_out = add_exe_suffix("rust_out".to_owned(), &rustdoc_options.target);
     let output_file = doctest.test_opts.outdir.path().join(rust_out);
@@ -391,12 +483,15 @@ fn run_test(
         compiler.arg(format!("--sysroot={}", sysroot.display()));
     }
 
-    compiler.arg("--edition").arg(&scraped_test.edition(rustdoc_options).to_string());
-    compiler.env("UNSTABLE_RUSTDOC_TEST_PATH", &doctest.test_opts.path);
-    compiler.env(
-        "UNSTABLE_RUSTDOC_TEST_LINE",
-        format!("{}", scraped_test.line as isize - doctest.full_test_line_offset as isize),
-    );
+    compiler.arg("--edition").arg(&doctest.edition.to_string());
+    if !doctest.is_multiple_tests {
+        // Setting these environment variables is unneeded if this is a merged doctest.
+        compiler.env("UNSTABLE_RUSTDOC_TEST_PATH", &doctest.test_opts.path);
+        compiler.env(
+            "UNSTABLE_RUSTDOC_TEST_LINE",
+            format!("{}", doctest.line as isize - doctest.full_test_line_offset as isize),
+        );
+    }
     compiler.arg("-o").arg(&output_file);
     if langstr.test_harness {
         compiler.arg("--test");
@@ -408,10 +503,7 @@ fn run_test(
         compiler.arg("-Z").arg("unstable-options");
     }
 
-    if scraped_test.no_run(rustdoc_options)
-        && !langstr.compile_fail
-        && rustdoc_options.persist_doctests.is_none()
-    {
+    if doctest.no_run && !langstr.compile_fail && rustdoc_options.persist_doctests.is_none() {
         // FIXME: why does this code check if it *shouldn't* persist doctests
         //        -- shouldn't it be the negation?
         compiler.arg("--emit=metadata");
@@ -442,18 +534,40 @@ fn run_test(
         }
     }
 
-    compiler.arg("-");
-    compiler.stdin(Stdio::piped());
-    compiler.stderr(Stdio::piped());
+    // If this is a merged doctest, we need to write it into a file instead of using stdin
+    // because if the size of the merged doctests is too big, it'll simply break stdin.
+    if doctest.is_multiple_tests {
+        // It makes the compilation failure much faster if it is for a combined doctest.
+        compiler.arg("--error-format=short");
+        let input_file = doctest.path_for_merged_doctest();
+        if std::fs::write(&input_file, &doctest.full_test_code).is_err() {
+            // If we cannot write this file for any reason, we leave. All combined tests will be
+            // tested as standalone tests.
+            return Err(TestFailure::CompileError);
+        }
+        compiler.arg(input_file);
+        if !rustdoc_options.nocapture {
+            // If `nocapture` is disabled, then we don't display rustc's output when compiling
+            // the merged doctests.
+            compiler.stderr(Stdio::null());
+        }
+    } else {
+        compiler.arg("-");
+        compiler.stdin(Stdio::piped());
+        compiler.stderr(Stdio::piped());
+    }
 
     debug!("compiler invocation for doctest: {compiler:?}");
 
     let mut child = compiler.spawn().expect("Failed to spawn rustc process");
-    {
+    let output = if doctest.is_multiple_tests {
+        let status = child.wait().expect("Failed to wait");
+        process::Output { status, stdout: Vec::new(), stderr: Vec::new() }
+    } else {
         let stdin = child.stdin.as_mut().expect("Failed to open stdin");
         stdin.write_all(doctest.full_test_code.as_bytes()).expect("could write out test sources");
-    }
-    let output = child.wait_with_output().expect("Failed to read stdout");
+        child.wait_with_output().expect("Failed to read stdout")
+    };
 
     struct Bomb<'a>(&'a str);
     impl Drop for Bomb<'_> {
@@ -492,8 +606,7 @@ fn run_test(
                 // We used to check if the output contained "error[{}]: " but since we added the
                 // colored output, we can't anymore because of the color escape characters before
                 // the ":".
-                let missing_codes: Vec<String> = scraped_test
-                    .langstr
+                let missing_codes: Vec<String> = langstr
                     .error_codes
                     .iter()
                     .filter(|err| !out.contains(&format!("error[{err}]")))
@@ -510,7 +623,7 @@ fn run_test(
         }
     }
 
-    if scraped_test.no_run(rustdoc_options) {
+    if doctest.no_run {
         return Ok(());
     }
 
@@ -522,15 +635,19 @@ fn run_test(
         let tool = make_maybe_absolute_path(tool.into());
         cmd = Command::new(tool);
         cmd.args(&rustdoc_options.runtool_args);
-        cmd.arg(output_file);
+        cmd.arg(&output_file);
     } else {
-        cmd = Command::new(output_file);
+        cmd = Command::new(&output_file);
+        if doctest.is_multiple_tests {
+            cmd.arg("*doctest-bin-path");
+            cmd.arg(&output_file);
+        }
     }
     if let Some(run_directory) = &rustdoc_options.test_run_directory {
         cmd.current_dir(run_directory);
     }
 
-    let result = if rustdoc_options.nocapture {
+    let result = if doctest.is_multiple_tests || rustdoc_options.nocapture {
         cmd.status().map(|status| process::Output {
             status,
             stdout: Vec::new(),
@@ -568,15 +685,14 @@ fn make_maybe_absolute_path(path: PathBuf) -> PathBuf {
 }
 struct IndividualTestOptions {
     outdir: DirState,
-    test_id: String,
     path: PathBuf,
 }
 
 impl IndividualTestOptions {
-    fn new(options: &RustdocOptions, test_id: String, test_path: PathBuf) -> Self {
+    fn new(options: &RustdocOptions, test_id: &Option<String>, test_path: PathBuf) -> Self {
         let outdir = if let Some(ref path) = options.persist_doctests {
             let mut path = path.clone();
-            path.push(&test_id);
+            path.push(&test_id.as_deref().unwrap_or_else(|| "<doctest>"));
 
             if let Err(err) = std::fs::create_dir_all(&path) {
                 eprintln!("Couldn't create directory for doctest executables: {err}");
@@ -588,20 +704,45 @@ impl IndividualTestOptions {
             DirState::Temp(get_doctest_dir().expect("rustdoc needs a tempdir"))
         };
 
-        Self { outdir, test_id, path: test_path }
+        Self { outdir, path: test_path }
     }
 }
 
 /// A doctest scraped from the code, ready to be turned into a runnable test.
-struct ScrapedDoctest {
+///
+/// The pipeline goes: [`clean`] AST -> `ScrapedDoctest` -> `RunnableDoctest`.
+/// [`run_merged_tests`] converts a bunch of scraped doctests to a single runnable doctest,
+/// while [`generate_unique_doctest`] does the standalones.
+///
+/// [`clean`]: crate::clean
+/// [`run_merged_tests`]: crate::doctest::runner::DocTestRunner::run_merged_tests
+/// [`generate_unique_doctest`]: crate::doctest::make::DocTestBuilder::generate_unique_doctest
+pub(crate) struct ScrapedDocTest {
     filename: FileName,
     line: usize,
-    logical_path: Vec<String>,
     langstr: LangString,
     text: String,
+    name: String,
 }
 
-impl ScrapedDoctest {
+impl ScrapedDocTest {
+    fn new(
+        filename: FileName,
+        line: usize,
+        logical_path: Vec<String>,
+        langstr: LangString,
+        text: String,
+    ) -> Self {
+        let mut item_path = logical_path.join("::");
+        item_path.retain(|c| c != ' ');
+        if !item_path.is_empty() {
+            item_path.push(' ');
+        }
+        let name =
+            format!("{} - {item_path}(line {line})", filename.prefer_remapped_unconditionaly());
+
+        Self { filename, line, langstr, text, name }
+    }
     fn edition(&self, opts: &RustdocOptions) -> Edition {
         self.langstr.edition.unwrap_or(opts.edition)
     }
@@ -609,67 +750,56 @@ impl ScrapedDoctest {
     fn no_run(&self, opts: &RustdocOptions) -> bool {
         self.langstr.no_run || opts.no_run
     }
+    fn path(&self) -> PathBuf {
+        match &self.filename {
+            FileName::Real(path) => {
+                if let Some(local_path) = path.local_path() {
+                    local_path.to_path_buf()
+                } else {
+                    // Somehow we got the filename from the metadata of another crate, should never happen
+                    unreachable!("doctest from a different crate");
+                }
+            }
+            _ => PathBuf::from(r"doctest.rs"),
+        }
+    }
 }
 
-pub(crate) trait DoctestVisitor {
+pub(crate) trait DocTestVisitor {
     fn visit_test(&mut self, test: String, config: LangString, rel_line: MdRelLine);
     fn visit_header(&mut self, _name: &str, _level: u32) {}
 }
 
-struct CreateRunnableDoctests {
-    tests: Vec<test::TestDescAndFn>,
+struct CreateRunnableDocTests {
+    standalone_tests: Vec<test::TestDescAndFn>,
+    mergeable_tests: FxHashMap<Edition, Vec<(DocTestBuilder, ScrapedDocTest)>>,
 
     rustdoc_options: Arc<RustdocOptions>,
     opts: GlobalTestOptions,
     visited_tests: FxHashMap<(String, usize), usize>,
     unused_extern_reports: Arc<Mutex<Vec<UnusedExterns>>>,
     compiling_test_count: AtomicUsize,
+    can_merge_doctests: bool,
 }
 
-impl CreateRunnableDoctests {
-    fn new(rustdoc_options: RustdocOptions, opts: GlobalTestOptions) -> CreateRunnableDoctests {
-        CreateRunnableDoctests {
-            tests: Vec::new(),
+impl CreateRunnableDocTests {
+    fn new(rustdoc_options: RustdocOptions, opts: GlobalTestOptions) -> CreateRunnableDocTests {
+        let can_merge_doctests = rustdoc_options.edition >= Edition::Edition2024;
+        CreateRunnableDocTests {
+            standalone_tests: Vec::new(),
+            mergeable_tests: FxHashMap::default(),
             rustdoc_options: Arc::new(rustdoc_options),
             opts,
             visited_tests: FxHashMap::default(),
             unused_extern_reports: Default::default(),
             compiling_test_count: AtomicUsize::new(0),
+            can_merge_doctests,
         }
     }
 
-    fn generate_name(&self, filename: &FileName, line: usize, logical_path: &[String]) -> String {
-        let mut item_path = logical_path.join("::");
-        item_path.retain(|c| c != ' ');
-        if !item_path.is_empty() {
-            item_path.push(' ');
-        }
-        format!("{} - {item_path}(line {line})", filename.prefer_remapped_unconditionaly())
-    }
-
-    fn add_test(&mut self, test: ScrapedDoctest) {
-        let name = self.generate_name(&test.filename, test.line, &test.logical_path);
-        let opts = self.opts.clone();
-        let target_str = self.rustdoc_options.target.to_string();
-        let unused_externs = self.unused_extern_reports.clone();
-        if !test.langstr.compile_fail {
-            self.compiling_test_count.fetch_add(1, Ordering::SeqCst);
-        }
-
-        let path = match &test.filename {
-            FileName::Real(path) => {
-                if let Some(local_path) = path.local_path() {
-                    local_path.to_path_buf()
-                } else {
-                    // Somehow we got the filename from the metadata of another crate, should never happen
-                    unreachable!("doctest from a different crate");
-                }
-            }
-            _ => PathBuf::from(r"doctest.rs"),
-        };
-
+    fn add_test(&mut self, scraped_test: ScrapedDocTest) {
         // For example `module/file.rs` would become `module_file_rs`
-        let file = test
+        let file = scraped_test
             .filename
             .prefer_local()
             .to_string_lossy()
@@ -679,75 +809,134 @@ impl CreateRunnableDoctests {
         let test_id = format!(
             "{file}_{line}_{number}",
             file = file,
-            line = test.line,
+            line = scraped_test.line,
             number = {
                 // Increases the current test number, if this file already
                 // exists or it creates a new entry with a test number of 0.
                 self.visited_tests
-                    .entry((file.clone(), test.line))
+                    .entry((file.clone(), scraped_test.line))
                     .and_modify(|v| *v += 1)
                     .or_insert(0)
             },
         );
 
-        let rustdoc_options = self.rustdoc_options.clone();
-        let rustdoc_test_options = IndividualTestOptions::new(&self.rustdoc_options, test_id, path);
-
-        debug!("creating test {name}: {}", test.text);
-        self.tests.push(test::TestDescAndFn {
-            desc: test::TestDesc {
-                name: test::DynTestName(name),
-                ignore: match test.langstr.ignore {
-                    Ignore::All => true,
-                    Ignore::None => false,
-                    Ignore::Some(ref ignores) => ignores.iter().any(|s| target_str.contains(s)),
-                },
-                ignore_message: None,
-                source_file: "",
-                start_line: 0,
-                start_col: 0,
-                end_line: 0,
-                end_col: 0,
-                // compiler failures are test failures
-                should_panic: test::ShouldPanic::No,
-                compile_fail: test.langstr.compile_fail,
-                no_run: test.no_run(&rustdoc_options),
-                test_type: test::TestType::DocTest,
+        let edition = scraped_test.edition(&self.rustdoc_options);
+        let doctest = DocTestBuilder::new(
+            &scraped_test.text,
+            Some(&self.opts.crate_name),
+            edition,
+            self.can_merge_doctests,
+            Some(test_id),
+            Some(&scraped_test.langstr),
+        );
+        let is_standalone = !doctest.can_be_merged
+            || scraped_test.langstr.compile_fail
+            || scraped_test.langstr.test_harness
+            || scraped_test.langstr.standalone
+            || self.rustdoc_options.nocapture
+            || self.rustdoc_options.test_args.iter().any(|arg| arg == "--show-output");
+        if is_standalone {
+            let test_desc = self.generate_test_desc_and_fn(doctest, scraped_test);
+            self.standalone_tests.push(test_desc);
+        } else {
+            self.mergeable_tests.entry(edition).or_default().push((doctest, scraped_test));
+        }
+    }
+
+    fn generate_test_desc_and_fn(
+        &mut self,
+        test: DocTestBuilder,
+        scraped_test: ScrapedDocTest,
+    ) -> test::TestDescAndFn {
+        if !scraped_test.langstr.compile_fail {
+            self.compiling_test_count.fetch_add(1, Ordering::SeqCst);
+        }
+
+        generate_test_desc_and_fn(
+            test,
+            scraped_test,
+            self.opts.clone(),
+            Arc::clone(&self.rustdoc_options),
+            self.unused_extern_reports.clone(),
+        )
+    }
+}
+
+fn generate_test_desc_and_fn(
+    test: DocTestBuilder,
+    scraped_test: ScrapedDocTest,
+    opts: GlobalTestOptions,
+    rustdoc_options: Arc<RustdocOptions>,
+    unused_externs: Arc<Mutex<Vec<UnusedExterns>>>,
+) -> test::TestDescAndFn {
+    let target_str = rustdoc_options.target.to_string();
+    let rustdoc_test_options =
+        IndividualTestOptions::new(&rustdoc_options, &test.test_id, scraped_test.path());
+
+    debug!("creating test {}: {}", scraped_test.name, scraped_test.text);
+    test::TestDescAndFn {
+        desc: test::TestDesc {
+            name: test::DynTestName(scraped_test.name.clone()),
+            ignore: match scraped_test.langstr.ignore {
+                Ignore::All => true,
+                Ignore::None => false,
+                Ignore::Some(ref ignores) => ignores.iter().any(|s| target_str.contains(s)),
             },
-            testfn: test::DynTestFn(Box::new(move || {
-                doctest_run_fn(rustdoc_test_options, opts, test, rustdoc_options, unused_externs)
-            })),
-        });
+            ignore_message: None,
+            source_file: "",
+            start_line: 0,
+            start_col: 0,
+            end_line: 0,
+            end_col: 0,
+            // compiler failures are test failures
+            should_panic: test::ShouldPanic::No,
+            compile_fail: scraped_test.langstr.compile_fail,
+            no_run: scraped_test.no_run(&rustdoc_options),
+            test_type: test::TestType::DocTest,
+        },
+        testfn: test::DynTestFn(Box::new(move || {
+            doctest_run_fn(
+                rustdoc_test_options,
+                opts,
+                test,
+                scraped_test,
+                rustdoc_options,
+                unused_externs,
+            )
+        })),
     }
 }
 
 fn doctest_run_fn(
     test_opts: IndividualTestOptions,
     global_opts: GlobalTestOptions,
-    scraped_test: ScrapedDoctest,
+    doctest: DocTestBuilder,
+    scraped_test: ScrapedDocTest,
     rustdoc_options: Arc<RustdocOptions>,
     unused_externs: Arc<Mutex<Vec<UnusedExterns>>>,
 ) -> Result<(), String> {
     let report_unused_externs = |uext| {
         unused_externs.lock().unwrap().push(uext);
     };
-    let edition = scraped_test.edition(&rustdoc_options);
-    let (full_test_code, full_test_line_offset, supports_color) = make_test(
+    let (full_test_code, full_test_line_offset) = doctest.generate_unique_doctest(
         &scraped_test.text,
-        Some(&global_opts.crate_name),
         scraped_test.langstr.test_harness,
         &global_opts,
-        edition,
-        Some(&test_opts.test_id),
+        Some(&global_opts.crate_name),
     );
-    let runnable_test = RunnableDoctest {
+    let runnable_test = RunnableDocTest {
         full_test_code,
         full_test_line_offset,
         test_opts,
         global_opts,
-        scraped_test,
+        langstr: scraped_test.langstr.clone(),
+        line: scraped_test.line,
+        edition: scraped_test.edition(&rustdoc_options),
+        no_run: scraped_test.no_run(&rustdoc_options),
+        is_multiple_tests: false,
     };
-    let res = run_test(runnable_test, &rustdoc_options, supports_color, report_unused_externs);
+    let res =
+        run_test(runnable_test, &rustdoc_options, doctest.supports_color, report_unused_externs);
 
     if let Err(err) = res {
         match err {
@@ -804,7 +993,7 @@ fn doctest_run_fn(
 }
 
 #[cfg(test)] // used in tests
-impl DoctestVisitor for Vec<usize> {
+impl DocTestVisitor for Vec<usize> {
     fn visit_test(&mut self, _test: String, _config: LangString, rel_line: MdRelLine) {
         self.push(1 + rel_line.offset());
     }
diff --git a/src/librustdoc/doctest/make.rs b/src/librustdoc/doctest/make.rs
index 74833c11362..aed079e5887 100644
--- a/src/librustdoc/doctest/make.rs
+++ b/src/librustdoc/doctest/make.rs
@@ -16,250 +16,428 @@ use rustc_span::symbol::sym;
 use rustc_span::FileName;
 
 use super::GlobalTestOptions;
+use crate::html::markdown::LangString;
+
+/// This struct contains information about the doctest itself which is then used to generate
+/// doctest source code appropriately.
+pub(crate) struct DocTestBuilder {
+    pub(crate) supports_color: bool,
+    pub(crate) already_has_extern_crate: bool,
+    pub(crate) has_main_fn: bool,
+    pub(crate) crate_attrs: String,
+    /// If this is a merged doctest, it will be put into `everything_else`, otherwise it will
+    /// put into `crate_attrs`.
+    pub(crate) maybe_crate_attrs: String,
+    pub(crate) crates: String,
+    pub(crate) everything_else: String,
+    pub(crate) test_id: Option<String>,
+    pub(crate) failed_ast: bool,
+    pub(crate) can_be_merged: bool,
+}
 
-/// 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;
+impl DocTestBuilder {
+    pub(crate) fn new(
+        source: &str,
+        crate_name: Option<&str>,
+        edition: Edition,
+        can_merge_doctests: bool,
+        // If `test_id` is `None`, it means we're generating code for a code example "run" link.
+        test_id: Option<String>,
+        lang_str: Option<&LangString>,
+    ) -> Self {
+        let can_merge_doctests = can_merge_doctests
+            && lang_str.is_some_and(|lang_str| {
+                !lang_str.compile_fail && !lang_str.test_harness && !lang_str.standalone
+            });
+
+        let SourceInfo { crate_attrs, maybe_crate_attrs, crates, everything_else } =
+            partition_source(source, edition);
+
+        // Uses librustc_ast to parse the doctest and find if there's a main fn and the extern
+        // crate already is included.
+        let Ok((
+            ParseSourceInfo {
+                has_main_fn,
+                found_extern_crate,
+                supports_color,
+                has_global_allocator,
+                has_macro_def,
+                ..
+            },
+            failed_ast,
+        )) = check_for_main_and_extern_crate(
+            crate_name,
+            source,
+            &everything_else,
+            &crates,
+            edition,
+            can_merge_doctests,
+        )
+        else {
+            // If the parser panicked due to a fatal error, pass the test code through unchanged.
+            // The error will be reported during compilation.
+            return Self {
+                supports_color: false,
+                has_main_fn: false,
+                crate_attrs,
+                maybe_crate_attrs,
+                crates,
+                everything_else,
+                already_has_extern_crate: false,
+                test_id,
+                failed_ast: true,
+                can_be_merged: false,
+            };
+        };
+        // If the AST returned an error, we don't want this doctest to be merged with the
+        // others. Same if it contains `#[feature]` or `#[no_std]`.
+        let can_be_merged = can_merge_doctests
+            && !failed_ast
+            && !has_global_allocator
+            && crate_attrs.is_empty()
+            // If this is a merged doctest and a defined macro uses `$crate`, then the path will
+            // not work, so better not put it into merged doctests.
+            && !(has_macro_def && everything_else.contains("$crate"));
+        Self {
+            supports_color,
+            has_main_fn,
+            crate_attrs,
+            maybe_crate_attrs,
+            crates,
+            everything_else,
+            already_has_extern_crate: found_extern_crate,
+            test_id,
+            failed_ast: false,
+            can_be_merged,
+        }
     }
 
-    // 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;
-    }
+    /// Transforms a test into code that can be compiled into a Rust binary, and returns the number of
+    /// lines before the test code begins.
+    pub(crate) fn generate_unique_doctest(
+        &self,
+        test_code: &str,
+        dont_insert_main: bool,
+        opts: &GlobalTestOptions,
+        crate_name: Option<&str>,
+    ) -> (String, usize) {
+        if self.failed_ast {
+            // If the AST failed to compile, no need to go generate a complete doctest, the error
+            // will be better this way.
+            return (test_code.to_string(), 0);
+        }
+        let mut line_offset = 0;
+        let mut prog = String::new();
+        let everything_else = self.everything_else.trim();
+        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;
+        }
 
-    // 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);
-    };
+        // 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;
+        }
 
-    // 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 {
+        // Now push any outer attributes from the example, assuming they
+        // are intended to be crate attributes.
+        prog.push_str(&self.crate_attrs);
+        prog.push_str(&self.maybe_crate_attrs);
+        prog.push_str(&self.crates);
+
+        // Don't inject `extern crate std` because it's already injected by the
+        // compiler.
+        if !self.already_has_extern_crate &&
+            !opts.no_crate_inject &&
+            let Some(crate_name) = crate_name &&
+            crate_name != "std" &&
             // 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;
-            }
+            test_code.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}")
+        // 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);
         } 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());
-    }
+            let returns_result = everything_else.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 {
+                format!("_doctest_main_{test_id}")
+            } else {
+                "_inner".into()
+            };
+            let inner_attr = if self.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 self.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;
+
+            prog.push_str(&main_pre);
+
+            // add extra 4 spaces for each line to offset the code block
+            if opts.insert_indent_space {
+                prog.push_str(
+                    &everything_else
+                        .lines()
+                        .map(|line| format!("    {}", line))
+                        .collect::<Vec<String>>()
+                        .join("\n"),
+                );
+            } else {
+                prog.push_str(everything_else);
+            };
+            prog.push_str(&main_post);
+        }
 
-    debug!("final doctest:\n{prog}");
+        debug!("final doctest:\n{prog}");
 
-    (prog, line_offset, supports_color)
+        (prog, line_offset)
+    }
 }
 
-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();
+#[derive(PartialEq, Eq, Debug)]
+enum ParsingResult {
+    Failed,
+    AstError,
+    Ok,
+}
 
-            let emitter = HumanEmitter::new(Box::new(io::sink()), fallback_bundle);
+fn cancel_error_count(psess: &ParseSess) {
+    // 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();
+}
 
-            // 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);
+fn parse_source(
+    source: String,
+    info: &mut ParseSourceInfo,
+    crate_name: &Option<&str>,
+) -> ParsingResult {
+    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,
+    );
+    info.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 parser = match new_parser_from_source_str(&psess, filename, source) {
+        Ok(p) => p,
+        Err(errs) => {
+            errs.into_iter().for_each(|err| err.cancel());
+            cancel_error_count(&psess);
+            return ParsingResult::Failed;
+        }
+    };
+    let mut parsing_result = ParsingResult::Ok;
+
+    // Recurse through functions body. It is necessary because the doctest source code is
+    // wrapped in a function to limit the number of AST errors. If we don't recurse into
+    // functions, we would thing all top-level items (so basically nothing).
+    fn check_item(item: &ast::Item, info: &mut ParseSourceInfo, crate_name: &Option<&str>) {
+        if !info.has_global_allocator
+            && item.attrs.iter().any(|attr| attr.name_or_empty() == sym::global_allocator)
+        {
+            info.has_global_allocator = true;
+        }
+        match item.kind {
+            ast::ItemKind::Fn(ref fn_item) if !info.has_main_fn => {
+                if item.ident.name == sym::main {
+                    info.has_main_fn = true;
+                }
+                if let Some(ref body) = fn_item.body {
+                    for stmt in &body.stmts {
+                        match stmt.kind {
+                            ast::StmtKind::Item(ref item) => check_item(item, info, crate_name),
+                            ast::StmtKind::MacCall(..) => info.found_macro = true,
+                            _ => {}
+                        }
+                    }
+                }
+            }
+            ast::ItemKind::ExternCrate(original) => {
+                if !info.found_extern_crate
+                    && let Some(ref crate_name) = crate_name
+                {
+                    info.found_extern_crate = match original {
+                        Some(name) => name.as_str() == *crate_name,
+                        None => item.ident.as_str() == *crate_name,
+                    };
+                }
+            }
+            ast::ItemKind::MacCall(..) => info.found_macro = true,
+            ast::ItemKind::MacroDef(..) => info.has_macro_def = true,
+            _ => {}
+        }
+    }
 
-            let mut found_main = false;
-            let mut found_extern_crate = crate_name.is_none();
-            let mut found_macro = false;
+    loop {
+        match parser.parse_item(ForceCollect::No) {
+            Ok(Some(item)) => {
+                check_item(&item, info, crate_name);
 
-            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);
+                if info.has_main_fn && info.found_extern_crate {
+                    break;
                 }
-            };
-
-            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;
-                        }
+            }
+            Ok(None) => break,
+            Err(e) => {
+                parsing_result = ParsingResult::AstError;
+                e.cancel();
+                break;
+            }
+        }
 
-                        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,
-                            }
-                        }
+        // The supplied item is only used for diagnostics,
+        // which are swallowed here anyway.
+        parser.maybe_consume_incorrect_semicolon(None);
+    }
 
-                        if !found_macro && let ast::ItemKind::MacCall(..) = item.kind {
-                            found_macro = true;
-                        }
+    cancel_error_count(&psess);
+    parsing_result
+}
 
-                        if found_main && found_extern_crate {
-                            break;
-                        }
-                    }
-                    Ok(None) => break,
-                    Err(e) => {
-                        e.cancel();
-                        break;
-                    }
-                }
+#[derive(Default)]
+struct ParseSourceInfo {
+    has_main_fn: bool,
+    found_extern_crate: bool,
+    found_macro: bool,
+    supports_color: bool,
+    has_global_allocator: bool,
+    has_macro_def: bool,
+}
 
-                // The supplied item is only used for diagnostics,
-                // which are swallowed here anyway.
-                parser.maybe_consume_incorrect_semicolon(None);
+fn check_for_main_and_extern_crate(
+    crate_name: Option<&str>,
+    original_source_code: &str,
+    everything_else: &str,
+    crates: &str,
+    edition: Edition,
+    can_merge_doctests: bool,
+) -> Result<(ParseSourceInfo, bool), FatalError> {
+    let result = rustc_driver::catch_fatal_errors(|| {
+        rustc_span::create_session_if_not_set_then(edition, |_| {
+            let mut info =
+                ParseSourceInfo { found_extern_crate: crate_name.is_none(), ..Default::default() };
+
+            let mut parsing_result =
+                parse_source(format!("{crates}{everything_else}"), &mut info, &crate_name);
+            // No need to double-check this if the "merged doctests" feature isn't enabled (so
+            // before the 2024 edition).
+            if can_merge_doctests && parsing_result != ParsingResult::Ok {
+                // If we found an AST error, we want to ensure it's because of an expression being
+                // used outside of a function.
+                //
+                // To do so, we wrap in a function in order to make sure that the doctest AST is
+                // correct. For example, if your doctest is `foo::bar()`, if we don't wrap it in a
+                // block, it would emit an AST error, which would be problematic for us since we
+                // want to filter out such errors which aren't "real" errors.
+                //
+                // The end goal is to be able to merge as many doctests as possible as one for much
+                // faster doctests run time.
+                parsing_result = parse_source(
+                    format!("{crates}\nfn __doctest_wrap(){{{everything_else}\n}}"),
+                    &mut info,
+                    &crate_name,
+                );
             }
 
-            // 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)
+            (info, parsing_result)
         })
     });
-    let (already_has_main, already_has_extern_crate, found_macro) = result?;
+    let (mut info, parsing_result) = match result {
+        Err(..) | Ok((_, ParsingResult::Failed)) => return Err(FatalError),
+        Ok((info, parsing_result)) => (info, parsing_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
+    if info.found_macro
+        && !info.has_main_fn
+        && original_source_code
             .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
-    };
+    {
+        info.has_main_fn = true;
+    }
 
-    Ok((already_has_main, already_has_extern_crate))
+    Ok((info, parsing_result != ParsingResult::Ok))
 }
 
-fn check_if_attr_is_complete(source: &str, edition: Edition) -> bool {
+enum AttrKind {
+    CrateAttr,
+    Attr,
+}
+
+/// Returns `Some` if the attribute is complete and `Some(true)` if it is an attribute that can be
+/// placed at the crate root.
+fn check_if_attr_is_complete(source: &str, edition: Edition) -> Option<AttrKind> {
     if source.is_empty() {
         // Empty content so nothing to check in here...
-        return true;
+        return None;
     }
+    let not_crate_attrs = [sym::forbid, sym::allow, sym::warn, sym::deny];
+
     rustc_driver::catch_fatal_errors(|| {
         rustc_span::create_session_if_not_set_then(edition, |_| {
             use rustc_errors::emitter::HumanEmitter;
@@ -285,32 +463,75 @@ fn check_if_attr_is_complete(source: &str, edition: Edition) -> bool {
                     errs.into_iter().for_each(|err| err.cancel());
                     // If there is an unclosed delimiter, an error will be returned by the
                     // tokentrees.
-                    return false;
+                    return None;
                 }
             };
             // 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
+            let ret = match parser.parse_attribute(InnerAttrPolicy::Permitted) {
+                Ok(attr) => {
+                    let attr_name = attr.name_or_empty();
+
+                    if not_crate_attrs.contains(&attr_name) {
+                        // There is one exception to these attributes:
+                        // `#![allow(internal_features)]`. If this attribute is used, we need to
+                        // consider it only as a crate-level attribute.
+                        if attr_name == sym::allow
+                            && let Some(list) = attr.meta_item_list()
+                            && list.iter().any(|sub_attr| {
+                                sub_attr.name_or_empty().as_str() == "internal_features"
+                            })
+                        {
+                            Some(AttrKind::CrateAttr)
+                        } else {
+                            Some(AttrKind::Attr)
+                        }
+                    } else {
+                        Some(AttrKind::CrateAttr)
+                    }
+                }
+                Err(e) => {
+                    e.cancel();
+                    None
+                }
+            };
+            ret
         })
     })
-    .unwrap_or(false)
+    .unwrap_or(None)
+}
+
+fn handle_attr(mod_attr_pending: &mut String, source_info: &mut SourceInfo, edition: Edition) {
+    if let Some(attr_kind) = check_if_attr_is_complete(mod_attr_pending, edition) {
+        let push_to = match attr_kind {
+            AttrKind::CrateAttr => &mut source_info.crate_attrs,
+            AttrKind::Attr => &mut source_info.maybe_crate_attrs,
+        };
+        push_to.push_str(mod_attr_pending);
+        push_to.push('\n');
+        // If it's complete, then we can clear the pending content.
+        mod_attr_pending.clear();
+    } else if mod_attr_pending.ends_with('\\') {
+        mod_attr_pending.push('n');
+    }
+}
+
+#[derive(Default)]
+struct SourceInfo {
+    crate_attrs: String,
+    maybe_crate_attrs: String,
+    crates: String,
+    everything_else: String,
 }
 
-fn partition_source(s: &str, edition: Edition) -> (String, String, String) {
+fn partition_source(s: &str, edition: Edition) -> SourceInfo {
     #[derive(Copy, Clone, PartialEq)]
     enum PartitionState {
         Attrs,
         Crates,
         Other,
     }
+    let mut source_info = SourceInfo::default();
     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() {
@@ -321,12 +542,9 @@ fn partition_source(s: &str, edition: Edition) -> (String, String, String) {
         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
+                    mod_attr_pending = line.to_owned();
+                    handle_attr(&mut mod_attr_pending, &mut source_info, edition);
+                    continue;
                 } else if trimline.chars().all(|c| c.is_whitespace())
                     || (trimline.starts_with("//") && !trimline.starts_with("///"))
                 {
@@ -341,15 +559,10 @@ fn partition_source(s: &str, edition: Edition) -> (String, String, String) {
                         // 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();
+                        if !trimline.is_empty() {
+                            handle_attr(&mut mod_attr_pending, &mut source_info, edition);
                         }
-                        // In any case, this is considered as `PartitionState::Attrs` so it's
-                        // prepended before rustdoc's inserts.
-                        PartitionState::Attrs
+                        continue;
                     } else {
                         PartitionState::Other
                     }
@@ -371,23 +584,25 @@ fn partition_source(s: &str, edition: Edition) -> (String, String, String) {
 
         match state {
             PartitionState::Attrs => {
-                before.push_str(line);
-                before.push('\n');
+                source_info.crate_attrs.push_str(line);
+                source_info.crate_attrs.push('\n');
             }
             PartitionState::Crates => {
-                crates.push_str(line);
-                crates.push('\n');
+                source_info.crates.push_str(line);
+                source_info.crates.push('\n');
             }
             PartitionState::Other => {
-                after.push_str(line);
-                after.push('\n');
+                source_info.everything_else.push_str(line);
+                source_info.everything_else.push('\n');
             }
         }
     }
 
-    debug!("before:\n{before}");
-    debug!("crates:\n{crates}");
-    debug!("after:\n{after}");
+    source_info.everything_else = source_info.everything_else.trim().to_string();
+
+    debug!("crate_attrs:\n{}{}", source_info.crate_attrs, source_info.maybe_crate_attrs);
+    debug!("crates:\n{}", source_info.crates);
+    debug!("after:\n{}", source_info.everything_else);
 
-    (before, after, crates)
+    source_info
 }
diff --git a/src/librustdoc/doctest/markdown.rs b/src/librustdoc/doctest/markdown.rs
index b8ab7adb36e..4806d865589 100644
--- a/src/librustdoc/doctest/markdown.rs
+++ b/src/librustdoc/doctest/markdown.rs
@@ -1,34 +1,29 @@
 //! Doctest functionality used only for doctests in `.md` Markdown files.
 
 use std::fs::read_to_string;
+use std::sync::{Arc, Mutex};
 
 use rustc_span::FileName;
 use tempfile::tempdir;
 
 use super::{
-    generate_args_file, CreateRunnableDoctests, DoctestVisitor, GlobalTestOptions, ScrapedDoctest,
+    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>,
+    tests: Vec<ScrapedDocTest>,
     cur_path: Vec<String>,
     filename: FileName,
 }
 
-impl DoctestVisitor for MdCollector {
+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,
-        });
+        self.tests.push(ScrapedDocTest::new(filename, line, self.cur_path.clone(), config, test));
     }
 
     fn visit_header(&mut self, name: &str, level: u32) {
@@ -118,8 +113,16 @@ pub(crate) fn test(options: Options) -> Result<(), String> {
         None,
     );
 
-    let mut collector = CreateRunnableDoctests::new(options.clone(), opts);
+    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);
+    let CreateRunnableDocTests { opts, rustdoc_options, standalone_tests, mergeable_tests, .. } =
+        collector;
+    crate::doctest::run_tests(
+        opts,
+        &rustdoc_options,
+        &Arc::new(Mutex::new(Vec::new())),
+        standalone_tests,
+        mergeable_tests,
+    );
     Ok(())
 }
diff --git a/src/librustdoc/doctest/runner.rs b/src/librustdoc/doctest/runner.rs
new file mode 100644
index 00000000000..b91333e5f81
--- /dev/null
+++ b/src/librustdoc/doctest/runner.rs
@@ -0,0 +1,269 @@
+use std::fmt::Write;
+
+use rustc_data_structures::fx::FxHashSet;
+use rustc_span::edition::Edition;
+
+use crate::doctest::{
+    run_test, DocTestBuilder, GlobalTestOptions, IndividualTestOptions, RunnableDocTest,
+    RustdocOptions, ScrapedDocTest, TestFailure, UnusedExterns,
+};
+use crate::html::markdown::{Ignore, LangString};
+
+/// Convenient type to merge compatible doctests into one.
+pub(crate) struct DocTestRunner {
+    crate_attrs: FxHashSet<String>,
+    ids: String,
+    output: String,
+    supports_color: bool,
+    nb_tests: usize,
+}
+
+impl DocTestRunner {
+    pub(crate) fn new() -> Self {
+        Self {
+            crate_attrs: FxHashSet::default(),
+            ids: String::new(),
+            output: String::new(),
+            supports_color: true,
+            nb_tests: 0,
+        }
+    }
+
+    pub(crate) fn add_test(
+        &mut self,
+        doctest: &DocTestBuilder,
+        scraped_test: &ScrapedDocTest,
+        target_str: &str,
+    ) {
+        let ignore = match scraped_test.langstr.ignore {
+            Ignore::All => true,
+            Ignore::None => false,
+            Ignore::Some(ref ignores) => ignores.iter().any(|s| target_str.contains(s)),
+        };
+        if !ignore {
+            for line in doctest.crate_attrs.split('\n') {
+                self.crate_attrs.insert(line.to_string());
+            }
+        }
+        if !self.ids.is_empty() {
+            self.ids.push(',');
+        }
+        self.ids.push_str(&format!(
+            "{}::TEST",
+            generate_mergeable_doctest(
+                doctest,
+                scraped_test,
+                ignore,
+                self.nb_tests,
+                &mut self.output
+            ),
+        ));
+        self.supports_color &= doctest.supports_color;
+        self.nb_tests += 1;
+    }
+
+    pub(crate) fn run_merged_tests(
+        &mut self,
+        test_options: IndividualTestOptions,
+        edition: Edition,
+        opts: &GlobalTestOptions,
+        test_args: &[String],
+        rustdoc_options: &RustdocOptions,
+    ) -> Result<bool, ()> {
+        let mut code = "\
+#![allow(unused_extern_crates)]
+#![allow(internal_features)]
+#![feature(test)]
+#![feature(rustc_attrs)]
+#![feature(coverage_attribute)]
+"
+        .to_string();
+
+        for crate_attr in &self.crate_attrs {
+            code.push_str(crate_attr);
+            code.push('\n');
+        }
+
+        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.
+            code.push_str("#![allow(unused)]\n");
+        }
+
+        // Next, any attributes that came from the crate root via #![doc(test(attr(...)))].
+        for attr in &opts.attrs {
+            code.push_str(&format!("#![{attr}]\n"));
+        }
+
+        code.push_str("extern crate test;\n");
+
+        let test_args =
+            test_args.iter().map(|arg| format!("{arg:?}.to_string(),")).collect::<String>();
+        write!(
+            code,
+            "\
+{output}
+
+mod __doctest_mod {{
+    use std::sync::OnceLock;
+    use std::path::PathBuf;
+
+    pub static BINARY_PATH: OnceLock<PathBuf> = OnceLock::new();
+    pub const RUN_OPTION: &str = \"*doctest-inner-test\";
+    pub const BIN_OPTION: &str = \"*doctest-bin-path\";
+
+    #[allow(unused)]
+    pub fn doctest_path() -> Option<&'static PathBuf> {{
+        self::BINARY_PATH.get()
+    }}
+
+    #[allow(unused)]
+    pub fn doctest_runner(bin: &std::path::Path, test_nb: usize) -> Result<(), String> {{
+        let out = std::process::Command::new(bin)
+            .arg(self::RUN_OPTION)
+            .arg(test_nb.to_string())
+            .output()
+            .expect(\"failed to run command\");
+        if !out.status.success() {{
+            Err(String::from_utf8_lossy(&out.stderr).to_string())
+        }} else {{
+            Ok(())
+        }}
+    }}
+}}
+
+#[rustc_main]
+#[coverage(off)]
+fn main() -> std::process::ExitCode {{
+const TESTS: [test::TestDescAndFn; {nb_tests}] = [{ids}];
+let bin_marker = std::ffi::OsStr::new(__doctest_mod::BIN_OPTION);
+let test_marker = std::ffi::OsStr::new(__doctest_mod::RUN_OPTION);
+let test_args = &[{test_args}];
+
+let mut args = std::env::args_os().skip(1);
+while let Some(arg) = args.next() {{
+    if arg == bin_marker {{
+        let Some(binary) = args.next() else {{
+            panic!(\"missing argument after `{{}}`\", __doctest_mod::BIN_OPTION);
+        }};
+        if crate::__doctest_mod::BINARY_PATH.set(binary.into()).is_err() {{
+            panic!(\"`{{}}` option was used more than once\", bin_marker.to_string_lossy());
+        }}
+        return std::process::Termination::report(test::test_main(test_args, Vec::from(TESTS), None));
+    }} else if arg == test_marker {{
+        let Some(nb_test) = args.next() else {{
+            panic!(\"missing argument after `{{}}`\", __doctest_mod::RUN_OPTION);
+        }};
+        if let Some(nb_test) = nb_test.to_str().and_then(|nb| nb.parse::<usize>().ok()) {{
+            if let Some(test) = TESTS.get(nb_test) {{
+                if let test::StaticTestFn(f) = test.testfn {{
+                    return std::process::Termination::report(f());
+                }}
+            }}
+        }}
+        panic!(\"Unexpected value after `{{}}`\", __doctest_mod::RUN_OPTION);
+    }}
+}}
+
+eprintln!(\"WARNING: No argument provided so doctests will be run in the same process\");
+std::process::Termination::report(test::test_main(test_args, Vec::from(TESTS), None))
+}}",
+            nb_tests = self.nb_tests,
+            output = self.output,
+            ids = self.ids,
+        )
+        .expect("failed to generate test code");
+        let runnable_test = RunnableDocTest {
+            full_test_code: code,
+            full_test_line_offset: 0,
+            test_opts: test_options,
+            global_opts: opts.clone(),
+            langstr: LangString::default(),
+            line: 0,
+            edition,
+            no_run: false,
+            is_multiple_tests: true,
+        };
+        let ret =
+            run_test(runnable_test, rustdoc_options, self.supports_color, |_: UnusedExterns| {});
+        if let Err(TestFailure::CompileError) = ret { Err(()) } else { Ok(ret.is_ok()) }
+    }
+}
+
+/// Push new doctest content into `output`. Returns the test ID for this doctest.
+fn generate_mergeable_doctest(
+    doctest: &DocTestBuilder,
+    scraped_test: &ScrapedDocTest,
+    ignore: bool,
+    id: usize,
+    output: &mut String,
+) -> String {
+    let test_id = format!("__doctest_{id}");
+
+    if ignore {
+        // We generate nothing else.
+        writeln!(output, "mod {test_id} {{\n").unwrap();
+    } else {
+        writeln!(output, "mod {test_id} {{\n{}{}", doctest.crates, doctest.maybe_crate_attrs)
+            .unwrap();
+        if scraped_test.langstr.no_run {
+            // To prevent having warnings about unused items since they're not called.
+            writeln!(output, "#![allow(unused)]").unwrap();
+        }
+        if doctest.has_main_fn {
+            output.push_str(&doctest.everything_else);
+        } else {
+            let returns_result = if doctest.everything_else.trim_end().ends_with("(())") {
+                "-> Result<(), impl core::fmt::Debug>"
+            } else {
+                ""
+            };
+            write!(
+                output,
+                "\
+fn main() {returns_result} {{
+{}
+}}",
+                doctest.everything_else
+            )
+            .unwrap();
+        }
+    }
+    let not_running = ignore || scraped_test.langstr.no_run;
+    writeln!(
+        output,
+        "
+#[rustc_test_marker = {test_name:?}]
+pub const TEST: test::TestDescAndFn = test::TestDescAndFn::new_doctest(
+{test_name:?}, {ignore}, {file:?}, {line}, {no_run}, {should_panic},
+test::StaticTestFn(
+    #[coverage(off)]
+    || {{{runner}}},
+));
+}}",
+        test_name = scraped_test.name,
+        file = scraped_test.path(),
+        line = scraped_test.line,
+        no_run = scraped_test.langstr.no_run,
+        should_panic = !scraped_test.langstr.no_run && scraped_test.langstr.should_panic,
+        // Setting `no_run` to `true` in `TestDesc` still makes the test run, so we simply
+        // don't give it the function to run.
+        runner = if not_running {
+            "test::assert_test_result(Ok::<(), String>(()))".to_string()
+        } else {
+            format!(
+                "
+if let Some(bin_path) = crate::__doctest_mod::doctest_path() {{
+    test::assert_test_result(crate::__doctest_mod::doctest_runner(bin_path, {id}))
+}} else {{
+    test::assert_test_result(self::main())
+}}
+",
+            )
+        },
+    )
+    .unwrap();
+    test_id
+}
diff --git a/src/librustdoc/doctest/rust.rs b/src/librustdoc/doctest/rust.rs
index f179f3aa1c9..abd66f15dc0 100644
--- a/src/librustdoc/doctest/rust.rs
+++ b/src/librustdoc/doctest/rust.rs
@@ -14,14 +14,14 @@ use rustc_session::Session;
 use rustc_span::source_map::SourceMap;
 use rustc_span::{BytePos, FileName, Pos, Span, DUMMY_SP};
 
-use super::{DoctestVisitor, ScrapedDoctest};
+use super::{DocTestVisitor, ScrapedDocTest};
 use crate::clean::types::AttributesExt;
 use crate::clean::Attributes;
 use crate::html::markdown::{self, ErrorCodes, LangString, MdRelLine};
 
 struct RustCollector {
     source_map: Lrc<SourceMap>,
-    tests: Vec<ScrapedDoctest>,
+    tests: Vec<ScrapedDocTest>,
     cur_path: Vec<String>,
     position: Span,
 }
@@ -48,16 +48,16 @@ impl RustCollector {
     }
 }
 
-impl DoctestVisitor for RustCollector {
+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(),
+        self.tests.push(ScrapedDocTest::new(
+            self.get_filename(),
             line,
-            logical_path: self.cur_path.clone(),
-            langstr: config,
-            text: test,
-        });
+            self.cur_path.clone(),
+            config,
+            test,
+        ));
     }
 
     fn visit_header(&mut self, _name: &str, _level: u32) {}
@@ -89,7 +89,7 @@ impl<'a, 'tcx> HirCollector<'a, 'tcx> {
         Self { sess, map, codes, enable_per_target_ignores, tcx, collector }
     }
 
-    pub fn collect_crate(mut self) -> Vec<ScrapedDoctest> {
+    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)
diff --git a/src/librustdoc/doctest/tests.rs b/src/librustdoc/doctest/tests.rs
index 0f13ee404c6..160d0f222b4 100644
--- a/src/librustdoc/doctest/tests.rs
+++ b/src/librustdoc/doctest/tests.rs
@@ -2,7 +2,27 @@ use std::path::PathBuf;
 
 use rustc_span::edition::DEFAULT_EDITION;
 
-use super::{make_test, GlobalTestOptions};
+use super::{DocTestBuilder, GlobalTestOptions};
+
+fn make_test(
+    test_code: &str,
+    crate_name: Option<&str>,
+    dont_insert_main: bool,
+    opts: &GlobalTestOptions,
+    test_id: Option<&str>,
+) -> (String, usize) {
+    let doctest = DocTestBuilder::new(
+        test_code,
+        crate_name,
+        DEFAULT_EDITION,
+        false,
+        test_id.map(|s| s.to_string()),
+        None,
+    );
+    let (code, line_offset) =
+        doctest.generate_unique_doctest(test_code, dont_insert_main, opts, crate_name);
+    (code, line_offset)
+}
 
 /// Default [`GlobalTestOptions`] for these unit tests.
 fn default_global_opts(crate_name: impl Into<String>) -> GlobalTestOptions {
@@ -25,7 +45,7 @@ fn main() {
 assert_eq!(2+2, 4);
 }"
     .to_string();
-    let (output, len, _) = make_test(input, None, false, &opts, DEFAULT_EDITION, None);
+    let (output, len) = make_test(input, None, false, &opts, None);
     assert_eq!((output, len), (expected, 2));
 }
 
@@ -40,7 +60,7 @@ fn main() {
 assert_eq!(2+2, 4);
 }"
     .to_string();
-    let (output, len, _) = make_test(input, Some("asdf"), false, &opts, DEFAULT_EDITION, None);
+    let (output, len) = make_test(input, Some("asdf"), false, &opts, None);
     assert_eq!((output, len), (expected, 2));
 }
 
@@ -59,7 +79,7 @@ use asdf::qwop;
 assert_eq!(2+2, 4);
 }"
     .to_string();
-    let (output, len, _) = make_test(input, Some("asdf"), false, &opts, DEFAULT_EDITION, None);
+    let (output, len) = make_test(input, Some("asdf"), false, &opts, None);
     assert_eq!((output, len), (expected, 3));
 }
 
@@ -76,7 +96,7 @@ use asdf::qwop;
 assert_eq!(2+2, 4);
 }"
     .to_string();
-    let (output, len, _) = make_test(input, Some("asdf"), false, &opts, DEFAULT_EDITION, None);
+    let (output, len) = make_test(input, Some("asdf"), false, &opts, None);
     assert_eq!((output, len), (expected, 2));
 }
 
@@ -94,7 +114,7 @@ use std::*;
 assert_eq!(2+2, 4);
 }"
     .to_string();
-    let (output, len, _) = make_test(input, Some("std"), false, &opts, DEFAULT_EDITION, None);
+    let (output, len) = make_test(input, Some("std"), false, &opts, None);
     assert_eq!((output, len), (expected, 2));
 }
 
@@ -113,7 +133,7 @@ use asdf::qwop;
 assert_eq!(2+2, 4);
 }"
     .to_string();
-    let (output, len, _) = make_test(input, Some("asdf"), false, &opts, DEFAULT_EDITION, None);
+    let (output, len) = make_test(input, Some("asdf"), false, &opts, None);
     assert_eq!((output, len), (expected, 2));
 }
 
@@ -130,7 +150,7 @@ use asdf::qwop;
 assert_eq!(2+2, 4);
 }"
     .to_string();
-    let (output, len, _) = make_test(input, Some("asdf"), false, &opts, DEFAULT_EDITION, None);
+    let (output, len) = make_test(input, Some("asdf"), false, &opts, None);
     assert_eq!((output, len), (expected, 2));
 }
 
@@ -150,7 +170,7 @@ use asdf::qwop;
 assert_eq!(2+2, 4);
 }"
     .to_string();
-    let (output, len, _) = make_test(input, Some("asdf"), false, &opts, DEFAULT_EDITION, None);
+    let (output, len) = make_test(input, Some("asdf"), false, &opts, None);
     assert_eq!((output, len), (expected, 3));
 
     // Adding more will also bump the returned line offset.
@@ -164,7 +184,7 @@ use asdf::qwop;
 assert_eq!(2+2, 4);
 }"
     .to_string();
-    let (output, len, _) = make_test(input, Some("asdf"), false, &opts, DEFAULT_EDITION, None);
+    let (output, len) = make_test(input, Some("asdf"), false, &opts, None);
     assert_eq!((output, len), (expected, 4));
 }
 
@@ -181,7 +201,7 @@ fn main() {
 assert_eq!(2+2, 4);
 }"
     .to_string();
-    let (output, len, _) = make_test(input, None, false, &opts, DEFAULT_EDITION, None);
+    let (output, len) = make_test(input, None, false, &opts, None);
     assert_eq!((output, len), (expected, 2));
 }
 
@@ -197,7 +217,7 @@ fn main() {
     assert_eq!(2+2, 4);
 }"
     .to_string();
-    let (output, len, _) = make_test(input, None, false, &opts, DEFAULT_EDITION, None);
+    let (output, len) = make_test(input, None, false, &opts, None);
     assert_eq!((output, len), (expected, 1));
 }
 
@@ -213,7 +233,7 @@ fn main() {
 assert_eq!(2+2, 4);
 }"
     .to_string();
-    let (output, len, _) = make_test(input, None, false, &opts, DEFAULT_EDITION, None);
+    let (output, len) = make_test(input, None, false, &opts, None);
     assert_eq!((output, len), (expected, 2));
 }
 
@@ -227,7 +247,7 @@ assert_eq!(2+2, 4);";
 //Ceci n'est pas une `fn main`
 assert_eq!(2+2, 4);"
         .to_string();
-    let (output, len, _) = make_test(input, None, true, &opts, DEFAULT_EDITION, None);
+    let (output, len) = make_test(input, None, true, &opts, None);
     assert_eq!((output, len), (expected, 1));
 }
 
@@ -245,7 +265,7 @@ assert_eq!(2+2, 4);
 }"
     .to_string();
 
-    let (output, len, _) = make_test(input, None, false, &opts, DEFAULT_EDITION, None);
+    let (output, len) = make_test(input, None, false, &opts, None);
     assert_eq!((output, len), (expected, 2));
 }
 
@@ -265,7 +285,7 @@ assert_eq!(asdf::foo, 4);
 }"
     .to_string();
 
-    let (output, len, _) = make_test(input, Some("asdf"), false, &opts, DEFAULT_EDITION, None);
+    let (output, len) = make_test(input, Some("asdf"), false, &opts, None);
     assert_eq!((output, len), (expected, 3));
 }
 
@@ -283,7 +303,7 @@ test_wrapper! {
 }"
     .to_string();
 
-    let (output, len, _) = make_test(input, Some("my_crate"), false, &opts, DEFAULT_EDITION, None);
+    let (output, len) = make_test(input, Some("my_crate"), false, &opts, None);
     assert_eq!((output, len), (expected, 1));
 }
 
@@ -303,7 +323,7 @@ io::stdin().read_line(&mut input)?;
 Ok::<(), io:Error>(())
 } _inner().unwrap() }"
         .to_string();
-    let (output, len, _) = make_test(input, None, false, &opts, DEFAULT_EDITION, None);
+    let (output, len) = make_test(input, None, false, &opts, None);
     assert_eq!((output, len), (expected, 2));
 }
 
@@ -317,8 +337,7 @@ fn main() { #[allow(non_snake_case)] fn _doctest_main__some_unique_name() {
 assert_eq!(2+2, 4);
 } _doctest_main__some_unique_name() }"
         .to_string();
-    let (output, len, _) =
-        make_test(input, None, false, &opts, DEFAULT_EDITION, Some("_some_unique_name"));
+    let (output, len) = make_test(input, None, false, &opts, Some("_some_unique_name"));
     assert_eq!((output, len), (expected, 2));
 }
 
@@ -337,7 +356,7 @@ fn main() {
     eprintln!(\"hello anan\");
 }"
     .to_string();
-    let (output, len, _) = make_test(input, None, false, &opts, DEFAULT_EDITION, None);
+    let (output, len) = make_test(input, None, false, &opts, None);
     assert_eq!((output, len), (expected, 2));
 }
 
@@ -357,6 +376,6 @@ fn main() {
     eprintln!(\"hello anan\");
 }"
     .to_string();
-    let (output, len, _) = make_test(input, None, false, &opts, DEFAULT_EDITION, None);
+    let (output, len) = make_test(input, None, false, &opts, None);
     assert_eq!((output, len), (expected, 1));
 }
diff --git a/src/librustdoc/html/markdown.rs b/src/librustdoc/html/markdown.rs
index c41db654112..7bfe5d87d39 100644
--- a/src/librustdoc/html/markdown.rs
+++ b/src/librustdoc/html/markdown.rs
@@ -297,7 +297,8 @@ impl<'a, I: Iterator<Item = Event<'a>>> Iterator for CodeBlocks<'_, 'a, I> {
                 attrs: vec![],
                 args_file: PathBuf::new(),
             };
-            let (test, _, _) = doctest::make_test(&test, krate, false, &opts, edition, None);
+            let doctest = doctest::DocTestBuilder::new(&test, krate, edition, false, None, None);
+            let (test, _) = doctest.generate_unique_doctest(&test, false, &opts, krate);
             let channel = if test.contains("#![feature(") { "&amp;version=nightly" } else { "" };
 
             let test_escaped = small_url_encode(test);
@@ -737,7 +738,7 @@ impl MdRelLine {
     }
 }
 
-pub(crate) fn find_testable_code<T: doctest::DoctestVisitor>(
+pub(crate) fn find_testable_code<T: doctest::DocTestVisitor>(
     doc: &str,
     tests: &mut T,
     error_codes: ErrorCodes,
@@ -747,7 +748,7 @@ pub(crate) fn find_testable_code<T: doctest::DoctestVisitor>(
     find_codes(doc, tests, error_codes, enable_per_target_ignores, extra_info, false)
 }
 
-pub(crate) fn find_codes<T: doctest::DoctestVisitor>(
+pub(crate) fn find_codes<T: doctest::DocTestVisitor>(
     doc: &str,
     tests: &mut T,
     error_codes: ErrorCodes,
@@ -868,6 +869,7 @@ pub(crate) struct LangString {
     pub(crate) rust: bool,
     pub(crate) test_harness: bool,
     pub(crate) compile_fail: bool,
+    pub(crate) standalone: bool,
     pub(crate) error_codes: Vec<String>,
     pub(crate) edition: Option<Edition>,
     pub(crate) added_classes: Vec<String>,
@@ -1190,6 +1192,7 @@ impl Default for LangString {
             rust: true,
             test_harness: false,
             compile_fail: false,
+            standalone: false,
             error_codes: Vec::new(),
             edition: None,
             added_classes: Vec::new(),
@@ -1259,6 +1262,10 @@ impl LangString {
                         seen_rust_tags = !seen_other_tags || seen_rust_tags;
                         data.no_run = true;
                     }
+                    LangStringToken::LangToken("standalone") => {
+                        data.standalone = true;
+                        seen_rust_tags = !seen_other_tags || seen_rust_tags;
+                    }
                     LangStringToken::LangToken(x) if x.starts_with("edition") => {
                         data.edition = x[7..].parse::<Edition>().ok();
                     }
diff --git a/src/librustdoc/passes/check_doc_test_visibility.rs b/src/librustdoc/passes/check_doc_test_visibility.rs
index d78afdffc62..5015d665955 100644
--- a/src/librustdoc/passes/check_doc_test_visibility.rs
+++ b/src/librustdoc/passes/check_doc_test_visibility.rs
@@ -45,7 +45,7 @@ pub(crate) struct Tests {
     pub(crate) found_tests: usize,
 }
 
-impl crate::doctest::DoctestVisitor for Tests {
+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;
diff --git a/tests/run-make/doctests-keep-binaries-2024/rmake.rs b/tests/run-make/doctests-keep-binaries-2024/rmake.rs
new file mode 100644
index 00000000000..3e8ffcbf244
--- /dev/null
+++ b/tests/run-make/doctests-keep-binaries-2024/rmake.rs
@@ -0,0 +1,67 @@
+// Check that valid binaries are persisted by running them, regardless of whether the
+// --run or --no-run option is used.
+
+//@ ignore-cross-compile
+
+use std::path::Path;
+
+use run_make_support::{rfs, run, rustc, rustdoc};
+
+fn setup_test_env<F: FnOnce(&Path, &Path)>(callback: F) {
+    let out_dir = Path::new("doctests");
+    rfs::create_dir(&out_dir);
+    rustc().input("t.rs").crate_type("rlib").run();
+    callback(&out_dir, Path::new("libt.rlib"));
+    rfs::remove_dir_all(out_dir);
+}
+
+fn check_generated_binaries() {
+    run("doctests/merged_doctest_2024/rust_out");
+}
+
+fn main() {
+    setup_test_env(|out_dir, extern_path| {
+        rustdoc()
+            .input("t.rs")
+            .arg("-Zunstable-options")
+            .arg("--test")
+            .arg("--persist-doctests")
+            .arg(out_dir)
+            .extern_("t", extern_path)
+            .edition("2024")
+            .run();
+        check_generated_binaries();
+    });
+    setup_test_env(|out_dir, extern_path| {
+        rustdoc()
+            .input("t.rs")
+            .arg("-Zunstable-options")
+            .arg("--test")
+            .arg("--persist-doctests")
+            .arg(out_dir)
+            .extern_("t", extern_path)
+            .arg("--no-run")
+            .edition("2024")
+            .run();
+        check_generated_binaries();
+    });
+    // Behavior with --test-run-directory with relative paths.
+    setup_test_env(|_, _| {
+        let run_dir_path = Path::new("rundir");
+        rfs::create_dir(&run_dir_path);
+
+        rustdoc()
+            .input("t.rs")
+            .arg("-Zunstable-options")
+            .arg("--test")
+            .arg("--persist-doctests")
+            .arg("doctests")
+            .arg("--test-run-directory")
+            .arg(run_dir_path)
+            .extern_("t", "libt.rlib")
+            .edition("2024")
+            .run();
+
+        rfs::remove_dir_all(run_dir_path);
+    });
+}
diff --git a/tests/run-make/doctests-keep-binaries-2024/t.rs b/tests/run-make/doctests-keep-binaries-2024/t.rs
new file mode 100644
index 00000000000..c38cf0a0b25
--- /dev/null
+++ b/tests/run-make/doctests-keep-binaries-2024/t.rs
@@ -0,0 +1,11 @@
+/// Fungle the foople.
+/// ```
+/// t::foople();
+/// ```
+pub fn foople() {}
+
+/// Flomble the florp
+/// ```
+/// t::florp();
+/// ```
+pub fn florp() {}
diff --git a/tests/run-make/doctests-merge/doctest-2021.stdout b/tests/run-make/doctests-merge/doctest-2021.stdout
new file mode 100644
index 00000000000..7da08d68faa
--- /dev/null
+++ b/tests/run-make/doctests-merge/doctest-2021.stdout
@@ -0,0 +1,7 @@
+
+running 2 tests
+test doctest.rs - (line 4) ... ok
+test doctest.rs - init (line 8) ... ok
+
+test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in $TIME
+
diff --git a/tests/run-make/doctests-merge/doctest-2024.stdout b/tests/run-make/doctests-merge/doctest-2024.stdout
new file mode 100644
index 00000000000..7da08d68faa
--- /dev/null
+++ b/tests/run-make/doctests-merge/doctest-2024.stdout
@@ -0,0 +1,7 @@
+
+running 2 tests
+test doctest.rs - (line 4) ... ok
+test doctest.rs - init (line 8) ... ok
+
+test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in $TIME
+
diff --git a/tests/run-make/doctests-merge/doctest-standalone.rs b/tests/run-make/doctests-merge/doctest-standalone.rs
new file mode 100644
index 00000000000..134ffb58285
--- /dev/null
+++ b/tests/run-make/doctests-merge/doctest-standalone.rs
@@ -0,0 +1,18 @@
+#![crate_name = "foo"]
+#![crate_type = "lib"]
+
+//! ```standalone
+//! foo::init();
+//! ```
+
+/// ```standalone
+/// foo::init();
+/// ```
+pub fn init() {
+    static mut IS_INIT: bool = false;
+
+    unsafe {
+        assert!(!IS_INIT);
+        IS_INIT = true;
+    }
+}
diff --git a/tests/run-make/doctests-merge/doctest-standalone.stdout b/tests/run-make/doctests-merge/doctest-standalone.stdout
new file mode 100644
index 00000000000..ee9f62326ab
--- /dev/null
+++ b/tests/run-make/doctests-merge/doctest-standalone.stdout
@@ -0,0 +1,7 @@
+
+running 2 tests
+test doctest-standalone.rs - (line 4) ... ok
+test doctest-standalone.rs - init (line 8) ... ok
+
+test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in $TIME
+
diff --git a/tests/run-make/doctests-merge/doctest.rs b/tests/run-make/doctests-merge/doctest.rs
new file mode 100644
index 00000000000..66a5d88db67
--- /dev/null
+++ b/tests/run-make/doctests-merge/doctest.rs
@@ -0,0 +1,18 @@
+#![crate_name = "foo"]
+#![crate_type = "lib"]
+
+//! ```
+//! foo::init();
+//! ```
+
+/// ```
+/// foo::init();
+/// ```
+pub fn init() {
+    static mut IS_INIT: bool = false;
+
+    unsafe {
+        assert!(!IS_INIT);
+        IS_INIT = true;
+    }
+}
diff --git a/tests/run-make/doctests-merge/rmake.rs b/tests/run-make/doctests-merge/rmake.rs
new file mode 100644
index 00000000000..a25da7403e2
--- /dev/null
+++ b/tests/run-make/doctests-merge/rmake.rs
@@ -0,0 +1,39 @@
+use std::path::Path;
+
+use run_make_support::{cwd, diff, rustc, rustdoc};
+
+fn test_and_compare(input_file: &str, stdout_file: &str, edition: &str, dep: &Path) {
+    let mut cmd = rustdoc();
+
+    let output = cmd
+        .input(input_file)
+        .arg("--test")
+        .arg("-Zunstable-options")
+        .edition(edition)
+        .arg("--test-args=--test-threads=1")
+        .extern_("foo", dep.display().to_string())
+        .env("RUST_BACKTRACE", "short")
+        .run();
+
+    diff()
+        .expected_file(stdout_file)
+        .actual_text("output", output.stdout_utf8())
+        .normalize(r#"finished in \d+\.\d+s"#, "finished in $$TIME")
+        .run();
+}
+
+fn main() {
+    let out_file = cwd().join("libfoo.rlib");
+
+    rustc().input("doctest.rs").crate_type("rlib").output(&out_file).run();
+
+    // First we ensure that running with the 2024 edition will not fail at runtime.
+    test_and_compare("doctest.rs", "doctest-2024.stdout", "2024", &out_file);
+
+    // Then we ensure that running with an edition < 2024 will not fail at runtime.
+    test_and_compare("doctest.rs", "doctest-2021.stdout", "2021", &out_file);
+
+    // Now we check with the standalone attribute which should succeed in all cases.
+    test_and_compare("doctest-standalone.rs", "doctest-standalone.stdout", "2024", &out_file);
+    test_and_compare("doctest-standalone.rs", "doctest-standalone.stdout", "2021", &out_file);
+}
diff --git a/tests/rustdoc-ui/2024-doctests-checks.rs b/tests/rustdoc-ui/2024-doctests-checks.rs
new file mode 100644
index 00000000000..464cf5b200d
--- /dev/null
+++ b/tests/rustdoc-ui/2024-doctests-checks.rs
@@ -0,0 +1,27 @@
+//@ check-pass
+//@ compile-flags: --test --test-args=--test-threads=1 -Zunstable-options --edition 2024
+//@ normalize-stdout-test: "tests/rustdoc-ui" -> "$$DIR"
+//@ normalize-stdout-test: "finished in \d+\.\d+s" -> "finished in $$TIME"
+//@ normalize-stdout-test: ".rs:\d+:\d+" -> ".rs:$$LINE:$$COL"
+
+/// ```
+/// let x = 12;
+/// ```
+///
+/// This one should not be a merged doctest (because of `$crate`). The output
+/// will confirm it by displaying both merged and standalone doctest passes.
+///
+/// ```
+/// macro_rules! bla {
+///     () => {{
+///         $crate::foo();
+///     }}
+/// }
+///
+/// fn foo() {}
+///
+/// fn main() {
+///     bla!();
+/// }
+/// ```
+pub struct Foo;
diff --git a/tests/rustdoc-ui/2024-doctests-checks.stdout b/tests/rustdoc-ui/2024-doctests-checks.stdout
new file mode 100644
index 00000000000..d1064084a85
--- /dev/null
+++ b/tests/rustdoc-ui/2024-doctests-checks.stdout
@@ -0,0 +1,12 @@
+
+running 1 test
+test $DIR/2024-doctests-checks.rs - Foo (line 7) ... ok
+
+test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in $TIME
+
+
+running 1 test
+test $DIR/2024-doctests-checks.rs - Foo (line 14) ... ok
+
+test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in $TIME
+
diff --git a/tests/rustdoc-ui/2024-doctests-crate-attribute.rs b/tests/rustdoc-ui/2024-doctests-crate-attribute.rs
new file mode 100644
index 00000000000..4984fdfe194
--- /dev/null
+++ b/tests/rustdoc-ui/2024-doctests-crate-attribute.rs
@@ -0,0 +1,22 @@
+//@ check-pass
+//@ compile-flags: --test --test-args=--test-threads=1 -Zunstable-options --edition 2024
+//@ normalize-stdout-test: "tests/rustdoc-ui" -> "$$DIR"
+//@ normalize-stdout-test: "finished in \d+\.\d+s" -> "finished in $$TIME"
+//@ normalize-stdout-test: ".rs:\d+:\d+" -> ".rs:$$LINE:$$COL"
+
+/// This doctest is used to ensure that if a crate attribute is present,
+/// it will not be part of the merged doctests.
+///
+/// ```
+/// #![doc(html_playground_url = "foo")]
+///
+/// pub struct Bar;
+/// ```
+///
+/// This one will allow us to confirm that the doctest above will be a
+/// standalone one (there will be two separate doctests passes).
+///
+/// ```
+/// let x = 12;
+/// ```
+pub struct Foo;
diff --git a/tests/rustdoc-ui/2024-doctests-crate-attribute.stdout b/tests/rustdoc-ui/2024-doctests-crate-attribute.stdout
new file mode 100644
index 00000000000..29702ce8929
--- /dev/null
+++ b/tests/rustdoc-ui/2024-doctests-crate-attribute.stdout
@@ -0,0 +1,12 @@
+
+running 1 test
+test $DIR/2024-doctests-crate-attribute.rs - Foo (line 19) ... ok
+
+test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in $TIME
+
+
+running 1 test
+test $DIR/2024-doctests-crate-attribute.rs - Foo (line 10) ... ok
+
+test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in $TIME
+
diff --git a/tests/rustdoc-ui/doctest/failed-doctest-should-panic-2021.rs b/tests/rustdoc-ui/doctest/failed-doctest-should-panic-2021.rs
new file mode 100644
index 00000000000..4fe513b4066
--- /dev/null
+++ b/tests/rustdoc-ui/doctest/failed-doctest-should-panic-2021.rs
@@ -0,0 +1,12 @@
+// FIXME: if/when the output of the test harness can be tested on its own, this test should be
+// adapted to use that, and that normalize line can go away
+
+//@ compile-flags:--test --edition 2021
+//@ normalize-stdout-test: "tests/rustdoc-ui/doctest" -> "$$DIR"
+//@ normalize-stdout-test: "finished in \d+\.\d+s" -> "finished in $$TIME"
+//@ failure-status: 101
+
+/// ```should_panic
+/// println!("Hello, world!");
+/// ```
+pub struct Foo;
diff --git a/tests/rustdoc-ui/doctest/failed-doctest-should-panic-2021.stdout b/tests/rustdoc-ui/doctest/failed-doctest-should-panic-2021.stdout
new file mode 100644
index 00000000000..63d987de8a9
--- /dev/null
+++ b/tests/rustdoc-ui/doctest/failed-doctest-should-panic-2021.stdout
@@ -0,0 +1,14 @@
+
+running 1 test
+test $DIR/failed-doctest-should-panic-2021.rs - Foo (line 9) ... FAILED
+
+failures:
+
+---- $DIR/failed-doctest-should-panic-2021.rs - Foo (line 9) stdout ----
+Test executable succeeded, but it's marked `should_panic`.
+
+failures:
+    $DIR/failed-doctest-should-panic-2021.rs - Foo (line 9)
+
+test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in $TIME
+
diff --git a/tests/rustdoc-ui/doctest/failed-doctest-should-panic.rs b/tests/rustdoc-ui/doctest/failed-doctest-should-panic.rs
index b24687993e5..4018e37105f 100644
--- a/tests/rustdoc-ui/doctest/failed-doctest-should-panic.rs
+++ b/tests/rustdoc-ui/doctest/failed-doctest-should-panic.rs
@@ -1,7 +1,7 @@
 // FIXME: if/when the output of the test harness can be tested on its own, this test should be
 // adapted to use that, and that normalize line can go away
 
-//@ compile-flags:--test
+//@ compile-flags:--test -Z unstable-options --edition 2024
 //@ normalize-stdout-test: "tests/rustdoc-ui/doctest" -> "$$DIR"
 //@ normalize-stdout-test: "finished in \d+\.\d+s" -> "finished in $$TIME"
 //@ failure-status: 101
diff --git a/tests/rustdoc-ui/doctest/failed-doctest-should-panic.stdout b/tests/rustdoc-ui/doctest/failed-doctest-should-panic.stdout
index 57a20092a5d..cb3456e087e 100644
--- a/tests/rustdoc-ui/doctest/failed-doctest-should-panic.stdout
+++ b/tests/rustdoc-ui/doctest/failed-doctest-should-panic.stdout
@@ -1,11 +1,11 @@
 
 running 1 test
-test $DIR/failed-doctest-should-panic.rs - Foo (line 9) ... FAILED
+test $DIR/failed-doctest-should-panic.rs - Foo (line 9) - should panic ... FAILED
 
 failures:
 
 ---- $DIR/failed-doctest-should-panic.rs - Foo (line 9) stdout ----
-Test executable succeeded, but it's marked `should_panic`.
+note: test did not panic as expected
 
 failures:
     $DIR/failed-doctest-should-panic.rs - Foo (line 9)
diff --git a/tests/rustdoc-ui/doctest/merged-ignore-no_run.rs b/tests/rustdoc-ui/doctest/merged-ignore-no_run.rs
new file mode 100644
index 00000000000..4c21d542951
--- /dev/null
+++ b/tests/rustdoc-ui/doctest/merged-ignore-no_run.rs
@@ -0,0 +1,14 @@
+//@ compile-flags:--test --test-args=--test-threads=1 -Zunstable-options --edition 2024
+//@ normalize-stdout-test: "tests/rustdoc-ui/doctest" -> "$$DIR"
+//@ normalize-stdout-test: "finished in \d+\.\d+s" -> "finished in $$TIME"
+//@ check-pass
+
+/// ```ignore (test)
+/// let x = 12;
+/// ```
+pub fn ignored() {}
+
+/// ```no_run
+/// panic!("blob");
+/// ```
+pub fn no_run() {}
diff --git a/tests/rustdoc-ui/doctest/merged-ignore-no_run.stdout b/tests/rustdoc-ui/doctest/merged-ignore-no_run.stdout
new file mode 100644
index 00000000000..f2cb1e7e72f
--- /dev/null
+++ b/tests/rustdoc-ui/doctest/merged-ignore-no_run.stdout
@@ -0,0 +1,7 @@
+
+running 2 tests
+test $DIR/merged-ignore-no_run.rs - ignored (line 6) ... ignored
+test $DIR/merged-ignore-no_run.rs - no_run (line 11) - compile ... ok
+
+test result: ok. 1 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in $TIME
+
diff --git a/tests/rustdoc-ui/doctest/wrong-ast-2024.rs b/tests/rustdoc-ui/doctest/wrong-ast-2024.rs
new file mode 100644
index 00000000000..7b4fa8fd2c9
--- /dev/null
+++ b/tests/rustdoc-ui/doctest/wrong-ast-2024.rs
@@ -0,0 +1,20 @@
+//@ compile-flags:--test --test-args=--test-threads=1 -Zunstable-options --edition 2024
+//@ normalize-stdout-test: "tests/rustdoc-ui/doctest" -> "$$DIR"
+//@ normalize-stdout-test: "finished in \d+\.\d+s" -> "finished in $$TIME"
+//@ normalize-stdout-test: ".rs:\d+:\d+" -> ".rs:$$LINE:$$COL"
+//@ failure-status: 101
+
+/// ```
+/// /* plop
+/// ```
+pub fn one() {}
+
+/// ```
+/// } mod __doctest_1 { fn main() {
+/// ```
+pub fn two() {}
+
+/// ```should_panic
+/// panic!()
+/// ```
+pub fn three() {}
diff --git a/tests/rustdoc-ui/doctest/wrong-ast-2024.stdout b/tests/rustdoc-ui/doctest/wrong-ast-2024.stdout
new file mode 100644
index 00000000000..22c8ce468fd
--- /dev/null
+++ b/tests/rustdoc-ui/doctest/wrong-ast-2024.stdout
@@ -0,0 +1,41 @@
+
+running 1 test
+test $DIR/wrong-ast-2024.rs - three (line 17) - should panic ... ok
+
+test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in $TIME
+
+
+running 2 tests
+test $DIR/wrong-ast-2024.rs - one (line 7) ... FAILED
+test $DIR/wrong-ast-2024.rs - two (line 12) ... FAILED
+
+failures:
+
+---- $DIR/wrong-ast-2024.rs - one (line 7) stdout ----
+error[E0758]: unterminated block comment
+  --> $DIR/wrong-ast-2024.rs:$LINE:$COL
+   |
+LL | /* plop
+   | ^^^^^^^
+
+error: aborting due to 1 previous error
+
+For more information about this error, try `rustc --explain E0758`.
+Couldn't compile the test.
+---- $DIR/wrong-ast-2024.rs - two (line 12) stdout ----
+error: unexpected closing delimiter: `}`
+  --> $DIR/wrong-ast-2024.rs:$LINE:$COL
+   |
+LL | } mod __doctest_1 { fn main() {
+   | ^ unexpected closing delimiter
+
+error: aborting due to 1 previous error
+
+Couldn't compile the test.
+
+failures:
+    $DIR/wrong-ast-2024.rs - one (line 7)
+    $DIR/wrong-ast-2024.rs - two (line 12)
+
+test result: FAILED. 0 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out; finished in $TIME
+
diff --git a/tests/rustdoc-ui/doctest/wrong-ast.rs b/tests/rustdoc-ui/doctest/wrong-ast.rs
new file mode 100644
index 00000000000..92286b33dcf
--- /dev/null
+++ b/tests/rustdoc-ui/doctest/wrong-ast.rs
@@ -0,0 +1,19 @@
+//@ compile-flags:--test --test-args=--test-threads=1
+//@ normalize-stdout-test: "tests/rustdoc-ui/doctest" -> "$$DIR"
+//@ normalize-stdout-test: "finished in \d+\.\d+s" -> "finished in $$TIME"
+//@ failure-status: 101
+
+/// ```
+/// /* plop
+/// ```
+pub fn one() {}
+
+/// ```
+/// } mod __doctest_1 { fn main() {
+/// ```
+pub fn two() {}
+
+/// ```should_panic
+/// panic!()
+/// ```
+pub fn three() {}
diff --git a/tests/rustdoc-ui/doctest/wrong-ast.stdout b/tests/rustdoc-ui/doctest/wrong-ast.stdout
new file mode 100644
index 00000000000..15494706c16
--- /dev/null
+++ b/tests/rustdoc-ui/doctest/wrong-ast.stdout
@@ -0,0 +1,36 @@
+
+running 3 tests
+test $DIR/wrong-ast.rs - one (line 6) ... FAILED
+test $DIR/wrong-ast.rs - three (line 16) ... ok
+test $DIR/wrong-ast.rs - two (line 11) ... FAILED
+
+failures:
+
+---- $DIR/wrong-ast.rs - one (line 6) stdout ----
+error[E0758]: unterminated block comment
+  --> $DIR/wrong-ast.rs:7:1
+   |
+LL | /* plop
+   | ^^^^^^^
+
+error: aborting due to 1 previous error
+
+For more information about this error, try `rustc --explain E0758`.
+Couldn't compile the test.
+---- $DIR/wrong-ast.rs - two (line 11) stdout ----
+error: unexpected closing delimiter: `}`
+  --> $DIR/wrong-ast.rs:12:1
+   |
+LL | } mod __doctest_1 { fn main() {
+   | ^ unexpected closing delimiter
+
+error: aborting due to 1 previous error
+
+Couldn't compile the test.
+
+failures:
+    $DIR/wrong-ast.rs - one (line 6)
+    $DIR/wrong-ast.rs - two (line 11)
+
+test result: FAILED. 1 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out; finished in $TIME
+