Skip to main content

panache_parser/parser/blocks/
tables.rs

1//! Simple table parsing for Pandoc's simple_tables extension.
2
3use crate::options::ParserOptions;
4use crate::syntax::SyntaxKind;
5use rowan::GreenNodeBuilder;
6use unicode_width::UnicodeWidthChar;
7
8use crate::parser::utils::attributes::{
9    emit_attribute_node, try_parse_trailing_attributes_with_pos,
10};
11use crate::parser::utils::helpers::{emit_line_tokens, emit_separator_tokens, strip_newline};
12use crate::parser::utils::inline_emission;
13
14use super::container_prefix::StrippedLines;
15
16/// Read-only indexed view over lines for table detection scans. Two
17/// backings:
18///
19/// - `[&str]` — a raw, unstripped line buffer, used by callers that scan
20///   the source directly (the block dispatcher's caption lookahead, list
21///   and definition-list probes).
22/// - [`StrippedLines`] / [`UniformStripView`] — a container-prefix-stripped
23///   view that strips each line lazily on access via
24///   [`StrippedLines::strip_at`]. Detection scans touch only a bounded
25///   range (they stop at the first blank line), so this stays
26///   O(scanned lines) rather than materializing the whole buffer. The old
27///   `strip_all` collected `0..raw.len()` on every call, which was
28///   quadratic when table detection runs at every block start inside a
29///   large blockquote or list.
30pub(crate) trait LineView {
31    /// The line at absolute index `i`.
32    fn line(&self, i: usize) -> &str;
33    /// Total number of lines (absolute upper bound for indices).
34    fn line_count(&self) -> usize;
35}
36
37impl LineView for [&str] {
38    fn line(&self, i: usize) -> &str {
39        self[i]
40    }
41    fn line_count(&self) -> usize {
42        self.len()
43    }
44}
45
46impl<'a, 'p> LineView for StrippedLines<'a, 'p> {
47    fn line(&self, i: usize) -> &str {
48        self.strip_at(i)
49    }
50    fn line_count(&self) -> usize {
51        self.raw().len()
52    }
53}
54
55/// A [`LineView`] over a [`StrippedLines`] window that strips *every* line —
56/// including the dispatch line — with the full container strip rather than
57/// the emission-safe line-0 strip. Grid-border detection needs this: a
58/// `+---+` border sitting at column 0 of a list item's inner content must
59/// not retain the list indent, or the strict column-0 check in
60/// `try_parse_grid_separator` would reject it. Emission still goes through
61/// the window, which preserves the indent bytes. This reproduces the old
62/// grid path's `stripped[dispatch] = prefix.strip(...)` override, but
63/// lazily.
64pub(crate) struct UniformStripView<'s, 'a, 'p>(&'s StrippedLines<'a, 'p>);
65
66impl<'s, 'a, 'p> LineView for UniformStripView<'s, 'a, 'p> {
67    fn line(&self, i: usize) -> &str {
68        self.0.prefix().strip(self.0.raw()[i])
69    }
70    fn line_count(&self) -> usize {
71        self.0.raw().len()
72    }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum Alignment {
77    Left,
78    Right,
79    Center,
80    Default,
81}
82
83/// Column information extracted from the separator line.
84#[derive(Debug, Clone)]
85pub(crate) struct Column {
86    /// Start position (byte index) in the line
87    start: usize,
88    /// End position (byte index) in the line
89    end: usize,
90    /// Column alignment
91    alignment: Alignment,
92}
93
94/// Try to detect if a line is a table separator line.
95/// Returns Some(column positions) if it's a valid separator.
96pub(crate) fn try_parse_table_separator(line: &str) -> Option<Vec<Column>> {
97    let trimmed = line.trim_start();
98    // Strip trailing newline if present (CRLF or LF)
99    let (trimmed, newline_str) = strip_newline(trimmed);
100    let leading_spaces = line.len() - trimmed.len() - newline_str.len();
101
102    // Must have leading spaces <= 3 to not be a code block
103    if leading_spaces > 3 {
104        return None;
105    }
106
107    // Simple tables only use dashed separators.
108    if trimmed.contains('*') || trimmed.contains('_') {
109        return None;
110    }
111
112    // Must contain at least one dash
113    if !trimmed.contains('-') {
114        return None;
115    }
116
117    // A separator line consists of dashes and spaces
118    if !trimmed.chars().all(|c| c == '-' || c == ' ') {
119        return None;
120    }
121
122    // Must not be a horizontal rule.
123    let dash_groups: Vec<_> = trimmed.split(' ').filter(|s| !s.is_empty()).collect();
124    if dash_groups.len() <= 1 {
125        return None;
126    }
127
128    // Extract column positions from dash groups
129    let columns = extract_columns(trimmed, leading_spaces);
130
131    if columns.is_empty() {
132        return None;
133    }
134
135    Some(columns)
136}
137
138/// Extract column positions from a separator line.
139fn extract_columns(separator: &str, offset: usize) -> Vec<Column> {
140    let mut columns = Vec::new();
141    let mut in_dashes = false;
142    let mut col_start = 0;
143
144    for (i, ch) in separator.char_indices() {
145        match ch {
146            '-' if !in_dashes => {
147                col_start = i + offset;
148                in_dashes = true;
149            }
150            ' ' if in_dashes => {
151                columns.push(Column {
152                    start: col_start,
153                    end: i + offset,
154                    alignment: Alignment::Default, // Will be determined later
155                });
156                in_dashes = false;
157            }
158            _ => {}
159        }
160    }
161
162    // Handle last column
163    if in_dashes {
164        columns.push(Column {
165            start: col_start,
166            end: separator.len() + offset,
167            alignment: Alignment::Default,
168        });
169    }
170
171    columns
172}
173
174/// Convert a display-column offset into a UTF-8 byte index for `line`.
175///
176/// Simple- and multiline-table column boundaries come from ASCII separator
177/// lines whose dashes and spaces are each one display column wide, so the
178/// `Column` offsets are effectively *display* columns. Header and data rows may
179/// contain wide characters (CJK, fullwidth forms) that occupy two display
180/// columns, or combining marks that occupy zero, so we must remap by
181/// accumulated display width rather than character count. Pandoc aligns simple
182/// tables the same way. The returned byte index is that of the first character
183/// whose start reaches `offset`, matching how an author lays cells out visually.
184fn column_offset_to_byte_index(line: &str, offset: usize) -> usize {
185    let mut width = 0;
186    for (byte_idx, ch) in line.char_indices() {
187        if width >= offset {
188            return byte_idx;
189        }
190        width += UnicodeWidthChar::width(ch).unwrap_or(0);
191    }
192    line.len()
193}
194
195/// Try to parse a table caption from a line.
196/// Returns Some((prefix_len, caption_text)) if it's a caption.
197fn try_parse_caption_prefix(line: &str) -> Option<(usize, &str)> {
198    let trimmed = line.trim_start();
199    let leading_spaces = line.len() - trimmed.len();
200
201    // Must have leading spaces <= 3 to not be a code block
202    if leading_spaces > 3 {
203        return None;
204    }
205
206    // Check for "Table:" or "table:" or just ":".
207    if let Some(rest) = trimmed.strip_prefix("Table:") {
208        Some((leading_spaces + 6, rest))
209    } else if let Some(rest) = trimmed.strip_prefix("table:") {
210        Some((leading_spaces + 6, rest))
211    } else if let Some(rest) = trimmed.strip_prefix(':') {
212        // Just ":" caption markers must be followed by whitespace (Pandoc-style).
213        // This avoids accidentally treating constructs like fenced div fences ":::" as captions.
214        if rest.starts_with(|c: char| c.is_whitespace()) {
215            Some((leading_spaces + 1, rest))
216        } else {
217            None
218        }
219    } else {
220        None
221    }
222}
223
224/// Check if a line could be the start of a table caption.
225fn is_table_caption_start(line: &str) -> bool {
226    try_parse_caption_prefix(line).is_some()
227}
228
229fn is_bare_colon_caption_start(line: &str) -> bool {
230    let trimmed = line.trim_start();
231    trimmed.starts_with(':') && !trimmed.starts_with("::") && !trimmed.starts_with(":::")
232}
233
234fn bare_colon_caption_looks_like_definition_code_block(line: &str) -> bool {
235    let Some((_, rest)) = try_parse_caption_prefix(line) else {
236        return false;
237    };
238    let trimmed = rest.trim_start();
239    trimmed.starts_with("```") || trimmed.starts_with("~~~")
240}
241
242fn line_is_fenced_div_fence(line: &str) -> bool {
243    let trimmed = line.trim_start();
244    let colon_count = trimmed.chars().take_while(|&c| c == ':').count();
245    if colon_count < 3 {
246        return false;
247    }
248    let rest = &trimmed[colon_count..];
249    rest.is_empty() || rest.starts_with(char::is_whitespace)
250}
251
252fn is_valid_caption_start_before_table(lines: &(impl LineView + ?Sized), pos: usize) -> bool {
253    if !is_table_caption_start(lines.line(pos)) {
254        return false;
255    }
256
257    if is_bare_colon_caption_start(lines.line(pos))
258        && bare_colon_caption_looks_like_definition_code_block(lines.line(pos))
259    {
260        return false;
261    }
262
263    // Avoid stealing definition-list definitions (":   ...") as table captions.
264    if is_bare_colon_caption_start(lines.line(pos))
265        && pos > 0
266        && !lines.line(pos - 1).trim().is_empty()
267        && !line_is_fenced_div_fence(lines.line(pos - 1))
268    {
269        return false;
270    }
271    true
272}
273
274/// Check if a line could be the start of a grid table.
275/// Grid tables start with a separator line like +---+---+ or +===+===+
276fn is_grid_table_start(line: &str) -> bool {
277    try_parse_grid_separator(line).is_some()
278}
279
280/// Check if a line could be the start of a multiline table.
281/// Multiline tables start with either:
282/// - A full-width dash separator (----)
283/// - A column separator with dashes and spaces (---- ---- ----)
284fn is_multiline_table_start(line: &str) -> bool {
285    try_parse_multiline_separator(line).is_some() || is_column_separator(line)
286}
287
288/// Check if there's a table following a potential caption at this position.
289/// This is used to avoid parsing a caption as a paragraph when it belongs to a table.
290pub(crate) fn is_caption_followed_by_table(
291    lines: &(impl LineView + ?Sized),
292    caption_pos: usize,
293) -> bool {
294    if caption_pos >= lines.line_count() {
295        return false;
296    }
297
298    // Caption must start with a caption prefix
299    if !is_valid_caption_start_before_table(lines, caption_pos) {
300        return false;
301    }
302
303    let mut pos = caption_pos + 1;
304
305    // Skip continuation lines of caption (non-blank lines).
306    // Stop at fenced-div fences (`:::`) — those close the enclosing div and
307    // must not be folded into the caption.
308    while pos < lines.line_count()
309        && !lines.line(pos).trim().is_empty()
310        && !line_is_fenced_div_fence(lines.line(pos))
311    {
312        // If we hit a table separator, we found a table
313        if try_parse_table_separator(lines.line(pos)).is_some() {
314            return true;
315        }
316        pos += 1;
317    }
318
319    // Skip one blank line
320    if pos < lines.line_count() && lines.line(pos).trim().is_empty() {
321        pos += 1;
322    }
323
324    // Check for a table grid at the next position.
325    table_grid_starts_at(lines, pos)
326}
327
328/// Cheap lookahead: does any table kind's grid begin at absolute line `pos`?
329///
330/// This is the lightweight twin of the block dispatcher's `first_kind_at`,
331/// which answers the same "is there a table here?" question by attempting a
332/// full parse of each kind in turn. We deliberately do **not** call that from
333/// the caption lookahead: caption detection runs at every block start, and a
334/// full per-kind parse there would reintroduce the O(n²) blowup the bounded
335/// separator probe exists to avoid. To keep the two predicates in agreement,
336/// this calls the same primitive separator detectors the real parsers gate on
337/// (`is_grid_table_start` → `try_parse_grid_separator`, `is_multiline_table_start`
338/// → `try_parse_multiline_separator`/`is_column_separator`,
339/// `try_parse_table_separator`, `try_parse_pipe_separator`).
340fn table_grid_starts_at(lines: &(impl LineView + ?Sized), pos: usize) -> bool {
341    if pos >= lines.line_count() {
342        return false;
343    }
344    let line = lines.line(pos);
345
346    // Grid table start (`+---+---+` or `+===+===+`).
347    if is_grid_table_start(line) {
348        return true;
349    }
350
351    // Multiline table start (`----` or `---- ---- ----`).
352    if is_multiline_table_start(line) {
353        return true;
354    }
355
356    // Separator line (simple/pipe table, headerless).
357    if try_parse_table_separator(line).is_some() {
358        return true;
359    }
360
361    // Header line followed by a separator (simple/pipe table with header).
362    if pos + 1 < lines.line_count() && !line.trim().is_empty() {
363        let next_line = lines.line(pos + 1);
364        if try_parse_table_separator(next_line).is_some()
365            || try_parse_pipe_separator(next_line).is_some()
366        {
367            return true;
368        }
369    }
370
371    false
372}
373
374fn caption_range_starting_at(
375    lines: &(impl LineView + ?Sized),
376    start: usize,
377) -> Option<(usize, usize)> {
378    if start >= lines.line_count() || !is_table_caption_start(lines.line(start)) {
379        return None;
380    }
381    let mut end = start + 1;
382    while end < lines.line_count()
383        && !lines.line(end).trim().is_empty()
384        && !line_is_fenced_div_fence(lines.line(end))
385    {
386        end += 1;
387    }
388    Some((start, end))
389}
390
391/// Find caption before table (if any).
392/// Returns (caption_start, caption_end) positions, or None.
393fn find_caption_before_table(
394    lines: &(impl LineView + ?Sized),
395    table_start: usize,
396) -> Option<(usize, usize)> {
397    if table_start == 0 {
398        return None;
399    }
400
401    // Look backward for a caption
402    // Caption must be immediately before table (with possible blank line between)
403    let mut pos = table_start - 1;
404
405    // Skip one blank line if present
406    if lines.line(pos).trim().is_empty() {
407        if pos == 0 {
408            return None;
409        }
410        pos -= 1;
411    }
412
413    // Now pos points to the last non-blank line before the table
414    // This could be the last line of a multiline caption, or a single-line caption
415    let caption_end = pos + 1; // End is exclusive
416
417    // If this line is NOT a caption start, it might be a continuation line
418    // Scan backward through non-blank lines to find the caption start
419    if !is_valid_caption_start_before_table(lines, pos) {
420        // Not a caption start - check if there's a caption start above
421        let mut scan_pos = pos;
422        while scan_pos > 0 {
423            scan_pos -= 1;
424            let line = lines.line(scan_pos);
425
426            // If we hit a blank line or fenced-div fence, we've gone too far
427            if line.trim().is_empty() || line_is_fenced_div_fence(line) {
428                return None;
429            }
430
431            // If we find a caption start, this is the beginning of the multiline caption
432            if is_valid_caption_start_before_table(lines, scan_pos) {
433                if scan_pos > 0 && !lines.line(scan_pos - 1).trim().is_empty() {
434                    return None;
435                }
436                if previous_nonblank_looks_like_table(lines, scan_pos) {
437                    return None;
438                }
439                return Some((scan_pos, caption_end));
440            }
441        }
442        // Scanned to beginning without finding caption start
443        None
444    } else {
445        if pos > 0 && !lines.line(pos - 1).trim().is_empty() {
446            return None;
447        }
448        if previous_nonblank_looks_like_table(lines, pos) {
449            return None;
450        }
451        // This line is a caption start - return the range
452        Some((pos, caption_end))
453    }
454}
455
456fn previous_nonblank_looks_like_table(lines: &(impl LineView + ?Sized), pos: usize) -> bool {
457    if pos == 0 {
458        return false;
459    }
460    // Skip the blank gap directly above the caption candidate.
461    let mut i = pos;
462    while i > 0 && lines.line(i - 1).trim().is_empty() {
463        i -= 1;
464    }
465    // Scan the contiguous non-blank block above for any table shape. A
466    // simple/multiline table's dashed separator sits *above* its data rows
467    // (which are plain text and don't look like table syntax on their own), so
468    // we must walk the whole block, not just the nearest line, to recognize
469    // that this caption is the caption-after of a preceding table rather than a
470    // caption-before of the following one. Stop at the next blank line or a
471    // fenced-div fence.
472    while i > 0 {
473        i -= 1;
474        if lines.line(i).trim().is_empty() || line_is_fenced_div_fence(lines.line(i)) {
475            break;
476        }
477        if line_looks_like_table_syntax(lines.line(i).trim()) {
478            return true;
479        }
480    }
481    false
482}
483
484fn line_looks_like_table_syntax(line: &str) -> bool {
485    if line.starts_with('|') && line.matches('|').count() >= 2 {
486        return true;
487    }
488    if line.starts_with('+') && line.ends_with('+') && (line.contains('-') || line.contains('=')) {
489        return true;
490    }
491    try_parse_table_separator(line).is_some()
492        || try_parse_pipe_separator(line).is_some()
493        || try_parse_grid_separator(line).is_some()
494}
495
496/// Find caption after table (if any).
497/// Returns (caption_start, caption_end) positions, or None.
498fn find_caption_after_table(
499    lines: &(impl LineView + ?Sized),
500    table_end: usize,
501) -> Option<(usize, usize)> {
502    if table_end >= lines.line_count() {
503        return None;
504    }
505
506    let mut pos = table_end;
507
508    // Skip one blank line if present
509    if pos < lines.line_count() && lines.line(pos).trim().is_empty() {
510        pos += 1;
511    }
512
513    if pos >= lines.line_count() {
514        return None;
515    }
516
517    // Check if this line is a caption
518    if is_table_caption_start(lines.line(pos)) {
519        let caption_start = pos;
520        // Find end of caption (continues until blank line or fenced-div fence)
521        let mut caption_end = caption_start + 1;
522        while caption_end < lines.line_count()
523            && !lines.line(caption_end).trim().is_empty()
524            && !line_is_fenced_div_fence(lines.line(caption_end))
525        {
526            caption_end += 1;
527        }
528        Some((caption_start, caption_end))
529    } else {
530        None
531    }
532}
533
534/// Emit a table caption node.
535/// Emit caption text for a single line. If `lift_trailing_attrs` is set and
536/// the text ends with a balanced `{...}` block, lift it into a structural
537/// `ATTRIBUTE` node so `AttributeNode::cast` finds its id (matches Pandoc's
538/// `+caption_attributes` behavior — `: caption {#tbl-id}` gives the table
539/// the id).
540fn emit_caption_line_text(
541    builder: &mut GreenNodeBuilder<'static>,
542    text_with_newline: &str,
543    config: &ParserOptions,
544    lift_trailing_attrs: bool,
545) {
546    let (text, newline_str) = strip_newline(text_with_newline);
547
548    if lift_trailing_attrs
549        && !text.is_empty()
550        && let Some((_attrs, before_attrs, start_brace_pos)) =
551            try_parse_trailing_attributes_with_pos(text)
552    {
553        let trimmed_len = text.trim_end().len();
554        let space = &text[before_attrs.len()..start_brace_pos];
555        let raw_attrs = &text[start_brace_pos..trimmed_len];
556        let trailing_ws = &text[trimmed_len..];
557
558        if !before_attrs.is_empty() {
559            inline_emission::emit_inlines(builder, before_attrs, config, false);
560        }
561        if !space.is_empty() {
562            builder.token(SyntaxKind::WHITESPACE.into(), space);
563        }
564        emit_attribute_node(builder, raw_attrs);
565        if !trailing_ws.is_empty() {
566            builder.token(SyntaxKind::WHITESPACE.into(), trailing_ws);
567        }
568        if !newline_str.is_empty() {
569            builder.token(SyntaxKind::NEWLINE.into(), newline_str);
570        }
571        return;
572    }
573
574    if !text.is_empty() {
575        inline_emission::emit_inlines(builder, text, config, false);
576    }
577    if !newline_str.is_empty() {
578        builder.token(SyntaxKind::NEWLINE.into(), newline_str);
579    }
580}
581
582/// Emit the blank (container-only) lines in the absolute range `[from, to)` as
583/// `BLANK_LINE` nodes. Re-emits each line's container prefix as tokens via the
584/// window, so a `>`-only blank line between a caption and its table inside a
585/// blockquote round-trips losslessly. Mirrors the interior blank-row emitter in
586/// `try_parse_multiline_table`. An empty range emits nothing.
587fn emit_caption_blank_lines(
588    builder: &mut GreenNodeBuilder<'static>,
589    window: &StrippedLines<'_, '_>,
590    from: usize,
591    to: usize,
592) {
593    for abs in from..to {
594        // `window.line` is the container-stripped view, so a `>`-only line reads
595        // as blank.
596        if window.line(abs).trim().is_empty() {
597            builder.start_node(SyntaxKind::BLANK_LINE.into());
598            let tail = window.emit_or_dispatch_tail(builder, abs);
599            builder.token(SyntaxKind::BLANK_LINE.into(), tail);
600            builder.finish_node();
601        }
602    }
603}
604
605fn emit_table_caption(
606    builder: &mut GreenNodeBuilder<'static>,
607    window: &StrippedLines<'_, '_>,
608    start: usize,
609    end: usize,
610    config: &ParserOptions,
611) {
612    builder.start_node(SyntaxKind::TABLE_CAPTION.into());
613
614    let last_idx = (end - start).saturating_sub(1);
615
616    for (i, abs) in (start..end).enumerate() {
617        let lift_attrs = i == last_idx;
618
619        // Re-emit this caption line's container prefix (`>`/whitespace) as
620        // tokens — except the dispatch line, whose prefix the core already
621        // emitted — and operate on the stripped `tail`, so the caption prefix
622        // (`Table:`/`:`) is recognized inside a blockquote or list rather than
623        // swallowed into the caption text (which doubled the marker and broke
624        // losslessness).
625        let tail = window.emit_or_dispatch_tail(builder, abs);
626
627        if i == 0 {
628            // First line - parse and emit prefix separately
629            let trimmed = tail.trim_start();
630            let leading_ws_len = tail.len() - trimmed.len();
631
632            // Emit leading whitespace if present
633            if leading_ws_len > 0 {
634                builder.token(SyntaxKind::WHITESPACE.into(), &tail[..leading_ws_len]);
635            }
636
637            // Check for caption prefix and emit separately
638            // Calculate where the prefix ends (after trimmed content)
639            let prefix_and_rest = if tail.ends_with('\n') {
640                &tail[leading_ws_len..tail.len() - 1] // Exclude newline
641            } else {
642                &tail[leading_ws_len..]
643            };
644
645            let (prefix_len, prefix_text) = if prefix_and_rest.starts_with("Table: ") {
646                (7, "Table: ")
647            } else if prefix_and_rest.starts_with("table: ") {
648                (7, "table: ")
649            } else if prefix_and_rest.starts_with(": ") {
650                (2, ": ")
651            } else if prefix_and_rest.starts_with(':') {
652                (1, ":")
653            } else {
654                (0, "")
655            };
656
657            if prefix_len > 0 {
658                builder.token(SyntaxKind::TABLE_CAPTION_PREFIX.into(), prefix_text);
659
660                // Emit rest of line after prefix
661                let rest_start = leading_ws_len + prefix_len;
662                if rest_start < tail.len() {
663                    emit_caption_line_text(builder, &tail[rest_start..], config, lift_attrs);
664                }
665            } else {
666                // No recognized prefix, emit whole trimmed line
667                emit_caption_line_text(builder, &tail[leading_ws_len..], config, lift_attrs);
668            }
669        } else {
670            // Continuation lines - emit with inline parsing (attrs only on last line).
671            emit_caption_line_text(builder, tail, config, lift_attrs);
672        }
673    }
674
675    builder.finish_node(); // TABLE_CAPTION
676}
677
678/// Emit a table cell with inline content parsing.
679/// This is the core helper for Phase 7.1 table inline parsing migration.
680fn emit_table_cell(
681    builder: &mut GreenNodeBuilder<'static>,
682    cell_text: &str,
683    config: &ParserOptions,
684) {
685    builder.start_node(SyntaxKind::TABLE_CELL.into());
686
687    // Parse inline content within the cell
688    if !cell_text.is_empty() {
689        inline_emission::emit_inlines(builder, cell_text, config, false);
690    }
691
692    builder.finish_node(); // TABLE_CELL
693}
694
695/// Determine column alignments based on separator and optional header.
696fn determine_alignments(columns: &mut [Column], separator_line: &str, header_line: Option<&str>) {
697    for col in columns.iter_mut() {
698        let sep_slice = &separator_line[col.start..col.end];
699
700        if let Some(header) = header_line {
701            let header_start = column_offset_to_byte_index(header, col.start);
702            let header_end = column_offset_to_byte_index(header, col.end);
703
704            // Extract header text for this column
705            let header_text = if header_start < header_end {
706                header[header_start..header_end].trim()
707            } else if header_start < header.len() {
708                header[header_start..].trim()
709            } else {
710                ""
711            };
712
713            if header_text.is_empty() {
714                col.alignment = Alignment::Default;
715                continue;
716            }
717
718            // Find where the header text starts and ends within the column
719            let header_in_col = &header[header_start..header_end];
720            let text_start = header_in_col.len() - header_in_col.trim_start().len();
721            let text_end = header_in_col.trim_end().len() + text_start;
722
723            // Check dash alignment relative to text
724            let dashes_start = 0; // Dashes start at beginning of sep_slice
725            let dashes_end = sep_slice.len();
726
727            let flush_left = dashes_start == text_start;
728            let flush_right = dashes_end == text_end;
729
730            col.alignment = match (flush_left, flush_right) {
731                (true, true) => Alignment::Default,
732                (true, false) => Alignment::Left,
733                (false, true) => Alignment::Right,
734                (false, false) => Alignment::Center,
735            };
736        } else {
737            // Without header, alignment based on first row (we'll handle this later)
738            col.alignment = Alignment::Default;
739        }
740    }
741}
742
743/// Try to parse a simple table starting at the given position.
744/// Returns the number of lines consumed if successful.
745pub(crate) fn try_parse_simple_table(
746    window: &StrippedLines<'_, '_>,
747    builder: &mut GreenNodeBuilder<'static>,
748    config: &ParserOptions,
749) -> Option<usize> {
750    let lines = window.raw();
751    let start_pos = window.pos();
752    log::trace!("try_parse_simple_table at line {}", start_pos + 1);
753
754    if start_pos >= lines.len() {
755        return None;
756    }
757
758    // Cheap gate before the O(buffer) `strip_all` below: a simple table's
759    // separator must sit on the dispatch line or the line just after it (see
760    // `find_separator_line`). Table detection runs at every block start, so
761    // stripping the whole line buffer for every prose/math paragraph that
762    // can't be a table was quadratic on large documents. Peek just those one
763    // or two lines via `strip_at` and bail before materializing the full view.
764    let gate_first = window.strip_at(start_pos);
765    let separator_here = try_parse_table_separator(gate_first).is_some();
766    let separator_next = !separator_here
767        && start_pos + 1 < lines.len()
768        && !gate_first.trim().is_empty()
769        && try_parse_table_separator(window.strip_at(start_pos + 1)).is_some();
770    if !separator_here && !separator_next {
771        return None;
772    }
773
774    // Detection scans read the container-prefix-stripped view lazily through
775    // the window (see `LineView`): a table nested in `list → blockquote`
776    // (e.g. `- >  a   b`) has its `  > ` prefix removed before the
777    // separator/column-shape checks. With an empty prefix the stripped view
778    // equals the raw lines. Scans stop at the first blank line, so only a
779    // bounded range is ever stripped. Emission re-emits the prefix bytes as
780    // tokens via the window; captions/blank lines still read raw `lines`.
781
782    // Look for a separator line
783    let separator_pos = find_separator_line(window, start_pos)?;
784    log::trace!("  found separator at line {}", separator_pos + 1);
785
786    let separator_line = window.line(separator_pos);
787    let mut columns = try_parse_table_separator(separator_line)?;
788
789    // Determine if there's a header (separator not at start)
790    let has_header = separator_pos > start_pos;
791    let header_line = if has_header {
792        Some(window.line(separator_pos - 1))
793    } else {
794        None
795    };
796
797    // Determine alignments
798    determine_alignments(&mut columns, separator_line, header_line);
799
800    // Find table end (blank line or end of input)
801    let end_pos = find_table_end(window, separator_pos + 1);
802
803    // Must have at least one data row (or it's just a separator)
804    let data_rows = end_pos - separator_pos - 1;
805
806    if data_rows == 0 {
807        return None;
808    }
809
810    // Check for caption before table
811    let caption_before = find_caption_before_table(window, start_pos);
812
813    // Check for caption after table
814    let caption_after = if caption_before.is_some() {
815        None
816    } else {
817        find_caption_after_table(window, end_pos)
818    };
819
820    // Build the table
821    builder.start_node(SyntaxKind::SIMPLE_TABLE.into());
822
823    // Emit caption before if present
824    if let Some((cap_start, cap_end)) = caption_before {
825        emit_table_caption(builder, window, cap_start, cap_end, config);
826        // Emit blank line between caption and table if present
827        emit_caption_blank_lines(builder, window, cap_end, start_pos);
828    }
829
830    // Emit header if present. On the dispatch line the core already emitted
831    // the container prefix; only continuation rows re-emit it (via the window
832    // inside `emit_table_row`).
833    if has_header {
834        emit_table_row(
835            builder,
836            window,
837            separator_pos - 1,
838            &columns,
839            SyntaxKind::TABLE_HEADER,
840            config,
841        );
842    }
843
844    // Emit separator, re-emitting any continuation-line container prefix
845    // (`  > `) as WHITESPACE/BLOCK_QUOTE_MARKER tokens before the row text.
846    builder.start_node(SyntaxKind::TABLE_SEPARATOR.into());
847    let separator_tail = window.emit_or_dispatch_tail(builder, separator_pos);
848    emit_separator_tokens(builder, separator_tail);
849    builder.finish_node();
850
851    // Emit data rows (always continuation lines)
852    for idx in (separator_pos + 1)..end_pos {
853        emit_table_row(
854            builder,
855            window,
856            idx,
857            &columns,
858            SyntaxKind::TABLE_ROW,
859            config,
860        );
861    }
862
863    // Emit caption after if present
864    if let Some((cap_start, cap_end)) = caption_after {
865        // Emit blank line before caption if needed
866        emit_caption_blank_lines(builder, window, end_pos, cap_start);
867        emit_table_caption(builder, window, cap_start, cap_end, config);
868    }
869
870    builder.finish_node(); // SimpleTable
871
872    // Calculate lines consumed (including captions)
873    let table_start = if let Some((cap_start, _)) = caption_before {
874        cap_start
875    } else if has_header {
876        separator_pos - 1
877    } else {
878        separator_pos
879    };
880
881    let table_end = if let Some((_, cap_end)) = caption_after {
882        cap_end
883    } else {
884        end_pos
885    };
886
887    let lines_consumed = table_end - table_start;
888
889    Some(lines_consumed)
890}
891
892/// Find the position of a separator line starting from pos.
893fn find_separator_line(lines: &(impl LineView + ?Sized), start_pos: usize) -> Option<usize> {
894    log::trace!("  find_separator_line from line {}", start_pos + 1);
895
896    // Check first line
897    log::trace!("    checking first line: {:?}", lines.line(start_pos));
898    if try_parse_table_separator(lines.line(start_pos)).is_some() {
899        log::trace!("    separator found at first line");
900        return Some(start_pos);
901    }
902
903    // Check second line (for table with header)
904    if start_pos + 1 < lines.line_count()
905        && !lines.line(start_pos).trim().is_empty()
906        && try_parse_table_separator(lines.line(start_pos + 1)).is_some()
907    {
908        return Some(start_pos + 1);
909    }
910    None
911}
912
913/// Find where the table ends (first blank line or end of input).
914fn find_table_end(lines: &(impl LineView + ?Sized), start_pos: usize) -> usize {
915    for i in start_pos..lines.line_count() {
916        if lines.line(i).trim().is_empty() {
917            return i;
918        }
919        // Check if this could be a closing separator
920        if try_parse_table_separator(lines.line(i)).is_some() {
921            // Check if next line is blank or end
922            if i + 1 >= lines.line_count() || lines.line(i + 1).trim().is_empty() {
923                return i + 1;
924            }
925        }
926    }
927    lines.line_count()
928}
929
930/// Emit a table row (header or data row) with inline-parsed cells for simple tables.
931/// Uses column boundaries from the separator line to extract cells.
932fn emit_table_row(
933    builder: &mut GreenNodeBuilder<'static>,
934    window: &StrippedLines<'_, '_>,
935    abs_idx: usize,
936    columns: &[Column],
937    row_kind: SyntaxKind,
938    config: &ParserOptions,
939) {
940    builder.start_node(row_kind.into());
941
942    // On continuation lines the leading `  > ` prefix is re-emitted as
943    // WHITESPACE/BLOCK_QUOTE_MARKER tokens inside the row node and the
944    // stripped tail returned; the dispatch line just strips its (already
945    // core-emitted) prefix. Empty prefix ⇒ the raw line.
946    let line = window.emit_or_dispatch_tail(builder, abs_idx);
947
948    let (line_without_newline, newline_str) = strip_newline(line);
949
950    // Emit leading whitespace if present
951    let trimmed = line_without_newline.trim_start();
952    let leading_ws_len = line_without_newline.len() - line_without_newline.trim_start().len();
953    if leading_ws_len > 0 {
954        builder.token(
955            SyntaxKind::WHITESPACE.into(),
956            &line_without_newline[..leading_ws_len],
957        );
958    }
959
960    // Track where we are in the line (for losslessness)
961    let mut current_pos = 0;
962
963    // Extract and emit cells based on column boundaries
964    for (i, col) in columns.iter().enumerate() {
965        // Calculate actual positions in the trimmed line (accounting for leading whitespace)
966        let cell_start = if col.start >= leading_ws_len {
967            column_offset_to_byte_index(trimmed, col.start - leading_ws_len)
968        } else {
969            0
970        };
971
972        // A column spans from its own start to the start of the next column
973        // (the inter-column gap belongs to the left column); the last column
974        // runs to end-of-line. Ending the slice at the dash-run end instead
975        // would split cell text that overruns a short dash run into the cell
976        // plus a bogus WHITESPACE token.
977        let end_offset = columns.get(i + 1).map_or(usize::MAX, |next| next.start);
978        let cell_end = if end_offset == usize::MAX {
979            trimmed.len()
980        } else if end_offset >= leading_ws_len {
981            column_offset_to_byte_index(trimmed, end_offset - leading_ws_len)
982        } else {
983            0
984        };
985
986        // Extract cell text from column bounds. When the column lies entirely
987        // before the trimmed content (col.end <= leading_ws_len) both bounds
988        // clamp to 0; treat that as an empty cell rather than re-emitting the
989        // whole row.
990        let cell_text = if cell_start < cell_end && cell_start < trimmed.len() {
991            &trimmed[cell_start..cell_end]
992        } else {
993            ""
994        };
995
996        let cell_content = cell_text.trim();
997        let cell_content_start = cell_text.len() - cell_text.trim_start().len();
998
999        // Emit any whitespace from current position to start of cell content
1000        let content_abs_pos = (cell_start + cell_content_start).min(trimmed.len());
1001        if current_pos < content_abs_pos {
1002            builder.token(
1003                SyntaxKind::WHITESPACE.into(),
1004                &trimmed[current_pos..content_abs_pos],
1005            );
1006        }
1007
1008        // Emit cell with inline parsing
1009        emit_table_cell(builder, cell_content, config);
1010
1011        // Update current position to end of cell content
1012        current_pos = content_abs_pos + cell_content.len();
1013    }
1014
1015    // Emit any remaining whitespace after last cell
1016    if current_pos < trimmed.len() {
1017        builder.token(SyntaxKind::WHITESPACE.into(), &trimmed[current_pos..]);
1018    }
1019
1020    // Emit newline if present
1021    if !newline_str.is_empty() {
1022        builder.token(SyntaxKind::NEWLINE.into(), newline_str);
1023    }
1024
1025    builder.finish_node();
1026}
1027
1028// ============================================================================
1029// Pipe Table Parsing
1030// ============================================================================
1031
1032/// Check if a line is a pipe table separator line.
1033/// Returns the column alignments if it's a valid separator.
1034fn try_parse_pipe_separator(line: &str) -> Option<Vec<Alignment>> {
1035    let trimmed = line.trim();
1036
1037    // Must contain at least one pipe
1038    if !trimmed.contains('|') && !trimmed.contains('+') {
1039        return None;
1040    }
1041
1042    // Split by pipes (or + for orgtbl variant)
1043    let cells: Vec<&str> = if trimmed.contains('+') {
1044        // Orgtbl variant: use + as separator in separator line
1045        trimmed.split(['|', '+']).collect()
1046    } else {
1047        trimmed.split('|').collect()
1048    };
1049
1050    let mut alignments = Vec::new();
1051
1052    for cell in cells {
1053        let cell = cell.trim();
1054
1055        // Skip empty cells (from leading/trailing pipes)
1056        if cell.is_empty() {
1057            continue;
1058        }
1059
1060        // Must be dashes with optional colons
1061        let starts_colon = cell.starts_with(':');
1062        let ends_colon = cell.ends_with(':');
1063
1064        // Remove colons to check if rest is all dashes
1065        let without_colons = cell.trim_start_matches(':').trim_end_matches(':');
1066
1067        // Must have at least one dash
1068        if without_colons.is_empty() || !without_colons.chars().all(|c| c == '-') {
1069            return None;
1070        }
1071
1072        // Determine alignment from colon positions
1073        let alignment = match (starts_colon, ends_colon) {
1074            (true, true) => Alignment::Center,
1075            (true, false) => Alignment::Left,
1076            (false, true) => Alignment::Right,
1077            (false, false) => Alignment::Default,
1078        };
1079
1080        alignments.push(alignment);
1081    }
1082
1083    // Must have at least one column
1084    if alignments.is_empty() {
1085        None
1086    } else {
1087        Some(alignments)
1088    }
1089}
1090
1091/// Split a pipe table row into cells.
1092/// Handles escaped pipes (\|) properly by not splitting on them.
1093fn parse_pipe_table_row(line: &str) -> Vec<String> {
1094    let trimmed = line.trim();
1095
1096    let mut cells = Vec::new();
1097    let mut current_cell = String::new();
1098    let mut chars = trimmed.chars().peekable();
1099    let mut char_count = 0;
1100
1101    while let Some(ch) = chars.next() {
1102        char_count += 1;
1103        match ch {
1104            '\\' => {
1105                // Check if next char is a pipe - if so, it's an escaped pipe
1106                if let Some(&'|') = chars.peek() {
1107                    current_cell.push('\\');
1108                    current_cell.push('|');
1109                    chars.next(); // consume the pipe
1110                } else {
1111                    current_cell.push(ch);
1112                }
1113            }
1114            '|' => {
1115                // Check if this is the leading pipe (first character)
1116                if char_count == 1 {
1117                    continue; // Skip leading pipe
1118                }
1119
1120                // End current cell, start new one
1121                cells.push(current_cell.trim().to_string());
1122                current_cell.clear();
1123            }
1124            _ => {
1125                current_cell.push(ch);
1126            }
1127        }
1128    }
1129
1130    // Add last cell if it's not empty (it would be empty if line ended with pipe)
1131    let trimmed_cell = current_cell.trim().to_string();
1132    if !trimmed_cell.is_empty() {
1133        cells.push(trimmed_cell);
1134    }
1135
1136    cells
1137}
1138
1139/// Emit a pipe table row with inline-parsed cells.
1140/// Preserves losslessness by emitting exact byte representation while parsing cell content inline.
1141fn emit_pipe_table_row(
1142    builder: &mut GreenNodeBuilder<'static>,
1143    window: &StrippedLines<'_, '_>,
1144    abs_idx: usize,
1145    row_kind: SyntaxKind,
1146    config: &ParserOptions,
1147) {
1148    builder.start_node(row_kind.into());
1149
1150    // On continuation lines (separator/data rows under a list+blockquote
1151    // container) the leading `  > ` prefix is not consumed by the core;
1152    // `emit_prefix_at` re-emits it as WHITESPACE/BLOCK_QUOTE_MARKER tokens
1153    // and returns the stripped tail. On the dispatch line the core already
1154    // emitted the prefix, so `dispatch_tail` just strips it from our view.
1155    // With an empty prefix (non-nested tables) both are no-ops returning
1156    // the raw line.
1157    let line = if abs_idx == window.dispatch_pos() {
1158        window.dispatch_tail()
1159    } else {
1160        window.emit_prefix_at(builder, abs_idx)
1161    };
1162
1163    let (line_without_newline, newline_str) = strip_newline(line);
1164    let trimmed = line_without_newline.trim();
1165
1166    // Parse cell boundaries
1167    let mut cell_starts = Vec::new();
1168    let mut cell_ends = Vec::new();
1169    let mut in_escape = false;
1170
1171    // Find all pipe positions (excluding escaped ones)
1172    let mut pipe_positions = Vec::new();
1173    for (i, ch) in trimmed.char_indices() {
1174        if in_escape {
1175            in_escape = false;
1176            continue;
1177        }
1178        if ch == '\\' {
1179            in_escape = true;
1180            continue;
1181        }
1182        if ch == '|' {
1183            pipe_positions.push(i);
1184        }
1185    }
1186
1187    // Determine cell boundaries based on pipe positions
1188    if pipe_positions.is_empty() {
1189        // No pipes - treat entire line as one cell (shouldn't happen for valid pipe tables)
1190        cell_starts.push(0);
1191        cell_ends.push(trimmed.len());
1192    } else {
1193        // Check if line starts with pipe
1194        let start_pipe = pipe_positions.first() == Some(&0);
1195        // Check if line ends with pipe
1196        let end_pipe = pipe_positions.last() == Some(&(trimmed.len() - 1));
1197
1198        if start_pipe {
1199            // Skip first pipe
1200            for i in 1..pipe_positions.len() {
1201                cell_starts.push(pipe_positions[i - 1] + 1);
1202                cell_ends.push(pipe_positions[i]);
1203            }
1204            // Add last cell if there's no trailing pipe
1205            if !end_pipe {
1206                cell_starts.push(*pipe_positions.last().unwrap() + 1);
1207                cell_ends.push(trimmed.len());
1208            }
1209        } else {
1210            // No leading pipe
1211            cell_starts.push(0);
1212            cell_ends.push(pipe_positions[0]);
1213
1214            for i in 1..pipe_positions.len() {
1215                cell_starts.push(pipe_positions[i - 1] + 1);
1216                cell_ends.push(pipe_positions[i]);
1217            }
1218
1219            // Add last cell if there's no trailing pipe
1220            if !end_pipe {
1221                cell_starts.push(*pipe_positions.last().unwrap() + 1);
1222                cell_ends.push(trimmed.len());
1223            }
1224        }
1225    }
1226
1227    // Emit leading whitespace if present (before trim)
1228    let leading_ws_len = line_without_newline.len() - line_without_newline.trim_start().len();
1229    if leading_ws_len > 0 {
1230        builder.token(
1231            SyntaxKind::WHITESPACE.into(),
1232            &line_without_newline[..leading_ws_len],
1233        );
1234    }
1235
1236    // Emit cells with pipes
1237    for (idx, (start, end)) in cell_starts.iter().zip(cell_ends.iter()).enumerate() {
1238        // Emit pipe before cell (except for first cell if no leading pipe)
1239        if *start > 0 {
1240            builder.token(SyntaxKind::TEXT.into(), "|");
1241        } else if idx == 0 && trimmed.starts_with('|') {
1242            // Leading pipe
1243            builder.token(SyntaxKind::TEXT.into(), "|");
1244        }
1245
1246        // Get cell content with its whitespace
1247        let cell_with_ws = &trimmed[*start..*end];
1248        let cell_content = cell_with_ws.trim();
1249
1250        // Emit leading whitespace within cell
1251        let cell_leading_ws = &cell_with_ws[..cell_with_ws.len() - cell_with_ws.trim_start().len()];
1252        if !cell_leading_ws.is_empty() {
1253            builder.token(SyntaxKind::WHITESPACE.into(), cell_leading_ws);
1254        }
1255
1256        // Emit cell with inline parsing
1257        emit_table_cell(builder, cell_content, config);
1258
1259        // Emit trailing whitespace within cell
1260        let cell_trailing_ws_start = cell_leading_ws.len() + cell_content.len();
1261        if cell_trailing_ws_start < cell_with_ws.len() {
1262            builder.token(
1263                SyntaxKind::WHITESPACE.into(),
1264                &cell_with_ws[cell_trailing_ws_start..],
1265            );
1266        }
1267    }
1268
1269    // Emit trailing pipe if present
1270    if !pipe_positions.is_empty() && trimmed.ends_with('|') {
1271        builder.token(SyntaxKind::TEXT.into(), "|");
1272    }
1273
1274    // Emit trailing whitespace after trim (before newline)
1275    let trailing_ws_start = leading_ws_len + trimmed.len();
1276    if trailing_ws_start < line_without_newline.len() {
1277        builder.token(
1278            SyntaxKind::WHITESPACE.into(),
1279            &line_without_newline[trailing_ws_start..],
1280        );
1281    }
1282
1283    // Emit newline
1284    if !newline_str.is_empty() {
1285        builder.token(SyntaxKind::NEWLINE.into(), newline_str);
1286    }
1287
1288    builder.finish_node();
1289}
1290
1291/// Try to parse a pipe table starting at the given position.
1292/// Returns the number of lines consumed if successful.
1293pub(crate) fn try_parse_pipe_table(
1294    window: &StrippedLines<'_, '_>,
1295    builder: &mut GreenNodeBuilder<'static>,
1296    config: &ParserOptions,
1297) -> Option<usize> {
1298    let lines = window.raw();
1299    let start_pos = window.pos();
1300    if start_pos + 1 >= lines.len() {
1301        return None;
1302    }
1303
1304    // Cheap gate: a pipe table's first line must contain a `|` (it is either
1305    // the header or, headerless, the delimiter row), unless this is a
1306    // caption-led table. Table detection runs at every block start, so doing
1307    // any per-line work for every prose/math paragraph was quadratic on large
1308    // documents. Peek the dispatch line and run the (bounded) caption probe on
1309    // the same stripped `window` the detection below uses, so the gate applies
1310    // inside containers (blockquote/list) too — not just at top level.
1311    if !window.strip_at(start_pos).contains('|') && !is_caption_followed_by_table(window, start_pos)
1312    {
1313        return None;
1314    }
1315
1316    // Detection scans read the container-prefix-stripped view lazily through
1317    // the window (see `LineView`), so a table nested in `list → blockquote`
1318    // (e.g. `- > | a | b |`) has its `  > ` prefix removed before the
1319    // separator/cell shape checks. The dispatch line uses the emission-safe
1320    // line-0 strip (its prefix was consumed by the core); every other line
1321    // gets the full continuation strip. Scans stop at the first blank line, so
1322    // only a bounded range is stripped. Emission still reads raw `lines` so the
1323    // prefix bytes can be re-emitted as tokens.
1324
1325    // Check if this line is a caption followed by a table
1326    // If so, the actual table starts after the caption and blank line
1327    let (actual_start, caption_before) = if is_caption_followed_by_table(window, start_pos) {
1328        let (cap_start, cap_end) = caption_range_starting_at(window, start_pos)?;
1329        let mut pos = cap_end;
1330        while pos < window.line_count() && window.line(pos).trim().is_empty() {
1331            pos += 1;
1332        }
1333        (pos, Some((cap_start, cap_end)))
1334    } else {
1335        (start_pos, None)
1336    };
1337
1338    if actual_start + 1 >= lines.len() {
1339        return None;
1340    }
1341
1342    // First line should have pipes (potential header)
1343    if !window.line(actual_start).contains('|') {
1344        return None;
1345    }
1346
1347    // Second line should be separator
1348    let alignments = try_parse_pipe_separator(window.line(actual_start + 1))?;
1349
1350    // Parse header cells
1351    let header_cells = parse_pipe_table_row(window.line(actual_start));
1352
1353    // Number of columns should match (approximately - be lenient)
1354    if header_cells.len() != alignments.len() && !header_cells.is_empty() {
1355        // Only fail if very different
1356        if header_cells.len() < alignments.len() / 2 || header_cells.len() > alignments.len() * 2 {
1357            return None;
1358        }
1359    }
1360
1361    // Find table end (first blank line or end of input)
1362    let mut end_pos = actual_start + 2;
1363    while end_pos < window.line_count() {
1364        let line = window.line(end_pos);
1365        if line.trim().is_empty() {
1366            break;
1367        }
1368        // Row should have pipes
1369        if !line.contains('|') {
1370            break;
1371        }
1372        end_pos += 1;
1373    }
1374
1375    // Must have at least one data row
1376    if end_pos <= actual_start + 2 {
1377        return None;
1378    }
1379
1380    // Check for caption before table (only if we didn't already detect it)
1381    let caption_before = caption_before.or_else(|| find_caption_before_table(window, actual_start));
1382
1383    // Check for caption after table
1384    let caption_after = if caption_before.is_some() {
1385        None
1386    } else {
1387        find_caption_after_table(window, end_pos)
1388    };
1389
1390    // Build the pipe table
1391    builder.start_node(SyntaxKind::PIPE_TABLE.into());
1392
1393    // Emit caption before if present
1394    if let Some((cap_start, cap_end)) = caption_before {
1395        emit_table_caption(builder, window, cap_start, cap_end, config);
1396        // Emit blank line between caption and table if present
1397        emit_caption_blank_lines(builder, window, cap_end, actual_start);
1398    }
1399
1400    // Emit header row with inline-parsed cells. On the dispatch line the
1401    // core already emitted the container prefix; only when the header is a
1402    // continuation line (e.g. it follows a caption-before line) do we emit
1403    // the prefix here.
1404    emit_pipe_table_row(
1405        builder,
1406        window,
1407        actual_start,
1408        SyntaxKind::TABLE_HEADER,
1409        config,
1410    );
1411
1412    // Emit separator, re-emitting any continuation-line container prefix
1413    // (`  > `) as WHITESPACE/BLOCK_QUOTE_MARKER tokens before the row text.
1414    builder.start_node(SyntaxKind::TABLE_SEPARATOR.into());
1415    let sep_idx = actual_start + 1;
1416    let separator_tail = if sep_idx == window.dispatch_pos() {
1417        window.dispatch_tail()
1418    } else {
1419        window.emit_prefix_at(builder, sep_idx)
1420    };
1421    emit_separator_tokens(builder, separator_tail);
1422    builder.finish_node();
1423
1424    // Emit data rows with inline-parsed cells (always continuation lines)
1425    for idx in (actual_start + 2)..end_pos {
1426        emit_pipe_table_row(builder, window, idx, SyntaxKind::TABLE_ROW, config);
1427    }
1428
1429    // Emit caption after if present
1430    if let Some((cap_start, cap_end)) = caption_after {
1431        // Emit blank line before caption if needed
1432        emit_caption_blank_lines(builder, window, end_pos, cap_start);
1433        emit_table_caption(builder, window, cap_start, cap_end, config);
1434    }
1435
1436    builder.finish_node(); // PipeTable
1437
1438    // Calculate lines consumed
1439    let table_start = caption_before
1440        .map(|(start, _)| start)
1441        .unwrap_or(actual_start);
1442    let table_end = if let Some((_, cap_end)) = caption_after {
1443        cap_end
1444    } else {
1445        end_pos
1446    };
1447
1448    Some(table_end - table_start)
1449}
1450
1451#[cfg(test)]
1452mod tests {
1453    use super::super::container_prefix::ContainerPrefix;
1454    use super::*;
1455
1456    #[test]
1457    fn test_separator_detection() {
1458        assert!(try_parse_table_separator("------- ------ ----------   -------").is_some());
1459        assert!(try_parse_table_separator("  ---  ---  ---").is_some());
1460        assert!(try_parse_table_separator("-------").is_none()); // horizontal rule
1461        assert!(try_parse_table_separator("--- --- ---").is_some()); // table separator
1462    }
1463
1464    #[test]
1465    fn test_column_extraction() {
1466        let line = "-------     ------ ----------   -------";
1467        let columns = extract_columns(line, 0);
1468        assert_eq!(columns.len(), 4);
1469    }
1470
1471    #[test]
1472    fn column_offset_maps_by_display_width() {
1473        // Wide CJK characters occupy two display columns each. A column offset
1474        // of 4 display columns lands after two wide chars (6 bytes), not after
1475        // four chars.
1476        let line = "地號xy";
1477        assert_eq!(column_offset_to_byte_index(line, 4), 6);
1478        // ASCII stays byte-for-byte.
1479        assert_eq!(column_offset_to_byte_index("abcd", 2), 2);
1480    }
1481
1482    #[test]
1483    fn simple_table_cjk_header_keeps_footnote_ref_intact() {
1484        // Regression for #411: wide (double-width) header text pushed the
1485        // footnote reference across a char-counted column boundary, splitting
1486        // `[^d]` so it never parsed as a FOOTNOTE_REFERENCE and the linter
1487        // flagged the definition as unused.
1488        let input = "\
1489 地號    地主    路段      總長[^d] 水利局舖的[^s]
1490------ -------   -------- --------- --------------
14912976    Ralph    南段          64   33
1492";
1493        let tree = crate::parser::parse(input, None);
1494        let refs: Vec<_> = tree
1495            .descendants()
1496            .filter(|n| n.kind() == SyntaxKind::FOOTNOTE_REFERENCE)
1497            .map(|n| n.text().to_string())
1498            .collect();
1499        assert_eq!(refs, vec!["[^d]".to_string(), "[^s]".to_string()]);
1500    }
1501
1502    #[test]
1503    fn test_simple_table_with_header() {
1504        let input = vec![
1505            "  Right     Left     Center     Default",
1506            "-------     ------ ----------   -------",
1507            "     12     12        12            12",
1508            "    123     123       123          123",
1509            "",
1510        ];
1511
1512        let mut builder = GreenNodeBuilder::new();
1513        let prefix = ContainerPrefix::default();
1514        let window = StrippedLines::new(&input, 0, &prefix);
1515        let result = try_parse_simple_table(&window, &mut builder, &ParserOptions::default());
1516
1517        assert!(result.is_some());
1518        assert_eq!(result.unwrap(), 4); // header + sep + 2 rows
1519    }
1520
1521    #[test]
1522    fn test_headerless_table() {
1523        let input = vec![
1524            "-------     ------ ----------   -------",
1525            "     12     12        12            12",
1526            "    123     123       123          123",
1527            "",
1528        ];
1529
1530        let mut builder = GreenNodeBuilder::new();
1531        let prefix = ContainerPrefix::default();
1532        let window = StrippedLines::new(&input, 0, &prefix);
1533        let result = try_parse_simple_table(&window, &mut builder, &ParserOptions::default());
1534
1535        assert!(result.is_some());
1536        assert_eq!(result.unwrap(), 3); // sep + 2 rows
1537    }
1538
1539    #[test]
1540    fn test_caption_prefix_detection() {
1541        assert!(try_parse_caption_prefix("Table: My caption").is_some());
1542        assert!(try_parse_caption_prefix("table: My caption").is_some());
1543        assert!(try_parse_caption_prefix(": My caption").is_some());
1544        assert!(try_parse_caption_prefix(":").is_none()); // Just colon, no content
1545        assert!(try_parse_caption_prefix("Not a caption").is_none());
1546    }
1547
1548    #[test]
1549    fn table_grid_starts_at_matches_each_kind() {
1550        // Positives — one shape per table kind the real parsers accept.
1551        assert!(table_grid_starts_at(&["+---+---+"][..], 0)); // grid
1552        assert!(table_grid_starts_at(&["----------- -------"][..], 0)); // multiline
1553        assert!(table_grid_starts_at(&["--- --- ---"][..], 0)); // simple, headerless
1554        assert!(table_grid_starts_at(&["A | B", "| --- | --- |"][..], 0)); // pipe, header + sep
1555        assert!(table_grid_starts_at(&["A    B", "--- ---"][..], 0)); // simple, header + sep
1556        // A lone dash run is a multiline full-width separator under Pandoc (not a
1557        // thematic break), so the lookahead intentionally accepts it; the full
1558        // parser then rejects it if no rows follow.
1559        assert!(table_grid_starts_at(&["-------"][..], 0));
1560
1561        // Negatives — shapes that must not read as a table start.
1562        assert!(!table_grid_starts_at(&["just some prose"][..], 0));
1563        assert!(!table_grid_starts_at(&["# Heading"][..], 0));
1564        assert!(!table_grid_starts_at(&["```", "code", "```"][..], 0)); // code fence
1565        assert!(!table_grid_starts_at(&["only one line"][..], 1)); // out of range
1566    }
1567
1568    /// The cheap caption lookahead must agree with what the full parser does:
1569    /// when it says a table follows the caption, a table node really forms; when
1570    /// it says no table follows, none does. This guards against the lookahead
1571    /// (`table_grid_starts_at`) drifting from the real per-kind parsers.
1572    #[test]
1573    fn caption_lookahead_agrees_with_real_parse() {
1574        let with_table = ": Cap\n\n| A | B |\n|---|---|\n| 1 | 2 |\n";
1575        let lines: Vec<&str> = with_table.lines().collect();
1576        assert!(is_caption_followed_by_table(&lines[..], 0));
1577        assert!(format!("{:#?}", crate::parse(with_table, None)).contains("PIPE_TABLE"));
1578
1579        let no_table = ": Cap\n\nplain paragraph\n";
1580        let lines: Vec<&str> = no_table.lines().collect();
1581        assert!(!is_caption_followed_by_table(&lines[..], 0));
1582        assert!(!format!("{:#?}", crate::parse(no_table, None)).contains("TABLE"));
1583    }
1584
1585    /// Pandoc parses `table` before `orderedList` (but `bulletList` before
1586    /// `table`) in its `block` choice. So an ordered marker whose line is the
1587    /// header of a valid pipe table is NOT a list: the whole construct is a
1588    /// top-level table absorbing the marker as the first header cell. Bullets
1589    /// and a lone ordered marker (no delimiter) stay lists. Verified against
1590    /// pandoc 3.9 (`-f markdown -t native`).
1591    #[test]
1592    fn ordered_marker_on_pipe_table_line_is_top_level_table() {
1593        let input = "1. | a | b |\n   | - | - |\n   | 1 | 2 |\n";
1594        let tree = crate::parse(input, None);
1595        assert!(
1596            tree.descendants()
1597                .any(|n| n.kind() == SyntaxKind::PIPE_TABLE),
1598            "ordered marker + pipe table on the marker line should be a top-level table"
1599        );
1600        assert!(
1601            !tree.descendants().any(|n| n.kind() == SyntaxKind::LIST),
1602            "it must not nest under a list"
1603        );
1604        // Lossless: the marker and the overflow cell survive in the CST.
1605        let dump = format!("{tree:#?}");
1606        assert!(
1607            dump.contains("1."),
1608            "marker text preserved as a header cell"
1609        );
1610        assert!(dump.contains('b'), "overflow cell `b` preserved (lossless)");
1611    }
1612
1613    #[test]
1614    fn lone_ordered_marker_pipe_line_is_a_list() {
1615        // No delimiter row → pandoc's `table` fails, `orderedList` catches it.
1616        let input = "1. | a | b |\n";
1617        let tree = crate::parse(input, None);
1618        assert!(
1619            tree.descendants().any(|n| n.kind() == SyntaxKind::LIST),
1620            "a lone ordered marker line stays a list"
1621        );
1622        assert!(
1623            !tree
1624                .descendants()
1625                .any(|n| n.kind() == SyntaxKind::PIPE_TABLE),
1626            "no table without a delimiter row"
1627        );
1628    }
1629
1630    #[test]
1631    fn bullet_marker_on_pipe_table_line_stays_a_nested_table() {
1632        // Bullets already match pandoc (`BulletList -> Table`): regression guard.
1633        let input = "- | a | b |\n  | - | - |\n  | 1 | 2 |\n";
1634        let tree = crate::parse(input, None);
1635        assert!(
1636            tree.descendants().any(|n| n.kind() == SyntaxKind::LIST),
1637            "bullet marker keeps the list"
1638        );
1639        assert!(
1640            tree.descendants()
1641                .any(|n| n.kind() == SyntaxKind::PIPE_TABLE),
1642            "with the table nested inside the list item"
1643        );
1644    }
1645
1646    #[test]
1647    fn bare_colon_fenced_code_is_not_table_caption() {
1648        let input = "Term\n: ```\n  code\n  ```\n";
1649        let tree = crate::parse(input, None);
1650
1651        assert!(
1652            tree.descendants()
1653                .any(|node| node.kind() == SyntaxKind::DEFINITION_LIST),
1654            "should parse as definition list"
1655        );
1656        assert!(
1657            tree.descendants()
1658                .any(|node| node.kind() == SyntaxKind::CODE_BLOCK),
1659            "definition should preserve fenced code block"
1660        );
1661        assert!(
1662            !tree
1663                .descendants()
1664                .any(|node| node.kind() == SyntaxKind::TABLE_CAPTION),
1665            "fenced code definition should not be parsed as table caption"
1666        );
1667    }
1668
1669    #[test]
1670    fn bare_colon_caption_after_div_opening_is_table_caption() {
1671        let input = "::: {#tbl:panel layout.nrow=\"1\"}\n  : My Caption {#tbl:foo-1}\n\n  | Col1 | Col2 | Col3 |\n  | ---- | ---- | ---- |\n  | A    | B    | C    |\n  | E    | F    | G    |\n  | A    | G    | G    |\n\n  : My Caption2 {#tbl:foo-2}\n\n  | Col1 | Col2 | Col3 |\n  | ---- | ---- | ---- |\n  | A    | B    | C    |\n  | E    | F    | G    |\n  | A    | G    | G    |\n\nCaption\n:::\n";
1672        let tree = crate::parse(input, None);
1673
1674        let caption_count = tree
1675            .descendants()
1676            .filter(|node| node.kind() == SyntaxKind::TABLE_CAPTION)
1677            .count();
1678        assert_eq!(
1679            caption_count, 2,
1680            "expected both captions to attach to tables"
1681        );
1682        assert!(
1683            !tree
1684                .descendants()
1685                .any(|node| node.kind() == SyntaxKind::DEFINITION_LIST),
1686            "caption lines in this fenced div table layout should not parse as definition list"
1687        );
1688    }
1689
1690    #[test]
1691    fn test_table_with_caption_after() {
1692        let input = vec![
1693            "  Right     Left     Center     Default",
1694            "-------     ------ ----------   -------",
1695            "     12     12        12            12",
1696            "    123     123       123          123",
1697            "",
1698            "Table: Demonstration of simple table syntax.",
1699            "",
1700        ];
1701
1702        let mut builder = GreenNodeBuilder::new();
1703        let prefix = ContainerPrefix::default();
1704        let window = StrippedLines::new(&input, 0, &prefix);
1705        let result = try_parse_simple_table(&window, &mut builder, &ParserOptions::default());
1706
1707        assert!(result.is_some());
1708        // Should consume: header + sep + 2 rows + blank + caption
1709        assert_eq!(result.unwrap(), 6);
1710    }
1711
1712    #[test]
1713    fn test_table_with_caption_before() {
1714        let input = vec![
1715            "Table: Demonstration of simple table syntax.",
1716            "",
1717            "  Right     Left     Center     Default",
1718            "-------     ------ ----------   -------",
1719            "     12     12        12            12",
1720            "    123     123       123          123",
1721            "",
1722        ];
1723
1724        let mut builder = GreenNodeBuilder::new();
1725        let prefix = ContainerPrefix::default();
1726        let window = StrippedLines::new(&input, 2, &prefix);
1727        let result = try_parse_simple_table(&window, &mut builder, &ParserOptions::default());
1728
1729        assert!(result.is_some());
1730        // Should consume: caption + blank + header + sep + 2 rows
1731        assert_eq!(result.unwrap(), 6);
1732    }
1733
1734    #[test]
1735    fn test_caption_with_colon_prefix() {
1736        let input = vec![
1737            "  Right     Left",
1738            "-------     ------",
1739            "     12     12",
1740            "",
1741            ": Short caption",
1742            "",
1743        ];
1744
1745        let mut builder = GreenNodeBuilder::new();
1746        let prefix = ContainerPrefix::default();
1747        let window = StrippedLines::new(&input, 0, &prefix);
1748        let result = try_parse_simple_table(&window, &mut builder, &ParserOptions::default());
1749
1750        assert!(result.is_some());
1751        assert_eq!(result.unwrap(), 5); // header + sep + row + blank + caption
1752    }
1753
1754    #[test]
1755    fn test_multiline_caption() {
1756        let input = vec![
1757            "  Right     Left",
1758            "-------     ------",
1759            "     12     12",
1760            "",
1761            "Table: This is a longer caption",
1762            "that spans multiple lines.",
1763            "",
1764        ];
1765
1766        let mut builder = GreenNodeBuilder::new();
1767        let prefix = ContainerPrefix::default();
1768        let window = StrippedLines::new(&input, 0, &prefix);
1769        let result = try_parse_simple_table(&window, &mut builder, &ParserOptions::default());
1770
1771        assert!(result.is_some());
1772        // Should consume through end of multi-line caption
1773        assert_eq!(result.unwrap(), 6);
1774    }
1775
1776    #[test]
1777    fn test_simple_table_with_multibyte_cell_content() {
1778        let input = vec![
1779            "Name            Hex code     Hue     C, M, Y, K (%)   R, G, B (0-255)   R, G, B (%)",
1780            "--------------  ------------ ------- ---------------- ----------------- ------------",
1781            "        orange       #E69F00     41° 0, 50, 100, 0    230, 159, 0       90, 60, 0",
1782            "      sky blue       #56B4E9    202° 80, 0, 0, 0      86, 180, 233      35, 70, 90",
1783            "",
1784        ];
1785
1786        let mut builder = GreenNodeBuilder::new();
1787        let prefix = ContainerPrefix::default();
1788        let window = StrippedLines::new(&input, 0, &prefix);
1789        let result = try_parse_simple_table(&window, &mut builder, &ParserOptions::default());
1790
1791        assert!(result.is_some());
1792        assert_eq!(result.unwrap(), 4);
1793    }
1794
1795    // Pipe table tests
1796    #[test]
1797    fn test_pipe_separator_detection() {
1798        assert!(try_parse_pipe_separator("|------:|:-----|---------|:------:|").is_some());
1799        assert!(try_parse_pipe_separator("|---|---|").is_some());
1800        assert!(try_parse_pipe_separator("-----|-----:").is_some()); // No leading pipe
1801        assert!(try_parse_pipe_separator("|-----+-------|").is_some()); // Orgtbl variant
1802        assert!(try_parse_pipe_separator("not a separator").is_none());
1803    }
1804
1805    #[test]
1806    fn test_pipe_alignments() {
1807        let aligns = try_parse_pipe_separator("|------:|:-----|---------|:------:|").unwrap();
1808        assert_eq!(aligns.len(), 4);
1809        assert_eq!(aligns[0], Alignment::Right);
1810        assert_eq!(aligns[1], Alignment::Left);
1811        assert_eq!(aligns[2], Alignment::Default);
1812        assert_eq!(aligns[3], Alignment::Center);
1813    }
1814
1815    #[test]
1816    fn test_parse_pipe_table_row() {
1817        let cells = parse_pipe_table_row("| Right | Left | Center |");
1818        assert_eq!(cells.len(), 3);
1819        assert_eq!(cells[0], "Right");
1820        assert_eq!(cells[1], "Left");
1821        assert_eq!(cells[2], "Center");
1822
1823        // Without leading/trailing pipes
1824        let cells2 = parse_pipe_table_row("Right | Left | Center");
1825        assert_eq!(cells2.len(), 3);
1826    }
1827
1828    #[test]
1829    fn test_basic_pipe_table() {
1830        let input = vec![
1831            "",
1832            "| Right | Left | Center |",
1833            "|------:|:-----|:------:|",
1834            "|   12  |  12  |   12   |",
1835            "|  123  |  123 |  123   |",
1836            "",
1837        ];
1838
1839        let mut builder = GreenNodeBuilder::new();
1840        let prefix = ContainerPrefix::default();
1841        let window = StrippedLines::new(&input, 1, &prefix);
1842        let result = try_parse_pipe_table(&window, &mut builder, &ParserOptions::default());
1843
1844        assert!(result.is_some());
1845        assert_eq!(result.unwrap(), 4); // header + sep + 2 rows
1846    }
1847
1848    #[test]
1849    fn test_pipe_table_no_edge_pipes() {
1850        let input = vec![
1851            "",
1852            "fruit| price",
1853            "-----|-----:",
1854            "apple|2.05",
1855            "pear|1.37",
1856            "",
1857        ];
1858
1859        let mut builder = GreenNodeBuilder::new();
1860        let prefix = ContainerPrefix::default();
1861        let window = StrippedLines::new(&input, 1, &prefix);
1862        let result = try_parse_pipe_table(&window, &mut builder, &ParserOptions::default());
1863
1864        assert!(result.is_some());
1865        assert_eq!(result.unwrap(), 4);
1866    }
1867
1868    #[test]
1869    fn test_pipe_table_with_caption() {
1870        let input = vec![
1871            "",
1872            "| Col1 | Col2 |",
1873            "|------|------|",
1874            "| A    | B    |",
1875            "",
1876            "Table: My pipe table",
1877            "",
1878        ];
1879
1880        let mut builder = GreenNodeBuilder::new();
1881        let prefix = ContainerPrefix::default();
1882        let window = StrippedLines::new(&input, 1, &prefix);
1883        let result = try_parse_pipe_table(&window, &mut builder, &ParserOptions::default());
1884
1885        assert!(result.is_some());
1886        assert_eq!(result.unwrap(), 5); // header + sep + row + blank + caption
1887    }
1888
1889    #[test]
1890    fn test_pipe_table_with_multiline_caption_before() {
1891        let input = vec![
1892            ": (#tab:base) base R quoting",
1893            "functions",
1894            "",
1895            "| C | D |",
1896            "|---|---|",
1897            "| 3 | 4 |",
1898            "",
1899        ];
1900
1901        let mut builder = GreenNodeBuilder::new();
1902        let prefix = ContainerPrefix::default();
1903        let window = StrippedLines::new(&input, 0, &prefix);
1904        let result = try_parse_pipe_table(&window, &mut builder, &ParserOptions::default());
1905
1906        assert!(result.is_some());
1907        // caption(2) + blank(1) + header + sep + row
1908        assert_eq!(result.unwrap(), 6);
1909    }
1910}
1911
1912// ============================================================================
1913// Grid Table Parsing
1914// ============================================================================
1915
1916/// Check if a line is a grid table row separator (starts with +, contains -, ends with +).
1917/// Returns Some(vec of column info) if valid, None otherwise.
1918fn try_parse_grid_separator(line: &str) -> Option<Vec<GridColumn>> {
1919    let trimmed = line.trim_start();
1920    let leading_spaces = line.len() - trimmed.len();
1921
1922    // A grid border must begin at column 0 of its container content. Detection
1923    // runs on the container-prefix-stripped line (see `try_parse_grid_table`),
1924    // so any remaining leading whitespace means the border is indented relative
1925    // to its container -- pandoc parses that as a paragraph, not a grid table.
1926    if leading_spaces > 0 {
1927        return None;
1928    }
1929
1930    // Must start with + and end with +
1931    if !trimmed.starts_with('+') || !trimmed.trim_end().ends_with('+') {
1932        return None;
1933    }
1934
1935    // Split by + to get column segments
1936    let trimmed = trimmed.trim_end();
1937    let segments: Vec<&str> = trimmed.split('+').collect();
1938
1939    // Need at least 3 parts: empty before first +, column(s), empty after last +
1940    if segments.len() < 3 {
1941        return None;
1942    }
1943
1944    let mut columns = Vec::new();
1945
1946    // Parse each segment between + signs
1947    for segment in segments.iter().skip(1).take(segments.len() - 2) {
1948        if segment.is_empty() {
1949            continue;
1950        }
1951
1952        // Segment must be dashes/equals with optional colons for alignment
1953        let seg_trimmed = *segment;
1954
1955        // Get the fill character (after removing colons)
1956        let inner = seg_trimmed.trim_start_matches(':').trim_end_matches(':');
1957
1958        // Must be all dashes or all equals
1959        if inner.is_empty() {
1960            return None;
1961        }
1962
1963        let first_char = inner.chars().next().unwrap();
1964        if first_char != '-' && first_char != '=' {
1965            return None;
1966        }
1967
1968        if !inner.chars().all(|c| c == first_char) {
1969            return None;
1970        }
1971
1972        let is_header_sep = first_char == '=';
1973
1974        columns.push(GridColumn {
1975            is_header_separator: is_header_sep,
1976            width: seg_trimmed.chars().count(),
1977        });
1978    }
1979
1980    if columns.is_empty() {
1981        None
1982    } else {
1983        Some(columns)
1984    }
1985}
1986
1987/// Column information for grid tables.
1988#[derive(Debug, Clone)]
1989struct GridColumn {
1990    is_header_separator: bool,
1991    width: usize,
1992}
1993
1994fn slice_cell_by_display_width(line: &str, start_byte: usize, width: usize) -> (usize, usize) {
1995    let mut end_byte = start_byte;
1996    let mut display_cols = 0usize;
1997
1998    for (offset, ch) in line[start_byte..].char_indices() {
1999        if ch == '|' {
2000            let sep_byte = start_byte + offset;
2001            return (sep_byte, sep_byte + 1);
2002        }
2003        let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
2004        if display_cols + ch_width > width {
2005            break;
2006        }
2007        display_cols += ch_width;
2008        end_byte = start_byte + offset + ch.len_utf8();
2009        if display_cols >= width {
2010            break;
2011        }
2012    }
2013
2014    // If the width budget is exhausted before seeing a separator (for example
2015    // because of padding/layout drift), advance to the next literal separator
2016    // to keep row slicing aligned and preserve losslessness.
2017    let mut sep_byte = end_byte;
2018    while sep_byte < line.len() {
2019        let mut chars = line[sep_byte..].chars();
2020        let Some(ch) = chars.next() else {
2021            break;
2022        };
2023        if ch == '|' {
2024            return (sep_byte, sep_byte + 1);
2025        }
2026        sep_byte += ch.len_utf8();
2027    }
2028
2029    (end_byte, end_byte)
2030}
2031
2032/// Check if a line is a grid table content row.
2033/// Accepts normal rows ending with `|` and spanning-style continuation lines ending with `+`.
2034fn is_grid_content_row(line: &str) -> bool {
2035    let trimmed = line.trim_start();
2036    let leading_spaces = line.len() - trimmed.len();
2037
2038    if leading_spaces > 3 {
2039        return false;
2040    }
2041
2042    let trimmed = trimmed.trim_end();
2043    trimmed.starts_with('|') && (trimmed.ends_with('|') || trimmed.ends_with('+'))
2044}
2045
2046/// Extract cell contents from a single grid table row line.
2047/// Returns a vector of cell contents (trimmed) based on column boundaries.
2048/// Grid table rows look like: "| Cell 1 | Cell 2 | Cell 3 |"
2049fn extract_grid_cells_from_line(line: &str, _columns: &[GridColumn]) -> Vec<String> {
2050    let (line_content, _) = strip_newline(line);
2051    let line_trimmed = line_content.trim();
2052
2053    if !line_trimmed.starts_with('|') || !line_trimmed.ends_with('|') {
2054        return vec![String::new(); _columns.len()];
2055    }
2056
2057    let mut cells = Vec::with_capacity(_columns.len());
2058    let mut pos_byte = 1; // Skip leading pipe
2059
2060    for col in _columns {
2061        let col_idx = cells.len();
2062        if pos_byte >= line_trimmed.len() {
2063            cells.push(String::new());
2064            continue;
2065        }
2066
2067        let start_byte = pos_byte;
2068        let end_byte = if col_idx + 1 == _columns.len() {
2069            line_trimmed.len().saturating_sub(1) // consume to trailing pipe for last column
2070        } else {
2071            let (end, next_start) = slice_cell_by_display_width(line_trimmed, pos_byte, col.width);
2072            pos_byte = next_start;
2073            end
2074        };
2075        cells.push(line_trimmed[start_byte..end_byte].trim().to_string());
2076        if col_idx + 1 == _columns.len() {
2077            pos_byte = line_trimmed.len();
2078        }
2079    }
2080
2081    cells
2082}
2083
2084/// Emit a grid table row with inline-parsed cells.
2085/// Handles multi-line rows by emitting first line with TABLE_CELL nodes,
2086/// then continuation lines as raw TEXT for losslessness.
2087fn emit_grid_table_row(
2088    builder: &mut GreenNodeBuilder<'static>,
2089    window: &StrippedLines<'_, '_>,
2090    indices: &[usize],
2091    columns: &[GridColumn],
2092    row_kind: SyntaxKind,
2093    config: &ParserOptions,
2094) {
2095    if indices.is_empty() {
2096        return;
2097    }
2098
2099    builder.start_node(row_kind.into());
2100
2101    // Emit first line with TABLE_CELL nodes. The continuation-line container
2102    // prefix (`  > `) is re-emitted as WHITESPACE/BLOCK_QUOTE_MARKER tokens
2103    // inside the row node before the cell text; the returned tail is the
2104    // prefix-stripped line we slice cells from (empty prefix ⇒ raw line).
2105    // Grid table rows look like: "| Cell 1 | Cell 2 | Cell 3 |"
2106    let first_line = window.emit_or_dispatch_tail(builder, indices[0]);
2107    let cell_contents = extract_grid_cells_from_line(first_line, columns);
2108    let (line_without_newline, newline_str) = strip_newline(first_line);
2109    let trimmed = line_without_newline.trim();
2110    let expected_pipe_count = columns.len().saturating_add(1);
2111    let actual_pipe_count = trimmed.chars().filter(|&c| c == '|').count();
2112
2113    // Rows that don't contain all expected column separators (spanning-style rows)
2114    // must be emitted verbatim for losslessness. The first line's prefix was
2115    // already consumed above; emit its tail and each continuation tail.
2116    if actual_pipe_count != expected_pipe_count {
2117        emit_line_tokens(builder, first_line);
2118        for &idx in &indices[1..] {
2119            let tail = window.emit_or_dispatch_tail(builder, idx);
2120            emit_line_tokens(builder, tail);
2121        }
2122        builder.finish_node();
2123        return;
2124    }
2125
2126    // Emit leading whitespace
2127    let leading_ws_len = line_without_newline.len() - line_without_newline.trim_start().len();
2128    if leading_ws_len > 0 {
2129        builder.token(
2130            SyntaxKind::WHITESPACE.into(),
2131            &line_without_newline[..leading_ws_len],
2132        );
2133    }
2134
2135    // Emit leading pipe
2136    if trimmed.starts_with('|') {
2137        builder.token(SyntaxKind::TEXT.into(), "|");
2138    }
2139
2140    // Emit each cell based on fixed column widths from separators
2141    let mut pos_byte = 1usize; // after leading pipe
2142    for (idx, cell_content) in cell_contents.iter().enumerate() {
2143        let part = if idx < columns.len() && pos_byte <= trimmed.len() {
2144            let start_byte = pos_byte;
2145            let end_byte = if idx + 1 == columns.len() && !trimmed.is_empty() {
2146                trimmed.len().saturating_sub(1) // consume to trailing pipe for last column
2147            } else {
2148                let (end, next_start) =
2149                    slice_cell_by_display_width(trimmed, pos_byte, columns[idx].width);
2150                pos_byte = next_start;
2151                end
2152            };
2153            let slice = &trimmed[start_byte..end_byte];
2154            if idx + 1 == columns.len() {
2155                pos_byte = trimmed.len();
2156            }
2157            slice
2158        } else {
2159            ""
2160        };
2161
2162        // Emit leading whitespace in cell
2163        let cell_trimmed = part.trim();
2164        let ws_start_len = part.len() - part.trim_start().len();
2165        if ws_start_len > 0 {
2166            builder.token(SyntaxKind::WHITESPACE.into(), &part[..ws_start_len]);
2167        }
2168
2169        // Emit TABLE_CELL with inline parsing
2170        emit_table_cell(builder, cell_content, config);
2171
2172        // Emit trailing whitespace in cell
2173        let ws_end_start = ws_start_len + cell_trimmed.len();
2174        if ws_end_start < part.len() {
2175            builder.token(SyntaxKind::WHITESPACE.into(), &part[ws_end_start..]);
2176        }
2177
2178        // Emit pipe separator (unless this is the last cell and line doesn't end with |)
2179        if idx < cell_contents.len() - 1 || trimmed.ends_with('|') {
2180            builder.token(SyntaxKind::TEXT.into(), "|");
2181        }
2182    }
2183
2184    // Emit trailing whitespace before newline
2185    let trailing_ws_start = leading_ws_len + trimmed.len();
2186    if trailing_ws_start < line_without_newline.len() {
2187        builder.token(
2188            SyntaxKind::WHITESPACE.into(),
2189            &line_without_newline[trailing_ws_start..],
2190        );
2191    }
2192
2193    // Emit newline
2194    if !newline_str.is_empty() {
2195        builder.token(SyntaxKind::NEWLINE.into(), newline_str);
2196    }
2197
2198    // Emit continuation lines as TEXT for losslessness, re-emitting each
2199    // line's container prefix first.
2200    for &idx in &indices[1..] {
2201        let tail = window.emit_or_dispatch_tail(builder, idx);
2202        emit_line_tokens(builder, tail);
2203    }
2204
2205    builder.finish_node();
2206}
2207
2208/// Try to parse a grid table starting at the given position.
2209/// Returns the number of lines consumed if successful.
2210pub(crate) fn try_parse_grid_table(
2211    window: &StrippedLines<'_, '_>,
2212    builder: &mut GreenNodeBuilder<'static>,
2213    config: &ParserOptions,
2214) -> Option<usize> {
2215    let lines = window.raw();
2216    let start_pos = window.pos();
2217    if start_pos >= lines.len() {
2218        return None;
2219    }
2220
2221    // Grid-border detection reads the stripped view through `UniformStripView`,
2222    // which strips *every* line — including the dispatch line — with the full
2223    // container strip. The strict column-0 check in `try_parse_grid_separator`
2224    // would otherwise reject a `+---+` border sitting at column 0 of a list
2225    // item's inner content if the dispatch line kept its list-indent. With an
2226    // empty prefix the stripped view equals the raw lines. Emission still goes
2227    // through `window.emit_or_dispatch_tail`, which preserves the indent bytes.
2228    // Scans stop at the first blank line, so only a bounded range is stripped.
2229    let view = UniformStripView(window);
2230
2231    // Cheap gate: a grid table's first line is a grid separator (`+---+`/`+===+`),
2232    // unless this is a caption-led table. Table detection runs at every block
2233    // start, so any per-line work for every prose/math paragraph was quadratic
2234    // on large documents. Run the gate on the same `view` the detection uses, so
2235    // it applies inside containers (blockquote/list) too — not just at top level.
2236    if try_parse_grid_separator(view.line(start_pos)).is_none()
2237        && !is_caption_followed_by_table(&view, start_pos)
2238    {
2239        return None;
2240    }
2241
2242    // Check if this line is a caption followed by a table
2243    // If so, the actual table starts after the caption and blank line
2244    let (actual_start, caption_before) = if is_caption_followed_by_table(&view, start_pos) {
2245        let (cap_start, cap_end) = caption_range_starting_at(&view, start_pos)?;
2246        let mut pos = cap_end;
2247        while pos < view.line_count() && view.line(pos).trim().is_empty() {
2248            pos += 1;
2249        }
2250        (pos, Some((cap_start, cap_end)))
2251    } else {
2252        (start_pos, None)
2253    };
2254
2255    if actual_start >= lines.len() {
2256        return None;
2257    }
2258
2259    // First line must be a grid separator
2260    let first_line = view.line(actual_start);
2261    let _columns = try_parse_grid_separator(first_line)?;
2262
2263    // Track table structure
2264    let mut end_pos = actual_start + 1;
2265    let mut found_header_sep = false;
2266    let mut in_footer = false;
2267
2268    // Scan table lines
2269    while end_pos < lines.len() {
2270        let line = view.line(end_pos);
2271
2272        // Check for blank line (table ends)
2273        if line.trim().is_empty() {
2274            break;
2275        }
2276
2277        // Check for separator line
2278        if let Some(sep_cols) = try_parse_grid_separator(line) {
2279            // Check if this is a header separator (=)
2280            if sep_cols.iter().any(|c| c.is_header_separator) {
2281                if !found_header_sep {
2282                    found_header_sep = true;
2283                } else if !in_footer {
2284                    // Second = separator starts footer
2285                    in_footer = true;
2286                }
2287            }
2288            end_pos += 1;
2289            continue;
2290        }
2291
2292        // Check for content row
2293        if is_grid_content_row(line) {
2294            end_pos += 1;
2295            continue;
2296        }
2297
2298        // Not a valid grid table line - table ends
2299        break;
2300    }
2301
2302    // Must have consumed at least 3 lines (top separator, content, bottom separator)
2303    // Or just top + content rows that end with a separator
2304    if end_pos <= actual_start + 1 {
2305        return None;
2306    }
2307
2308    // Last consumed line should be a separator for a well-formed table
2309    // But we'll be lenient and accept tables ending with content rows
2310
2311    // Check for caption before table (only if we didn't already detected it)
2312    let caption_before = caption_before.or_else(|| find_caption_before_table(&view, actual_start));
2313
2314    // Check for caption after table
2315    let caption_after = if caption_before.is_some() {
2316        None
2317    } else {
2318        find_caption_after_table(&view, end_pos)
2319    };
2320
2321    // Build the grid table
2322    builder.start_node(SyntaxKind::GRID_TABLE.into());
2323
2324    // Emit caption before if present
2325    if let Some((cap_start, cap_end)) = caption_before {
2326        emit_table_caption(builder, window, cap_start, cap_end, config);
2327        // Emit blank line between caption and table if present
2328        emit_caption_blank_lines(builder, window, cap_end, actual_start);
2329    }
2330
2331    // Track whether we've passed the header separator
2332    let mut past_header_sep = false;
2333    let mut in_footer_section = false;
2334    // Accumulate ABSOLUTE indices of the lines making up a multi-line row, so
2335    // each line's container prefix can be re-emitted via the window.
2336    let mut current_row_indices: Vec<usize> = Vec::new();
2337    let mut current_row_kind = SyntaxKind::TABLE_HEADER;
2338
2339    // Emit table rows - accumulate multi-line cells
2340    for idx in actual_start..end_pos {
2341        let line = view.line(idx);
2342        if let Some(sep_cols) = try_parse_grid_separator(line) {
2343            // Separator line - emit any accumulated row first
2344            if !current_row_indices.is_empty() {
2345                emit_grid_table_row(
2346                    builder,
2347                    window,
2348                    &current_row_indices,
2349                    &sep_cols,
2350                    current_row_kind,
2351                    config,
2352                );
2353                current_row_indices.clear();
2354            }
2355
2356            let is_header_sep = sep_cols.iter().any(|c| c.is_header_separator);
2357
2358            // Re-emit any continuation-line container prefix (`  > `) as
2359            // WHITESPACE/BLOCK_QUOTE_MARKER tokens before the separator text.
2360            if is_header_sep {
2361                if !past_header_sep {
2362                    // This is the header/body separator
2363                    builder.start_node(SyntaxKind::TABLE_SEPARATOR.into());
2364                    let tail = window.emit_or_dispatch_tail(builder, idx);
2365                    emit_separator_tokens(builder, tail);
2366                    builder.finish_node();
2367                    past_header_sep = true;
2368                } else {
2369                    // Footer separator
2370                    if !in_footer_section {
2371                        in_footer_section = true;
2372                    }
2373                    builder.start_node(SyntaxKind::TABLE_SEPARATOR.into());
2374                    let tail = window.emit_or_dispatch_tail(builder, idx);
2375                    emit_separator_tokens(builder, tail);
2376                    builder.finish_node();
2377                }
2378            } else {
2379                // Regular separator (row boundary)
2380                builder.start_node(SyntaxKind::TABLE_SEPARATOR.into());
2381                let tail = window.emit_or_dispatch_tail(builder, idx);
2382                emit_separator_tokens(builder, tail);
2383                builder.finish_node();
2384            }
2385        } else if is_grid_content_row(line) {
2386            // Content row - accumulate for multi-line cells
2387            current_row_kind = if !past_header_sep && found_header_sep {
2388                SyntaxKind::TABLE_HEADER
2389            } else if in_footer_section {
2390                SyntaxKind::TABLE_FOOTER
2391            } else {
2392                SyntaxKind::TABLE_ROW
2393            };
2394
2395            current_row_indices.push(idx);
2396        }
2397    }
2398
2399    // Emit any remaining accumulated row
2400    if !current_row_indices.is_empty() {
2401        // Use first separator's columns for cell boundaries
2402        if let Some(sep_cols) = try_parse_grid_separator(view.line(actual_start)) {
2403            emit_grid_table_row(
2404                builder,
2405                window,
2406                &current_row_indices,
2407                &sep_cols,
2408                current_row_kind,
2409                config,
2410            );
2411        }
2412    }
2413
2414    // Emit caption after if present
2415    if let Some((cap_start, cap_end)) = caption_after {
2416        emit_caption_blank_lines(builder, window, end_pos, cap_start);
2417        emit_table_caption(builder, window, cap_start, cap_end, config);
2418    }
2419
2420    builder.finish_node(); // GRID_TABLE
2421
2422    // Calculate lines consumed
2423    let table_start = caption_before
2424        .map(|(start, _)| start)
2425        .unwrap_or(actual_start);
2426    let table_end = if let Some((_, cap_end)) = caption_after {
2427        cap_end
2428    } else {
2429        end_pos
2430    };
2431
2432    Some(table_end - table_start)
2433}
2434
2435#[cfg(test)]
2436mod grid_table_tests {
2437    use super::super::container_prefix::ContainerPrefix;
2438    use super::*;
2439
2440    #[test]
2441    fn test_grid_separator_detection() {
2442        assert!(try_parse_grid_separator("+---+---+").is_some());
2443        assert!(try_parse_grid_separator("+===+===+").is_some());
2444        assert!(try_parse_grid_separator("+---------------+---------------+").is_some());
2445        assert!(try_parse_grid_separator("+:---:+").is_some()); // center aligned
2446        assert!(try_parse_grid_separator("not a separator").is_none());
2447        assert!(try_parse_grid_separator("|---|---|").is_none()); // pipe table sep
2448
2449        // A grid border must sit at column 0 of its container content; an
2450        // indented border is not a grid table (matches pandoc, which parses
2451        // an indented `+---+` as a paragraph). Detection runs on the
2452        // container-stripped line, so any remaining leading space disqualifies.
2453        assert!(try_parse_grid_separator(" +---+---+").is_none());
2454        assert!(try_parse_grid_separator("  +---+---+").is_none());
2455        assert!(try_parse_grid_separator("   +===+===+").is_none());
2456    }
2457
2458    #[test]
2459    fn test_grid_header_separator() {
2460        let cols = try_parse_grid_separator("+===+===+").unwrap();
2461        assert!(cols.iter().all(|c| c.is_header_separator));
2462
2463        let cols2 = try_parse_grid_separator("+---+---+").unwrap();
2464        assert!(cols2.iter().all(|c| !c.is_header_separator));
2465    }
2466
2467    #[test]
2468    fn test_grid_content_row_detection() {
2469        assert!(is_grid_content_row("| content | content |"));
2470        assert!(is_grid_content_row("|  |  |"));
2471        assert!(is_grid_content_row("| content +------+"));
2472        assert!(!is_grid_content_row("+---+---+")); // separator, not content
2473        assert!(!is_grid_content_row("no pipes here"));
2474    }
2475
2476    #[test]
2477    fn test_basic_grid_table() {
2478        let input = vec![
2479            "+-------+-------+",
2480            "| Col1  | Col2  |",
2481            "+=======+=======+",
2482            "| A     | B     |",
2483            "+-------+-------+",
2484            "",
2485        ];
2486
2487        let mut builder = GreenNodeBuilder::new();
2488        let prefix = ContainerPrefix::default();
2489        let window = StrippedLines::new(&input, 0, &prefix);
2490        let result = try_parse_grid_table(&window, &mut builder, &ParserOptions::default());
2491
2492        assert!(result.is_some());
2493        assert_eq!(result.unwrap(), 5);
2494    }
2495
2496    #[test]
2497    fn test_grid_table_multirow() {
2498        let input = vec![
2499            "+---------------+---------------+",
2500            "| Fruit         | Advantages    |",
2501            "+===============+===============+",
2502            "| Bananas       | - wrapper     |",
2503            "|               | - color       |",
2504            "+---------------+---------------+",
2505            "| Oranges       | - scurvy      |",
2506            "|               | - tasty       |",
2507            "+---------------+---------------+",
2508            "",
2509        ];
2510
2511        let mut builder = GreenNodeBuilder::new();
2512        let prefix = ContainerPrefix::default();
2513        let window = StrippedLines::new(&input, 0, &prefix);
2514        let result = try_parse_grid_table(&window, &mut builder, &ParserOptions::default());
2515
2516        assert!(result.is_some());
2517        assert_eq!(result.unwrap(), 9);
2518    }
2519
2520    #[test]
2521    fn test_grid_table_with_footer() {
2522        let input = vec![
2523            "+-------+-------+",
2524            "| Fruit | Price |",
2525            "+=======+=======+",
2526            "| Apple | $1.00 |",
2527            "+-------+-------+",
2528            "| Pear  | $1.50 |",
2529            "+=======+=======+",
2530            "| Total | $2.50 |",
2531            "+=======+=======+",
2532            "",
2533        ];
2534
2535        let mut builder = GreenNodeBuilder::new();
2536        let prefix = ContainerPrefix::default();
2537        let window = StrippedLines::new(&input, 0, &prefix);
2538        let result = try_parse_grid_table(&window, &mut builder, &ParserOptions::default());
2539
2540        assert!(result.is_some());
2541        assert_eq!(result.unwrap(), 9);
2542    }
2543
2544    #[test]
2545    fn test_grid_table_headerless() {
2546        let input = vec![
2547            "+-------+-------+",
2548            "| A     | B     |",
2549            "+-------+-------+",
2550            "| C     | D     |",
2551            "+-------+-------+",
2552            "",
2553        ];
2554
2555        let mut builder = GreenNodeBuilder::new();
2556        let prefix = ContainerPrefix::default();
2557        let window = StrippedLines::new(&input, 0, &prefix);
2558        let result = try_parse_grid_table(&window, &mut builder, &ParserOptions::default());
2559
2560        assert!(result.is_some());
2561        assert_eq!(result.unwrap(), 5);
2562    }
2563
2564    #[test]
2565    fn test_grid_table_with_caption_before() {
2566        let input = vec![
2567            ": Sample table",
2568            "",
2569            "+-------+-------+",
2570            "| A     | B     |",
2571            "+=======+=======+",
2572            "| C     | D     |",
2573            "+-------+-------+",
2574            "",
2575        ];
2576
2577        let mut builder = GreenNodeBuilder::new();
2578        let prefix = ContainerPrefix::default();
2579        let window = StrippedLines::new(&input, 2, &prefix);
2580        let result = try_parse_grid_table(&window, &mut builder, &ParserOptions::default());
2581
2582        assert!(result.is_some());
2583        // Should include caption + blank + table
2584        assert_eq!(result.unwrap(), 7);
2585    }
2586
2587    #[test]
2588    fn test_grid_table_with_caption_after() {
2589        let input = vec![
2590            "+-------+-------+",
2591            "| A     | B     |",
2592            "+=======+=======+",
2593            "| C     | D     |",
2594            "+-------+-------+",
2595            "",
2596            "Table: My grid table",
2597            "",
2598        ];
2599
2600        let mut builder = GreenNodeBuilder::new();
2601        let prefix = ContainerPrefix::default();
2602        let window = StrippedLines::new(&input, 0, &prefix);
2603        let result = try_parse_grid_table(&window, &mut builder, &ParserOptions::default());
2604
2605        assert!(result.is_some());
2606        // table + blank + caption
2607        assert_eq!(result.unwrap(), 7);
2608    }
2609}
2610
2611// ============================================================================
2612// Multiline Table Parsing
2613// ============================================================================
2614
2615/// Check if a line is a multiline table separator (continuous dashes).
2616/// Multiline table separators span the full width and are all dashes.
2617/// Returns Some(columns) if valid, None otherwise.
2618fn try_parse_multiline_separator(line: &str) -> Option<Vec<Column>> {
2619    let trimmed = line.trim_start();
2620    let leading_spaces = line.len() - trimmed.len();
2621
2622    // Must have leading spaces <= 3 to not be a code block
2623    if leading_spaces > 3 {
2624        return None;
2625    }
2626
2627    let trimmed = trimmed.trim_end();
2628
2629    // Must be all dashes (continuous line of dashes)
2630    if trimmed.is_empty() || !trimmed.chars().all(|c| c == '-') {
2631        return None;
2632    }
2633
2634    // Must have at least 3 dashes
2635    if trimmed.len() < 3 {
2636        return None;
2637    }
2638
2639    // This is a full-width separator - columns will be determined by column separator lines
2640    Some(vec![Column {
2641        start: leading_spaces,
2642        end: leading_spaces + trimmed.len(),
2643        alignment: Alignment::Default,
2644    }])
2645}
2646
2647/// Check if a line is a column separator line for multiline tables.
2648/// Column separators have dashes with spaces between them to define columns.
2649fn is_column_separator(line: &str) -> bool {
2650    try_parse_table_separator(line).is_some() && !line.contains('*') && !line.contains('_')
2651}
2652
2653fn is_headerless_single_row_without_blank(
2654    lines: &(impl LineView + ?Sized),
2655    row_start: usize,
2656    row_end: usize,
2657    columns: &[Column],
2658) -> bool {
2659    if row_start >= row_end {
2660        return false;
2661    }
2662
2663    if row_end - row_start == 1 {
2664        return false;
2665    }
2666
2667    let Some(last_col) = columns.last() else {
2668        return false;
2669    };
2670
2671    for i in (row_start + 1)..row_end {
2672        let (content, _) = strip_newline(lines.line(i));
2673        let prefix_end = last_col.start.min(content.len());
2674        if !content[..prefix_end].trim().is_empty() {
2675            return false;
2676        }
2677    }
2678
2679    true
2680}
2681
2682/// Try to parse a multiline table starting at the given position.
2683/// Returns the number of lines consumed if successful.
2684pub(crate) fn try_parse_multiline_table(
2685    window: &StrippedLines<'_, '_>,
2686    builder: &mut GreenNodeBuilder<'static>,
2687    config: &ParserOptions,
2688) -> Option<usize> {
2689    let lines = window.raw();
2690    let start_pos = window.pos();
2691    if start_pos >= lines.len() {
2692        return None;
2693    }
2694
2695    // Cheap gate: a multiline table's first line is either a full-width dash
2696    // separator or a column separator. Table detection runs at every block
2697    // start, so any per-line work for every paragraph that can't begin a
2698    // multiline table was quadratic on large documents. Peek just the dispatch
2699    // line via `strip_at` and bail before any further scanning.
2700    let first_line = window.strip_at(start_pos);
2701
2702    // First line can be either:
2703    // 1. A full-width dash separator (for tables with headers)
2704    // 2. A column separator (for headerless tables)
2705    let is_full_width_start = try_parse_multiline_separator(first_line).is_some();
2706    let is_column_sep_start = !is_full_width_start && is_column_separator(first_line);
2707    if !is_full_width_start && !is_column_sep_start {
2708        return None;
2709    }
2710
2711    // Detection scans read the container-prefix-stripped view lazily through the
2712    // window (see `LineView`) so a multiline table nested in `list → blockquote`
2713    // (e.g. `- > ----`) has its `  > ` prefix removed before the
2714    // separator/blank-row shape checks. The interior `>`-only row then strips to
2715    // `""` and registers as a blank row separator. With an empty prefix the
2716    // stripped view equals the raw lines. Scans stop at the first blank/closing
2717    // line, so only a bounded range is stripped. Emission re-emits the prefix
2718    // bytes as tokens via the window; captions read raw `lines`.
2719    let headerless_columns = if is_column_sep_start {
2720        try_parse_table_separator(window.line(start_pos))
2721    } else {
2722        None
2723    };
2724
2725    // A headerless opening with at least one multi-dash column run is a genuine
2726    // table border (`------  ------`), as opposed to a spaced thematic break
2727    // whose "columns" are all single dashes (`- - - - -`). Only a genuine border
2728    // may close on a bare continuous dash run (the closer broadening below);
2729    // otherwise `- - - - -` would swallow following blocks up to the next
2730    // thematic break. A column-separator closer still works for either shape.
2731    let opening_has_wide_column = headerless_columns
2732        .as_deref()
2733        .is_some_and(|cols| cols.iter().any(|col| col.end - col.start >= 2));
2734
2735    // Look ahead to find the structure
2736    let mut pos = start_pos + 1;
2737    let mut found_column_sep = is_column_sep_start; // Already found if headerless
2738    let mut column_sep_pos = if is_column_sep_start { start_pos } else { 0 };
2739    let mut has_header = false;
2740    let mut found_blank_line = false;
2741    let mut found_closing_sep = false;
2742    let mut content_line_count = 0usize;
2743
2744    // Scan for header section and column separator
2745    while pos < lines.len() {
2746        let line = window.line(pos);
2747
2748        // Check for column separator (defines columns) - only if we started with full-width
2749        if is_full_width_start && is_column_separator(line) && !found_column_sep {
2750            found_column_sep = true;
2751            column_sep_pos = pos;
2752            has_header = pos > start_pos + 1; // Has header if there's content before column sep
2753            pos += 1;
2754            continue;
2755        }
2756
2757        // Check for blank line (row separator in body)
2758        if line.trim().is_empty() {
2759            found_blank_line = true;
2760            pos += 1;
2761            // Check if next line is a valid closing separator for this table shape.
2762            if pos < lines.len() {
2763                let next = window.line(pos);
2764                let is_valid_closer = if is_full_width_start {
2765                    try_parse_multiline_separator(next).is_some()
2766                } else {
2767                    // A headerless table may close with a column separator
2768                    // (`---- ----`) or a single continuous dash run (`--------`).
2769                    // The latter is rejected by `is_column_separator` (one dash
2770                    // group reads as a thematic rule), so accept it via the
2771                    // multiline-separator check too — but only for a genuine
2772                    // border (see `opening_has_wide_column`). Matches pandoc,
2773                    // which ends the table on either shape.
2774                    is_column_separator(next)
2775                        || (opening_has_wide_column
2776                            && try_parse_multiline_separator(next).is_some())
2777                };
2778                if is_valid_closer {
2779                    found_closing_sep = true;
2780                    pos += 1; // Include the closing separator
2781                    break;
2782                }
2783            }
2784            continue;
2785        }
2786
2787        // Check for closing full-width dashes (only for full-width-start tables).
2788        if is_full_width_start && try_parse_multiline_separator(line).is_some() {
2789            found_closing_sep = true;
2790            pos += 1;
2791            break;
2792        }
2793
2794        // Check for closing column separator (for headerless tables)
2795        if is_column_sep_start && is_column_separator(line) && content_line_count > 0 {
2796            found_closing_sep = true;
2797            pos += 1;
2798            break;
2799        }
2800
2801        // Content row
2802        content_line_count += 1;
2803        pos += 1;
2804    }
2805
2806    // Must have found a column separator to be a valid multiline table
2807    if !found_column_sep {
2808        return None;
2809    }
2810
2811    // A blank line between rows is one way to tell a multiline table from a
2812    // simple one, but not the only one. A full-width top border (the
2813    // `is_full_width_start` case) already distinguishes a multiline table from
2814    // a simple table, so pandoc accepts it even when every row is a single line
2815    // with no interior blanks; the required column separator and closing border
2816    // (checked above and below) keep a bare thematic break from matching. Only
2817    // the headerless, column-separator-started shape still needs the
2818    // single-row guard.
2819    if !found_blank_line && is_column_sep_start {
2820        let columns = headerless_columns.as_deref()?;
2821        if !is_headerless_single_row_without_blank(window, start_pos + 1, pos - 1, columns) {
2822            return None;
2823        }
2824    }
2825
2826    // Must have a closing separator
2827    if !found_closing_sep {
2828        return None;
2829    }
2830
2831    // Must have consumed more than just the opening separator
2832    if pos <= start_pos + 2 {
2833        return None;
2834    }
2835
2836    let end_pos = pos;
2837
2838    // Extract column boundaries from the separator line
2839    let columns = try_parse_table_separator(window.line(column_sep_pos))
2840        .expect("Column separator must be valid");
2841
2842    // Check for caption before table
2843    let caption_before = find_caption_before_table(window, start_pos);
2844
2845    // Check for caption after table
2846    let caption_after = if caption_before.is_some() {
2847        None
2848    } else {
2849        find_caption_after_table(window, end_pos)
2850    };
2851
2852    // Build the multiline table
2853    builder.start_node(SyntaxKind::MULTILINE_TABLE.into());
2854
2855    // Emit caption before if present
2856    if let Some((cap_start, cap_end)) = caption_before {
2857        emit_table_caption(builder, window, cap_start, cap_end, config);
2858        // Emit blank line between caption and table if present
2859        emit_caption_blank_lines(builder, window, cap_end, start_pos);
2860    }
2861
2862    // Emit opening separator. The dispatch line's prefix was already consumed
2863    // by core (`dispatch_tail`); a non-dispatch start (caption-before case)
2864    // re-emits its `  > ` prefix via `emit_prefix_at`.
2865    builder.start_node(SyntaxKind::TABLE_SEPARATOR.into());
2866    let tail = window.emit_or_dispatch_tail(builder, start_pos);
2867    emit_separator_tokens(builder, tail);
2868    builder.finish_node();
2869
2870    // Track state for emitting. Accumulate ABSOLUTE indices of the lines making
2871    // up a multi-line row so each line's container prefix can be re-emitted via
2872    // the window.
2873    let mut in_header = has_header;
2874    let mut current_row_indices: Vec<usize> = Vec::new();
2875
2876    for i in (start_pos + 1)..end_pos {
2877        let line = window.line(i);
2878        // Column separator (header/body divider)
2879        if i == column_sep_pos {
2880            // Emit any accumulated header lines
2881            if !current_row_indices.is_empty() {
2882                emit_multiline_table_row(
2883                    builder,
2884                    window,
2885                    &current_row_indices,
2886                    &columns,
2887                    SyntaxKind::TABLE_HEADER,
2888                    config,
2889                );
2890                current_row_indices.clear();
2891            }
2892
2893            builder.start_node(SyntaxKind::TABLE_SEPARATOR.into());
2894            let tail = window.emit_or_dispatch_tail(builder, i);
2895            emit_separator_tokens(builder, tail);
2896            builder.finish_node();
2897            in_header = false;
2898            continue;
2899        }
2900
2901        // Closing separator (full-width or column separator at end)
2902        if try_parse_multiline_separator(line).is_some() || is_column_separator(line) {
2903            // Emit any accumulated row lines
2904            if !current_row_indices.is_empty() {
2905                let kind = if in_header {
2906                    SyntaxKind::TABLE_HEADER
2907                } else {
2908                    SyntaxKind::TABLE_ROW
2909                };
2910                emit_multiline_table_row(
2911                    builder,
2912                    window,
2913                    &current_row_indices,
2914                    &columns,
2915                    kind,
2916                    config,
2917                );
2918                current_row_indices.clear();
2919            }
2920
2921            builder.start_node(SyntaxKind::TABLE_SEPARATOR.into());
2922            let tail = window.emit_or_dispatch_tail(builder, i);
2923            emit_separator_tokens(builder, tail);
2924            builder.finish_node();
2925            continue;
2926        }
2927
2928        // Blank line (row separator)
2929        if line.trim().is_empty() {
2930            // Emit accumulated row
2931            if !current_row_indices.is_empty() {
2932                let kind = if in_header {
2933                    SyntaxKind::TABLE_HEADER
2934                } else {
2935                    SyntaxKind::TABLE_ROW
2936                };
2937                emit_multiline_table_row(
2938                    builder,
2939                    window,
2940                    &current_row_indices,
2941                    &columns,
2942                    kind,
2943                    config,
2944                );
2945                current_row_indices.clear();
2946            }
2947
2948            // Re-emit the interior `>`-only separator row's container prefix
2949            // (`  > `) inside the BLANK_LINE node so it round-trips losslessly.
2950            builder.start_node(SyntaxKind::BLANK_LINE.into());
2951            let tail = window.emit_or_dispatch_tail(builder, i);
2952            builder.token(SyntaxKind::BLANK_LINE.into(), tail);
2953            builder.finish_node();
2954            continue;
2955        }
2956
2957        // Content line - accumulate for current row
2958        current_row_indices.push(i);
2959    }
2960
2961    // Emit any remaining accumulated lines
2962    if !current_row_indices.is_empty() {
2963        let kind = if in_header {
2964            SyntaxKind::TABLE_HEADER
2965        } else {
2966            SyntaxKind::TABLE_ROW
2967        };
2968        emit_multiline_table_row(
2969            builder,
2970            window,
2971            &current_row_indices,
2972            &columns,
2973            kind,
2974            config,
2975        );
2976    }
2977
2978    // Emit caption after if present
2979    if let Some((cap_start, cap_end)) = caption_after {
2980        emit_caption_blank_lines(builder, window, end_pos, cap_start);
2981        emit_table_caption(builder, window, cap_start, cap_end, config);
2982    }
2983
2984    builder.finish_node(); // MultilineTable
2985
2986    // Calculate lines consumed
2987    let table_start = caption_before.map(|(start, _)| start).unwrap_or(start_pos);
2988    let table_end = if let Some((_, cap_end)) = caption_after {
2989        cap_end
2990    } else {
2991        end_pos
2992    };
2993
2994    Some(table_end - table_start)
2995}
2996
2997/// Extract cell contents from first line only (for CST emission).
2998/// Multi-line content will be in continuation TEXT tokens.
2999fn extract_first_line_cell_contents(line: &str, columns: &[Column]) -> Vec<String> {
3000    let (line_content, _) = strip_newline(line);
3001    let mut cells = Vec::new();
3002
3003    for column in columns.iter() {
3004        let column_start = column_offset_to_byte_index(line_content, column.start);
3005        let column_end = column_offset_to_byte_index(line_content, column.end);
3006
3007        // Extract FULL text for this column (including whitespace)
3008        let cell_text = if column_start < column_end {
3009            &line_content[column_start..column_end]
3010        } else if column_start < line_content.len() {
3011            &line_content[column_start..]
3012        } else {
3013            ""
3014        };
3015
3016        cells.push(cell_text.to_string());
3017    }
3018
3019    cells
3020}
3021
3022/// Emit a multiline table row with inline parsing (Phase 7.1).
3023///
3024/// `indices` are ABSOLUTE line indices into the window's raw buffer; each
3025/// physical line re-emits its container prefix (`  > `) via the window before
3026/// its content. With an empty prefix the tails equal the raw lines, so emission
3027/// is byte-identical to the pre-window path.
3028fn emit_multiline_table_row(
3029    builder: &mut GreenNodeBuilder<'static>,
3030    window: &StrippedLines<'_, '_>,
3031    indices: &[usize],
3032    columns: &[Column],
3033    kind: SyntaxKind,
3034    config: &ParserOptions,
3035) {
3036    if indices.is_empty() {
3037        return;
3038    }
3039
3040    builder.start_node(kind.into());
3041
3042    // Emit the first line's container prefix as tokens, then slice cells from
3043    // the prefix-stripped tail (for CST losslessness, only the first physical
3044    // line is parsed into cells; continuation lines stay verbatim TEXT).
3045    let first_line = window.emit_or_dispatch_tail(builder, indices[0]);
3046    let cell_contents = extract_first_line_cell_contents(first_line, columns);
3047    let (trimmed, newline_str) = strip_newline(first_line);
3048    let mut current_pos = 0;
3049
3050    for (col_idx, column) in columns.iter().enumerate() {
3051        let cell_text = &cell_contents[col_idx];
3052        let cell_start = column_offset_to_byte_index(trimmed, column.start);
3053        let cell_end = column_offset_to_byte_index(trimmed, column.end);
3054
3055        // Emit whitespace before cell
3056        if current_pos < cell_start {
3057            builder.token(
3058                SyntaxKind::WHITESPACE.into(),
3059                &trimmed[current_pos..cell_start],
3060            );
3061        }
3062
3063        // Emit cell with inline parsing (first line content only)
3064        emit_table_cell(builder, cell_text, config);
3065
3066        current_pos = cell_end;
3067    }
3068
3069    // Emit trailing whitespace
3070    if current_pos < trimmed.len() {
3071        builder.token(SyntaxKind::WHITESPACE.into(), &trimmed[current_pos..]);
3072    }
3073
3074    // Emit newline
3075    if !newline_str.is_empty() {
3076        builder.token(SyntaxKind::NEWLINE.into(), newline_str);
3077    }
3078
3079    // Emit continuation lines as TEXT to preserve exact line structure,
3080    // re-emitting each line's container prefix first.
3081    for &idx in &indices[1..] {
3082        let tail = window.emit_or_dispatch_tail(builder, idx);
3083        emit_line_tokens(builder, tail);
3084    }
3085
3086    builder.finish_node();
3087}
3088
3089#[cfg(test)]
3090mod multiline_table_tests {
3091    use super::super::container_prefix::ContainerPrefix;
3092    use super::*;
3093    use crate::syntax::SyntaxNode;
3094
3095    #[test]
3096    fn test_multiline_separator_detection() {
3097        assert!(
3098            try_parse_multiline_separator(
3099                "-------------------------------------------------------------"
3100            )
3101            .is_some()
3102        );
3103        assert!(try_parse_multiline_separator("---").is_some());
3104        assert!(try_parse_multiline_separator("  -----").is_some()); // with leading spaces
3105        assert!(try_parse_multiline_separator("--").is_none()); // too short
3106        assert!(try_parse_multiline_separator("--- ---").is_none()); // has spaces
3107        assert!(try_parse_multiline_separator("+---+").is_none()); // grid separator
3108    }
3109
3110    #[test]
3111    fn test_basic_multiline_table() {
3112        let input = vec![
3113            "-------------------------------------------------------------",
3114            " Centered   Default           Right Left",
3115            "  Header    Aligned         Aligned Aligned",
3116            "----------- ------- --------------- -------------------------",
3117            "   First    row                12.0 Example of a row that",
3118            "                                    spans multiple lines.",
3119            "",
3120            "  Second    row                 5.0 Here's another one.",
3121            "-------------------------------------------------------------",
3122            "",
3123        ];
3124
3125        let mut builder = GreenNodeBuilder::new();
3126        let prefix = ContainerPrefix::default();
3127        let window = StrippedLines::new(&input, 0, &prefix);
3128        let result = try_parse_multiline_table(&window, &mut builder, &ParserOptions::default());
3129
3130        assert!(result.is_some());
3131        assert_eq!(result.unwrap(), 9);
3132    }
3133
3134    #[test]
3135    fn test_multiline_table_headerless() {
3136        let input = vec![
3137            "----------- ------- --------------- -------------------------",
3138            "   First    row                12.0 Example of a row that",
3139            "                                    spans multiple lines.",
3140            "",
3141            "  Second    row                 5.0 Here's another one.",
3142            "----------- ------- --------------- -------------------------",
3143            "",
3144        ];
3145
3146        let mut builder = GreenNodeBuilder::new();
3147        let prefix = ContainerPrefix::default();
3148        let window = StrippedLines::new(&input, 0, &prefix);
3149        let result = try_parse_multiline_table(&window, &mut builder, &ParserOptions::default());
3150
3151        assert!(result.is_some());
3152        assert_eq!(result.unwrap(), 6);
3153    }
3154
3155    #[test]
3156    fn test_multiline_table_headerless_single_line_is_not_multiline() {
3157        let input = vec![
3158            "-------     ------ ----------   -------",
3159            "     12     12        12             12",
3160            "-------     ------ ----------   -------",
3161            "",
3162            "Not part of table.",
3163            "",
3164        ];
3165
3166        let mut builder = GreenNodeBuilder::new();
3167        let prefix = ContainerPrefix::default();
3168        let window = StrippedLines::new(&input, 0, &prefix);
3169        let result = try_parse_multiline_table(&window, &mut builder, &ParserOptions::default());
3170
3171        assert!(result.is_none());
3172    }
3173
3174    #[test]
3175    fn test_multiline_table_headerless_single_row_continuation_without_blank_line() {
3176        let input = vec![
3177            "----------  ---------  -----------  ---------------------------",
3178            "   First    row               12.0  Example of a row that spans",
3179            "                                    multiple lines.",
3180            "----------  ---------  -----------  ---------------------------",
3181            "",
3182        ];
3183
3184        let mut builder = GreenNodeBuilder::new();
3185        let prefix = ContainerPrefix::default();
3186        let window = StrippedLines::new(&input, 0, &prefix);
3187        let result = try_parse_multiline_table(&window, &mut builder, &ParserOptions::default());
3188
3189        assert!(result.is_some());
3190        assert_eq!(result.unwrap(), 4);
3191    }
3192
3193    #[test]
3194    fn test_multiline_table_with_caption() {
3195        let input = vec![
3196            "-------------------------------------------------------------",
3197            " Col1       Col2",
3198            "----------- -------",
3199            "   A        B",
3200            "",
3201            "-------------------------------------------------------------",
3202            "",
3203            "Table: Here's the caption.",
3204            "",
3205        ];
3206
3207        let mut builder = GreenNodeBuilder::new();
3208        let prefix = ContainerPrefix::default();
3209        let window = StrippedLines::new(&input, 0, &prefix);
3210        let result = try_parse_multiline_table(&window, &mut builder, &ParserOptions::default());
3211
3212        assert!(result.is_some());
3213        // table (6 lines) + blank + caption
3214        assert_eq!(result.unwrap(), 8);
3215    }
3216
3217    #[test]
3218    fn test_multiline_table_single_row() {
3219        let input = vec![
3220            "---------------------------------------------",
3221            " Header1    Header2",
3222            "----------- -----------",
3223            "   Data     More data",
3224            "",
3225            "---------------------------------------------",
3226            "",
3227        ];
3228
3229        let mut builder = GreenNodeBuilder::new();
3230        let prefix = ContainerPrefix::default();
3231        let window = StrippedLines::new(&input, 0, &prefix);
3232        let result = try_parse_multiline_table(&window, &mut builder, &ParserOptions::default());
3233
3234        assert!(result.is_some());
3235        assert_eq!(result.unwrap(), 6);
3236    }
3237
3238    #[test]
3239    fn test_headerless_multiline_table_does_not_close_on_full_width_rule() {
3240        let input = vec![
3241            "- - - - -",
3242            "Third section with underscores.",
3243            "",
3244            "_____",
3245            "",
3246            "> Quote before rule",
3247            ">",
3248            "> ***",
3249            ">",
3250            "> Quote after rule",
3251            "",
3252            "Final paragraph.",
3253            "",
3254            "Here's a horizontal rule:",
3255            "",
3256            "---",
3257            "Text directly after the horizontal rule.",
3258            "",
3259        ];
3260
3261        let mut builder = GreenNodeBuilder::new();
3262        let prefix = ContainerPrefix::default();
3263        let window = StrippedLines::new(&input, 0, &prefix);
3264        let result = try_parse_multiline_table(&window, &mut builder, &ParserOptions::default());
3265
3266        assert!(result.is_none());
3267    }
3268
3269    #[test]
3270    fn test_not_multiline_table() {
3271        // Simple table should not be parsed as multiline
3272        let input = vec![
3273            "  Right     Left     Center     Default",
3274            "-------     ------ ----------   -------",
3275            "     12     12        12            12",
3276            "",
3277        ];
3278
3279        let mut builder = GreenNodeBuilder::new();
3280        let prefix = ContainerPrefix::default();
3281        let window = StrippedLines::new(&input, 0, &prefix);
3282        let result = try_parse_multiline_table(&window, &mut builder, &ParserOptions::default());
3283
3284        // Should not parse because first line isn't a full-width separator
3285        assert!(result.is_none());
3286    }
3287
3288    // Phase 7.1: Unit tests for emit_table_cell() helper
3289    #[test]
3290    fn test_emit_table_cell_plain_text() {
3291        let mut builder = GreenNodeBuilder::new();
3292        emit_table_cell(&mut builder, "Cell", &ParserOptions::default());
3293        let green = builder.finish();
3294        let node = SyntaxNode::new_root(green);
3295
3296        assert_eq!(node.kind(), SyntaxKind::TABLE_CELL);
3297        assert_eq!(node.text(), "Cell");
3298
3299        // Should have TEXT child
3300        let children: Vec<_> = node.children_with_tokens().collect();
3301        assert_eq!(children.len(), 1);
3302        assert_eq!(children[0].kind(), SyntaxKind::TEXT);
3303    }
3304
3305    #[test]
3306    fn test_emit_table_cell_with_emphasis() {
3307        let mut builder = GreenNodeBuilder::new();
3308        emit_table_cell(&mut builder, "*italic*", &ParserOptions::default());
3309        let green = builder.finish();
3310        let node = SyntaxNode::new_root(green);
3311
3312        assert_eq!(node.kind(), SyntaxKind::TABLE_CELL);
3313        assert_eq!(node.text(), "*italic*");
3314
3315        // Should have EMPHASIS child
3316        let children: Vec<_> = node.children().collect();
3317        assert_eq!(children.len(), 1);
3318        assert_eq!(children[0].kind(), SyntaxKind::EMPHASIS);
3319    }
3320
3321    #[test]
3322    fn test_emit_table_cell_with_code() {
3323        let mut builder = GreenNodeBuilder::new();
3324        emit_table_cell(&mut builder, "`code`", &ParserOptions::default());
3325        let green = builder.finish();
3326        let node = SyntaxNode::new_root(green);
3327
3328        assert_eq!(node.kind(), SyntaxKind::TABLE_CELL);
3329        assert_eq!(node.text(), "`code`");
3330
3331        // Should have CODE_SPAN child
3332        let children: Vec<_> = node.children().collect();
3333        assert_eq!(children.len(), 1);
3334        assert_eq!(children[0].kind(), SyntaxKind::INLINE_CODE);
3335    }
3336
3337    #[test]
3338    fn test_emit_table_cell_with_link() {
3339        let mut builder = GreenNodeBuilder::new();
3340        emit_table_cell(&mut builder, "[text](url)", &ParserOptions::default());
3341        let green = builder.finish();
3342        let node = SyntaxNode::new_root(green);
3343
3344        assert_eq!(node.kind(), SyntaxKind::TABLE_CELL);
3345        assert_eq!(node.text(), "[text](url)");
3346
3347        // Should have LINK child
3348        let children: Vec<_> = node.children().collect();
3349        assert_eq!(children.len(), 1);
3350        assert_eq!(children[0].kind(), SyntaxKind::LINK);
3351    }
3352
3353    #[test]
3354    fn test_emit_table_cell_with_strong() {
3355        let mut builder = GreenNodeBuilder::new();
3356        emit_table_cell(&mut builder, "**bold**", &ParserOptions::default());
3357        let green = builder.finish();
3358        let node = SyntaxNode::new_root(green);
3359
3360        assert_eq!(node.kind(), SyntaxKind::TABLE_CELL);
3361        assert_eq!(node.text(), "**bold**");
3362
3363        // Should have STRONG child
3364        let children: Vec<_> = node.children().collect();
3365        assert_eq!(children.len(), 1);
3366        assert_eq!(children[0].kind(), SyntaxKind::STRONG);
3367    }
3368
3369    #[test]
3370    fn test_emit_table_cell_mixed_inline() {
3371        let mut builder = GreenNodeBuilder::new();
3372        emit_table_cell(
3373            &mut builder,
3374            "Text **bold** and `code`",
3375            &ParserOptions::default(),
3376        );
3377        let green = builder.finish();
3378        let node = SyntaxNode::new_root(green);
3379
3380        assert_eq!(node.kind(), SyntaxKind::TABLE_CELL);
3381        assert_eq!(node.text(), "Text **bold** and `code`");
3382
3383        // Should have multiple children: TEXT, STRONG, TEXT, CODE_SPAN
3384        let children: Vec<_> = node.children_with_tokens().collect();
3385        assert!(children.len() >= 4);
3386
3387        // Check some expected types
3388        assert_eq!(children[0].kind(), SyntaxKind::TEXT);
3389        assert_eq!(children[1].kind(), SyntaxKind::STRONG);
3390    }
3391
3392    #[test]
3393    fn test_emit_table_cell_empty() {
3394        let mut builder = GreenNodeBuilder::new();
3395        emit_table_cell(&mut builder, "", &ParserOptions::default());
3396        let green = builder.finish();
3397        let node = SyntaxNode::new_root(green);
3398
3399        assert_eq!(node.kind(), SyntaxKind::TABLE_CELL);
3400        assert_eq!(node.text(), "");
3401
3402        // Empty cell should have no children
3403        let children: Vec<_> = node.children_with_tokens().collect();
3404        assert_eq!(children.len(), 0);
3405    }
3406
3407    #[test]
3408    fn test_emit_table_cell_escaped_pipe() {
3409        let mut builder = GreenNodeBuilder::new();
3410        emit_table_cell(&mut builder, r"A \| B", &ParserOptions::default());
3411        let green = builder.finish();
3412        let node = SyntaxNode::new_root(green);
3413
3414        assert_eq!(node.kind(), SyntaxKind::TABLE_CELL);
3415        // The escaped pipe should be preserved
3416        assert_eq!(node.text(), r"A \| B");
3417    }
3418}