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