Skip to main content

twrite_core/batteries/markdown/
table.rs

1use std::ops::Range;
2
3use crate::{EditorBuffer, display_width};
4
5/// Custom highlight tag names emitted for GFM tables.
6///
7/// Battery-only extension point: the core engine never interprets these.
8/// Apps map them to colors via `SyntaxTheme::set_custom_tag_color`; unmapped
9/// names fall back to the editor foreground.
10pub const TABLE_HEADER_TAG: &str = "markdown.table.header";
11/// Custom tag for GFM table body cell content.
12pub const TABLE_CELL_TAG: &str = "markdown.table.cell";
13/// Custom tag for GFM table delimiter rows (`| --- | :-: |`).
14pub const TABLE_DELIMITER_TAG: &str = "markdown.table.delimiter";
15
16/// Column alignment parsed from a GFM delimiter cell (`---`, `:--`, `--:`, `:-:`).
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum TableAlignment {
19    /// `:--` / `:---`.
20    Left,
21    /// `:-:` / `:--:`.
22    Center,
23    /// `--:` / `---:`.
24    Right,
25    /// `---`.
26    None,
27}
28
29/// Which row of a [`TableBlock`] a buffer row is.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum TableRowKind {
32    /// First row of the block (column names).
33    Header,
34    /// Second row (`| --- | --- |`).
35    Delimiter,
36    /// Any data row below the delimiter.
37    Body,
38}
39
40/// A contiguous GFM pipe-table block: header + delimiter + 0..N body rows.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct TableBlock {
43    /// Buffer row of the header line.
44    pub header_row: usize,
45    /// Buffer row of the delimiter line.
46    pub delimiter_row: usize,
47    /// Last body row (inclusive); equals `delimiter_row` when bodiless.
48    pub end_row: usize,
49    /// Number of columns (header and delimiter agree; body rows may vary).
50    pub col_count: usize,
51    /// Per-column alignment from the delimiter row.
52    pub aligns: Vec<TableAlignment>,
53}
54
55impl TableBlock {
56    /// Returns which part of the block `row` is, or `None` when outside it.
57    pub fn kind_at(&self, row: usize) -> Option<TableRowKind> {
58        if row == self.header_row {
59            Some(TableRowKind::Header)
60        } else if row == self.delimiter_row {
61            Some(TableRowKind::Delimiter)
62        } else if row > self.delimiter_row && row <= self.end_row {
63            Some(TableRowKind::Body)
64        } else {
65            None
66        }
67    }
68
69    /// Returns whether `row` lies anywhere inside the block.
70    pub fn contains(&self, row: usize) -> bool {
71        self.kind_at(row).is_some()
72    }
73}
74
75/// Strips a single trailing `\n` / `\r\n` for table scanning.
76pub(crate) fn clean_table_line(raw: &str) -> &str {
77    raw.strip_suffix("\r\n")
78        .or_else(|| raw.strip_suffix('\n'))
79        .unwrap_or(raw)
80}
81
82fn is_fence_line(cleaned: &str) -> bool {
83    let t = cleaned.trim_start();
84    t.starts_with("```") || t.starts_with("~~~")
85}
86
87/// Collects every fence-marker row in a single linear pass.
88///
89/// Hot paths (per-frame highlight, table layout) must call this once per
90/// document version and share the result — never scan per row.
91pub fn fence_rows(buffer: &EditorBuffer) -> Vec<usize> {
92    let total = buffer.len_lines();
93    let mut fences = Vec::new();
94    for r in 0..total {
95        if is_fence_line(clean_table_line(&buffer.line_to_string(r))) {
96            fences.push(r);
97        }
98    }
99    fences
100}
101
102/// Returns whether `row` sits inside a fenced code block.
103///
104/// `fences` is the sorted output of [`fence_rows`]; the query is
105/// `O(log F)` via `partition_point`. Counts only markers strictly above
106/// `row`, matching the previous per-call scan semantics.
107pub fn is_fenced_row(fences: &[usize], row: usize) -> bool {
108    fences.partition_point(|&f_row| f_row < row) % 2 == 1
109}
110
111/// Byte ranges of inline code spans (backtick runs) in `line`.
112///
113/// Pipes inside these ranges are literal text, not cell separators.
114/// Unmatched backticks are treated as literal characters.
115fn code_span_ranges(line: &str) -> Vec<Range<usize>> {
116    let bytes = line.as_bytes();
117    let mut ranges = Vec::new();
118    let mut i = 0;
119    while i < bytes.len() {
120        // Skip `\x` escapes entirely so an escaped backtick can't open a span.
121        if bytes[i] == b'\\' {
122            i += if i + 1 < bytes.len() { 2 } else { 1 };
123            continue;
124        }
125        if bytes[i] != b'`' {
126            i += 1;
127            continue;
128        }
129        let mut run = 0;
130        while i + run < bytes.len() && bytes[i + run] == b'`' {
131            run += 1;
132        }
133        // Find a closing run of exactly `run` backticks.
134        let mut j = i + run;
135        let mut closed = None;
136        while j < bytes.len() {
137            if bytes[j] == b'\\' {
138                j += if j + 1 < bytes.len() { 2 } else { 1 };
139                continue;
140            }
141            if bytes[j] == b'`' {
142                let mut k = 0;
143                while j + k < bytes.len() && bytes[j + k] == b'`' {
144                    k += 1;
145                }
146                if k == run {
147                    closed = Some(j + k);
148                    break;
149                }
150                j += k;
151                continue;
152            }
153            j += 1;
154        }
155        if let Some(end) = closed {
156            ranges.push(i..end);
157            i = end;
158        } else {
159            i += run;
160        }
161    }
162    ranges
163}
164
165fn is_escaped_at(line: &str, pos: usize) -> bool {
166    let bytes = line.as_bytes();
167    let mut slashes = 0;
168    let mut i = pos;
169    while i > 0 && bytes[i - 1] == b'\\' {
170        slashes += 1;
171        i -= 1;
172    }
173    slashes % 2 == 1
174}
175
176/// Byte offsets of cell-separator pipes in `line`.
177///
178/// Ignores `\|` escapes and pipes inside inline code spans.
179/// All returned offsets are ASCII `|` bytes, so byte/char indices coincide.
180pub fn find_unescaped_pipes(line: &str) -> Vec<usize> {
181    let cleaned = clean_table_line(line);
182    let code = code_span_ranges(cleaned);
183    let mut pipes = Vec::new();
184    for (idx, ch) in cleaned.char_indices() {
185        if ch != '|' {
186            continue;
187        }
188        if is_escaped_at(cleaned, idx) {
189            continue;
190        }
191        if code.iter().any(|r| r.contains(&idx)) {
192            continue;
193        }
194        pipes.push(idx);
195    }
196    pipes
197}
198
199/// Splits `line` into cell-content byte ranges plus separator pipe offsets.
200///
201/// Outer pipes are optional per GFM: a leading `|` drops the empty segment
202/// before it and a trailing `|` drops the one after it. With no pipes at all
203/// the whole line is a single cell.
204pub fn split_table_cells(line: &str) -> (Vec<usize>, Vec<Range<usize>>) {
205    let cleaned = clean_table_line(line);
206    let pipes = find_unescaped_pipes(cleaned);
207    if pipes.is_empty() {
208        let whole = 0..cleaned.len();
209        return (Vec::new(), vec![whole]);
210    }
211    let mut bounds = Vec::with_capacity(pipes.len() + 2);
212    bounds.push(0);
213    bounds.extend(pipes.iter().copied());
214    bounds.push(cleaned.len());
215    let mut cells: Vec<Range<usize>> = Vec::with_capacity(bounds.len() - 1);
216    for i in 0..bounds.len() - 1 {
217        // Segment `bounds[i]..bounds[i+1]` sits between two pipes (or a line
218        // edge and a pipe); skip the opening pipe byte for non-first segments.
219        let s = if i > 0 { bounds[i] + 1 } else { bounds[i] };
220        let e = bounds[i + 1];
221        cells.push(s..e.min(cleaned.len()));
222    }
223    // Drop the empty outer segments produced by leading/trailing pipes.
224    if cleaned.trim_start().starts_with('|') && !cells.is_empty() {
225        cells.remove(0);
226    }
227    if cleaned.trim_end().ends_with('|') && !cells.is_empty() {
228        cells.pop();
229    }
230    (pipes, cells)
231}
232
233/// Parses a GFM delimiter row into per-column alignments.
234///
235/// Returns `None` when any cell is not `:?-+:?` (after trimming whitespace).
236pub fn parse_delimiter_row(line: &str) -> Option<Vec<TableAlignment>> {
237    let cleaned = clean_table_line(line);
238    if cleaned.trim().is_empty() {
239        return None;
240    }
241    let (_, cells) = split_table_cells(cleaned);
242    if cells.is_empty() {
243        return None;
244    }
245    // A delimiter without pipes is only a table delimiter when the caller
246    // pairs it with a piped header (checked in `table_block_at`); cell-level
247    // validation is identical either way.
248    let mut aligns = Vec::with_capacity(cells.len());
249    for cell in &cells {
250        let content = cleaned
251            .get(cell.clone())
252            .unwrap_or("")
253            .trim()
254            .trim_matches(['\r', '\n']);
255        if content.is_empty() || !content.contains('-') {
256            return None;
257        }
258        let inner = content.trim_matches(':');
259        if inner.is_empty() || !inner.chars().all(|c| c == '-') {
260            return None;
261        }
262        // Colons are only legal as a single leading and/or trailing marker.
263        let stripped_leading = content.strip_prefix(':').unwrap_or(content);
264        let stripped_both = stripped_leading
265            .strip_suffix(':')
266            .unwrap_or(stripped_leading);
267        if stripped_both.contains(':') {
268            return None;
269        }
270        let left = content.starts_with(':');
271        let right = content.ends_with(':');
272        aligns.push(match (left, right) {
273            (true, true) => TableAlignment::Center,
274            (true, false) => TableAlignment::Left,
275            (false, true) => TableAlignment::Right,
276            (false, false) => TableAlignment::None,
277        });
278    }
279    Some(aligns)
280}
281
282fn table_line_has_pipe(cleaned: &str) -> bool {
283    !find_unescaped_pipes(cleaned).is_empty()
284}
285
286/// Locates the GFM pipe-table block containing `row`, if any.
287///
288/// Pure in buffer text: looks for a `header` / `delimiter` pair adjacent to
289/// `row` and extends through contiguous piped body rows. Returns `None` for
290/// blank lines, fence lines, blockquotes, single-column pipe-less text (which
291/// is a setext heading, not a table), and bare `---` (thematic break).
292///
293/// Single-shot helper: computes the fence index in one `O(N)` pass. Hot
294/// per-frame paths must hoist that pass per document version and call
295/// [`table_block_at_with_fences`] instead.
296pub fn table_block_at(buffer: &EditorBuffer, row: usize) -> Option<TableBlock> {
297    let fences = fence_rows(buffer);
298    table_block_at_with_fences(buffer, row, &fences)
299}
300
301/// [`table_block_at`] with a caller-provided fence index.
302///
303/// `fences` is the sorted output of [`fence_rows`]; the fenced-region check
304/// is `O(log F)`. Remaining work per call is bounded (upward walk budget
305/// 512, body extension), so this is safe per visible row per frame as long
306/// as `fences` is computed once per document version.
307pub fn table_block_at_with_fences(
308    buffer: &EditorBuffer,
309    row: usize,
310    fences: &[usize],
311) -> Option<TableBlock> {
312    let total = buffer.len_lines();
313    if row >= total {
314        return None;
315    }
316    let cur = clean_table_line(&buffer.line_to_string(row)).to_string();
317    if cur.trim().is_empty() || is_fence_line(&cur) || cur.trim_start().starts_with('>') {
318        return None;
319    }
320    if is_fenced_row(fences, row) {
321        return None;
322    }
323
324    // Find the delimiter row: it is either `row` itself, the line below a
325    // header row, or somewhere above a body row.
326    let mut delim: Option<usize> = None;
327    if parse_delimiter_row(&cur).is_some() {
328        delim = Some(row);
329    } else if row + 1 < total {
330        let next = clean_table_line(&buffer.line_to_string(row + 1)).to_string();
331        if !is_fence_line(&next) && parse_delimiter_row(&next).is_some() {
332            delim = Some(row + 1);
333        }
334    }
335    if delim.is_none() {
336        // Walk upward through piped body candidates to find the delimiter.
337        // Bounded so pathological documents can't turn highlight into O(N^2).
338        let mut r = row.checked_sub(1);
339        let mut budget = 512;
340        while let Some(j) = r {
341            if budget == 0 {
342                break;
343            }
344            budget -= 1;
345            let text = clean_table_line(&buffer.line_to_string(j)).to_string();
346            if text.trim().is_empty() || is_fence_line(&text) {
347                break;
348            }
349            if parse_delimiter_row(&text).is_some() {
350                delim = Some(j);
351                break;
352            }
353            if !table_line_has_pipe(&text) {
354                break;
355            }
356            r = j.checked_sub(1);
357        }
358    }
359    let d = delim?;
360    if d == 0 {
361        return None; // delimiter needs a header line above it.
362    }
363    let header = clean_table_line(&buffer.line_to_string(d - 1)).to_string();
364    if header.trim().is_empty() || is_fence_line(&header) {
365        return None;
366    }
367    let header_pipes = table_line_has_pipe(&header);
368    let delim_text = clean_table_line(&buffer.line_to_string(d)).to_string();
369    let delim_pipes = table_line_has_pipe(&delim_text);
370    // Without a pipe in either line this is a setext heading (`foo\n---`)
371    // or a thematic break, never a table.
372    if !header_pipes && !delim_pipes {
373        return None;
374    }
375    let aligns = parse_delimiter_row(&delim_text)?;
376    let (_, header_cells) = split_table_cells(&header);
377    if header_cells.len() != aligns.len() {
378        return None;
379    }
380    let header_row = d - 1;
381    // Extend through contiguous piped, non-fence body rows.
382    let mut end = d;
383    let mut r = d + 1;
384    while r < total {
385        let text = clean_table_line(&buffer.line_to_string(r)).to_string();
386        if text.trim().is_empty() || is_fence_line(&text) || !table_line_has_pipe(&text) {
387            break;
388        }
389        end = r;
390        r += 1;
391        if r - d > 4096 {
392            break;
393        }
394    }
395    // `row` must lie inside header..=end (it can be above the block when the
396    // upward walk overshoots, e.g. delimiter search from a paragraph below).
397    if row < header_row || row > end {
398        return None;
399    }
400    Some(TableBlock {
401        header_row,
402        delimiter_row: d,
403        end_row: end,
404        col_count: aligns.len(),
405        aligns,
406    })
407}
408
409/// Display-column widths for one [`TableBlock`], measured on unconcealed
410/// source cell content so widths stay stable as the cursor moves (inactive
411/// rows conceal markers; per-row padding absorbs the difference and pipes
412/// still align everywhere).
413#[derive(Debug, Clone, PartialEq, Eq)]
414pub struct TableLayout {
415    /// The block these widths were measured for.
416    pub block: TableBlock,
417    /// Max trimmed content width per column (minimum 3, keeping the
418    /// delimiter shape valid).
419    pub col_widths: Vec<usize>,
420}
421
422impl TableLayout {
423    /// Measures column widths for `block` from header, delimiter, and body rows.
424    ///
425    /// Delimiter cells count so wide markers (`:---:`) still fit; the
426    /// delimiter has no concealment, so its width is always stable.
427    pub fn build(buffer: &EditorBuffer, block: &TableBlock) -> Self {
428        let mut col_widths = vec![3; block.col_count];
429        for row in block.header_row..=block.end_row {
430            let line = clean_table_line(&buffer.line_to_string(row)).to_string();
431            let (_, cells) = split_table_cells(&line);
432            for (i, cell) in cells.iter().enumerate().take(block.col_count) {
433                let content = line.get(cell.clone()).unwrap_or("").trim();
434                col_widths[i] = col_widths[i].max(display_width(content));
435            }
436        }
437        Self {
438            block: block.clone(),
439            col_widths,
440        }
441    }
442}
443
444/// Finds every table block in the buffer with its measured [`TableLayout`].
445///
446/// Pure in buffer text; callers cache the result per document version.
447///
448/// Single-shot helper: computes the fence index in one `O(N)` pass. Hot
449/// paths must hoist that pass and call [`table_layouts_with_fences`].
450pub fn table_layouts(buffer: &EditorBuffer) -> Vec<TableLayout> {
451    let fences = fence_rows(buffer);
452    table_layouts_with_fences(buffer, &fences)
453}
454
455/// [`table_layouts`] with a caller-provided fence index.
456///
457/// Sweeps rows once, reusing `fences` for every point query instead of
458/// rescanning `0..row` per row (which made the naive version quadratic).
459pub fn table_layouts_with_fences(buffer: &EditorBuffer, fences: &[usize]) -> Vec<TableLayout> {
460    let mut layouts = Vec::new();
461    let mut row = 0;
462    let total = buffer.len_lines();
463    while row < total {
464        if let Some(block) = table_block_at_with_fences(buffer, row, fences) {
465            let end = block.end_row;
466            layouts.push(TableLayout::build(buffer, &block));
467            row = end + 1;
468        } else {
469            row += 1;
470        }
471    }
472    layouts
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    #[test]
480    fn test_table_pipes_ignore_escapes_and_code() {
481        assert_eq!(find_unescaped_pipes("| a | b |"), vec![0, 4, 8]);
482        // Escaped pipe is literal text.
483        assert_eq!(find_unescaped_pipes("| a \\| b |"), vec![0, 9]);
484        // Pipes inside code spans are literal text.
485        assert_eq!(find_unescaped_pipes("| `a|b` | c |"), vec![0, 8, 12]);
486        // Double backslash means the pipe still separates.
487        assert_eq!(find_unescaped_pipes("| a \\\\| b |"), vec![0, 6, 10]);
488    }
489
490    #[test]
491    fn test_table_split_cells_outer_pipes_optional() {
492        let (pipes, cells) = split_table_cells("| a | b |");
493        assert_eq!(pipes, vec![0, 4, 8]);
494        assert_eq!(cells.len(), 2);
495
496        let (pipes_bare, cells_bare) = split_table_cells("a | b");
497        assert_eq!(pipes_bare, vec![2]);
498        assert_eq!(cells_bare.len(), 2);
499
500        let (pipes_none, cells_none) = split_table_cells("plain");
501        assert!(pipes_none.is_empty());
502        assert_eq!(cells_none, vec![0..5]);
503    }
504
505    #[test]
506    fn test_table_parse_delimiter_alignments() {
507        assert_eq!(
508            parse_delimiter_row("| --- | :--- | ---: | :---: |"),
509            Some(vec![
510                TableAlignment::None,
511                TableAlignment::Left,
512                TableAlignment::Right,
513                TableAlignment::Center
514            ])
515        );
516        assert_eq!(
517            parse_delimiter_row("--- | :-:"),
518            Some(vec![TableAlignment::None, TableAlignment::Center])
519        );
520        assert!(parse_delimiter_row("| --- | nope |").is_none());
521        assert!(parse_delimiter_row("| --- | :: |").is_none());
522        assert!(parse_delimiter_row("").is_none());
523    }
524
525    #[test]
526    fn test_table_block_detection_and_kinds() {
527        let buffer = EditorBuffer::new("| a | b |\n| --- | --- |\n| c | d |\n| e | f |");
528        let block = table_block_at(&buffer, 0).expect("header must detect block");
529        assert_eq!(block.header_row, 0);
530        assert_eq!(block.delimiter_row, 1);
531        assert_eq!(block.end_row, 3);
532        assert_eq!(block.col_count, 2);
533        assert_eq!(block.kind_at(0), Some(TableRowKind::Header));
534        assert_eq!(block.kind_at(1), Some(TableRowKind::Delimiter));
535        assert_eq!(block.kind_at(2), Some(TableRowKind::Body));
536        assert!(block.contains(3));
537        assert!(!block.contains(4));
538
539        // Outer pipes optional.
540        let bare = EditorBuffer::new("a | b\n--- | ---\n c | d ");
541        let bare_block = table_block_at(&bare, 2).expect("bare pipes must detect");
542        assert_eq!(bare_block.col_count, 2);
543
544        // Plain paragraph below the table is not part of it.
545        let trailed = EditorBuffer::new("| a |\n| --- |\n| b |\nplain");
546        assert!(table_block_at(&trailed, 3).is_none());
547    }
548
549    #[test]
550    fn test_table_rejects_setext_hr_fence_and_quote() {
551        // Setext heading, not a table: no pipes anywhere.
552        let setext = EditorBuffer::new("foo\n---\n");
553        assert!(table_block_at(&setext, 0).is_none());
554        assert!(table_block_at(&setext, 1).is_none());
555
556        // Bare thematic break.
557        let hr = EditorBuffer::new("---\n");
558        assert!(table_block_at(&hr, 0).is_none());
559
560        // Mismatched column counts.
561        let uneven = EditorBuffer::new("| a | b |\n| --- |\n| c | d |");
562        assert!(table_block_at(&uneven, 0).is_none());
563
564        // Fenced code is never a table.
565        let fence = EditorBuffer::new("```\n| a |\n| --- |\n```");
566        assert!(table_block_at(&fence, 1).is_none());
567        assert!(table_block_at(&fence, 2).is_none());
568
569        // Blockquote tables are out of scope for v1.
570        let quote = EditorBuffer::new("> | a |\n> | --- |");
571        assert!(table_block_at(&quote, 0).is_none());
572    }
573
574    #[test]
575    fn test_table_layout_measures_max_source_width() {
576        let buffer = EditorBuffer::new("| a | b |\n| --- | --- |\n| looong | c |\n| d | e |");
577        let layouts = table_layouts(&buffer);
578        assert_eq!(layouts.len(), 1);
579        // Long word in one row widens the whole column (minimum 3).
580        assert_eq!(layouts[0].col_widths, vec![6, 3]);
581
582        // Multiple blocks each get their own layout.
583        let two = EditorBuffer::new("| a |\n| --- |\n| b |\n\n| x | yy |\n| --- | --- |");
584        assert_eq!(table_layouts(&two).len(), 2);
585    }
586
587    #[test]
588    fn test_fenced_index_queries_agree_with_single_shot() {
589        // Fence pair at the top, a real table deep in the doc, and a
590        // pipe-table lookalike inside a second fence (must stay invisible).
591        let mut s = String::from("```rust\nfn f() {}\n```\n");
592        for i in 0..200 {
593            s.push_str(&format!("plain line {i}\n"));
594        }
595        s.push_str("| a | b |\n| --- | --- |\n| c | d |\n");
596        s.push_str("```\n| x |\n| --- |\n```\n");
597        let buffer = EditorBuffer::new(&s);
598        let fences = fence_rows(&buffer);
599        // Fence rows: 0, 2, and the second block's open/close.
600        assert!(fences.contains(&0) && fences.contains(&2));
601        assert_eq!(fences.len(), 4);
602
603        let total = buffer.len_lines();
604        for row in 0..total {
605            assert_eq!(
606                table_block_at_with_fences(&buffer, row, &fences),
607                table_block_at(&buffer, row),
608                "indexed query must agree with single-shot at row {row}"
609            );
610        }
611        assert_eq!(
612            table_layouts_with_fences(&buffer, &fences),
613            table_layouts(&buffer),
614            "indexed layouts must agree with single-shot layouts"
615        );
616
617        // The real table is detected; the fenced lookalike is not.
618        let table_row = 203;
619        assert!(table_block_at(&buffer, table_row).is_some());
620        assert!(!is_fenced_row(&fences, table_row));
621        let fenced_row = total - 3;
622        assert!(is_fenced_row(&fences, fenced_row));
623        assert!(table_block_at(&buffer, fenced_row).is_none());
624    }
625}