diff --git a/Cargo.lock b/Cargo.lock index 1d9a9240ccf2c..4ec04f6658c78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3675,6 +3675,7 @@ name = "rustc_errors" version = "0.0.0" dependencies = [ "annotate-snippets", + "regex", "rustc_ast", "rustc_ast_pretty", "rustc_data_structures", diff --git a/compiler/rustc_driver/src/lib.rs b/compiler/rustc_driver/src/lib.rs index 711eed2b27231..cb49676cccb9e 100644 --- a/compiler/rustc_driver/src/lib.rs +++ b/compiler/rustc_driver/src/lib.rs @@ -22,6 +22,7 @@ use rustc_ast as ast; use rustc_codegen_ssa::{traits::CodegenBackend, CodegenErrors, CodegenResults}; use rustc_data_structures::profiling::{get_resident_set_size, print_time_passes_entry}; use rustc_data_structures::sync::SeqCst; +use rustc_errors::markdown; use rustc_errors::registry::{InvalidErrorCode, Registry}; use rustc_errors::{ErrorGuaranteed, PResult}; use rustc_feature::find_gated_cfg; @@ -511,7 +512,7 @@ fn handle_explain(registry: Registry, code: &str, output: ErrorOutputType) { text.push('\n'); } if io::stdout().is_terminal() { - show_content_with_pager(&text); + show_md_content_with_pager(&text); } else { print!("{}", text); } @@ -525,17 +526,38 @@ fn handle_explain(registry: Registry, code: &str, output: ErrorOutputType) { } } -fn show_content_with_pager(content: &str) { +fn show_md_content_with_pager(content: &str) { + let mut print_color = true; + let mut fallback_to_println = false; + let pager_name = env::var_os("PAGER").unwrap_or_else(|| { if cfg!(windows) { OsString::from("more.com") } else { OsString::from("less") } }); - let mut fallback_to_println = false; + let mut cmd = Command::new(&pager_name); + + // FIXME: find if other pagers accept color options + if pager_name == "less" { + cmd.arg("-r"); + } else { + print_color = false; + }; - match Command::new(pager_name).stdin(Stdio::piped()).spawn() { + let md_ast = markdown::create_ast(content); + let bufwtr = markdown::create_stdout_bufwtr(); + let mut buffer = bufwtr.buffer(); + md_ast.write_termcolor_buf(&mut buffer); + + match cmd.stdin(Stdio::piped()).spawn() { Ok(mut pager) => { if let Some(pipe) = pager.stdin.as_mut() { - if pipe.write_all(content.as_bytes()).is_err() { + let res = if print_color { + pipe.write_all(buffer.as_slice()) + } else { + pipe.write_all(content.as_bytes()) + }; + + if res.is_err() { fallback_to_println = true; } } @@ -551,8 +573,13 @@ fn show_content_with_pager(content: &str) { // If pager fails for whatever reason, we should still print the content // to standard output - if fallback_to_println { - print!("{}", content); + if fallback_to_println && print_color { + // If we fail to print the buffer, we'll just fall back to println + print_color = bufwtr.print(&buffer).is_ok(); + } + + if fallback_to_println && !print_color { + println!("{content}"); } } diff --git a/compiler/rustc_errors/Cargo.toml b/compiler/rustc_errors/Cargo.toml index dee7a31ec2028..f84fc1556b361 100644 --- a/compiler/rustc_errors/Cargo.toml +++ b/compiler/rustc_errors/Cargo.toml @@ -23,6 +23,7 @@ annotate-snippets = "0.9" termize = "0.1.1" serde = { version = "1.0.125", features = [ "derive" ] } serde_json = "1.0.59" +regex = "1.5" [target.'cfg(windows)'.dependencies] winapi = { version = "0.3", features = [ "handleapi", "synchapi", "winbase" ] } diff --git a/compiler/rustc_errors/src/lib.rs b/compiler/rustc_errors/src/lib.rs index 518b5ec10f890..382a76bfbc5f5 100644 --- a/compiler/rustc_errors/src/lib.rs +++ b/compiler/rustc_errors/src/lib.rs @@ -9,6 +9,7 @@ #![feature(adt_const_params)] #![feature(let_chains)] #![feature(never_type)] +#![feature(once_cell)] #![feature(result_option_inspect)] #![feature(rustc_attrs)] #![allow(incomplete_features)] @@ -56,6 +57,7 @@ mod diagnostic_impls; pub mod emitter; pub mod json; mod lock; +pub mod markdown; pub mod registry; mod snippet; mod styled_buffer; diff --git a/compiler/rustc_errors/src/markdown.rs b/compiler/rustc_errors/src/markdown.rs new file mode 100644 index 0000000000000..93b94d6631697 --- /dev/null +++ b/compiler/rustc_errors/src/markdown.rs @@ -0,0 +1,462 @@ +//! A very minimal markdown parser +//! +//! Use the entrypoint `create_ast(&str) -> MdTree` to generate the AST. +#![warn(clippy::pedantic)] +#![warn(clippy::nursery)] + +use regex::bytes::Regex; +use std::cmp::min; +use std::io::Error; +use std::io::Write; +use std::str::{self, from_utf8}; +use std::sync::LazyLock; +use termcolor::{Buffer, BufferWriter, Color, ColorChoice, ColorSpec, WriteColor}; + +const NEWLINE_CHARS: &[u8; 2] = b"\r\n"; +const BREAK_CHARS: &[u8; 10] = br#".,"'\;:?()"#; + +/// Representation of how to match various markdown types +const PATTERNS: [MdPattern; 10] = [ + MdPattern::new(Anchor::Any(""), MdType::Comment), + MdPattern::new(Anchor::Sol("```"), Anchor::Sol("```"), MdType::CodeBlock), + MdPattern::new(Anchor::Sol("# "), Anchor::Eol(""), MdType::Heading1), + MdPattern::new(Anchor::Sol("## "), Anchor::Eol(""), MdType::Heading2), + MdPattern::new(Anchor::Sol("### "), Anchor::Eol(""), MdType::Heading3), + MdPattern::new(Anchor::Sol("#### "), Anchor::Eol(""), MdType::Heading4), + MdPattern::new(Anchor::LeadBreak("`"), Anchor::TrailBreak("`"), MdType::CodeInline), + MdPattern::new(Anchor::LeadBreak("**"), Anchor::TrailBreak("**"), MdType::Strong), + MdPattern::new(Anchor::LeadBreak("_"), Anchor::TrailBreak("_"), MdType::Emphasis), + MdPattern::new(Anchor::Sol("-"), Anchor::Eol(""), MdType::ListItem), + // MdPattern::new(Anchor::Any("\n\n"),Anchor::Any("")) + // strikethrough +]; + +/// This is an example for using doc comment attributes +static RE_URL_NAMED: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?P^|\s|[[:punct:]])\[(?P.+)\]\((?P\S+)\)(?P$|\s|[[:punct:]])" +).unwrap() +}); +static RE_URL: LazyLock = LazyLock::new(|| { + Regex::new(r"(?P^|\s|[[:punct:]])<(?P\S+)>(?P$|\s|[[:punct:]])").unwrap() +}); + +/// An AST representation of a Markdown document +#[derive(Debug, PartialEq, Clone)] +pub enum MdTree<'a> { + /// Leaf types + Comment(&'a str), + CodeBlock(&'a str), + CodeInline(&'a str), + Strong(&'a str), + Emphasis(&'a str), + PlainText(&'a str), + /// Nexting types + Heading1(Vec>), + Heading2(Vec>), + Heading3(Vec>), + Heading4(Vec>), + ListItem(Vec>), + /// Root node + Root(Vec>), +} + +impl<'a> MdTree<'a> { + /// Create a `MdTree` from a string and a specified type + fn from_type(s: &'a str, tag: MdType) -> Self { + match tag { + MdType::Comment => Self::Comment(s), + MdType::CodeBlock => Self::CodeBlock(s), + MdType::CodeInline => Self::CodeInline(s), + MdType::Strong => Self::Strong(s), + MdType::Emphasis => Self::Emphasis(s), + MdType::Heading1 => Self::Heading1(vec![MdTree::PlainText(s)]), + MdType::Heading2 => Self::Heading2(vec![MdTree::PlainText(s)]), + MdType::Heading3 => Self::Heading3(vec![MdTree::PlainText(s)]), + MdType::Heading4 => Self::Heading4(vec![MdTree::PlainText(s)]), + MdType::ListItem => Self::ListItem(vec![MdTree::PlainText(s)]), + } + } + + /// Print to terminal output, resetting to the default style after each + fn term_print_vec( + v: &Vec, + buf: &mut Buffer, + default: Option<&ColorSpec>, + is_firstline: &mut bool, + ) -> Result<(), Error> { + match default { + Some(c) => buf.set_color(c)?, + None => buf.reset()?, + } + + for item in v { + item.term_write_recurse(buf, is_firstline)?; + if let Some(c) = default { + buf.set_color(c)?; + } + } + + buf.reset()?; + Ok(()) + } + + fn term_write_recurse(&self, buf: &mut Buffer, is_firstline: &mut bool) -> Result<(), Error> { + match self { + // Do nothing + MdTree::Comment(_) => (), + MdTree::CodeBlock(s) => { + buf.set_color(ColorSpec::new().set_dimmed(true))?; + write_if_not_first(buf, "\n", is_firstline)?; + // Account for "```rust\n..." starts of strings + if !s.starts_with('\n') { + write!(buf, "{}\n", s.split('\n').nth(1).unwrap_or(s).trim())?; + } else { + write!(buf, "{}\n", s.trim())?; + } + } + MdTree::CodeInline(s) => { + buf.set_color(ColorSpec::new().set_dimmed(true))?; + write!(buf, "{s}")?; + } + MdTree::Strong(s) => { + buf.set_color(ColorSpec::new().set_bold(true))?; + buf.write(&write_replace(s))?; + } + MdTree::Emphasis(s) => { + buf.set_color(ColorSpec::new().set_italic(true))?; + buf.write(&write_replace(s))?; + } + MdTree::Heading1(v) => { + write_if_not_first(buf, "\n", is_firstline)?; + Self::term_print_vec( + v, + buf, + Some( + ColorSpec::new() + .set_fg(Some(Color::Cyan)) + .set_intense(true) + .set_bold(true) + .set_underline(true), + ), + is_firstline, + )?; + } + MdTree::Heading2(v) => { + write_if_not_first(buf, "\n", is_firstline)?; + Self::term_print_vec( + v, + buf, + Some( + ColorSpec::new() + .set_fg(Some(Color::Cyan)) + .set_intense(true) + .set_underline(true), + ), + is_firstline, + )?; + } + MdTree::Heading3(v) => { + Self::term_print_vec( + v, + buf, + Some( + ColorSpec::new() + .set_fg(Some(Color::Cyan)) + .set_intense(true) + .set_italic(true), + ), + is_firstline, + )?; + } + MdTree::Heading4(v) => { + Self::term_print_vec( + v, + buf, + Some( + ColorSpec::new() + .set_fg(Some(Color::Cyan)) + .set_underline(true) + .set_italic(true), + ), + is_firstline, + )?; + } + MdTree::ListItem(v) => { + write!(buf, "* ")?; + Self::term_print_vec(v, buf, None, is_firstline)?; + } + MdTree::Root(v) => Self::term_print_vec(v, buf, None, is_firstline)?, + MdTree::PlainText(s) => { + buf.write(&write_replace(s))?; + } + } + + buf.reset()?; + Ok(()) + } + + pub fn write_termcolor_buf(&self, buf: &mut Buffer) { + let _ = self.term_write_recurse(buf, &mut true); + } +} + +/// Grumble grumble workaround for not being able to `as` cast mixed field enums +#[derive(Debug, PartialEq, Copy, Clone)] +enum MdType { + Comment, + CodeBlock, + CodeInline, + Heading1, + Heading2, + Heading3, + Heading4, + Strong, + Emphasis, + ListItem, +} + +/// A representation of the requirements to match a pattern +#[derive(Debug, PartialEq, Clone)] +enum Anchor { + /// Start of line + Sol(&'static str), + /// End of line + Eol(&'static str), + /// Preceded by whitespace or punctuation + LeadBreak(&'static str), + /// Precedes whitespace or punctuation + TrailBreak(&'static str), + /// Plain pattern matching + Any(&'static str), +} + +impl Anchor { + /// Get any inner value + const fn unwrap(&self) -> &str { + match self { + Self::Sol(s) + | Self::Eol(s) + | Self::LeadBreak(s) + | Self::TrailBreak(s) + | Self::Any(s) => s, + } + } +} + +/// Context used for pattern matching +#[derive(Debug, PartialEq, Clone)] +struct Context { + at_line_start: bool, + preceded_by_break: bool, +} + +/// A simple markdown type +#[derive(Debug, PartialEq, Clone)] +struct MdPattern { + start: Anchor, + end: Anchor, + tag: MdType, +} + +/// Return matched data and leftover data +#[derive(Debug, PartialEq, Clone)] +struct MdResult<'a> { + matched: MdTree<'a>, + residual: &'a [u8], +} + +impl MdPattern { + const fn new(start: Anchor, end: Anchor, tag: MdType) -> Self { + Self { start, end, tag } + } + + /// Given a string like `match]residual`, return `match` and `residual` within `MdResult` + fn parse_end<'a>(&self, bytes: &'a [u8], ctx: &Context) -> MdResult<'a> { + let mut i = 0usize; + let mut at_line_start: bool; // whether this index is the start of a line + let mut next_at_line_start = ctx.at_line_start; + let anchor = &self.end; + let pat_end = anchor.unwrap().as_bytes(); + + while i < bytes.len() { + let working = &bytes[i..]; + at_line_start = next_at_line_start; + next_at_line_start = NEWLINE_CHARS.contains(&working[0]); + + if !working.starts_with(pat_end) { + i += 1; + continue; + } + + // Our pattern matches. Just break if there is no remaining + // string + let residual = &working[pat_end.len()..]; + + let Some(next_byte) = residual.first() else { + break + }; + + // Validate postconditions if we have a remaining string + let is_matched = match anchor { + Anchor::TrailBreak(_) => is_break_char(*next_byte), + Anchor::Eol(_) => NEWLINE_CHARS.contains(next_byte), + Anchor::Sol(_) => at_line_start, + Anchor::Any(_) => true, + Anchor::LeadBreak(_) => panic!("unexpected end pattern"), + }; + + if is_matched { + break; + } + i += 1; + } + + let matched = MdTree::from_type(from_utf8(&bytes[..i]).unwrap(), self.tag); + let residual = &bytes[min(bytes.len(), i + pat_end.len())..]; + + MdResult { matched, residual } + } + + /// Given a string like `[match]residual`, return `MdTree(match)` and `residual` + /// + /// Return `None` if the string does not start with the correct pattern + fn parse_start<'a>(&self, bytes: &'a [u8], ctx: &Context) -> Option> { + // Guard for strings that do not match + if !ctx.at_line_start && matches!(self.start, Anchor::Sol(_)) { + return None; + } + if !ctx.preceded_by_break && matches!(self.start, Anchor::LeadBreak(_)) { + return None; + } + + // Return if we don't start with the pattern + let pat_start = self.start.unwrap().as_bytes(); + if !bytes.starts_with(pat_start) { + return None; + } + + // We have a match, parse to the closing delimiter + let residual = &bytes[pat_start.len()..]; + Some(self.parse_end(residual, ctx)) + } +} + +/// Apply `recurse_tree` to each element in a vector +fn recurse_vec<'a>(v: Vec>) -> Vec> { + v.into_iter().flat_map(recurse_tree).collect() +} + +/// Given a `MdTree`, expand all children +fn recurse_tree<'a>(tree: MdTree<'a>) -> Vec> { + match tree { + // Leaf nodes; just add + MdTree::Comment(_) + | MdTree::CodeBlock(_) + | MdTree::CodeInline(_) + | MdTree::Strong(_) + | MdTree::Emphasis(_) => vec![tree], + // Leaf node with possible further expansion + MdTree::PlainText(s) => parse_str(s), + // Parent nodes; recurse these and add + MdTree::Heading1(v) => vec![MdTree::Heading1(recurse_vec(v))], + MdTree::Heading2(v) => vec![MdTree::Heading2(recurse_vec(v))], + MdTree::Heading3(v) => vec![MdTree::Heading3(recurse_vec(v))], + MdTree::Heading4(v) => vec![MdTree::Heading4(recurse_vec(v))], + MdTree::ListItem(v) => vec![MdTree::ListItem(recurse_vec(v))], + MdTree::Root(v) => vec![MdTree::Root(recurse_vec(v))], + } +} + +/// Main parser function for a single string +fn parse_str<'a>(s: &'a str) -> Vec> { + let mut v: Vec> = Vec::new(); + let mut ctx = Context { at_line_start: true, preceded_by_break: true }; + let mut next_ctx = ctx.clone(); + let mut working = s.as_bytes(); + let mut i = 0; + + while i < working.len() { + let test_slice = &working[i..]; + let current_char = test_slice.first().unwrap(); + + ctx = next_ctx.clone(); + next_ctx.at_line_start = NEWLINE_CHARS.contains(current_char); + next_ctx.preceded_by_break = is_break_char(*current_char); + + let found = PATTERNS.iter().find_map(|p| p.parse_start(&working[i..], &ctx)); + + let Some(res) = found else { + i += 1; + continue; + }; + + if i > 0 { + v.push(MdTree::PlainText(from_utf8(&working[..i]).unwrap())); + } + v.append(&mut recurse_tree(res.matched)); + working = res.residual; + i = 0; + } + + if i > 0 { + v.push(MdTree::PlainText(from_utf8(&working[..i]).unwrap())); + } + + v +} + +/// Test if a character is whitespace or a breaking character (punctuation) +fn is_break_char(c: u8) -> bool { + c.is_ascii_whitespace() || BREAK_CHARS.contains(&c) +} + +#[must_use] +pub fn create_ast<'a>(s: &'a str) -> MdTree<'a> { + MdTree::Root(parse_str(s)) +} + +fn write_replace(s: &str) -> Vec { + const REPLACEMENTS: [(&str, &str); 7] = [ + ("(c)", "©"), + ("(C)", "©"), + ("(r)", "®"), + ("(R)", "®"), + ("(tm)", "™"), + ("(TM)", "™"), + (":crab:", "🦀"), + ]; + + let mut ret = s.to_owned(); + for (from, to) in REPLACEMENTS { + ret = ret.replace(from, to); + } + + let tmp = RE_URL_NAMED.replace_all( + ret.as_bytes(), + b"${pre_ws}\x1b]8;;${url}\x1b\\${display}\x1b]8;;\x1b\\${post_ws}".as_slice(), + ); + RE_URL + .replace_all( + &tmp, + b"${pre_ws}\x1b]8;;${url}\x1b\\${url}\x1b]8;;\x1b\\${post_ws}".as_slice(), + ) + .to_vec() +} + +/// Write something to the buf if some first indicator is false, then set the indicator false +fn write_if_not_first(buf: &mut Buffer, s: &str, is_firstline: &mut bool) -> Result<(), Error> { + if *is_firstline { + *is_firstline = false; + } else { + write!(buf, "{s}")?; + } + Ok(()) +} + +pub fn create_stdout_bufwtr() -> BufferWriter { + BufferWriter::stdout(ColorChoice::Always) +} + +#[cfg(test)] +mod tests; diff --git a/compiler/rustc_errors/src/markdown/tests.rs b/compiler/rustc_errors/src/markdown/tests.rs new file mode 100644 index 0000000000000..fb5b8c8b7abb3 --- /dev/null +++ b/compiler/rustc_errors/src/markdown/tests.rs @@ -0,0 +1,165 @@ +use super::*; + +#[test] +fn test_comment() { + const TAG: MdType = MdType::Comment; + let pat = PATTERNS.iter().find(|p| p.tag == TAG).unwrap(); + let ctx = Context { at_line_start: true, preceded_by_break: true }; + + let input = b"noneresidual"; + assert_eq!( + pat.parse_end(input, &ctx), + MdResult { matched: MdTree::Comment("noneresidual"; + let expected = MdResult { matched: MdTree::Comment("comment\n"), residual: b"residual" }; + assert_eq!(pat.parse_start(input, &ctx), Some(expected)); +} + +#[test] +fn test_code_block() { + const TAG: MdType = MdType::CodeBlock; + let pat = PATTERNS.iter().find(|p| p.tag == TAG).unwrap(); + let ctx = Context { at_line_start: true, preceded_by_break: true }; + + let input = b"none\n```\nblock\n```"; + let end_expected = + MdResult { matched: MdTree::from_type("none\n", TAG), residual: b"\nblock\n```" }; + assert_eq!(pat.parse_end(input, &ctx), end_expected); + assert_eq!(pat.parse_start(input, &ctx), None); + + let input = b"```\nblock\nof\ncode\n```residual"; + let expected = + MdResult { matched: MdTree::from_type("\nblock\nof\ncode\n", TAG), residual: b"residual" }; + assert_eq!(pat.parse_start(input, &ctx), Some(expected)); + + let ctx = Context { at_line_start: false, preceded_by_break: true }; + assert_eq!(pat.parse_start(input, &ctx), None); +} + +#[test] +fn test_headings() { + const TAG: MdType = MdType::Heading1; + let pat = PATTERNS.iter().find(|p| p.tag == TAG).unwrap(); + let ctx = Context { at_line_start: true, preceded_by_break: true }; + + let input = b"content\nresidual"; + let end_expected = MdResult { + // Only match if whitespace comes after + matched: MdTree::from_type("content", TAG), + residual: b"\nresidual", + }; + assert_eq!(pat.parse_end(input, &ctx), end_expected); + assert_eq!(pat.parse_start(input, &ctx), None); + + let input = b"# content\nresidual"; + let expected = MdResult { matched: MdTree::from_type("content", TAG), residual: b"\nresidual" }; + assert_eq!(pat.parse_start(input, &ctx), Some(expected)); + + let ctx = Context { at_line_start: false, preceded_by_break: true }; + assert_eq!(pat.parse_start(input, &ctx), None); +} + +#[test] +fn test_code_inline() { + const TAG: MdType = MdType::CodeInline; + let pat = PATTERNS.iter().find(|p| p.tag == TAG).unwrap(); + let ctx = Context { at_line_start: false, preceded_by_break: true }; + + let input = b"none `block` residual"; + let end_expected = MdResult { + // Only match if whitespace comes after + matched: MdTree::from_type("none `block", TAG), + residual: b" residual", + }; + assert_eq!(pat.parse_end(input, &ctx), end_expected); + assert_eq!(pat.parse_start(input, &ctx), None); + + let input = b"`block` residual"; + let expected = MdResult { matched: MdTree::from_type("block", TAG), residual: b" residual" }; + assert_eq!(pat.parse_start(input, &ctx), Some(expected)); + + let ctx = Context { at_line_start: false, preceded_by_break: false }; + assert_eq!(pat.parse_start(input, &ctx), None); +} + +const MD_INPUT: &str = r#"# Headding 1 + +Some `inline code` + +``` +code block here; +more code; +``` + + +Further `inline`, some **bold**, a bit of _italics +wrapped across lines_. We can also try (`code inside parentheses`). + +Let's end with a list: + +- Item 1 _italics_ example +- Item 2 **bold** + +## Heading 2: Other things for `code` + +_start of line_ +**more start of line** + +```rust +try two of everything +``` + +"#; + +fn expected_ast() -> MdTree<'static> { + MdTree::Root(vec![ + MdTree::Heading1(vec![MdTree::PlainText("Headding 1")]), + MdTree::PlainText("\n\nSome "), + MdTree::CodeInline("inline code"), + MdTree::PlainText("\n\n"), + MdTree::CodeBlock("\ncode block here;\nmore code;\n"), + MdTree::PlainText("\n\n"), + MdTree::Comment(" I should disappear "), + MdTree::PlainText("\nFurther "), + MdTree::CodeInline("inline"), + MdTree::PlainText(", some "), + MdTree::Strong("bold"), + MdTree::PlainText(", a bit of "), + MdTree::Emphasis("italics\nwrapped across lines"), + MdTree::PlainText(". We can also try ("), + MdTree::CodeInline("code inside parentheses"), + MdTree::PlainText(").\n\nLet's end with a list:\n\n"), + MdTree::ListItem(vec![ + MdTree::PlainText(" Item 1 "), + MdTree::Emphasis("italics"), + MdTree::PlainText(" example"), + ]), + MdTree::PlainText("\n"), + MdTree::ListItem(vec![MdTree::PlainText(" Item 2 "), MdTree::Strong("bold")]), + MdTree::PlainText("\n\n"), + MdTree::Heading2(vec![ + MdTree::PlainText("Heading 2: Other things for "), + MdTree::CodeInline("code"), + ]), + MdTree::PlainText("\n\n"), + MdTree::Emphasis("start of line"), + MdTree::PlainText("\n"), + MdTree::Strong("more start of line"), + MdTree::PlainText("\n\n"), + MdTree::CodeBlock("rust\ntry two of everything\n"), + MdTree::PlainText("\n"), + MdTree::Comment("\n another\n comment "), + MdTree::PlainText("\n"), + ]) +} + +#[test] +fn test_tree() { + let result = create_ast(MD_INPUT); + assert_eq!(result, expected_ast()); +}