use crate::{ChecklistItem, Document, InlineStyle, Paragraph, ParagraphType, Span};
use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::HashMap;
use std::io::Write;
const DEFAULT_WRAP_WIDTH: usize = 72;
const DEFAULT_QUOTE_PREFIX: &str = "| ";
const DEFAULT_UNORDERED_LIST_ITEM_PREFIX: &str = " • ";
static ANSI_ESCAPE_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\x1b\[[0-9;]*m").expect("valid ANSI escape regex"));
static OSC8_SEQUENCE_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\x1b]8;([^;]*);([^\x1b]*)\x1b\\").expect("valid OSC8 regex"));
static OSC8_ESCAPE_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\x1b]8;[^\x1b]*\x1b\\").expect("valid OSC8 escape regex"));
#[derive(Clone)]
pub struct StyleTags {
pub begin: String,
pub end: String,
}
impl StyleTags {
pub fn new(begin: impl Into<String>, end: impl Into<String>) -> Self {
Self {
begin: begin.into(),
end: end.into(),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LinkIndexFormat {
#[default]
SuperscriptArabic,
Bracketed,
}
#[derive(Clone)]
pub struct FormattingStyle {
pub reset_styles: String,
pub text_styles: HashMap<InlineStyle, StyleTags>,
pub quote_prefix: String,
pub unordered_list_item_prefix: String,
pub wrap_width: usize,
pub left_padding: usize,
pub enable_osc8_hyperlinks: bool,
pub link_index_format: LinkIndexFormat,
pub link_footnotes: bool,
}
impl Default for FormattingStyle {
fn default() -> Self {
Self {
reset_styles: String::new(),
text_styles: HashMap::new(),
quote_prefix: DEFAULT_QUOTE_PREFIX.to_string(),
unordered_list_item_prefix: DEFAULT_UNORDERED_LIST_ITEM_PREFIX.to_string(),
wrap_width: DEFAULT_WRAP_WIDTH,
left_padding: 0,
enable_osc8_hyperlinks: false,
link_index_format: LinkIndexFormat::default(),
link_footnotes: true,
}
}
}
impl FormattingStyle {
pub fn ascii() -> Self {
Self::default()
}
pub fn ansi() -> Self {
let mut text_styles = HashMap::new();
text_styles.insert(InlineStyle::Bold, StyleTags::new("\x1b[1m", "\x1b[22m"));
text_styles.insert(InlineStyle::Italic, StyleTags::new("\x1b[3m", "\x1b[23m"));
text_styles.insert(
InlineStyle::Highlight,
StyleTags::new("\x1b[7m", "\x1b[27m"),
);
text_styles.insert(
InlineStyle::Underline,
StyleTags::new("\x1b[4m", "\x1b[24m"),
);
text_styles.insert(InlineStyle::Strike, StyleTags::new("\x1b[9m", "\x1b[29m"));
Self {
reset_styles: "\x1b[0m".to_string(),
text_styles,
quote_prefix: DEFAULT_QUOTE_PREFIX.to_string(),
unordered_list_item_prefix: DEFAULT_UNORDERED_LIST_ITEM_PREFIX.to_string(),
wrap_width: DEFAULT_WRAP_WIDTH,
left_padding: 0,
enable_osc8_hyperlinks: true,
link_index_format: LinkIndexFormat::default(),
link_footnotes: true,
}
}
}
pub struct Formatter<W: Write> {
pub style: FormattingStyle,
writer: W,
pending_links: Vec<LinkReference>,
link_indices: HashMap<String, usize>,
next_link_index: usize,
next_hyperlink_id: usize,
}
#[derive(Clone, Debug)]
struct LinkReference {
index: usize,
target: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct Osc8Link {
id: Option<String>,
target: String,
}
impl Osc8Link {
fn new(id: Option<String>, target: impl Into<String>) -> Self {
Self {
id,
target: target.into(),
}
}
}
impl<W: Write> Formatter<W> {
pub fn new(writer: W, style: FormattingStyle) -> Self {
Self {
writer,
style,
pending_links: Vec::new(),
link_indices: HashMap::new(),
next_link_index: 1,
next_hyperlink_id: 1,
}
}
pub fn new_ascii(writer: W) -> Self {
Self::new(writer, FormattingStyle::ascii())
}
pub fn new_ansi(writer: W) -> Self {
Self::new(writer, FormattingStyle::ansi())
}
pub fn write_document(&mut self, document: &Document) -> std::io::Result<()> {
self.next_hyperlink_id = 1;
let indent = " ".repeat(self.style.left_padding);
self.write_paragraphs(&document.paragraphs, &indent, &indent, &indent)?;
let _ = self.flush_pending_links(&indent)?;
if !self.style.reset_styles.is_empty() {
write!(self.writer, "{}", self.style.reset_styles)?;
}
Ok(())
}
fn write_paragraphs(
&mut self,
paragraphs: &[Paragraph],
prefix: &str,
continuation_prefix: &str,
blank_line_prefix: &str,
) -> std::io::Result<()> {
self.write_paragraphs_with_prefixes(
paragraphs,
&[],
prefix,
continuation_prefix,
blank_line_prefix,
)
}
fn write_paragraphs_with_prefixes(
&mut self,
paragraphs: &[Paragraph],
first_line_prefixes: &[&str],
default_first_line_prefix: &str,
continuation_prefix: &str,
blank_line_prefix: &str,
) -> std::io::Result<()> {
let mut previous_type: Option<ParagraphType> = None;
for (idx, paragraph) in paragraphs.iter().enumerate() {
let paragraph_type = paragraph.paragraph_type();
let flushed_links = if matches!(
paragraph_type,
ParagraphType::Header1 | ParagraphType::Header2 | ParagraphType::Header3
) {
self.flush_pending_links(blank_line_prefix)?
} else {
false
};
let previous_after = previous_type
.map(|ty| self.blank_lines_after(ty))
.unwrap_or(0);
let mut blank_lines = self.blank_lines_before(previous_type, paragraph_type);
if flushed_links && blank_lines > 0 {
blank_lines -= 1;
}
self.write_blank_lines_with_prefix(blank_line_prefix, previous_after.max(blank_lines))?;
let paragraph_prefix = if idx < first_line_prefixes.len() {
first_line_prefixes[idx]
} else {
default_first_line_prefix
};
self.write_paragraph(
paragraph,
paragraph_prefix,
continuation_prefix,
blank_line_prefix,
)?;
previous_type = Some(paragraph_type);
}
if let Some(last_type) = previous_type {
self.write_blank_lines_with_prefix(
blank_line_prefix,
self.blank_lines_after(last_type),
)?;
}
Ok(())
}
fn flush_pending_links(&mut self, prefix: &str) -> std::io::Result<bool> {
if !self.style.link_footnotes {
if !self.pending_links.is_empty() {
self.pending_links.clear();
}
if !self.link_indices.is_empty() {
self.link_indices.clear();
}
self.next_link_index = 1;
return Ok(false);
}
if self.pending_links.is_empty() {
self.link_indices.clear();
self.next_link_index = 1;
return Ok(false);
}
self.write_blank_lines_with_prefix(prefix, 1)?;
let links = std::mem::take(&mut self.pending_links);
self.link_indices.clear();
let max_label_width = links
.last()
.map(|link| {
let formatted = self.format_link_index(link.index);
formatted.chars().count()
})
.unwrap_or(1);
for link in &links {
let label = self.link_label(link.index, max_label_width);
let first_prefix = format!("{}{}", prefix, label);
let continuation_prefix = format!("{}{}", prefix, " ".repeat(label.chars().count()));
let footnote_text = if self.style.enable_osc8_hyperlinks {
let hyperlink = self.next_osc8_link(&link.target);
self.osc8_wrap(&hyperlink, &link.target)
} else {
link.target.clone()
};
let parts = vec![footnote_text];
self.write_wrapped_text(&parts, &first_prefix, &continuation_prefix)?;
writeln!(self.writer)?;
}
self.next_link_index = 1;
Ok(true)
}
fn write_paragraph(
&mut self,
paragraph: &Paragraph,
prefix: &str,
continuation_prefix: &str,
blank_line_prefix: &str,
) -> std::io::Result<()> {
match paragraph.paragraph_type() {
ParagraphType::Header1 => {
self.write_header1_paragraph(paragraph.content(), prefix)?;
}
ParagraphType::Header2 => {
self.write_header2_paragraph(paragraph.content(), prefix)?;
}
ParagraphType::Header3 => {
self.write_header3_paragraph(paragraph.content(), prefix)?;
}
ParagraphType::Text => {
self.write_text_paragraph(paragraph.content(), prefix, continuation_prefix)?;
}
ParagraphType::CodeBlock => {
self.write_code_block_paragraph(paragraph.content(), prefix, continuation_prefix)?;
}
ParagraphType::Quote => {
let quote_continuation =
format!("{}{}", continuation_prefix, self.style.quote_prefix);
let shared_prefix_len = prefix
.chars()
.zip(continuation_prefix.chars())
.take_while(|(a, b)| a == b)
.count();
let children = paragraph.children();
let list_context = prefix != continuation_prefix
&& shared_prefix_len > 0
&& !children.is_empty()
&& children.len() > 1
&& matches!(
children.first().map(|p| p.paragraph_type()),
Some(ParagraphType::Text)
);
if list_context {
let quote_prefix =
format!("{}{}", continuation_prefix, self.style.quote_prefix);
let owned_prefixes = [quote_prefix, quote_continuation.clone()];
let default_first_prefix = owned_prefixes[0].as_str();
let continuation = owned_prefixes[1].as_str();
let first_line_prefixes = [prefix];
self.write_paragraphs_with_prefixes(
children,
&first_line_prefixes,
default_first_prefix,
continuation,
continuation_prefix,
)?;
} else {
let quote_prefix = format!("{}{}", prefix, self.style.quote_prefix);
self.write_paragraphs(
children,
"e_prefix,
"e_continuation,
"e_prefix,
)?;
}
}
ParagraphType::UnorderedList => {
for (idx, entry) in paragraph.entries().iter().enumerate() {
if idx > 0 {
self.write_blank_lines_with_prefix(blank_line_prefix, 1)?;
}
let base_prefix = continuation_prefix;
let bullet_prefix =
format!("{}{}", base_prefix, self.style.unordered_list_item_prefix);
let bullet_continuation = {
let desired_width = bullet_prefix.chars().count();
let current_width = base_prefix.chars().count();
let mut continuation = base_prefix.to_string();
if desired_width > current_width {
continuation.push_str(&" ".repeat(desired_width - current_width));
}
continuation
};
self.write_paragraphs_with_prefixes(
entry,
&[bullet_prefix.as_str()],
&bullet_continuation,
&bullet_continuation,
&bullet_continuation,
)?;
}
}
ParagraphType::OrderedList => {
for (i, entry) in paragraph.entries().iter().enumerate() {
if i > 0 {
self.write_blank_lines_with_prefix(blank_line_prefix, 1)?;
}
let base_prefix = continuation_prefix;
let numbering_prefix = if i == 0 && prefix != continuation_prefix {
prefix
} else {
base_prefix
};
let bullet_prefix = format!("{}{:2}. ", numbering_prefix, i + 1);
let bullet_continuation = {
let desired_width = bullet_prefix.chars().count();
let current_width = base_prefix.chars().count();
let mut continuation = base_prefix.to_string();
if desired_width > current_width {
continuation.push_str(&" ".repeat(desired_width - current_width));
}
continuation
};
self.write_paragraphs_with_prefixes(
entry,
&[bullet_prefix.as_str()],
&bullet_continuation,
&bullet_continuation,
&bullet_continuation,
)?;
}
}
ParagraphType::Checklist => self.write_checklist_items(
paragraph.checklist_items(),
continuation_prefix,
continuation_prefix,
)?,
}
Ok(())
}
fn write_checklist_items(
&mut self,
items: &[ChecklistItem],
prefix: &str,
continuation_prefix: &str,
) -> std::io::Result<()> {
for item in items {
self.write_checklist_item(item, prefix, continuation_prefix)?;
}
Ok(())
}
fn write_checklist_item(
&mut self,
item: &ChecklistItem,
prefix: &str,
continuation_prefix: &str,
) -> std::io::Result<()> {
let marker = if item.checked { "[✓] " } else { "[ ] " };
let first_prefix = format!("{}{}", prefix, marker);
let continuation = format!(
"{}{}",
continuation_prefix,
" ".repeat(marker.chars().count())
);
self.write_checklist_text(item, &first_prefix, &continuation)?;
writeln!(self.writer)?;
if !item.children.is_empty() {
let child_prefix = continuation.clone();
let child_continuation = continuation.clone();
self.write_checklist_items(
&item.children,
child_prefix.as_str(),
child_continuation.as_str(),
)?;
}
Ok(())
}
fn write_code_block_paragraph(
&mut self,
spans: &[Span],
prefix: &str,
continuation_prefix: &str,
) -> std::io::Result<()> {
self.write_code_block_fence(prefix)?;
let mut code_text = Self::collect_code_text(spans);
if !code_text.is_empty() {
code_text = code_text.replace("\r\n", "\n").replace('\r', "\n");
for line in code_text.split('\n') {
self.write_hard_wrapped_code_line(line, continuation_prefix)?;
}
}
self.write_code_block_fence(continuation_prefix)?;
Ok(())
}
fn write_hard_wrapped_code_line(
&mut self,
line: &str,
continuation_prefix: &str,
) -> std::io::Result<()> {
let available_width = self
.style
.wrap_width
.saturating_sub(continuation_prefix.chars().count())
.max(1);
if line.is_empty() {
writeln!(self.writer, "{}", continuation_prefix)?;
return Ok(());
}
let mut remaining = line;
while !remaining.is_empty() {
let mut end_idx = 0;
for (count, (idx, ch)) in remaining.char_indices().enumerate() {
if count >= available_width {
break;
}
end_idx = idx + ch.len_utf8();
}
if end_idx == 0 {
end_idx = remaining.len();
}
let (chunk, rest) = remaining.split_at(end_idx);
writeln!(self.writer, "{}{}", continuation_prefix, chunk)?;
remaining = rest;
}
Ok(())
}
fn write_code_block_fence(&mut self, prefix: &str) -> std::io::Result<()> {
const MIN_FENCE_WIDTH: usize = 4;
let available_width = self.style.wrap_width.saturating_sub(prefix.chars().count());
let dash_count = available_width.max(MIN_FENCE_WIDTH);
writeln!(self.writer, "{}{}", prefix, "-".repeat(dash_count))
}
fn collect_code_text(spans: &[Span]) -> String {
let mut buffer = String::new();
for span in spans {
Self::append_plain_text(span, &mut buffer);
}
buffer
}
fn append_plain_text(span: &Span, buffer: &mut String) {
if !span.text.is_empty() {
buffer.push_str(&span.text);
}
for child in &span.children {
Self::append_plain_text(child, buffer);
}
}
fn write_blank_lines_with_prefix(&mut self, prefix: &str, count: usize) -> std::io::Result<()> {
for _ in 0..count {
if prefix.is_empty() {
writeln!(self.writer)?;
} else {
writeln!(self.writer, "{}", prefix)?;
}
}
Ok(())
}
fn blank_lines_before(
&self,
previous_type: Option<ParagraphType>,
current_type: ParagraphType,
) -> usize {
match current_type {
ParagraphType::Header1 => 3,
ParagraphType::Header2 => 3,
ParagraphType::Header3 => 2,
_ => match previous_type {
Some(_) => 1,
None => 0,
},
}
}
fn blank_lines_after(&self, paragraph_type: ParagraphType) -> usize {
match paragraph_type {
ParagraphType::Header1 => 3,
ParagraphType::Header2 => 2,
ParagraphType::Header3 => 1,
_ => 0,
}
}
fn render_heading_text(&mut self, spans: &[Span]) -> std::io::Result<(String, usize)> {
let mut parts = Vec::new();
for span in spans {
self.collect_formatted_text(span, &mut parts)?;
}
let mut combined = String::new();
for part in parts {
if part == "\n" {
combined.push(' ');
} else {
combined.push_str(&part);
}
}
let trimmed = combined.trim().to_string();
if trimmed.is_empty() {
return Ok((String::new(), 0));
}
let bold_text = self.apply_bold(&trimmed);
let visible_width = self.visible_width(&bold_text);
Ok((bold_text, visible_width))
}
fn write_header1_paragraph(&mut self, spans: &[Span], prefix: &str) -> std::io::Result<()> {
let (bold_text, visible_width) = self.render_heading_text(spans)?;
let prefix_width = prefix.chars().count();
let available_width = self.style.wrap_width.saturating_sub(prefix_width);
if visible_width <= available_width {
let padding = if available_width > visible_width {
(available_width - visible_width) / 2
} else {
0
};
write!(self.writer, "{}", prefix)?;
for _ in 0..padding {
write!(self.writer, " ")?;
}
write!(self.writer, "{}", bold_text)?;
writeln!(self.writer)?;
} else {
let parts = vec![bold_text];
self.write_wrapped_text(&parts, prefix, prefix)?;
writeln!(self.writer)?;
}
Ok(())
}
fn write_header2_paragraph(&mut self, spans: &[Span], prefix: &str) -> std::io::Result<()> {
let (bold_text, _) = self.render_heading_text(spans)?;
let prefix_width = prefix.chars().count();
let parts = vec![bold_text];
let line_widths = self.measure_wrapped_lines(&parts, prefix_width, prefix_width);
self.write_wrapped_text(&parts, prefix, prefix)?;
writeln!(self.writer)?;
let underline_width = line_widths.into_iter().max().unwrap_or(0);
write!(self.writer, "{}", prefix)?;
for _ in 0..underline_width {
write!(self.writer, "=")?;
}
writeln!(self.writer)?;
Ok(())
}
fn write_header3_paragraph(&mut self, spans: &[Span], prefix: &str) -> std::io::Result<()> {
let (bold_text, _) = self.render_heading_text(spans)?;
let prefix_width = prefix.chars().count();
let parts = vec![bold_text];
let line_widths = self.measure_wrapped_lines(&parts, prefix_width, prefix_width);
self.write_wrapped_text(&parts, prefix, prefix)?;
writeln!(self.writer)?;
let underline_width = line_widths.into_iter().max().unwrap_or(0);
write!(self.writer, "{}", prefix)?;
for _ in 0..underline_width {
write!(self.writer, "-")?;
}
writeln!(self.writer)?;
Ok(())
}
fn write_text_paragraph(
&mut self,
spans: &[Span],
prefix: &str,
continuation_prefix: &str,
) -> std::io::Result<()> {
if spans.is_empty() {
writeln!(self.writer)?;
return Ok(());
}
let mut text_parts = Vec::new();
for span in spans {
self.collect_formatted_text(span, &mut text_parts)?;
}
self.write_wrapped_text(&text_parts, prefix, continuation_prefix)?;
writeln!(self.writer)?;
Ok(())
}
fn write_checklist_text(
&mut self,
item: &ChecklistItem,
first_prefix: &str,
continuation_prefix: &str,
) -> std::io::Result<()> {
let mut text_parts = Vec::new();
for span in &item.content {
self.collect_formatted_text(span, &mut text_parts)?;
}
if text_parts.is_empty() {
write!(self.writer, "{}", first_prefix)?;
} else {
self.write_wrapped_text(&text_parts, first_prefix, continuation_prefix)?;
}
Ok(())
}
fn apply_bold(&self, text: &str) -> String {
if text.is_empty() {
return String::new();
}
if let Some(style_tags) = self.style.text_styles.get(&InlineStyle::Bold) {
format!("{}{}{}", style_tags.begin, text, style_tags.end)
} else {
text.to_string()
}
}
fn collect_formatted_text(
&mut self,
span: &Span,
parts: &mut Vec<String>,
) -> std::io::Result<()> {
if span.style == InlineStyle::Link {
return self.collect_link_text(span, parts);
}
if span.children.is_empty() {
self.push_text_fragment(parts, &span.text);
} else {
if let Some(style_tags) = self.style.text_styles.get(&span.style) {
parts.push(style_tags.begin.clone());
}
if !span.text.is_empty() {
self.push_text_fragment(parts, &span.text);
}
for child in &span.children {
self.collect_formatted_text(child, parts)?;
}
if let Some(style_tags) = self.style.text_styles.get(&span.style) {
parts.push(style_tags.end.clone());
}
}
Ok(())
}
fn collect_link_text(&mut self, span: &Span, parts: &mut Vec<String>) -> std::io::Result<()> {
let Some(target) = span.link_target.as_ref() else {
if !span.text.is_empty() {
self.push_text_fragment(parts, &span.text);
}
for child in &span.children {
self.collect_formatted_text(child, parts)?;
}
return Ok(());
};
let hyperlink = if self.style.enable_osc8_hyperlinks {
Some(self.next_osc8_link(target))
} else {
None
};
if !span.has_content() {
let display = if let Some(link) = &hyperlink {
self.osc8_wrap(link, target)
} else {
target.clone()
};
self.push_text_fragment(parts, &display);
return Ok(());
}
if Self::is_mailto_with_matching_description(span, target) {
if let Some(link) = &hyperlink {
parts.push(self.osc8_start(link));
}
if !span.text.is_empty() {
self.push_text_fragment(parts, &span.text);
}
for child in &span.children {
self.collect_formatted_text(child, parts)?;
}
if hyperlink.is_some() {
parts.push(self.osc8_end());
}
return Ok(());
}
let footnote_index = if self.style.link_footnotes {
Some(self.register_numbered_link(target))
} else {
None
};
if let Some(link) = &hyperlink {
parts.push(self.osc8_start(link));
}
if !span.text.is_empty() {
self.push_text_fragment(parts, &span.text);
}
for child in &span.children {
self.collect_formatted_text(child, parts)?;
}
if hyperlink.is_some() {
parts.push(self.osc8_end());
}
if let Some(index) = footnote_index {
parts.push(self.inline_link_index(index));
}
Ok(())
}
fn is_mailto_with_matching_description(span: &Span, target: &str) -> bool {
let Some(address) = target.strip_prefix("mailto:") else {
return false;
};
let mut description = String::new();
Self::collect_visible_text(span, &mut description);
if description.is_empty() {
return false;
}
description.trim() == address.trim()
}
fn collect_visible_text(span: &Span, buffer: &mut String) {
if !span.text.is_empty() {
buffer.push_str(&span.text);
}
for child in &span.children {
Self::collect_visible_text(child, buffer);
}
}
fn push_text_fragment(&self, parts: &mut Vec<String>, text: &str) {
if text.is_empty() {
return;
}
if text.contains('\n') {
for (i, line) in text.split('\n').enumerate() {
if i > 0 {
parts.push("\n".to_string());
}
if !line.is_empty() {
parts.push(line.to_string());
}
}
} else {
parts.push(text.to_string());
}
}
fn next_osc8_link(&mut self, target: &str) -> Osc8Link {
let id = self.next_hyperlink_id.to_string();
self.next_hyperlink_id += 1;
Osc8Link::new(Some(id), target.to_string())
}
fn register_numbered_link(&mut self, target: &str) -> usize {
if let Some(&index) = self.link_indices.get(target) {
return index;
}
let index = self.next_link_index;
self.next_link_index += 1;
self.pending_links.push(LinkReference {
index,
target: target.to_string(),
});
self.link_indices.insert(target.to_string(), index);
index
}
fn osc8_start(&self, link: &Osc8Link) -> String {
let params = link
.id
.as_ref()
.map(|id| format!("id={}", id))
.unwrap_or_default();
format!("\x1b]8;{};{}\x1b\\", params, link.target)
}
fn osc8_end(&self) -> String {
"\x1b]8;;\x1b\\".to_string()
}
fn osc8_wrap(&self, link: &Osc8Link, text: &str) -> String {
if self.style.enable_osc8_hyperlinks {
format!("{}{}{}", self.osc8_start(link), text, self.osc8_end())
} else {
text.to_string()
}
}
fn link_label(&self, index: usize, max_width: usize) -> String {
let mut label = self.format_link_index(index);
while label.chars().count() < max_width {
label.insert(0, ' ');
}
label.push(' ');
label
}
fn inline_link_index(&self, index: usize) -> String {
match self.style.link_index_format {
LinkIndexFormat::SuperscriptArabic => self.superscript_number(index),
LinkIndexFormat::Bracketed => format!("[{}]", index),
}
}
fn format_link_index(&self, index: usize) -> String {
match self.style.link_index_format {
LinkIndexFormat::SuperscriptArabic => self.superscript_number(index),
LinkIndexFormat::Bracketed => format!("[{}]", index),
}
}
fn superscript_number(&self, index: usize) -> String {
const SUPERSCRIPTS: [char; 10] = ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹'];
index
.to_string()
.chars()
.map(|ch| {
ch.to_digit(10)
.and_then(|digit| SUPERSCRIPTS.get(digit as usize).copied())
.unwrap_or(ch)
})
.collect()
}
fn write_wrapped_text(
&mut self,
parts: &[String],
prefix: &str,
continuation_prefix: &str,
) -> std::io::Result<()> {
let mut full_text = String::new();
let mut has_forced_breaks = false;
for part in parts {
if part == "\n" {
has_forced_breaks = true;
full_text.push('\n');
} else {
full_text.push_str(part);
}
}
let mut active_styles: Vec<InlineStyle> = Vec::new();
let mut active_osc_links: Vec<Osc8Link> = Vec::new();
if has_forced_breaks {
let lines: Vec<&str> = full_text.split('\n').collect();
for (i, line) in lines.iter().enumerate() {
if i == 0 {
write!(self.writer, "{}", prefix)?;
self.write_wrapped_line(
line,
prefix.chars().count(),
continuation_prefix,
&mut active_styles,
&mut active_osc_links,
)?;
} else {
self.write_line_break(continuation_prefix, &active_styles, &active_osc_links)?;
self.write_wrapped_line(
line,
continuation_prefix.chars().count(),
continuation_prefix,
&mut active_styles,
&mut active_osc_links,
)?;
}
}
} else {
write!(self.writer, "{}", prefix)?;
self.write_wrapped_line(
&full_text,
prefix.chars().count(),
continuation_prefix,
&mut active_styles,
&mut active_osc_links,
)?;
}
Ok(())
}
fn measure_wrapped_lines(
&self,
parts: &[String],
initial_prefix_width: usize,
continuation_prefix_width: usize,
) -> Vec<usize> {
let mut full_text = String::new();
let mut has_forced_breaks = false;
for part in parts {
if part == "\n" {
has_forced_breaks = true;
full_text.push('\n');
} else {
full_text.push_str(part);
}
}
if full_text.is_empty() {
return Vec::new();
}
let mut widths = Vec::new();
if has_forced_breaks {
for (idx, segment) in full_text.split('\n').enumerate() {
let prefix_width = if idx == 0 {
initial_prefix_width
} else {
continuation_prefix_width
};
self.measure_wrapped_segment(
segment,
prefix_width,
continuation_prefix_width,
&mut widths,
);
}
} else {
self.measure_wrapped_segment(
&full_text,
initial_prefix_width,
continuation_prefix_width,
&mut widths,
);
}
widths
}
fn measure_wrapped_segment(
&self,
text: &str,
initial_prefix_width: usize,
continuation_prefix_width: usize,
widths: &mut Vec<usize>,
) {
if text.is_empty() {
return;
}
let trimmed_text = text.trim_start_matches(' ');
let leading_spaces = text.len() - trimmed_text.len();
let mut current_width = 0usize;
let mut line_width = initial_prefix_width;
let mut pending_whitespace = 0usize;
let mut saw_visible_token = false;
for _ in 0..leading_spaces {
current_width += 1;
line_width += 1;
saw_visible_token = true;
}
if trimmed_text.is_empty() {
if current_width > 0 {
widths.push(current_width);
}
return;
}
let tokens = self.tokenize_for_wrap(trimmed_text);
if tokens.is_empty() {
if current_width > 0 {
widths.push(current_width);
}
return;
}
for (is_whitespace, token) in tokens {
if is_whitespace {
pending_whitespace += token.chars().count();
continue;
}
let word_width = self.visible_width(&token);
let whitespace_width = if current_width == 0 {
0
} else {
pending_whitespace
};
if line_width + whitespace_width + word_width > self.style.wrap_width
&& current_width > 0
{
widths.push(current_width);
line_width = continuation_prefix_width;
current_width = 0;
pending_whitespace = 0;
}
if pending_whitespace > 0 && current_width > 0 {
line_width += pending_whitespace;
current_width += pending_whitespace;
}
pending_whitespace = 0;
current_width += word_width;
line_width += word_width;
if word_width > 0 {
saw_visible_token = true;
}
}
if current_width > 0 || saw_visible_token {
widths.push(current_width);
}
}
fn tokenize_for_wrap(&self, text: &str) -> Vec<(bool, String)> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut current_kind: Option<bool> = None;
for ch in text.chars() {
let is_whitespace = ch.is_whitespace();
match current_kind {
Some(kind) if kind == is_whitespace => current.push(ch),
Some(kind) => {
tokens.push((kind, std::mem::take(&mut current)));
current.push(ch);
current_kind = Some(is_whitespace);
}
None => {
current.push(ch);
current_kind = Some(is_whitespace);
}
}
}
if let Some(kind) = current_kind {
tokens.push((kind, current));
}
tokens
}
fn write_wrapped_line(
&mut self,
text: &str,
initial_width: usize,
continuation_prefix: &str,
active_styles: &mut Vec<InlineStyle>,
active_osc_links: &mut Vec<Osc8Link>,
) -> std::io::Result<()> {
if text.is_empty() {
return Ok(());
}
let trimmed_text = text.trim_start_matches(' ');
let leading_spaces = text.len() - trimmed_text.len();
let mut current_line = String::new();
let mut line_width = initial_width;
if trimmed_text.is_empty() {
return Ok(());
}
for _ in 0..leading_spaces {
current_line.push(' ');
line_width += 1;
}
let tokens = self.tokenize_for_wrap(trimmed_text);
if tokens.is_empty() {
if !current_line.is_empty() {
write!(self.writer, "{}", current_line)?;
}
return Ok(());
}
let mut pending_whitespace = String::new();
for (is_whitespace, token) in tokens {
if is_whitespace {
pending_whitespace.push_str(&token);
continue;
}
let word_width = self.visible_width(&token);
let whitespace_width = if current_line.is_empty() {
0
} else {
pending_whitespace.chars().count()
};
if line_width + whitespace_width + word_width > self.style.wrap_width
&& !current_line.is_empty()
{
let trimmed_line = current_line.trim_end();
write!(self.writer, "{}", trimmed_line)?;
self.write_line_break(continuation_prefix, active_styles, active_osc_links)?;
line_width = continuation_prefix.chars().count();
current_line.clear();
pending_whitespace.clear();
}
if !pending_whitespace.is_empty() && !current_line.is_empty() {
line_width += whitespace_width;
current_line.push_str(&pending_whitespace);
}
pending_whitespace.clear();
current_line.push_str(&token);
line_width += word_width;
self.update_active_styles_from_text(&token, active_styles);
self.update_active_osc_links_from_text(&token, active_osc_links);
}
if !current_line.is_empty() {
let trimmed_line = current_line.trim_end();
write!(self.writer, "{}", trimmed_line)?;
}
Ok(())
}
fn write_line_break(
&mut self,
continuation_prefix: &str,
active_styles: &[InlineStyle],
active_osc_links: &[Osc8Link],
) -> std::io::Result<()> {
self.write_style_resets(active_styles)?;
self.write_osc8_resets(active_osc_links)?;
writeln!(self.writer)?;
write!(self.writer, "{}", continuation_prefix)?;
self.reapply_osc8_links(active_osc_links)?;
self.reapply_active_styles(active_styles)?;
Ok(())
}
fn write_style_resets(&mut self, active_styles: &[InlineStyle]) -> std::io::Result<()> {
for style in active_styles.iter().rev() {
if let Some(tags) = self.style.text_styles.get(style) {
write!(self.writer, "{}", tags.end)?;
}
}
Ok(())
}
fn write_osc8_resets(&mut self, active_osc_links: &[Osc8Link]) -> std::io::Result<()> {
if self.style.enable_osc8_hyperlinks {
for _ in active_osc_links.iter().rev() {
write!(self.writer, "{}", self.osc8_end())?;
}
}
Ok(())
}
fn reapply_active_styles(&mut self, active_styles: &[InlineStyle]) -> std::io::Result<()> {
for style in active_styles {
if let Some(tags) = self.style.text_styles.get(style) {
write!(self.writer, "{}", tags.begin)?;
}
}
Ok(())
}
fn reapply_osc8_links(&mut self, active_osc_links: &[Osc8Link]) -> std::io::Result<()> {
if self.style.enable_osc8_hyperlinks {
for link in active_osc_links {
write!(self.writer, "{}", self.osc8_start(link))?;
}
}
Ok(())
}
fn update_active_styles_from_text(&self, text: &str, active_styles: &mut Vec<InlineStyle>) {
for capture in ANSI_ESCAPE_REGEX.find_iter(text) {
let sequence = capture.as_str();
if let Some(style) = self.find_style_start(sequence) {
active_styles.push(style);
} else if let Some(style) = self.find_style_end(sequence) {
if let Some(idx) = active_styles.iter().rposition(|s| *s == style) {
active_styles.remove(idx);
}
}
}
}
fn update_active_osc_links_from_text(&self, text: &str, active_osc_links: &mut Vec<Osc8Link>) {
if !self.style.enable_osc8_hyperlinks {
return;
}
for capture in OSC8_SEQUENCE_REGEX.captures_iter(text) {
let params = capture.get(1).map(|m| m.as_str()).unwrap_or("");
let target = capture.get(2).map(|m| m.as_str()).unwrap_or("");
if target.is_empty() {
let _ = active_osc_links.pop();
} else {
let id = params
.split(':')
.find_map(|param| param.strip_prefix("id="))
.map(|value| value.to_string());
active_osc_links.push(Osc8Link::new(id, target.to_string()));
}
}
}
fn find_style_start(&self, sequence: &str) -> Option<InlineStyle> {
self.style.text_styles.iter().find_map(|(style, tags)| {
if tags.begin == sequence {
Some(*style)
} else {
None
}
})
}
fn find_style_end(&self, sequence: &str) -> Option<InlineStyle> {
self.style.text_styles.iter().find_map(|(style, tags)| {
if tags.end == sequence {
Some(*style)
} else {
None
}
})
}
fn visible_width(&self, text: &str) -> usize {
let without_ansi = ANSI_ESCAPE_REGEX.replace_all(text, "");
let visible_text = OSC8_ESCAPE_REGEX.replace_all(&without_ansi, "");
visible_text.chars().count()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parse;
use crate::test_helpers::*;
use std::io::Cursor;
use std::time::{Duration, Instant};
#[test]
fn test_ascii_formatting() {
let mut output = Vec::new();
let mut formatter = Formatter::new_ascii(&mut output);
let doc = doc(vec![p_(vec![span("Hello "), b__("world"), span("!")])]);
formatter.write_document(&doc).unwrap();
let result = String::from_utf8(output).unwrap();
println!("ASCII format result: '{}'", result);
assert!(result.contains("Hello world!"));
assert!(!result.contains("\x1b["));
}
#[test]
fn test_ansi_formatting() {
let mut output = Vec::new();
let mut formatter = Formatter::new_ansi(&mut output);
let doc = doc(vec![p_(vec![span("Hello "), b__("world"), span("!")])]);
formatter.write_document(&doc).unwrap();
let result = String::from_utf8(output).unwrap();
assert!(result.contains("\x1b[1m")); assert!(result.contains("\x1b[22m")); assert!(result.contains("\x1b[0m")); }
#[test]
fn test_ascii_and_ansi_preserve_consecutive_spaces() {
let doc = doc(vec![p_(vec![span("A B")])]);
let mut ascii_output = Vec::new();
Formatter::new_ascii(&mut ascii_output)
.write_document(&doc)
.unwrap();
let ascii_result = String::from_utf8(ascii_output).unwrap();
assert_eq!(ascii_result, "A B\n");
let mut ansi_output = Vec::new();
Formatter::new_ansi(&mut ansi_output)
.write_document(&doc)
.unwrap();
let ansi_result = String::from_utf8(ansi_output).unwrap();
assert_eq!(ansi_result, "A B\n\x1b[0m");
}
#[test]
fn test_ansi_wrapped_style_does_not_color_prefix() {
let mut output = Vec::new();
let mut formatter = Formatter::new_ansi(&mut output);
formatter.style.wrap_width = 20;
let doc = doc(vec![ul_(vec![li_(vec![p_(vec![mark__(
"Highlighted content that wraps to another line.",
)])])])]);
formatter.write_document(&doc).unwrap();
let result = String::from_utf8(output).unwrap();
assert!(
result.contains("\x1b[27m\n \x1b[7m"),
"Expected highlight styling to reset before the newline and resume after the indent"
);
}
#[test]
fn test_ansi_forced_newline_reapplies_after_prefix() {
let mut output = Vec::new();
let mut formatter = Formatter::new_ansi(&mut output);
let doc = doc(vec![quote_(vec![p_(vec![mark__(
"Styled first line\nstyled second line continues.",
)])])]);
formatter.write_document(&doc).unwrap();
let result = String::from_utf8(output).unwrap();
assert!(
result.contains("\x1b[27m\n| \x1b[7m"),
"Expected quote prefix to remain unstyled around forced line breaks"
);
}
#[test]
fn test_ascii_links_with_footnotes() {
let doc = doc(vec![
p_(vec![
span("Visit "),
link_text__("https://example.com/docs", "Docs"),
span(" and "),
link__("https://example.com/plain"),
span("."),
]),
h2_("Next section"),
]);
let mut output = Vec::new();
Formatter::new_ascii(&mut output)
.write_document(&doc)
.unwrap();
let result = String::from_utf8(output).unwrap();
assert!(result.contains("Docs¹"));
assert!(result.contains("¹ https://example.com/docs"));
assert!(result.contains("https://example.com/plain"));
assert!(!result.contains("https://example.com/plain¹"));
let footnote_pos = result.find("¹ https://example.com/docs").unwrap();
let heading_pos = result.find("Next section").unwrap();
assert!(footnote_pos < heading_pos);
let footnote_entry = "¹ https://example.com/docs";
let footer_start = result.find(footnote_entry).unwrap();
let after_entry = footer_start + footnote_entry.len();
assert!(
result[after_entry..].starts_with('\n'),
"expected newline after footnote entry"
);
}
#[test]
fn test_ansi_links_with_footnotes() {
let doc = doc(vec![
p_(vec![
span("Visit "),
link_text__("https://example.com/docs", "Docs"),
span(" and "),
link__("https://example.com/plain"),
span("."),
]),
h2_("Next section"),
]);
let mut output = Vec::new();
Formatter::new_ansi(&mut output)
.write_document(&doc)
.unwrap();
let result = String::from_utf8(output).unwrap();
assert!(result.contains("\x1b]8;id=1;https://example.com/docs\x1b\\Docs"));
let docs_pos = result.find("Docs").unwrap();
let index_marker = "\x1b]8;;\x1b\\¹";
let index_pos = result
.find(index_marker)
.expect("superscript index marker missing");
assert!(docs_pos < index_pos);
assert!(result.contains(
"\x1b]8;id=2;https://example.com/plain\x1b\\https://example.com/plain\x1b]8;;\x1b\\"
));
assert!(result.contains(
"¹ \x1b]8;id=3;https://example.com/docs\x1b\\https://example.com/docs\x1b]8;;\x1b\\"
));
assert!(result.ends_with("\x1b[0m"));
}
#[test]
fn test_ansi_links_without_footnotes_when_disabled() {
let doc = doc(vec![
p_(vec![
span("Visit "),
link_text__("https://example.com/docs", "Docs"),
span(" and "),
link__("https://example.com/plain"),
span("."),
]),
h2_("Next section"),
]);
let mut output = Vec::new();
let mut style = FormattingStyle::ansi();
style.link_footnotes = false;
Formatter::new(&mut output, style)
.write_document(&doc)
.unwrap();
let result = String::from_utf8(output).unwrap();
assert!(result.contains("\x1b]8;id=1;https://example.com/docs\x1b\\Docs"));
assert!(
result.contains("\x1b]8;id=2;https://example.com/plain\x1b\\https://example.com/plain")
);
assert!(result.contains("Next section"));
assert!(!result.contains("\x1b]8;;\x1b\\¹"));
assert!(!result.contains("\n¹ "));
assert!(result.ends_with("\x1b[0m"));
}
#[test]
fn test_duplicate_links_share_indices() {
let doc = doc(vec![p_(vec![
span("See "),
link_text__("https://example.com/docs", "Docs"),
span(" and later revisit "),
link_text__("https://example.com/docs", "Docs again"),
span(" for details."),
])]);
let mut ascii_output = Vec::new();
Formatter::new_ascii(&mut ascii_output)
.write_document(&doc)
.unwrap();
let ascii_result = String::from_utf8(ascii_output).unwrap();
assert!(ascii_result.contains("Docs¹"));
assert!(ascii_result.contains("Docs again¹"));
assert_eq!(ascii_result.matches("\n¹ ").count(), 1);
assert!(!ascii_result.contains('²'));
let mut ansi_output = Vec::new();
Formatter::new_ansi(&mut ansi_output)
.write_document(&doc)
.unwrap();
let ansi_result = String::from_utf8(ansi_output).unwrap();
assert!(ansi_result.contains("Docs\x1b]8;;\x1b\\¹"));
assert!(ansi_result.contains("Docs again\x1b]8;;\x1b\\¹"));
assert_eq!(ansi_result.matches("\n¹ ").count(), 1);
assert!(!ansi_result.contains('²'));
}
#[test]
fn test_ascii_links_with_bracketed_indices() {
let doc = doc(vec![
p_(vec![
span("Visit "),
link_text__("https://example.com/docs", "Docs"),
span(" and "),
link__("https://example.com/plain"),
span("."),
]),
h2_("Next section"),
]);
let mut output = Vec::new();
let mut style = FormattingStyle::ascii();
style.link_index_format = LinkIndexFormat::Bracketed;
Formatter::new(&mut output, style)
.write_document(&doc)
.unwrap();
let result = String::from_utf8(output).unwrap();
assert!(result.contains("Docs[1]"));
assert!(result.contains("[1] https://example.com/docs"));
assert!(!result.contains("https://example.com/plain["));
}
#[test]
fn test_ascii_mailto_links_skip_footnotes() {
let doc = doc(vec![p_(vec![
span("Contact "),
link_text__("mailto:support@example.com", "support@example.com"),
span(" or visit "),
link_text__("https://example.com/docs", "Docs"),
span("."),
])]);
let mut output = Vec::new();
Formatter::new_ascii(&mut output)
.write_document(&doc)
.unwrap();
let result = String::from_utf8(output).unwrap();
assert!(result.contains("support@example.com"));
assert!(!result.contains("support@example.com¹"));
assert!(!result.contains("mailto:"));
assert!(result.contains("Docs¹"));
assert!(result.contains("¹ https://example.com/docs"));
}
#[test]
fn test_ansi_mailto_links_skip_indices() {
let doc = doc(vec![p_(vec![
span("Contact "),
link_text__("mailto:support@example.com", "support@example.com"),
span(" or visit "),
link_text__("https://example.com/docs", "Docs"),
span("."),
])]);
let mut output = Vec::new();
Formatter::new_ansi(&mut output)
.write_document(&doc)
.unwrap();
let result = String::from_utf8(output).unwrap();
let mailto_sequence =
"\x1b]8;id=1;mailto:support@example.com\x1b\\support@example.com\x1b]8;;\x1b\\";
assert!(
result.contains(mailto_sequence),
"expected OSC 8 wrapped mailto link"
);
assert!(!result.contains("support@example.com\x1b]8;;\x1b\\¹"));
assert!(result.contains("\x1b]8;id=2;https://example.com/docs\x1b\\Docs"));
assert!(result.contains(
"¹ \x1b]8;id=3;https://example.com/docs\x1b\\https://example.com/docs\x1b]8;;\x1b\\"
));
assert!(result.ends_with("\x1b[0m"));
}
#[test]
fn test_ansi_links_with_bracketed_indices() {
let doc = doc(vec![
p_(vec![
span("Visit "),
link_text__("https://example.com/docs", "Docs"),
span(" and "),
link__("https://example.com/plain"),
span("."),
]),
h2_("Next section"),
]);
let mut output = Vec::new();
let mut style = FormattingStyle::ansi();
style.link_index_format = LinkIndexFormat::Bracketed;
Formatter::new(&mut output, style)
.write_document(&doc)
.unwrap();
let result = String::from_utf8(output).unwrap();
assert!(result.contains("\x1b]8;id=1;https://example.com/docs\x1b\\Docs"));
assert!(result.contains("\x1b]8;;\x1b\\[1]"));
assert!(result.contains(
"[1] \x1b]8;id=3;https://example.com/docs\x1b\\https://example.com/docs\x1b]8;;\x1b\\"
));
}
#[test]
fn test_ansi_wrapped_links_emit_osc8_sequences() {
let doc = doc(vec![p_(vec![
span("See "),
link_text__(
"https://example.com",
"this link text will wrap across multiple lines for testing",
),
span(" please."),
])]);
let mut style = FormattingStyle::ansi();
style.wrap_width = 30;
let mut output = Vec::new();
Formatter::new(&mut output, style)
.write_document(&doc)
.unwrap();
let result = String::from_utf8(output).unwrap();
assert!(
result.contains("\x1b]8;id=1;https://example.com\x1b\\this"),
"expected OSC 8 hyperlink start before link text"
);
assert!(
result.contains("\x1b]8;;\x1b\\\n\x1b]8;id=1;https://example.com\x1b\\"),
"expected OSC 8 hyperlink to close before wrap newline and reopen afterwards:\n{}",
result
);
}
#[test]
fn test_superscript_link_list_alignment() {
let mut spans = Vec::new();
for i in 1..=10 {
if i > 1 {
spans.push(span(", "));
}
let target = format!("https://example.com/{}", i);
let text = format!("Doc{}", i);
spans.push(link_text__(&target, &text));
}
let doc = doc(vec![p_(spans)]);
let mut output = Vec::new();
Formatter::new_ascii(&mut output)
.write_document(&doc)
.unwrap();
let result = String::from_utf8(output).unwrap();
assert!(result.contains("\n ¹ https://example.com/1"));
assert!(!result.contains("\n¹ https://example.com/1"));
assert!(result.contains("\n¹⁰ https://example.com/10"));
}
#[test]
fn test_bracketed_link_list_alignment() {
let mut spans = Vec::new();
for i in 1..=10 {
if i > 1 {
spans.push(span(", "));
}
let target = format!("https://example.com/{}", i);
let text = format!("Doc{}", i);
spans.push(link_text__(&target, &text));
}
let doc = doc(vec![p_(spans)]);
let mut output = Vec::new();
let mut style = FormattingStyle::ascii();
style.link_index_format = LinkIndexFormat::Bracketed;
Formatter::new(&mut output, style)
.write_document(&doc)
.unwrap();
let result = String::from_utf8(output).unwrap();
assert!(result.contains("\n [1] https://example.com/1"));
assert!(!result.contains("\n[1] https://example.com/1"));
assert!(result.contains("\n[10] https://example.com/10"));
}
#[test]
fn test_quote_formatting() {
let mut output = Vec::new();
let mut formatter = Formatter::new_ascii(&mut output);
let doc = doc(vec![quote_(vec![p__("Quoted text")])]);
formatter.write_document(&doc).unwrap();
let result = String::from_utf8(output).unwrap();
assert!(result.contains("| Quoted text"));
}
#[test]
fn test_list_formatting() {
let mut output = Vec::new();
let mut formatter = Formatter::new_ascii(&mut output);
let doc = doc(vec![ul_(vec![
li_(vec![p__("Item 1")]),
li_(vec![p__("Item 2")]),
])]);
formatter.write_document(&doc).unwrap();
let result = String::from_utf8(output).unwrap();
assert!(result.contains(" • Item 1"));
assert!(result.contains(" • Item 2"));
assert!(result.contains(" • Item 1\n\n • Item 2"));
}
#[test]
fn test_ordered_list_formatting() {
let mut output = Vec::new();
let mut formatter = Formatter::new_ascii(&mut output);
let doc = doc(vec![ol_(vec![
li_(vec![p__("First")]),
li_(vec![p__("Second")]),
])]);
formatter.write_document(&doc).unwrap();
let result = String::from_utf8(output).unwrap();
assert!(result.contains(" 1. First"));
assert!(result.contains(" 2. Second"));
assert!(result.contains(" 1. First\n\n 2. Second"));
}
#[test]
fn test_list_item_multiple_paragraphs() {
let mut output = Vec::new();
let mut formatter = Formatter::new_ascii(&mut output);
let doc = doc(vec![ul_(vec![li_(vec![
p__("Primary text."),
p__("Follow-up paragraph."),
])])]);
formatter.write_document(&doc).unwrap();
let result = String::from_utf8(output).unwrap();
assert!(result.contains(" • Primary text."));
assert!(result.contains(" \n Follow-up paragraph."));
assert!(!result.contains(" • Follow-up paragraph."));
}
#[test]
fn test_list_item_forced_newline_spacing() {
let mut output = Vec::new();
let mut formatter = Formatter::new_ascii(&mut output);
let doc = doc(vec![ul_(vec![li_(vec![p_(vec![span(
"First line\nSecond line",
)])])])]);
formatter.write_document(&doc).unwrap();
let result = String::from_utf8(output).unwrap();
assert!(result.contains(" • First line\n Second line"));
}
#[test]
fn test_nested_list_item_additional_paragraphs_spacing() {
let mut output = Vec::new();
let mut formatter = Formatter::new_ascii(&mut output);
let doc = doc(vec![ul_(vec![li_(vec![ul_(vec![li_(vec![
p__("Inner item primary text."),
p__("Inner item follow-up paragraph."),
])])])])]);
formatter.write_document(&doc).unwrap();
let result = String::from_utf8(output).unwrap();
assert!(result.contains(" • Inner item primary text."));
assert!(result.contains(" Inner item follow-up paragraph."));
assert!(!result.contains(" • • "));
assert!(!result.contains("• Inner item follow-up paragraph."));
}
#[test]
fn test_list_item_only_nested_ordered_list_shows_bullet() {
let mut output = Vec::new();
let mut formatter = Formatter::new_ascii(&mut output);
let doc = doc(vec![ul_(vec![li_(vec![ol_(vec![
li_(vec![p__("First")]),
li_(vec![p__("Second")]),
])])])]);
formatter.write_document(&doc).unwrap();
let result = String::from_utf8(output).unwrap();
let lines: Vec<&str> = result.lines().collect();
let bullet_index = lines
.iter()
.position(|line| *line == " • 1. First")
.expect("Bullet line with first ordered entry missing");
let first_entry = lines
.get(bullet_index + 1)
.expect("Expected spacer after first ordered list entry");
assert!(first_entry.trim().is_empty());
let second_entry = lines
.iter()
.skip(bullet_index + 2)
.find(|line| line.trim_start().starts_with("2. Second"))
.expect("Second ordered list entry missing");
assert!(second_entry.starts_with(" 2. Second"));
}
#[test]
fn test_quote_multiple_paragraphs_spacing() {
let mut output = Vec::new();
let mut formatter = Formatter::new_ascii(&mut output);
let doc = doc(vec![quote_(vec![
p__("Paragraph one."),
p__("Paragraph two."),
])]);
formatter.write_document(&doc).unwrap();
let result = String::from_utf8(output).unwrap();
assert!(result.contains("| Paragraph one."));
assert!(result.contains("| \n| Paragraph two."));
}
#[test]
fn test_quote_list_spacing() {
let mut output = Vec::new();
let mut formatter = Formatter::new_ascii(&mut output);
let doc = doc(vec![quote_(vec![ul_(vec![
li_(vec![p__("Item 1")]),
li_(vec![p__("Item 2")]),
])])]);
formatter.write_document(&doc).unwrap();
let result = String::from_utf8(output).unwrap();
assert!(result.contains("| • Item 1"));
assert!(result.contains("| \n| • Item 2"));
}
#[test]
fn test_quote_list_with_nested_quote_spacing() {
let mut output = Vec::new();
let mut formatter = Formatter::new_ascii(&mut output);
let doc = doc(vec![quote_(vec![
p__("Please see, how the following list is part of a quote and contains nested paragraphs."),
ul_(vec![
li_(vec![p__("This is a paragraph inside of a quoted paragraph")]),
li_(vec![
p__("This bullet points contains another quote:"),
quote_(vec![p_(vec![span(
"You can never have enough nesting of paragraphs.\n —Robert Lillack",
)])]),
]),
]),
])]);
formatter.write_document(&doc).unwrap();
let result = String::from_utf8(output).unwrap();
let bullet_count = result.matches(" • ").count();
assert_eq!(bullet_count, 2);
assert!(result.contains("| • This bullet points contains another quote:"));
assert!(result.contains("| | You can never have enough nesting of paragraphs."));
assert!(result.contains("| | —Robert Lillack"));
assert!(!result.contains("| • |"));
}
#[test]
fn test_quote_list_with_nested_quote_blank_line_indent() {
let mut output = Vec::new();
let mut formatter = Formatter::new_ascii(&mut output);
let doc = doc(vec![quote_(vec![ul_(vec![li_(vec![
p__("Para 1"),
quote_(vec![p__("Para 2")]),
])])])]);
formatter.write_document(&doc).unwrap();
let result = String::from_utf8(output).unwrap();
println!("{}", result);
let lines: Vec<&str> = result.lines().collect();
let para1_index = lines
.iter()
.position(|line| *line == "| • Para 1")
.expect("Para 1 line missing");
assert_eq!(lines.get(para1_index + 1), Some(&"| "));
assert_eq!(lines.get(para1_index + 2), Some(&"| | Para 2"));
}
#[test]
fn test_quote_list_with_nested_quote_blank_line_indent_ansi() {
let mut output = Vec::new();
let mut formatter = Formatter::new_ansi(&mut output);
let doc = doc(vec![quote_(vec![ul_(vec![li_(vec![
p__("Para 1"),
quote_(vec![p__("Para 2")]),
])])])]);
formatter.write_document(&doc).unwrap();
let result = String::from_utf8(output).unwrap();
let lines: Vec<&str> = result.lines().collect();
let para1_index = lines
.iter()
.position(|line| *line == "| • Para 1")
.expect("Para 1 line missing");
assert_eq!(lines.get(para1_index + 1), Some(&"| "));
assert_eq!(lines.get(para1_index + 2), Some(&"| | Para 2"));
assert_eq!(lines.last(), Some(&"\u{1b}[0m"));
}
#[test]
fn test_quote_list_with_nested_quote_intro_inside_quote() {
let mut output = Vec::new();
let mut formatter = Formatter::new_ascii(&mut output);
let doc = doc(vec![quote_(vec![ul_(vec![li_(vec![quote_(vec![
p__("Para 1"),
p__("Para 2"),
])])])])]);
formatter.write_document(&doc).unwrap();
let result = String::from_utf8(output).unwrap();
let lines: Vec<&str> = result.lines().collect();
let para1_index = lines
.iter()
.position(|line| *line == "| • Para 1")
.expect("Para 1 line missing");
assert_eq!(lines.get(para1_index + 1), Some(&"| "));
assert_eq!(lines.get(para1_index + 2), Some(&"| | Para 2"));
assert!(!result.contains("| • | Para 1"));
}
#[test]
fn test_wrap_width() {
let mut output = Vec::new();
let mut style = FormattingStyle::ascii();
style.wrap_width = 20; let mut formatter = Formatter::new(&mut output, style);
let doc = doc(vec![p__(
"This is a very long line that should definitely be wrapped",
)]);
formatter.write_document(&doc).unwrap();
let result = String::from_utf8(output).unwrap();
let lines: Vec<&str> = result.lines().collect();
assert!(lines.len() > 1);
}
#[test]
fn test_wrap_width_with_left_padding() {
let mut output = Vec::new();
let mut style = FormattingStyle::ascii();
style.wrap_width = 10;
style.left_padding = 4;
let mut formatter = Formatter::new(&mut output, style);
let doc = doc(vec![p__("123456 7890 1234")]);
formatter.write_document(&doc).unwrap();
let result = String::from_utf8(output).unwrap();
let lines: Vec<&str> = result.lines().filter(|line| !line.is_empty()).collect();
assert!(lines.len() >= 2);
assert!(lines.iter().all(|line| line.starts_with(" ")));
assert!(lines[0].chars().count() <= 10);
assert!(lines[1].contains("7890"));
assert!(lines.iter().any(|line| line.contains("1234")));
}
#[test]
fn test_header2_wraps_and_underlines_to_longest_line() {
let doc = doc(vec![h2_(
"A level two header that definitely needs to wrap across multiple segments",
)]);
let mut output = Vec::new();
let mut style = FormattingStyle::ascii();
style.wrap_width = 30;
Formatter::new(&mut output, style)
.write_document(&doc)
.unwrap();
let result = String::from_utf8(output).unwrap();
let non_empty: Vec<&str> = result
.lines()
.filter(|line| !line.trim().is_empty())
.collect();
assert!(
non_empty.len() >= 2,
"expected at least header text and underline lines, got {:?}",
non_empty
);
let underline_index = non_empty
.iter()
.position(|line| line.chars().all(|ch| ch == '=' || ch.is_whitespace()))
.expect("expected underline made of '=' characters");
let header_lines = &non_empty[..underline_index];
assert!(
!header_lines.is_empty(),
"expected header text lines before underline"
);
let max_line_width = header_lines
.iter()
.map(|line| line.chars().count())
.max()
.unwrap_or(0);
assert!(
max_line_width <= 30,
"wrapped header line exceeds configured width ({max_line_width})"
);
let underline_width = non_empty[underline_index]
.chars()
.filter(|ch| *ch == '=')
.count();
assert_eq!(underline_width, max_line_width);
}
#[test]
fn test_header1_wraps_when_text_exceeds_width() {
let doc = doc(vec![h1_(
"This level-one heading is long enough that it should wrap in the formatter output",
)]);
let mut output = Vec::new();
let mut style = FormattingStyle::ascii();
style.wrap_width = 24;
Formatter::new(&mut output, style)
.write_document(&doc)
.unwrap();
let result = String::from_utf8(output).unwrap();
let lines: Vec<&str> = result
.lines()
.filter(|line| !line.trim().is_empty())
.collect();
assert!(
lines.len() >= 2,
"expected heading text to wrap across multiple lines, got {:?}",
lines
);
for line in &lines {
assert!(
line.chars().count() <= 24,
"header line '{line}' exceeds configured wrap width"
);
}
}
#[test]
fn test_header_formatting_ascii() {
let mut output = Vec::new();
let mut formatter = Formatter::new_ascii(&mut output);
let doc = doc(vec![h1_("Heading"), p__("Following text")]);
formatter.write_document(&doc).unwrap();
let result = String::from_utf8(output).unwrap();
let lines: Vec<&str> = result.lines().collect();
assert_eq!(lines.len(), 8);
assert!(lines[0].is_empty());
assert!(lines[1].is_empty());
assert!(lines[2].is_empty());
let header_line = lines[3];
assert_eq!(header_line.trim(), "Heading");
let leading_spaces = header_line.len() - header_line.trim_start().len();
let expected_padding = (super::DEFAULT_WRAP_WIDTH - header_line.trim().len()) / 2;
assert_eq!(leading_spaces, expected_padding);
assert!(lines[4].is_empty());
assert!(lines[5].is_empty());
assert!(lines[6].is_empty());
assert_eq!(lines[7], "Following text");
}
#[test]
fn test_header_formatting_ansi() {
let mut output = Vec::new();
let mut formatter = Formatter::new_ansi(&mut output);
let doc = doc(vec![h1_("Heading"), p__("Following text")]);
formatter.write_document(&doc).unwrap();
let result = String::from_utf8(output).unwrap();
assert!(result.contains("\x1b[1m"));
assert!(result.contains("\x1b[22m"));
let lines: Vec<&str> = result.split('\n').collect();
assert!(lines.len() >= 9);
assert!(lines[0].is_empty());
assert!(lines[1].is_empty());
assert!(lines[2].is_empty());
let header_line = lines[3];
let ansi_regex = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap();
let header_plain = ansi_regex.replace_all(header_line, "").to_string();
assert_eq!(header_plain.trim(), "Heading");
let leading_spaces = header_plain.len() - header_plain.trim_start().len();
let expected_padding = (super::DEFAULT_WRAP_WIDTH - header_plain.trim().len()) / 2;
assert_eq!(leading_spaces, expected_padding);
assert!(lines[4].is_empty());
assert!(lines[5].is_empty());
assert!(lines[6].is_empty());
assert!(lines[7].starts_with("Following text"));
assert_eq!(lines[8], "\x1b[0m");
}
#[test]
fn test_header2_formatting_ascii() {
let mut output = Vec::new();
let mut formatter = Formatter::new_ascii(&mut output);
let doc = doc(vec![h2_("Heading 2"), p__("Following text")]);
formatter.write_document(&doc).unwrap();
let result = String::from_utf8(output).unwrap();
let lines: Vec<&str> = result.lines().collect();
assert_eq!(lines.len(), 8);
assert!(lines[0].is_empty());
assert!(lines[1].is_empty());
assert!(lines[2].is_empty());
assert_eq!(lines[3], "Heading 2");
assert!(lines[4].chars().all(|c| c == '='));
assert_eq!(lines[4].chars().count(), lines[3].trim().chars().count());
assert!(lines[5].is_empty());
assert!(lines[6].is_empty());
assert_eq!(lines[7], "Following text");
}
#[test]
fn test_header2_formatting_ansi() {
let mut output = Vec::new();
let mut formatter = Formatter::new_ansi(&mut output);
let doc = doc(vec![h2_("Heading 2"), p__("Following text")]);
formatter.write_document(&doc).unwrap();
let result = String::from_utf8(output).unwrap();
assert!(result.contains("\x1b[1m"));
let lines: Vec<&str> = result.split('\n').collect();
let ansi_regex = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap();
assert!(lines.len() >= 9);
assert!(lines[0].is_empty());
assert!(lines[1].is_empty());
assert!(lines[2].is_empty());
let header_plain = ansi_regex.replace_all(lines[3], "").to_string();
assert_eq!(header_plain.trim(), "Heading 2");
assert!(lines[4].chars().all(|c| c == '='));
assert_eq!(
lines[4].chars().count(),
header_plain.trim().chars().count()
);
assert!(lines[5].is_empty());
assert!(lines[6].is_empty());
assert!(lines[7].starts_with("Following text"));
assert_eq!(lines[8], "\x1b[0m");
}
#[test]
fn test_header3_formatting_ascii() {
let mut output = Vec::new();
let mut formatter = Formatter::new_ascii(&mut output);
let doc = doc(vec![h3_("Heading 3"), p__("Following text")]);
formatter.write_document(&doc).unwrap();
let result = String::from_utf8(output).unwrap();
let lines: Vec<&str> = result.lines().collect();
assert_eq!(lines.len(), 6);
assert!(lines[0].is_empty());
assert!(lines[1].is_empty());
assert_eq!(lines[2], "Heading 3");
assert!(lines[3].chars().all(|c| c == '-'));
assert_eq!(lines[3].chars().count(), lines[2].trim().chars().count());
assert!(lines[4].is_empty());
assert_eq!(lines[5], "Following text");
}
#[test]
fn test_header3_formatting_ansi() {
let mut output = Vec::new();
let mut formatter = Formatter::new_ansi(&mut output);
let doc = doc(vec![h3_("Heading 3"), p__("Following text")]);
formatter.write_document(&doc).unwrap();
let result = String::from_utf8(output).unwrap();
assert!(result.contains("\x1b[1m"));
let lines: Vec<&str> = result.split('\n').collect();
let ansi_regex = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap();
assert!(lines.len() >= 7);
assert!(lines[0].is_empty());
assert!(lines[1].is_empty());
let header_plain = ansi_regex.replace_all(lines[2], "").to_string();
assert_eq!(header_plain.trim(), "Heading 3");
assert!(lines[3].chars().all(|c| c == '-'));
assert_eq!(
lines[3].chars().count(),
header_plain.trim().chars().count()
);
assert!(lines[4].is_empty());
assert!(lines[5].starts_with("Following text"));
assert_eq!(lines[6], "\x1b[0m");
}
#[test]
fn test_heading_spacing_collapse() {
let mut output = Vec::new();
let mut formatter = Formatter::new_ascii(&mut output);
let doc = doc(vec![h2_("Heading 2"), h3_("Heading 3")]);
formatter.write_document(&doc).unwrap();
let result = String::from_utf8(output).unwrap();
let lines: Vec<&str> = result.lines().collect();
let h2_idx = lines.iter().position(|line| line == &"Heading 2").unwrap();
let h2_underline_idx = h2_idx + 1;
let h3_idx = lines.iter().position(|line| line == &"Heading 3").unwrap();
assert!(lines[h2_underline_idx].chars().all(|c| c == '='));
let blank_count = h3_idx.saturating_sub(h2_underline_idx + 1);
assert_eq!(blank_count, 2);
assert!(lines[h3_idx + 1].chars().all(|c| c == '-'));
}
#[test]
fn renders_large_document_quickly() {
let data = include_str!("../../tests/snapshots/markdown/import/progit1-de.snap.ftml");
let doc = parse(Cursor::new(data.as_bytes())).expect("failed to parse FTML fixture");
let mut output = Vec::new();
let start = Instant::now();
Formatter::new_ascii(&mut output)
.write_document(&doc)
.expect("render should succeed");
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_secs(5),
"Rendering took {elapsed:?}"
);
assert!(!output.is_empty());
}
}