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, Verbatim,
};
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(), }
}
use crate::common::verbatim::VerbatimRegistry;
pub struct LexSerializer {
rules: FormattingRules,
output: String,
indent_level: usize,
consecutive_newlines: usize,
list_stack: Vec<ListContext>,
verbatim_registry: VerbatimRegistry,
skip_verbatim_lines: bool,
formatted_verbatim_content: Option<String>,
}
impl LexSerializer {
pub fn new(rules: FormattingRules) -> Self {
Self {
rules,
output: String::new(),
indent_level: 0,
consecutive_newlines: 2, list_stack: Vec::new(),
verbatim_registry: VerbatimRegistry::default_with_standard(),
skip_verbatim_lines: false,
formatted_verbatim_content: None,
}
}
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;
}
doc.root.accept(&mut self);
Ok(self.output)
}
fn indent(&self) -> String {
self.rules.indent_string.repeat(self.indent_level)
}
fn write_line(&mut self, text: &str) {
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 ensure_blank_lines(&mut self, count: usize) {
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);
self.list_stack.push(ListContext {
style,
upper_case,
marker_form,
index: 1,
});
}
fn leave_list(&mut self, _list: &List) {
self.list_stack.pop();
}
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 = &annotation.data.label.value;
let params = &annotation.data.parameters;
let mut header = format!(":: {label}");
if !params.is_empty() {
for param in params {
header.push(' ');
header.push_str(¶m.key);
header.push('=');
header.push_str(¶m.value);
}
}
if annotation.children.is_empty() {
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;
}
}
fn visit_verbatim_block(&mut self, verbatim: &Verbatim) {
let label = &verbatim.closing_data.label.value;
if let Some(handler) = self.verbatim_registry.get(label) {
if let Ok(Some(content)) = handler.format_content(verbatim) {
self.formatted_verbatim_content = Some(content);
self.skip_verbatim_lines = true;
} else {
self.formatted_verbatim_content = None;
self.skip_verbatim_lines = false;
}
} else {
self.formatted_verbatim_content = None;
self.skip_verbatim_lines = false;
}
}
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) {
if !self.skip_verbatim_lines {
self.write_line(verbatim_line.content.as_string());
}
}
fn leave_verbatim_block(&mut self, verbatim: &Verbatim) {
if let Some(content) = self.formatted_verbatim_content.take() {
self.output.push_str(&content);
if !content.ends_with('\n') {
self.output.push('\n');
}
}
let label = &verbatim.closing_data.label.value;
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);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::format::Format;
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
}
#[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_source(&source);
assert_eq!(formatted, ":: note\n");
}
#[test]
fn test_annotation_02_with_params() {
let source = Lexplore::load(ElementType::Annotation, 2).source();
let formatted = format_source(&source);
assert_eq!(formatted, ":: warning severity=high\n");
}
#[test]
fn test_annotation_05_block_paragraph() {
let source = Lexplore::load(ElementType::Annotation, 5).source();
let formatted = format_source(&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:: doc.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:: doc.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_verbatim_04_user_repro() {
let source = " The Table:\n | Markup Language | Great |\n |--------------------|--------|\n | Markdown | No |\n | Lex | Yes |\n :: doc.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(":: doc.table").expect("Footer not found");
assert!(table_start < separator);
assert!(separator < footer_start);
}
}