Skip to main content

lex_babel/formats/lex/
serializer.rs

1use super::formatting_rules::FormattingRules;
2use lex_core::lex::ast::{
3    elements::{
4        blank_line_group::BlankLineGroup, paragraph::TextLine, sequence_marker::Form,
5        verbatim::VerbatimGroupItemRef, VerbatimLine,
6    },
7    traits::{AstNode, Visitor},
8    Annotation, Definition, Document, List, ListItem, Paragraph, Session, Verbatim,
9};
10
11#[derive(Debug, Clone, Copy, PartialEq)]
12enum MarkerType {
13    Bullet,
14    Numeric,
15    AlphaLower,
16    AlphaUpper,
17    RomanUpper,
18}
19
20struct ListContext {
21    index: usize,
22    marker_type: MarkerType,
23    marker_form: Option<Form>,
24}
25
26impl MarkerType {}
27
28fn format_marker_index(marker_type: MarkerType, index: usize) -> String {
29    match marker_type {
30        MarkerType::Bullet => "-".to_string(),
31        MarkerType::Numeric => index.to_string(),
32        MarkerType::AlphaLower => to_alpha_lower(index),
33        MarkerType::AlphaUpper => to_alpha_upper(index),
34        MarkerType::RomanUpper => to_roman_upper(index),
35    }
36}
37
38fn to_alpha_lower(n: usize) -> String {
39    if (1..=26).contains(&n) {
40        char::from_u32((n as u32) + 96).unwrap().to_string()
41    } else {
42        n.to_string()
43    }
44}
45fn to_alpha_upper(n: usize) -> String {
46    if (1..=26).contains(&n) {
47        char::from_u32((n as u32) + 64).unwrap().to_string()
48    } else {
49        n.to_string()
50    }
51}
52
53fn to_roman_upper(n: usize) -> String {
54    // Convert to Roman numerals (uppercase) for common values
55    // Falls back to decimal for values > 20
56    match n {
57        1 => "I".to_string(),
58        2 => "II".to_string(),
59        3 => "III".to_string(),
60        4 => "IV".to_string(),
61        5 => "V".to_string(),
62        6 => "VI".to_string(),
63        7 => "VII".to_string(),
64        8 => "VIII".to_string(),
65        9 => "IX".to_string(),
66        10 => "X".to_string(),
67        11 => "XI".to_string(),
68        12 => "XII".to_string(),
69        13 => "XIII".to_string(),
70        14 => "XIV".to_string(),
71        15 => "XV".to_string(),
72        16 => "XVI".to_string(),
73        17 => "XVII".to_string(),
74        18 => "XVIII".to_string(),
75        19 => "XIX".to_string(),
76        20 => "XX".to_string(),
77        _ => n.to_string(), // Fallback to decimal for larger numbers
78    }
79}
80
81use crate::common::verbatim::VerbatimRegistry;
82
83pub struct LexSerializer {
84    rules: FormattingRules,
85    output: String,
86    indent_level: usize,
87    consecutive_newlines: usize,
88    list_stack: Vec<ListContext>,
89    verbatim_registry: VerbatimRegistry,
90    skip_verbatim_lines: bool,
91    formatted_verbatim_content: Option<String>,
92}
93
94impl LexSerializer {
95    pub fn new(rules: FormattingRules) -> Self {
96        Self {
97            rules,
98            output: String::new(),
99            indent_level: 0,
100            consecutive_newlines: 2, // Start as if we have blank lines
101            list_stack: Vec::new(),
102            verbatim_registry: VerbatimRegistry::default_with_standard(),
103            skip_verbatim_lines: false,
104            formatted_verbatim_content: None,
105        }
106    }
107
108    pub fn serialize(mut self, doc: &Document) -> Result<String, String> {
109        doc.root.accept(&mut self);
110        Ok(self.output)
111    }
112
113    fn indent(&self) -> String {
114        self.rules.indent_string.repeat(self.indent_level)
115    }
116
117    fn write_line(&mut self, text: &str) {
118        self.output.push_str(&self.indent());
119        self.output.push_str(text);
120        self.output.push('\n');
121        self.consecutive_newlines = 1;
122    }
123
124    /// Build an extended marker from the full list stack hierarchy.
125    /// Each level contributes its index formatted according to its marker type.
126    /// Ancestor levels have already incremented their index, so use `index - 1`.
127    /// The current (last) level has not yet incremented, so use `index` as-is.
128    fn build_extended_marker(&self) -> String {
129        let mut parts = Vec::new();
130        let len = self.list_stack.len();
131        for (i, ctx) in self.list_stack.iter().enumerate() {
132            let idx = if i < len - 1 {
133                // Ancestor: already incremented past current item
134                ctx.index - 1
135            } else {
136                // Current level: not yet incremented
137                ctx.index
138            };
139            parts.push(format_marker_index(ctx.marker_type, idx));
140        }
141        format!("{}.", parts.join("."))
142    }
143
144    fn ensure_blank_lines(&mut self, count: usize) {
145        let target_newlines = count + 1;
146        while self.consecutive_newlines < target_newlines {
147            self.output.push('\n');
148            self.consecutive_newlines += 1;
149        }
150    }
151}
152
153impl Visitor for LexSerializer {
154    fn visit_session(&mut self, session: &Session) {
155        let title = session.title.as_string();
156        if !title.is_empty() {
157            self.ensure_blank_lines(self.rules.session_blank_lines_before);
158            self.write_line(title);
159            self.ensure_blank_lines(self.rules.session_blank_lines_after);
160            self.indent_level += 1;
161        }
162    }
163
164    fn leave_session(&mut self, session: &Session) {
165        if !session.title.as_string().is_empty() {
166            self.indent_level -= 1;
167        }
168    }
169
170    fn visit_paragraph(&mut self, _paragraph: &Paragraph) {
171        // Paragraphs are handled by visiting TextLines
172        // TODO: Investigate why some paragraphs are skipped during traversal when indentation is mixed.
173        // See: https://github.com/lex-project/lex/issues/new?title=Parser+drops+paragraphs+with+mixed+indentation
174    }
175
176    fn visit_text_line(&mut self, text_line: &TextLine) {
177        let text = text_line.text().trim_end();
178        self.write_line(text);
179    }
180
181    fn visit_blank_line_group(&mut self, group: &BlankLineGroup) {
182        if group.count == 0 {
183            return;
184        }
185
186        let count = if self.rules.max_blank_lines > 0 {
187            std::cmp::min(group.count, self.rules.max_blank_lines)
188        } else {
189            group.count
190        };
191        self.ensure_blank_lines(count);
192    }
193
194    fn visit_list(&mut self, list: &List) {
195        // Use the SequenceMarker to determine marker type
196        let marker_type = if let Some(marker) = &list.marker {
197            use lex_core::lex::ast::elements::DecorationStyle;
198            match marker.style {
199                DecorationStyle::Plain => MarkerType::Bullet,
200                DecorationStyle::Numerical => MarkerType::Numeric,
201                DecorationStyle::Alphabetical => {
202                    let text = marker.as_str();
203                    if text.chars().next().is_some_and(|c| c.is_uppercase()) {
204                        MarkerType::AlphaUpper
205                    } else {
206                        MarkerType::AlphaLower
207                    }
208                }
209                DecorationStyle::Roman => MarkerType::RomanUpper,
210            }
211        } else {
212            MarkerType::Bullet
213        };
214
215        // Determine marker form (Standard vs Extended)
216        let marker_form = list.marker.as_ref().map(|marker| marker.form);
217
218        self.list_stack.push(ListContext {
219            marker_type,
220            marker_form,
221            index: 1,
222        });
223    }
224
225    fn leave_list(&mut self, _list: &List) {
226        self.list_stack.pop();
227    }
228
229    fn visit_list_item(&mut self, list_item: &ListItem) {
230        let is_extended = self
231            .list_stack
232            .iter()
233            .any(|ctx| matches!(ctx.marker_form, Some(Form::Extended)));
234
235        let marker = if self.rules.normalize_seq_markers {
236            if is_extended {
237                // Build hierarchical prefix from the full list stack
238                self.build_extended_marker()
239            } else {
240                let context = self
241                    .list_stack
242                    .last()
243                    .expect("List stack empty in list item");
244                match context.marker_type {
245                    MarkerType::Bullet => self.rules.unordered_seq_marker.to_string(),
246                    MarkerType::Numeric => format!("{}.", context.index),
247                    MarkerType::AlphaLower => format!("{}.", to_alpha_lower(context.index)),
248                    MarkerType::AlphaUpper => format!("{}.", to_alpha_upper(context.index)),
249                    MarkerType::RomanUpper => format!("{}.", to_roman_upper(context.index)),
250                }
251            }
252        } else {
253            list_item.marker.as_string().to_string()
254        };
255
256        let context = self
257            .list_stack
258            .last_mut()
259            .expect("List stack empty in list item");
260        context.index += 1;
261
262        // Use the first text content as the item line
263        let text = if !list_item.text.is_empty() {
264            list_item.text[0].as_string().trim_end()
265        } else {
266            ""
267        };
268
269        let line = if text.is_empty() {
270            marker
271        } else {
272            format!("{marker} {text}")
273        };
274
275        self.write_line(&line);
276        self.indent_level += 1;
277    }
278
279    fn leave_list_item(&mut self, _list_item: &ListItem) {
280        self.indent_level -= 1;
281    }
282
283    fn visit_definition(&mut self, definition: &Definition) {
284        let subject = definition.subject.as_string();
285        self.write_line(&format!("{subject}:"));
286        self.indent_level += 1;
287    }
288
289    fn leave_definition(&mut self, _definition: &Definition) {
290        self.indent_level -= 1;
291    }
292
293    fn visit_annotation(&mut self, annotation: &Annotation) {
294        let label = &annotation.data.label.value;
295        let params = &annotation.data.parameters;
296
297        let mut header = format!(":: {label}");
298        if !params.is_empty() {
299            for param in params {
300                header.push(' ');
301                header.push_str(&param.key);
302                header.push('=');
303                header.push_str(&param.value);
304            }
305        }
306
307        // Only add closing :: for short-form annotations (no children)
308        if annotation.children.is_empty() {
309            header.push_str(" ::");
310        }
311
312        self.write_line(&header);
313
314        if !annotation.children.is_empty() {
315            self.indent_level += 1;
316        }
317    }
318
319    fn leave_annotation(&mut self, annotation: &Annotation) {
320        if !annotation.children.is_empty() {
321            self.indent_level -= 1;
322            self.write_line("::");
323        }
324    }
325
326    fn visit_verbatim_block(&mut self, verbatim: &Verbatim) {
327        let label = &verbatim.closing_data.label.value;
328
329        // Try to get formatted content from handler
330        if let Some(handler) = self.verbatim_registry.get(label) {
331            // We ignore errors here for now as the visitor trait doesn't support Result
332            if let Ok(Some(content)) = handler.format_content(verbatim) {
333                self.formatted_verbatim_content = Some(content);
334                self.skip_verbatim_lines = true;
335            } else {
336                self.formatted_verbatim_content = None;
337                self.skip_verbatim_lines = false;
338            }
339        } else {
340            self.formatted_verbatim_content = None;
341            self.skip_verbatim_lines = false;
342        }
343    }
344
345    fn visit_verbatim_group(&mut self, group: &VerbatimGroupItemRef) {
346        let subject = group.subject.as_string();
347        self.write_line(&format!("{subject}:"));
348        self.indent_level += 1;
349    }
350
351    fn leave_verbatim_group(&mut self, _group: &VerbatimGroupItemRef) {
352        self.indent_level -= 1;
353    }
354
355    fn visit_verbatim_line(&mut self, verbatim_line: &VerbatimLine) {
356        if !self.skip_verbatim_lines {
357            self.write_line(verbatim_line.content.as_string());
358        }
359    }
360
361    fn leave_verbatim_block(&mut self, verbatim: &Verbatim) {
362        // If we have formatted content, print it now (before the closing marker)
363        if let Some(content) = self.formatted_verbatim_content.take() {
364            self.output.push_str(&content);
365            // Ensure newline after content if not present (though TableHandler adds it)
366            if !content.ends_with('\n') {
367                self.output.push('\n');
368            }
369        }
370
371        let label = &verbatim.closing_data.label.value;
372        let mut footer = format!(":: {label}");
373        if !verbatim.closing_data.parameters.is_empty() {
374            for param in &verbatim.closing_data.parameters {
375                footer.push(' ');
376                footer.push_str(&param.key);
377                footer.push('=');
378                footer.push_str(&param.value);
379            }
380        }
381        footer.push_str(" ::");
382        self.write_line(&footer);
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use crate::format::Format;
390    use lex_core::lex::testing::lexplore::{ElementType, Lexplore};
391    use lex_core::lex::testing::text_diff::assert_text_eq;
392
393    fn format_source(source: &str) -> String {
394        let format = super::super::LexFormat::default();
395        let doc = format.parse(source).unwrap();
396        let rules = FormattingRules::default();
397        let mut serializer = LexSerializer::new(rules);
398        doc.accept(&mut serializer);
399        serializer.output
400    }
401
402    // ==== Paragraph Tests ====
403
404    #[test]
405    fn test_paragraph_01_oneline() {
406        let source = Lexplore::load(ElementType::Paragraph, 1).source();
407        let formatted = format_source(&source);
408        assert_text_eq(
409            &formatted,
410            "This is a simple paragraph with just one line.\n",
411        );
412    }
413
414    #[test]
415    fn test_paragraph_02_multiline() {
416        let source = Lexplore::load(ElementType::Paragraph, 2).source();
417        let formatted = format_source(&source);
418        assert!(formatted.contains("This is a multi-line paragraph"));
419        assert!(formatted.contains("second line"));
420        assert!(formatted.contains("third line"));
421    }
422
423    #[test]
424    fn test_paragraph_03_special_chars() {
425        let source = Lexplore::load(ElementType::Paragraph, 3).source();
426        let formatted = format_source(&source);
427        assert!(formatted.contains("!@#$%^&*()"));
428    }
429
430    // ==== Session Tests ====
431
432    #[test]
433    fn test_session_01_simple() {
434        let source = Lexplore::load(ElementType::Session, 1).source();
435        let formatted = format_source(&source);
436        assert!(formatted.contains("Introduction\n"));
437        assert!(formatted.contains("    This is a simple session"));
438    }
439
440    #[test]
441    fn test_session_02_numbered_title() {
442        let source = Lexplore::load(ElementType::Session, 2).source();
443        let formatted = format_source(&source);
444        assert!(formatted.contains("1. Introduction:\n"));
445    }
446
447    #[test]
448    fn test_session_05_nested() {
449        let source = Lexplore::load(ElementType::Session, 5).source();
450        let formatted = format_source(&source);
451        // This is actually a complex doc with paragraphs and sessions
452        assert!(formatted.contains("1. Introduction {{session-title}}\n"));
453        assert!(formatted.contains("    This is the content of the session"));
454    }
455
456    // ==== List Tests ====
457
458    #[test]
459    fn test_list_01_dash() {
460        let source = Lexplore::load(ElementType::List, 1).source();
461        let formatted = format_source(&source);
462        assert!(formatted.contains("- First item\n"));
463        assert!(formatted.contains("- Second item\n"));
464    }
465
466    #[test]
467    fn test_list_02_numbered() {
468        let source = Lexplore::load(ElementType::List, 2).source();
469        let formatted = format_source(&source);
470        // Should normalize to sequential numbering
471        assert!(formatted.contains("1. "));
472        assert!(formatted.contains("2. "));
473        assert!(formatted.contains("3. "));
474    }
475
476    #[test]
477    fn test_list_03_alphabetical() {
478        let source = Lexplore::load(ElementType::List, 3).source();
479        let formatted = format_source(&source);
480        assert!(formatted.contains("a. "));
481        assert!(formatted.contains("b. "));
482        assert!(formatted.contains("c. "));
483    }
484
485    #[test]
486    fn test_list_04_mixed_markers() {
487        let source = Lexplore::load(ElementType::List, 4).source();
488        let formatted = format_source(&source);
489        // Should normalize to consistent markers
490        assert!(formatted.contains("1. First item\n"));
491        assert!(formatted.contains("2. Second item\n"));
492        assert!(formatted.contains("3. Third item\n"));
493    }
494
495    #[test]
496    fn test_list_07_nested_simple() {
497        let source = Lexplore::load(ElementType::List, 7).source();
498        let formatted = format_source(&source);
499        // Check for proper indentation of nested items
500        assert!(formatted.contains("- First outer item\n"));
501        assert!(formatted.contains("    - First nested item\n"));
502    }
503
504    #[test]
505    fn test_list_extended_markers_preserved() {
506        // NOTE: Extended markers (e.g., "1.2.3") require core parser support
507        // for Form::Extended. Currently the parser treats them as standard
508        // numbered lists, so normalization produces "1.", "2.", etc.
509        let source = "1.2.3 Item one\n1.2.4 Item two\n";
510        let formatted = format_source(source);
511        assert!(formatted.contains("1. Item one\n"));
512        assert!(formatted.contains("2. Item two\n"));
513    }
514
515    #[test]
516    fn test_list_extended_markers_nested_normalization() {
517        // Nested list with extended markers: formatter should rebuild hierarchical markers
518        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";
519        let formatted = format_source(source);
520        // Outer level items
521        assert!(
522            formatted.contains("1. Outer level one"),
523            "Expected '1. Outer level one' in: {formatted}"
524        );
525        assert!(
526            formatted.contains("2. Outer level two"),
527            "Expected '2. Outer level two' in: {formatted}"
528        );
529    }
530
531    #[test]
532    fn test_list_12_extended_form_fixture() {
533        let source = Lexplore::load(ElementType::List, 12).source();
534        let formatted = format_source(&source);
535        let formatted_again = format_source(&formatted);
536        assert_text_eq(&formatted, &formatted_again);
537    }
538
539    // ==== Definition Tests ====
540
541    #[test]
542    fn test_definition_01_simple() {
543        let source = Lexplore::load(ElementType::Definition, 1).source();
544        let formatted = format_source(&source);
545        assert!(formatted.contains("Cache:\n"));
546        assert!(formatted.contains("    Temporary storage"));
547    }
548
549    #[test]
550    fn test_definition_02_multi_paragraph() {
551        let source = Lexplore::load(ElementType::Definition, 2).source();
552        let formatted = format_source(&source);
553        // Should handle multiple paragraphs in definition body
554        assert!(formatted.contains("Microservice:\n"));
555        assert!(formatted.contains("    An architectural style"));
556        assert!(formatted.contains("    Each service is independently"));
557    }
558
559    // ==== Verbatim Tests ====
560
561    #[test]
562    fn test_verbatim_01_simple_code() {
563        let source = Lexplore::load(ElementType::Verbatim, 1).source();
564        let formatted = format_source(&source);
565        assert!(formatted.contains(":: javascript"));
566        assert!(formatted.contains("function hello()"));
567    }
568
569    #[test]
570    fn test_verbatim_02_with_caption() {
571        let source = Lexplore::load(ElementType::Verbatim, 2).source();
572        let formatted = format_source(&source);
573        // Should preserve verbatim content and captions
574        assert!(formatted.contains("API Response:"));
575    }
576
577    // ==== Annotation Tests ====
578
579    #[test]
580    fn test_annotation_01_marker_simple() {
581        let source = Lexplore::load(ElementType::Annotation, 1).source();
582        let formatted = format_source(&source);
583        // Document-level annotations should be preserved
584        assert_eq!(formatted, ":: note\n::\n");
585    }
586
587    #[test]
588    fn test_annotation_02_with_params() {
589        let source = Lexplore::load(ElementType::Annotation, 2).source();
590        let formatted = format_source(&source);
591        // Document-level annotations should be preserved
592        assert_eq!(formatted, ":: warning severity=high\n::\n");
593    }
594
595    #[test]
596    fn test_annotation_05_block_paragraph() {
597        let source = Lexplore::load(ElementType::Annotation, 5).source();
598        let formatted = format_source(&source);
599        // Document-level annotations should be preserved
600        assert_eq!(
601            formatted,
602            ":: note\n    This is an important note that requires a detailed explanation.\n::\n"
603        );
604    }
605
606    // ==== Round-trip Tests ====
607    // Format → parse → format should be idempotent
608
609    #[test]
610    fn test_round_trip_paragraph_01() {
611        let source = Lexplore::load(ElementType::Paragraph, 1).source();
612        let formatted = format_source(&source);
613        let formatted_again = format_source(&formatted);
614        assert_text_eq(&formatted, &formatted_again);
615    }
616
617    #[test]
618    fn test_round_trip_paragraph_02_multiline() {
619        let source = Lexplore::load(ElementType::Paragraph, 2).source();
620        let formatted = format_source(&source);
621        let formatted_again = format_source(&formatted);
622        assert_text_eq(&formatted, &formatted_again);
623    }
624
625    #[test]
626    fn test_round_trip_session_01() {
627        let source = Lexplore::load(ElementType::Session, 1).source();
628        let formatted = format_source(&source);
629        let formatted_again = format_source(&formatted);
630        assert_text_eq(&formatted, &formatted_again);
631    }
632
633    #[test]
634    fn test_round_trip_session_02_numbered() {
635        let source = Lexplore::load(ElementType::Session, 2).source();
636        let formatted = format_source(&source);
637        let formatted_again = format_source(&formatted);
638        assert_text_eq(&formatted, &formatted_again);
639    }
640
641    #[test]
642    fn test_round_trip_list_01_dash() {
643        let source = Lexplore::load(ElementType::List, 1).source();
644        let formatted = format_source(&source);
645        let formatted_again = format_source(&formatted);
646        assert_text_eq(&formatted, &formatted_again);
647    }
648
649    #[test]
650    fn test_round_trip_list_02_numbered() {
651        let source = Lexplore::load(ElementType::List, 2).source();
652        let formatted = format_source(&source);
653        let formatted_again = format_source(&formatted);
654        assert_text_eq(&formatted, &formatted_again);
655    }
656
657    #[test]
658    fn test_round_trip_list_03_alphabetical() {
659        let source = Lexplore::load(ElementType::List, 3).source();
660        let formatted = format_source(&source);
661        let formatted_again = format_source(&formatted);
662        assert_text_eq(&formatted, &formatted_again);
663    }
664
665    #[test]
666    fn test_round_trip_list_04_mixed_markers() {
667        let source = Lexplore::load(ElementType::List, 4).source();
668        let formatted = format_source(&source);
669        let formatted_again = format_source(&formatted);
670        assert_text_eq(&formatted, &formatted_again);
671    }
672
673    #[test]
674    fn test_round_trip_list_07_nested() {
675        let source = Lexplore::load(ElementType::List, 7).source();
676        let formatted = format_source(&source);
677        let formatted_again = format_source(&formatted);
678        assert_text_eq(&formatted, &formatted_again);
679    }
680
681    #[test]
682    fn test_round_trip_definition_01() {
683        let source = Lexplore::load(ElementType::Definition, 1).source();
684        let formatted = format_source(&source);
685        let formatted_again = format_source(&formatted);
686        assert_text_eq(&formatted, &formatted_again);
687    }
688
689    #[test]
690    fn test_round_trip_definition_02_multi() {
691        let source = Lexplore::load(ElementType::Definition, 2).source();
692        let formatted = format_source(&source);
693        let formatted_again = format_source(&formatted);
694        assert_text_eq(&formatted, &formatted_again);
695    }
696
697    #[test]
698    fn test_round_trip_verbatim_01() {
699        let source = Lexplore::load(ElementType::Verbatim, 1).source();
700        let formatted = format_source(&source);
701        let formatted_again = format_source(&formatted);
702        assert_text_eq(&formatted, &formatted_again);
703    }
704
705    #[test]
706    fn test_round_trip_verbatim_02_caption() {
707        let source = Lexplore::load(ElementType::Verbatim, 2).source();
708        let formatted = format_source(&source);
709        let formatted_again = format_source(&formatted);
710        assert_text_eq(&formatted, &formatted_again);
711    }
712
713    #[test]
714    fn test_verbatim_03_table_formatting() {
715        // Use standard verbatim syntax: Subject + indented content + closing marker (dedented)
716        let source =
717            "Table Example:\n    | A | B |\n    |---|---|\n    | 1 | 2 |\n:: doc.table ::\n";
718        // The serializer should format this table
719        let formatted = format_source(source);
720
721        // Check that it's formatted (aligned)
722        // Note: The exact spacing depends on the markdown serializer, but it should be consistent
723        // Markdown serializer adds padding for alignment
724        assert!(formatted.contains("| A   | B   |"));
725        assert!(formatted.contains("| --- | --- |"));
726        assert!(formatted.contains("| 1   | 2   |"));
727
728        // Also test with unformatted input
729        let unformatted = "Table Example:\n    |A|B|\n    |-|-|\n    |1|2|\n:: doc.table ::\n";
730        let formatted_2 = format_source(unformatted);
731
732        // Should be formatted nicely
733        assert!(formatted_2.contains("| A   | B   |"));
734        assert!(formatted_2.contains("| --- | --- |"));
735        assert!(formatted_2.contains("| 1   | 2   |"));
736    }
737
738    #[test]
739    fn test_verbatim_04_user_repro() {
740        // NOTE: The user's original input had dedented marker "::  doc.table ::".
741        // This caused it to be parsed as Definition + Document Annotation.
742        // The fix is to indent the marker to match the subject.
743        let source = "  The Table:\n    | Markup Language | Great |\n    |--------------------|--------|\n    | Markdown | No |\n    | Lex | Yes |\n  ::  doc.table ::\n";
744
745        let formatted = format_source(source);
746
747        // Check for formatting
748        // Markdown serializer adds padding for alignment
749        let table_start = formatted
750            .find("| Markup Language | Great |")
751            .expect("Table start not found");
752        let separator = formatted
753            .find("| --------------- | ----- |")
754            .expect("Separator not found");
755        let footer_start = formatted.find(":: doc.table").expect("Footer not found");
756
757        assert!(table_start < separator);
758        assert!(separator < footer_start);
759    }
760}