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 a closing
207/// `TABLE_SEPARATOR`.
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, has_closer) = if single_column_here {
852        let end_pos = find_single_column_table_end(window, start_pos + 1)?;
853        (start_pos, end_pos, true)
854    } else {
855        let separator_pos = find_separator_line(window, start_pos)?;
856        // Headerless (separator on the dispatch line): pandoc demands the
857        // closing dash line, same as the single-column path above. With a
858        // header the table may end at a blank line or EOF instead.
859        let headerless = separator_pos == start_pos;
860        let (end_pos, has_closer) = find_table_end(window, separator_pos + 1, headerless)?;
861        (separator_pos, end_pos, has_closer)
862    };
863    log::trace!("  found separator at line {}", separator_pos + 1);
864
865    let separator_line = window.line(separator_pos);
866    let mut columns = if single_column_here {
867        parse_single_dash_run(separator_line)?
868    } else {
869        try_parse_table_separator(separator_line)?
870    };
871
872    // Determine if there's a header (separator not at start)
873    let has_header = separator_pos > start_pos;
874    let header_line = if has_header {
875        Some(window.line(separator_pos - 1))
876    } else {
877        None
878    };
879
880    // Determine alignments
881    determine_alignments(&mut columns, separator_line, header_line);
882
883    // Must have at least one data row (excluding the closing dash line):
884    // pandoc reads header + separator + closer as a paragraph, not an
885    // empty table.
886    let data_rows = end_pos - separator_pos - 1 - usize::from(has_closer);
887
888    if data_rows == 0 {
889        return None;
890    }
891
892    // Check for caption before table
893    let caption_before = find_caption_before_table(window, start_pos);
894
895    // Check for caption after table
896    let caption_after = if caption_before.is_some() {
897        None
898    } else {
899        find_caption_after_table(window, end_pos)
900    };
901
902    // Build the table
903    builder.start_node(SyntaxKind::SIMPLE_TABLE.into());
904
905    // Emit caption before if present
906    if let Some((cap_start, cap_end)) = caption_before {
907        emit_table_caption(builder, window, cap_start, cap_end, config);
908        // Emit blank line between caption and table if present
909        emit_caption_blank_lines(builder, window, cap_end, start_pos);
910    }
911
912    // Emit header if present. On the dispatch line the core already emitted
913    // the container prefix; only continuation rows re-emit it (via the window
914    // inside `emit_table_row`).
915    if has_header {
916        emit_table_row(
917            builder,
918            window,
919            separator_pos - 1,
920            &columns,
921            SyntaxKind::TABLE_HEADER,
922            config,
923        );
924    }
925
926    // Emit separator, re-emitting any continuation-line container prefix
927    // (`  > `) as WHITESPACE/BLOCK_QUOTE_MARKER tokens before the row text.
928    builder.start_node(SyntaxKind::TABLE_SEPARATOR.into());
929    let separator_tail = window.emit_or_dispatch_tail(builder, separator_pos);
930    emit_separator_tokens(builder, separator_tail);
931    builder.finish_node();
932
933    // Emit data rows (always continuation lines); the closing dash line, when
934    // present, is a separator, not a row.
935    let rows_end = if has_closer { end_pos - 1 } else { end_pos };
936    for idx in (separator_pos + 1)..rows_end {
937        emit_table_row(
938            builder,
939            window,
940            idx,
941            &columns,
942            SyntaxKind::TABLE_ROW,
943            config,
944        );
945    }
946
947    if has_closer {
948        builder.start_node(SyntaxKind::TABLE_SEPARATOR.into());
949        let closer_tail = window.emit_or_dispatch_tail(builder, end_pos - 1);
950        emit_separator_tokens(builder, closer_tail);
951        builder.finish_node();
952    }
953
954    // Emit caption after if present
955    if let Some((cap_start, cap_end)) = caption_after {
956        // Emit blank line before caption if needed
957        emit_caption_blank_lines(builder, window, end_pos, cap_start);
958        emit_table_caption(builder, window, cap_start, cap_end, config);
959    }
960
961    builder.finish_node(); // SimpleTable
962
963    // Calculate lines consumed (including captions)
964    let table_start = if let Some((cap_start, _)) = caption_before {
965        cap_start
966    } else if has_header {
967        separator_pos - 1
968    } else {
969        separator_pos
970    };
971
972    let table_end = if let Some((_, cap_end)) = caption_after {
973        cap_end
974    } else {
975        end_pos
976    };
977
978    let lines_consumed = table_end - table_start;
979
980    Some(lines_consumed)
981}
982
983/// Find the position of a separator line starting from pos.
984fn find_separator_line(lines: &(impl LineView + ?Sized), start_pos: usize) -> Option<usize> {
985    log::trace!("  find_separator_line from line {}", start_pos + 1);
986
987    // Check first line
988    log::trace!("    checking first line: {:?}", lines.line(start_pos));
989    if try_parse_table_separator(lines.line(start_pos)).is_some() {
990        log::trace!("    separator found at first line");
991        return Some(start_pos);
992    }
993
994    // Check second line (for table with header)
995    if start_pos + 1 < lines.line_count()
996        && !lines.line(start_pos).trim().is_empty()
997        && try_parse_table_separator(lines.line(start_pos + 1)).is_some()
998    {
999        return Some(start_pos + 1);
1000    }
1001    None
1002}
1003
1004/// Find where the table ends: a closing separator line, or (unless
1005/// `require_closer`) the first blank line or end of input. Headerless tables
1006/// pass `require_closer` because pandoc rejects them without the closing
1007/// dash line (the opener is a horizontal rule instead). Returns
1008/// `(end_pos, has_closer)` where `has_closer` marks that `end_pos - 1` is a
1009/// closing dash line the caller must emit as a `TABLE_SEPARATOR`.
1010fn find_table_end(
1011    lines: &(impl LineView + ?Sized),
1012    start_pos: usize,
1013    require_closer: bool,
1014) -> Option<(usize, bool)> {
1015    let mut saw_row = false;
1016    for i in start_pos..lines.line_count() {
1017        if lines.line(i).trim().is_empty() {
1018            return (!require_closer).then_some((i, false));
1019        }
1020        // Check if this could be a closing separator (next line blank or EOF)
1021        if try_parse_table_separator(lines.line(i)).is_some()
1022            && (i + 1 >= lines.line_count() || lines.line(i + 1).trim().is_empty())
1023        {
1024            // Headerless: two adjacent separator lines are two horizontal
1025            // rules, not an empty table.
1026            if require_closer && !saw_row {
1027                return None;
1028            }
1029            return Some((i + 1, true));
1030        }
1031        saw_row = true;
1032    }
1033    (!require_closer).then_some((lines.line_count(), false))
1034}
1035
1036/// Emit a table row (header or data row) with inline-parsed cells for simple tables.
1037/// Uses column boundaries from the separator line to extract cells.
1038fn emit_table_row(
1039    builder: &mut GreenNodeBuilder<'static>,
1040    window: &StrippedLines<'_, '_>,
1041    abs_idx: usize,
1042    columns: &[Column],
1043    row_kind: SyntaxKind,
1044    config: &ParserOptions,
1045) {
1046    builder.start_node(row_kind.into());
1047
1048    // On continuation lines the leading `  > ` prefix is re-emitted as
1049    // WHITESPACE/BLOCK_QUOTE_MARKER tokens inside the row node and the
1050    // stripped tail returned; the dispatch line just strips its (already
1051    // core-emitted) prefix. Empty prefix ⇒ the raw line.
1052    let line = window.emit_or_dispatch_tail(builder, abs_idx);
1053
1054    let (line_without_newline, newline_str) = strip_newline(line);
1055
1056    // Emit leading whitespace if present
1057    let trimmed = line_without_newline.trim_start();
1058    let leading_ws_len = line_without_newline.len() - line_without_newline.trim_start().len();
1059    if leading_ws_len > 0 {
1060        builder.token(
1061            SyntaxKind::WHITESPACE.into(),
1062            &line_without_newline[..leading_ws_len],
1063        );
1064    }
1065
1066    // Track where we are in the line (for losslessness)
1067    let mut current_pos = 0;
1068
1069    // Extract and emit cells based on column boundaries
1070    for (i, col) in columns.iter().enumerate() {
1071        // Calculate actual positions in the trimmed line (accounting for leading whitespace)
1072        let cell_start = if col.start >= leading_ws_len {
1073            column_offset_to_byte_index(trimmed, col.start - leading_ws_len)
1074        } else {
1075            0
1076        };
1077
1078        // A column spans from its own start to the start of the next column
1079        // (the inter-column gap belongs to the left column); the last column
1080        // runs to end-of-line. Ending the slice at the dash-run end instead
1081        // would split cell text that overruns a short dash run into the cell
1082        // plus a bogus WHITESPACE token.
1083        let end_offset = columns.get(i + 1).map_or(usize::MAX, |next| next.start);
1084        let cell_end = if end_offset == usize::MAX {
1085            trimmed.len()
1086        } else if end_offset >= leading_ws_len {
1087            column_offset_to_byte_index(trimmed, end_offset - leading_ws_len)
1088        } else {
1089            0
1090        };
1091
1092        // Extract cell text from column bounds. When the column lies entirely
1093        // before the trimmed content (col.end <= leading_ws_len) both bounds
1094        // clamp to 0; treat that as an empty cell rather than re-emitting the
1095        // whole row.
1096        let cell_text = if cell_start < cell_end && cell_start < trimmed.len() {
1097            &trimmed[cell_start..cell_end]
1098        } else {
1099            ""
1100        };
1101
1102        let cell_content = cell_text.trim();
1103        let cell_content_start = cell_text.len() - cell_text.trim_start().len();
1104
1105        // Emit any whitespace from current position to start of cell content
1106        let content_abs_pos = (cell_start + cell_content_start).min(trimmed.len());
1107        if current_pos < content_abs_pos {
1108            builder.token(
1109                SyntaxKind::WHITESPACE.into(),
1110                &trimmed[current_pos..content_abs_pos],
1111            );
1112        }
1113
1114        // Emit cell with inline parsing
1115        emit_table_cell(builder, cell_content, config);
1116
1117        // Update current position to end of cell content
1118        current_pos = content_abs_pos + cell_content.len();
1119    }
1120
1121    // Emit any remaining whitespace after last cell
1122    if current_pos < trimmed.len() {
1123        builder.token(SyntaxKind::WHITESPACE.into(), &trimmed[current_pos..]);
1124    }
1125
1126    // Emit newline if present
1127    if !newline_str.is_empty() {
1128        builder.token(SyntaxKind::NEWLINE.into(), newline_str);
1129    }
1130
1131    builder.finish_node();
1132}
1133
1134// ============================================================================
1135// Pipe Table Parsing
1136// ============================================================================
1137
1138/// Check if a line is a pipe table separator line.
1139/// Returns the column alignments if it's a valid separator.
1140fn try_parse_pipe_separator(line: &str) -> Option<Vec<Alignment>> {
1141    let trimmed = line.trim();
1142
1143    // Must contain at least one pipe
1144    if !trimmed.contains('|') && !trimmed.contains('+') {
1145        return None;
1146    }
1147
1148    // Split by pipes (or + for orgtbl variant)
1149    let cells: Vec<&str> = if trimmed.contains('+') {
1150        // Orgtbl variant: use + as separator in separator line
1151        trimmed.split(['|', '+']).collect()
1152    } else {
1153        trimmed.split('|').collect()
1154    };
1155
1156    let mut alignments = Vec::new();
1157
1158    for cell in cells {
1159        let cell = cell.trim();
1160
1161        // Skip empty cells (from leading/trailing pipes)
1162        if cell.is_empty() {
1163            continue;
1164        }
1165
1166        // Must be dashes with optional colons
1167        let starts_colon = cell.starts_with(':');
1168        let ends_colon = cell.ends_with(':');
1169
1170        // Remove colons to check if rest is all dashes
1171        let without_colons = cell.trim_start_matches(':').trim_end_matches(':');
1172
1173        // Must have at least one dash
1174        if without_colons.is_empty() || !without_colons.chars().all(|c| c == '-') {
1175            return None;
1176        }
1177
1178        // Determine alignment from colon positions
1179        let alignment = match (starts_colon, ends_colon) {
1180            (true, true) => Alignment::Center,
1181            (true, false) => Alignment::Left,
1182            (false, true) => Alignment::Right,
1183            (false, false) => Alignment::Default,
1184        };
1185
1186        alignments.push(alignment);
1187    }
1188
1189    // Must have at least one column
1190    if alignments.is_empty() {
1191        None
1192    } else {
1193        Some(alignments)
1194    }
1195}
1196
1197/// Split a pipe table row into cells.
1198/// Handles escaped pipes (\|) properly by not splitting on them.
1199fn parse_pipe_table_row(line: &str) -> Vec<String> {
1200    let trimmed = line.trim();
1201
1202    let mut cells = Vec::new();
1203    let mut current_cell = String::new();
1204    let mut chars = trimmed.chars().peekable();
1205    let mut char_count = 0;
1206
1207    while let Some(ch) = chars.next() {
1208        char_count += 1;
1209        match ch {
1210            '\\' => {
1211                // Check if next char is a pipe - if so, it's an escaped pipe
1212                if let Some(&'|') = chars.peek() {
1213                    current_cell.push('\\');
1214                    current_cell.push('|');
1215                    chars.next(); // consume the pipe
1216                } else {
1217                    current_cell.push(ch);
1218                }
1219            }
1220            '|' => {
1221                // Check if this is the leading pipe (first character)
1222                if char_count == 1 {
1223                    continue; // Skip leading pipe
1224                }
1225
1226                // End current cell, start new one
1227                cells.push(current_cell.trim().to_string());
1228                current_cell.clear();
1229            }
1230            _ => {
1231                current_cell.push(ch);
1232            }
1233        }
1234    }
1235
1236    // Add last cell if it's not empty (it would be empty if line ended with pipe)
1237    let trimmed_cell = current_cell.trim().to_string();
1238    if !trimmed_cell.is_empty() {
1239        cells.push(trimmed_cell);
1240    }
1241
1242    cells
1243}
1244
1245/// Emit a pipe table row with inline-parsed cells.
1246/// Preserves losslessness by emitting exact byte representation while parsing cell content inline.
1247fn emit_pipe_table_row(
1248    builder: &mut GreenNodeBuilder<'static>,
1249    window: &StrippedLines<'_, '_>,
1250    abs_idx: usize,
1251    row_kind: SyntaxKind,
1252    config: &ParserOptions,
1253) {
1254    builder.start_node(row_kind.into());
1255
1256    // On continuation lines (separator/data rows under a list+blockquote
1257    // container) the leading `  > ` prefix is not consumed by the core;
1258    // `emit_prefix_at` re-emits it as WHITESPACE/BLOCK_QUOTE_MARKER tokens
1259    // and returns the stripped tail. On the dispatch line the core already
1260    // emitted the prefix, so `dispatch_tail` just strips it from our view.
1261    // With an empty prefix (non-nested tables) both are no-ops returning
1262    // the raw line.
1263    let line = if abs_idx == window.dispatch_pos() {
1264        window.dispatch_tail()
1265    } else {
1266        window.emit_prefix_at(builder, abs_idx)
1267    };
1268
1269    let (line_without_newline, newline_str) = strip_newline(line);
1270    let trimmed = line_without_newline.trim();
1271
1272    // Parse cell boundaries
1273    let mut cell_starts = Vec::new();
1274    let mut cell_ends = Vec::new();
1275    let mut in_escape = false;
1276
1277    // Find all pipe positions (excluding escaped ones)
1278    let mut pipe_positions = Vec::new();
1279    for (i, ch) in trimmed.char_indices() {
1280        if in_escape {
1281            in_escape = false;
1282            continue;
1283        }
1284        if ch == '\\' {
1285            in_escape = true;
1286            continue;
1287        }
1288        if ch == '|' {
1289            pipe_positions.push(i);
1290        }
1291    }
1292
1293    // Determine cell boundaries based on pipe positions
1294    if pipe_positions.is_empty() {
1295        // No pipes - treat entire line as one cell (shouldn't happen for valid pipe tables)
1296        cell_starts.push(0);
1297        cell_ends.push(trimmed.len());
1298    } else {
1299        // Check if line starts with pipe
1300        let start_pipe = pipe_positions.first() == Some(&0);
1301        // Check if line ends with pipe
1302        let end_pipe = pipe_positions.last() == Some(&(trimmed.len() - 1));
1303
1304        if start_pipe {
1305            // Skip first pipe
1306            for i in 1..pipe_positions.len() {
1307                cell_starts.push(pipe_positions[i - 1] + 1);
1308                cell_ends.push(pipe_positions[i]);
1309            }
1310            // Add last cell if there's no trailing pipe
1311            if !end_pipe {
1312                cell_starts.push(*pipe_positions.last().unwrap() + 1);
1313                cell_ends.push(trimmed.len());
1314            }
1315        } else {
1316            // No leading pipe
1317            cell_starts.push(0);
1318            cell_ends.push(pipe_positions[0]);
1319
1320            for i in 1..pipe_positions.len() {
1321                cell_starts.push(pipe_positions[i - 1] + 1);
1322                cell_ends.push(pipe_positions[i]);
1323            }
1324
1325            // Add last cell if there's no trailing pipe
1326            if !end_pipe {
1327                cell_starts.push(*pipe_positions.last().unwrap() + 1);
1328                cell_ends.push(trimmed.len());
1329            }
1330        }
1331    }
1332
1333    // Emit leading whitespace if present (before trim)
1334    let leading_ws_len = line_without_newline.len() - line_without_newline.trim_start().len();
1335    if leading_ws_len > 0 {
1336        builder.token(
1337            SyntaxKind::WHITESPACE.into(),
1338            &line_without_newline[..leading_ws_len],
1339        );
1340    }
1341
1342    // Emit cells with pipes
1343    for (idx, (start, end)) in cell_starts.iter().zip(cell_ends.iter()).enumerate() {
1344        // Emit pipe before cell (except for first cell if no leading pipe)
1345        if *start > 0 {
1346            builder.token(SyntaxKind::TEXT.into(), "|");
1347        } else if idx == 0 && trimmed.starts_with('|') {
1348            // Leading pipe
1349            builder.token(SyntaxKind::TEXT.into(), "|");
1350        }
1351
1352        // Get cell content with its whitespace
1353        let cell_with_ws = &trimmed[*start..*end];
1354        let cell_content = cell_with_ws.trim();
1355
1356        // Emit leading whitespace within cell
1357        let cell_leading_ws = &cell_with_ws[..cell_with_ws.len() - cell_with_ws.trim_start().len()];
1358        if !cell_leading_ws.is_empty() {
1359            builder.token(SyntaxKind::WHITESPACE.into(), cell_leading_ws);
1360        }
1361
1362        // Emit cell with inline parsing
1363        emit_table_cell(builder, cell_content, config);
1364
1365        // Emit trailing whitespace within cell
1366        let cell_trailing_ws_start = cell_leading_ws.len() + cell_content.len();
1367        if cell_trailing_ws_start < cell_with_ws.len() {
1368            builder.token(
1369                SyntaxKind::WHITESPACE.into(),
1370                &cell_with_ws[cell_trailing_ws_start..],
1371            );
1372        }
1373    }
1374
1375    // Emit trailing pipe if present
1376    if !pipe_positions.is_empty() && trimmed.ends_with('|') {
1377        builder.token(SyntaxKind::TEXT.into(), "|");
1378    }
1379
1380    // Emit trailing whitespace after trim (before newline)
1381    let trailing_ws_start = leading_ws_len + trimmed.len();
1382    if trailing_ws_start < line_without_newline.len() {
1383        builder.token(
1384            SyntaxKind::WHITESPACE.into(),
1385            &line_without_newline[trailing_ws_start..],
1386        );
1387    }
1388
1389    // Emit newline
1390    if !newline_str.is_empty() {
1391        builder.token(SyntaxKind::NEWLINE.into(), newline_str);
1392    }
1393
1394    builder.finish_node();
1395}
1396
1397/// Try to parse a pipe table starting at the given position.
1398/// Returns the number of lines consumed if successful.
1399pub(crate) fn try_parse_pipe_table(
1400    window: &StrippedLines<'_, '_>,
1401    builder: &mut GreenNodeBuilder<'static>,
1402    config: &ParserOptions,
1403) -> Option<usize> {
1404    let lines = window.raw();
1405    let start_pos = window.pos();
1406    if start_pos + 1 >= lines.len() {
1407        return None;
1408    }
1409
1410    // Cheap gate: a pipe table's first line must contain a `|` (it is either
1411    // the header or, headerless, the delimiter row), unless this is a
1412    // caption-led table. Table detection runs at every block start, so doing
1413    // any per-line work for every prose/math paragraph was quadratic on large
1414    // documents. Peek the dispatch line and run the (bounded) caption probe on
1415    // the same stripped `window` the detection below uses, so the gate applies
1416    // inside containers (blockquote/list) too — not just at top level.
1417    if !window.strip_at(start_pos).contains('|') && !is_caption_followed_by_table(window, start_pos)
1418    {
1419        return None;
1420    }
1421
1422    // Detection scans read the container-prefix-stripped view lazily through
1423    // the window (see `LineView`), so a table nested in `list → blockquote`
1424    // (e.g. `- > | a | b |`) has its `  > ` prefix removed before the
1425    // separator/cell shape checks. The dispatch line uses the emission-safe
1426    // line-0 strip (its prefix was consumed by the core); every other line
1427    // gets the full continuation strip. Scans stop at the first blank line, so
1428    // only a bounded range is stripped. Emission still reads raw `lines` so the
1429    // prefix bytes can be re-emitted as tokens.
1430
1431    // Check if this line is a caption followed by a table
1432    // If so, the actual table starts after the caption and blank line
1433    let (actual_start, caption_before) = if is_caption_followed_by_table(window, start_pos) {
1434        let (cap_start, cap_end) = caption_range_starting_at(window, start_pos)?;
1435        let mut pos = cap_end;
1436        while pos < window.line_count() && window.line(pos).trim().is_empty() {
1437            pos += 1;
1438        }
1439        (pos, Some((cap_start, cap_end)))
1440    } else {
1441        (start_pos, None)
1442    };
1443
1444    if actual_start + 1 >= lines.len() {
1445        return None;
1446    }
1447
1448    // First line should have pipes (potential header)
1449    if !window.line(actual_start).contains('|') {
1450        return None;
1451    }
1452
1453    // Second line should be separator
1454    let alignments = try_parse_pipe_separator(window.line(actual_start + 1))?;
1455
1456    // Parse header cells
1457    let header_cells = parse_pipe_table_row(window.line(actual_start));
1458
1459    // Number of columns should match (approximately - be lenient)
1460    if header_cells.len() != alignments.len() && !header_cells.is_empty() {
1461        // Only fail if very different
1462        if header_cells.len() < alignments.len() / 2 || header_cells.len() > alignments.len() * 2 {
1463            return None;
1464        }
1465    }
1466
1467    // Find table end (first blank line or end of input)
1468    let mut end_pos = actual_start + 2;
1469    while end_pos < window.line_count() {
1470        let line = window.line(end_pos);
1471        if line.trim().is_empty() {
1472            break;
1473        }
1474        // Row should have pipes
1475        if !line.contains('|') {
1476            break;
1477        }
1478        end_pos += 1;
1479    }
1480
1481    // Must have at least one data row
1482    if end_pos <= actual_start + 2 {
1483        return None;
1484    }
1485
1486    // Check for caption before table (only if we didn't already detect it)
1487    let caption_before = caption_before.or_else(|| find_caption_before_table(window, actual_start));
1488
1489    // Check for caption after table
1490    let caption_after = if caption_before.is_some() {
1491        None
1492    } else {
1493        find_caption_after_table(window, end_pos)
1494    };
1495
1496    // Build the pipe table
1497    builder.start_node(SyntaxKind::PIPE_TABLE.into());
1498
1499    // Emit caption before if present
1500    if let Some((cap_start, cap_end)) = caption_before {
1501        emit_table_caption(builder, window, cap_start, cap_end, config);
1502        // Emit blank line between caption and table if present
1503        emit_caption_blank_lines(builder, window, cap_end, actual_start);
1504    }
1505
1506    // Emit header row with inline-parsed cells. On the dispatch line the
1507    // core already emitted the container prefix; only when the header is a
1508    // continuation line (e.g. it follows a caption-before line) do we emit
1509    // the prefix here.
1510    emit_pipe_table_row(
1511        builder,
1512        window,
1513        actual_start,
1514        SyntaxKind::TABLE_HEADER,
1515        config,
1516    );
1517
1518    // Emit separator, re-emitting any continuation-line container prefix
1519    // (`  > `) as WHITESPACE/BLOCK_QUOTE_MARKER tokens before the row text.
1520    builder.start_node(SyntaxKind::TABLE_SEPARATOR.into());
1521    let sep_idx = actual_start + 1;
1522    let separator_tail = if sep_idx == window.dispatch_pos() {
1523        window.dispatch_tail()
1524    } else {
1525        window.emit_prefix_at(builder, sep_idx)
1526    };
1527    emit_separator_tokens(builder, separator_tail);
1528    builder.finish_node();
1529
1530    // Emit data rows with inline-parsed cells (always continuation lines)
1531    for idx in (actual_start + 2)..end_pos {
1532        emit_pipe_table_row(builder, window, idx, SyntaxKind::TABLE_ROW, config);
1533    }
1534
1535    // Emit caption after if present
1536    if let Some((cap_start, cap_end)) = caption_after {
1537        // Emit blank line before caption if needed
1538        emit_caption_blank_lines(builder, window, end_pos, cap_start);
1539        emit_table_caption(builder, window, cap_start, cap_end, config);
1540    }
1541
1542    builder.finish_node(); // PipeTable
1543
1544    // Calculate lines consumed
1545    let table_start = caption_before
1546        .map(|(start, _)| start)
1547        .unwrap_or(actual_start);
1548    let table_end = if let Some((_, cap_end)) = caption_after {
1549        cap_end
1550    } else {
1551        end_pos
1552    };
1553
1554    Some(table_end - table_start)
1555}
1556
1557#[cfg(test)]
1558mod tests {
1559    use super::super::container_prefix::ContainerPrefix;
1560    use super::*;
1561
1562    #[test]
1563    fn test_separator_detection() {
1564        assert!(try_parse_table_separator("------- ------ ----------   -------").is_some());
1565        assert!(try_parse_table_separator("  ---  ---  ---").is_some());
1566        assert!(try_parse_table_separator("-------").is_none()); // horizontal rule
1567        assert!(try_parse_table_separator("--- --- ---").is_some()); // table separator
1568    }
1569
1570    #[test]
1571    fn test_column_extraction() {
1572        let line = "-------     ------ ----------   -------";
1573        let columns = extract_columns(line, 0);
1574        assert_eq!(columns.len(), 4);
1575    }
1576
1577    #[test]
1578    fn column_offset_maps_by_display_width() {
1579        // Wide CJK characters occupy two display columns each. A column offset
1580        // of 4 display columns lands after two wide chars (6 bytes), not after
1581        // four chars.
1582        let line = "地號xy";
1583        assert_eq!(column_offset_to_byte_index(line, 4), 6);
1584        // ASCII stays byte-for-byte.
1585        assert_eq!(column_offset_to_byte_index("abcd", 2), 2);
1586    }
1587
1588    #[test]
1589    fn simple_table_cjk_header_keeps_footnote_ref_intact() {
1590        // Regression for #411: wide (double-width) header text pushed the
1591        // footnote reference across a char-counted column boundary, splitting
1592        // `[^d]` so it never parsed as a FOOTNOTE_REFERENCE and the linter
1593        // flagged the definition as unused.
1594        let input = "\
1595 地號    地主    路段      總長[^d] 水利局舖的[^s]
1596------ -------   -------- --------- --------------
15972976    Ralph    南段          64   33
1598";
1599        let tree = crate::parser::parse(input, None);
1600        let refs: Vec<_> = tree
1601            .descendants()
1602            .filter(|n| n.kind() == SyntaxKind::FOOTNOTE_REFERENCE)
1603            .map(|n| n.text().to_string())
1604            .collect();
1605        assert_eq!(refs, vec!["[^d]".to_string(), "[^s]".to_string()]);
1606    }
1607
1608    #[test]
1609    fn test_simple_table_with_header() {
1610        let input = vec![
1611            "  Right     Left     Center     Default",
1612            "-------     ------ ----------   -------",
1613            "     12     12        12            12",
1614            "    123     123       123          123",
1615            "",
1616        ];
1617
1618        let mut builder = GreenNodeBuilder::new();
1619        let prefix = ContainerPrefix::default();
1620        let window = StrippedLines::new(&input, 0, &prefix);
1621        let result = try_parse_simple_table(&window, &mut builder, &ParserOptions::default());
1622
1623        assert!(result.is_some());
1624        assert_eq!(result.unwrap(), 4); // header + sep + 2 rows
1625    }
1626
1627    #[test]
1628    fn test_headerless_table() {
1629        let input = vec![
1630            "-------     ------ ----------   -------",
1631            "     12     12        12            12",
1632            "    123     123       123          123",
1633            "-------     ------ ----------   -------",
1634            "",
1635        ];
1636
1637        let mut builder = GreenNodeBuilder::new();
1638        let prefix = ContainerPrefix::default();
1639        let window = StrippedLines::new(&input, 0, &prefix);
1640        let result = try_parse_simple_table(&window, &mut builder, &ParserOptions::default());
1641
1642        assert!(result.is_some());
1643        assert_eq!(result.unwrap(), 4); // sep + 2 rows + closer
1644    }
1645
1646    /// Asserts a `SIMPLE_TABLE` closing dash line is shaped as a
1647    /// `TABLE_SEPARATOR`, not a phantom all-dash `TABLE_ROW`.
1648    fn assert_closer_is_separator(input: &str, expected_rows: usize) {
1649        let tree = crate::parser::parse(input, None);
1650        let table = tree
1651            .descendants()
1652            .find(|n| n.kind() == SyntaxKind::SIMPLE_TABLE)
1653            .expect("input should parse as a simple table");
1654        let separators = table
1655            .children()
1656            .filter(|n| n.kind() == SyntaxKind::TABLE_SEPARATOR)
1657            .count();
1658        assert_eq!(separators, 2, "opener and closer should both be separators");
1659        let rows: Vec<_> = table
1660            .children()
1661            .filter(|n| n.kind() == SyntaxKind::TABLE_ROW)
1662            .map(|n| n.text().to_string())
1663            .collect();
1664        assert_eq!(rows.len(), expected_rows, "rows: {rows:?}");
1665        for row in &rows {
1666            assert!(
1667                !row.trim().chars().all(|c| c == '-' || c.is_whitespace()),
1668                "all-dash closer emitted as TABLE_ROW: {row:?}"
1669            );
1670        }
1671    }
1672
1673    #[test]
1674    fn headerless_multi_column_closer_is_separator() {
1675        assert_closer_is_separator("--- ---\nfoo bar\n--- ---\n", 1);
1676    }
1677
1678    #[test]
1679    fn header_with_closer_is_separator() {
1680        assert_closer_is_separator("foo bar\n--- ---\n1   2\n--- ---\n", 1);
1681    }
1682
1683    #[test]
1684    fn headerless_single_column_closer_is_separator() {
1685        assert_closer_is_separator("----------\nfirst row\nsecond row\n----------\n", 2);
1686    }
1687
1688    #[test]
1689    fn headerless_multi_column_requires_closer() {
1690        // Pandoc requires headerless simple tables to end with a line of
1691        // dashes; without one the opener is a horizontal rule and the rows
1692        // are a paragraph.
1693        let input = vec!["--- ---", "foo bar", "", "after"];
1694
1695        let mut builder = GreenNodeBuilder::new();
1696        let prefix = ContainerPrefix::default();
1697        let window = StrippedLines::new(&input, 0, &prefix);
1698        let result = try_parse_simple_table(&window, &mut builder, &ParserOptions::default());
1699
1700        assert!(result.is_none());
1701    }
1702
1703    #[test]
1704    fn headerless_multi_column_requires_closer_at_eof() {
1705        // The closer is required even when the table would run to EOF.
1706        let input = vec!["--- ---", "foo bar"];
1707
1708        let mut builder = GreenNodeBuilder::new();
1709        let prefix = ContainerPrefix::default();
1710        let window = StrippedLines::new(&input, 0, &prefix);
1711        let result = try_parse_simple_table(&window, &mut builder, &ParserOptions::default());
1712
1713        assert!(result.is_none());
1714    }
1715
1716    #[test]
1717    fn header_with_closer_requires_rows() {
1718        // Header + separator + closer with no data rows is a paragraph in
1719        // pandoc, not an empty table.
1720        let input = vec!["foo bar", "--- ---", "--- ---", ""];
1721
1722        let mut builder = GreenNodeBuilder::new();
1723        let prefix = ContainerPrefix::default();
1724        let window = StrippedLines::new(&input, 0, &prefix);
1725        let result = try_parse_simple_table(&window, &mut builder, &ParserOptions::default());
1726
1727        assert!(result.is_none());
1728    }
1729
1730    #[test]
1731    fn headerless_multi_column_requires_rows() {
1732        // Two adjacent separator lines are two horizontal rules, not an
1733        // empty table.
1734        let input = vec!["--- ---", "--- ---", ""];
1735
1736        let mut builder = GreenNodeBuilder::new();
1737        let prefix = ContainerPrefix::default();
1738        let window = StrippedLines::new(&input, 0, &prefix);
1739        let result = try_parse_simple_table(&window, &mut builder, &ParserOptions::default());
1740
1741        assert!(result.is_none());
1742    }
1743
1744    #[test]
1745    fn headerless_multi_column_with_closer_at_eof() {
1746        // A closing dash line at EOF (no trailing blank) still closes the
1747        // table, matching pandoc.
1748        let input = vec!["--- ---", "foo bar", "--- ---"];
1749
1750        let mut builder = GreenNodeBuilder::new();
1751        let prefix = ContainerPrefix::default();
1752        let window = StrippedLines::new(&input, 0, &prefix);
1753        let result = try_parse_simple_table(&window, &mut builder, &ParserOptions::default());
1754
1755        assert_eq!(result, Some(3)); // sep + row + closer
1756    }
1757
1758    #[test]
1759    fn single_dash_run_detection() {
1760        assert!(parse_single_dash_run("---").is_some());
1761        assert!(parse_single_dash_run("--").is_some()); // pandoc accepts two dashes
1762        assert!(parse_single_dash_run("-").is_none()); // list marker territory
1763        assert!(parse_single_dash_run("  ----------  ").is_some());
1764        assert!(parse_single_dash_run("    ---").is_none()); // indented code
1765        assert!(parse_single_dash_run("--- ---").is_none()); // multi-column separator
1766        assert!(parse_single_dash_run("---x").is_none());
1767        assert!(parse_single_dash_run("- - -").is_none()); // spaced thematic break
1768    }
1769
1770    #[test]
1771    fn headerless_single_column_simple_table() {
1772        // Pandoc parses a bare dash run followed by rows and a closing dash
1773        // run as a headerless single-column simple table (`---` / rows /
1774        // `---`), e.g. when the YAML metadata gate rejects a non-mapping
1775        // block.
1776        let input = vec!["---", "- a", "- b", "---", ""];
1777
1778        let mut builder = GreenNodeBuilder::new();
1779        let prefix = ContainerPrefix::default();
1780        let window = StrippedLines::new(&input, 0, &prefix);
1781        let result = try_parse_simple_table(&window, &mut builder, &ParserOptions::default());
1782
1783        assert_eq!(result, Some(4)); // opener + 2 rows + closer
1784    }
1785
1786    #[test]
1787    fn headerless_single_column_requires_closer() {
1788        // Without a closing dash line before the first blank line the dash
1789        // run is a horizontal rule, not a table opener (matches pandoc).
1790        let input = vec!["---", "just prose", "", "after"];
1791
1792        let mut builder = GreenNodeBuilder::new();
1793        let prefix = ContainerPrefix::default();
1794        let window = StrippedLines::new(&input, 0, &prefix);
1795        let result = try_parse_simple_table(&window, &mut builder, &ParserOptions::default());
1796
1797        assert!(result.is_none());
1798    }
1799
1800    #[test]
1801    fn headerless_single_column_requires_rows() {
1802        // Two adjacent dash runs are two horizontal rules, not an empty table.
1803        let input = vec!["---", "---", ""];
1804
1805        let mut builder = GreenNodeBuilder::new();
1806        let prefix = ContainerPrefix::default();
1807        let window = StrippedLines::new(&input, 0, &prefix);
1808        let result = try_parse_simple_table(&window, &mut builder, &ParserOptions::default());
1809
1810        assert!(result.is_none());
1811    }
1812
1813    #[test]
1814    fn test_caption_prefix_detection() {
1815        assert!(try_parse_caption_prefix("Table: My caption").is_some());
1816        assert!(try_parse_caption_prefix("table: My caption").is_some());
1817        assert!(try_parse_caption_prefix(": My caption").is_some());
1818        assert!(try_parse_caption_prefix(":").is_none()); // Just colon, no content
1819        assert!(try_parse_caption_prefix("Not a caption").is_none());
1820    }
1821
1822    #[test]
1823    fn table_grid_starts_at_matches_each_kind() {
1824        // Positives — one shape per table kind the real parsers accept.
1825        assert!(table_grid_starts_at(&["+---+---+"][..], 0)); // grid
1826        assert!(table_grid_starts_at(&["----------- -------"][..], 0)); // multiline
1827        assert!(table_grid_starts_at(&["--- --- ---"][..], 0)); // simple, headerless
1828        assert!(table_grid_starts_at(&["A | B", "| --- | --- |"][..], 0)); // pipe, header + sep
1829        assert!(table_grid_starts_at(&["A    B", "--- ---"][..], 0)); // simple, header + sep
1830        // A lone dash run is a multiline full-width separator under Pandoc (not a
1831        // thematic break), so the lookahead intentionally accepts it; the full
1832        // parser then rejects it if no rows follow.
1833        assert!(table_grid_starts_at(&["-------"][..], 0));
1834
1835        // Negatives — shapes that must not read as a table start.
1836        assert!(!table_grid_starts_at(&["just some prose"][..], 0));
1837        assert!(!table_grid_starts_at(&["# Heading"][..], 0));
1838        assert!(!table_grid_starts_at(&["```", "code", "```"][..], 0)); // code fence
1839        assert!(!table_grid_starts_at(&["only one line"][..], 1)); // out of range
1840    }
1841
1842    /// The cheap caption lookahead must agree with what the full parser does:
1843    /// when it says a table follows the caption, a table node really forms; when
1844    /// it says no table follows, none does. This guards against the lookahead
1845    /// (`table_grid_starts_at`) drifting from the real per-kind parsers.
1846    #[test]
1847    fn caption_lookahead_agrees_with_real_parse() {
1848        let with_table = ": Cap\n\n| A | B |\n|---|---|\n| 1 | 2 |\n";
1849        let lines: Vec<&str> = with_table.lines().collect();
1850        assert!(is_caption_followed_by_table(&lines[..], 0));
1851        assert!(format!("{:#?}", crate::parse(with_table, None)).contains("PIPE_TABLE"));
1852
1853        let no_table = ": Cap\n\nplain paragraph\n";
1854        let lines: Vec<&str> = no_table.lines().collect();
1855        assert!(!is_caption_followed_by_table(&lines[..], 0));
1856        assert!(!format!("{:#?}", crate::parse(no_table, None)).contains("TABLE"));
1857    }
1858
1859    /// Pandoc parses `table` before `orderedList` (but `bulletList` before
1860    /// `table`) in its `block` choice. So an ordered marker whose line is the
1861    /// header of a valid pipe table is NOT a list: the whole construct is a
1862    /// top-level table absorbing the marker as the first header cell. Bullets
1863    /// and a lone ordered marker (no delimiter) stay lists. Verified against
1864    /// pandoc 3.9 (`-f markdown -t native`).
1865    #[test]
1866    fn ordered_marker_on_pipe_table_line_is_top_level_table() {
1867        let input = "1. | a | b |\n   | - | - |\n   | 1 | 2 |\n";
1868        let tree = crate::parse(input, None);
1869        assert!(
1870            tree.descendants()
1871                .any(|n| n.kind() == SyntaxKind::PIPE_TABLE),
1872            "ordered marker + pipe table on the marker line should be a top-level table"
1873        );
1874        assert!(
1875            !tree.descendants().any(|n| n.kind() == SyntaxKind::LIST),
1876            "it must not nest under a list"
1877        );
1878        // Lossless: the marker and the overflow cell survive in the CST.
1879        let dump = format!("{tree:#?}");
1880        assert!(
1881            dump.contains("1."),
1882            "marker text preserved as a header cell"
1883        );
1884        assert!(dump.contains('b'), "overflow cell `b` preserved (lossless)");
1885    }
1886
1887    #[test]
1888    fn lone_ordered_marker_pipe_line_is_a_list() {
1889        // No delimiter row → pandoc's `table` fails, `orderedList` catches it.
1890        let input = "1. | a | b |\n";
1891        let tree = crate::parse(input, None);
1892        assert!(
1893            tree.descendants().any(|n| n.kind() == SyntaxKind::LIST),
1894            "a lone ordered marker line stays a list"
1895        );
1896        assert!(
1897            !tree
1898                .descendants()
1899                .any(|n| n.kind() == SyntaxKind::PIPE_TABLE),
1900            "no table without a delimiter row"
1901        );
1902    }
1903
1904    #[test]
1905    fn bullet_marker_on_pipe_table_line_stays_a_nested_table() {
1906        // Bullets already match pandoc (`BulletList -> Table`): regression guard.
1907        let input = "- | a | b |\n  | - | - |\n  | 1 | 2 |\n";
1908        let tree = crate::parse(input, None);
1909        assert!(
1910            tree.descendants().any(|n| n.kind() == SyntaxKind::LIST),
1911            "bullet marker keeps the list"
1912        );
1913        assert!(
1914            tree.descendants()
1915                .any(|n| n.kind() == SyntaxKind::PIPE_TABLE),
1916            "with the table nested inside the list item"
1917        );
1918    }
1919
1920    #[test]
1921    fn bare_colon_fenced_code_is_not_table_caption() {
1922        let input = "Term\n: ```\n  code\n  ```\n";
1923        let tree = crate::parse(input, None);
1924
1925        assert!(
1926            tree.descendants()
1927                .any(|node| node.kind() == SyntaxKind::DEFINITION_LIST),
1928            "should parse as definition list"
1929        );
1930        assert!(
1931            tree.descendants()
1932                .any(|node| node.kind() == SyntaxKind::CODE_BLOCK),
1933            "definition should preserve fenced code block"
1934        );
1935        assert!(
1936            !tree
1937                .descendants()
1938                .any(|node| node.kind() == SyntaxKind::TABLE_CAPTION),
1939            "fenced code definition should not be parsed as table caption"
1940        );
1941    }
1942
1943    #[test]
1944    fn bare_colon_caption_after_div_opening_is_table_caption() {
1945        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";
1946        let tree = crate::parse(input, None);
1947
1948        let caption_count = tree
1949            .descendants()
1950            .filter(|node| node.kind() == SyntaxKind::TABLE_CAPTION)
1951            .count();
1952        assert_eq!(
1953            caption_count, 2,
1954            "expected both captions to attach to tables"
1955        );
1956        assert!(
1957            !tree
1958                .descendants()
1959                .any(|node| node.kind() == SyntaxKind::DEFINITION_LIST),
1960            "caption lines in this fenced div table layout should not parse as definition list"
1961        );
1962    }
1963
1964    #[test]
1965    fn test_table_with_caption_after() {
1966        let input = vec![
1967            "  Right     Left     Center     Default",
1968            "-------     ------ ----------   -------",
1969            "     12     12        12            12",
1970            "    123     123       123          123",
1971            "",
1972            "Table: Demonstration of simple table syntax.",
1973            "",
1974        ];
1975
1976        let mut builder = GreenNodeBuilder::new();
1977        let prefix = ContainerPrefix::default();
1978        let window = StrippedLines::new(&input, 0, &prefix);
1979        let result = try_parse_simple_table(&window, &mut builder, &ParserOptions::default());
1980
1981        assert!(result.is_some());
1982        // Should consume: header + sep + 2 rows + blank + caption
1983        assert_eq!(result.unwrap(), 6);
1984    }
1985
1986    #[test]
1987    fn test_table_with_caption_before() {
1988        let input = vec![
1989            "Table: Demonstration of simple table syntax.",
1990            "",
1991            "  Right     Left     Center     Default",
1992            "-------     ------ ----------   -------",
1993            "     12     12        12            12",
1994            "    123     123       123          123",
1995            "",
1996        ];
1997
1998        let mut builder = GreenNodeBuilder::new();
1999        let prefix = ContainerPrefix::default();
2000        let window = StrippedLines::new(&input, 2, &prefix);
2001        let result = try_parse_simple_table(&window, &mut builder, &ParserOptions::default());
2002
2003        assert!(result.is_some());
2004        // Should consume: caption + blank + header + sep + 2 rows
2005        assert_eq!(result.unwrap(), 6);
2006    }
2007
2008    #[test]
2009    fn test_caption_with_colon_prefix() {
2010        let input = vec![
2011            "  Right     Left",
2012            "-------     ------",
2013            "     12     12",
2014            "",
2015            ": Short caption",
2016            "",
2017        ];
2018
2019        let mut builder = GreenNodeBuilder::new();
2020        let prefix = ContainerPrefix::default();
2021        let window = StrippedLines::new(&input, 0, &prefix);
2022        let result = try_parse_simple_table(&window, &mut builder, &ParserOptions::default());
2023
2024        assert!(result.is_some());
2025        assert_eq!(result.unwrap(), 5); // header + sep + row + blank + caption
2026    }
2027
2028    #[test]
2029    fn test_multiline_caption() {
2030        let input = vec![
2031            "  Right     Left",
2032            "-------     ------",
2033            "     12     12",
2034            "",
2035            "Table: This is a longer caption",
2036            "that spans multiple lines.",
2037            "",
2038        ];
2039
2040        let mut builder = GreenNodeBuilder::new();
2041        let prefix = ContainerPrefix::default();
2042        let window = StrippedLines::new(&input, 0, &prefix);
2043        let result = try_parse_simple_table(&window, &mut builder, &ParserOptions::default());
2044
2045        assert!(result.is_some());
2046        // Should consume through end of multi-line caption
2047        assert_eq!(result.unwrap(), 6);
2048    }
2049
2050    #[test]
2051    fn test_simple_table_with_multibyte_cell_content() {
2052        let input = vec![
2053            "Name            Hex code     Hue     C, M, Y, K (%)   R, G, B (0-255)   R, G, B (%)",
2054            "--------------  ------------ ------- ---------------- ----------------- ------------",
2055            "        orange       #E69F00     41° 0, 50, 100, 0    230, 159, 0       90, 60, 0",
2056            "      sky blue       #56B4E9    202° 80, 0, 0, 0      86, 180, 233      35, 70, 90",
2057            "",
2058        ];
2059
2060        let mut builder = GreenNodeBuilder::new();
2061        let prefix = ContainerPrefix::default();
2062        let window = StrippedLines::new(&input, 0, &prefix);
2063        let result = try_parse_simple_table(&window, &mut builder, &ParserOptions::default());
2064
2065        assert!(result.is_some());
2066        assert_eq!(result.unwrap(), 4);
2067    }
2068
2069    // Pipe table tests
2070    #[test]
2071    fn test_pipe_separator_detection() {
2072        assert!(try_parse_pipe_separator("|------:|:-----|---------|:------:|").is_some());
2073        assert!(try_parse_pipe_separator("|---|---|").is_some());
2074        assert!(try_parse_pipe_separator("-----|-----:").is_some()); // No leading pipe
2075        assert!(try_parse_pipe_separator("|-----+-------|").is_some()); // Orgtbl variant
2076        assert!(try_parse_pipe_separator("not a separator").is_none());
2077    }
2078
2079    #[test]
2080    fn test_pipe_alignments() {
2081        let aligns = try_parse_pipe_separator("|------:|:-----|---------|:------:|").unwrap();
2082        assert_eq!(aligns.len(), 4);
2083        assert_eq!(aligns[0], Alignment::Right);
2084        assert_eq!(aligns[1], Alignment::Left);
2085        assert_eq!(aligns[2], Alignment::Default);
2086        assert_eq!(aligns[3], Alignment::Center);
2087    }
2088
2089    #[test]
2090    fn test_parse_pipe_table_row() {
2091        let cells = parse_pipe_table_row("| Right | Left | Center |");
2092        assert_eq!(cells.len(), 3);
2093        assert_eq!(cells[0], "Right");
2094        assert_eq!(cells[1], "Left");
2095        assert_eq!(cells[2], "Center");
2096
2097        // Without leading/trailing pipes
2098        let cells2 = parse_pipe_table_row("Right | Left | Center");
2099        assert_eq!(cells2.len(), 3);
2100    }
2101
2102    #[test]
2103    fn test_basic_pipe_table() {
2104        let input = vec![
2105            "",
2106            "| Right | Left | Center |",
2107            "|------:|:-----|:------:|",
2108            "|   12  |  12  |   12   |",
2109            "|  123  |  123 |  123   |",
2110            "",
2111        ];
2112
2113        let mut builder = GreenNodeBuilder::new();
2114        let prefix = ContainerPrefix::default();
2115        let window = StrippedLines::new(&input, 1, &prefix);
2116        let result = try_parse_pipe_table(&window, &mut builder, &ParserOptions::default());
2117
2118        assert!(result.is_some());
2119        assert_eq!(result.unwrap(), 4); // header + sep + 2 rows
2120    }
2121
2122    #[test]
2123    fn test_pipe_table_no_edge_pipes() {
2124        let input = vec![
2125            "",
2126            "fruit| price",
2127            "-----|-----:",
2128            "apple|2.05",
2129            "pear|1.37",
2130            "",
2131        ];
2132
2133        let mut builder = GreenNodeBuilder::new();
2134        let prefix = ContainerPrefix::default();
2135        let window = StrippedLines::new(&input, 1, &prefix);
2136        let result = try_parse_pipe_table(&window, &mut builder, &ParserOptions::default());
2137
2138        assert!(result.is_some());
2139        assert_eq!(result.unwrap(), 4);
2140    }
2141
2142    #[test]
2143    fn test_pipe_table_with_caption() {
2144        let input = vec![
2145            "",
2146            "| Col1 | Col2 |",
2147            "|------|------|",
2148            "| A    | B    |",
2149            "",
2150            "Table: My pipe table",
2151            "",
2152        ];
2153
2154        let mut builder = GreenNodeBuilder::new();
2155        let prefix = ContainerPrefix::default();
2156        let window = StrippedLines::new(&input, 1, &prefix);
2157        let result = try_parse_pipe_table(&window, &mut builder, &ParserOptions::default());
2158
2159        assert!(result.is_some());
2160        assert_eq!(result.unwrap(), 5); // header + sep + row + blank + caption
2161    }
2162
2163    #[test]
2164    fn test_pipe_table_with_multiline_caption_before() {
2165        let input = vec![
2166            ": (#tab:base) base R quoting",
2167            "functions",
2168            "",
2169            "| C | D |",
2170            "|---|---|",
2171            "| 3 | 4 |",
2172            "",
2173        ];
2174
2175        let mut builder = GreenNodeBuilder::new();
2176        let prefix = ContainerPrefix::default();
2177        let window = StrippedLines::new(&input, 0, &prefix);
2178        let result = try_parse_pipe_table(&window, &mut builder, &ParserOptions::default());
2179
2180        assert!(result.is_some());
2181        // caption(2) + blank(1) + header + sep + row
2182        assert_eq!(result.unwrap(), 6);
2183    }
2184}
2185
2186// ============================================================================
2187// Grid Table Parsing
2188// ============================================================================
2189
2190/// Check if a line is a grid table row separator (starts with +, contains -, ends with +).
2191/// Returns Some(vec of column info) if valid, None otherwise.
2192fn try_parse_grid_separator(line: &str) -> Option<Vec<GridColumn>> {
2193    let trimmed = line.trim_start();
2194    let leading_spaces = line.len() - trimmed.len();
2195
2196    // A grid border must begin at column 0 of its container content. Detection
2197    // runs on the container-prefix-stripped line (see `try_parse_grid_table`),
2198    // so any remaining leading whitespace means the border is indented relative
2199    // to its container -- pandoc parses that as a paragraph, not a grid table.
2200    if leading_spaces > 0 {
2201        return None;
2202    }
2203
2204    // Must start with + and end with +
2205    if !trimmed.starts_with('+') || !trimmed.trim_end().ends_with('+') {
2206        return None;
2207    }
2208
2209    // Split by + to get column segments
2210    let trimmed = trimmed.trim_end();
2211    let segments: Vec<&str> = trimmed.split('+').collect();
2212
2213    // Need at least 3 parts: empty before first +, column(s), empty after last +
2214    if segments.len() < 3 {
2215        return None;
2216    }
2217
2218    let mut columns = Vec::new();
2219
2220    // Parse each segment between + signs
2221    for segment in segments.iter().skip(1).take(segments.len() - 2) {
2222        if segment.is_empty() {
2223            continue;
2224        }
2225
2226        // Segment must be dashes/equals with optional colons for alignment
2227        let seg_trimmed = *segment;
2228
2229        // Get the fill character (after removing colons)
2230        let inner = seg_trimmed.trim_start_matches(':').trim_end_matches(':');
2231
2232        // Must be all dashes or all equals
2233        if inner.is_empty() {
2234            return None;
2235        }
2236
2237        let first_char = inner.chars().next().unwrap();
2238        if first_char != '-' && first_char != '=' {
2239            return None;
2240        }
2241
2242        if !inner.chars().all(|c| c == first_char) {
2243            return None;
2244        }
2245
2246        let is_header_sep = first_char == '=';
2247
2248        columns.push(GridColumn {
2249            is_header_separator: is_header_sep,
2250            width: seg_trimmed.chars().count(),
2251        });
2252    }
2253
2254    if columns.is_empty() {
2255        None
2256    } else {
2257        Some(columns)
2258    }
2259}
2260
2261/// Column information for grid tables.
2262#[derive(Debug, Clone)]
2263struct GridColumn {
2264    is_header_separator: bool,
2265    width: usize,
2266}
2267
2268fn slice_cell_by_display_width(line: &str, start_byte: usize, width: usize) -> (usize, usize) {
2269    let mut end_byte = start_byte;
2270    let mut display_cols = 0usize;
2271
2272    for (offset, ch) in line[start_byte..].char_indices() {
2273        if ch == '|' {
2274            let sep_byte = start_byte + offset;
2275            return (sep_byte, sep_byte + 1);
2276        }
2277        let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
2278        if display_cols + ch_width > width {
2279            break;
2280        }
2281        display_cols += ch_width;
2282        end_byte = start_byte + offset + ch.len_utf8();
2283        if display_cols >= width {
2284            break;
2285        }
2286    }
2287
2288    // If the width budget is exhausted before seeing a separator (for example
2289    // because of padding/layout drift), advance to the next literal separator
2290    // to keep row slicing aligned and preserve losslessness.
2291    let mut sep_byte = end_byte;
2292    while sep_byte < line.len() {
2293        let mut chars = line[sep_byte..].chars();
2294        let Some(ch) = chars.next() else {
2295            break;
2296        };
2297        if ch == '|' {
2298            return (sep_byte, sep_byte + 1);
2299        }
2300        sep_byte += ch.len_utf8();
2301    }
2302
2303    (end_byte, end_byte)
2304}
2305
2306/// Check if a line is a grid table content row.
2307/// Accepts normal rows ending with `|` and spanning-style continuation lines ending with `+`.
2308fn is_grid_content_row(line: &str) -> bool {
2309    let trimmed = line.trim_start();
2310    let leading_spaces = line.len() - trimmed.len();
2311
2312    if leading_spaces > 3 {
2313        return false;
2314    }
2315
2316    let trimmed = trimmed.trim_end();
2317    trimmed.starts_with('|') && (trimmed.ends_with('|') || trimmed.ends_with('+'))
2318}
2319
2320/// Extract cell contents from a single grid table row line.
2321/// Returns a vector of cell contents (trimmed) based on column boundaries.
2322/// Grid table rows look like: "| Cell 1 | Cell 2 | Cell 3 |"
2323fn extract_grid_cells_from_line(line: &str, _columns: &[GridColumn]) -> Vec<String> {
2324    let (line_content, _) = strip_newline(line);
2325    let line_trimmed = line_content.trim();
2326
2327    if !line_trimmed.starts_with('|') || !line_trimmed.ends_with('|') {
2328        return vec![String::new(); _columns.len()];
2329    }
2330
2331    let mut cells = Vec::with_capacity(_columns.len());
2332    let mut pos_byte = 1; // Skip leading pipe
2333
2334    for col in _columns {
2335        let col_idx = cells.len();
2336        if pos_byte >= line_trimmed.len() {
2337            cells.push(String::new());
2338            continue;
2339        }
2340
2341        let start_byte = pos_byte;
2342        let end_byte = if col_idx + 1 == _columns.len() {
2343            line_trimmed.len().saturating_sub(1) // consume to trailing pipe for last column
2344        } else {
2345            let (end, next_start) = slice_cell_by_display_width(line_trimmed, pos_byte, col.width);
2346            pos_byte = next_start;
2347            end
2348        };
2349        cells.push(line_trimmed[start_byte..end_byte].trim().to_string());
2350        if col_idx + 1 == _columns.len() {
2351            pos_byte = line_trimmed.len();
2352        }
2353    }
2354
2355    cells
2356}
2357
2358/// Emit a grid table row with inline-parsed cells.
2359/// Handles multi-line rows by emitting first line with TABLE_CELL nodes,
2360/// then continuation lines as raw TEXT for losslessness.
2361fn emit_grid_table_row(
2362    builder: &mut GreenNodeBuilder<'static>,
2363    window: &StrippedLines<'_, '_>,
2364    indices: &[usize],
2365    columns: &[GridColumn],
2366    row_kind: SyntaxKind,
2367    config: &ParserOptions,
2368) {
2369    if indices.is_empty() {
2370        return;
2371    }
2372
2373    builder.start_node(row_kind.into());
2374
2375    // Emit first line with TABLE_CELL nodes. The continuation-line container
2376    // prefix (`  > `) is re-emitted as WHITESPACE/BLOCK_QUOTE_MARKER tokens
2377    // inside the row node before the cell text; the returned tail is the
2378    // prefix-stripped line we slice cells from (empty prefix ⇒ raw line).
2379    // Grid table rows look like: "| Cell 1 | Cell 2 | Cell 3 |"
2380    let first_line = window.emit_or_dispatch_tail(builder, indices[0]);
2381    let cell_contents = extract_grid_cells_from_line(first_line, columns);
2382    let (line_without_newline, newline_str) = strip_newline(first_line);
2383    let trimmed = line_without_newline.trim();
2384    let expected_pipe_count = columns.len().saturating_add(1);
2385    let actual_pipe_count = trimmed.chars().filter(|&c| c == '|').count();
2386
2387    // Rows that don't contain all expected column separators (spanning-style rows)
2388    // must be emitted verbatim for losslessness. The first line's prefix was
2389    // already consumed above; emit its tail and each continuation tail.
2390    if actual_pipe_count != expected_pipe_count {
2391        emit_line_tokens(builder, first_line);
2392        for &idx in &indices[1..] {
2393            let tail = window.emit_or_dispatch_tail(builder, idx);
2394            emit_line_tokens(builder, tail);
2395        }
2396        builder.finish_node();
2397        return;
2398    }
2399
2400    // Emit leading whitespace
2401    let leading_ws_len = line_without_newline.len() - line_without_newline.trim_start().len();
2402    if leading_ws_len > 0 {
2403        builder.token(
2404            SyntaxKind::WHITESPACE.into(),
2405            &line_without_newline[..leading_ws_len],
2406        );
2407    }
2408
2409    // Emit leading pipe
2410    if trimmed.starts_with('|') {
2411        builder.token(SyntaxKind::TEXT.into(), "|");
2412    }
2413
2414    // Emit each cell based on fixed column widths from separators
2415    let mut pos_byte = 1usize; // after leading pipe
2416    for (idx, cell_content) in cell_contents.iter().enumerate() {
2417        let part = if idx < columns.len() && pos_byte <= trimmed.len() {
2418            let start_byte = pos_byte;
2419            let end_byte = if idx + 1 == columns.len() && !trimmed.is_empty() {
2420                trimmed.len().saturating_sub(1) // consume to trailing pipe for last column
2421            } else {
2422                let (end, next_start) =
2423                    slice_cell_by_display_width(trimmed, pos_byte, columns[idx].width);
2424                pos_byte = next_start;
2425                end
2426            };
2427            let slice = &trimmed[start_byte..end_byte];
2428            if idx + 1 == columns.len() {
2429                pos_byte = trimmed.len();
2430            }
2431            slice
2432        } else {
2433            ""
2434        };
2435
2436        // Emit leading whitespace in cell
2437        let cell_trimmed = part.trim();
2438        let ws_start_len = part.len() - part.trim_start().len();
2439        if ws_start_len > 0 {
2440            builder.token(SyntaxKind::WHITESPACE.into(), &part[..ws_start_len]);
2441        }
2442
2443        // Emit TABLE_CELL with inline parsing
2444        emit_table_cell(builder, cell_content, config);
2445
2446        // Emit trailing whitespace in cell
2447        let ws_end_start = ws_start_len + cell_trimmed.len();
2448        if ws_end_start < part.len() {
2449            builder.token(SyntaxKind::WHITESPACE.into(), &part[ws_end_start..]);
2450        }
2451
2452        // Emit pipe separator (unless this is the last cell and line doesn't end with |)
2453        if idx < cell_contents.len() - 1 || trimmed.ends_with('|') {
2454            builder.token(SyntaxKind::TEXT.into(), "|");
2455        }
2456    }
2457
2458    // Emit trailing whitespace before newline
2459    let trailing_ws_start = leading_ws_len + trimmed.len();
2460    if trailing_ws_start < line_without_newline.len() {
2461        builder.token(
2462            SyntaxKind::WHITESPACE.into(),
2463            &line_without_newline[trailing_ws_start..],
2464        );
2465    }
2466
2467    // Emit newline
2468    if !newline_str.is_empty() {
2469        builder.token(SyntaxKind::NEWLINE.into(), newline_str);
2470    }
2471
2472    // Emit continuation lines as TEXT for losslessness, re-emitting each
2473    // line's container prefix first.
2474    for &idx in &indices[1..] {
2475        let tail = window.emit_or_dispatch_tail(builder, idx);
2476        emit_line_tokens(builder, tail);
2477    }
2478
2479    builder.finish_node();
2480}
2481
2482/// Try to parse a grid table starting at the given position.
2483/// Returns the number of lines consumed if successful.
2484pub(crate) fn try_parse_grid_table(
2485    window: &StrippedLines<'_, '_>,
2486    builder: &mut GreenNodeBuilder<'static>,
2487    config: &ParserOptions,
2488) -> Option<usize> {
2489    let lines = window.raw();
2490    let start_pos = window.pos();
2491    if start_pos >= lines.len() {
2492        return None;
2493    }
2494
2495    // Grid-border detection reads the stripped view through `UniformStripView`,
2496    // which strips *every* line — including the dispatch line — with the full
2497    // container strip. The strict column-0 check in `try_parse_grid_separator`
2498    // would otherwise reject a `+---+` border sitting at column 0 of a list
2499    // item's inner content if the dispatch line kept its list-indent. With an
2500    // empty prefix the stripped view equals the raw lines. Emission still goes
2501    // through `window.emit_or_dispatch_tail`, which preserves the indent bytes.
2502    // Scans stop at the first blank line, so only a bounded range is stripped.
2503    let view = UniformStripView(window);
2504
2505    // Cheap gate: a grid table's first line is a grid separator (`+---+`/`+===+`),
2506    // unless this is a caption-led table. Table detection runs at every block
2507    // start, so any per-line work for every prose/math paragraph was quadratic
2508    // on large documents. Run the gate on the same `view` the detection uses, so
2509    // it applies inside containers (blockquote/list) too — not just at top level.
2510    if try_parse_grid_separator(view.line(start_pos)).is_none()
2511        && !is_caption_followed_by_table(&view, start_pos)
2512    {
2513        return None;
2514    }
2515
2516    // Check if this line is a caption followed by a table
2517    // If so, the actual table starts after the caption and blank line
2518    let (actual_start, caption_before) = if is_caption_followed_by_table(&view, start_pos) {
2519        let (cap_start, cap_end) = caption_range_starting_at(&view, start_pos)?;
2520        let mut pos = cap_end;
2521        while pos < view.line_count() && view.line(pos).trim().is_empty() {
2522            pos += 1;
2523        }
2524        (pos, Some((cap_start, cap_end)))
2525    } else {
2526        (start_pos, None)
2527    };
2528
2529    if actual_start >= lines.len() {
2530        return None;
2531    }
2532
2533    // First line must be a grid separator
2534    let first_line = view.line(actual_start);
2535    let _columns = try_parse_grid_separator(first_line)?;
2536
2537    // Track table structure
2538    let mut end_pos = actual_start + 1;
2539    let mut found_header_sep = false;
2540    let mut in_footer = false;
2541
2542    // Scan table lines
2543    while end_pos < lines.len() {
2544        let line = view.line(end_pos);
2545
2546        // Check for blank line (table ends)
2547        if line.trim().is_empty() {
2548            break;
2549        }
2550
2551        // Check for separator line
2552        if let Some(sep_cols) = try_parse_grid_separator(line) {
2553            // Check if this is a header separator (=)
2554            if sep_cols.iter().any(|c| c.is_header_separator) {
2555                if !found_header_sep {
2556                    found_header_sep = true;
2557                } else if !in_footer {
2558                    // Second = separator starts footer
2559                    in_footer = true;
2560                }
2561            }
2562            end_pos += 1;
2563            continue;
2564        }
2565
2566        // Check for content row
2567        if is_grid_content_row(line) {
2568            end_pos += 1;
2569            continue;
2570        }
2571
2572        // Not a valid grid table line - table ends
2573        break;
2574    }
2575
2576    // Must have consumed at least 3 lines (top separator, content, bottom separator)
2577    // Or just top + content rows that end with a separator
2578    if end_pos <= actual_start + 1 {
2579        return None;
2580    }
2581
2582    // Last consumed line should be a separator for a well-formed table
2583    // But we'll be lenient and accept tables ending with content rows
2584
2585    // Check for caption before table (only if we didn't already detected it)
2586    let caption_before = caption_before.or_else(|| find_caption_before_table(&view, actual_start));
2587
2588    // Check for caption after table
2589    let caption_after = if caption_before.is_some() {
2590        None
2591    } else {
2592        find_caption_after_table(&view, end_pos)
2593    };
2594
2595    // Build the grid table
2596    builder.start_node(SyntaxKind::GRID_TABLE.into());
2597
2598    // Emit caption before if present
2599    if let Some((cap_start, cap_end)) = caption_before {
2600        emit_table_caption(builder, window, cap_start, cap_end, config);
2601        // Emit blank line between caption and table if present
2602        emit_caption_blank_lines(builder, window, cap_end, actual_start);
2603    }
2604
2605    // Track whether we've passed the header separator
2606    let mut past_header_sep = false;
2607    let mut in_footer_section = false;
2608    // Accumulate ABSOLUTE indices of the lines making up a multi-line row, so
2609    // each line's container prefix can be re-emitted via the window.
2610    let mut current_row_indices: Vec<usize> = Vec::new();
2611    let mut current_row_kind = SyntaxKind::TABLE_HEADER;
2612
2613    // Emit table rows - accumulate multi-line cells
2614    for idx in actual_start..end_pos {
2615        let line = view.line(idx);
2616        if let Some(sep_cols) = try_parse_grid_separator(line) {
2617            // Separator line - emit any accumulated row first
2618            if !current_row_indices.is_empty() {
2619                emit_grid_table_row(
2620                    builder,
2621                    window,
2622                    &current_row_indices,
2623                    &sep_cols,
2624                    current_row_kind,
2625                    config,
2626                );
2627                current_row_indices.clear();
2628            }
2629
2630            let is_header_sep = sep_cols.iter().any(|c| c.is_header_separator);
2631
2632            // Re-emit any continuation-line container prefix (`  > `) as
2633            // WHITESPACE/BLOCK_QUOTE_MARKER tokens before the separator text.
2634            if is_header_sep {
2635                if !past_header_sep {
2636                    // This is the header/body separator
2637                    builder.start_node(SyntaxKind::TABLE_SEPARATOR.into());
2638                    let tail = window.emit_or_dispatch_tail(builder, idx);
2639                    emit_separator_tokens(builder, tail);
2640                    builder.finish_node();
2641                    past_header_sep = true;
2642                } else {
2643                    // Footer separator
2644                    if !in_footer_section {
2645                        in_footer_section = true;
2646                    }
2647                    builder.start_node(SyntaxKind::TABLE_SEPARATOR.into());
2648                    let tail = window.emit_or_dispatch_tail(builder, idx);
2649                    emit_separator_tokens(builder, tail);
2650                    builder.finish_node();
2651                }
2652            } else {
2653                // Regular separator (row boundary)
2654                builder.start_node(SyntaxKind::TABLE_SEPARATOR.into());
2655                let tail = window.emit_or_dispatch_tail(builder, idx);
2656                emit_separator_tokens(builder, tail);
2657                builder.finish_node();
2658            }
2659        } else if is_grid_content_row(line) {
2660            // Content row - accumulate for multi-line cells
2661            current_row_kind = if !past_header_sep && found_header_sep {
2662                SyntaxKind::TABLE_HEADER
2663            } else if in_footer_section {
2664                SyntaxKind::TABLE_FOOTER
2665            } else {
2666                SyntaxKind::TABLE_ROW
2667            };
2668
2669            current_row_indices.push(idx);
2670        }
2671    }
2672
2673    // Emit any remaining accumulated row
2674    if !current_row_indices.is_empty() {
2675        // Use first separator's columns for cell boundaries
2676        if let Some(sep_cols) = try_parse_grid_separator(view.line(actual_start)) {
2677            emit_grid_table_row(
2678                builder,
2679                window,
2680                &current_row_indices,
2681                &sep_cols,
2682                current_row_kind,
2683                config,
2684            );
2685        }
2686    }
2687
2688    // Emit caption after if present
2689    if let Some((cap_start, cap_end)) = caption_after {
2690        emit_caption_blank_lines(builder, window, end_pos, cap_start);
2691        emit_table_caption(builder, window, cap_start, cap_end, config);
2692    }
2693
2694    builder.finish_node(); // GRID_TABLE
2695
2696    // Calculate lines consumed
2697    let table_start = caption_before
2698        .map(|(start, _)| start)
2699        .unwrap_or(actual_start);
2700    let table_end = if let Some((_, cap_end)) = caption_after {
2701        cap_end
2702    } else {
2703        end_pos
2704    };
2705
2706    Some(table_end - table_start)
2707}
2708
2709#[cfg(test)]
2710mod grid_table_tests {
2711    use super::super::container_prefix::ContainerPrefix;
2712    use super::*;
2713
2714    #[test]
2715    fn test_grid_separator_detection() {
2716        assert!(try_parse_grid_separator("+---+---+").is_some());
2717        assert!(try_parse_grid_separator("+===+===+").is_some());
2718        assert!(try_parse_grid_separator("+---------------+---------------+").is_some());
2719        assert!(try_parse_grid_separator("+:---:+").is_some()); // center aligned
2720        assert!(try_parse_grid_separator("not a separator").is_none());
2721        assert!(try_parse_grid_separator("|---|---|").is_none()); // pipe table sep
2722
2723        // A grid border must sit at column 0 of its container content; an
2724        // indented border is not a grid table (matches pandoc, which parses
2725        // an indented `+---+` as a paragraph). Detection runs on the
2726        // container-stripped line, so any remaining leading space disqualifies.
2727        assert!(try_parse_grid_separator(" +---+---+").is_none());
2728        assert!(try_parse_grid_separator("  +---+---+").is_none());
2729        assert!(try_parse_grid_separator("   +===+===+").is_none());
2730    }
2731
2732    #[test]
2733    fn test_grid_header_separator() {
2734        let cols = try_parse_grid_separator("+===+===+").unwrap();
2735        assert!(cols.iter().all(|c| c.is_header_separator));
2736
2737        let cols2 = try_parse_grid_separator("+---+---+").unwrap();
2738        assert!(cols2.iter().all(|c| !c.is_header_separator));
2739    }
2740
2741    #[test]
2742    fn test_grid_content_row_detection() {
2743        assert!(is_grid_content_row("| content | content |"));
2744        assert!(is_grid_content_row("|  |  |"));
2745        assert!(is_grid_content_row("| content +------+"));
2746        assert!(!is_grid_content_row("+---+---+")); // separator, not content
2747        assert!(!is_grid_content_row("no pipes here"));
2748    }
2749
2750    #[test]
2751    fn test_basic_grid_table() {
2752        let input = vec![
2753            "+-------+-------+",
2754            "| Col1  | Col2  |",
2755            "+=======+=======+",
2756            "| A     | B     |",
2757            "+-------+-------+",
2758            "",
2759        ];
2760
2761        let mut builder = GreenNodeBuilder::new();
2762        let prefix = ContainerPrefix::default();
2763        let window = StrippedLines::new(&input, 0, &prefix);
2764        let result = try_parse_grid_table(&window, &mut builder, &ParserOptions::default());
2765
2766        assert!(result.is_some());
2767        assert_eq!(result.unwrap(), 5);
2768    }
2769
2770    #[test]
2771    fn test_grid_table_multirow() {
2772        let input = vec![
2773            "+---------------+---------------+",
2774            "| Fruit         | Advantages    |",
2775            "+===============+===============+",
2776            "| Bananas       | - wrapper     |",
2777            "|               | - color       |",
2778            "+---------------+---------------+",
2779            "| Oranges       | - scurvy      |",
2780            "|               | - tasty       |",
2781            "+---------------+---------------+",
2782            "",
2783        ];
2784
2785        let mut builder = GreenNodeBuilder::new();
2786        let prefix = ContainerPrefix::default();
2787        let window = StrippedLines::new(&input, 0, &prefix);
2788        let result = try_parse_grid_table(&window, &mut builder, &ParserOptions::default());
2789
2790        assert!(result.is_some());
2791        assert_eq!(result.unwrap(), 9);
2792    }
2793
2794    #[test]
2795    fn test_grid_table_with_footer() {
2796        let input = vec![
2797            "+-------+-------+",
2798            "| Fruit | Price |",
2799            "+=======+=======+",
2800            "| Apple | $1.00 |",
2801            "+-------+-------+",
2802            "| Pear  | $1.50 |",
2803            "+=======+=======+",
2804            "| Total | $2.50 |",
2805            "+=======+=======+",
2806            "",
2807        ];
2808
2809        let mut builder = GreenNodeBuilder::new();
2810        let prefix = ContainerPrefix::default();
2811        let window = StrippedLines::new(&input, 0, &prefix);
2812        let result = try_parse_grid_table(&window, &mut builder, &ParserOptions::default());
2813
2814        assert!(result.is_some());
2815        assert_eq!(result.unwrap(), 9);
2816    }
2817
2818    #[test]
2819    fn test_grid_table_headerless() {
2820        let input = vec![
2821            "+-------+-------+",
2822            "| A     | B     |",
2823            "+-------+-------+",
2824            "| C     | D     |",
2825            "+-------+-------+",
2826            "",
2827        ];
2828
2829        let mut builder = GreenNodeBuilder::new();
2830        let prefix = ContainerPrefix::default();
2831        let window = StrippedLines::new(&input, 0, &prefix);
2832        let result = try_parse_grid_table(&window, &mut builder, &ParserOptions::default());
2833
2834        assert!(result.is_some());
2835        assert_eq!(result.unwrap(), 5);
2836    }
2837
2838    #[test]
2839    fn test_grid_table_with_caption_before() {
2840        let input = vec![
2841            ": Sample table",
2842            "",
2843            "+-------+-------+",
2844            "| A     | B     |",
2845            "+=======+=======+",
2846            "| C     | D     |",
2847            "+-------+-------+",
2848            "",
2849        ];
2850
2851        let mut builder = GreenNodeBuilder::new();
2852        let prefix = ContainerPrefix::default();
2853        let window = StrippedLines::new(&input, 2, &prefix);
2854        let result = try_parse_grid_table(&window, &mut builder, &ParserOptions::default());
2855
2856        assert!(result.is_some());
2857        // Should include caption + blank + table
2858        assert_eq!(result.unwrap(), 7);
2859    }
2860
2861    #[test]
2862    fn test_grid_table_with_caption_after() {
2863        let input = vec![
2864            "+-------+-------+",
2865            "| A     | B     |",
2866            "+=======+=======+",
2867            "| C     | D     |",
2868            "+-------+-------+",
2869            "",
2870            "Table: My grid table",
2871            "",
2872        ];
2873
2874        let mut builder = GreenNodeBuilder::new();
2875        let prefix = ContainerPrefix::default();
2876        let window = StrippedLines::new(&input, 0, &prefix);
2877        let result = try_parse_grid_table(&window, &mut builder, &ParserOptions::default());
2878
2879        assert!(result.is_some());
2880        // table + blank + caption
2881        assert_eq!(result.unwrap(), 7);
2882    }
2883}
2884
2885// ============================================================================
2886// Multiline Table Parsing
2887// ============================================================================
2888
2889/// Check if a line is a multiline table separator (continuous dashes).
2890/// Multiline table separators span the full width and are all dashes.
2891/// Returns Some(columns) if valid, None otherwise.
2892fn try_parse_multiline_separator(line: &str) -> Option<Vec<Column>> {
2893    let trimmed = line.trim_start();
2894    let leading_spaces = line.len() - trimmed.len();
2895
2896    // Must have leading spaces <= 3 to not be a code block
2897    if leading_spaces > 3 {
2898        return None;
2899    }
2900
2901    let trimmed = trimmed.trim_end();
2902
2903    // Must be all dashes (continuous line of dashes)
2904    if trimmed.is_empty() || !trimmed.chars().all(|c| c == '-') {
2905        return None;
2906    }
2907
2908    // Must have at least 2 dashes. Pandoc accepts even a single dash as a
2909    // full-width border, but a lone `-` reads as a bullet list marker or
2910    // setext underline first, so panache stops at 2 (`--` is neither a rule
2911    // nor a list marker).
2912    if trimmed.len() < 2 {
2913        return None;
2914    }
2915
2916    // This is a full-width separator - columns will be determined by column separator lines
2917    Some(vec![Column {
2918        start: leading_spaces,
2919        end: leading_spaces + trimmed.len(),
2920        alignment: Alignment::Default,
2921    }])
2922}
2923
2924/// Check if a line is a column separator line for multiline tables.
2925/// Column separators have dashes with spaces between them to define columns.
2926fn is_column_separator(line: &str) -> bool {
2927    try_parse_table_separator(line).is_some() && !line.contains('*') && !line.contains('_')
2928}
2929
2930fn is_headerless_single_row_without_blank(
2931    lines: &(impl LineView + ?Sized),
2932    row_start: usize,
2933    row_end: usize,
2934    columns: &[Column],
2935) -> bool {
2936    if row_start >= row_end {
2937        return false;
2938    }
2939
2940    if row_end - row_start == 1 {
2941        return false;
2942    }
2943
2944    let Some(last_col) = columns.last() else {
2945        return false;
2946    };
2947
2948    for i in (row_start + 1)..row_end {
2949        let (content, _) = strip_newline(lines.line(i));
2950        let prefix_end = last_col.start.min(content.len());
2951        if !content[..prefix_end].trim().is_empty() {
2952            return false;
2953        }
2954    }
2955
2956    true
2957}
2958
2959/// Try to parse a multiline table starting at the given position.
2960/// Returns the number of lines consumed if successful.
2961pub(crate) fn try_parse_multiline_table(
2962    window: &StrippedLines<'_, '_>,
2963    builder: &mut GreenNodeBuilder<'static>,
2964    config: &ParserOptions,
2965) -> Option<usize> {
2966    let lines = window.raw();
2967    let start_pos = window.pos();
2968    if start_pos >= lines.len() {
2969        return None;
2970    }
2971
2972    // Cheap gate: a multiline table's first line is either a full-width dash
2973    // separator or a column separator. Table detection runs at every block
2974    // start, so any per-line work for every paragraph that can't begin a
2975    // multiline table was quadratic on large documents. Peek just the dispatch
2976    // line via `strip_at` and bail before any further scanning.
2977    let first_line = window.strip_at(start_pos);
2978
2979    // First line can be either:
2980    // 1. A full-width dash separator (for tables with headers)
2981    // 2. A column separator (for headerless tables)
2982    let is_full_width_start = try_parse_multiline_separator(first_line).is_some();
2983    let is_column_sep_start = !is_full_width_start && is_column_separator(first_line);
2984    if !is_full_width_start && !is_column_sep_start {
2985        return None;
2986    }
2987
2988    // Detection scans read the container-prefix-stripped view lazily through the
2989    // window (see `LineView`) so a multiline table nested in `list → blockquote`
2990    // (e.g. `- > ----`) has its `  > ` prefix removed before the
2991    // separator/blank-row shape checks. The interior `>`-only row then strips to
2992    // `""` and registers as a blank row separator. With an empty prefix the
2993    // stripped view equals the raw lines. Scans stop at the first blank/closing
2994    // line, so only a bounded range is stripped. Emission re-emits the prefix
2995    // bytes as tokens via the window; captions read raw `lines`.
2996    let headerless_columns = if is_column_sep_start {
2997        try_parse_table_separator(window.line(start_pos))
2998    } else {
2999        None
3000    };
3001
3002    // A headerless opening with at least one multi-dash column run is a genuine
3003    // table border (`------  ------`), as opposed to a spaced thematic break
3004    // whose "columns" are all single dashes (`- - - - -`). Only a genuine border
3005    // may close on a bare continuous dash run (the closer broadening below);
3006    // otherwise `- - - - -` would swallow following blocks up to the next
3007    // thematic break. A column-separator closer still works for either shape.
3008    let opening_has_wide_column = headerless_columns
3009        .as_deref()
3010        .is_some_and(|cols| cols.iter().any(|col| col.end - col.start >= 2));
3011
3012    // Look ahead to find the structure
3013    let mut pos = start_pos + 1;
3014    let mut found_column_sep = is_column_sep_start; // Already found if headerless
3015    let mut column_sep_pos = if is_column_sep_start { start_pos } else { 0 };
3016    let mut has_header = false;
3017    let mut found_blank_line = false;
3018    let mut found_closing_sep = false;
3019    let mut content_line_count = 0usize;
3020
3021    // A bare `---` opener is also a plain horizontal rule, so the
3022    // single-column reinterpretation below must not let the scan cross the
3023    // enclosing container's end looking for a closer: a truly blank line ends
3024    // a blockquote (a quoted blank still carries `>`), and a `:::` fence
3025    // closes the enclosing div (the convention the simple-table scans already
3026    // follow). Multi-group shapes keep their historical scan behavior.
3027    let bq_prefixed = window.prefix().bq_depth() > 0;
3028    let mut crossed_scope_boundary = false;
3029
3030    // Scan for header section and column separator
3031    while pos < lines.len() {
3032        let line = window.line(pos);
3033
3034        if line_is_fenced_div_fence(line) || (bq_prefixed && lines[pos].trim().is_empty()) {
3035            crossed_scope_boundary = true;
3036        }
3037
3038        // Check for column separator (defines columns) - only if we started with full-width
3039        if is_full_width_start && is_column_separator(line) && !found_column_sep {
3040            found_column_sep = true;
3041            column_sep_pos = pos;
3042            has_header = pos > start_pos + 1; // Has header if there's content before column sep
3043            pos += 1;
3044            continue;
3045        }
3046
3047        // Check for blank line (row separator in body)
3048        if line.trim().is_empty() {
3049            found_blank_line = true;
3050            pos += 1;
3051            // Check if next line is a valid closing separator for this table shape.
3052            if pos < lines.len() {
3053                let next = window.line(pos);
3054                let is_valid_closer = if is_full_width_start {
3055                    try_parse_multiline_separator(next).is_some()
3056                } else {
3057                    // A headerless table may close with a column separator
3058                    // (`---- ----`) or a single continuous dash run (`--------`).
3059                    // The latter is rejected by `is_column_separator` (one dash
3060                    // group reads as a thematic rule), so accept it via the
3061                    // multiline-separator check too — but only for a genuine
3062                    // border (see `opening_has_wide_column`). Matches pandoc,
3063                    // which ends the table on either shape.
3064                    is_column_separator(next)
3065                        || (opening_has_wide_column
3066                            && try_parse_multiline_separator(next).is_some())
3067                };
3068                if is_valid_closer {
3069                    found_closing_sep = true;
3070                    pos += 1; // Include the closing separator
3071                    break;
3072                }
3073            }
3074            continue;
3075        }
3076
3077        // Check for closing full-width dashes (only for full-width-start tables).
3078        if is_full_width_start && try_parse_multiline_separator(line).is_some() {
3079            found_closing_sep = true;
3080            pos += 1;
3081            break;
3082        }
3083
3084        // Check for closing column separator (for headerless tables)
3085        if is_column_sep_start && is_column_separator(line) && content_line_count > 0 {
3086            found_closing_sep = true;
3087            pos += 1;
3088            break;
3089        }
3090
3091        // Content row
3092        content_line_count += 1;
3093        pos += 1;
3094    }
3095
3096    // A headerless *single-column* table has no multi-group column separator:
3097    // its bare dash-run opener doubles as the column definition (`---`, rows
3098    // with blank separators, `---`). Pandoc parses this shape as a table, so
3099    // accept it when the scan saw blank-separated content and a closing dash
3100    // run. Without a blank line the span stays with the headerless simple
3101    // table path (one row per line, no soft-break joining), and a blank line
3102    // directly after the opener disqualifies the table (pandoc keeps the
3103    // rule-plus-blocks reading there), both matching pandoc.
3104    let first_row_adjacent =
3105        start_pos + 1 < lines.len() && !window.line(start_pos + 1).trim().is_empty();
3106    let headerless_single_column = !found_column_sep
3107        && is_full_width_start
3108        && first_row_adjacent
3109        && found_blank_line
3110        && found_closing_sep
3111        && !crossed_scope_boundary;
3112    if headerless_single_column {
3113        found_column_sep = true;
3114        column_sep_pos = start_pos;
3115    }
3116
3117    // Must have found a column separator to be a valid multiline table
3118    if !found_column_sep {
3119        return None;
3120    }
3121
3122    // A blank line between rows is one way to tell a multiline table from a
3123    // simple one, but not the only one. A full-width top border (the
3124    // `is_full_width_start` case) already distinguishes a multiline table from
3125    // a simple table, so pandoc accepts it even when every row is a single line
3126    // with no interior blanks; the required column separator and closing border
3127    // (checked above and below) keep a bare thematic break from matching. Only
3128    // the headerless, column-separator-started shape still needs the
3129    // single-row guard.
3130    if !found_blank_line && is_column_sep_start {
3131        let columns = headerless_columns.as_deref()?;
3132        if !is_headerless_single_row_without_blank(window, start_pos + 1, pos - 1, columns) {
3133            return None;
3134        }
3135    }
3136
3137    // Must have a closing separator
3138    if !found_closing_sep {
3139        return None;
3140    }
3141
3142    // Must have consumed more than just the opening separator
3143    if pos <= start_pos + 2 {
3144        return None;
3145    }
3146
3147    let end_pos = pos;
3148
3149    // Extract column boundaries from the separator line. A single-column
3150    // headerless table's separator is a bare dash run, which
3151    // `try_parse_table_separator` rejects (one dash group reads as a rule),
3152    // so fall back to the single-run parser.
3153    let columns = try_parse_table_separator(window.line(column_sep_pos))
3154        .or_else(|| parse_single_dash_run(window.line(column_sep_pos)))?;
3155
3156    // Check for caption before table
3157    let caption_before = find_caption_before_table(window, start_pos);
3158
3159    // Check for caption after table
3160    let caption_after = if caption_before.is_some() {
3161        None
3162    } else {
3163        find_caption_after_table(window, end_pos)
3164    };
3165
3166    // Build the multiline table
3167    builder.start_node(SyntaxKind::MULTILINE_TABLE.into());
3168
3169    // Emit caption before if present
3170    if let Some((cap_start, cap_end)) = caption_before {
3171        emit_table_caption(builder, window, cap_start, cap_end, config);
3172        // Emit blank line between caption and table if present
3173        emit_caption_blank_lines(builder, window, cap_end, start_pos);
3174    }
3175
3176    // Emit opening separator. The dispatch line's prefix was already consumed
3177    // by core (`dispatch_tail`); a non-dispatch start (caption-before case)
3178    // re-emits its `  > ` prefix via `emit_prefix_at`.
3179    builder.start_node(SyntaxKind::TABLE_SEPARATOR.into());
3180    let tail = window.emit_or_dispatch_tail(builder, start_pos);
3181    emit_separator_tokens(builder, tail);
3182    builder.finish_node();
3183
3184    // Track state for emitting. Accumulate ABSOLUTE indices of the lines making
3185    // up a multi-line row so each line's container prefix can be re-emitted via
3186    // the window.
3187    let mut in_header = has_header;
3188    let mut current_row_indices: Vec<usize> = Vec::new();
3189
3190    for i in (start_pos + 1)..end_pos {
3191        let line = window.line(i);
3192        // Column separator (header/body divider)
3193        if i == column_sep_pos {
3194            // Emit any accumulated header lines
3195            if !current_row_indices.is_empty() {
3196                emit_multiline_table_row(
3197                    builder,
3198                    window,
3199                    &current_row_indices,
3200                    &columns,
3201                    SyntaxKind::TABLE_HEADER,
3202                    config,
3203                );
3204                current_row_indices.clear();
3205            }
3206
3207            builder.start_node(SyntaxKind::TABLE_SEPARATOR.into());
3208            let tail = window.emit_or_dispatch_tail(builder, i);
3209            emit_separator_tokens(builder, tail);
3210            builder.finish_node();
3211            in_header = false;
3212            continue;
3213        }
3214
3215        // Closing separator (full-width or column separator at end)
3216        if try_parse_multiline_separator(line).is_some() || is_column_separator(line) {
3217            // Emit any accumulated row lines
3218            if !current_row_indices.is_empty() {
3219                let kind = if in_header {
3220                    SyntaxKind::TABLE_HEADER
3221                } else {
3222                    SyntaxKind::TABLE_ROW
3223                };
3224                emit_multiline_table_row(
3225                    builder,
3226                    window,
3227                    &current_row_indices,
3228                    &columns,
3229                    kind,
3230                    config,
3231                );
3232                current_row_indices.clear();
3233            }
3234
3235            builder.start_node(SyntaxKind::TABLE_SEPARATOR.into());
3236            let tail = window.emit_or_dispatch_tail(builder, i);
3237            emit_separator_tokens(builder, tail);
3238            builder.finish_node();
3239            continue;
3240        }
3241
3242        // Blank line (row separator)
3243        if line.trim().is_empty() {
3244            // Emit accumulated row
3245            if !current_row_indices.is_empty() {
3246                let kind = if in_header {
3247                    SyntaxKind::TABLE_HEADER
3248                } else {
3249                    SyntaxKind::TABLE_ROW
3250                };
3251                emit_multiline_table_row(
3252                    builder,
3253                    window,
3254                    &current_row_indices,
3255                    &columns,
3256                    kind,
3257                    config,
3258                );
3259                current_row_indices.clear();
3260            }
3261
3262            // Re-emit the interior `>`-only separator row's container prefix
3263            // (`  > `) inside the BLANK_LINE node so it round-trips losslessly.
3264            builder.start_node(SyntaxKind::BLANK_LINE.into());
3265            let tail = window.emit_or_dispatch_tail(builder, i);
3266            builder.token(SyntaxKind::BLANK_LINE.into(), tail);
3267            builder.finish_node();
3268            continue;
3269        }
3270
3271        // Content line - accumulate for current row
3272        current_row_indices.push(i);
3273    }
3274
3275    // Emit any remaining accumulated lines
3276    if !current_row_indices.is_empty() {
3277        let kind = if in_header {
3278            SyntaxKind::TABLE_HEADER
3279        } else {
3280            SyntaxKind::TABLE_ROW
3281        };
3282        emit_multiline_table_row(
3283            builder,
3284            window,
3285            &current_row_indices,
3286            &columns,
3287            kind,
3288            config,
3289        );
3290    }
3291
3292    // Emit caption after if present
3293    if let Some((cap_start, cap_end)) = caption_after {
3294        emit_caption_blank_lines(builder, window, end_pos, cap_start);
3295        emit_table_caption(builder, window, cap_start, cap_end, config);
3296    }
3297
3298    builder.finish_node(); // MultilineTable
3299
3300    // Calculate lines consumed
3301    let table_start = caption_before.map(|(start, _)| start).unwrap_or(start_pos);
3302    let table_end = if let Some((_, cap_end)) = caption_after {
3303        cap_end
3304    } else {
3305        end_pos
3306    };
3307
3308    Some(table_end - table_start)
3309}
3310
3311/// Extract cell contents from first line only (for CST emission).
3312/// Multi-line content will be in continuation TEXT tokens.
3313fn extract_first_line_cell_contents(line: &str, columns: &[Column]) -> Vec<String> {
3314    let (line_content, _) = strip_newline(line);
3315    let mut cells = Vec::new();
3316
3317    for (i, column) in columns.iter().enumerate() {
3318        let column_start = column_offset_to_byte_index(line_content, column.start);
3319        // The last column runs to end-of-line (pandoc takes the remainder);
3320        // stopping at the dash-run end would split cell text that overruns a
3321        // short run into the cell plus a bogus WHITESPACE token.
3322        let column_end = if i + 1 == columns.len() {
3323            line_content.len().max(column_start)
3324        } else {
3325            column_offset_to_byte_index(line_content, column.end)
3326        };
3327
3328        // Extract FULL text for this column (including whitespace)
3329        let cell_text = if column_start < column_end {
3330            &line_content[column_start..column_end]
3331        } else if column_start < line_content.len() {
3332            &line_content[column_start..]
3333        } else {
3334            ""
3335        };
3336
3337        cells.push(cell_text.to_string());
3338    }
3339
3340    cells
3341}
3342
3343/// Emit a multiline table row with inline parsing (Phase 7.1).
3344///
3345/// `indices` are ABSOLUTE line indices into the window's raw buffer; each
3346/// physical line re-emits its container prefix (`  > `) via the window before
3347/// its content. With an empty prefix the tails equal the raw lines, so emission
3348/// is byte-identical to the pre-window path.
3349fn emit_multiline_table_row(
3350    builder: &mut GreenNodeBuilder<'static>,
3351    window: &StrippedLines<'_, '_>,
3352    indices: &[usize],
3353    columns: &[Column],
3354    kind: SyntaxKind,
3355    config: &ParserOptions,
3356) {
3357    if indices.is_empty() {
3358        return;
3359    }
3360
3361    builder.start_node(kind.into());
3362
3363    // Emit the first line's container prefix as tokens, then slice cells from
3364    // the prefix-stripped tail (for CST losslessness, only the first physical
3365    // line is parsed into cells; continuation lines stay verbatim TEXT).
3366    let first_line = window.emit_or_dispatch_tail(builder, indices[0]);
3367    let cell_contents = extract_first_line_cell_contents(first_line, columns);
3368    let (trimmed, newline_str) = strip_newline(first_line);
3369    let mut current_pos = 0;
3370
3371    for (col_idx, column) in columns.iter().enumerate() {
3372        let cell_text = &cell_contents[col_idx];
3373        let cell_start = column_offset_to_byte_index(trimmed, column.start);
3374        // Keep in sync with `extract_first_line_cell_contents`: the last
3375        // column runs to end-of-line.
3376        let cell_end = if col_idx + 1 == columns.len() {
3377            trimmed.len().max(cell_start)
3378        } else {
3379            column_offset_to_byte_index(trimmed, column.end)
3380        };
3381
3382        // Emit whitespace before cell
3383        if current_pos < cell_start {
3384            builder.token(
3385                SyntaxKind::WHITESPACE.into(),
3386                &trimmed[current_pos..cell_start],
3387            );
3388        }
3389
3390        // Emit cell with inline parsing (first line content only)
3391        emit_table_cell(builder, cell_text, config);
3392
3393        current_pos = cell_end;
3394    }
3395
3396    // Emit trailing whitespace
3397    if current_pos < trimmed.len() {
3398        builder.token(SyntaxKind::WHITESPACE.into(), &trimmed[current_pos..]);
3399    }
3400
3401    // Emit newline
3402    if !newline_str.is_empty() {
3403        builder.token(SyntaxKind::NEWLINE.into(), newline_str);
3404    }
3405
3406    // Emit continuation lines as TEXT to preserve exact line structure,
3407    // re-emitting each line's container prefix first.
3408    for &idx in &indices[1..] {
3409        let tail = window.emit_or_dispatch_tail(builder, idx);
3410        emit_line_tokens(builder, tail);
3411    }
3412
3413    builder.finish_node();
3414}
3415
3416#[cfg(test)]
3417mod multiline_table_tests {
3418    use super::super::container_prefix::ContainerPrefix;
3419    use super::*;
3420    use crate::syntax::SyntaxNode;
3421
3422    #[test]
3423    fn test_multiline_separator_detection() {
3424        assert!(
3425            try_parse_multiline_separator(
3426                "-------------------------------------------------------------"
3427            )
3428            .is_some()
3429        );
3430        assert!(try_parse_multiline_separator("---").is_some());
3431        assert!(try_parse_multiline_separator("  -----").is_some()); // with leading spaces
3432        assert!(try_parse_multiline_separator("--").is_some()); // pandoc accepts 2 dashes
3433        assert!(try_parse_multiline_separator("-").is_none()); // list marker/setext territory
3434        assert!(try_parse_multiline_separator("--- ---").is_none()); // has spaces
3435        assert!(try_parse_multiline_separator("+---+").is_none()); // grid separator
3436    }
3437
3438    #[test]
3439    fn test_basic_multiline_table() {
3440        let input = vec![
3441            "-------------------------------------------------------------",
3442            " Centered   Default           Right Left",
3443            "  Header    Aligned         Aligned Aligned",
3444            "----------- ------- --------------- -------------------------",
3445            "   First    row                12.0 Example of a row that",
3446            "                                    spans multiple lines.",
3447            "",
3448            "  Second    row                 5.0 Here's another one.",
3449            "-------------------------------------------------------------",
3450            "",
3451        ];
3452
3453        let mut builder = GreenNodeBuilder::new();
3454        let prefix = ContainerPrefix::default();
3455        let window = StrippedLines::new(&input, 0, &prefix);
3456        let result = try_parse_multiline_table(&window, &mut builder, &ParserOptions::default());
3457
3458        assert!(result.is_some());
3459        assert_eq!(result.unwrap(), 9);
3460    }
3461
3462    #[test]
3463    fn test_multiline_table_headerless() {
3464        let input = vec![
3465            "----------- ------- --------------- -------------------------",
3466            "   First    row                12.0 Example of a row that",
3467            "                                    spans multiple lines.",
3468            "",
3469            "  Second    row                 5.0 Here's another one.",
3470            "----------- ------- --------------- -------------------------",
3471            "",
3472        ];
3473
3474        let mut builder = GreenNodeBuilder::new();
3475        let prefix = ContainerPrefix::default();
3476        let window = StrippedLines::new(&input, 0, &prefix);
3477        let result = try_parse_multiline_table(&window, &mut builder, &ParserOptions::default());
3478
3479        assert!(result.is_some());
3480        assert_eq!(result.unwrap(), 6);
3481    }
3482
3483    #[test]
3484    fn test_multiline_table_headerless_single_line_is_not_multiline() {
3485        let input = vec![
3486            "-------     ------ ----------   -------",
3487            "     12     12        12             12",
3488            "-------     ------ ----------   -------",
3489            "",
3490            "Not part of table.",
3491            "",
3492        ];
3493
3494        let mut builder = GreenNodeBuilder::new();
3495        let prefix = ContainerPrefix::default();
3496        let window = StrippedLines::new(&input, 0, &prefix);
3497        let result = try_parse_multiline_table(&window, &mut builder, &ParserOptions::default());
3498
3499        assert!(result.is_none());
3500    }
3501
3502    #[test]
3503    fn test_multiline_table_headerless_single_row_continuation_without_blank_line() {
3504        let input = vec![
3505            "----------  ---------  -----------  ---------------------------",
3506            "   First    row               12.0  Example of a row that spans",
3507            "                                    multiple lines.",
3508            "----------  ---------  -----------  ---------------------------",
3509            "",
3510        ];
3511
3512        let mut builder = GreenNodeBuilder::new();
3513        let prefix = ContainerPrefix::default();
3514        let window = StrippedLines::new(&input, 0, &prefix);
3515        let result = try_parse_multiline_table(&window, &mut builder, &ParserOptions::default());
3516
3517        assert!(result.is_some());
3518        assert_eq!(result.unwrap(), 4);
3519    }
3520
3521    #[test]
3522    fn test_multiline_table_with_caption() {
3523        let input = vec![
3524            "-------------------------------------------------------------",
3525            " Col1       Col2",
3526            "----------- -------",
3527            "   A        B",
3528            "",
3529            "-------------------------------------------------------------",
3530            "",
3531            "Table: Here's the caption.",
3532            "",
3533        ];
3534
3535        let mut builder = GreenNodeBuilder::new();
3536        let prefix = ContainerPrefix::default();
3537        let window = StrippedLines::new(&input, 0, &prefix);
3538        let result = try_parse_multiline_table(&window, &mut builder, &ParserOptions::default());
3539
3540        assert!(result.is_some());
3541        // table (6 lines) + blank + caption
3542        assert_eq!(result.unwrap(), 8);
3543    }
3544
3545    #[test]
3546    fn test_multiline_table_single_row() {
3547        let input = vec![
3548            "---------------------------------------------",
3549            " Header1    Header2",
3550            "----------- -----------",
3551            "   Data     More data",
3552            "",
3553            "---------------------------------------------",
3554            "",
3555        ];
3556
3557        let mut builder = GreenNodeBuilder::new();
3558        let prefix = ContainerPrefix::default();
3559        let window = StrippedLines::new(&input, 0, &prefix);
3560        let result = try_parse_multiline_table(&window, &mut builder, &ParserOptions::default());
3561
3562        assert!(result.is_some());
3563        assert_eq!(result.unwrap(), 6);
3564    }
3565
3566    #[test]
3567    fn test_headerless_multiline_table_does_not_close_on_full_width_rule() {
3568        let input = vec![
3569            "- - - - -",
3570            "Third section with underscores.",
3571            "",
3572            "_____",
3573            "",
3574            "> Quote before rule",
3575            ">",
3576            "> ***",
3577            ">",
3578            "> Quote after rule",
3579            "",
3580            "Final paragraph.",
3581            "",
3582            "Here's a horizontal rule:",
3583            "",
3584            "---",
3585            "Text directly after the horizontal rule.",
3586            "",
3587        ];
3588
3589        let mut builder = GreenNodeBuilder::new();
3590        let prefix = ContainerPrefix::default();
3591        let window = StrippedLines::new(&input, 0, &prefix);
3592        let result = try_parse_multiline_table(&window, &mut builder, &ParserOptions::default());
3593
3594        assert!(result.is_none());
3595    }
3596
3597    #[test]
3598    fn test_not_multiline_table() {
3599        // Simple table should not be parsed as multiline
3600        let input = vec![
3601            "  Right     Left     Center     Default",
3602            "-------     ------ ----------   -------",
3603            "     12     12        12            12",
3604            "",
3605        ];
3606
3607        let mut builder = GreenNodeBuilder::new();
3608        let prefix = ContainerPrefix::default();
3609        let window = StrippedLines::new(&input, 0, &prefix);
3610        let result = try_parse_multiline_table(&window, &mut builder, &ParserOptions::default());
3611
3612        // Should not parse because first line isn't a full-width separator
3613        assert!(result.is_none());
3614    }
3615
3616    // Phase 7.1: Unit tests for emit_table_cell() helper
3617    #[test]
3618    fn test_emit_table_cell_plain_text() {
3619        let mut builder = GreenNodeBuilder::new();
3620        emit_table_cell(&mut builder, "Cell", &ParserOptions::default());
3621        let green = builder.finish();
3622        let node = SyntaxNode::new_root(green);
3623
3624        assert_eq!(node.kind(), SyntaxKind::TABLE_CELL);
3625        assert_eq!(node.text(), "Cell");
3626
3627        // Should have TEXT child
3628        let children: Vec<_> = node.children_with_tokens().collect();
3629        assert_eq!(children.len(), 1);
3630        assert_eq!(children[0].kind(), SyntaxKind::TEXT);
3631    }
3632
3633    #[test]
3634    fn test_emit_table_cell_with_emphasis() {
3635        let mut builder = GreenNodeBuilder::new();
3636        emit_table_cell(&mut builder, "*italic*", &ParserOptions::default());
3637        let green = builder.finish();
3638        let node = SyntaxNode::new_root(green);
3639
3640        assert_eq!(node.kind(), SyntaxKind::TABLE_CELL);
3641        assert_eq!(node.text(), "*italic*");
3642
3643        // Should have EMPHASIS child
3644        let children: Vec<_> = node.children().collect();
3645        assert_eq!(children.len(), 1);
3646        assert_eq!(children[0].kind(), SyntaxKind::EMPHASIS);
3647    }
3648
3649    #[test]
3650    fn test_emit_table_cell_with_code() {
3651        let mut builder = GreenNodeBuilder::new();
3652        emit_table_cell(&mut builder, "`code`", &ParserOptions::default());
3653        let green = builder.finish();
3654        let node = SyntaxNode::new_root(green);
3655
3656        assert_eq!(node.kind(), SyntaxKind::TABLE_CELL);
3657        assert_eq!(node.text(), "`code`");
3658
3659        // Should have CODE_SPAN child
3660        let children: Vec<_> = node.children().collect();
3661        assert_eq!(children.len(), 1);
3662        assert_eq!(children[0].kind(), SyntaxKind::INLINE_CODE);
3663    }
3664
3665    #[test]
3666    fn test_emit_table_cell_with_link() {
3667        let mut builder = GreenNodeBuilder::new();
3668        emit_table_cell(&mut builder, "[text](url)", &ParserOptions::default());
3669        let green = builder.finish();
3670        let node = SyntaxNode::new_root(green);
3671
3672        assert_eq!(node.kind(), SyntaxKind::TABLE_CELL);
3673        assert_eq!(node.text(), "[text](url)");
3674
3675        // Should have LINK child
3676        let children: Vec<_> = node.children().collect();
3677        assert_eq!(children.len(), 1);
3678        assert_eq!(children[0].kind(), SyntaxKind::LINK);
3679    }
3680
3681    #[test]
3682    fn test_emit_table_cell_with_strong() {
3683        let mut builder = GreenNodeBuilder::new();
3684        emit_table_cell(&mut builder, "**bold**", &ParserOptions::default());
3685        let green = builder.finish();
3686        let node = SyntaxNode::new_root(green);
3687
3688        assert_eq!(node.kind(), SyntaxKind::TABLE_CELL);
3689        assert_eq!(node.text(), "**bold**");
3690
3691        // Should have STRONG child
3692        let children: Vec<_> = node.children().collect();
3693        assert_eq!(children.len(), 1);
3694        assert_eq!(children[0].kind(), SyntaxKind::STRONG);
3695    }
3696
3697    #[test]
3698    fn test_emit_table_cell_mixed_inline() {
3699        let mut builder = GreenNodeBuilder::new();
3700        emit_table_cell(
3701            &mut builder,
3702            "Text **bold** and `code`",
3703            &ParserOptions::default(),
3704        );
3705        let green = builder.finish();
3706        let node = SyntaxNode::new_root(green);
3707
3708        assert_eq!(node.kind(), SyntaxKind::TABLE_CELL);
3709        assert_eq!(node.text(), "Text **bold** and `code`");
3710
3711        // Should have multiple children: TEXT, STRONG, TEXT, CODE_SPAN
3712        let children: Vec<_> = node.children_with_tokens().collect();
3713        assert!(children.len() >= 4);
3714
3715        // Check some expected types
3716        assert_eq!(children[0].kind(), SyntaxKind::TEXT);
3717        assert_eq!(children[1].kind(), SyntaxKind::STRONG);
3718    }
3719
3720    #[test]
3721    fn test_emit_table_cell_empty() {
3722        let mut builder = GreenNodeBuilder::new();
3723        emit_table_cell(&mut builder, "", &ParserOptions::default());
3724        let green = builder.finish();
3725        let node = SyntaxNode::new_root(green);
3726
3727        assert_eq!(node.kind(), SyntaxKind::TABLE_CELL);
3728        assert_eq!(node.text(), "");
3729
3730        // Empty cell should have no children
3731        let children: Vec<_> = node.children_with_tokens().collect();
3732        assert_eq!(children.len(), 0);
3733    }
3734
3735    #[test]
3736    fn test_emit_table_cell_escaped_pipe() {
3737        let mut builder = GreenNodeBuilder::new();
3738        emit_table_cell(&mut builder, r"A \| B", &ParserOptions::default());
3739        let green = builder.finish();
3740        let node = SyntaxNode::new_root(green);
3741
3742        assert_eq!(node.kind(), SyntaxKind::TABLE_CELL);
3743        // The escaped pipe should be preserved
3744        assert_eq!(node.text(), r"A \| B");
3745    }
3746}