diff options
Diffstat (limited to 'compiler/rustc_errors/src')
| -rw-r--r-- | compiler/rustc_errors/src/diagnostic.rs | 10 | ||||
| -rw-r--r-- | compiler/rustc_errors/src/diagnostic_builder.rs | 4 | ||||
| -rw-r--r-- | compiler/rustc_errors/src/diagnostic_impls.rs | 69 | ||||
| -rw-r--r-- | compiler/rustc_errors/src/emitter.rs | 8 | ||||
| -rw-r--r-- | compiler/rustc_errors/src/json/tests.rs | 2 | ||||
| -rw-r--r-- | compiler/rustc_errors/src/lib.rs | 183 | ||||
| -rw-r--r-- | compiler/rustc_errors/src/markdown/mod.rs | 76 | ||||
| -rw-r--r-- | compiler/rustc_errors/src/markdown/parse.rs | 588 | ||||
| -rw-r--r-- | compiler/rustc_errors/src/markdown/term.rs | 189 | ||||
| -rw-r--r-- | compiler/rustc_errors/src/markdown/tests/input.md | 50 | ||||
| -rw-r--r-- | compiler/rustc_errors/src/markdown/tests/output.stdout | 35 | ||||
| -rw-r--r-- | compiler/rustc_errors/src/markdown/tests/parse.rs | 312 | ||||
| -rw-r--r-- | compiler/rustc_errors/src/markdown/tests/term.rs | 90 |
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 @@ +[0m[0m[1m[4m[38;5;14mH1 Heading [0m[0m[1m[4m[38;5;14m]8;;http://docs.rs\with a link]8;;\[0m[0m[1m[4m[38;5;14m[0m +[0mH1 content: [0m[0m[1msome words in bold[0m and [0m[0m[2mso does inline code[0m + +[0m[0m[4m[38;5;14mH2 Heading[0m[0m[4m[38;5;14m[0m +[0mH2 content: [0m[0m[3msome words in italic[0m + +[0m[0m[3m[38;5;14mH3 Heading[0m[0m[3m[38;5;14m[0m +[0mH3 content: [0m[0m[9mstrikethrough[0m text[0m + +[0m[0m[3m[4m[36mH4 Heading[0m[0m[3m[4m[36m[0m +[0mH4 content: A [0m]8;;https://docs.rs\simple link]8;;\[0m and a [0m]8;;http://docs.rs\remote-link]8;;\[0m.[0m +[0m--------------------------------------------------------------------------------------------------------------------------------------------[0m +[0mA section break was above. We can also do paragraph breaks:[0m + +[0m(new paragraph) and unordered lists:[0m + +[0m* [0mItem 1 in [0m[0m[2mcode[0m[0m[0m +[0m* [0mItem 2 in [0m[0m[3mitalics[0m[0m[0m + +[0mOr ordered:[0m + +[0m1. [0mItem 1 in [0m[0m[1mbold[0m[0m[0m +[0m2. [0mItem 2 with some long lines that should wrap: Lorem ipsum dolor sit amet,[0m consectetur adipiscing elit. Aenean ac mattis nunc. Phasellus + elit quam,[0m pulvinar ac risus in, dictum vehicula turpis. Vestibulum neque est, accumsan[0m in cursus sit amet, dictum a nunc. Suspendisse + aliquet, lorem eu eleifend[0m accumsan, magna neque sodales nisi, a aliquet lectus leo eu sem.[0m[0m[0m +[0m--------------------------------------------------------------------------------------------------------------------------------------------[0m +[0m[0m[4m[38;5;14mCode[0m[0m[4m[38;5;14m[0m +[0mBoth [0m[0m[2minline code[0m and code blocks are supported:[0m + +[0m[0m[2m/// A rust enum +#[derive(Debug, PartialEq, Clone)] +enum Foo { + /// Start of line + Bar +}[0m[0m 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"); + } + } +} |
