#[cfg(feature = "gfm")]
use crate::ast::TableAlignment;
use crate::ast::{
CodeBlockType, CustomNode, CustomNodeWriter, HeadingType, HtmlElement, ListItem, Node,
};
use crate::error::{WriteError, WriteResult};
use crate::options::WriterOptions;
use std::fmt::{self};
use super::processors::{
BlockNodeProcessor, CustomNodeProcessor, InlineNodeProcessor, NodeProcessor,
};
#[derive(Debug)]
pub struct CommonMarkWriter {
options: WriterOptions,
buffer: String,
}
impl CommonMarkWriter {
pub fn new() -> Self {
Self::with_options(WriterOptions::default())
}
pub fn with_options(options: WriterOptions) -> Self {
Self {
options,
buffer: String::new(),
}
}
pub(crate) fn is_strict_mode(&self) -> bool {
self.options.strict
}
fn apply_prefix(&self, content: &str, prefix: &str, first_line_prefix: Option<&str>) -> String {
if content.is_empty() {
return String::new();
}
let mut result = String::new();
let lines: Vec<&str> = content.lines().collect();
if !lines.is_empty() {
let actual_prefix = first_line_prefix.unwrap_or(prefix);
result.push_str(actual_prefix);
result.push_str(lines[0]);
}
for line in &lines[1..] {
result.push('\n');
result.push_str(prefix);
result.push_str(line);
}
result
}
pub fn write(&mut self, node: &Node) -> WriteResult<()> {
if let Node::Custom(_) = node {
return CustomNodeProcessor.process(self, node);
}
if node.is_block() {
BlockNodeProcessor.process(self, node)
} else if node.is_inline() {
InlineNodeProcessor.process(self, node)
} else {
Err(WriteError::UnsupportedNodeType)
}
}
#[allow(clippy::borrowed_box)]
pub(crate) fn write_custom_node(&mut self, node: &Box<dyn CustomNode>) -> WriteResult<()> {
node.write(self)
}
pub(crate) fn get_context_for_node(&self, node: &Node) -> String {
match node {
Node::Text(_) => "Text".to_string(),
Node::Emphasis(_) => "Emphasis".to_string(),
Node::Strong(_) => "Strong".to_string(),
#[cfg(feature = "gfm")]
Node::Strikethrough(_) => "Strikethrough".to_string(),
Node::InlineCode(_) => "InlineCode".to_string(),
Node::Link { .. } => "Link content".to_string(),
Node::Image { .. } => "Image alt text".to_string(),
Node::HtmlElement(_) => "HtmlElement content".to_string(),
Node::Custom(_) => "Custom node".to_string(),
_ => "Unknown inline element".to_string(),
}
}
pub(crate) fn check_no_newline(&self, node: &Node, context: &str) -> WriteResult<()> {
if Self::node_contains_newline(node) {
return Err(WriteError::NewlineInInlineElement(context.to_string()));
}
Ok(())
}
fn node_contains_newline(node: &Node) -> bool {
match node {
Node::Text(s) | Node::InlineCode(s) => s.contains('\n'),
Node::Emphasis(children) | Node::Strong(children) => {
children.iter().any(Self::node_contains_newline)
}
#[cfg(feature = "gfm")]
Node::Strikethrough(children) => children.iter().any(Self::node_contains_newline),
Node::HtmlElement(element) => element.children.iter().any(Self::node_contains_newline),
Node::Link { content, .. } => content.iter().any(Self::node_contains_newline),
Node::Image { alt, .. } => alt.iter().any(Self::node_contains_newline),
Node::SoftBreak | Node::HardBreak => true,
Node::Custom(_) => false,
_ => false,
}
}
pub(crate) fn write_text_content(&mut self, content: &str) -> WriteResult<()> {
let escaped = content
.replace('\\', "\\\\")
.replace('*', "\\*")
.replace('_', "\\_")
.replace('[', "\\[")
.replace(']', "\\]")
.replace('<', "\\<")
.replace('>', "\\>")
.replace('`', "\\`");
self.write_str(&escaped)?;
Ok(())
}
pub(crate) fn write_code_content(&mut self, content: &str) -> WriteResult<()> {
self.write_char('`')?;
self.write_str(content)?;
self.write_char('`')?;
Ok(())
}
pub(crate) fn write_delimited(&mut self, content: &[Node], delimiter: &str) -> WriteResult<()> {
self.write_str(delimiter)?;
for node in content {
self.write(node)?;
}
self.write_str(delimiter)?;
Ok(())
}
pub(crate) fn write_document(&mut self, children: &[Node]) -> WriteResult<()> {
for (i, child) in children.iter().enumerate() {
if i > 0 {
self.write_str("\n")?;
}
self.write(child)?;
}
Ok(())
}
pub(crate) fn write_heading(
&mut self,
level: u8,
content: &[Node],
heading_type: &HeadingType,
) -> WriteResult<()> {
if level == 0 || level > 6 {
return Err(WriteError::InvalidHeadingLevel(level));
}
match heading_type {
HeadingType::Atx => {
for _ in 0..level {
self.write_char('#')?;
}
self.write_char(' ')?;
for node in content {
self.write(node)?;
}
self.write_char('\n')?;
}
HeadingType::Setext => {
for node in content {
self.write(node)?;
}
self.write_char('\n')?;
let underline_char = if level == 1 { '=' } else { '-' };
let min_len = 3;
for _ in 0..min_len {
self.write_char(underline_char)?;
}
self.write_char('\n')?;
}
}
Ok(())
}
pub(crate) fn write_paragraph(&mut self, content: &[Node]) -> WriteResult<()> {
for node in content.iter() {
self.write(node)?;
}
Ok(())
}
pub(crate) fn write_blockquote(&mut self, content: &[Node]) -> WriteResult<()> {
let mut temp_writer = CommonMarkWriter::with_options(self.options.clone());
for (i, node) in content.iter().enumerate() {
if i > 0 {
temp_writer.write_char('\n')?;
}
temp_writer.write(node)?;
}
let all_content = temp_writer.into_string();
let prefix = "> ";
let formatted_content = self.apply_prefix(&all_content, prefix, Some(prefix));
self.buffer.push_str(&formatted_content);
Ok(())
}
pub(crate) fn write_thematic_break(&mut self) -> WriteResult<()> {
self.write_str("---")?;
Ok(())
}
pub(crate) fn write_code_block(
&mut self,
language: &Option<String>,
content: &str,
block_type: &CodeBlockType,
) -> WriteResult<()> {
match block_type {
CodeBlockType::Indented => {
let indent = " ";
let indented_content = self.apply_prefix(content, indent, Some(indent));
self.buffer.push_str(&indented_content);
}
CodeBlockType::Fenced => {
let max_backticks = content
.chars()
.fold((0, 0), |(max, current), c| {
if c == '`' {
(max.max(current + 1), current + 1)
} else {
(max, 0)
}
})
.0;
let fence_len = std::cmp::max(max_backticks + 1, 3);
let fence = "`".repeat(fence_len);
self.write_str(&fence)?;
if let Some(lang) = language {
self.write_str(lang)?;
}
self.write_char('\n')?;
self.buffer.push_str(content);
if !content.ends_with('\n') {
self.write_char('\n')?;
}
self.write_str(&fence)?;
}
}
Ok(())
}
pub(crate) fn write_unordered_list(&mut self, items: &[ListItem]) -> WriteResult<()> {
for (i, item) in items.iter().enumerate() {
if i > 0 {
self.write_char('\n')?;
}
self.write_list_item(item, "- ")?;
}
Ok(())
}
pub(crate) fn write_ordered_list(&mut self, start: u32, items: &[ListItem]) -> WriteResult<()> {
let mut current_number = start;
for (i, item) in items.iter().enumerate() {
if i > 0 {
self.write_char('\n')?;
}
match item {
ListItem::Ordered { number, content: _ } => {
if let Some(custom_num) = number {
let prefix = format!("{}. ", custom_num);
self.write_list_item(item, &prefix)?;
current_number = custom_num + 1;
} else {
let prefix = format!("{}. ", current_number);
self.write_list_item(item, &prefix)?;
current_number += 1;
}
}
_ => {
let prefix = format!("{}. ", current_number);
self.write_list_item(item, &prefix)?;
current_number += 1;
}
}
}
Ok(())
}
fn write_list_item(&mut self, item: &ListItem, prefix: &str) -> WriteResult<()> {
match item {
ListItem::Unordered { content } => {
self.write_str(prefix)?;
self.write_list_item_content(content, prefix.len())?;
}
ListItem::Ordered { number, content } => {
if let Some(num) = number {
let custom_prefix = format!("{}. ", num);
self.write_str(&custom_prefix)?;
self.write_list_item_content(content, custom_prefix.len())?;
} else {
self.write_str(prefix)?;
self.write_list_item_content(content, prefix.len())?;
}
}
#[cfg(feature = "gfm")]
ListItem::Task { status, content } => {
if self.options.gfm_tasklists {
let checkbox = match status {
crate::ast::TaskListStatus::Checked => "[x] ",
crate::ast::TaskListStatus::Unchecked => "[ ] ",
};
let task_prefix = format!("{}{}", prefix, checkbox);
self.write_str(&task_prefix)?;
self.write_list_item_content(content, task_prefix.len())?;
} else {
self.write_str(prefix)?;
self.write_list_item_content(content, prefix.len())?;
}
}
}
Ok(())
}
fn write_list_item_content(&mut self, content: &[Node], prefix_len: usize) -> WriteResult<()> {
if content.is_empty() {
return Ok(());
}
let mut temp_writer = CommonMarkWriter::with_options(self.options.clone());
for (i, node) in content.iter().enumerate() {
if i > 0 {
temp_writer.write_char('\n')?;
}
temp_writer.write(node)?;
}
let all_content = temp_writer.into_string();
let indent = " ".repeat(prefix_len);
let formatted_content = self.apply_prefix(&all_content, &indent, Some(""));
self.buffer.push_str(&formatted_content);
Ok(())
}
pub(crate) fn write_table(&mut self, headers: &[Node], rows: &[Vec<Node>]) -> WriteResult<()> {
self.write_char('|')?;
for header in headers {
self.check_no_newline(header, "Table Header")?;
self.write_char(' ')?;
self.write(header)?;
self.write_str(" |")?;
}
self.write_char('\n')?;
self.write_char('|')?;
for _ in 0..headers.len() {
self.write_str(" --- |")?;
}
self.write_char('\n')?;
for row in rows {
self.write_char('|')?;
for cell in row {
self.check_no_newline(cell, "Table Cell")?;
self.write_char(' ')?;
self.write(cell)?;
self.write_str(" |")?;
}
self.write_char('\n')?;
}
Ok(())
}
#[cfg(feature = "gfm")]
pub(crate) fn write_table_with_alignment(
&mut self,
headers: &[Node],
alignments: &[TableAlignment],
rows: &[Vec<Node>],
) -> WriteResult<()> {
if !self.options.gfm_tables {
return self.write_table(headers, rows);
}
self.write_char('|')?;
for header in headers {
self.check_no_newline(header, "Table Header")?;
self.write_char(' ')?;
self.write(header)?;
self.write_str(" |")?;
}
self.write_char('\n')?;
self.write_char('|')?;
for i in 0..headers.len() {
let alignment = if i < alignments.len() {
&alignments[i]
} else {
&TableAlignment::Center
};
match alignment {
TableAlignment::Left => self.write_str(" :--- |")?,
TableAlignment::Center => self.write_str(" :---: |")?,
TableAlignment::Right => self.write_str(" ---: |")?,
TableAlignment::None => self.write_str(" --- |")?,
}
}
self.write_char('\n')?;
for row in rows {
self.write_char('|')?;
for cell in row {
self.check_no_newline(cell, "Table Cell")?;
self.write_char(' ')?;
self.write(cell)?;
self.write_str(" |")?;
}
self.write_char('\n')?;
}
Ok(())
}
pub(crate) fn write_link(
&mut self,
url: &str,
title: &Option<String>,
content: &[Node],
) -> WriteResult<()> {
for node in content {
self.check_no_newline(node, "Link Text")?;
}
self.write_char('[')?;
for node in content {
self.write(node)?;
}
self.write_str("](")?;
self.write_str(url)?;
if let Some(title_text) = title {
self.write_str(" \"")?;
self.write_str(title_text)?;
self.write_char('"')?;
}
self.write_char(')')?;
Ok(())
}
pub(crate) fn write_image(
&mut self,
url: &str,
title: &Option<String>,
alt: &[Node],
) -> WriteResult<()> {
for node in alt {
self.check_no_newline(node, "Image alt text")?;
}
self.write_str("?;
self.write_str(url)?;
if let Some(title_text) = title {
self.write_str(" \"")?;
self.write_str(title_text)?;
self.write_char('"')?;
}
self.write_char(')')?;
Ok(())
}
pub(crate) fn write_soft_break(&mut self) -> WriteResult<()> {
self.write_char('\n')?;
Ok(())
}
pub(crate) fn write_hard_break(&mut self) -> WriteResult<()> {
if self.options.hard_break_spaces {
self.write_str(" \n")?;
} else {
self.write_str("\\\n")?;
}
Ok(())
}
pub(crate) fn write_html_block(&mut self, content: &str) -> WriteResult<()> {
self.buffer.push_str(content);
Ok(())
}
pub(crate) fn write_autolink(&mut self, url: &str, is_email: bool) -> WriteResult<()> {
if url.contains('\n') {
return Err(WriteError::NewlineInInlineElement(
"Autolink URL".to_string(),
));
}
self.write_char('<')?;
if !is_email && !url.contains(':') {
self.write_str("https://")?;
}
self.write_str(url)?;
self.write_char('>')?;
Ok(())
}
#[cfg(feature = "gfm")]
pub(crate) fn write_extended_autolink(&mut self, url: &str) -> WriteResult<()> {
if !self.options.gfm_autolinks {
self.write_text_content(url)?;
return Ok(());
}
if url.contains('\n') {
return Err(WriteError::NewlineInInlineElement(
"Extended Autolink URL".to_string(),
));
}
self.write_str(url)?;
Ok(())
}
pub(crate) fn write_link_reference_definition(
&mut self,
label: &str,
destination: &str,
title: &Option<String>,
) -> WriteResult<()> {
self.write_char('[')?;
self.write_str(label)?;
self.write_str("]: ")?;
self.write_str(destination)?;
if let Some(title_text) = title {
self.write_str(" \"")?;
self.write_str(title_text)?;
self.write_char('"')?;
}
Ok(())
}
pub(crate) fn write_reference_link(
&mut self,
label: &str,
content: &[Node],
) -> WriteResult<()> {
for node in content {
self.check_no_newline(node, "Reference Link Text")?;
}
if content.is_empty() {
self.write_char('[')?;
self.write_str(label)?;
self.write_char(']')?;
return Ok(());
}
let is_shortcut =
content.len() == 1 && matches!(&content[0], Node::Text(text) if text == label);
if is_shortcut {
self.write_char('[')?;
self.write_str(label)?;
self.write_char(']')?;
} else {
self.write_char('[')?;
for node in content {
self.write(node)?;
}
self.write_str("][")?;
self.write_str(label)?;
self.write_char(']')?;
}
Ok(())
}
pub(crate) fn write_html_element(&mut self, element: &HtmlElement) -> WriteResult<()> {
#[cfg(feature = "gfm")]
if self.options.enable_gfm
&& self
.options
.gfm_disallowed_html_tags
.iter()
.any(|tag| tag.eq_ignore_ascii_case(&element.tag))
{
self.write_str("<")?;
self.write_str(&element.tag)?;
for attr in &element.attributes {
self.write_char(' ')?;
self.write_str(&attr.name)?;
self.write_str("=\"")?;
self.write_str(&escape_attribute_value(&attr.value))?;
self.write_char('"')?;
}
if element.self_closing {
self.write_str(" />")?;
return Ok(());
}
self.write_str(">")?;
for child in &element.children {
self.write(child)?;
}
self.write_str("</")?;
self.write_str(&element.tag)?;
self.write_str(">")?;
return Ok(());
}
if !is_safe_tag_name(&element.tag) {
return Err(WriteError::InvalidHtmlTag(element.tag.clone()));
}
self.write_char('<')?;
self.write_str(&element.tag)?;
for attr in &element.attributes {
if !is_safe_attribute_name(&attr.name) {
return Err(WriteError::InvalidHtmlAttribute(attr.name.clone()));
}
self.write_char(' ')?;
self.write_str(&attr.name)?;
self.write_str("=\"")?;
self.write_str(&escape_attribute_value(&attr.value))?;
self.write_char('"')?;
}
if element.self_closing {
self.write_str(" />")?;
return Ok(());
}
self.write_char('>')?;
for child in &element.children {
self.write(child)?;
}
self.write_str("</")?;
self.write_str(&element.tag)?;
self.write_char('>')?;
Ok(())
}
pub fn into_string(self) -> String {
self.buffer
}
pub(crate) fn ensure_trailing_newline(&mut self) -> WriteResult<()> {
if !self.buffer.ends_with('\n') {
self.write_char('\n')?;
}
Ok(())
}
#[cfg(feature = "gfm")]
pub(crate) fn write_strikethrough(&mut self, content: &[Node]) -> WriteResult<()> {
if !self.options.enable_gfm || !self.options.gfm_strikethrough {
for node in content.iter() {
self.write(node)?;
}
return Ok(());
}
self.write_delimited(content, "~~")
}
}
impl Default for CommonMarkWriter {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for Node {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut writer = CommonMarkWriter::new();
match writer.write(self) {
Ok(_) => write!(f, "{}", writer.into_string()),
Err(e) => write!(f, "Error writing Node: {}", e),
}
}
}
impl CustomNodeWriter for CommonMarkWriter {
fn write_str(&mut self, s: &str) -> fmt::Result {
self.buffer.push_str(s);
Ok(())
}
fn write_char(&mut self, c: char) -> fmt::Result {
self.buffer.push(c);
Ok(())
}
}
fn is_safe_tag_name(tag: &str) -> bool {
!tag.is_empty()
&& tag
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == ':' || c == '-')
}
fn is_safe_attribute_name(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == ':' || c == '-' || c == '.')
}
fn escape_attribute_value(value: &str) -> String {
value
.replace('&', "&")
.replace('"', """)
.replace('\'', "'")
.replace('<', "<")
.replace('>', ">")
}