use super::formatting_rules::FormattingRules;
use lex_core::lex::ast::{
elements::{
blank_line_group::BlankLineGroup, paragraph::TextLine, sequence_marker::Form,
verbatim::VerbatimGroupItemRef, VerbatimLine,
},
traits::{AstNode, Visitor},
Annotation, Definition, Document, List, ListItem, Paragraph, Session, Table, TableCell,
TableCellAlignment, TableRow, Verbatim,
};
use lex_core::lex::assembling::stages::normalize_labels::source_spelling;
use lex_core::lex::ast::elements::sequence_marker::DecorationStyle;
struct ListContext {
index: usize,
style: DecorationStyle,
upper_case: bool,
marker_form: Option<Form>,
}
fn format_marker_index(style: DecorationStyle, upper_case: bool, index: usize) -> String {
match style {
DecorationStyle::Plain => "-".to_string(),
DecorationStyle::Numerical => index.to_string(),
DecorationStyle::Alphabetical => {
if upper_case {
to_alpha_upper(index)
} else {
to_alpha_lower(index)
}
}
DecorationStyle::Roman => to_roman_upper(index),
}
}
fn to_alpha_lower(n: usize) -> String {
if (1..=26).contains(&n) {
char::from_u32((n as u32) + 96).unwrap().to_string()
} else {
n.to_string()
}
}
fn to_alpha_upper(n: usize) -> String {
if (1..=26).contains(&n) {
char::from_u32((n as u32) + 64).unwrap().to_string()
} else {
n.to_string()
}
}
fn to_roman_upper(n: usize) -> String {
match n {
1 => "I".to_string(),
2 => "II".to_string(),
3 => "III".to_string(),
4 => "IV".to_string(),
5 => "V".to_string(),
6 => "VI".to_string(),
7 => "VII".to_string(),
8 => "VIII".to_string(),
9 => "IX".to_string(),
10 => "X".to_string(),
11 => "XI".to_string(),
12 => "XII".to_string(),
13 => "XIII".to_string(),
14 => "XIV".to_string(),
15 => "XV".to_string(),
16 => "XVI".to_string(),
17 => "XVII".to_string(),
18 => "XVIII".to_string(),
19 => "XIX".to_string(),
20 => "XX".to_string(),
_ => n.to_string(), }
}
pub struct LexSerializer {
rules: FormattingRules,
output: String,
indent_level: usize,
consecutive_newlines: usize,
list_stack: Vec<ListContext>,
emitted_footnote_lists: Vec<*const List>,
suppress_output: usize,
}
impl LexSerializer {
pub fn new(rules: FormattingRules) -> Self {
Self {
rules,
output: String::new(),
indent_level: 0,
consecutive_newlines: 2, list_stack: Vec::new(),
emitted_footnote_lists: Vec::new(),
suppress_output: 0,
}
}
pub fn serialize(mut self, doc: &Document) -> Result<String, String> {
if let Some(title) = &doc.title {
if title.subtitle.is_some() {
self.output.push_str(title.as_str());
self.output.push_str(":\n");
} else {
self.output.push_str(title.as_str());
self.output.push('\n');
}
if let Some(subtitle) = title.subtitle_str() {
self.output.push_str(subtitle);
self.output.push('\n');
}
self.consecutive_newlines = 1;
if !doc.root.children.is_empty() {
self.ensure_blank_lines(1);
}
}
doc.root.accept(&mut self);
if !self.rules.preserve_trailing_blanks {
let end = self.output.trim_end_matches('\n').len();
self.output.truncate(end);
if !self.output.is_empty() {
self.output.push('\n');
}
}
Ok(self.output)
}
fn indent(&self) -> String {
self.rules.indent_string.repeat(self.indent_level)
}
fn write_line(&mut self, text: &str) {
if self.suppress_output > 0 {
return;
}
self.output.push_str(&self.indent());
self.output.push_str(text);
self.output.push('\n');
self.consecutive_newlines = 1;
}
fn build_extended_marker(&self) -> String {
let mut parts = Vec::new();
let len = self.list_stack.len();
for (i, ctx) in self.list_stack.iter().enumerate() {
let idx = if i < len - 1 {
ctx.index - 1
} else {
ctx.index
};
parts.push(format_marker_index(ctx.style, ctx.upper_case, idx));
}
format!("{}.", parts.join("."))
}
fn last_emission_ended_with_container_opener_colon(&self) -> bool {
if self.consecutive_newlines != 1 {
return false;
}
let trimmed = self.output.trim_end();
trimmed.ends_with(':') && !trimmed.ends_with("::")
}
fn ensure_blank_lines(&mut self, count: usize) {
if self.suppress_output > 0 {
return;
}
let target_newlines = count + 1;
while self.consecutive_newlines < target_newlines {
self.output.push('\n');
self.consecutive_newlines += 1;
}
}
}
impl Visitor for LexSerializer {
fn visit_session(&mut self, session: &Session) {
let title = session.title.as_string();
if !title.is_empty() {
self.ensure_blank_lines(self.rules.session_blank_lines_before);
self.write_line(title);
self.ensure_blank_lines(self.rules.session_blank_lines_after);
self.indent_level += 1;
}
}
fn leave_session(&mut self, session: &Session) {
if !session.title.as_string().is_empty() {
self.indent_level -= 1;
}
}
fn visit_paragraph(&mut self, _paragraph: &Paragraph) {
}
fn visit_text_line(&mut self, text_line: &TextLine) {
let text = text_line.text().trim_end();
self.write_line(text);
}
fn visit_blank_line_group(&mut self, group: &BlankLineGroup) {
if group.count == 0 {
return;
}
let count = if self.rules.max_blank_lines > 0 {
std::cmp::min(group.count, self.rules.max_blank_lines)
} else {
group.count
};
self.ensure_blank_lines(count);
}
fn visit_list(&mut self, list: &List) {
let (style, upper_case) = if let Some(marker) = &list.marker {
let upper = marker.style == DecorationStyle::Alphabetical
&& marker
.as_str()
.chars()
.next()
.is_some_and(|c| c.is_uppercase());
(marker.style, upper)
} else {
(DecorationStyle::Plain, false)
};
let marker_form = list.marker.as_ref().map(|marker| marker.form);
if self.suppress_output > 0 || self.emitted_footnote_lists.contains(&(list as *const List))
{
self.suppress_output += 1;
}
self.list_stack.push(ListContext {
style,
upper_case,
marker_form,
index: 1,
});
}
fn leave_list(&mut self, _list: &List) {
self.list_stack.pop();
if self.suppress_output > 0 {
self.suppress_output -= 1;
}
}
fn visit_list_item(&mut self, list_item: &ListItem) {
let is_extended = self
.list_stack
.iter()
.any(|ctx| matches!(ctx.marker_form, Some(Form::Extended)));
let marker = if self.rules.normalize_seq_markers {
if is_extended {
self.build_extended_marker()
} else {
let context = self
.list_stack
.last()
.expect("List stack empty in list item");
if context.style == DecorationStyle::Plain {
self.rules.unordered_seq_marker.to_string()
} else {
format!(
"{}.",
format_marker_index(context.style, context.upper_case, context.index)
)
}
}
} else {
list_item.marker.as_string().to_string()
};
let context = self
.list_stack
.last_mut()
.expect("List stack empty in list item");
context.index += 1;
let text = if !list_item.text.is_empty() {
list_item.text[0].as_string().trim_end()
} else {
""
};
let line = if text.is_empty() {
marker
} else {
format!("{marker} {text}")
};
self.write_line(&line);
self.indent_level += 1;
}
fn leave_list_item(&mut self, _list_item: &ListItem) {
self.indent_level -= 1;
}
fn visit_definition(&mut self, definition: &Definition) {
let subject = definition.subject.as_string();
self.write_line(&format!("{subject}:"));
self.indent_level += 1;
}
fn leave_definition(&mut self, _definition: &Definition) {
self.indent_level -= 1;
}
fn visit_annotation(&mut self, annotation: &Annotation) {
let label = source_spelling(&annotation.data.label);
let params = &annotation.data.parameters;
let mut header = format!(":: {label}");
for (i, param) in params.iter().enumerate() {
header.push_str(if i == 0 { " " } else { ", " });
header.push_str(¶m.key);
header.push('=');
header.push_str(¶m.value);
}
header.push_str(" ::");
self.write_line(&header);
if !annotation.children.is_empty() {
self.indent_level += 1;
}
}
fn leave_annotation(&mut self, annotation: &Annotation) {
if !annotation.children.is_empty() {
self.indent_level -= 1;
self.ensure_blank_lines(1);
}
}
fn visit_verbatim_block(&mut self, _verbatim: &Verbatim) {
if !self.last_emission_ended_with_container_opener_colon() {
self.ensure_blank_lines(1);
}
}
fn visit_verbatim_group(&mut self, group: &VerbatimGroupItemRef) {
let subject = group.subject.as_string();
self.write_line(&format!("{subject}:"));
self.indent_level += 1;
}
fn leave_verbatim_group(&mut self, _group: &VerbatimGroupItemRef) {
self.indent_level -= 1;
}
fn visit_verbatim_line(&mut self, verbatim_line: &VerbatimLine) {
self.write_line(verbatim_line.content.as_string());
}
fn leave_verbatim_block(&mut self, verbatim: &Verbatim) {
let label = source_spelling(&verbatim.closing_data.label);
let mut footer = format!(":: {label}");
if !verbatim.closing_data.parameters.is_empty() {
for param in &verbatim.closing_data.parameters {
footer.push(' ');
footer.push_str(¶m.key);
footer.push('=');
footer.push_str(¶m.value);
}
}
footer.push_str(" ::");
self.write_line(&footer);
}
fn visit_table(&mut self, table: &Table) {
if !self.last_emission_ended_with_container_opener_colon() {
self.ensure_blank_lines(1);
}
let subject = table.subject.as_string();
if !subject.is_empty() {
self.write_line(&format!("{subject}:"));
}
self.indent_level += 1;
emit_pipe_table(self, table);
if let Some(footnotes) = &table.footnotes {
self.ensure_blank_lines(1);
footnotes.accept(self);
self.emitted_footnote_lists
.push(footnotes.as_ref() as *const List);
}
self.indent_level -= 1;
}
fn leave_table(&mut self, _table: &Table) {
}
}
enum Slot<'a> {
Origin(&'a TableCell),
Colspan,
Rowspan,
Empty,
}
impl Slot<'_> {
fn text(&self) -> &str {
match self {
Slot::Origin(cell) => cell.content.as_string().trim(),
Slot::Colspan => ">>",
Slot::Rowspan => "^^",
Slot::Empty => "",
}
}
}
fn build_grid<'a>(rows: &[&'a TableRow]) -> Vec<Vec<Slot<'a>>> {
let mut carry: Vec<usize> = Vec::new();
let mut grid: Vec<Vec<Slot>> = Vec::with_capacity(rows.len());
for row in rows {
let mut slots: Vec<Slot> = Vec::new();
let mut cells = row.cells.iter();
let mut col = 0;
loop {
if carry.get(col).copied().unwrap_or(0) > 0 {
carry[col] -= 1;
slots.push(Slot::Rowspan);
col += 1;
} else if let Some(cell) = cells.next() {
let span = cell.colspan.max(1);
if cell.rowspan > 1 {
for c in col..col + span {
if carry.len() <= c {
carry.resize(c + 1, 0);
}
carry[c] += cell.rowspan - 1;
}
}
slots.push(Slot::Origin(cell));
for _ in 1..span {
slots.push(Slot::Colspan);
}
col += span;
} else if carry.iter().skip(col).any(|&r| r > 0) {
slots.push(Slot::Empty);
col += 1;
} else {
break;
}
}
grid.push(slots);
}
grid
}
fn emit_pipe_table(serializer: &mut LexSerializer, table: &Table) {
let all_rows: Vec<&TableRow> = table
.header_rows
.iter()
.chain(table.body_rows.iter())
.collect();
if all_rows.is_empty() {
return;
}
let grid = build_grid(&all_rows);
let col_count = grid.iter().map(Vec::len).max().unwrap_or(0);
if col_count == 0 {
return;
}
let aligns = compute_column_aligns(&grid, col_count);
let widths = compute_column_widths(&grid, col_count, &aligns);
let header_count = table.header_rows.len();
for (idx, slots) in grid.iter().enumerate() {
serializer.write_line(&format_grid_row(slots, &widths, col_count));
if header_count > 0 && idx + 1 == header_count {
serializer.write_line(&format_separator_row(&widths, &aligns));
}
}
}
fn compute_column_aligns(grid: &[Vec<Slot>], col_count: usize) -> Vec<TableCellAlignment> {
let mut aligns = vec![TableCellAlignment::None; col_count];
for slots in grid {
for (col, slot) in slots.iter().enumerate() {
if let Slot::Origin(cell) = slot {
if aligns[col] == TableCellAlignment::None && cell.align != TableCellAlignment::None
{
aligns[col] = cell.align;
}
}
}
}
aligns
}
fn compute_column_widths(
grid: &[Vec<Slot>],
col_count: usize,
aligns: &[TableCellAlignment],
) -> Vec<usize> {
let mut widths = vec![0usize; col_count];
for slots in grid {
for (col, slot) in slots.iter().enumerate() {
widths[col] = widths[col].max(slot.text().chars().count());
}
}
for (col, w) in widths.iter_mut().enumerate() {
let min = match aligns.get(col).copied().unwrap_or(TableCellAlignment::None) {
TableCellAlignment::Center => 5, TableCellAlignment::Left | TableCellAlignment::Right => 4, TableCellAlignment::None => 3,
};
if *w < min {
*w = min;
}
}
widths
}
fn push_cell(out: &mut String, text: &str, width: usize) {
out.push(' ');
out.push_str(text);
for _ in text.chars().count()..width {
out.push(' ');
}
out.push(' ');
out.push('|');
}
fn format_grid_row(slots: &[Slot], widths: &[usize], col_count: usize) -> String {
let mut out = String::from("|");
for (col, &width) in widths.iter().enumerate().take(col_count) {
let text = slots.get(col).map(Slot::text).unwrap_or("");
push_cell(&mut out, text, width);
}
out
}
fn format_separator_row(widths: &[usize], aligns: &[TableCellAlignment]) -> String {
let mut out = String::from("|");
for (i, &w) in widths.iter().enumerate() {
out.push(' ');
let align = aligns.get(i).copied().unwrap_or(TableCellAlignment::None);
match align {
TableCellAlignment::Left => {
out.push(':');
for _ in 1..w {
out.push('-');
}
}
TableCellAlignment::Right => {
for _ in 0..w.saturating_sub(1) {
out.push('-');
}
out.push(':');
}
TableCellAlignment::Center => {
out.push(':');
for _ in 0..w.saturating_sub(2) {
out.push('-');
}
out.push(':');
}
TableCellAlignment::None => {
for _ in 0..w {
out.push('-');
}
}
}
out.push(' ');
out.push('|');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::format::Format;
use lex_core::lex::ast::text_content::TextContent;
use lex_core::lex::testing::lexplore::{ElementType, Lexplore};
use lex_core::lex::testing::text_diff::assert_text_eq;
fn format_source(source: &str) -> String {
let format = super::super::LexFormat::default();
let doc = format.parse(source).unwrap();
let rules = FormattingRules::default();
let mut serializer = LexSerializer::new(rules);
doc.accept(&mut serializer);
serializer.output
}
fn format_full(source: &str) -> String {
use crate::format::Format;
let format = super::super::LexFormat::default();
let doc = format.parse(source).unwrap();
format.serialize(&doc).unwrap()
}
#[test]
fn shortcut_form_round_trips_to_shortcut_spelling() {
let formatted = format_source(":: author :: Alice\n\nBody.\n");
assert!(
formatted.contains(":: author"),
"shortcut spelling should round-trip; got: {formatted}"
);
assert!(
!formatted.contains("lex.metadata.author"),
"canonical spelling must not leak into output: {formatted}"
);
}
#[test]
fn stripped_form_round_trips_to_stripped_spelling() {
let formatted = format_source(":: metadata.category :: tech\n\nBody.\n");
assert!(
formatted.contains(":: metadata.category"),
"stripped spelling should round-trip; got: {formatted}"
);
assert!(
!formatted.contains("lex.metadata.category"),
"canonical spelling must not leak: {formatted}"
);
}
#[test]
fn canonical_form_round_trips_unchanged() {
let formatted = format_source(":: lex.metadata.title :: My Doc\n\nBody.\n");
assert!(
formatted.contains(":: lex.metadata.title"),
"canonical spelling should round-trip; got: {formatted}"
);
}
#[test]
fn community_form_round_trips_unchanged() {
let formatted = format_source(":: acme.task id=42 :: foo\n\nBody.\n");
assert!(
formatted.contains(":: acme.task"),
"community label should round-trip; got: {formatted}"
);
}
#[test]
fn verbatim_shortcut_closer_round_trips() {
let formatted = format_source("Photo subject:\n alt text\n:: image src=\"x.png\" ::\n");
assert!(
formatted.contains(":: image"),
"verbatim closer should preserve shortcut: {formatted}"
);
assert!(
!formatted.contains("lex.media.image"),
"canonical must not leak: {formatted}"
);
}
#[test]
fn test_paragraph_01_oneline() {
let source = Lexplore::load(ElementType::Paragraph, 1).source();
let formatted = format_source(&source);
assert_text_eq(
&formatted,
"This is a simple paragraph with just one line.\n",
);
}
#[test]
fn test_paragraph_02_multiline() {
let source = Lexplore::load(ElementType::Paragraph, 2).source();
let formatted = format_source(&source);
assert!(formatted.contains("This is a multi-line paragraph"));
assert!(formatted.contains("second line"));
assert!(formatted.contains("third line"));
}
#[test]
fn test_paragraph_03_special_chars() {
let source = Lexplore::load(ElementType::Paragraph, 3).source();
let formatted = format_source(&source);
assert!(formatted.contains("!@#$%^&*()"));
}
#[test]
fn test_session_01_simple() {
let source = Lexplore::load(ElementType::Session, 1).source();
let formatted = format_source(&source);
assert!(formatted.contains("Introduction\n"));
assert!(formatted.contains(" This is a simple session"));
}
#[test]
fn test_session_02_numbered_title() {
let source = Lexplore::load(ElementType::Session, 2).source();
let formatted = format_source(&source);
assert!(formatted.contains("1. Introduction:\n"));
}
#[test]
fn test_session_05_nested() {
let source = Lexplore::load(ElementType::Session, 5).source();
let formatted = format_source(&source);
assert!(formatted.contains("1. Introduction {{session-title}}\n"));
assert!(formatted.contains(" This is the content of the session"));
}
#[test]
fn test_list_01_dash() {
let source = Lexplore::load(ElementType::List, 1).source();
let formatted = format_source(&source);
assert!(formatted.contains("- First item\n"));
assert!(formatted.contains("- Second item\n"));
}
#[test]
fn test_list_02_numbered() {
let source = Lexplore::load(ElementType::List, 2).source();
let formatted = format_source(&source);
assert!(formatted.contains("1. "));
assert!(formatted.contains("2. "));
assert!(formatted.contains("3. "));
}
#[test]
fn test_list_03_alphabetical() {
let source = Lexplore::load(ElementType::List, 3).source();
let formatted = format_source(&source);
assert!(formatted.contains("a. "));
assert!(formatted.contains("b. "));
assert!(formatted.contains("c. "));
}
#[test]
fn test_list_04_mixed_markers() {
let source = Lexplore::load(ElementType::List, 4).source();
let formatted = format_source(&source);
assert!(formatted.contains("1. First item\n"));
assert!(formatted.contains("2. Second item\n"));
assert!(formatted.contains("3. Third item\n"));
}
#[test]
fn test_list_07_nested_simple() {
let source = Lexplore::load(ElementType::List, 7).source();
let formatted = format_source(&source);
assert!(formatted.contains("- First outer item\n"));
assert!(formatted.contains(" - First nested item\n"));
}
#[test]
fn test_list_extended_markers_preserved() {
let source = "1.2.3 Item one\n1.2.4 Item two\n";
let formatted = format_source(source);
assert!(formatted.contains("1. Item one\n"));
assert!(formatted.contains("2. Item two\n"));
}
#[test]
fn test_list_extended_markers_nested_normalization() {
let source = "Test:\n\n1. Outer level one\n 1.a Middle level one\n 1.a.1 Inner level one\n 1.a.2 Inner level two\n 1.b Middle level two\n2. Outer level two\n";
let formatted = format_source(source);
assert!(
formatted.contains("1. Outer level one"),
"Expected '1. Outer level one' in: {formatted}"
);
assert!(
formatted.contains("2. Outer level two"),
"Expected '2. Outer level two' in: {formatted}"
);
}
#[test]
fn test_list_12_extended_form_fixture() {
let source = Lexplore::load(ElementType::List, 12).source();
let formatted = format_source(&source);
let formatted_again = format_source(&formatted);
assert_text_eq(&formatted, &formatted_again);
}
#[test]
fn test_definition_01_simple() {
let source = Lexplore::load(ElementType::Definition, 1).source();
let formatted = format_source(&source);
assert!(formatted.contains("Cache:\n"));
assert!(formatted.contains(" Temporary storage"));
}
#[test]
fn test_definition_02_multi_paragraph() {
let source = Lexplore::load(ElementType::Definition, 2).source();
let formatted = format_source(&source);
assert!(formatted.contains("Microservice:\n"));
assert!(formatted.contains(" An architectural style"));
assert!(formatted.contains(" Each service is independently"));
}
#[test]
fn test_verbatim_01_simple_code() {
let source = Lexplore::load(ElementType::Verbatim, 1).source();
let formatted = format_source(&source);
assert!(formatted.contains(":: javascript"));
assert!(formatted.contains("function hello()"));
}
#[test]
fn test_verbatim_02_with_caption() {
let source = Lexplore::load(ElementType::Verbatim, 2).source();
let formatted = format_source(&source);
assert!(formatted.contains("API Response:"));
}
#[test]
fn test_annotation_01_marker_simple() {
let source = Lexplore::load(ElementType::Annotation, 1).source();
let formatted = format_full(&source);
assert_eq!(formatted, ":: note ::\n");
}
#[test]
fn test_annotation_02_with_params() {
let source = Lexplore::load(ElementType::Annotation, 2).source();
let formatted = format_full(&source);
assert_eq!(formatted, ":: warning severity=high ::\n");
}
#[test]
fn test_annotation_multi_param_keeps_comma_separator() {
let formatted = format_full(":: warning type=critical, id=123 ::\n");
assert_eq!(formatted, ":: warning type=critical, id=123 ::\n");
assert_eq!(format_full(&formatted), formatted, "must be idempotent");
}
#[test]
fn test_annotation_05_block_paragraph() {
let source = Lexplore::load(ElementType::Annotation, 5).source();
let formatted = format_full(&source);
assert_eq!(
formatted,
":: note ::\n This is an important note that requires a detailed explanation.\n"
);
}
#[test]
fn test_round_trip_paragraph_01() {
let source = Lexplore::load(ElementType::Paragraph, 1).source();
let formatted = format_source(&source);
let formatted_again = format_source(&formatted);
assert_text_eq(&formatted, &formatted_again);
}
#[test]
fn test_round_trip_paragraph_02_multiline() {
let source = Lexplore::load(ElementType::Paragraph, 2).source();
let formatted = format_source(&source);
let formatted_again = format_source(&formatted);
assert_text_eq(&formatted, &formatted_again);
}
#[test]
fn test_round_trip_session_01() {
let source = Lexplore::load(ElementType::Session, 1).source();
let formatted = format_source(&source);
let formatted_again = format_source(&formatted);
assert_text_eq(&formatted, &formatted_again);
}
#[test]
fn test_round_trip_session_02_numbered() {
let source = Lexplore::load(ElementType::Session, 2).source();
let formatted = format_source(&source);
let formatted_again = format_source(&formatted);
assert_text_eq(&formatted, &formatted_again);
}
#[test]
fn test_round_trip_list_01_dash() {
let source = Lexplore::load(ElementType::List, 1).source();
let formatted = format_source(&source);
let formatted_again = format_source(&formatted);
assert_text_eq(&formatted, &formatted_again);
}
#[test]
fn test_round_trip_list_02_numbered() {
let source = Lexplore::load(ElementType::List, 2).source();
let formatted = format_source(&source);
let formatted_again = format_source(&formatted);
assert_text_eq(&formatted, &formatted_again);
}
#[test]
fn test_round_trip_list_03_alphabetical() {
let source = Lexplore::load(ElementType::List, 3).source();
let formatted = format_source(&source);
let formatted_again = format_source(&formatted);
assert_text_eq(&formatted, &formatted_again);
}
#[test]
fn test_round_trip_list_04_mixed_markers() {
let source = Lexplore::load(ElementType::List, 4).source();
let formatted = format_source(&source);
let formatted_again = format_source(&formatted);
assert_text_eq(&formatted, &formatted_again);
}
#[test]
fn test_round_trip_list_07_nested() {
let source = Lexplore::load(ElementType::List, 7).source();
let formatted = format_source(&source);
let formatted_again = format_source(&formatted);
assert_text_eq(&formatted, &formatted_again);
}
#[test]
fn test_round_trip_definition_01() {
let source = Lexplore::load(ElementType::Definition, 1).source();
let formatted = format_source(&source);
let formatted_again = format_source(&formatted);
assert_text_eq(&formatted, &formatted_again);
}
#[test]
fn test_round_trip_definition_02_multi() {
let source = Lexplore::load(ElementType::Definition, 2).source();
let formatted = format_source(&source);
let formatted_again = format_source(&formatted);
assert_text_eq(&formatted, &formatted_again);
}
#[test]
fn test_round_trip_verbatim_01() {
let source = Lexplore::load(ElementType::Verbatim, 1).source();
let formatted = format_source(&source);
let formatted_again = format_source(&formatted);
assert_text_eq(&formatted, &formatted_again);
}
#[test]
fn test_round_trip_verbatim_02_caption() {
let source = Lexplore::load(ElementType::Verbatim, 2).source();
let formatted = format_source(&source);
let formatted_again = format_source(&formatted);
assert_text_eq(&formatted, &formatted_again);
}
#[test]
fn test_verbatim_03_table_formatting() {
let source = "Table Example:\n | A | B |\n |---|---|\n | 1 | 2 |\n:: table ::\n";
let formatted = format_source(source);
assert!(formatted.contains("| A | B |"));
assert!(formatted.contains("| --- | --- |"));
assert!(formatted.contains("| 1 | 2 |"));
let unformatted = "Table Example:\n |A|B|\n |-|-|\n |1|2|\n:: table ::\n";
let formatted_2 = format_source(unformatted);
assert!(formatted_2.contains("| A | B |"));
assert!(formatted_2.contains("| --- | --- |"));
assert!(formatted_2.contains("| 1 | 2 |"));
}
#[test]
fn test_round_trip_paragraph_then_verbatim_lex505() {
let source =
"Doc\n===\n\nIntro paragraph.\n\nCode Example:\n\n fn main() {}\n\n:: rust ::\n";
let formatted = format_source(source);
assert!(
formatted.contains("Intro paragraph.\n\nCode Example:"),
"expected blank line between paragraph and verbatim subject, got:\n{formatted}"
);
let formatted_again = format_source(&formatted);
assert_text_eq(&formatted, &formatted_again);
}
#[test]
fn test_verbatim_04_user_repro() {
let source = " The Table:\n | Markup Language | Great |\n |--------------------|--------|\n | Markdown | No |\n | Lex | Yes |\n :: table ::\n";
let formatted = format_source(source);
let table_start = formatted
.find("| Markup Language | Great |")
.expect("Table start not found");
let separator = formatted
.find("| --------------- | ----- |")
.expect("Separator not found");
let footer_start = formatted.find(":: table ::").expect("Footer not found");
assert!(table_start < separator);
assert!(separator < footer_start);
}
#[test]
fn build_grid_pads_hole_before_trailing_rowspan() {
let tc = |s: &str| TextContent::from_string(s.to_string(), None);
let row0 = TableRow::new(vec![
TableCell::new(tc("a")),
TableCell::new(tc("b")),
TableCell::new(tc("c")).with_span(1, 2),
]);
let row1 = TableRow::new(vec![TableCell::new(tc("d"))]);
let grid = build_grid(&[&row0, &row1]);
let render = |slots: &[Slot]| {
slots
.iter()
.map(|s| s.text().to_string())
.collect::<Vec<_>>()
};
assert_eq!(render(&grid[0]), ["a", "b", "c"]);
assert_eq!(
render(&grid[1]),
["d", "", "^^"],
"hole padded empty, trailing rowspan marker kept"
);
}
}