diff --git a/Cargo.toml b/Cargo.toml index 45bfd139..d164479c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,12 @@ keywords = ["commonmark", "markdown", "parse", "render", "tokenize"] categories = ["compilers", "encoding", "parser-implementations", "parsing", "text-processing"] include = ["src/", "license"] +[workspace] +members = ["generate", "mdast_util_to_markdown"] + +[workspace.dependencies] +pretty_assertions = "1" + [[bench]] name = "bench" path = "benches/bench.rs" @@ -31,7 +37,7 @@ serde = { version = "1", features = ["derive"], optional = true } [dev-dependencies] env_logger = "0.11" criterion = "0.5" -pretty_assertions = "1" +pretty_assertions = { workspace = true } serde_json = { version = "1" } swc_core = { version = "0.100", features = [ "ecma_ast", @@ -39,6 +45,3 @@ swc_core = { version = "0.100", features = [ "ecma_parser", "common", ] } - -[workspace] -members = ["generate"] diff --git a/mdast_util_to_markdown/Cargo.toml b/mdast_util_to_markdown/Cargo.toml new file mode 100644 index 00000000..faf3bccb --- /dev/null +++ b/mdast_util_to_markdown/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "mdast_util_to_markdown" +version = "0.0.0" +edition = "2018" +license = "MIT" + +[dependencies] +markdown = { path = "../" } +regex = { version = "1.7.3" } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/mdast_util_to_markdown/src/association.rs b/mdast_util_to_markdown/src/association.rs new file mode 100644 index 00000000..a5e9d16f --- /dev/null +++ b/mdast_util_to_markdown/src/association.rs @@ -0,0 +1,37 @@ +use alloc::string::String; +use markdown::mdast::{Definition, ImageReference, LinkReference}; + +pub trait Association { + fn identifier(&self) -> &String; + fn label(&self) -> &Option; +} + +impl Association for Definition { + fn identifier(&self) -> &String { + &self.identifier + } + + fn label(&self) -> &Option { + &self.label + } +} + +impl Association for ImageReference { + fn identifier(&self) -> &String { + &self.identifier + } + + fn label(&self) -> &Option { + &self.label + } +} + +impl Association for LinkReference { + fn identifier(&self) -> &String { + &self.identifier + } + + fn label(&self) -> &Option { + &self.label + } +} diff --git a/mdast_util_to_markdown/src/configure.rs b/mdast_util_to_markdown/src/configure.rs new file mode 100644 index 00000000..0960ea84 --- /dev/null +++ b/mdast_util_to_markdown/src/configure.rs @@ -0,0 +1,52 @@ +#[allow(dead_code)] +pub struct Options { + pub bullet: char, + pub bullet_other: char, + pub bullet_ordered: char, + pub emphasis: char, + pub fence: char, + pub fences: bool, + pub list_item_indent: IndentOptions, + pub quote: char, + pub rule: char, + pub strong: char, + pub increment_list_marker: bool, + pub close_atx: bool, + pub resource_link: bool, + pub rule_spaces: bool, + pub setext: bool, + pub tight_definitions: bool, + pub rule_repetition: u32, +} + +#[allow(dead_code)] +#[derive(Copy, Clone)] +pub enum IndentOptions { + Mixed, + One, + Tab, +} + +impl Default for Options { + fn default() -> Self { + Self { + bullet: '*', + bullet_other: '-', + bullet_ordered: '.', + emphasis: '*', + fence: '`', + fences: true, + increment_list_marker: true, + rule_repetition: 3, + list_item_indent: IndentOptions::One, + quote: '"', + rule: '*', + strong: '*', + close_atx: false, + rule_spaces: false, + resource_link: false, + setext: false, + tight_definitions: false, + } + } +} diff --git a/mdast_util_to_markdown/src/construct_name.rs b/mdast_util_to_markdown/src/construct_name.rs new file mode 100644 index 00000000..52511866 --- /dev/null +++ b/mdast_util_to_markdown/src/construct_name.rs @@ -0,0 +1,31 @@ +#[derive(Clone, PartialEq)] +#[allow(dead_code)] +pub enum ConstructName { + Autolink, + Blockquote, + CodeIndented, + CodeFenced, + CodeFencedLangGraveAccent, + CodeFencedLangTilde, + CodeFencedMetaGraveAccent, + CodeFencedMetaTilde, + Definition, + DestinationLiteral, + DestinationRaw, + Emphasis, + HeadingAtx, + HeadingSetext, + Image, + ImageReference, + Label, + Link, + LinkReference, + List, + ListItem, + Paragraph, + Phrasing, + Reference, + Strong, + TitleApostrophe, + TitleQuote, +} diff --git a/mdast_util_to_markdown/src/handle/blockquote.rs b/mdast_util_to_markdown/src/handle/blockquote.rs new file mode 100644 index 00000000..80a575b8 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/blockquote.rs @@ -0,0 +1,38 @@ +use alloc::string::String; +use markdown::mdast::{Blockquote, Node}; + +use crate::{ + construct_name::ConstructName, + message::Message, + state::{Info, State}, + util::indent_lines::indent_lines, +}; + +use super::Handle; + +impl Handle for Blockquote { + fn handle( + &self, + state: &mut State, + _info: &Info, + _parent: Option<&Node>, + node: &Node, + ) -> Result { + state.enter(ConstructName::Blockquote); + let value = indent_lines(&state.container_flow(node)?, map); + Ok(value) + } +} + +fn map(value: &str, _line: usize, blank: bool) -> String { + let marker = ">"; + let total_allocation = marker.len() + value.len() + 1; + let mut result = String::with_capacity(total_allocation); + result.push_str(marker); + if !blank { + let blank_str = " "; + result.push_str(blank_str); + } + result.push_str(value); + result +} diff --git a/mdast_util_to_markdown/src/handle/break.rs b/mdast_util_to_markdown/src/handle/break.rs new file mode 100644 index 00000000..07b81739 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/break.rs @@ -0,0 +1,33 @@ +use alloc::string::ToString; +use markdown::mdast::{Break, Node}; + +use crate::{ + message::Message, + state::{Info, State}, + util::pattern_in_scope::pattern_in_scope, +}; + +use super::Handle; + +impl Handle for Break { + fn handle( + &self, + state: &mut State, + info: &Info, + _parent: Option<&Node>, + _node: &Node, + ) -> Result { + for pattern in state.r#unsafe.iter() { + if pattern.character == '\n' && pattern_in_scope(&state.stack, pattern) { + let is_whitespace_or_tab = info.before.chars().any(|c| c == ' ' || c == '\t'); + if is_whitespace_or_tab { + return Ok("".to_string()); + } + + return Ok(" ".to_string()); + } + } + + Ok("\\\n".to_string()) + } +} diff --git a/mdast_util_to_markdown/src/handle/code.rs b/mdast_util_to_markdown/src/handle/code.rs new file mode 100644 index 00000000..45d6aa09 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/code.rs @@ -0,0 +1,90 @@ +use alloc::{ + format, + string::{String, ToString}, +}; +use markdown::mdast::{Code, Node}; + +use crate::{ + construct_name::ConstructName, + message::Message, + state::{Info, State}, + util::{ + check_fence::check_fence, format_code_as_indented::format_code_as_indented, + indent_lines::indent_lines, longest_char_streak::longest_char_streak, safe::SafeConfig, + }, +}; + +use super::Handle; + +impl Handle for Code { + fn handle( + &self, + state: &mut State, + _info: &Info, + _parent: Option<&Node>, + _node: &Node, + ) -> Result { + let marker = check_fence(state)?; + + if format_code_as_indented(self, state) { + state.enter(ConstructName::CodeIndented); + let value = indent_lines(&self.value, map); + state.exit(); + return Ok(value); + } + + let sequence = marker + .to_string() + .repeat((longest_char_streak(&self.value, marker) + 1).max(3)); + + state.enter(ConstructName::CodeFenced); + let mut value = sequence.clone(); + + if let Some(lang) = &self.lang { + let code_fenced_lang_construct = if marker == '`' { + ConstructName::CodeFencedLangGraveAccent + } else { + ConstructName::CodeFencedLangTilde + }; + state.enter(code_fenced_lang_construct); + + value.push_str(&state.safe(lang, &SafeConfig::new(&value, " ", Some('`')))); + + state.exit(); + + if let Some(meta) = &self.meta { + let code_fenced_meta_construct = if marker == '`' { + ConstructName::CodeFencedMetaGraveAccent + } else { + ConstructName::CodeFencedMetaTilde + }; + + state.enter(code_fenced_meta_construct); + value.push(' '); + + value.push_str(&state.safe(meta, &SafeConfig::new(&value, "\n", Some('`')))); + + state.exit(); + } + } + + value.push('\n'); + + if !self.value.is_empty() { + value.push_str(&self.value); + value.push('\n'); + } + + value.push_str(&sequence); + + Ok(value) + } +} + +fn map(value: &str, _line: usize, blank: bool) -> String { + if blank { + String::new() + } else { + format!(" {}", value) + } +} diff --git a/mdast_util_to_markdown/src/handle/definition.rs b/mdast_util_to_markdown/src/handle/definition.rs new file mode 100644 index 00000000..a553448a --- /dev/null +++ b/mdast_util_to_markdown/src/handle/definition.rs @@ -0,0 +1,77 @@ +use alloc::string::String; +use markdown::mdast::{Definition, Node}; + +use crate::{ + construct_name::ConstructName, + message::Message, + state::{Info, State}, + util::{check_quote::check_quote, safe::SafeConfig}, +}; + +use super::Handle; + +impl Handle for Definition { + fn handle( + &self, + state: &mut State, + _info: &Info, + _parent: Option<&Node>, + _node: &Node, + ) -> Result { + let quote = check_quote(state)?; + + state.enter(ConstructName::Definition); + state.enter(ConstructName::Label); + + let mut value = String::from('['); + + value.push_str(&state.safe( + &state.association(self), + &SafeConfig::new(&value, "]", None), + )); + + value.push_str("]: "); + + state.exit(); + + if self.url.is_empty() || contain_control_char_or_whitespace(&self.url) { + state.enter(ConstructName::DestinationLiteral); + value.push('<'); + value.push_str(&state.safe(&self.url, &SafeConfig::new(&value, ">", None))); + value.push('>'); + } else { + state.enter(ConstructName::DestinationRaw); + let after = if self.title.is_some() { " " } else { ")" }; + value.push_str(&state.safe(&self.url, &SafeConfig::new(&value, after, None))); + } + + state.exit(); + + if let Some(title) = &self.title { + let title_construct_name = if quote == '"' { + ConstructName::TitleQuote + } else { + ConstructName::TitleApostrophe + }; + + state.enter(title_construct_name); + value.push(' '); + value.push(quote); + + let mut before_buffer = [0u8; 4]; + let before = quote.encode_utf8(&mut before_buffer); + value.push_str(&state.safe(title, &SafeConfig::new(&self.url, before, None))); + + value.push(quote); + state.exit(); + } + + state.exit(); + + Ok(value) + } +} + +fn contain_control_char_or_whitespace(value: &str) -> bool { + value.chars().any(|c| c.is_whitespace() || c.is_control()) +} diff --git a/mdast_util_to_markdown/src/handle/emphasis.rs b/mdast_util_to_markdown/src/handle/emphasis.rs new file mode 100644 index 00000000..e0864785 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/emphasis.rs @@ -0,0 +1,36 @@ +use alloc::format; +use markdown::mdast::{Emphasis, Node}; + +use crate::{ + construct_name::ConstructName, + message::Message, + state::{Info, State}, + util::check_emphasis::check_emphasis, +}; + +use super::Handle; + +impl Handle for Emphasis { + fn handle( + &self, + state: &mut State, + info: &Info, + _parent: Option<&Node>, + node: &Node, + ) -> Result { + let marker = check_emphasis(state)?; + + state.enter(ConstructName::Emphasis); + + let mut value = format!("{}{}", marker, state.container_phrasing(node, info)?); + value.push(marker); + + state.exit(); + + Ok(value) + } +} + +pub fn peek_emphasis(state: &State) -> char { + state.options.emphasis +} diff --git a/mdast_util_to_markdown/src/handle/heading.rs b/mdast_util_to_markdown/src/handle/heading.rs new file mode 100644 index 00000000..6e547482 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/heading.rs @@ -0,0 +1,79 @@ +use alloc::format; +use markdown::mdast::{Heading, Node}; + +use crate::{ + construct_name::ConstructName, + message::Message, + state::{Info, State}, + util::format_heading_as_setext::format_heading_as_setext, +}; + +use super::Handle; + +impl Handle for Heading { + fn handle( + &self, + state: &mut State, + _info: &Info, + _parent: Option<&Node>, + node: &Node, + ) -> Result { + let rank = self.depth.clamp(1, 6); + + if format_heading_as_setext(self, state) { + state.enter(ConstructName::HeadingSetext); + state.enter(ConstructName::Phrasing); + let mut value = state.container_phrasing(node, &Info::new("\n", "\n"))?; + + state.exit(); + state.exit(); + + let underline_char = if rank == 1 { "=" } else { "-" }; + let last_line_rank = value + .rfind('\n') + .unwrap_or(0) + .max(value.rfind('\r').unwrap_or(0)); + + let last_line_rank = if last_line_rank > 0 { + last_line_rank + 1 + } else { + 0 + }; + + let setext_underline = underline_char.repeat(value.len() - last_line_rank); + value.push('\n'); + value.push_str(&setext_underline); + + return Ok(value); + } + + let sequence = "#".repeat(rank as usize); + state.enter(ConstructName::HeadingAtx); + state.enter(ConstructName::Phrasing); + + let mut value = state.container_phrasing(node, &Info::new("# ", "\n"))?; + + if let Some(first_char) = value.chars().nth(0) { + if first_char == ' ' || first_char == '\t' { + let hex_code = u32::from(first_char); + value = format!("&#x{:X};{}", hex_code, &value[1..]) + } + } + + if value.is_empty() { + value.push_str(&sequence); + } else { + value = format!("{} {}", &sequence, value); + } + + if state.options.close_atx { + value.push(' '); + value.push_str(&sequence); + } + + state.exit(); + state.exit(); + + Ok(value) + } +} diff --git a/mdast_util_to_markdown/src/handle/html.rs b/mdast_util_to_markdown/src/handle/html.rs new file mode 100644 index 00000000..32ed6bd9 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/html.rs @@ -0,0 +1,24 @@ +use markdown::mdast::{Html, Node}; + +use crate::{ + message::Message, + state::{Info, State}, +}; + +use super::Handle; + +impl Handle for Html { + fn handle( + &self, + _state: &mut State, + _info: &Info, + _parent: Option<&Node>, + _node: &Node, + ) -> Result { + Ok(self.value.clone()) + } +} + +pub fn peek_html() -> char { + '<' +} diff --git a/mdast_util_to_markdown/src/handle/image.rs b/mdast_util_to_markdown/src/handle/image.rs new file mode 100644 index 00000000..0a1fc4e7 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/image.rs @@ -0,0 +1,81 @@ +use alloc::string::String; +use markdown::mdast::{Image, Node}; + +use crate::{ + construct_name::ConstructName, + message::Message, + state::{Info, State}, + util::{check_quote::check_quote, safe::SafeConfig}, +}; + +use super::Handle; + +impl Handle for Image { + fn handle( + &self, + state: &mut State, + _info: &Info, + _parent: Option<&Node>, + _node: &Node, + ) -> Result { + let quote = check_quote(state)?; + state.enter(ConstructName::Image); + state.enter(ConstructName::Label); + + let mut value = String::new(); + + value.push_str("!["); + + value.push_str(&state.safe(&self.alt, &SafeConfig::new(value.as_str(), "]", None))); + + value.push_str("]("); + state.exit(); + + if self.url.is_empty() && self.title.is_some() + || contain_control_char_or_whitespace(&self.url) + { + state.enter(ConstructName::DestinationLiteral); + value.push('<'); + value.push_str(&state.safe(&self.url, &SafeConfig::new(&value, ">", None))); + value.push('>'); + } else { + state.enter(ConstructName::DestinationRaw); + let after = if self.title.is_some() { " " } else { ")" }; + value.push_str(&state.safe(&self.url, &SafeConfig::new(&value, after, None))); + } + + state.exit(); + + if let Some(title) = &self.title { + let title_construct_name = if quote == '"' { + ConstructName::TitleQuote + } else { + ConstructName::TitleApostrophe + }; + + state.enter(title_construct_name); + value.push(' '); + value.push(quote); + + let mut before_buffer = [0u8; 4]; + let before = quote.encode_utf8(&mut before_buffer); + value.push_str(&state.safe(title, &SafeConfig::new(&self.url, before, None))); + + value.push(quote); + state.exit(); + } + + value.push(')'); + state.exit(); + + Ok(value) + } +} + +fn contain_control_char_or_whitespace(value: &str) -> bool { + value.chars().any(|c| c.is_whitespace() || c.is_control()) +} + +pub fn peek_image() -> char { + '!' +} diff --git a/mdast_util_to_markdown/src/handle/image_reference.rs b/mdast_util_to_markdown/src/handle/image_reference.rs new file mode 100644 index 00000000..d3fd3e96 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/image_reference.rs @@ -0,0 +1,62 @@ +use core::mem; + +use alloc::string::String; +use markdown::mdast::{ImageReference, Node, ReferenceKind}; + +use crate::{ + construct_name::ConstructName, + message::Message, + state::{Info, State}, + util::safe::SafeConfig, +}; + +use super::Handle; + +impl Handle for ImageReference { + fn handle( + &self, + state: &mut State, + _info: &Info, + _parent: Option<&Node>, + _node: &Node, + ) -> Result { + state.enter(ConstructName::ImageReference); + state.enter(ConstructName::Label); + + let mut value = String::from("!["); + let alt = state.safe(&self.alt, &SafeConfig::new(&value, "]", None)); + + value.push_str(&alt); + value.push_str("]["); + + state.exit(); + + let old_stack = mem::take(&mut state.stack); + state.enter(ConstructName::Reference); + + let reference = state.safe( + &state.association(self), + &SafeConfig::new(&value, "]", None), + ); + + state.exit(); + state.stack = old_stack; + state.exit(); + + if matches!(self.reference_kind, ReferenceKind::Full) || alt.is_empty() || alt != reference + { + value.push_str(&reference); + value.push(']'); + } else if matches!(self.reference_kind, ReferenceKind::Shortcut) { + value.pop(); + } else { + value.push(']'); + } + + Ok(value) + } +} + +pub fn peek_image_reference() -> char { + '!' +} diff --git a/mdast_util_to_markdown/src/handle/inline_code.rs b/mdast_util_to_markdown/src/handle/inline_code.rs new file mode 100644 index 00000000..e8a2d78e --- /dev/null +++ b/mdast_util_to_markdown/src/handle/inline_code.rs @@ -0,0 +1,72 @@ +use alloc::{format, string::String}; +use markdown::mdast::{InlineCode, Node}; +use regex::Regex; + +use crate::{ + message::Message, + state::{Info, State}, +}; + +use super::Handle; + +impl Handle for InlineCode { + fn handle( + &self, + state: &mut State, + _info: &Info, + _parent: Option<&Node>, + _node: &Node, + ) -> Result { + let mut value = self.value.clone(); + let mut sequence = String::from('`'); + let mut grave_accent_match = Regex::new(&format!(r"(^|[^`]){}([^`]|$)", sequence)).unwrap(); + while grave_accent_match.is_match(&value) { + sequence.push('`'); + grave_accent_match = Regex::new(&format!(r"(^|[^`]){}([^`]|$)", sequence)).unwrap(); + } + + let no_whitespaces = !value.chars().all(char::is_whitespace); + let starts_with_whitespace = value.starts_with(char::is_whitespace); + let ends_with_whitespace = value.ends_with(char::is_whitespace); + let starts_with_tick = value.starts_with('`'); + let ends_with_tick = value.ends_with('`'); + if no_whitespaces + && ((starts_with_whitespace && ends_with_whitespace) + || starts_with_tick + || ends_with_tick) + { + value = format!("{}{}{}", ' ', value, ' '); + } + + for pattern in &mut state.r#unsafe { + if !pattern.at_break { + continue; + } + + State::compile_pattern(pattern); + + if let Some(regex) = &pattern.compiled { + while let Some(m) = regex.find(&value) { + let position = m.start(); + + let position = if position > 0 + && &value[position..m.len()] == "\n" + && &value[position - 1..position] == "\r" + { + position - 1 + } else { + position + }; + + value.replace_range(position..m.start() + 1, " "); + } + } + } + + Ok(format!("{}{}{}", sequence, value, sequence)) + } +} + +pub fn peek_inline_code() -> char { + '`' +} diff --git a/mdast_util_to_markdown/src/handle/link.rs b/mdast_util_to_markdown/src/handle/link.rs new file mode 100644 index 00000000..81afe716 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/link.rs @@ -0,0 +1,97 @@ +use core::mem; + +use alloc::string::String; +use markdown::mdast::{Link, Node}; + +use crate::{ + construct_name::ConstructName, + message::Message, + state::{Info, State}, + util::{ + check_quote::check_quote, format_link_as_auto_link::format_link_as_auto_link, + safe::SafeConfig, + }, +}; + +use super::Handle; + +impl Handle for Link { + fn handle( + &self, + state: &mut State, + _info: &Info, + _parent: Option<&Node>, + node: &Node, + ) -> Result { + let quote = check_quote(state)?; + + if format_link_as_auto_link(self, node, state) { + let old_stack = mem::take(&mut state.stack); + state.enter(ConstructName::Autolink); + let mut value = String::from("<"); + value.push_str(&state.container_phrasing(node, &Info::new(&value, ">"))?); + value.push('>'); + state.exit(); + state.stack = old_stack; + return Ok(value); + } + + state.enter(ConstructName::Link); + state.enter(ConstructName::Label); + let mut value = String::from("["); + value.push_str(&state.container_phrasing(node, &Info::new(&value, "]("))?); + value.push_str("]("); + state.exit(); + + if self.url.is_empty() && self.title.is_some() + || contain_control_char_or_whitespace(&self.url) + { + state.enter(ConstructName::DestinationLiteral); + value.push('<'); + value.push_str(&state.safe(&self.url, &SafeConfig::new(&value, ">", None))); + value.push('>'); + } else { + state.enter(ConstructName::DestinationRaw); + let after = if self.title.is_some() { " " } else { ")" }; + value.push_str(&state.safe(&self.url, &SafeConfig::new(&value, after, None))) + } + + state.exit(); + + if let Some(title) = &self.title { + let title_construct_name = if quote == '"' { + ConstructName::TitleQuote + } else { + ConstructName::TitleApostrophe + }; + + state.enter(title_construct_name); + value.push(' '); + value.push(quote); + + let mut before_buffer = [0u8; 4]; + let before = quote.encode_utf8(&mut before_buffer); + value.push_str(&state.safe(title, &SafeConfig::new(&self.url, before, None))); + + value.push(quote); + state.exit(); + } + + value.push(')'); + state.exit(); + + Ok(value) + } +} + +fn contain_control_char_or_whitespace(value: &str) -> bool { + value.chars().any(|c| c.is_whitespace() || c.is_control()) +} + +pub fn peek_link(link: &Link, node: &Node, state: &State) -> char { + if format_link_as_auto_link(link, node, state) { + '>' + } else { + '[' + } +} diff --git a/mdast_util_to_markdown/src/handle/link_reference.rs b/mdast_util_to_markdown/src/handle/link_reference.rs new file mode 100644 index 00000000..e63beb53 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/link_reference.rs @@ -0,0 +1,64 @@ +use core::mem; + +use alloc::string::String; +use markdown::mdast::{LinkReference, Node, ReferenceKind}; + +use crate::{ + construct_name::ConstructName, + message::Message, + state::{Info, State}, + util::safe::SafeConfig, +}; + +use super::Handle; + +impl Handle for LinkReference { + fn handle( + &self, + state: &mut State, + _info: &Info, + _parent: Option<&Node>, + node: &Node, + ) -> Result { + state.enter(ConstructName::LinkReference); + state.enter(ConstructName::Label); + + let mut value = String::from("["); + let text = state.container_phrasing(node, &Info::new(&value, "]"))?; + + value.push_str(&text); + value.push_str("]["); + + state.exit(); + + let old_stack = mem::take(&mut state.stack); + state.enter(ConstructName::Reference); + + let reference = state.safe( + &state.association(self), + &SafeConfig::new(&value, "]", None), + ); + + state.exit(); + state.stack = old_stack; + state.exit(); + + if matches!(self.reference_kind, ReferenceKind::Full) + || text.is_empty() + || text != reference + { + value.push_str(&reference); + value.push(']'); + } else if matches!(self.reference_kind, ReferenceKind::Shortcut) { + value.pop(); + } else { + value.push(']'); + } + + Ok(value) + } +} + +pub fn peek_link_reference() -> char { + '[' +} diff --git a/mdast_util_to_markdown/src/handle/list.rs b/mdast_util_to_markdown/src/handle/list.rs new file mode 100644 index 00000000..e58b201f --- /dev/null +++ b/mdast_util_to_markdown/src/handle/list.rs @@ -0,0 +1,91 @@ +use markdown::mdast::{List, Node}; + +use crate::{ + construct_name::ConstructName, + message::Message, + state::{Info, State}, + util::{ + check_bullet::check_bullet, check_bullet_ordered::check_bullet_ordered, + check_bullet_other::check_bullet_other, check_rule::check_rule, + }, +}; + +use super::Handle; + +impl Handle for List { + fn handle( + &self, + state: &mut State, + _info: &Info, + _parent: Option<&Node>, + node: &Node, + ) -> Result { + state.enter(ConstructName::List); + let bullet_current = state.bullet_current; + + let mut bullet = if self.ordered { + check_bullet_ordered(state)? + } else { + check_bullet(state)? + }; + + let bullet_other = if self.ordered { + if bullet == '.' { + ')' + } else { + '.' + } + } else { + check_bullet_other(state)? + }; + + let mut use_different_marker = false; + if let Some(bullet_last_used) = state.bullet_last_used { + use_different_marker = bullet == bullet_last_used; + } + + if !self.ordered && !self.children.is_empty() { + let is_valid_bullet = bullet == '*' || bullet == '-'; + let first_child_has_no_children = self.children[0].children().is_none(); + let is_within_bounds = state.stack.len() >= 4 && state.index_stack.len() >= 3; + if is_valid_bullet + && first_child_has_no_children + && is_within_bounds + && state.stack[state.stack.len() - 1] == ConstructName::List + && state.stack[state.stack.len() - 2] == ConstructName::ListItem + && state.stack[state.stack.len() - 3] == ConstructName::List + && state.stack[state.stack.len() - 4] == ConstructName::ListItem + && state.index_stack[state.index_stack.len() - 1] == 0 + && state.index_stack[state.index_stack.len() - 2] == 0 + && state.index_stack[state.index_stack.len() - 3] == 0 + { + use_different_marker = true; + } + } + + if check_rule(state)? == bullet { + for child in self.children.iter() { + if let Some(child_children) = child.children() { + if !child_children.is_empty() + && matches!(child, Node::ListItem(_)) + && matches!(child_children[0], Node::ThematicBreak(_)) + { + use_different_marker = true; + break; + } + } + } + } + + if use_different_marker { + bullet = bullet_other; + } + + state.bullet_current = Some(bullet); + let value = state.container_flow(node)?; + state.bullet_last_used = Some(bullet); + state.bullet_current = bullet_current; + state.exit(); + Ok(value) + } +} diff --git a/mdast_util_to_markdown/src/handle/list_item.rs b/mdast_util_to_markdown/src/handle/list_item.rs new file mode 100644 index 00000000..1f3fff15 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/list_item.rs @@ -0,0 +1,92 @@ +use alloc::{ + format, + string::{String, ToString}, +}; +use markdown::mdast::{ListItem, Node}; + +use crate::{ + configure::IndentOptions, + construct_name::ConstructName, + message::Message, + state::{Info, State}, + util::{check_bullet::check_bullet, indent_lines::indent_lines}, +}; + +use super::Handle; + +impl Handle for ListItem { + fn handle( + &self, + state: &mut State, + _info: &Info, + parent: Option<&Node>, + node: &Node, + ) -> Result { + let list_item_indent = state.options.list_item_indent; + let mut bullet = state + .bullet_current + .unwrap_or(check_bullet(state)?) + .to_string(); + + // This is equal to bullet.len() + 1, since we know bullet is always one byte long we can + // safely assign 2 to size. + let mut size = 2; + if let Some(Node::List(list)) = parent { + if list.ordered { + let bullet_number = if let Some(start) = list.start { + start as usize + } else { + 1 + }; + + if state.options.increment_list_marker { + if let Some(position_node) = list.children.iter().position(|x| *x == *node) { + bullet = format!("{}{}", bullet_number + position_node, bullet); + } + } else { + bullet = format!("{}{}", bullet_number, bullet); + } + } + + size = bullet.len() + 1; + + if matches!(list_item_indent, IndentOptions::Tab) + || (matches!(list_item_indent, IndentOptions::Mixed) && list.spread || self.spread) + { + size = compute_size(size); + } + } + + state.enter(ConstructName::ListItem); + + let value = indent_lines(&state.container_flow(node)?, |line, index, blank| { + if index > 0 { + if blank { + String::new() + } else { + let blank = " ".repeat(size); + let mut result = String::with_capacity(blank.len() + line.len()); + result.push_str(&blank); + result.push_str(line); + result + } + } else if blank { + bullet.clone() + } else { + // size - bullet.len() will never panic because size > bullet.len() always. + let blank = " ".repeat(size - bullet.len()); + let mut result = String::with_capacity(blank.len() + line.len() + bullet.len()); + result.push_str(&bullet); + result.push_str(&blank); + result.push_str(line); + result + } + }); + + Ok(value) + } +} + +fn compute_size(a: usize) -> usize { + ((a + 4 - 1) / 4) * 4 +} diff --git a/mdast_util_to_markdown/src/handle/mod.rs b/mdast_util_to_markdown/src/handle/mod.rs new file mode 100644 index 00000000..debca631 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/mod.rs @@ -0,0 +1,33 @@ +use crate::{message::Message, state::Info, State}; +use alloc::string::String; +use markdown::mdast::Node; + +mod blockquote; +mod r#break; +mod code; +mod definition; +pub mod emphasis; +mod heading; +pub mod html; +pub mod image; +pub mod image_reference; +pub mod inline_code; +pub mod link; +pub mod link_reference; +mod list; +mod list_item; +mod paragraph; +mod root; +pub mod strong; +mod text; +mod thematic_break; + +pub trait Handle { + fn handle( + &self, + state: &mut State, + info: &Info, + parent: Option<&Node>, + node: &Node, + ) -> Result; +} diff --git a/mdast_util_to_markdown/src/handle/paragraph.rs b/mdast_util_to_markdown/src/handle/paragraph.rs new file mode 100644 index 00000000..1ada03b9 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/paragraph.rs @@ -0,0 +1,29 @@ +use markdown::mdast::{Node, Paragraph}; + +use crate::{ + construct_name::ConstructName, + message::Message, + state::{Info, State}, +}; + +use super::Handle; + +impl Handle for Paragraph { + fn handle( + &self, + state: &mut State, + info: &Info, + _parent: Option<&Node>, + node: &Node, + ) -> Result { + state.enter(ConstructName::Paragraph); + + state.enter(ConstructName::Phrasing); + let value = state.container_phrasing(node, info)?; + // exit phrasing + state.exit(); + // exit paragarph + state.exit(); + Ok(value) + } +} diff --git a/mdast_util_to_markdown/src/handle/root.rs b/mdast_util_to_markdown/src/handle/root.rs new file mode 100644 index 00000000..c3ce928c --- /dev/null +++ b/mdast_util_to_markdown/src/handle/root.rs @@ -0,0 +1,41 @@ +use alloc::string::String; +use markdown::mdast::{Node, Root}; + +use crate::{ + message::Message, + state::{Info, State}, +}; + +use super::Handle; + +impl Handle for Root { + fn handle( + &self, + state: &mut State, + info: &Info, + _parent: Option<&Node>, + node: &Node, + ) -> Result { + let has_phrasing = self.children.iter().any(phrasing); + if has_phrasing { + state.container_phrasing(node, info) + } else { + state.container_flow(node) + } + } +} + +fn phrasing(child: &Node) -> bool { + matches!( + *child, + Node::Break(_) + | Node::Emphasis(_) + | Node::Image(_) + | Node::ImageReference(_) + | Node::InlineCode(_) + | Node::Link(_) + | Node::LinkReference(_) + | Node::Strong(_) + | Node::Text(_) + ) +} diff --git a/mdast_util_to_markdown/src/handle/strong.rs b/mdast_util_to_markdown/src/handle/strong.rs new file mode 100644 index 00000000..928f343b --- /dev/null +++ b/mdast_util_to_markdown/src/handle/strong.rs @@ -0,0 +1,42 @@ +use alloc::format; +use markdown::mdast::{Node, Strong}; + +use crate::{ + construct_name::ConstructName, + message::Message, + state::{Info, State}, + util::check_strong::check_strong, +}; + +use super::Handle; + +impl Handle for Strong { + fn handle( + &self, + state: &mut State, + info: &Info, + _parent: Option<&Node>, + node: &Node, + ) -> Result { + let marker = check_strong(state)?; + + state.enter(ConstructName::Strong); + + let mut value = format!( + "{}{}{}", + marker, + marker, + state.container_phrasing(node, info)? + ); + value.push(marker); + value.push(marker); + + state.exit(); + + Ok(value) + } +} + +pub fn peek_strong(state: &State) -> char { + state.options.strong +} diff --git a/mdast_util_to_markdown/src/handle/text.rs b/mdast_util_to_markdown/src/handle/text.rs new file mode 100644 index 00000000..8decb542 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/text.rs @@ -0,0 +1,21 @@ +use markdown::mdast::{Node, Text}; + +use crate::{ + message::Message, + state::{Info, State}, + util::safe::SafeConfig, +}; + +use super::Handle; + +impl Handle for Text { + fn handle( + &self, + state: &mut State, + info: &Info, + _parent: Option<&Node>, + _node: &Node, + ) -> Result { + Ok(state.safe(&self.value, &SafeConfig::new(info.before, info.after, None))) + } +} diff --git a/mdast_util_to_markdown/src/handle/thematic_break.rs b/mdast_util_to_markdown/src/handle/thematic_break.rs new file mode 100644 index 00000000..23f98fda --- /dev/null +++ b/mdast_util_to_markdown/src/handle/thematic_break.rs @@ -0,0 +1,32 @@ +use alloc::format; +use markdown::mdast::{Node, ThematicBreak}; + +use crate::{ + message::Message, + state::{Info, State}, + util::{check_rule::check_rule, check_rule_repetition::check_rule_repetition}, +}; + +use super::Handle; + +impl Handle for ThematicBreak { + fn handle( + &self, + state: &mut State, + _info: &Info, + _parent: Option<&Node>, + _node: &Node, + ) -> Result { + let marker = check_rule(state)?; + let space = if state.options.rule_spaces { " " } else { "" }; + let mut value = + format!("{}{}", marker, space).repeat(check_rule_repetition(state)? as usize); + + if state.options.rule_spaces { + value.pop(); // remove the last space + Ok(value) + } else { + Ok(value) + } + } +} diff --git a/mdast_util_to_markdown/src/lib.rs b/mdast_util_to_markdown/src/lib.rs new file mode 100644 index 00000000..c11e5205 --- /dev/null +++ b/mdast_util_to_markdown/src/lib.rs @@ -0,0 +1,34 @@ +#![no_std] + +use alloc::string::String; +pub use configure::{IndentOptions, Options}; +use markdown::mdast::Node; +use message::Message; +use state::{Info, State}; + +extern crate alloc; +mod association; +mod configure; +mod construct_name; +mod handle; +mod message; +mod state; +mod r#unsafe; +mod util; + +pub fn to_markdown(tree: &Node) -> Result { + to_markdown_with_options(tree, &Options::default()) +} + +pub fn to_markdown_with_options(tree: &Node, options: &Options) -> Result { + let mut state = State::new(options); + let mut result = state.handle(tree, &Info::new("\n", "\n"), None)?; + if !result.is_empty() { + let last_char = result.chars().last().unwrap(); + if last_char != '\n' && last_char != '\r' { + result.push('\n'); + } + } + + Ok(result) +} diff --git a/mdast_util_to_markdown/src/message.rs b/mdast_util_to_markdown/src/message.rs new file mode 100644 index 00000000..c3f39447 --- /dev/null +++ b/mdast_util_to_markdown/src/message.rs @@ -0,0 +1,24 @@ +use core::{error::Error, fmt::Display}; + +use alloc::string::{String, ToString}; + +#[derive(Debug, PartialEq)] +pub struct Message { + pub reason: String, +} + +impl Error for Message {} + +impl Display for Message { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.reason) + } +} + +impl From<&str> for Message { + fn from(value: &str) -> Self { + Message { + reason: value.to_string(), + } + } +} diff --git a/mdast_util_to_markdown/src/state.rs b/mdast_util_to_markdown/src/state.rs new file mode 100644 index 00000000..3ba3a3d6 --- /dev/null +++ b/mdast_util_to_markdown/src/state.rs @@ -0,0 +1,500 @@ +use crate::association::Association; +use crate::construct_name::ConstructName; +use crate::handle::emphasis::peek_emphasis; +use crate::handle::html::peek_html; +use crate::handle::image::peek_image; +use crate::handle::image_reference::peek_image_reference; +use crate::handle::inline_code::peek_inline_code; +use crate::handle::link::peek_link; +use crate::handle::link_reference::peek_link_reference; +use crate::handle::strong::peek_strong; +use crate::handle::Handle; +use crate::message::Message; +use crate::Options; +use crate::{ + r#unsafe::Unsafe, + util::{ + format_code_as_indented::format_code_as_indented, + format_heading_as_setext::format_heading_as_setext, + pattern_in_scope::pattern_in_scope, + safe::{escape_backslashes, EscapeInfos, SafeConfig}, + }, +}; +use alloc::string::ToString; +use alloc::{collections::BTreeMap, format, string::String, vec::Vec}; +use markdown::mdast::Node; +use regex::{Captures, Regex, RegexBuilder}; + +#[allow(dead_code)] +#[derive(Debug)] +enum Join { + True, + False, + Number(usize), +} + +#[allow(dead_code)] +pub struct State<'a> { + pub stack: Vec, + pub index_stack: Vec, + pub bullet_last_used: Option, + pub bullet_current: Option, + pub r#unsafe: Vec>, + pub options: &'a Options, +} + +pub struct Info<'a> { + pub before: &'a str, + pub after: &'a str, +} + +impl<'a> Info<'a> { + pub fn new(before: &'a str, after: &'a str) -> Self { + Info { before, after } + } +} + +#[allow(dead_code)] +impl<'a> State<'a> { + pub fn new(options: &'a Options) -> Self { + State { + stack: Vec::new(), + index_stack: Vec::new(), + bullet_last_used: None, + bullet_current: None, + r#unsafe: Unsafe::get_default_unsafe(), + options, + } + } + + pub fn enter(&mut self, name: ConstructName) { + self.stack.push(name); + } + + pub fn exit(&mut self) { + self.stack.pop(); + } + + pub fn handle( + &mut self, + node: &Node, + info: &Info, + parent: Option<&Node>, + ) -> Result { + match node { + Node::Root(root) => root.handle(self, info, parent, node), + Node::Paragraph(paragraph) => paragraph.handle(self, info, parent, node), + Node::Text(text) => text.handle(self, info, parent, node), + Node::Strong(strong) => strong.handle(self, info, parent, node), + Node::Emphasis(emphasis) => emphasis.handle(self, info, parent, node), + Node::Heading(heading) => heading.handle(self, info, parent, node), + Node::Break(r#break) => r#break.handle(self, info, parent, node), + Node::Html(html) => html.handle(self, info, parent, node), + Node::ThematicBreak(thematic_break) => thematic_break.handle(self, info, parent, node), + Node::Code(code) => code.handle(self, info, parent, node), + Node::Blockquote(block_quote) => block_quote.handle(self, info, parent, node), + Node::List(list) => list.handle(self, info, parent, node), + Node::ListItem(list_item) => list_item.handle(self, info, parent, node), + Node::Image(image) => image.handle(self, info, parent, node), + Node::Link(link) => link.handle(self, info, parent, node), + Node::InlineCode(inline_code) => inline_code.handle(self, info, parent, node), + Node::Definition(definition) => definition.handle(self, info, parent, node), + Node::ImageReference(image_reference) => { + image_reference.handle(self, info, parent, node) + } + Node::LinkReference(link_reference) => link_reference.handle(self, info, parent, node), + _ => Err("Cannot handle node".into()), + } + } + + pub fn safe(&mut self, input: &str, config: &SafeConfig) -> String { + let value = format!("{}{}{}", config.before, input, config.after); + let mut positions: Vec = Vec::new(); + let mut result: String = String::new(); + let mut infos: BTreeMap = BTreeMap::new(); + + for pattern in &mut self.r#unsafe { + if !pattern_in_scope(&self.stack, pattern) { + continue; + } + + Self::compile_pattern(pattern); + + if let Some(regex) = &pattern.compiled { + for m in regex.captures_iter(&value) { + let full_match = m.get(0).expect("Guaranteed to have a match"); + let captured_group_len = m + .get(1) + .map(|captured_group| captured_group.len()) + .unwrap_or(0); + + let before = pattern.before.is_some() || pattern.at_break; + let after = pattern.after.is_some(); + let position = full_match.start() + if before { captured_group_len } else { 0 }; + + if positions.contains(&position) { + if let Some(entry) = infos.get_mut(&position) { + if entry.before && !before { + entry.before = false; + } + if entry.after && !after { + entry.after = false; + } + } + } else { + infos.insert(position, EscapeInfos { before, after }); + positions.push(position); + } + } + } + } + + positions.sort_unstable(); + + let mut start = config.before.len(); + let end = value.len() - config.after.len(); + + for (index, position) in positions.iter().enumerate() { + if *position < start || *position >= end { + continue; + } + + // If this character is supposed to be escaped because it has a condition on + // the next character, and the next character is definitly being escaped, + // then skip this escape. + // This will never panic because the bounds are properly checked, and we + // guarantee that the positions are already keys in the `infos` map before this + // point in execution. + if index + 1 < positions.len() + && position + 1 < end + && positions[index + 1] == position + 1 + && infos[position].after + && !infos[&(position + 1)].before + && !infos[&(position + 1)].after + || index > 0 + && positions[index - 1] == position - 1 + && infos[position].before + && !infos[&(position - 1)].before + && !infos[&(position - 1)].after + { + continue; + } + + if start != *position { + result.push_str(&escape_backslashes(&value[start..*position], r"\")); + } + + start = *position; + + let char_at_pos = value.chars().nth(*position); + match char_at_pos { + Some('!'..='/') | Some(':'..='@') | Some('['..='`') | Some('{'..='~') => { + if let Some(encode) = &config.encode { + let character = char_at_pos.expect("To be a valid char"); + if *encode != character { + result.push('\\'); + } else { + let encoded_char = Self::encode_char(character); + result.push_str(&encoded_char); + start += character.len_utf8(); + } + } else { + result.push('\\'); + } + } + Some(character) => { + let encoded_char = Self::encode_char(character); + result.push_str(&encoded_char); + start += character.len_utf8(); + } + _ => (), + }; + } + + result.push_str(&escape_backslashes(&value[start..end], config.after)); + + result + } + + fn encode_char(character: char) -> String { + let hex_code = u32::from(character); + format!("&#x{:X};", hex_code) + } + + pub fn compile_pattern(pattern: &mut Unsafe) { + if pattern.compiled.is_none() { + let mut pattern_to_compile = String::new(); + + if let Some(pattern_before) = pattern.before { + pattern_to_compile.push('('); + if pattern.at_break { + pattern_to_compile.push_str("[\\r\\n][\\t ]*"); + } + pattern_to_compile.push_str("(?:"); + pattern_to_compile.push_str(pattern_before); + pattern_to_compile.push(')'); + pattern_to_compile.push(')'); + } else if pattern.at_break { + pattern_to_compile.push('('); + pattern_to_compile.push_str("[\\r\\n][\\t ]*"); + pattern_to_compile.push(')'); + } + + if matches!( + pattern.character, + '|' | '\\' + | '{' + | '}' + | '(' + | ')' + | '[' + | ']' + | '^' + | '$' + | '+' + | '*' + | '?' + | '.' + | '-' + ) { + pattern_to_compile.push('\\'); + } + + pattern_to_compile.push(pattern.character); + + if let Some(pattern_after) = pattern.after { + pattern_to_compile.push_str("(?:"); + pattern_to_compile.push_str(pattern_after); + pattern_to_compile.push(')'); + } + + pattern.set_compiled( + Regex::new(&pattern_to_compile).expect("A valid unsafe regex pattern"), + ); + } + } + + pub fn container_phrasing(&mut self, parent: &Node, info: &Info) -> Result { + let children = parent.children().expect("To be a parent."); + + let mut results: String = String::new(); + let mut index = 0; + let mut children_iter = children.iter().peekable(); + + if !children.is_empty() { + self.index_stack.push(0); + } + + while let Some(child) = children_iter.next() { + if index > 0 { + let top = self + .index_stack + .last_mut() + .expect("The stack is populated with at least one child position"); + *top = index; + } + + let mut new_info = Info::new(info.before, info.after); + let mut buffer = [0u8; 4]; + if let Some(child) = children_iter.peek() { + if let Some(first_char) = self.peek_node(child) { + new_info.after = first_char.encode_utf8(&mut buffer); + } else { + new_info.after = self + .handle(child, &Info::new("", ""), Some(parent))? + .chars() + .nth(0) + .unwrap_or_default() + .encode_utf8(&mut buffer); + } + } + + if !results.is_empty() { + if info.before == "\r" || info.before == "\n" && matches!(child, Node::Html(_)) { + // TODO Remove this check here it might not be needed since we're + // checking for the before info. + if results.ends_with('\n') || results.ends_with('\r') { + results.pop(); + if results.ends_with('\r') { + results.pop(); + } + } + results.push(' '); + new_info.before = " "; + } else { + new_info.before = &results[results.len() - 1..]; + } + } + + results.push_str(&self.handle(child, &new_info, Some(parent))?); + index += 1; + } + + self.index_stack.pop(); + + Ok(results) + } + + fn peek_node(&self, node: &Node) -> Option { + match node { + Node::Strong(_) => Some(peek_strong(self)), + Node::Emphasis(_) => Some(peek_emphasis(self)), + Node::Html(_) => Some(peek_html()), + Node::Image(_) => Some(peek_image()), + Node::Link(link) => Some(peek_link(link, node, self)), + Node::InlineCode(_) => Some(peek_inline_code()), + Node::ImageReference(_) => Some(peek_image_reference()), + Node::LinkReference(_) => Some(peek_link_reference()), + _ => None, + } + } + + pub fn container_flow(&mut self, parent: &Node) -> Result { + let children = parent.children().expect("To be a parent."); + + let mut results: String = String::new(); + let mut children_iter = children.iter().peekable(); + let mut index = 0; + + if !children.is_empty() { + self.index_stack.push(0); + } + + while let Some(child) = children_iter.next() { + if index > 0 { + let top = self + .index_stack + .last_mut() + .expect("The stack is populated with at least one child position"); + *top = index; + } + + if matches!(child, Node::List(_)) { + self.bullet_last_used = None; + } + + results.push_str(&self.handle(child, &Info::new("\n", "\n"), Some(parent))?); + + if let Some(next_child) = children_iter.peek() { + self.set_between(child, next_child, parent, &mut results); + } + + index += 1; + } + + self.index_stack.pop(); + + Ok(results) + } + + fn set_between(&self, left: &Node, right: &Node, parent: &Node, results: &mut String) { + match self.join_defaults(left, right, parent) { + Join::Number(n) => { + results.push_str("\n".repeat(1 + n).as_ref()); + } + Join::False => { + results.push_str("\n\n\n\n"); + } + Join::True => results.push_str("\n\n"), + } + } + + fn join_defaults(&self, left: &Node, right: &Node, parent: &Node) -> Join { + if let Node::Code(code) = right { + if format_code_as_indented(code, self) && matches!(left, Node::List(_)) { + return Join::False; + } + + if let Node::Code(code) = left { + if format_code_as_indented(code, self) { + return Join::False; + } + } + } + + if matches!(parent, Node::List(_) | Node::ListItem(_)) { + if matches!(left, Node::Paragraph(_)) && Self::matches((left, right)) + || matches!(right, Node::Definition(_)) + { + return Join::True; + } + + if let Node::Heading(heading) = right { + if format_heading_as_setext(heading, self) { + return Join::True; + } + } + + let spread = if let Node::List(list) = parent { + list.spread + } else if let Node::ListItem(list_item) = parent { + list_item.spread + } else { + false + }; + + if spread { + return Join::Number(1); + } + + return Join::Number(0); + } + + Join::True + } + + fn matches(nodes: (&Node, &Node)) -> bool { + matches!( + nodes, + (Node::Root(_), Node::Root(_)) + | (Node::Blockquote(_), Node::Blockquote(_)) + | (Node::FootnoteDefinition(_), Node::FootnoteDefinition(_)) + | (Node::Heading(_), Node::Heading(_)) + | (Node::List(_), Node::List(_)) + | (Node::ListItem(_), Node::ListItem(_)) + | (Node::Paragraph(_), Node::Paragraph(_)) + | (Node::Table(_), Node::Table(_)) + ) + } + + pub fn association(&self, node: &impl Association) -> String { + if node.label().is_some() || node.identifier().is_empty() { + return node.label().clone().unwrap_or_default(); + } + + let character_escape_or_reference = + RegexBuilder::new(r"\\([!-/:-@\[-`{-~])|&(#(?:\d{1,7}|x[\da-f]{1,6})|[\da-z]{1,31});") + .case_insensitive(true) + .build() + .unwrap(); + + character_escape_or_reference + .replace_all(node.identifier(), Self::decode) + .into_owned() + } + + fn decode(caps: &Captures) -> String { + if let Some(first_cap) = caps.get(1) { + return String::from(first_cap.as_str()); + } + + if let Some(head) = &caps[2].chars().nth(0) { + if *head == '#' { + let radix = match caps[2].chars().nth(1) { + Some('x') | Some('X') => 16, + _ => 10, + }; + + let capture = &caps[2]; + let numeric_encoded = if radix == 16 { + &capture[2..] + } else { + &capture[1..] + }; + return markdown::decode_numeric(numeric_encoded, radix); + } + } + + markdown::decode_named(&caps[2], true).unwrap_or(caps[0].to_string()) + } +} diff --git a/mdast_util_to_markdown/src/unsafe.rs b/mdast_util_to_markdown/src/unsafe.rs new file mode 100644 index 00000000..08c76133 --- /dev/null +++ b/mdast_util_to_markdown/src/unsafe.rs @@ -0,0 +1,324 @@ +use alloc::{vec, vec::Vec}; +use regex::Regex; + +use crate::construct_name::ConstructName; + +#[derive(Default)] +pub struct Unsafe<'a> { + pub character: char, + pub in_construct: Option, + pub not_in_construct: Option, + pub before: Option<&'a str>, + pub after: Option<&'a str>, + pub at_break: bool, + pub(crate) compiled: Option, +} + +// This could use a better name. +pub enum Construct { + List(Vec), + Single(ConstructName), +} + +impl<'a> Unsafe<'a> { + pub fn new( + character: char, + before: Option<&'a str>, + after: Option<&'a str>, + in_construct: Option, + not_in_construct: Option, + at_break: bool, + ) -> Self { + Unsafe { + character, + in_construct, + not_in_construct, + before, + after, + at_break, + compiled: None, + } + } + + pub fn get_default_unsafe() -> Vec { + let full_phrasing_spans = vec![ + ConstructName::Autolink, + ConstructName::DestinationLiteral, + ConstructName::DestinationRaw, + ConstructName::Reference, + ConstructName::TitleQuote, + ConstructName::TitleApostrophe, + ]; + + vec![ + Self::new( + '\t', + None, + "[\\r\\n]".into(), + Construct::Single(ConstructName::Phrasing).into(), + None, + false, + ), + Self::new( + '\t', + "[\\r\\n]".into(), + None, + Construct::Single(ConstructName::Phrasing).into(), + None, + false, + ), + Self::new( + '\t', + None, + None, + Construct::List(vec![ + ConstructName::CodeFencedLangGraveAccent, + ConstructName::CodeFencedLangTilde, + ]) + .into(), + None, + false, + ), + Self::new( + '\r', + None, + None, + Construct::List(vec![ + ConstructName::CodeFencedLangGraveAccent, + ConstructName::CodeFencedLangTilde, + ConstructName::CodeFencedMetaGraveAccent, + ConstructName::CodeFencedMetaTilde, + ConstructName::DestinationLiteral, + ConstructName::HeadingAtx, + ]) + .into(), + None, + false, + ), + Self::new( + '\n', + None, + None, + Construct::List(vec![ + ConstructName::CodeFencedLangGraveAccent, + ConstructName::CodeFencedLangTilde, + ConstructName::CodeFencedMetaGraveAccent, + ConstructName::CodeFencedMetaTilde, + ConstructName::DestinationLiteral, + ConstructName::HeadingAtx, + ]) + .into(), + None, + false, + ), + Self::new( + ' ', + None, + "[\\r\\n]".into(), + Construct::Single(ConstructName::Phrasing).into(), + None, + false, + ), + Self::new( + ' ', + "[\\r\\n]".into(), + None, + Construct::Single(ConstructName::Phrasing).into(), + None, + false, + ), + Self::new( + ' ', + None, + None, + Construct::List(vec![ + ConstructName::CodeFencedLangGraveAccent, + ConstructName::CodeFencedLangTilde, + ]) + .into(), + None, + false, + ), + Self::new( + '!', + None, + "\\[".into(), + Construct::Single(ConstructName::Phrasing).into(), + Construct::List(full_phrasing_spans.clone()).into(), + false, + ), + Self::new( + '\"', + None, + None, + Construct::Single(ConstructName::TitleQuote).into(), + None, + false, + ), + Self::new('#', None, None, None, None, true), + Self::new( + '#', + None, + "(?:[\r\n]|$)".into(), + Construct::Single(ConstructName::HeadingAtx).into(), + None, + false, + ), + Self::new( + '&', + None, + "[#A-Za-z]".into(), + Construct::Single(ConstructName::Phrasing).into(), + None, + false, + ), + Self::new( + '\'', + None, + None, + Construct::Single(ConstructName::TitleApostrophe).into(), + None, + false, + ), + Self::new( + '(', + None, + None, + Construct::Single(ConstructName::DestinationRaw).into(), + None, + false, + ), + Self::new( + '(', + "\\]".into(), + None, + Construct::Single(ConstructName::Phrasing).into(), + Construct::List(full_phrasing_spans.clone()).into(), + false, + ), + Self::new(')', "\\d+".into(), None, None, None, true), + Self::new( + ')', + None, + None, + Construct::Single(ConstructName::DestinationRaw).into(), + None, + false, + ), + Self::new('*', None, "(?:[ \t\r\n*])".into(), None, None, true), + Self::new( + '*', + None, + None, + Construct::Single(ConstructName::Phrasing).into(), + Construct::List(full_phrasing_spans.clone()).into(), + false, + ), + Self::new('+', None, "(?:[ \t\r\n])".into(), None, None, true), + Self::new('-', None, "(?:[ \t\r\n-])".into(), None, None, true), + Self::new( + '.', + "\\d+".into(), + "(?:[ \t\r\n]|$)".into(), + None, + None, + true, + ), + Self::new('<', None, "[!/?A-Za-z]".into(), None, None, true), + Self::new( + '<', + None, + "[!/?A-Za-z]".into(), + Construct::Single(ConstructName::Phrasing).into(), + Construct::List(full_phrasing_spans.clone()).into(), + false, + ), + Self::new( + '<', + None, + None, + Construct::Single(ConstructName::DestinationLiteral).into(), + None, + false, + ), + Self::new('=', None, None, None, None, true), + Self::new('>', None, None, None, None, true), + Self::new( + '>', + None, + None, + Construct::Single(ConstructName::DestinationLiteral).into(), + None, + false, + ), + Self::new('[', None, None, None, None, true), + Self::new( + '[', + None, + None, + Construct::Single(ConstructName::Phrasing).into(), + Construct::List(full_phrasing_spans.clone()).into(), + false, + ), + Self::new( + '[', + None, + None, + Construct::List(vec![ConstructName::Label, ConstructName::Reference]).into(), + None, + false, + ), + Self::new( + '\\', + None, + "[\\r\\n]".into(), + Construct::Single(ConstructName::Phrasing).into(), + None, + false, + ), + Self::new( + ']', + None, + None, + Construct::List(vec![ConstructName::Label, ConstructName::Reference]).into(), + None, + false, + ), + Self::new('_', None, None, None, None, true), + Self::new( + '_', + None, + None, + Construct::Single(ConstructName::Phrasing).into(), + Construct::List(full_phrasing_spans.clone()).into(), + false, + ), + Self::new('`', None, None, None, None, true), + Self::new( + '`', + None, + None, + Construct::List(vec![ + ConstructName::CodeFencedLangGraveAccent, + ConstructName::CodeFencedMetaGraveAccent, + ]) + .into(), + None, + false, + ), + Self::new( + '`', + None, + None, + Construct::Single(ConstructName::Phrasing).into(), + Construct::List(full_phrasing_spans.clone()).into(), + false, + ), + Self::new('~', None, None, None, None, true), + ] + } + + pub(crate) fn set_compiled(&mut self, regex_pattern: Regex) { + self.compiled = Some(regex_pattern); + } +} diff --git a/mdast_util_to_markdown/src/util/check_bullet.rs b/mdast_util_to_markdown/src/util/check_bullet.rs new file mode 100644 index 00000000..a2379f2f --- /dev/null +++ b/mdast_util_to_markdown/src/util/check_bullet.rs @@ -0,0 +1,18 @@ +use alloc::format; + +use crate::{message::Message, state::State}; + +pub fn check_bullet(state: &mut State) -> Result { + let marker = state.options.bullet; + + if marker != '*' && marker != '+' && marker != '-' { + return Err(Message { + reason: format!( + "Cannot serialize items with `' {} '` for `options.bullet`, expected `*`, `+`, or `-`", + marker + ), + }); + } + + Ok(marker) +} diff --git a/mdast_util_to_markdown/src/util/check_bullet_ordered.rs b/mdast_util_to_markdown/src/util/check_bullet_ordered.rs new file mode 100644 index 00000000..271bce8e --- /dev/null +++ b/mdast_util_to_markdown/src/util/check_bullet_ordered.rs @@ -0,0 +1,18 @@ +use alloc::format; + +use crate::{message::Message, state::State}; + +pub fn check_bullet_ordered(state: &mut State) -> Result { + let marker = state.options.bullet_ordered; + + if marker != '.' && marker != ')' { + return Err(Message { + reason: format!( + "Cannot serialize items with `' {} '` for `options.bulletOrdered`, expected `.` or `)`", + marker + ), + }); + } + + Ok(marker) +} diff --git a/mdast_util_to_markdown/src/util/check_bullet_other.rs b/mdast_util_to_markdown/src/util/check_bullet_other.rs new file mode 100644 index 00000000..06409422 --- /dev/null +++ b/mdast_util_to_markdown/src/util/check_bullet_other.rs @@ -0,0 +1,30 @@ +use alloc::format; + +use crate::{message::Message, state::State}; + +use super::check_bullet::check_bullet; + +pub fn check_bullet_other(state: &mut State) -> Result { + let bullet = check_bullet(state)?; + let bullet_other = state.options.bullet_other; + + if bullet_other != '*' && bullet_other != '+' && bullet_other != '-' { + return Err(Message { + reason: format!( + "Cannot serialize items with `' {} '` for `options.bullet_other`, expected `*`, `+`, or `-`", + bullet_other + ), + }); + } + + if bullet_other == bullet { + return Err(Message { + reason: format!( + "Expected `bullet` (`' {} '`) and `bullet_other` (`' {} '`) to be different", + bullet, bullet_other + ), + }); + } + + Ok(bullet_other) +} diff --git a/mdast_util_to_markdown/src/util/check_emphasis.rs b/mdast_util_to_markdown/src/util/check_emphasis.rs new file mode 100644 index 00000000..c8f7856e --- /dev/null +++ b/mdast_util_to_markdown/src/util/check_emphasis.rs @@ -0,0 +1,18 @@ +use alloc::format; + +use crate::{message::Message, state::State}; + +pub fn check_emphasis(state: &State) -> Result { + let marker = state.options.emphasis; + + if marker != '*' && marker != '_' { + return Err(Message { + reason: format!( + "Cannot serialize emphasis with `{}` for `options.emphasis`, expected `*`, or `_`", + marker + ), + }); + } + + Ok(marker) +} diff --git a/mdast_util_to_markdown/src/util/check_fence.rs b/mdast_util_to_markdown/src/util/check_fence.rs new file mode 100644 index 00000000..f7d03c11 --- /dev/null +++ b/mdast_util_to_markdown/src/util/check_fence.rs @@ -0,0 +1,18 @@ +use alloc::format; + +use crate::{message::Message, state::State}; + +pub fn check_fence(state: &mut State) -> Result { + let marker = state.options.fence; + + if marker != '`' && marker != '~' { + return Err(Message { + reason: format!( + "Cannot serialize code with `{}` for `options.fence`, expected `` ` `` or `~`", + marker + ), + }); + } + + Ok(marker) +} diff --git a/mdast_util_to_markdown/src/util/check_quote.rs b/mdast_util_to_markdown/src/util/check_quote.rs new file mode 100644 index 00000000..ed3bd04d --- /dev/null +++ b/mdast_util_to_markdown/src/util/check_quote.rs @@ -0,0 +1,18 @@ +use alloc::format; + +use crate::{message::Message, state::State}; + +pub fn check_quote(state: &State) -> Result { + let marker = state.options.quote; + + if marker != '"' && marker != '\'' { + return Err(Message { + reason: format!( + "Cannot serialize title with `' {} '` for `options.quote`, expected `\"`, or `'`", + marker + ), + }); + } + + Ok(marker) +} diff --git a/mdast_util_to_markdown/src/util/check_rule.rs b/mdast_util_to_markdown/src/util/check_rule.rs new file mode 100644 index 00000000..59e3a667 --- /dev/null +++ b/mdast_util_to_markdown/src/util/check_rule.rs @@ -0,0 +1,18 @@ +use alloc::format; + +use crate::{message::Message, state::State}; + +pub fn check_rule(state: &State) -> Result { + let marker = state.options.rule; + + if marker != '*' && marker != '-' && marker != '_' { + return Err(Message { + reason: format!( + "Cannot serialize rules with `{}` for `options.rule`, expected `*`, `-`, or `_`", + marker + ), + }); + } + + Ok(marker) +} diff --git a/mdast_util_to_markdown/src/util/check_rule_repetition.rs b/mdast_util_to_markdown/src/util/check_rule_repetition.rs new file mode 100644 index 00000000..15a0d158 --- /dev/null +++ b/mdast_util_to_markdown/src/util/check_rule_repetition.rs @@ -0,0 +1,18 @@ +use alloc::format; + +use crate::{message::Message, state::State}; + +pub fn check_rule_repetition(state: &State) -> Result { + let repetition = state.options.rule_repetition; + + if repetition < 3 { + return Err(Message { + reason: format!( + "Cannot serialize rules with repetition `{}` for `options.rule_repetition`, expected `3` or more", + repetition + ), + }); + } + + Ok(repetition) +} diff --git a/mdast_util_to_markdown/src/util/check_strong.rs b/mdast_util_to_markdown/src/util/check_strong.rs new file mode 100644 index 00000000..622ad94e --- /dev/null +++ b/mdast_util_to_markdown/src/util/check_strong.rs @@ -0,0 +1,18 @@ +use alloc::format; + +use crate::{message::Message, state::State}; + +pub fn check_strong(state: &State) -> Result { + let marker = state.options.strong; + + if marker != '*' && marker != '_' { + return Err(Message { + reason: format!( + "Cannot serialize strong with `{}` for `options.strong`, expected `*`, or `_`", + marker + ), + }); + } + + Ok(marker) +} diff --git a/mdast_util_to_markdown/src/util/format_code_as_indented.rs b/mdast_util_to_markdown/src/util/format_code_as_indented.rs new file mode 100644 index 00000000..68e318e1 --- /dev/null +++ b/mdast_util_to_markdown/src/util/format_code_as_indented.rs @@ -0,0 +1,14 @@ +use markdown::mdast::Code; +use regex::Regex; + +use crate::state::State; + +pub fn format_code_as_indented(code: &Code, state: &State) -> bool { + let non_whitespace = code.value.chars().any(|c| !c.is_whitespace()); + let blank = Regex::new(r"^[\t ]*(?:[\r\n]|$)|(?:^|[\r\n])[\t ]*$").unwrap(); + !state.options.fences + && !code.value.is_empty() + && code.lang.is_none() + && non_whitespace + && !blank.is_match(&code.value) +} diff --git a/mdast_util_to_markdown/src/util/format_heading_as_setext.rs b/mdast_util_to_markdown/src/util/format_heading_as_setext.rs new file mode 100644 index 00000000..9fa55696 --- /dev/null +++ b/mdast_util_to_markdown/src/util/format_heading_as_setext.rs @@ -0,0 +1,52 @@ +use alloc::string::{String, ToString}; +use markdown::mdast::{Heading, Node}; +use regex::Regex; + +use crate::state::State; + +pub fn format_heading_as_setext(heading: &Heading, state: &State) -> bool { + let line_break = Regex::new(r"\r?\n|\r").unwrap(); + let mut literal_with_line_break = false; + for child in &heading.children { + if include_literal_with_line_break(child, &line_break) { + literal_with_line_break = true; + break; + } + } + + heading.depth < 3 + && !to_string(&heading.children).is_empty() + && (state.options.setext || literal_with_line_break) +} + +fn include_literal_with_line_break(node: &Node, regex: &Regex) -> bool { + match node { + Node::Break(_) => true, + Node::MdxjsEsm(x) => regex.is_match(&x.value), + Node::Toml(x) => regex.is_match(&x.value), + Node::Yaml(x) => regex.is_match(&x.value), + Node::InlineCode(x) => regex.is_match(&x.value), + Node::InlineMath(x) => regex.is_match(&x.value), + Node::MdxTextExpression(x) => regex.is_match(&x.value), + Node::Html(x) => regex.is_match(&x.value), + Node::Text(x) => regex.is_match(&x.value), + Node::Code(x) => regex.is_match(&x.value), + Node::Math(x) => regex.is_match(&x.value), + Node::MdxFlowExpression(x) => regex.is_match(&x.value), + _ => { + if let Some(children) = node.children() { + for child in children { + if include_literal_with_line_break(child, regex) { + return true; + } + } + } + + false + } + } +} + +fn to_string(children: &[Node]) -> String { + children.iter().map(ToString::to_string).collect() +} diff --git a/mdast_util_to_markdown/src/util/format_link_as_auto_link.rs b/mdast_util_to_markdown/src/util/format_link_as_auto_link.rs new file mode 100644 index 00000000..7def88f9 --- /dev/null +++ b/mdast_util_to_markdown/src/util/format_link_as_auto_link.rs @@ -0,0 +1,36 @@ +use alloc::{format, string::ToString}; +use markdown::mdast::{Link, Node}; +use regex::RegexBuilder; + +use crate::state::State; + +pub fn format_link_as_auto_link(link: &Link, node: &Node, state: &State) -> bool { + let raw = node.to_string(); + + if let Some(children) = node.children() { + if children.len() != 1 { + return false; + } + + let mail_to = format!("mailto:{}", raw); + let start_with_protocol = RegexBuilder::new("^[a-z][a-z+.-]+:") + .case_insensitive(true) + .build() + .unwrap(); + + return !state.options.resource_link + && !link.url.is_empty() + && link.title.is_none() + && matches!(children[0], Node::Text(_)) + && (raw == link.url || mail_to == link.url) + && start_with_protocol.is_match(&link.url) + && is_valid_url(&link.url); + } + + false +} + +fn is_valid_url(url: &str) -> bool { + !url.chars() + .any(|c| c.is_whitespace() || c.is_control() || c == '>' || c == '<') +} diff --git a/mdast_util_to_markdown/src/util/indent_lines.rs b/mdast_util_to_markdown/src/util/indent_lines.rs new file mode 100644 index 00000000..144fed62 --- /dev/null +++ b/mdast_util_to_markdown/src/util/indent_lines.rs @@ -0,0 +1,19 @@ +use alloc::string::String; +use regex::Regex; + +pub fn indent_lines(value: &str, map: impl Fn(&str, usize, bool) -> String) -> String { + let mut result = String::new(); + let mut start = 0; + let mut line = 0; + let eol = Regex::new(r"\r?\n|\r").unwrap(); + for m in eol.captures_iter(value) { + let full_match = m.get(0).unwrap(); + let value_slice = &value[start..full_match.start()]; + result.push_str(&map(value_slice, line, value_slice.is_empty())); + result.push_str(full_match.as_str()); + start = full_match.start() + full_match.len(); + line += 1; + } + result.push_str(&map(&value[start..], line, value.is_empty())); + result +} diff --git a/mdast_util_to_markdown/src/util/longest_char_streak.rs b/mdast_util_to_markdown/src/util/longest_char_streak.rs new file mode 100644 index 00000000..b5097d55 --- /dev/null +++ b/mdast_util_to_markdown/src/util/longest_char_streak.rs @@ -0,0 +1,44 @@ +pub fn longest_char_streak(haystack: &str, needle: char) -> usize { + let mut max = 0; + let mut chars = haystack.chars(); + + while let Some(char) = chars.next() { + if char == needle { + let mut count = 1; + for char in chars.by_ref() { + if char == needle { + count += 1; + } else { + break; + } + } + max = count.max(max); + } + } + + max +} + +#[cfg(test)] +mod longest_char_streak { + use super::*; + + #[test] + fn longest_streak_tests() { + assert_eq!(longest_char_streak("", 'f'), 0); + assert_eq!(longest_char_streak("foo", 'o'), 2); + assert_eq!(longest_char_streak("fo foo fo", 'o'), 2); + assert_eq!(longest_char_streak("fo foo foo", 'o'), 2); + + assert_eq!(longest_char_streak("fo fooo fo", 'o'), 3); + assert_eq!(longest_char_streak("fo fooo foo", 'o'), 3); + assert_eq!(longest_char_streak("ooo", 'o'), 3); + assert_eq!(longest_char_streak("fo fooo fooooo", 'o'), 5); + + assert_eq!(longest_char_streak("fo fooooo fooo", 'o'), 5); + assert_eq!(longest_char_streak("fo fooooo fooooo", 'o'), 5); + + assert_eq!(longest_char_streak("'`'", '`'), 1); + assert_eq!(longest_char_streak("'`'", '`'), 1); + } +} diff --git a/mdast_util_to_markdown/src/util/mod.rs b/mdast_util_to_markdown/src/util/mod.rs new file mode 100644 index 00000000..c002aee4 --- /dev/null +++ b/mdast_util_to_markdown/src/util/mod.rs @@ -0,0 +1,16 @@ +pub mod check_bullet; +pub mod check_bullet_ordered; +pub mod check_bullet_other; +pub mod check_emphasis; +pub mod check_fence; +pub mod check_quote; +pub mod check_rule; +pub mod check_rule_repetition; +pub mod check_strong; +pub mod format_code_as_indented; +pub mod format_heading_as_setext; +pub mod format_link_as_auto_link; +pub mod indent_lines; +pub mod longest_char_streak; +pub mod pattern_in_scope; +pub mod safe; diff --git a/mdast_util_to_markdown/src/util/pattern_in_scope.rs b/mdast_util_to_markdown/src/util/pattern_in_scope.rs new file mode 100644 index 00000000..0480b339 --- /dev/null +++ b/mdast_util_to_markdown/src/util/pattern_in_scope.rs @@ -0,0 +1,31 @@ +use crate::{ + construct_name::ConstructName, + r#unsafe::{Construct, Unsafe}, +}; + +pub fn pattern_in_scope(stack: &[ConstructName], pattern: &Unsafe) -> bool { + list_in_scope(stack, &pattern.in_construct, true) + && !list_in_scope(stack, &pattern.not_in_construct, false) +} + +fn list_in_scope(stack: &[ConstructName], list: &Option, none: bool) -> bool { + let Some(list) = list else { + return none; + }; + match list { + Construct::Single(construct_name) => stack.contains(construct_name), + Construct::List(constructs_names) => { + if constructs_names.is_empty() { + return none; + } + + for construct_name in constructs_names { + if stack.contains(construct_name) { + return true; + } + } + + false + } + } +} diff --git a/mdast_util_to_markdown/src/util/safe.rs b/mdast_util_to_markdown/src/util/safe.rs new file mode 100644 index 00000000..4fa33bea --- /dev/null +++ b/mdast_util_to_markdown/src/util/safe.rs @@ -0,0 +1,46 @@ +use alloc::{format, string::String, vec::Vec}; +use regex::Regex; + +pub struct SafeConfig<'a> { + pub before: &'a str, + pub after: &'a str, + pub encode: Option, +} + +impl<'a> SafeConfig<'a> { + pub(crate) fn new(before: &'a str, after: &'a str, encode: Option) -> Self { + SafeConfig { + before, + after, + encode, + } + } +} + +pub struct EscapeInfos { + pub before: bool, + pub after: bool, +} + +pub fn escape_backslashes(value: &str, after: &str) -> String { + let expression = Regex::new(r"\\[!-/:-@\[-`{-~]").unwrap(); + let mut results: String = String::new(); + let whole = format!("{}{}", value, after); + + let positions: Vec = expression.find_iter(&whole).map(|m| m.start()).collect(); + let mut start = 0; + + for position in &positions { + if start != *position { + results.push_str(&value[start..*position]); + } + + results.push('\\'); + + start = *position; + } + + results.push_str(&value[start..]); + + results +} diff --git a/mdast_util_to_markdown/tests/blockquote.rs b/mdast_util_to_markdown/tests/blockquote.rs new file mode 100644 index 00000000..ab27a5b2 --- /dev/null +++ b/mdast_util_to_markdown/tests/blockquote.rs @@ -0,0 +1,56 @@ +use markdown::mdast::{Blockquote, Node, Paragraph, Text, ThematicBreak}; +use mdast_util_to_markdown::to_markdown as to; + +use pretty_assertions::assert_eq; + +#[test] +fn block_quote() { + assert_eq!( + to(&Node::Blockquote(Blockquote { + children: vec![], + position: None, + })) + .unwrap(), + ">\n", + "should support a block quote" + ); + + assert_eq!( + to(&Node::Blockquote(Blockquote { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None, + })) + .unwrap(), + "> a\n", + "should support a block quote w/ a child" + ); + + assert_eq!( + to(&Node::Blockquote(Blockquote { + children: vec![ + Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None + }), + Node::ThematicBreak(ThematicBreak { position: None }), + Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("b"), + position: None + })], + position: None + }), + ], + position: None, + })) + .unwrap(), + "> a\n>\n> ***\n>\n> b\n", + "should support a block quote" + ); +} diff --git a/mdast_util_to_markdown/tests/break.rs b/mdast_util_to_markdown/tests/break.rs new file mode 100644 index 00000000..228a5897 --- /dev/null +++ b/mdast_util_to_markdown/tests/break.rs @@ -0,0 +1,69 @@ +use markdown::mdast::{Break, Heading, Node, Text}; +use markdown::to_mdast as from; +use mdast_util_to_markdown::to_markdown_with_options as to_md_with_opts; +use mdast_util_to_markdown::{to_markdown as to, Options}; +use pretty_assertions::assert_eq; + +#[test] +fn r#break() { + assert_eq!( + to(&Node::Break(Break { position: None })).unwrap(), + "\\\n", + "should support a break" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![ + Node::Text(Text { + value: String::from("a"), + position: None + }), + Node::Break(Break { position: None }), + Node::Text(Text { + value: String::from("b"), + position: None + }), + ], + position: None, + depth: 3 + })) + .unwrap(), + "### a b\n", + "should serialize breaks in heading (atx) as a space" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![ + Node::Text(Text { + value: String::from("a "), + position: None + }), + Node::Break(Break { position: None }), + Node::Text(Text { + value: String::from("b"), + position: None + }), + ], + position: None, + depth: 3 + })) + .unwrap(), + "### a b\n", + "should serialize breaks in heading (atx) as a space" + ); + + assert_eq!( + to_md_with_opts( + &from("a \nb\n=\n", &Default::default()).unwrap(), + &Options { + setext: true, + ..Default::default() + } + ) + .unwrap(), + "a\\\nb\n=\n", + "should serialize breaks in heading (setext)" + ); +} diff --git a/mdast_util_to_markdown/tests/code.rs b/mdast_util_to_markdown/tests/code.rs new file mode 100644 index 00000000..4fbfa9b1 --- /dev/null +++ b/mdast_util_to_markdown/tests/code.rs @@ -0,0 +1,327 @@ +use markdown::mdast::{Code, Node}; +use mdast_util_to_markdown::to_markdown as to; +use mdast_util_to_markdown::to_markdown_with_options as to_md_with_opts; + +use mdast_util_to_markdown::Options; +use pretty_assertions::assert_eq; + +#[test] +fn text() { + assert_eq!( + to_md_with_opts( + &Node::Code(Code { + value: String::from("a"), + position: None, + lang: None, + meta: None + }), + &Options { + fences: false, + ..Default::default() + } + ) + .unwrap(), + " a\n", + "should support code w/ a value (indent)" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::from("a"), + position: None, + lang: None, + meta: None + })) + .unwrap(), + "```\na\n```\n", + "should support code w/ a value (fences)" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::new(), + position: None, + lang: Some("a".to_string()), + meta: None + })) + .unwrap(), + "```a\n```\n", + "should support code w/ a lang" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::new(), + position: None, + lang: None, + meta: Some("a".to_string()) + })) + .unwrap(), + "```\n```\n", + "should support (ignore) code w/ only a meta" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::new(), + position: None, + lang: Some("a".to_string()), + meta: Some("b".to_string()) + })) + .unwrap(), + "```a b\n```\n", + "should support code w/ lang and meta" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::new(), + position: None, + lang: Some("a b".to_string()), + meta: None + })) + .unwrap(), + "```a b\n```\n", + "should encode a space in `lang`" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::new(), + position: None, + lang: Some("a\nb".to_string()), + meta: None + })) + .unwrap(), + "```a b\n```\n", + "should encode a line ending in `lang`" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::new(), + position: None, + lang: Some("a`b".to_string()), + meta: None + })) + .unwrap(), + "```a`b\n```\n", + "should encode a grave accent in `lang`" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::new(), + position: None, + lang: Some("a\\-b".to_string()), + meta: None + })) + .unwrap(), + "```a\\\\-b\n```\n", + "should escape a backslash in `lang`" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::new(), + position: None, + lang: Some("x".to_string()), + meta: Some("a b".to_string()) + })) + .unwrap(), + "```x a b\n```\n", + "should not encode a space in `meta`" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::new(), + position: None, + lang: Some("x".to_string()), + meta: Some("a\nb".to_string()) + })) + .unwrap(), + "```x a b\n```\n", + "should encode a line ending in `meta`" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::new(), + position: None, + lang: Some("x".to_string()), + meta: Some("a`b".to_string()) + })) + .unwrap(), + "```x a`b\n```\n", + "should encode a grave accent in `meta`" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::new(), + position: None, + lang: Some("x".to_string()), + meta: Some("a\\-b".to_string()) + })) + .unwrap(), + "```x a\\\\-b\n```\n", + "should escape a backslash in `meta`" + ); + + assert_eq!( + to_md_with_opts( + &Node::Code(Code { + value: String::new(), + position: None, + lang: None, + meta: None + }), + &Options { + fence: '~', + ..Default::default() + } + ) + .unwrap(), + "~~~\n~~~\n", + "should support fenced code w/ tildes when `fence: \"~\"`" + ); + + assert_eq!( + to_md_with_opts( + &Node::Code(Code { + value: String::new(), + position: None, + lang: Some("a`b".to_string()), + meta: None + }), + &Options { + fence: '~', + ..Default::default() + } + ) + .unwrap(), + "~~~a`b\n~~~\n", + "should not encode a grave accent when using tildes for fences" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::from("```\nasd\n```"), + position: None, + lang: None, + meta: None + })) + .unwrap(), + "````\n```\nasd\n```\n````\n", + "should use more grave accents for fences if there are streaks of grave accents in the value (fences)" + ); + + assert_eq!( + to_md_with_opts( + &Node::Code(Code { + value: String::from("~~~\nasd\n~~~"), + position: None, + lang: None, + meta: None + }), + &Options { + fence: '~', + ..Default::default() + } + ) + .unwrap(), + "~~~~\n~~~\nasd\n~~~\n~~~~\n", + "should use more tildes for fences if there are streaks of tildes in the value (fences)" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::from("b"), + position: None, + lang: Some("a".to_string()), + meta: None + })) + .unwrap(), + "```a\nb\n```\n", + "should use a fence if there is an info" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::from(" "), + position: None, + lang: None, + meta: None + })) + .unwrap(), + "```\n \n```\n", + "should use a fence if there is only whitespace" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::from("\na"), + position: None, + lang: None, + meta: None + })) + .unwrap(), + "```\n\na\n```\n", + "should use a fence if there first line is blank (void)" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::from(" \na"), + position: None, + lang: None, + meta: None + })) + .unwrap(), + "```\n \na\n```\n", + "should use a fence if there first line is blank (filled)" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::from("a\n"), + position: None, + lang: None, + meta: None + })) + .unwrap(), + "```\na\n\n```\n", + "should use a fence if there last line is blank (void)" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::from("a\n "), + position: None, + lang: None, + meta: None + })) + .unwrap(), + "```\na\n \n```\n", + "should use a fence if there last line is blank (filled)" + ); + + assert_eq!( + to_md_with_opts( + &Node::Code(Code { + value: String::from(" a\n\n b"), + position: None, + lang: None, + meta: None + }), + &Options { + fences: false, + ..Default::default() + } + ) + .unwrap(), + " a\n\n b\n", + "should use an indent if the value is indented" + ); +} diff --git a/mdast_util_to_markdown/tests/core.rs b/mdast_util_to_markdown/tests/core.rs new file mode 100644 index 00000000..6ea07d9b --- /dev/null +++ b/mdast_util_to_markdown/tests/core.rs @@ -0,0 +1,33 @@ +use markdown::mdast::{Node, Paragraph, Root, Text, ThematicBreak}; +use mdast_util_to_markdown::to_markdown as to; + +use pretty_assertions::assert_eq; + +#[test] +fn core() { + assert_eq!( + to(&Node::Root(Root { + children: vec![ + Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None + }), + Node::ThematicBreak(ThematicBreak { position: None }), + Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("b"), + position: None + })], + position: None + }), + ], + position: None + })) + .unwrap(), + "a\n\n***\n\nb\n", + "should support root" + ); +} diff --git a/mdast_util_to_markdown/tests/definition.rs b/mdast_util_to_markdown/tests/definition.rs new file mode 100644 index 00000000..f526376a --- /dev/null +++ b/mdast_util_to_markdown/tests/definition.rs @@ -0,0 +1,345 @@ +use markdown::mdast::{Definition, Node}; +use mdast_util_to_markdown::{to_markdown as to, Options}; + +use mdast_util_to_markdown::to_markdown_with_options as to_md_with_opts; +use pretty_assertions::assert_eq; + +#[test] +fn defintion() { + assert_eq!( + to(&Node::Definition(Definition { + url: String::new(), + title: None, + identifier: String::new(), + position: None, + label: None + })) + .unwrap(), + "[]: <>\n", + "should support a definition w/o label" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::new(), + title: None, + identifier: String::new(), + position: None, + label: Some(String::from("a")) + })) + .unwrap(), + "[a]: <>\n", + "should support a definition w/ label" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::new(), + title: None, + identifier: String::new(), + position: None, + label: Some(String::from("\\")) + })) + .unwrap(), + "[\\\\]: <>\n", + "should escape a backslash in `label`" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::new(), + title: None, + identifier: String::new(), + position: None, + label: Some(String::from("[")) + })) + .unwrap(), + "[\\[]: <>\n", + "should escape an opening bracket in `label`" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::new(), + title: None, + identifier: String::new(), + position: None, + label: Some(String::from("]")) + })) + .unwrap(), + "[\\]]: <>\n", + "should escape a closing bracket in `label`" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::new(), + title: None, + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: <>\n", + "should support a definition w/ identifier" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::new(), + title: None, + identifier: String::from(r"\\"), + position: None, + label: None + })) + .unwrap(), + "[\\\\]: <>\n", + "should escape a backslash in `identifier`" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::new(), + title: None, + identifier: String::from("["), + position: None, + label: None + })) + .unwrap(), + "[\\[]: <>\n", + "should escape an opening bracket in `identifier`" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::new(), + title: None, + identifier: String::from("]"), + position: None, + label: None + })) + .unwrap(), + "[\\]]: <>\n", + "should escape a closing bracket in `identifier`" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::from("b"), + title: None, + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: b\n", + "should support a definition w/ url" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::from("b c"), + title: None, + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: \n", + "should support a definition w/ enclosed url w/ whitespace in url" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::from("b \n", + "should escape an opening angle bracket in `url` in an enclosed url" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::from("b >c"), + title: None, + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: c>\n", + "should escape a closing angle bracket in `url` in an enclosed url" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::from("b \\.c"), + title: None, + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: \n", + "should escape a backslash in `url` in an enclosed url" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::from("b\nc"), + title: None, + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: \n", + "should encode a line ending in `url` in an enclosed url" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::from("\x0C"), + title: None, + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: <\x0C>\n", + "should encode a line ending in `url` in an enclosed url" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::from("b(c"), + title: None, + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: b\\(c\n", + "should escape an opening paren in `url` in a raw url" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::from("b)c"), + title: None, + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: b\\)c\n", + "should escape a closing paren in `url` in a raw url" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::from("b\\?c"), + title: None, + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: b\\\\?c\n", + "should escape a backslash in `url` in a raw url" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::new(), + title: String::from("b").into(), + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: <> \"b\"\n", + "should support a definition w/ title" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::from("b"), + title: String::from("c").into(), + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: b \"c\"\n", + "should support a definition w/ url & title" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::new(), + title: String::from("\"").into(), + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: <> \"\\\"\"\n", + "should escape a quote in `title` in a title" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::new(), + title: String::from("\\").into(), + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: <> \"\\\\\"\n", + "should escape a backslash in `title` in a title" + ); + + assert_eq!( + to_md_with_opts( + &Node::Definition(Definition { + url: String::new(), + title: String::from("b").into(), + identifier: String::from("a"), + position: None, + label: None + }), + &Options { + quote: '\'', + ..Default::default() + } + ) + .unwrap(), + "[a]: <> 'b'\n", + "should support a definition w/ title when `quote: \"\'\"`" + ); + + assert_eq!( + to_md_with_opts( + &Node::Definition(Definition { + url: String::new(), + title: String::from("'").into(), + identifier: String::from("a"), + position: None, + label: None + }), + &Options { + quote: '\'', + ..Default::default() + } + ) + .unwrap(), + "[a]: <> '\\''\n", + "should escape a quote in `title` in a title when `quote: \"\'\"`" + ); +} diff --git a/mdast_util_to_markdown/tests/emphasis.rs b/mdast_util_to_markdown/tests/emphasis.rs new file mode 100644 index 00000000..be39cffc --- /dev/null +++ b/mdast_util_to_markdown/tests/emphasis.rs @@ -0,0 +1,70 @@ +use markdown::mdast::Emphasis; +use markdown::mdast::{Node, Text}; +use mdast_util_to_markdown::to_markdown as to; +use mdast_util_to_markdown::to_markdown_with_options as to_md_with_opts; + +use mdast_util_to_markdown::Options; +use pretty_assertions::assert_eq; + +#[test] +fn emphasis() { + assert_eq!( + to(&Node::Emphasis(Emphasis { + children: Vec::new(), + position: None + })) + .unwrap(), + "**\n", + "should support an empty emphasis" + ); + + assert_eq!( + to_md_with_opts( + &Node::Emphasis(Emphasis { + children: Vec::new(), + position: None + }), + &Options { + emphasis: '?', + ..Default::default() + } + ), + Err( + "Cannot serialize emphasis with `?` for `options.emphasis`, expected `*`, or `_`" + .into() + ), + "should throw on when given an incorrect `emphasis`" + ); + + assert_eq!( + to(&Node::Emphasis(Emphasis { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None, + })], + position: None + })) + .unwrap(), + "*a*\n", + "should support an emphasis w/ children" + ); + + assert_eq!( + to_md_with_opts( + &Node::Emphasis(Emphasis { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None, + })], + position: None + }), + &Options { + emphasis: '_', + ..Default::default() + } + ) + .unwrap(), + "_a_\n", + "should support an emphasis w/ underscores when `emphasis: \"_\"`" + ); +} diff --git a/mdast_util_to_markdown/tests/heading.rs b/mdast_util_to_markdown/tests/heading.rs new file mode 100644 index 00000000..4b128890 --- /dev/null +++ b/mdast_util_to_markdown/tests/heading.rs @@ -0,0 +1,509 @@ +use markdown::mdast::{Break, Html}; +use markdown::mdast::{Heading, Node, Text}; +use mdast_util_to_markdown::to_markdown as to; +use mdast_util_to_markdown::to_markdown_with_options as to_md_with_opts; + +use mdast_util_to_markdown::Options; +use pretty_assertions::assert_eq; + +#[test] +fn heading() { + assert_eq!( + to(&Node::Heading(Heading { + children: vec![], + position: None, + depth: 1 + })) + .unwrap(), + "#\n", + "should serialize a heading w/ rank 1" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![], + position: None, + depth: 6 + })) + .unwrap(), + "######\n", + "should serialize a heading w/ rank 6" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![], + position: None, + depth: 7 + })) + .unwrap(), + "######\n", + "should serialize a heading w/ rank 7 as 6" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![], + position: None, + depth: 0 + })) + .unwrap(), + "#\n", + "should serialize a heading w/ rank 0 as 1" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None, + depth: 1 + })) + .unwrap(), + "# a\n", + "should serialize a heading w/ content" + ); + + assert_eq!( + to_md_with_opts( + &Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None, + depth: 1 + }), + &Options { + setext: true, + ..Default::default() + } + ) + .unwrap(), + "a\n=\n", + "should serialize a heading w/ rank 1 as setext when `setext: true`" + ); + + assert_eq!( + to_md_with_opts( + &Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None, + depth: 2 + }), + &Options { + setext: true, + ..Default::default() + } + ) + .unwrap(), + "a\n-\n", + "should serialize a heading w/ rank 2 as setext when `setext: true`" + ); + + assert_eq!( + to_md_with_opts( + &Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None, + depth: 3 + }), + &Options { + setext: true, + ..Default::default() + } + ) + .unwrap(), + "### a\n", + "should serialize a heading w/ rank 3 as atx when `setext: true`" + ); + + assert_eq!( + to_md_with_opts( + &Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("aa\rb"), + position: None + })], + position: None, + depth: 2 + }), + &Options { + setext: true, + ..Default::default() + } + ) + .unwrap(), + "aa\rb\n-\n", + "should serialize a setext underline as long as the last line (1)" + ); + + assert_eq!( + to_md_with_opts( + &Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a\r\nbbb"), + position: None + })], + position: None, + depth: 1 + }), + &Options { + setext: true, + ..Default::default() + } + ) + .unwrap(), + "a\r\nbbb\n===\n", + "should serialize a setext underline as long as the last line (2)" + ); + + assert_eq!( + to_md_with_opts( + &Node::Heading(Heading { + children: vec![], + position: None, + depth: 1 + }), + &Options { + setext: true, + ..Default::default() + } + ) + .unwrap(), + "#\n", + "should serialize an empty heading w/ rank 1 as atx when `setext: true`" + ); + + assert_eq!( + to_md_with_opts( + &Node::Heading(Heading { + children: vec![], + position: None, + depth: 2 + }), + &Options { + setext: true, + ..Default::default() + } + ) + .unwrap(), + "##\n", + "should serialize an empty heading w/ rank 1 as atx when `setext: true`" + ); + + //assert_eq!( + // to(&Node::Heading(Heading { + // children: vec![], + // position: None, + // depth: 1 + // }),) + // .unwrap(), + // "`\n`\n=\n", + // "should serialize an heading w/ rank 1 and code w/ a line ending as setext" + //); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Html(Html { + value: "".to_string(), + position: None + })], + position: None, + depth: 1 + }),) + .unwrap(), + "\n==\n", + "should serialize an heading w/ rank 1 and html w/ a line ending as setext" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a\nb"), + position: None + })], + position: None, + depth: 1 + })) + .unwrap(), + "a\nb\n=\n", + "should serialize an heading w/ rank 1 and text w/ a line ending as setext" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![ + Node::Text(Text { + value: String::from("a"), + position: None + }), + Node::Break(Break { position: None }), + Node::Text(Text { + value: String::from("b"), + position: None + }), + ], + position: None, + depth: 1 + })) + .unwrap(), + "a\\\nb\n=\n", + "should serialize an heading w/ rank 1 and a break as setext" + ); + + assert_eq!( + to_md_with_opts( + &Node::Heading(Heading { + children: vec![], + position: None, + depth: 1 + }), + &Options { + close_atx: true, + ..Default::default() + } + ) + .unwrap(), + "# #\n", + "should serialize a heading with a closing sequence when `closeAtx` (empty)" + ); + + assert_eq!( + to_md_with_opts( + &Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None, + depth: 3 + }), + &Options { + close_atx: true, + ..Default::default() + } + ) + .unwrap(), + "### a ###\n", + "should serialize a with a closing sequence when `closeAtx` (content)" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("# a"), + position: None + })], + position: None, + depth: 2 + })) + .unwrap(), + "## # a\n", + "should not escape a `#` at the start of phrasing in a heading" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("1) a"), + position: None + })], + position: None, + depth: 2 + })) + .unwrap(), + "## 1) a\n", + "should not escape a `1)` at the start of phrasing in a heading" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("+ a"), + position: None + })], + position: None, + depth: 2 + })) + .unwrap(), + "## + a\n", + "should not escape a `+` at the start of phrasing in a heading" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("- a"), + position: None + })], + position: None, + depth: 2 + })) + .unwrap(), + "## - a\n", + "should not escape a `-` at the start of phrasing in a heading" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("= a"), + position: None + })], + position: None, + depth: 2 + })) + .unwrap(), + "## = a\n", + "should not escape a `=` at the start of phrasing in a heading" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("> a"), + position: None + })], + position: None, + depth: 2 + })) + .unwrap(), + "## > a\n", + "should not escape a `>` at the start of phrasing in a heading" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a #"), + position: None + })], + position: None, + depth: 1 + })) + .unwrap(), + "# a \\#\n", + "should escape a `#` at the end of a heading (1)" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a ##"), + position: None + })], + position: None, + depth: 1 + })) + .unwrap(), + "# a #\\#\n", + "should escape a `#` at the end of a heading (2)" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a # b"), + position: None + })], + position: None, + depth: 1 + })) + .unwrap(), + "# a # b\n", + "should not escape a `#` in a heading (2)" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from(" a"), + position: None + })], + position: None, + depth: 1 + })) + .unwrap(), + "# a\n", + "should encode a space at the start of an atx heading" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("\t\ta"), + position: None + })], + position: None, + depth: 1 + })) + .unwrap(), + "# \ta\n", + "should encode a tab at the start of an atx heading" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a "), + position: None + })], + position: None, + depth: 1 + })) + .unwrap(), + "# a \n", + "should encode a space at the end of an atx heading" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a\t\t"), + position: None + })], + position: None, + depth: 1 + })) + .unwrap(), + "# a\t \n", + "should encode a tab at the end of an atx heading" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a \n b"), + position: None + })], + position: None, + depth: 1 + })) + .unwrap(), + "a \n b\n=======\n", + "should encode spaces around a line ending in a setext heading" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a \n b"), + position: None + })], + position: None, + depth: 3 + })) + .unwrap(), + "### a b\n", + "should not need to encode spaces around a line ending in an atx heading (because the line ending is encoded)" + ); +} diff --git a/mdast_util_to_markdown/tests/html.rs b/mdast_util_to_markdown/tests/html.rs new file mode 100644 index 00000000..24967ea4 --- /dev/null +++ b/mdast_util_to_markdown/tests/html.rs @@ -0,0 +1,103 @@ +use markdown::mdast::{Html, Node, Paragraph, Text}; +use mdast_util_to_markdown::to_markdown as to; + +use pretty_assertions::assert_eq; + +#[test] +fn html() { + assert_eq!( + to(&Node::Html(Html { + value: String::new(), + position: None + })) + .unwrap(), + "", + "should support an empty html" + ); + + assert_eq!( + to(&Node::Html(Html { + value: String::from("a\nb"), + position: None + })) + .unwrap(), + "a\nb\n", + "should support html" + ); + + assert_eq!( + to(&Node::Paragraph(Paragraph { + children: vec![ + Node::Text(Text { + value: "a\n".to_string(), + position: None + }), + Node::Html(Html { + value: "
".to_string(), + position: None + }) + ], + position: None + })) + .unwrap(), + "a
\n", + "should prevent html (text) from becoming html (flow) (1)" + ); + + assert_eq!( + to(&Node::Paragraph(Paragraph { + children: vec![ + Node::Text(Text { + value: "a\r".to_string(), + position: None + }), + Node::Html(Html { + value: "
".to_string(), + position: None + }) + ], + position: None + })) + .unwrap(), + "a
\n", + "should prevent html (text) from becoming html (flow) (2)" + ); + + assert_eq!( + to(&Node::Paragraph(Paragraph { + children: vec![ + Node::Text(Text { + value: "a\r\n".to_string(), + position: None + }), + Node::Html(Html { + value: "
".to_string(), + position: None + }) + ], + position: None + })) + .unwrap(), + "a
\n", + "should prevent html (text) from becoming html (flow) (3)" + ); + + assert_eq!( + to(&Node::Paragraph(Paragraph { + children: vec![ + Node::Html(Html { + value: "".to_string(), + position: None + }), + Node::Text(Text { + value: "a".to_string(), + position: None + }) + ], + position: None + })) + .unwrap(), + "a\n", + "should serialize html (text)" + ); +} diff --git a/mdast_util_to_markdown/tests/image.rs b/mdast_util_to_markdown/tests/image.rs new file mode 100644 index 00000000..45308da2 --- /dev/null +++ b/mdast_util_to_markdown/tests/image.rs @@ -0,0 +1,237 @@ +use markdown::mdast::{Image, Node}; +use mdast_util_to_markdown::to_markdown as to; +use mdast_util_to_markdown::to_markdown_with_options as to_md_with_opts; + +use mdast_util_to_markdown::Options; +use pretty_assertions::assert_eq; + +#[test] +fn image() { + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::new(), + title: None + })) + .unwrap(), + "![]()\n", + "should support an image" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::from("a"), + url: String::new(), + title: None + })) + .unwrap(), + "![a]()\n", + "should support `alt`" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::from("a"), + title: None + })) + .unwrap(), + "![](a)\n", + "should support a url" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::new(), + title: Some(String::from("a")) + })) + .unwrap(), + "![](<> \"a\")\n", + "should support a title" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::from("a"), + title: Some(String::from("b")) + })) + .unwrap(), + "![](a \"b\")\n", + "should support a url and title" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::from("b c"), + title: None + })) + .unwrap(), + "![]()\n", + "should support an image w/ enclosed url w/ whitespace in url" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::from("b )\n", + "should escape an opening angle bracket in `url` in an enclosed url" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::from("b >c"), + title: None + })) + .unwrap(), + "![](c>)\n", + "should escape a closing angle bracket in `url` in an enclosed url" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::from("b \\+c"), + title: None + })) + .unwrap(), + "![]()\n", + "should escape a backslash in `url` in an enclosed url" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::from("b\nc"), + title: None + })) + .unwrap(), + "![]()\n", + "should encode a line ending in `url` in an enclosed url" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::from("b(c"), + title: None + })) + .unwrap(), + "![](b\\(c)\n", + "should escape an opening paren in `url` in a raw url" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::from("b)c"), + title: None + })) + .unwrap(), + "![](b\\)c)\n", + "should escape a closing paren in `url` in a raw url" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::from("b\\+c"), + title: None + })) + .unwrap(), + "![](b\\\\+c)\n", + "should escape a backslash in `url` in a raw url" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::from("\x0C"), + title: None + })) + .unwrap(), + "![](<\x0C>)\n", + "should support control characters in images" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::new(), + title: Some(String::from("b\"c")) + })) + .unwrap(), + "![](<> \"b\\\"c\")\n", + "should escape a double quote in `title`" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::new(), + title: Some(String::from("b\\.c")) + })) + .unwrap(), + "![](<> \"b\\\\.c\")\n", + "should escape a backslash in `title`" + ); + + assert_eq!( + to_md_with_opts( + &Node::Image(Image { + position: None, + alt: String::new(), + url: String::new(), + title: Some(String::from("b")) + }), + &Options { + quote: '\'', + ..Default::default() + } + ) + .unwrap(), + "![](<> 'b')\n", + "should support an image w/ title when `quote: \"\'\"`" + ); + + assert_eq!( + to_md_with_opts( + &Node::Image(Image { + position: None, + alt: String::new(), + url: String::new(), + title: Some(String::from("'")) + }), + &Options { + quote: '\'', + ..Default::default() + } + ) + .unwrap(), + "![](<> '\\'')\n", + "should escape a quote in `title` in a title when `quote: \"\'\"`" + ); +} diff --git a/mdast_util_to_markdown/tests/image_reference.rs b/mdast_util_to_markdown/tests/image_reference.rs new file mode 100644 index 00000000..fba31bfa --- /dev/null +++ b/mdast_util_to_markdown/tests/image_reference.rs @@ -0,0 +1,166 @@ +use markdown::mdast::{ImageReference, Node, Paragraph, ReferenceKind}; +use mdast_util_to_markdown::to_markdown as to; + +use pretty_assertions::assert_eq; + +#[test] +fn image_reference() { + assert_eq!( + to(&Node::ImageReference(ImageReference { + position: None, + alt: String::new(), + reference_kind: ReferenceKind::Full, + identifier: String::new(), + label: None + })) + .unwrap(), + "![][]\n", + "should support a link reference (nonsensical)" + ); + + assert_eq!( + to(&Node::ImageReference(ImageReference { + position: None, + alt: String::from("a"), + reference_kind: ReferenceKind::Full, + identifier: String::new(), + label: None + })) + .unwrap(), + "![a][]\n", + "should support `alt`" + ); + + assert_eq!( + to(&Node::ImageReference(ImageReference { + position: None, + alt: String::new(), + reference_kind: ReferenceKind::Full, + identifier: String::from("a"), + label: None + })) + .unwrap(), + "![][a]\n", + "should support an `identifier` (nonsensical)" + ); + + assert_eq!( + to(&Node::ImageReference(ImageReference { + position: None, + alt: String::new(), + reference_kind: ReferenceKind::Full, + identifier: String::new(), + label: String::from("a").into() + })) + .unwrap(), + "![][a]\n", + "should support a `label` (nonsensical)" + ); + + assert_eq!( + to(&Node::ImageReference(ImageReference { + position: None, + alt: String::from("A"), + reference_kind: ReferenceKind::Shortcut, + identifier: String::from("A"), + label: None + })) + .unwrap(), + "![A]\n", + "should support `reference_kind: \"ReferenceKind::Shortcut\"`" + ); + + assert_eq!( + to(&Node::ImageReference(ImageReference { + position: None, + alt: String::from("A"), + reference_kind: ReferenceKind::Collapsed, + identifier: String::from("A"), + label: None + })) + .unwrap(), + "![A][]\n", + "should support `reference_kind: \"ReferenceKind::Collapsed\"`" + ); + + assert_eq!( + to(&Node::ImageReference(ImageReference { + position: None, + alt: String::from("A"), + reference_kind: ReferenceKind::Full, + identifier: String::from("A"), + label: None + })) + .unwrap(), + "![A][A]\n", + "should support `reference_kind: \"ReferenceKind::Full\"`" + ); + + assert_eq!( + to(&Node::ImageReference(ImageReference { + position: None, + alt: String::from("&"), + label: String::from("&").into(), + reference_kind: ReferenceKind::Full, + identifier: String::from("&"), + })) + .unwrap(), + "![&][&]\n", + "should prefer label over identifier" + ); + + assert_eq!( + to(&Node::ImageReference(ImageReference { + position: None, + label: None, + alt: String::from("&"), + reference_kind: ReferenceKind::Full, + identifier: String::from("&"), + })) + .unwrap(), + "![&][&]\n", + "should decode `identifier` if w/o `label`" + ); + + assert_eq!( + to(&Node::Paragraph(Paragraph { + children: vec![Node::ImageReference(ImageReference { + position: None, + label: None, + alt: String::from("&a;"), + reference_kind: ReferenceKind::Full, + identifier: String::from("&b;"), + })], + position: None + })) + .unwrap(), + "![\\&a;][&b;]\n", + "should support incorrect character references" + ); + + assert_eq!( + to(&Node::ImageReference(ImageReference { + position: None, + label: None, + alt: String::from("+"), + reference_kind: ReferenceKind::Full, + identifier: String::from("\\+"), + })) + .unwrap(), + "![+][+]\n", + "should unescape `identifier` if w/o `label`" + ); + + assert_eq!( + to(&Node::ImageReference(ImageReference { + position: None, + label: None, + alt: String::from("a"), + reference_kind: ReferenceKind::Collapsed, + identifier: String::from("b"), + })) + .unwrap(), + "![a][b]\n", + "should use a full reference if w/o `ReferenceKind` and the label does not match the reference" + ); +} diff --git a/mdast_util_to_markdown/tests/inline_code.rs b/mdast_util_to_markdown/tests/inline_code.rs new file mode 100644 index 00000000..62bf2fec --- /dev/null +++ b/mdast_util_to_markdown/tests/inline_code.rs @@ -0,0 +1,187 @@ +use markdown::mdast::{InlineCode, Node}; +use mdast_util_to_markdown::to_markdown as to; + +use pretty_assertions::assert_eq; + +#[test] +fn text() { + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::new(), + position: None + })) + .unwrap(), + "``\n", + "should support an empty code text" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from("a"), + position: None + })) + .unwrap(), + "`a`\n", + "should support a code text" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from(" "), + position: None + })) + .unwrap(), + "` `\n", + "should support a space" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from("\n"), + position: None + })) + .unwrap(), + "`\n`\n", + "should support an eol" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from(" "), + position: None + })) + .unwrap(), + "` `\n", + "should support several spaces" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from("a`b"), + position: None + })) + .unwrap(), + "``a`b``\n", + "should use a fence of two grave accents if the value contains one" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from("a``b"), + position: None + })) + .unwrap(), + "`a``b`\n", + "should use a fence of one grave accent if the value contains two" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from("a``b`c"), + position: None + })) + .unwrap(), + "```a``b`c```\n", + "should use a fence of three grave accents if the value contains two and one" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from("`a"), + position: None + })) + .unwrap(), + "`` `a ``\n", + "should pad w/ a space if the value starts w/ a grave accent" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from("a`"), + position: None + })) + .unwrap(), + "`` a` ``\n", + "should pad w/ a space if the value ends w/ a grave accent" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from(" a "), + position: None + })) + .unwrap(), + "` a `\n", + "should pad w/ a space if the value starts and ends w/ a space" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from(" a"), + position: None + })) + .unwrap(), + "` a`\n", + "should not pad w/ spaces if the value ends w/ a non-space" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from("a "), + position: None + })) + .unwrap(), + "`a `\n", + "should not pad w/ spaces if the value starts w/ a non-space" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from("a\n- b"), + position: None + })) + .unwrap(), + "`a - b`\n", + "should prevent breaking out of code (-)" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from("a\n#"), + position: None + })) + .unwrap(), + "`a #`\n", + "should prevent breaking out of code (#)" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from("a\n1. "), + position: None + })) + .unwrap(), + "`a 1. `\n", + "should prevent breaking out of code (\\d\\.)" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from("a\r- b"), + position: None + })) + .unwrap(), + "`a - b`\n", + "should prevent breaking out of code (cr)" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from("a\r\n- b"), + position: None + })) + .unwrap(), + "`a - b`\n", + "should prevent breaking out of code (crlf)" + ); +} diff --git a/mdast_util_to_markdown/tests/link.rs b/mdast_util_to_markdown/tests/link.rs new file mode 100644 index 00000000..dfb9cd7c --- /dev/null +++ b/mdast_util_to_markdown/tests/link.rs @@ -0,0 +1,393 @@ +use markdown::mdast::{Link, Node, Text}; +use mdast_util_to_markdown::to_markdown as to; + +use mdast_util_to_markdown::to_markdown_with_options as to_md_with_opts; +use mdast_util_to_markdown::Options; +use pretty_assertions::assert_eq; + +#[test] +fn text() { + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::new(), + title: None + })) + .unwrap(), + "[]()\n", + "should support a link" + ); + + assert_eq!( + to(&Node::Link(Link { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None, + url: String::new(), + title: None + })) + .unwrap(), + "[a]()\n", + "should support children" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::from("a"), + title: None + })) + .unwrap(), + "[](a)\n", + "should support a url" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::new(), + title: Some(String::from("a")) + })) + .unwrap(), + "[](<> \"a\")\n", + "should support a title" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::from("a"), + title: Some(String::from("b")) + })) + .unwrap(), + "[](a \"b\")\n", + "should support a url and title" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::from("b c"), + title: None + })) + .unwrap(), + "[]()\n", + "should support a link w/ enclosed url w/ whitespace in url" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::from("b )\n", + "should escape an opening angle bracket in `url` in an enclosed url" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::from("b >c"), + title: None + })) + .unwrap(), + "[](c>)\n", + "should escape a closing angle bracket in `url` in an enclosed url" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::from("b \\+c"), + title: None + })) + .unwrap(), + "[]()\n", + "should escape a backslash in `url` in an enclosed url" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::from("b\nc"), + title: None + })) + .unwrap(), + "[]()\n", + "should encode a line ending in `url` in an enclosed url" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::from("b(c"), + title: None + })) + .unwrap(), + "[](b\\(c)\n", + "should escape an opening paren in `url` in a raw url" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::from("b)c"), + title: None + })) + .unwrap(), + "[](b\\)c)\n", + "should escape a closing paren in `url` in a raw url" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::from("b\\.c"), + title: None + })) + .unwrap(), + "[](b\\\\.c)\n", + "should escape a backslash in `url` in a raw url" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::from("\x0C"), + title: None + })) + .unwrap(), + "[](<\x0C>)\n", + "should support control characters in links" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::new(), + title: Some(String::from("b\\-c")) + })) + .unwrap(), + "[](<> \"b\\\\-c\")\n", + "should escape a backslash in `title`" + ); + + assert_eq!( + to(&Node::Link(Link { + children: vec![Node::Text(Text { + value: String::from("tel:123"), + position: None + })], + position: None, + url: String::from("tel:123"), + title: None + })) + .unwrap(), + "\n", + "should use an autolink for nodes w/ a value similar to the url and a protocol" + ); + + assert_eq!( + to_md_with_opts( + &Node::Link(Link { + children: vec![Node::Text(Text { + value: String::from("tel:123"), + position: None + })], + position: None, + url: String::from("tel:123"), + title: None + }), + &Options { + resource_link: true, + ..Default::default() + } + ) + .unwrap(), + "[tel:123](tel:123)\n", + "should use a resource link (`resourceLink: true`)" + ); + + assert_eq!( + to(&Node::Link(Link { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None, + url: String::from("a"), + title: None + }),) + .unwrap(), + "[a](a)\n", + "should use a normal link for nodes w/ a value similar to the url w/o a protocol" + ); + + assert_eq!( + to(&Node::Link(Link { + children: vec![Node::Text(Text { + value: String::from("tel:123"), + position: None + })], + position: None, + url: String::from("tel:123"), + title: None + }),) + .unwrap(), + "\n", + "should use an autolink for nodes w/ a value similar to the url and a protocol" + ); + + assert_eq!( + to(&Node::Link(Link { + children: vec![Node::Text(Text { + value: String::from("tel:123"), + position: None + })], + position: None, + url: String::from("tel:123"), + title: Some(String::from("a")) + }),) + .unwrap(), + "[tel:123](tel:123 \"a\")\n", + "should use a normal link for nodes w/ a value similar to the url w/ a title" + ); + + assert_eq!( + to(&Node::Link(Link { + children: vec![Node::Text(Text { + value: String::from("a@b.c"), + position: None + })], + position: None, + url: String::from("mailto:a@b.c"), + title: None + }),) + .unwrap(), + "\n", + "should use an autolink for nodes w/ a value similar to the url and a protocol (email)" + ); + + assert_eq!( + to(&Node::Link(Link { + children: vec![Node::Text(Text { + value: String::from("a.b-c_d@a.b"), + position: None + })], + position: None, + url: String::from("mailto:a.b-c_d@a.b"), + title: None + }),) + .unwrap(), + "\n", + "should not escape in autolinks" + ); + + assert_eq!( + to_md_with_opts( + &Node::Link(Link { + children: Vec::new(), + position: None, + url: String::new(), + title: Some("b".to_string()) + }), + &Options { + quote: '\'', + ..Default::default() + } + ) + .unwrap(), + "[](<> 'b')\n", + "should support a link w/ title when `quote: \"\'\"`" + ); + + assert_eq!( + to_md_with_opts( + &Node::Link(Link { + children: Vec::new(), + position: None, + url: String::new(), + title: Some("'".to_string()) + }), + &Options { + quote: '\'', + ..Default::default() + } + ) + .unwrap(), + "[](<> '\\'')\n", + "should escape a quote in `title` in a title when `quote: \"\'\"`'" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: "a b![c](d*e_f[g_h`i".to_string(), + title: None + })) + .unwrap(), + "[]()\n", + "should not escape unneeded characters in a `DestinationLiteral`" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: "a![b](c*d_e[f_g`h