about summary refs log tree commit diff
path: root/compiler/rustc_errors/src
diff options
context:
space:
mode:
Diffstat (limited to 'compiler/rustc_errors/src')
-rw-r--r--compiler/rustc_errors/src/diagnostic.rs10
-rw-r--r--compiler/rustc_errors/src/diagnostic_builder.rs4
-rw-r--r--compiler/rustc_errors/src/diagnostic_impls.rs69
-rw-r--r--compiler/rustc_errors/src/emitter.rs8
-rw-r--r--compiler/rustc_errors/src/json/tests.rs2
-rw-r--r--compiler/rustc_errors/src/lib.rs183
-rw-r--r--compiler/rustc_errors/src/markdown/mod.rs76
-rw-r--r--compiler/rustc_errors/src/markdown/parse.rs588
-rw-r--r--compiler/rustc_errors/src/markdown/term.rs189
-rw-r--r--compiler/rustc_errors/src/markdown/tests/input.md50
-rw-r--r--compiler/rustc_errors/src/markdown/tests/output.stdout35
-rw-r--r--compiler/rustc_errors/src/markdown/tests/parse.rs312
-rw-r--r--compiler/rustc_errors/src/markdown/tests/term.rs90
13 files changed, 1534 insertions, 82 deletions
diff --git a/compiler/rustc_errors/src/diagnostic.rs b/compiler/rustc_errors/src/diagnostic.rs
index ed0d06ed0ff..a96e317df55 100644
--- a/compiler/rustc_errors/src/diagnostic.rs
+++ b/compiler/rustc_errors/src/diagnostic.rs
@@ -420,13 +420,13 @@ impl Diagnostic {
         let expected_label = if expected_label.is_empty() {
             "expected".to_string()
         } else {
-            format!("expected {}", expected_label)
+            format!("expected {expected_label}")
         };
         let found_label = found_label.to_string();
         let found_label = if found_label.is_empty() {
             "found".to_string()
         } else {
-            format!("found {}", found_label)
+            format!("found {found_label}")
         };
         let (found_padding, expected_padding) = if expected_label.len() > found_label.len() {
             (expected_label.len() - found_label.len(), 0)
@@ -439,13 +439,13 @@ impl Diagnostic {
             StringPart::Normal(ref s) => (s.to_owned(), Style::NoStyle),
             StringPart::Highlighted(ref s) => (s.to_owned(), Style::Highlight),
         }));
-        msg.push((format!("`{}\n", expected_extra), Style::NoStyle));
+        msg.push((format!("`{expected_extra}\n"), Style::NoStyle));
         msg.push((format!("{}{} `", " ".repeat(found_padding), found_label), Style::NoStyle));
         msg.extend(found.0.iter().map(|x| match *x {
             StringPart::Normal(ref s) => (s.to_owned(), Style::NoStyle),
             StringPart::Highlighted(ref s) => (s.to_owned(), Style::Highlight),
         }));
-        msg.push((format!("`{}", found_extra), Style::NoStyle));
+        msg.push((format!("`{found_extra}"), Style::NoStyle));
 
         // For now, just attach these as notes.
         self.highlighted_note(msg);
@@ -454,7 +454,7 @@ impl Diagnostic {
 
     pub fn note_trait_signature(&mut self, name: Symbol, signature: String) -> &mut Self {
         self.highlighted_note(vec![
-            (format!("`{}` from trait: `", name), Style::NoStyle),
+            (format!("`{name}` from trait: `"), Style::NoStyle),
             (signature, Style::Highlight),
             ("`".to_string(), Style::NoStyle),
         ]);
diff --git a/compiler/rustc_errors/src/diagnostic_builder.rs b/compiler/rustc_errors/src/diagnostic_builder.rs
index 08ff2cfba5c..5e23ae655fe 100644
--- a/compiler/rustc_errors/src/diagnostic_builder.rs
+++ b/compiler/rustc_errors/src/diagnostic_builder.rs
@@ -536,7 +536,9 @@ impl<'a, G: EmissionGuarantee> DiagnosticBuilder<'a, G> {
             }
         };
 
-        if handler.flags.dont_buffer_diagnostics || handler.flags.treat_err_as_bug.is_some() {
+        if handler.inner.lock().flags.dont_buffer_diagnostics
+            || handler.inner.lock().flags.treat_err_as_bug.is_some()
+        {
             self.emit();
             return None;
         }
diff --git a/compiler/rustc_errors/src/diagnostic_impls.rs b/compiler/rustc_errors/src/diagnostic_impls.rs
index 10fe7fc74a8..a170e3a8943 100644
--- a/compiler/rustc_errors/src/diagnostic_impls.rs
+++ b/compiler/rustc_errors/src/diagnostic_impls.rs
@@ -1,3 +1,4 @@
+use crate::diagnostic::DiagnosticLocation;
 use crate::{fluent_generated as fluent, AddToDiagnostic};
 use crate::{DiagnosticArgValue, DiagnosticBuilder, Handler, IntoDiagnostic, IntoDiagnosticArg};
 use rustc_ast as ast;
@@ -10,6 +11,7 @@ use rustc_span::Span;
 use rustc_target::abi::TargetDataLayoutErrors;
 use rustc_target::spec::{PanicStrategy, SplitDebuginfo, StackProtector, TargetTriple};
 use rustc_type_ir as type_ir;
+use std::backtrace::Backtrace;
 use std::borrow::Cow;
 use std::fmt;
 use std::num::ParseIntError;
@@ -102,7 +104,7 @@ impl IntoDiagnosticArg for bool {
 
 impl IntoDiagnosticArg for char {
     fn into_diagnostic_arg(self) -> DiagnosticArgValue<'static> {
-        DiagnosticArgValue::Str(Cow::Owned(format!("{:?}", self)))
+        DiagnosticArgValue::Str(Cow::Owned(format!("{self:?}")))
     }
 }
 
@@ -164,6 +166,12 @@ impl IntoDiagnosticArg for hir::ConstContext {
     }
 }
 
+impl IntoDiagnosticArg for ast::Expr {
+    fn into_diagnostic_arg(self) -> DiagnosticArgValue<'static> {
+        DiagnosticArgValue::Str(Cow::Owned(pprust::expr_to_string(&self)))
+    }
+}
+
 impl IntoDiagnosticArg for ast::Path {
     fn into_diagnostic_arg(self) -> DiagnosticArgValue<'static> {
         DiagnosticArgValue::Str(Cow::Owned(pprust::path_to_string(&self)))
@@ -311,3 +319,62 @@ pub enum LabelKind {
     Label,
     Help,
 }
+
+#[derive(Subdiagnostic)]
+#[label(errors_expected_lifetime_parameter)]
+pub struct ExpectedLifetimeParameter {
+    #[primary_span]
+    pub span: Span,
+    pub count: usize,
+}
+
+#[derive(Subdiagnostic)]
+#[note(errors_delayed_at_with_newline)]
+pub struct DelayedAtWithNewline {
+    #[primary_span]
+    pub span: Span,
+    pub emitted_at: DiagnosticLocation,
+    pub note: Backtrace,
+}
+#[derive(Subdiagnostic)]
+#[note(errors_delayed_at_without_newline)]
+pub struct DelayedAtWithoutNewline {
+    #[primary_span]
+    pub span: Span,
+    pub emitted_at: DiagnosticLocation,
+    pub note: Backtrace,
+}
+
+impl IntoDiagnosticArg for DiagnosticLocation {
+    fn into_diagnostic_arg(self) -> DiagnosticArgValue<'static> {
+        DiagnosticArgValue::Str(Cow::from(self.to_string()))
+    }
+}
+
+impl IntoDiagnosticArg for Backtrace {
+    fn into_diagnostic_arg(self) -> DiagnosticArgValue<'static> {
+        DiagnosticArgValue::Str(Cow::from(self.to_string()))
+    }
+}
+
+#[derive(Subdiagnostic)]
+#[note(errors_invalid_flushed_delayed_diagnostic_level)]
+pub struct InvalidFlushedDelayedDiagnosticLevel {
+    #[primary_span]
+    pub span: Span,
+    pub level: rustc_errors::Level,
+}
+impl IntoDiagnosticArg for rustc_errors::Level {
+    fn into_diagnostic_arg(self) -> DiagnosticArgValue<'static> {
+        DiagnosticArgValue::Str(Cow::from(self.to_string()))
+    }
+}
+
+#[derive(Subdiagnostic)]
+#[suggestion(errors_indicate_anonymous_lifetime, code = "{suggestion}", style = "verbose")]
+pub struct IndicateAnonymousLifetime {
+    #[primary_span]
+    pub span: Span,
+    pub count: usize,
+    pub suggestion: String,
+}
diff --git a/compiler/rustc_errors/src/emitter.rs b/compiler/rustc_errors/src/emitter.rs
index d8c997b49a1..961feba3250 100644
--- a/compiler/rustc_errors/src/emitter.rs
+++ b/compiler/rustc_errors/src/emitter.rs
@@ -279,12 +279,12 @@ pub trait Emitter: Translate {
                 let msg = if substitution.is_empty() || sugg.style.hide_inline() {
                     // This substitution is only removal OR we explicitly don't want to show the
                     // code inline (`hide_inline`). Therefore, we don't show the substitution.
-                    format!("help: {}", &msg)
+                    format!("help: {msg}")
                 } else {
                     // Show the default suggestion text with the substitution
                     format!(
                         "help: {}{}: `{}`",
-                        &msg,
+                        msg,
                         if self.source_map().is_some_and(|sm| is_case_difference(
                             sm,
                             substitution,
@@ -616,7 +616,7 @@ pub enum ColorConfig {
 }
 
 impl ColorConfig {
-    fn to_color_choice(self) -> ColorChoice {
+    pub fn to_color_choice(self) -> ColorChoice {
         match self {
             ColorConfig::Always => {
                 if io::stderr().is_terminal() {
@@ -1982,7 +1982,7 @@ impl EmitterWriter {
                 // We special case `#[derive(_)]\n` and other attribute suggestions, because those
                 // are the ones where context is most useful.
                 let file_lines = sm
-                    .span_to_lines(span.primary_span().unwrap().shrink_to_hi())
+                    .span_to_lines(parts[0].span.shrink_to_hi())
                     .expect("span_to_lines failed when emitting suggestion");
                 let line_num = sm.lookup_char_pos(parts[0].span.lo()).line;
                 if let Some(line) = file_lines.file.get_line(line_num - 1) {
diff --git a/compiler/rustc_errors/src/json/tests.rs b/compiler/rustc_errors/src/json/tests.rs
index 671dc449eaa..1f9a2981e02 100644
--- a/compiler/rustc_errors/src/json/tests.rs
+++ b/compiler/rustc_errors/src/json/tests.rs
@@ -64,7 +64,7 @@ fn test_positions(code: &str, span: (u32, u32), expected_output: SpanTestData) {
         );
 
         let span = Span::with_root_ctxt(BytePos(span.0), BytePos(span.1));
-        let handler = Handler::with_emitter(true, None, Box::new(je));
+        let handler = Handler::with_emitter(Box::new(je));
         handler.span_err(span, "foo");
 
         let bytes = output.lock().unwrap();
diff --git a/compiler/rustc_errors/src/lib.rs b/compiler/rustc_errors/src/lib.rs
index bf77ed81f9b..2181bd526eb 100644
--- a/compiler/rustc_errors/src/lib.rs
+++ b/compiler/rustc_errors/src/lib.rs
@@ -4,7 +4,7 @@
 
 #![doc(html_root_url = "https://doc.rust-lang.org/nightly/nightly-rustc/")]
 #![feature(array_windows)]
-#![feature(drain_filter)]
+#![feature(extract_if)]
 #![feature(if_let_guard)]
 #![feature(let_chains)]
 #![feature(never_type)]
@@ -22,6 +22,8 @@ extern crate rustc_macros;
 #[macro_use]
 extern crate tracing;
 
+extern crate self as rustc_errors;
+
 pub use emitter::ColorConfig;
 
 use rustc_lint_defs::LintExpectationId;
@@ -47,9 +49,10 @@ use std::borrow::Cow;
 use std::error::Report;
 use std::fmt;
 use std::hash::Hash;
+use std::io::Write;
 use std::num::NonZeroUsize;
 use std::panic;
-use std::path::Path;
+use std::path::{Path, PathBuf};
 
 use termcolor::{Color, ColorSpec};
 
@@ -61,6 +64,7 @@ pub mod emitter;
 pub mod error;
 pub mod json;
 mod lock;
+pub mod markdown;
 pub mod registry;
 mod snippet;
 mod styled_buffer;
@@ -375,21 +379,23 @@ pub struct ExplicitBug;
 /// rather than a failed assertion, etc.
 pub struct DelayedBugPanic;
 
+use crate::diagnostic_impls::{DelayedAtWithNewline, DelayedAtWithoutNewline};
 pub use diagnostic::{
     AddToDiagnostic, DecorateLint, Diagnostic, DiagnosticArg, DiagnosticArgValue, DiagnosticId,
     DiagnosticStyledString, IntoDiagnosticArg, SubDiagnostic,
 };
 pub use diagnostic_builder::{DiagnosticBuilder, EmissionGuarantee, Noted};
 pub use diagnostic_impls::{
-    DiagnosticArgFromDisplay, DiagnosticSymbolList, LabelKind, SingleLabelManySpans,
+    DiagnosticArgFromDisplay, DiagnosticSymbolList, ExpectedLifetimeParameter,
+    IndicateAnonymousLifetime, InvalidFlushedDelayedDiagnosticLevel, LabelKind,
+    SingleLabelManySpans,
 };
-use std::backtrace::Backtrace;
+use std::backtrace::{Backtrace, BacktraceStatus};
 
 /// A handler deals with errors and other compiler output.
 /// Certain errors (fatal, bug, unimpl) may cause immediate exit,
 /// others log errors for later reporting.
 pub struct Handler {
-    flags: HandlerFlags,
     inner: Lock<HandlerInner>,
 }
 
@@ -460,6 +466,10 @@ struct HandlerInner {
     ///
     /// [RFC-2383]: https://rust-lang.github.io/rfcs/2383-lint-reasons.html
     fulfilled_expectations: FxHashSet<LintExpectationId>,
+
+    /// The file where the ICE information is stored. This allows delayed_span_bug backtraces to be
+    /// stored along side the main panic backtrace.
+    ice_file: Option<PathBuf>,
 }
 
 /// A key denoting where from a diagnostic was stashed.
@@ -543,63 +553,47 @@ impl Drop for HandlerInner {
 
 impl Handler {
     pub fn with_tty_emitter(
-        color_config: ColorConfig,
-        can_emit_warnings: bool,
-        treat_err_as_bug: Option<NonZeroUsize>,
         sm: Option<Lrc<SourceMap>>,
-        fluent_bundle: Option<Lrc<FluentBundle>>,
         fallback_bundle: LazyFallbackBundle,
     ) -> Self {
-        Self::with_tty_emitter_and_flags(
-            color_config,
-            sm,
-            fluent_bundle,
-            fallback_bundle,
-            HandlerFlags { can_emit_warnings, treat_err_as_bug, ..Default::default() },
-        )
-    }
-
-    pub fn with_tty_emitter_and_flags(
-        color_config: ColorConfig,
-        sm: Option<Lrc<SourceMap>>,
-        fluent_bundle: Option<Lrc<FluentBundle>>,
-        fallback_bundle: LazyFallbackBundle,
-        flags: HandlerFlags,
-    ) -> Self {
         let emitter = Box::new(EmitterWriter::stderr(
-            color_config,
+            ColorConfig::Auto,
             sm,
-            fluent_bundle,
+            None,
             fallback_bundle,
             false,
             false,
             None,
-            flags.macro_backtrace,
-            flags.track_diagnostics,
+            false,
+            false,
             TerminalUrl::No,
         ));
-        Self::with_emitter_and_flags(emitter, flags)
+        Self::with_emitter(emitter)
+    }
+    pub fn disable_warnings(mut self) -> Self {
+        self.inner.get_mut().flags.can_emit_warnings = false;
+        self
     }
 
-    pub fn with_emitter(
-        can_emit_warnings: bool,
-        treat_err_as_bug: Option<NonZeroUsize>,
-        emitter: Box<dyn Emitter + sync::Send>,
-    ) -> Self {
-        Handler::with_emitter_and_flags(
-            emitter,
-            HandlerFlags { can_emit_warnings, treat_err_as_bug, ..Default::default() },
-        )
+    pub fn treat_err_as_bug(mut self, treat_err_as_bug: NonZeroUsize) -> Self {
+        self.inner.get_mut().flags.treat_err_as_bug = Some(treat_err_as_bug);
+        self
     }
 
-    pub fn with_emitter_and_flags(
-        emitter: Box<dyn Emitter + sync::Send>,
-        flags: HandlerFlags,
-    ) -> Self {
+    pub fn with_flags(mut self, flags: HandlerFlags) -> Self {
+        self.inner.get_mut().flags = flags;
+        self
+    }
+
+    pub fn with_ice_file(mut self, ice_file: PathBuf) -> Self {
+        self.inner.get_mut().ice_file = Some(ice_file);
+        self
+    }
+
+    pub fn with_emitter(emitter: Box<dyn Emitter + sync::Send>) -> Self {
         Self {
-            flags,
             inner: Lock::new(HandlerInner {
-                flags,
+                flags: HandlerFlags { can_emit_warnings: true, ..Default::default() },
                 lint_err_count: 0,
                 err_count: 0,
                 warn_count: 0,
@@ -617,6 +611,7 @@ impl Handler {
                 check_unstable_expect_diagnostics: false,
                 unstable_expect_diagnostics: Vec::new(),
                 fulfilled_expectations: Default::default(),
+                ice_file: None,
             }),
         }
     }
@@ -644,7 +639,7 @@ impl Handler {
     // This is here to not allow mutation of flags;
     // as of this writing it's only used in tests in librustc_middle.
     pub fn can_emit_warnings(&self) -> bool {
-        self.flags.can_emit_warnings
+        self.inner.lock().flags.can_emit_warnings
     }
 
     /// Resets the diagnostic error count as well as the cached emitted diagnostics.
@@ -990,7 +985,7 @@ impl Handler {
         self.emit_diag_at_span(Diagnostic::new_with_code(Warning(None), Some(code), msg), span);
     }
 
-    pub fn span_bug(&self, span: impl Into<MultiSpan>, msg: impl Into<DiagnosticMessage>) -> ! {
+    pub fn span_bug(&self, span: impl Into<MultiSpan>, msg: impl Into<String>) -> ! {
         self.inner.borrow_mut().span_bug(span, msg)
     }
 
@@ -999,7 +994,7 @@ impl Handler {
     pub fn delay_span_bug(
         &self,
         span: impl Into<MultiSpan>,
-        msg: impl Into<DiagnosticMessage>,
+        msg: impl Into<String>,
     ) -> ErrorGuaranteed {
         self.inner.borrow_mut().delay_span_bug(span, msg)
     }
@@ -1331,7 +1326,7 @@ impl HandlerInner {
             // once *any* errors were emitted (and truncate `delayed_span_bugs`
             // when an error is first emitted, also), but maybe there's a case
             // in which that's not sound? otherwise this is really inefficient.
-            let backtrace = std::backtrace::Backtrace::force_capture();
+            let backtrace = std::backtrace::Backtrace::capture();
             self.delayed_span_bugs
                 .push(DelayedDiagnostic::with_backtrace(diagnostic.clone(), backtrace));
 
@@ -1399,7 +1394,7 @@ impl HandlerInner {
                     !self.emitted_diagnostics.insert(diagnostic_hash)
                 };
 
-                diagnostic.children.drain_filter(already_emitted_sub).for_each(|_| {});
+                diagnostic.children.extract_if(already_emitted_sub).for_each(|_| {});
 
                 self.emitter.emit_diagnostic(diagnostic);
                 if diagnostic.is_error() {
@@ -1472,7 +1467,7 @@ impl HandlerInner {
                 let _ = self.fatal(errors);
             }
             (_, _) => {
-                let _ = self.fatal(format!("{}; {}", &errors, &warnings));
+                let _ = self.fatal(format!("{errors}; {warnings}"));
             }
         }
 
@@ -1583,8 +1578,8 @@ impl HandlerInner {
     }
 
     #[track_caller]
-    fn span_bug(&mut self, sp: impl Into<MultiSpan>, msg: impl Into<DiagnosticMessage>) -> ! {
-        self.emit_diag_at_span(Diagnostic::new(Bug, msg), sp);
+    fn span_bug(&mut self, sp: impl Into<MultiSpan>, msg: impl Into<String>) -> ! {
+        self.emit_diag_at_span(Diagnostic::new(Bug, msg.into()), sp);
         panic::panic_any(ExplicitBug);
     }
 
@@ -1597,7 +1592,7 @@ impl HandlerInner {
     fn delay_span_bug(
         &mut self,
         sp: impl Into<MultiSpan>,
-        msg: impl Into<DiagnosticMessage>,
+        msg: impl Into<String>,
     ) -> ErrorGuaranteed {
         // This is technically `self.treat_err_as_bug()` but `delay_span_bug` is called before
         // incrementing `err_count` by one, so we need to +1 the comparing.
@@ -1606,9 +1601,9 @@ impl HandlerInner {
             self.err_count() + self.lint_err_count + self.delayed_bug_count() + 1 >= c.get()
         }) {
             // FIXME: don't abort here if report_delayed_bugs is off
-            self.span_bug(sp, msg);
+            self.span_bug(sp, msg.into());
         }
-        let mut diagnostic = Diagnostic::new(Level::DelayedBug, msg);
+        let mut diagnostic = Diagnostic::new(Level::DelayedBug, msg.into());
         diagnostic.set_span(sp.into());
         self.emit_diagnostic(&mut diagnostic).unwrap()
     }
@@ -1620,7 +1615,7 @@ impl HandlerInner {
         if self.flags.report_delayed_bugs {
             self.emit_diagnostic(&mut diagnostic);
         }
-        let backtrace = std::backtrace::Backtrace::force_capture();
+        let backtrace = std::backtrace::Backtrace::capture();
         self.delayed_good_path_bugs.push(DelayedDiagnostic::with_backtrace(diagnostic, backtrace));
     }
 
@@ -1656,8 +1651,21 @@ impl HandlerInner {
         explanation: impl Into<DiagnosticMessage> + Copy,
     ) {
         let mut no_bugs = true;
+        // If backtraces are enabled, also print the query stack
+        let backtrace = std::env::var_os("RUST_BACKTRACE").map_or(true, |x| &x != "0");
         for bug in bugs {
-            let mut bug = bug.decorate();
+            if let Some(file) = self.ice_file.as_ref()
+                && let Ok(mut out) = std::fs::File::options().create(true).append(true).open(file)
+            {
+                let _ = write!(
+                    &mut out,
+                    "delayed span bug: {}\n{}\n",
+                    bug.inner.styled_message().iter().filter_map(|(msg, _)| msg.as_str()).collect::<String>(),
+                    &bug.note
+                );
+            }
+            let mut bug =
+                if backtrace || self.ice_file.is_none() { bug.decorate() } else { bug.inner };
 
             if no_bugs {
                 // Put the overall explanation before the `DelayedBug`s, to
@@ -1670,11 +1678,10 @@ impl HandlerInner {
             if bug.level != Level::DelayedBug {
                 // NOTE(eddyb) not panicking here because we're already producing
                 // an ICE, and the more information the merrier.
-                bug.note(format!(
-                    "`flushed_delayed` got diagnostic with level {:?}, \
-                     instead of the expected `DelayedBug`",
-                    bug.level,
-                ));
+                bug.subdiagnostic(InvalidFlushedDelayedDiagnosticLevel {
+                    span: bug.span.primary_span().unwrap(),
+                    level: bug.level,
+                });
             }
             bug.level = Level::Bug;
 
@@ -1739,7 +1746,27 @@ impl DelayedDiagnostic {
     }
 
     fn decorate(mut self) -> Diagnostic {
-        self.inner.note(format!("delayed at {}\n{}", self.inner.emitted_at, self.note));
+        match self.note.status() {
+            BacktraceStatus::Captured => {
+                let inner = &self.inner;
+                self.inner.subdiagnostic(DelayedAtWithNewline {
+                    span: inner.span.primary_span().unwrap(),
+                    emitted_at: inner.emitted_at.clone(),
+                    note: self.note,
+                });
+            }
+            // Avoid the needless newline when no backtrace has been captured,
+            // the display impl should just be a single line.
+            _ => {
+                let inner = &self.inner;
+                self.inner.subdiagnostic(DelayedAtWithoutNewline {
+                    span: inner.span.primary_span().unwrap(),
+                    emitted_at: inner.emitted_at.clone(),
+                    note: self.note,
+                });
+            }
+        }
+
         self.inner
     }
 }
@@ -1828,7 +1855,7 @@ pub fn add_elided_lifetime_in_path_suggestion(
     incl_angl_brckt: bool,
     insertion_span: Span,
 ) {
-    diag.span_label(path_span, format!("expected lifetime parameter{}", pluralize!(n)));
+    diag.subdiagnostic(ExpectedLifetimeParameter { span: path_span, count: n });
     if !source_map.is_span_accessible(insertion_span) {
         // Do not try to suggest anything if generated by a proc-macro.
         return;
@@ -1836,12 +1863,28 @@ pub fn add_elided_lifetime_in_path_suggestion(
     let anon_lts = vec!["'_"; n].join(", ");
     let suggestion =
         if incl_angl_brckt { format!("<{}>", anon_lts) } else { format!("{}, ", anon_lts) };
-    diag.span_suggestion_verbose(
-        insertion_span.shrink_to_hi(),
-        format!("indicate the anonymous lifetime{}", pluralize!(n)),
+
+    diag.subdiagnostic(IndicateAnonymousLifetime {
+        span: insertion_span.shrink_to_hi(),
+        count: n,
         suggestion,
-        Applicability::MachineApplicable,
-    );
+    });
+}
+
+pub fn report_ambiguity_error<'a, G: EmissionGuarantee>(
+    db: &mut DiagnosticBuilder<'a, G>,
+    ambiguity: rustc_lint_defs::AmbiguityErrorDiag,
+) {
+    db.span_label(ambiguity.label_span, ambiguity.label_msg);
+    db.note(ambiguity.note_msg);
+    db.span_note(ambiguity.b1_span, ambiguity.b1_note_msg);
+    for help_msg in ambiguity.b1_help_msgs {
+        db.help(help_msg);
+    }
+    db.span_note(ambiguity.b2_span, ambiguity.b2_note_msg);
+    for help_msg in ambiguity.b2_help_msgs {
+        db.help(help_msg);
+    }
 }
 
 #[derive(Clone, Copy, PartialEq, Hash, Debug)]
diff --git a/compiler/rustc_errors/src/markdown/mod.rs b/compiler/rustc_errors/src/markdown/mod.rs
new file mode 100644
index 00000000000..53b766dfcce
--- /dev/null
+++ b/compiler/rustc_errors/src/markdown/mod.rs
@@ -0,0 +1,76 @@
+//! A simple markdown parser that can write formatted text to the terminal
+//!
+//! Entrypoint is `MdStream::parse_str(...)`
+use std::io;
+
+use termcolor::{Buffer, BufferWriter, ColorChoice};
+mod parse;
+mod term;
+
+/// An AST representation of a Markdown document
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct MdStream<'a>(Vec<MdTree<'a>>);
+
+impl<'a> MdStream<'a> {
+    /// Parse a markdown string to a tokenstream
+    #[must_use]
+    pub fn parse_str(s: &str) -> MdStream<'_> {
+        parse::entrypoint(s)
+    }
+
+    /// Write formatted output to a termcolor buffer
+    pub fn write_termcolor_buf(&self, buf: &mut Buffer) -> io::Result<()> {
+        term::entrypoint(self, buf)
+    }
+}
+
+/// Create a termcolor buffer with the `Always` color choice
+pub fn create_stdout_bufwtr() -> BufferWriter {
+    BufferWriter::stdout(ColorChoice::Always)
+}
+
+/// A single tokentree within a Markdown document
+#[derive(Clone, Debug, PartialEq)]
+pub enum MdTree<'a> {
+    /// Leaf types
+    Comment(&'a str),
+    CodeBlock {
+        txt: &'a str,
+        lang: Option<&'a str>,
+    },
+    CodeInline(&'a str),
+    Strong(&'a str),
+    Emphasis(&'a str),
+    Strikethrough(&'a str),
+    PlainText(&'a str),
+    /// [Foo](www.foo.com) or simple anchor <www.foo.com>
+    Link {
+        disp: &'a str,
+        link: &'a str,
+    },
+    /// `[Foo link][ref]`
+    RefLink {
+        disp: &'a str,
+        id: Option<&'a str>,
+    },
+    /// [ref]: www.foo.com
+    LinkDef {
+        id: &'a str,
+        link: &'a str,
+    },
+    /// Break bewtween two paragraphs (double `\n`), not directly parsed but
+    /// added later
+    ParagraphBreak,
+    /// Break bewtween two lines (single `\n`)
+    LineBreak,
+    HorizontalRule,
+    Heading(u8, MdStream<'a>),
+    OrderedListItem(u16, MdStream<'a>),
+    UnorderedListItem(MdStream<'a>),
+}
+
+impl<'a> From<Vec<MdTree<'a>>> for MdStream<'a> {
+    fn from(value: Vec<MdTree<'a>>) -> Self {
+        Self(value)
+    }
+}
diff --git a/compiler/rustc_errors/src/markdown/parse.rs b/compiler/rustc_errors/src/markdown/parse.rs
new file mode 100644
index 00000000000..d3a08da6283
--- /dev/null
+++ b/compiler/rustc_errors/src/markdown/parse.rs
@@ -0,0 +1,588 @@
+use crate::markdown::{MdStream, MdTree};
+use std::{iter, mem, str};
+
+/// Short aliases that we can use in match patterns. If an end pattern is not
+/// included, this type may be variable
+const ANC_E: &[u8] = b">";
+const ANC_S: &[u8] = b"<";
+const BRK: &[u8] = b"---";
+const CBK: &[u8] = b"```";
+const CIL: &[u8] = b"`";
+const CMT_E: &[u8] = b"-->";
+const CMT_S: &[u8] = b"<!--";
+const EMP: &[u8] = b"_";
+const HDG: &[u8] = b"#";
+const LNK_CHARS: &str = "$-_.+!*'()/&?=:%";
+const LNK_E: &[u8] = b"]";
+const LNK_S: &[u8] = b"[";
+const STG: &[u8] = b"**";
+const STK: &[u8] = b"~~";
+const UL1: &[u8] = b"* ";
+const UL2: &[u8] = b"- ";
+
+/// Pattern replacements
+const REPLACEMENTS: &[(&str, &str)] = &[
+    ("(c)", "©"),
+    ("(C)", "©"),
+    ("(r)", "®"),
+    ("(R)", "®"),
+    ("(tm)", "™"),
+    ("(TM)", "™"),
+    (":crab:", "🦀"),
+    ("\n", " "),
+];
+
+/// `(extracted, remaining)`
+type Parsed<'a> = (MdTree<'a>, &'a [u8]);
+/// Output of a parse function
+type ParseResult<'a> = Option<Parsed<'a>>;
+
+/// Parsing context
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct Context {
+    /// If true, we are at a the topmost level (not recursing a nested tt)
+    top_block: bool,
+    /// Previous character
+    prev: Prev,
+}
+
+/// Character class preceding this one
+#[derive(Clone, Copy, Debug, PartialEq)]
+enum Prev {
+    Newline,
+    /// Whitespace that is not a newline
+    Whitespace,
+    Escape,
+    Any,
+}
+
+impl Default for Context {
+    /// Most common setting for non top-level parsing: not top block, not at
+    /// line start (yes leading whitespace, not escaped)
+    fn default() -> Self {
+        Self { top_block: false, prev: Prev::Whitespace }
+    }
+}
+
+/// Flags to simple parser function
+#[derive(Clone, Copy, Debug, PartialEq)]
+enum ParseOpt {
+    /// Ignore escapes before closing pattern, trim content
+    TrimNoEsc,
+    None,
+}
+
+/// Parse a buffer
+pub fn entrypoint(txt: &str) -> MdStream<'_> {
+    let ctx = Context { top_block: true, prev: Prev::Newline };
+    normalize(parse_recursive(txt.trim().as_bytes(), ctx), &mut Vec::new())
+}
+
+/// Parse a buffer with specified context
+fn parse_recursive<'a>(buf: &'a [u8], ctx: Context) -> MdStream<'_> {
+    use ParseOpt as Po;
+    use Prev::{Escape, Newline, Whitespace};
+
+    let mut stream: Vec<MdTree<'a>> = Vec::new();
+    let Context { top_block: top_blk, mut prev } = ctx;
+
+    // wip_buf is our entire unprocessed (unpushed) buffer, loop_buf is our to
+    // check buffer that shrinks with each loop
+    let mut wip_buf = buf;
+    let mut loop_buf = wip_buf;
+
+    while !loop_buf.is_empty() {
+        let next_prev = match loop_buf[0] {
+            b'\n' => Newline,
+            b'\\' => Escape,
+            x if x.is_ascii_whitespace() => Whitespace,
+            _ => Prev::Any,
+        };
+
+        let res: ParseResult<'_> = match (top_blk, prev) {
+            (_, Newline | Whitespace) if loop_buf.starts_with(CMT_S) => {
+                parse_simple_pat(loop_buf, CMT_S, CMT_E, Po::TrimNoEsc, MdTree::Comment)
+            }
+            (true, Newline) if loop_buf.starts_with(CBK) => Some(parse_codeblock(loop_buf)),
+            (_, Newline | Whitespace) if loop_buf.starts_with(CIL) => parse_codeinline(loop_buf),
+            (true, Newline | Whitespace) if loop_buf.starts_with(HDG) => parse_heading(loop_buf),
+            (true, Newline) if loop_buf.starts_with(BRK) => {
+                Some((MdTree::HorizontalRule, parse_to_newline(loop_buf).1))
+            }
+            (_, Newline | Whitespace) if loop_buf.starts_with(EMP) => {
+                parse_simple_pat(loop_buf, EMP, EMP, Po::None, MdTree::Emphasis)
+            }
+            (_, Newline | Whitespace) if loop_buf.starts_with(STG) => {
+                parse_simple_pat(loop_buf, STG, STG, Po::None, MdTree::Strong)
+            }
+            (_, Newline | Whitespace) if loop_buf.starts_with(STK) => {
+                parse_simple_pat(loop_buf, STK, STK, Po::None, MdTree::Strikethrough)
+            }
+            (_, Newline | Whitespace) if loop_buf.starts_with(ANC_S) => {
+                let tt_fn = |link| MdTree::Link { disp: link, link };
+                let ret = parse_simple_pat(loop_buf, ANC_S, ANC_E, Po::None, tt_fn);
+                match ret {
+                    Some((MdTree::Link { disp, .. }, _))
+                        if disp.chars().all(|ch| LNK_CHARS.contains(ch)) =>
+                    {
+                        ret
+                    }
+                    _ => None,
+                }
+            }
+            (_, Newline) if (loop_buf.starts_with(UL1) || loop_buf.starts_with(UL2)) => {
+                Some(parse_unordered_li(loop_buf))
+            }
+            (_, Newline) if ord_list_start(loop_buf).is_some() => Some(parse_ordered_li(loop_buf)),
+            (_, Newline | Whitespace) if loop_buf.starts_with(LNK_S) => {
+                parse_any_link(loop_buf, top_blk && prev == Prev::Newline)
+            }
+            (_, Escape | _) => None,
+        };
+
+        if let Some((tree, rest)) = res {
+            // We found something: push our WIP and then push the found tree
+            let prev_buf = &wip_buf[..(wip_buf.len() - loop_buf.len())];
+            if !prev_buf.is_empty() {
+                let prev_str = str::from_utf8(prev_buf).unwrap();
+                stream.push(MdTree::PlainText(prev_str));
+            }
+            stream.push(tree);
+
+            wip_buf = rest;
+            loop_buf = rest;
+        } else {
+            // Just move on to the next character
+            loop_buf = &loop_buf[1..];
+            // If we are at the end and haven't found anything, just push plain text
+            if loop_buf.is_empty() && !wip_buf.is_empty() {
+                let final_str = str::from_utf8(wip_buf).unwrap();
+                stream.push(MdTree::PlainText(final_str));
+            }
+        };
+
+        prev = next_prev;
+    }
+
+    MdStream(stream)
+}
+
+/// The simplest kind of patterns: data within start and end patterns
+fn parse_simple_pat<'a, F>(
+    buf: &'a [u8],
+    start_pat: &[u8],
+    end_pat: &[u8],
+    opts: ParseOpt,
+    create_tt: F,
+) -> ParseResult<'a>
+where
+    F: FnOnce(&'a str) -> MdTree<'a>,
+{
+    let ignore_esc = matches!(opts, ParseOpt::TrimNoEsc);
+    let trim = matches!(opts, ParseOpt::TrimNoEsc);
+    let (txt, rest) = parse_with_end_pat(&buf[start_pat.len()..], end_pat, ignore_esc)?;
+    let mut txt = str::from_utf8(txt).unwrap();
+    if trim {
+        txt = txt.trim();
+    }
+    Some((create_tt(txt), rest))
+}
+
+/// Parse backtick-wrapped inline code. Accounts for >1 backtick sets
+fn parse_codeinline(buf: &[u8]) -> ParseResult<'_> {
+    let seps = buf.iter().take_while(|ch| **ch == b'`').count();
+    let (txt, rest) = parse_with_end_pat(&buf[seps..], &buf[..seps], true)?;
+    Some((MdTree::CodeInline(str::from_utf8(txt).unwrap()), rest))
+}
+
+/// Parse a codeblock. Accounts for >3 backticks and language specification
+fn parse_codeblock(buf: &[u8]) -> Parsed<'_> {
+    // account for ````code```` style
+    let seps = buf.iter().take_while(|ch| **ch == b'`').count();
+    let end_sep = &buf[..seps];
+    let mut working = &buf[seps..];
+
+    // Handle "````rust" style language specifications
+    let next_ws_idx = working.iter().take_while(|ch| !ch.is_ascii_whitespace()).count();
+
+    let lang = if next_ws_idx > 0 {
+        // Munch the lang
+        let tmp = str::from_utf8(&working[..next_ws_idx]).unwrap();
+        working = &working[next_ws_idx..];
+        Some(tmp)
+    } else {
+        None
+    };
+
+    let mut end_pat = vec![b'\n'];
+    end_pat.extend(end_sep);
+
+    // Find first end pattern with nothing else on its line
+    let mut found = None;
+    for idx in (0..working.len()).filter(|idx| working[*idx..].starts_with(&end_pat)) {
+        let (eol_txt, rest) = parse_to_newline(&working[(idx + end_pat.len())..]);
+        if !eol_txt.iter().any(u8::is_ascii_whitespace) {
+            found = Some((&working[..idx], rest));
+            break;
+        }
+    }
+
+    let (txt, rest) = found.unwrap_or((working, &[]));
+    let txt = str::from_utf8(txt).unwrap().trim_matches('\n');
+
+    (MdTree::CodeBlock { txt, lang }, rest)
+}
+
+fn parse_heading(buf: &[u8]) -> ParseResult<'_> {
+    let level = buf.iter().take_while(|ch| **ch == b'#').count();
+    let buf = &buf[level..];
+
+    if level > 6 || (buf.len() > 1 && !buf[0].is_ascii_whitespace()) {
+        // Enforce max 6 levels and whitespace following the `##` pattern
+        return None;
+    }
+
+    let (txt, rest) = parse_to_newline(&buf[1..]);
+    let ctx = Context { top_block: false, prev: Prev::Whitespace };
+    let stream = parse_recursive(txt, ctx);
+
+    Some((MdTree::Heading(level.try_into().unwrap(), stream), rest))
+}
+
+/// Bulleted list
+fn parse_unordered_li(buf: &[u8]) -> Parsed<'_> {
+    debug_assert!(buf.starts_with(b"* ") || buf.starts_with(b"- "));
+    let (txt, rest) = get_indented_section(&buf[2..]);
+    let ctx = Context { top_block: false, prev: Prev::Whitespace };
+    let stream = parse_recursive(trim_ascii_start(txt), ctx);
+    (MdTree::UnorderedListItem(stream), rest)
+}
+
+/// Numbered list
+fn parse_ordered_li(buf: &[u8]) -> Parsed<'_> {
+    let (num, pos) = ord_list_start(buf).unwrap(); // success tested in caller
+    let (txt, rest) = get_indented_section(&buf[pos..]);
+    let ctx = Context { top_block: false, prev: Prev::Whitespace };
+    let stream = parse_recursive(trim_ascii_start(txt), ctx);
+    (MdTree::OrderedListItem(num, stream), rest)
+}
+
+/// Find first line that isn't empty or doesn't start with whitespace, that will
+/// be our contents
+fn get_indented_section(buf: &[u8]) -> (&[u8], &[u8]) {
+    let mut end = buf.len();
+    for (idx, window) in buf.windows(2).enumerate() {
+        let &[ch, next_ch] = window else { unreachable!("always 2 elements") };
+        if idx >= buf.len().saturating_sub(2) && next_ch == b'\n' {
+            // End of stream
+            end = buf.len().saturating_sub(1);
+            break;
+        } else if ch == b'\n' && (!next_ch.is_ascii_whitespace() || next_ch == b'\n') {
+            end = idx;
+            break;
+        }
+    }
+
+    (&buf[..end], &buf[end..])
+}
+
+/// Verify a valid ordered list start (e.g. `1.`) and parse it. Returns the
+/// parsed number and offset of character after the dot.
+fn ord_list_start(buf: &[u8]) -> Option<(u16, usize)> {
+    let pos = buf.iter().take(10).position(|ch| *ch == b'.')?;
+    let n = str::from_utf8(&buf[..pos]).ok()?;
+    if !buf.get(pos + 1)?.is_ascii_whitespace() {
+        return None;
+    }
+    n.parse::<u16>().ok().map(|v| (v, pos + 2))
+}
+
+/// Parse links. `can_be_def` indicates that a link definition is possible (top
+/// level, located at the start of a line)
+fn parse_any_link(buf: &[u8], can_be_def: bool) -> ParseResult<'_> {
+    let (bracketed, rest) = parse_with_end_pat(&buf[1..], LNK_E, true)?;
+    if rest.is_empty() {
+        return None;
+    }
+
+    let disp = str::from_utf8(bracketed).unwrap();
+    match (can_be_def, rest[0]) {
+        (true, b':') => {
+            let (link, tmp) = parse_to_newline(&rest[1..]);
+            let link = str::from_utf8(link).unwrap().trim();
+            Some((MdTree::LinkDef { id: disp, link }, tmp))
+        }
+        (_, b'(') => parse_simple_pat(rest, b"(", b")", ParseOpt::TrimNoEsc, |link| MdTree::Link {
+            disp,
+            link,
+        }),
+        (_, b'[') => parse_simple_pat(rest, b"[", b"]", ParseOpt::TrimNoEsc, |id| {
+            MdTree::RefLink { disp, id: Some(id) }
+        }),
+        _ => Some((MdTree::RefLink { disp, id: None }, rest)),
+    }
+}
+
+/// Find and consume an end pattern, return `(match, residual)`
+fn parse_with_end_pat<'a>(
+    buf: &'a [u8],
+    end_sep: &[u8],
+    ignore_esc: bool,
+) -> Option<(&'a [u8], &'a [u8])> {
+    // Find positions that start with the end seperator
+    for idx in (0..buf.len()).filter(|idx| buf[*idx..].starts_with(end_sep)) {
+        if !ignore_esc && idx > 0 && buf[idx - 1] == b'\\' {
+            continue;
+        }
+        return Some((&buf[..idx], &buf[idx + end_sep.len()..]));
+    }
+    None
+}
+
+/// Resturn `(match, residual)` to end of line. The EOL is returned with the
+/// residual.
+fn parse_to_newline(buf: &[u8]) -> (&[u8], &[u8]) {
+    buf.iter().position(|ch| *ch == b'\n').map_or((buf, &[]), |pos| buf.split_at(pos))
+}
+
+/// Take a parsed stream and fix the little things
+fn normalize<'a>(MdStream(stream): MdStream<'a>, linkdefs: &mut Vec<MdTree<'a>>) -> MdStream<'a> {
+    let mut new_stream = Vec::with_capacity(stream.len());
+    let new_defs = stream.iter().filter(|tt| matches!(tt, MdTree::LinkDef { .. }));
+    linkdefs.extend(new_defs.cloned());
+
+    // Run plaintest expansions on types that need it, call this function on nested types
+    for item in stream {
+        match item {
+            MdTree::PlainText(txt) => expand_plaintext(txt, &mut new_stream, MdTree::PlainText),
+            MdTree::Strong(txt) => expand_plaintext(txt, &mut new_stream, MdTree::Strong),
+            MdTree::Emphasis(txt) => expand_plaintext(txt, &mut new_stream, MdTree::Emphasis),
+            MdTree::Strikethrough(txt) => {
+                expand_plaintext(txt, &mut new_stream, MdTree::Strikethrough);
+            }
+            MdTree::RefLink { disp, id } => new_stream.push(match_reflink(linkdefs, disp, id)),
+            MdTree::OrderedListItem(n, st) => {
+                new_stream.push(MdTree::OrderedListItem(n, normalize(st, linkdefs)));
+            }
+            MdTree::UnorderedListItem(st) => {
+                new_stream.push(MdTree::UnorderedListItem(normalize(st, linkdefs)));
+            }
+            MdTree::Heading(n, st) => new_stream.push(MdTree::Heading(n, normalize(st, linkdefs))),
+            _ => new_stream.push(item),
+        }
+    }
+
+    // Remove non printing types, duplicate paragraph breaks, and breaks at start/end
+    new_stream.retain(|x| !matches!(x, MdTree::Comment(_) | MdTree::LinkDef { .. }));
+    new_stream.dedup_by(|r, l| matches!((r, l), (MdTree::ParagraphBreak, MdTree::ParagraphBreak)));
+
+    if new_stream.first().is_some_and(is_break_ty) {
+        new_stream.remove(0);
+    }
+    if new_stream.last().is_some_and(is_break_ty) {
+        new_stream.pop();
+    }
+
+    // Remove paragraph breaks that shouldn't be there. w[1] is what will be
+    // removed in these cases. Note that these are the items to keep, not delete
+    // (for `retain`)
+    let to_keep: Vec<bool> = new_stream
+        .windows(3)
+        .map(|w| {
+            !((matches!(&w[1], MdTree::ParagraphBreak)
+                && matches!(should_break(&w[0], &w[2]), BreakRule::Always(1) | BreakRule::Never))
+                || (matches!(&w[1], MdTree::PlainText(txt) if txt.trim().is_empty())
+                    && matches!(
+                        should_break(&w[0], &w[2]),
+                        BreakRule::Always(_) | BreakRule::Never
+                    )))
+        })
+        .collect();
+    let mut iter = iter::once(true).chain(to_keep).chain(iter::once(true));
+    new_stream.retain(|_| iter.next().unwrap());
+
+    // Insert line or paragraph breaks where there should be some
+    let mut insertions = 0;
+    let to_insert: Vec<(usize, MdTree<'_>)> = new_stream
+        .windows(2)
+        .enumerate()
+        .filter_map(|(idx, w)| match should_break(&w[0], &w[1]) {
+            BreakRule::Always(1) => Some((idx, MdTree::LineBreak)),
+            BreakRule::Always(2) => Some((idx, MdTree::ParagraphBreak)),
+            _ => None,
+        })
+        .map(|(idx, tt)| {
+            insertions += 1;
+            (idx + insertions, tt)
+        })
+        .collect();
+    to_insert.into_iter().for_each(|(idx, tt)| new_stream.insert(idx, tt));
+
+    MdStream(new_stream)
+}
+
+/// Whether two types should or shouldn't have a paragraph break between them
+#[derive(Clone, Copy, Debug, PartialEq)]
+enum BreakRule {
+    Always(u8),
+    Never,
+    Optional,
+}
+
+/// Blocks that automatically handle their own text wrapping
+fn should_break(left: &MdTree<'_>, right: &MdTree<'_>) -> BreakRule {
+    use MdTree::*;
+
+    match (left, right) {
+        // Separate these types with a single line
+        (HorizontalRule, _)
+        | (_, HorizontalRule)
+        | (OrderedListItem(_, _), OrderedListItem(_, _))
+        | (UnorderedListItem(_), UnorderedListItem(_)) => BreakRule::Always(1),
+        // Condensed types shouldn't have an extra break on either side
+        (Comment(_) | ParagraphBreak | Heading(_, _), _) | (_, Comment(_) | ParagraphBreak) => {
+            BreakRule::Never
+        }
+        // Block types should always be separated by full breaks
+        (CodeBlock { .. } | OrderedListItem(_, _) | UnorderedListItem(_), _)
+        | (_, CodeBlock { .. } | Heading(_, _) | OrderedListItem(_, _) | UnorderedListItem(_)) => {
+            BreakRule::Always(2)
+        }
+        // Text types may or may not be separated by a break
+        (
+            CodeInline(_)
+            | Strong(_)
+            | Emphasis(_)
+            | Strikethrough(_)
+            | PlainText(_)
+            | Link { .. }
+            | RefLink { .. }
+            | LinkDef { .. },
+            CodeInline(_)
+            | Strong(_)
+            | Emphasis(_)
+            | Strikethrough(_)
+            | PlainText(_)
+            | Link { .. }
+            | RefLink { .. }
+            | LinkDef { .. },
+        ) => BreakRule::Optional,
+        (LineBreak, _) | (_, LineBreak) => {
+            unreachable!("should have been removed during deduplication")
+        }
+    }
+}
+
+/// Types that indicate some form of break
+fn is_break_ty(val: &MdTree<'_>) -> bool {
+    matches!(val, MdTree::ParagraphBreak | MdTree::LineBreak)
+        // >1 break between paragraphs acts as a break
+        || matches!(val, MdTree::PlainText(txt) if txt.trim().is_empty())
+}
+
+/// Perform tranformations to text. This splits paragraphs, replaces patterns,
+/// and corrects newlines.
+///
+/// To avoid allocating strings (and using a different heavier tt type), our
+/// replace method means split into three and append each. For this reason, any
+/// viewer should treat consecutive `PlainText` types as belonging to the same
+/// paragraph.
+fn expand_plaintext<'a>(
+    txt: &'a str,
+    stream: &mut Vec<MdTree<'a>>,
+    mut f: fn(&'a str) -> MdTree<'a>,
+) {
+    if txt.is_empty() {
+        return;
+    } else if txt == "\n" {
+        if let Some(tt) = stream.last() {
+            let tmp = MdTree::PlainText(" ");
+            if should_break(tt, &tmp) == BreakRule::Optional {
+                stream.push(tmp);
+            }
+        }
+        return;
+    }
+    let mut queue1 = Vec::new();
+    let mut queue2 = Vec::new();
+    let stream_start_len = stream.len();
+    for paragraph in txt.split("\n\n") {
+        if paragraph.is_empty() {
+            stream.push(MdTree::ParagraphBreak);
+            continue;
+        }
+        let paragraph = trim_extra_ws(paragraph);
+
+        queue1.clear();
+        queue1.push(paragraph);
+
+        for (from, to) in REPLACEMENTS {
+            queue2.clear();
+            for item in &queue1 {
+                for s in item.split(from) {
+                    queue2.extend(&[s, to]);
+                }
+                if queue2.len() > 1 {
+                    let _ = queue2.pop(); // remove last unnecessary intersperse
+                }
+            }
+            mem::swap(&mut queue1, &mut queue2);
+        }
+
+        // Make sure we don't double whitespace
+        queue1.retain(|s| !s.is_empty());
+        for idx in 0..queue1.len() {
+            queue1[idx] = trim_extra_ws(queue1[idx]);
+            if idx < queue1.len() - 1
+                && queue1[idx].ends_with(char::is_whitespace)
+                && queue1[idx + 1].starts_with(char::is_whitespace)
+            {
+                queue1[idx] = queue1[idx].trim_end();
+            }
+        }
+        stream.extend(queue1.iter().copied().filter(|txt| !txt.is_empty()).map(&mut f));
+        stream.push(MdTree::ParagraphBreak);
+    }
+
+    if stream.len() - stream_start_len > 1 {
+        let _ = stream.pop(); // remove last unnecessary intersperse
+    }
+}
+
+/// Turn reflinks (links with reference IDs) into normal standalone links using
+/// listed link definitions
+fn match_reflink<'a>(linkdefs: &[MdTree<'a>], disp: &'a str, match_id: Option<&str>) -> MdTree<'a> {
+    let to_match = match_id.unwrap_or(disp); // Match with the display name if there isn't an id
+    for def in linkdefs {
+        if let MdTree::LinkDef { id, link } = def {
+            if *id == to_match {
+                return MdTree::Link { disp, link };
+            }
+        }
+    }
+    MdTree::Link { disp, link: "" } // link not found
+}
+
+/// If there is more than one whitespace char at start or end, trim the extras
+fn trim_extra_ws(mut txt: &str) -> &str {
+    let start_ws =
+        txt.bytes().position(|ch| !ch.is_ascii_whitespace()).unwrap_or(txt.len()).saturating_sub(1);
+    txt = &txt[start_ws..];
+    let end_ws = txt
+        .bytes()
+        .rev()
+        .position(|ch| !ch.is_ascii_whitespace())
+        .unwrap_or(txt.len())
+        .saturating_sub(1);
+    &txt[..txt.len() - end_ws]
+}
+
+/// If there is more than one whitespace char at start, trim the extras
+fn trim_ascii_start(buf: &[u8]) -> &[u8] {
+    let count = buf.iter().take_while(|ch| ch.is_ascii_whitespace()).count();
+    &buf[count..]
+}
+
+#[cfg(test)]
+#[path = "tests/parse.rs"]
+mod tests;
diff --git a/compiler/rustc_errors/src/markdown/term.rs b/compiler/rustc_errors/src/markdown/term.rs
new file mode 100644
index 00000000000..88c3c8b9ff2
--- /dev/null
+++ b/compiler/rustc_errors/src/markdown/term.rs
@@ -0,0 +1,189 @@
+use std::cell::Cell;
+use std::io::{self, Write};
+
+use termcolor::{Buffer, Color, ColorSpec, WriteColor};
+
+use crate::markdown::{MdStream, MdTree};
+
+const DEFAULT_COLUMN_WIDTH: usize = 140;
+
+thread_local! {
+    /// Track the position of viewable characters in our buffer
+    static CURSOR: Cell<usize> = Cell::new(0);
+    /// Width of the terminal
+    static WIDTH: Cell<usize> = Cell::new(DEFAULT_COLUMN_WIDTH);
+}
+
+/// Print to terminal output to a buffer
+pub fn entrypoint(stream: &MdStream<'_>, buf: &mut Buffer) -> io::Result<()> {
+    #[cfg(not(test))]
+    if let Some((w, _)) = termize::dimensions() {
+        WIDTH.with(|c| c.set(std::cmp::min(w, DEFAULT_COLUMN_WIDTH)));
+    }
+    write_stream(stream, buf, None, 0)?;
+    buf.write_all(b"\n")
+}
+
+/// Write the buffer, reset to the default style after each
+fn write_stream(
+    MdStream(stream): &MdStream<'_>,
+    buf: &mut Buffer,
+    default: Option<&ColorSpec>,
+    indent: usize,
+) -> io::Result<()> {
+    match default {
+        Some(c) => buf.set_color(c)?,
+        None => buf.reset()?,
+    }
+
+    for tt in stream {
+        write_tt(tt, buf, indent)?;
+        if let Some(c) = default {
+            buf.set_color(c)?;
+        }
+    }
+
+    buf.reset()?;
+    Ok(())
+}
+
+pub fn write_tt(tt: &MdTree<'_>, buf: &mut Buffer, indent: usize) -> io::Result<()> {
+    match tt {
+        MdTree::CodeBlock { txt, lang: _ } => {
+            buf.set_color(ColorSpec::new().set_dimmed(true))?;
+            buf.write_all(txt.as_bytes())?;
+        }
+        MdTree::CodeInline(txt) => {
+            buf.set_color(ColorSpec::new().set_dimmed(true))?;
+            write_wrapping(buf, txt, indent, None)?;
+        }
+        MdTree::Strong(txt) => {
+            buf.set_color(ColorSpec::new().set_bold(true))?;
+            write_wrapping(buf, txt, indent, None)?;
+        }
+        MdTree::Emphasis(txt) => {
+            buf.set_color(ColorSpec::new().set_italic(true))?;
+            write_wrapping(buf, txt, indent, None)?;
+        }
+        MdTree::Strikethrough(txt) => {
+            buf.set_color(ColorSpec::new().set_strikethrough(true))?;
+            write_wrapping(buf, txt, indent, None)?;
+        }
+        MdTree::PlainText(txt) => {
+            write_wrapping(buf, txt, indent, None)?;
+        }
+        MdTree::Link { disp, link } => {
+            write_wrapping(buf, disp, indent, Some(link))?;
+        }
+        MdTree::ParagraphBreak => {
+            buf.write_all(b"\n\n")?;
+            reset_cursor();
+        }
+        MdTree::LineBreak => {
+            buf.write_all(b"\n")?;
+            reset_cursor();
+        }
+        MdTree::HorizontalRule => {
+            (0..WIDTH.with(Cell::get)).for_each(|_| buf.write_all(b"-").unwrap());
+            reset_cursor();
+        }
+        MdTree::Heading(n, stream) => {
+            let mut cs = ColorSpec::new();
+            cs.set_fg(Some(Color::Cyan));
+            match n {
+                1 => cs.set_intense(true).set_bold(true).set_underline(true),
+                2 => cs.set_intense(true).set_underline(true),
+                3 => cs.set_intense(true).set_italic(true),
+                4.. => cs.set_underline(true).set_italic(true),
+                0 => unreachable!(),
+            };
+            write_stream(stream, buf, Some(&cs), 0)?;
+            buf.write_all(b"\n")?;
+        }
+        MdTree::OrderedListItem(n, stream) => {
+            let base = format!("{n}. ");
+            write_wrapping(buf, &format!("{base:<4}"), indent, None)?;
+            write_stream(stream, buf, None, indent + 4)?;
+        }
+        MdTree::UnorderedListItem(stream) => {
+            let base = "* ";
+            write_wrapping(buf, &format!("{base:<4}"), indent, None)?;
+            write_stream(stream, buf, None, indent + 4)?;
+        }
+        // Patterns popped in previous step
+        MdTree::Comment(_) | MdTree::LinkDef { .. } | MdTree::RefLink { .. } => unreachable!(),
+    }
+
+    buf.reset()?;
+
+    Ok(())
+}
+
+/// End of that block, just wrap the line
+fn reset_cursor() {
+    CURSOR.with(|cur| cur.set(0));
+}
+
+/// Change to be generic on Write for testing. If we have a link URL, we don't
+/// count the extra tokens to make it clickable.
+fn write_wrapping<B: io::Write>(
+    buf: &mut B,
+    text: &str,
+    indent: usize,
+    link_url: Option<&str>,
+) -> io::Result<()> {
+    let ind_ws = &b"          "[..indent];
+    let mut to_write = text;
+    if let Some(url) = link_url {
+        // This is a nonprinting prefix so we don't increment our cursor
+        write!(buf, "\x1b]8;;{url}\x1b\\")?;
+    }
+    CURSOR.with(|cur| {
+        loop {
+            if cur.get() == 0 {
+                buf.write_all(ind_ws)?;
+                cur.set(indent);
+            }
+            let ch_count = WIDTH.with(Cell::get) - cur.get();
+            let mut iter = to_write.char_indices();
+            let Some((end_idx, _ch)) = iter.nth(ch_count) else {
+                // Write entire line
+                buf.write_all(to_write.as_bytes())?;
+                cur.set(cur.get() + to_write.chars().count());
+                break;
+            };
+
+            if let Some((break_idx, ch)) = to_write[..end_idx]
+                .char_indices()
+                .rev()
+                .find(|(_idx, ch)| ch.is_whitespace() || ['_', '-'].contains(ch))
+            {
+                // Found whitespace to break at
+                if ch.is_whitespace() {
+                    writeln!(buf, "{}", &to_write[..break_idx])?;
+                    to_write = to_write[break_idx..].trim_start();
+                } else {
+                    // Break at a `-` or `_` separator
+                    writeln!(buf, "{}", &to_write.get(..break_idx + 1).unwrap_or(to_write))?;
+                    to_write = to_write.get(break_idx + 1..).unwrap_or_default().trim_start();
+                }
+            } else {
+                // No whitespace, we need to just split
+                let ws_idx =
+                    iter.find(|(_, ch)| ch.is_whitespace()).map_or(to_write.len(), |(idx, _)| idx);
+                writeln!(buf, "{}", &to_write[..ws_idx])?;
+                to_write = to_write.get(ws_idx + 1..).map_or("", str::trim_start);
+            }
+            cur.set(0);
+        }
+        if link_url.is_some() {
+            buf.write_all(b"\x1b]8;;\x1b\\")?;
+        }
+
+        Ok(())
+    })
+}
+
+#[cfg(test)]
+#[path = "tests/term.rs"]
+mod tests;
diff --git a/compiler/rustc_errors/src/markdown/tests/input.md b/compiler/rustc_errors/src/markdown/tests/input.md
new file mode 100644
index 00000000000..7d207fc4220
--- /dev/null
+++ b/compiler/rustc_errors/src/markdown/tests/input.md
@@ -0,0 +1,50 @@
+# H1 Heading [with a link][remote-link]
+
+H1 content: **some words in bold** and `so does inline code`
+
+## H2 Heading
+
+H2 content: _some words in italic_
+
+### H3 Heading
+
+H3 content: ~~strikethrough~~ text
+
+#### H4 Heading
+
+H4 content: A [simple link](https://docs.rs) and a [remote-link].
+
+---
+
+A section break was above. We can also do paragraph breaks:
+
+(new paragraph) and unordered lists:
+
+- Item 1 in `code`
+- Item 2 in _italics_
+
+Or ordered:
+
+1. Item 1 in **bold**
+2. Item 2 with some long lines that should wrap: Lorem ipsum dolor sit amet,
+   consectetur adipiscing elit. Aenean ac mattis nunc. Phasellus elit quam,
+   pulvinar ac risus in, dictum vehicula turpis. Vestibulum neque est, accumsan
+   in cursus sit amet, dictum a nunc. Suspendisse aliquet, lorem eu eleifend
+   accumsan, magna neque sodales nisi, a aliquet lectus leo eu sem.
+
+---
+
+## Code
+
+Both `inline code` and code blocks are supported:
+
+```rust
+/// A rust enum
+#[derive(Debug, PartialEq, Clone)]
+enum Foo {
+    /// Start of line
+    Bar
+}
+```
+
+[remote-link]: http://docs.rs
diff --git a/compiler/rustc_errors/src/markdown/tests/output.stdout b/compiler/rustc_errors/src/markdown/tests/output.stdout
new file mode 100644
index 00000000000..23c60d5c319
--- /dev/null
+++ b/compiler/rustc_errors/src/markdown/tests/output.stdout
@@ -0,0 +1,35 @@
+H1 Heading ]8;;http://docs.rs\with a link]8;;\
+H1 content: some words in bold and so does inline code
+
+H2 Heading
+H2 content: some words in italic
+
+H3 Heading
+H3 content: strikethrough text
+
+H4 Heading
+H4 content: A ]8;;https://docs.rs\simple link]8;;\ and a ]8;;http://docs.rs\remote-link]8;;\.
+--------------------------------------------------------------------------------------------------------------------------------------------
+A section break was above. We can also do paragraph breaks:
+
+(new paragraph) and unordered lists:
+
+*   Item 1 in code
+*   Item 2 in italics
+
+Or ordered:
+
+1.  Item 1 in bold
+2.  Item 2 with some long lines that should wrap: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean ac mattis nunc. Phasellus
+    elit quam, pulvinar ac risus in, dictum vehicula turpis. Vestibulum neque est, accumsan in cursus sit amet, dictum a nunc. Suspendisse
+    aliquet, lorem eu eleifend accumsan, magna neque sodales nisi, a aliquet lectus leo eu sem.
+--------------------------------------------------------------------------------------------------------------------------------------------
+Code
+Both inline code and code blocks are supported:
+
+/// A rust enum
+#[derive(Debug, PartialEq, Clone)]
+enum Foo {
+    /// Start of line
+    Bar
+}
diff --git a/compiler/rustc_errors/src/markdown/tests/parse.rs b/compiler/rustc_errors/src/markdown/tests/parse.rs
new file mode 100644
index 00000000000..e39e8c89b35
--- /dev/null
+++ b/compiler/rustc_errors/src/markdown/tests/parse.rs
@@ -0,0 +1,312 @@
+use super::*;
+use ParseOpt as PO;
+
+#[test]
+fn test_parse_simple() {
+    let buf = "**abcd** rest";
+    let (t, r) = parse_simple_pat(buf.as_bytes(), STG, STG, PO::None, MdTree::Strong).unwrap();
+    assert_eq!(t, MdTree::Strong("abcd"));
+    assert_eq!(r, b" rest");
+
+    // Escaping should fail
+    let buf = r"**abcd\** rest";
+    let res = parse_simple_pat(buf.as_bytes(), STG, STG, PO::None, MdTree::Strong);
+    assert!(res.is_none());
+}
+
+#[test]
+fn test_parse_comment() {
+    let opt = PO::TrimNoEsc;
+    let buf = "<!-- foobar! -->rest";
+    let (t, r) = parse_simple_pat(buf.as_bytes(), CMT_S, CMT_E, opt, MdTree::Comment).unwrap();
+    assert_eq!(t, MdTree::Comment("foobar!"));
+    assert_eq!(r, b"rest");
+
+    let buf = r"<!-- foobar! \-->rest";
+    let (t, r) = parse_simple_pat(buf.as_bytes(), CMT_S, CMT_E, opt, MdTree::Comment).unwrap();
+    assert_eq!(t, MdTree::Comment(r"foobar! \"));
+    assert_eq!(r, b"rest");
+}
+
+#[test]
+fn test_parse_heading() {
+    let buf1 = "# Top level\nrest";
+    let (t, r) = parse_heading(buf1.as_bytes()).unwrap();
+    assert_eq!(t, MdTree::Heading(1, vec![MdTree::PlainText("Top level")].into()));
+    assert_eq!(r, b"\nrest");
+
+    let buf1 = "# Empty";
+    let (t, r) = parse_heading(buf1.as_bytes()).unwrap();
+    assert_eq!(t, MdTree::Heading(1, vec![MdTree::PlainText("Empty")].into()));
+    assert_eq!(r, b"");
+
+    // Combo
+    let buf2 = "### Top `level` _woo_\nrest";
+    let (t, r) = parse_heading(buf2.as_bytes()).unwrap();
+    assert_eq!(
+        t,
+        MdTree::Heading(
+            3,
+            vec![
+                MdTree::PlainText("Top "),
+                MdTree::CodeInline("level"),
+                MdTree::PlainText(" "),
+                MdTree::Emphasis("woo"),
+            ]
+            .into()
+        )
+    );
+    assert_eq!(r, b"\nrest");
+}
+
+#[test]
+fn test_parse_code_inline() {
+    let buf1 = "`abcd` rest";
+    let (t, r) = parse_codeinline(buf1.as_bytes()).unwrap();
+    assert_eq!(t, MdTree::CodeInline("abcd"));
+    assert_eq!(r, b" rest");
+
+    // extra backticks, newline
+    let buf2 = "```ab\ncd``` rest";
+    let (t, r) = parse_codeinline(buf2.as_bytes()).unwrap();
+    assert_eq!(t, MdTree::CodeInline("ab\ncd"));
+    assert_eq!(r, b" rest");
+
+    // test no escaping
+    let buf3 = r"`abcd\` rest";
+    let (t, r) = parse_codeinline(buf3.as_bytes()).unwrap();
+    assert_eq!(t, MdTree::CodeInline(r"abcd\"));
+    assert_eq!(r, b" rest");
+}
+
+#[test]
+fn test_parse_code_block() {
+    let buf1 = "```rust\ncode\ncode\n```\nleftovers";
+    let (t, r) = parse_codeblock(buf1.as_bytes());
+    assert_eq!(t, MdTree::CodeBlock { txt: "code\ncode", lang: Some("rust") });
+    assert_eq!(r, b"\nleftovers");
+
+    let buf2 = "`````\ncode\ncode````\n`````\nleftovers";
+    let (t, r) = parse_codeblock(buf2.as_bytes());
+    assert_eq!(t, MdTree::CodeBlock { txt: "code\ncode````", lang: None });
+    assert_eq!(r, b"\nleftovers");
+}
+
+#[test]
+fn test_parse_link() {
+    let simple = "[see here](docs.rs) other";
+    let (t, r) = parse_any_link(simple.as_bytes(), false).unwrap();
+    assert_eq!(t, MdTree::Link { disp: "see here", link: "docs.rs" });
+    assert_eq!(r, b" other");
+
+    let simple_toplevel = "[see here](docs.rs) other";
+    let (t, r) = parse_any_link(simple_toplevel.as_bytes(), true).unwrap();
+    assert_eq!(t, MdTree::Link { disp: "see here", link: "docs.rs" });
+    assert_eq!(r, b" other");
+
+    let reference = "[see here] other";
+    let (t, r) = parse_any_link(reference.as_bytes(), true).unwrap();
+    assert_eq!(t, MdTree::RefLink { disp: "see here", id: None });
+    assert_eq!(r, b" other");
+
+    let reference_full = "[see here][docs-rs] other";
+    let (t, r) = parse_any_link(reference_full.as_bytes(), false).unwrap();
+    assert_eq!(t, MdTree::RefLink { disp: "see here", id: Some("docs-rs") });
+    assert_eq!(r, b" other");
+
+    let reference_def = "[see here]: docs.rs\nother";
+    let (t, r) = parse_any_link(reference_def.as_bytes(), true).unwrap();
+    assert_eq!(t, MdTree::LinkDef { id: "see here", link: "docs.rs" });
+    assert_eq!(r, b"\nother");
+}
+
+const IND1: &str = r"test standard
+    ind
+    ind2
+not ind";
+const IND2: &str = r"test end of stream
+  1
+  2
+";
+const IND3: &str = r"test empty lines
+  1
+  2
+
+not ind";
+
+#[test]
+fn test_indented_section() {
+    let (t, r) = get_indented_section(IND1.as_bytes());
+    assert_eq!(str::from_utf8(t).unwrap(), "test standard\n    ind\n    ind2");
+    assert_eq!(str::from_utf8(r).unwrap(), "\nnot ind");
+
+    let (txt, rest) = get_indented_section(IND2.as_bytes());
+    assert_eq!(str::from_utf8(txt).unwrap(), "test end of stream\n  1\n  2");
+    assert_eq!(str::from_utf8(rest).unwrap(), "\n");
+
+    let (txt, rest) = get_indented_section(IND3.as_bytes());
+    assert_eq!(str::from_utf8(txt).unwrap(), "test empty lines\n  1\n  2");
+    assert_eq!(str::from_utf8(rest).unwrap(), "\n\nnot ind");
+}
+
+const HBT: &str = r"# Heading
+
+content";
+
+#[test]
+fn test_heading_breaks() {
+    let expected = vec![
+        MdTree::Heading(1, vec![MdTree::PlainText("Heading")].into()),
+        MdTree::PlainText("content"),
+    ]
+    .into();
+    let res = entrypoint(HBT);
+    assert_eq!(res, expected);
+}
+
+const NL1: &str = r"start
+
+end";
+const NL2: &str = r"start
+
+
+end";
+const NL3: &str = r"start
+
+
+
+end";
+
+#[test]
+fn test_newline_breaks() {
+    let expected =
+        vec![MdTree::PlainText("start"), MdTree::ParagraphBreak, MdTree::PlainText("end")].into();
+    for (idx, check) in [NL1, NL2, NL3].iter().enumerate() {
+        let res = entrypoint(check);
+        assert_eq!(res, expected, "failed {idx}");
+    }
+}
+
+const WRAP: &str = "plain _italics
+italics_";
+
+#[test]
+fn test_wrap_pattern() {
+    let expected = vec![
+        MdTree::PlainText("plain "),
+        MdTree::Emphasis("italics"),
+        MdTree::Emphasis(" "),
+        MdTree::Emphasis("italics"),
+    ]
+    .into();
+    let res = entrypoint(WRAP);
+    assert_eq!(res, expected);
+}
+
+const WRAP_NOTXT: &str = r"_italics_
+**bold**";
+
+#[test]
+fn test_wrap_notxt() {
+    let expected =
+        vec![MdTree::Emphasis("italics"), MdTree::PlainText(" "), MdTree::Strong("bold")].into();
+    let res = entrypoint(WRAP_NOTXT);
+    assert_eq!(res, expected);
+}
+
+const MIXED_LIST: &str = r"start
+- _italics item_
+<!-- comment -->
+- **bold item**
+  second line [link1](foobar1)
+  third line [link2][link-foo]
+-   :crab:
+    extra indent
+end
+[link-foo]: foobar2
+";
+
+#[test]
+fn test_list() {
+    let expected = vec![
+        MdTree::PlainText("start"),
+        MdTree::ParagraphBreak,
+        MdTree::UnorderedListItem(vec![MdTree::Emphasis("italics item")].into()),
+        MdTree::LineBreak,
+        MdTree::UnorderedListItem(
+            vec![
+                MdTree::Strong("bold item"),
+                MdTree::PlainText(" second line "),
+                MdTree::Link { disp: "link1", link: "foobar1" },
+                MdTree::PlainText(" third line "),
+                MdTree::Link { disp: "link2", link: "foobar2" },
+            ]
+            .into(),
+        ),
+        MdTree::LineBreak,
+        MdTree::UnorderedListItem(
+            vec![MdTree::PlainText("🦀"), MdTree::PlainText(" extra indent")].into(),
+        ),
+        MdTree::ParagraphBreak,
+        MdTree::PlainText("end"),
+    ]
+    .into();
+    let res = entrypoint(MIXED_LIST);
+    assert_eq!(res, expected);
+}
+
+const SMOOSHED: &str = r#"
+start
+### heading
+1. ordered item
+```rust
+println!("Hello, world!");
+```
+`inline`
+``end``
+"#;
+
+#[test]
+fn test_without_breaks() {
+    let expected = vec![
+        MdTree::PlainText("start"),
+        MdTree::ParagraphBreak,
+        MdTree::Heading(3, vec![MdTree::PlainText("heading")].into()),
+        MdTree::OrderedListItem(1, vec![MdTree::PlainText("ordered item")].into()),
+        MdTree::ParagraphBreak,
+        MdTree::CodeBlock { txt: r#"println!("Hello, world!");"#, lang: Some("rust") },
+        MdTree::ParagraphBreak,
+        MdTree::CodeInline("inline"),
+        MdTree::PlainText(" "),
+        MdTree::CodeInline("end"),
+    ]
+    .into();
+    let res = entrypoint(SMOOSHED);
+    assert_eq!(res, expected);
+}
+
+const CODE_STARTLINE: &str = r#"
+start
+`code`
+middle
+`more code`
+end
+"#;
+
+#[test]
+fn test_code_at_start() {
+    let expected = vec![
+        MdTree::PlainText("start"),
+        MdTree::PlainText(" "),
+        MdTree::CodeInline("code"),
+        MdTree::PlainText(" "),
+        MdTree::PlainText("middle"),
+        MdTree::PlainText(" "),
+        MdTree::CodeInline("more code"),
+        MdTree::PlainText(" "),
+        MdTree::PlainText("end"),
+    ]
+    .into();
+    let res = entrypoint(CODE_STARTLINE);
+    assert_eq!(res, expected);
+}
diff --git a/compiler/rustc_errors/src/markdown/tests/term.rs b/compiler/rustc_errors/src/markdown/tests/term.rs
new file mode 100644
index 00000000000..6f68fb25a58
--- /dev/null
+++ b/compiler/rustc_errors/src/markdown/tests/term.rs
@@ -0,0 +1,90 @@
+use std::io::BufWriter;
+use std::path::PathBuf;
+use termcolor::{BufferWriter, ColorChoice};
+
+use super::*;
+use crate::markdown::MdStream;
+
+const INPUT: &str = include_str!("input.md");
+const OUTPUT_PATH: &[&str] = &[env!("CARGO_MANIFEST_DIR"), "src","markdown","tests","output.stdout"];
+
+const TEST_WIDTH: usize = 80;
+
+// We try to make some words long to create corner cases
+const TXT: &str = r"Lorem ipsum dolor sit amet, consecteturadipiscingelit.
+Fusce-id-urna-sollicitudin, pharetra nisl nec, lobortis tellus. In at
+metus hendrerit, tincidunteratvel, ultrices turpis. Curabitur_risus_sapien,
+porta-sed-nunc-sed, ultricesposuerelacus. Sed porttitor quis
+dolor non venenatis. Aliquam ut. ";
+
+const WRAPPED: &str = r"Lorem ipsum dolor sit amet, consecteturadipiscingelit. Fusce-id-urna-
+sollicitudin, pharetra nisl nec, lobortis tellus. In at metus hendrerit,
+tincidunteratvel, ultrices turpis. Curabitur_risus_sapien, porta-sed-nunc-sed,
+ultricesposuerelacus. Sed porttitor quis dolor non venenatis. Aliquam ut. Lorem
+    ipsum dolor sit amet, consecteturadipiscingelit. Fusce-id-urna-
+    sollicitudin, pharetra nisl nec, lobortis tellus. In at metus hendrerit,
+    tincidunteratvel, ultrices turpis. Curabitur_risus_sapien, porta-sed-nunc-
+    sed, ultricesposuerelacus. Sed porttitor quis dolor non venenatis. Aliquam
+    ut. Sample link lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet,
+consecteturadipiscingelit. Fusce-id-urna-sollicitudin, pharetra nisl nec,
+lobortis tellus. In at metus hendrerit, tincidunteratvel, ultrices turpis.
+Curabitur_risus_sapien, porta-sed-nunc-sed, ultricesposuerelacus. Sed porttitor
+quis dolor non venenatis. Aliquam ut. ";
+
+#[test]
+fn test_wrapping_write() {
+    WIDTH.with(|w| w.set(TEST_WIDTH));
+    let mut buf = BufWriter::new(Vec::new());
+    let txt = TXT.replace("-\n","-").replace("_\n","_").replace('\n', " ").replace("    ", "");
+    write_wrapping(&mut buf, &txt, 0, None).unwrap();
+    write_wrapping(&mut buf, &txt, 4, None).unwrap();
+    write_wrapping(
+        &mut buf,
+        "Sample link lorem ipsum dolor sit amet. ",
+        4,
+        Some("link-address-placeholder"),
+    )
+    .unwrap();
+    write_wrapping(&mut buf, &txt, 0, None).unwrap();
+    let out = String::from_utf8(buf.into_inner().unwrap()).unwrap();
+    let out = out
+        .replace("\x1b\\", "")
+        .replace('\x1b', "")
+        .replace("]8;;", "")
+        .replace("link-address-placeholder", "");
+
+    for line in out.lines() {
+        assert!(line.len() <= TEST_WIDTH, "line length\n'{line}'")
+    }
+
+    assert_eq!(out, WRAPPED);
+}
+
+#[test]
+fn test_output() {
+    // Capture `--bless` when run via ./x
+    let bless = std::env::var_os("RUSTC_BLESS").is_some_and(|v| v != "0");
+    let ast = MdStream::parse_str(INPUT);
+    let bufwtr = BufferWriter::stderr(ColorChoice::Always);
+    let mut buffer = bufwtr.buffer();
+    ast.write_termcolor_buf(&mut buffer).unwrap();
+
+    let mut blessed = PathBuf::new();
+    blessed.extend(OUTPUT_PATH);
+
+    if bless {
+        std::fs::write(&blessed, buffer.into_inner()).unwrap();
+        eprintln!("blessed output at {}", blessed.display());
+    } else {
+        let output = buffer.into_inner();
+        if std::fs::read(blessed).unwrap() != output {
+            // hack: I don't know any way to write bytes to the captured stdout
+            // that cargo test uses
+            let mut out = std::io::stdout();
+            out.write_all(b"\n\nMarkdown output did not match. Expected:\n").unwrap();
+            out.write_all(&output).unwrap();
+            out.write_all(b"\n\n").unwrap();
+            panic!("markdown output mismatch");
+        }
+    }
+}