Skip to main content

twrite_core/batteries/markdown/
highlight.rs

1use std::ops::Range;
2use std::sync::{Arc, RwLock};
3
4use crate::{
5    ConcealedLine, DisplayPad, EditorBuffer, HighlightTag, StyleSpan, SyntaxHighlighter,
6    display_width,
7};
8
9use super::config::{ConcealMode, MarkdownConfig};
10use super::links::extract_markdown_links;
11use super::table::{
12    TABLE_CELL_TAG, TABLE_DELIMITER_TAG, TABLE_HEADER_TAG, TableAlignment, TableBlock, TableLayout,
13    TableRowKind, clean_table_line, find_unescaped_pipes, is_fenced_row, split_table_cells,
14    table_layouts_with_fences,
15};
16
17/// Cached table display layouts and associated document version.
18type TableCache = Arc<RwLock<Option<(usize, Vec<TableLayout>)>>>;
19
20/// Cached fence line row indices and associated document version.
21type FenceCache = Arc<RwLock<Option<(usize, Vec<usize>)>>>;
22
23/// A syntax highlighter for CommonMark and GFM Markdown documents using `pulldown-cmark`.
24#[derive(Debug, Clone)]
25pub struct MarkdownHighlighter {
26    config: MarkdownConfig,
27    cached_fences: FenceCache,
28    cached_tables: TableCache,
29}
30
31impl Default for MarkdownHighlighter {
32    fn default() -> Self {
33        Self::new()
34    }
35}
36
37impl MarkdownHighlighter {
38    /// Creates a new Markdown syntax highlighter with default configuration.
39    pub fn new() -> Self {
40        Self::with_config(MarkdownConfig::default())
41    }
42
43    /// Creates a new Markdown syntax highlighter with custom configuration.
44    pub fn with_config(config: MarkdownConfig) -> Self {
45        Self {
46            config,
47            cached_fences: Arc::new(RwLock::new(None)),
48            cached_tables: Arc::new(RwLock::new(None)),
49        }
50    }
51
52    /// Returns the active Markdown configuration.
53    pub fn config(&self) -> &MarkdownConfig {
54        &self.config
55    }
56
57    /// Updates the Markdown configuration.
58    pub fn set_config(&mut self, config: MarkdownConfig) {
59        self.config = config;
60    }
61
62    fn is_in_fenced_code_block(&self, buffer: &EditorBuffer, current_row: usize) -> bool {
63        let fences = self.cached_fence_rows(buffer);
64        is_fenced_row(&fences, current_row)
65    }
66
67    /// Returns the fence-marker rows for this document version, scanning once
68    /// and sharing the result across all per-row queries in the epoch.
69    ///
70    /// This is the single `O(N)` fence pass per version: `highlight_line`,
71    /// table-block lookup, and table-layout building all share it instead of
72    /// each rescanning `0..row` per visible row per frame.
73    fn cached_fence_rows(&self, buffer: &EditorBuffer) -> Vec<usize> {
74        let version = buffer.version();
75        if let Ok(guard) = self.cached_fences.read()
76            && let Some((v, ref fences)) = *guard
77            && v == version
78        {
79            return fences.clone();
80        }
81
82        if let Ok(mut guard) = self.cached_fences.write() {
83            if let Some((v, ref fences)) = *guard
84                && v == version
85            {
86                return fences.clone();
87            }
88
89            let fences = scan_fence_rows(buffer);
90            *guard = Some((version, fences.clone()));
91            fences
92        } else {
93            scan_fence_rows(buffer)
94        }
95    }
96
97    /// Locates the table block for `row` from the version-cached layouts.
98    ///
99    /// Point queries never walk the buffer: the one linear sweep per version
100    /// lives in [`Self::cached_layouts`], and every per-row call
101    /// (`highlight_line`, `should_wrap_line`, `expand_line`) shares it.
102    /// Previously each call re-walked up to the whole table (upward search +
103    /// body extension with a `line_to_string` alloc per row), costing
104    /// milliseconds per row inside large tables on every selection frame.
105    fn cached_table_block(&self, buffer: &EditorBuffer, row: usize) -> Option<TableBlock> {
106        if row >= buffer.len_lines() {
107            return None;
108        }
109        self.cached_layouts(buffer)
110            .into_iter()
111            .find(|l| l.block.contains(row))
112            .map(|l| l.block)
113    }
114
115    /// Returns the table layout containing `row`, if any.
116    fn layout_for_row(&self, buffer: &EditorBuffer, row: usize) -> Option<TableLayout> {
117        self.cached_layouts(buffer)
118            .into_iter()
119            .find(|l| l.block.contains(row))
120    }
121
122    /// Returns all table layouts for this document version, sweeping once
123    /// and sharing the result across every per-row query in the epoch.
124    fn cached_layouts(&self, buffer: &EditorBuffer) -> Vec<TableLayout> {
125        let version = buffer.version();
126        if let Ok(guard) = self.cached_tables.read()
127            && let Some((v, ref layouts)) = *guard
128            && v == version
129        {
130            return layouts.clone();
131        }
132        if let Ok(mut guard) = self.cached_tables.write() {
133            if let Some((v, ref layouts)) = *guard
134                && v == version
135            {
136                return layouts.clone();
137            }
138            // Build layouts with the shared fence index: one linear sweep,
139            // not one `O(row)` fence rescan per row.
140            let fences = self.cached_fence_rows(buffer);
141            let layouts = table_layouts_with_fences(buffer, &fences);
142            *guard = Some((version, layouts.clone()));
143            layouts
144        } else {
145            let fences = self.cached_fence_rows(buffer);
146            table_layouts_with_fences(buffer, &fences)
147        }
148    }
149}
150
151/// Single linear fence-marker scan shared by every table query in an epoch.
152fn scan_fence_rows(buffer: &EditorBuffer) -> Vec<usize> {
153    let mut fences = Vec::new();
154    let total_lines = buffer.len_lines();
155    let rope = buffer.text();
156    for r in 0..total_lines {
157        let line = rope.line(r);
158        let mut chars = line.chars();
159        while let Some(c) = chars.next() {
160            if !c.is_whitespace() {
161                if (c == '`' && chars.next() == Some('`') && chars.next() == Some('`'))
162                    || (c == '~' && chars.next() == Some('~') && chars.next() == Some('~'))
163                {
164                    fences.push(r);
165                }
166                break;
167            }
168        }
169    }
170    fences
171}
172
173/// Snaps a display byte offset forward to a char boundary.
174fn snap_display_fwd(display: &str, mut i: usize) -> usize {
175    i = i.min(display.len());
176    while i < display.len() && !display.is_char_boundary(i) {
177        i += 1;
178    }
179    i
180}
181
182/// Snaps a display byte offset backward to a char boundary.
183fn snap_display_back(display: &str, mut i: usize) -> usize {
184    i = i.min(display.len());
185    while i > 0 && !display.is_char_boundary(i) {
186        i -= 1;
187    }
188    i
189}
190
191/// Computes display-only padding aligning one table row's cells to the
192/// block's column widths.
193///
194/// `source` is the stripped source line, `concealed` its collapsed display
195/// form. Column widths are measured on unconcealed source text (see
196/// [`TableLayout`]); per-row padding absorbs concealment shrinkage so pipes
197/// align on active and inactive rows alike. Delimiter dashes are extended
198/// with `-` fill; body/header cells are space-padded honoring the column's
199/// delimiter alignment.
200fn table_row_pads(
201    layout: &TableLayout,
202    kind: TableRowKind,
203    source: &str,
204    concealed: &ConcealedLine,
205) -> Vec<DisplayPad> {
206    let display = &concealed.display_text;
207    let (_, cells) = split_table_cells(source);
208    let mut pads = Vec::new();
209    for (i, cell) in cells.iter().enumerate().take(layout.col_widths.len()) {
210        let width = layout.col_widths[i];
211        let ds = snap_display_fwd(
212            display,
213            concealed.source_to_display(cell.start.min(source.len())),
214        );
215        let de = snap_display_back(
216            display,
217            concealed.source_to_display(cell.end.min(source.len())),
218        );
219        if ds >= de {
220            continue;
221        }
222        // Trim padding already present in the display slice.
223        let bytes = display.as_bytes();
224        let mut cs = ds;
225        while cs < de && (bytes[cs] == b' ' || bytes[cs] == b'\t') {
226            cs += 1;
227        }
228        let mut ce = de;
229        while ce > cs && (bytes[ce - 1] == b' ' || bytes[ce - 1] == b'\t') {
230            ce -= 1;
231        }
232        let content_width = display_width(&display[cs..ce]);
233        if content_width >= width {
234            continue;
235        }
236        let need = width - content_width;
237        if kind == TableRowKind::Delimiter {
238            // Extend the dash run, keeping a trailing alignment colon last.
239            let at = if display[cs..ce].ends_with(':') {
240                ce - 1
241            } else {
242                ce
243            };
244            pads.push(DisplayPad {
245                display_at: at,
246                fill: '-',
247                len: need,
248            });
249            continue;
250        }
251        match layout
252            .block
253            .aligns
254            .get(i)
255            .copied()
256            .unwrap_or(TableAlignment::None)
257        {
258            TableAlignment::Right => pads.push(DisplayPad {
259                display_at: cs,
260                fill: ' ',
261                len: need,
262            }),
263            TableAlignment::Center => {
264                let left = need / 2;
265                let right = need - left;
266                if left > 0 {
267                    pads.push(DisplayPad {
268                        display_at: cs,
269                        fill: ' ',
270                        len: left,
271                    });
272                }
273                if right > 0 {
274                    pads.push(DisplayPad {
275                        display_at: ce,
276                        fill: ' ',
277                        len: right,
278                    });
279                }
280            }
281            TableAlignment::Left | TableAlignment::None => pads.push(DisplayPad {
282                display_at: ce,
283                fill: ' ',
284                len: need,
285            }),
286        }
287    }
288    pads
289}
290
291impl SyntaxHighlighter for MarkdownHighlighter {
292    fn highlight_line(&self, buffer: &EditorBuffer, row: usize, line_text: &str) -> Vec<StyleSpan> {
293        let mut spans = Vec::new();
294        let trimmed_start = line_text.trim_start();
295
296        let delimiter_tag = match self.config.conceal_mode {
297            ConcealMode::Off => None,
298            ConcealMode::Dimmed => Some(HighlightTag::Dimmed),
299            ConcealMode::Hidden => Some(HighlightTag::Hidden),
300        };
301
302        let is_cursor_row = row == buffer.cursor_point().row;
303
304        if trimmed_start.starts_with("```") || trimmed_start.starts_with("~~~") {
305            spans.push(StyleSpan::tag(0..line_text.len(), HighlightTag::Code));
306            return spans;
307        }
308
309        if self.is_in_fenced_code_block(buffer, row) {
310            spans.push(StyleSpan::tag(0..line_text.len(), HighlightTag::Code));
311            return spans;
312        }
313
314        // GFM pipe tables. Runs before the thematic-break check so a
315        // single-column `---` delimiter is not mistaken for an `<hr>`.
316        // Pipes stay visible in every conceal mode (Hidden maps to Dimmed)
317        // to preserve `ConcealedLine` source/display column alignment.
318        if self.config.visual_tables
319            && let Some(block) = self.cached_table_block(buffer, row)
320            && let Some(kind) = block.kind_at(row)
321        {
322            // Pipes dim on inactive rows but are never concealed.
323            let pipe_dim = match self.config.conceal_mode {
324                ConcealMode::Off => None,
325                ConcealMode::Dimmed | ConcealMode::Hidden => Some(HighlightTag::Dimmed),
326            };
327            let pipes = find_unescaped_pipes(line_text);
328            match kind {
329                TableRowKind::Delimiter => {
330                    spans.push(StyleSpan::tag(
331                        0..line_text.len(),
332                        HighlightTag::Custom(TABLE_DELIMITER_TAG),
333                    ));
334                    for p in &pipes {
335                        spans.push(StyleSpan::tag(*p..*p + 1, HighlightTag::Punctuation));
336                    }
337                    if !is_cursor_row {
338                        let tag = delimiter_tag.unwrap_or(HighlightTag::Comment);
339                        // Map Hidden -> Dimmed: concealing dashes would collapse
340                        // the row to nothing and break cursor mapping.
341                        let tag = if tag == HighlightTag::Hidden {
342                            HighlightTag::Dimmed
343                        } else {
344                            tag
345                        };
346                        spans.push(StyleSpan::tag(0..line_text.len(), tag));
347                    }
348                    return spans;
349                }
350                TableRowKind::Header | TableRowKind::Body => {
351                    let cell_tag = if kind == TableRowKind::Header {
352                        HighlightTag::Custom(TABLE_HEADER_TAG)
353                    } else {
354                        HighlightTag::Custom(TABLE_CELL_TAG)
355                    };
356                    let (_, cells) = split_table_cells(line_text);
357                    for cell in &cells {
358                        let end = cell.end.min(line_text.len());
359                        if cell.start < end {
360                            spans.push(StyleSpan::tag(cell.start..end, cell_tag));
361                            if kind == TableRowKind::Header
362                                && let Some(content) = line_text.get(cell.start..end)
363                                && !content.trim().is_empty()
364                            {
365                                spans.push(StyleSpan::tag(cell.start..end, HighlightTag::Bold));
366                            }
367                        }
368                    }
369                    for p in &pipes {
370                        spans.push(StyleSpan::tag(*p..*p + 1, HighlightTag::Punctuation));
371                        if !is_cursor_row && let Some(dim) = pipe_dim {
372                            spans.push(StyleSpan::tag(*p..*p + 1, dim));
373                        }
374                    }
375                    // Fall through to the inline pulldown pass so emphasis,
376                    // code spans, and links inside cells keep working.
377                }
378            }
379        }
380
381        if self.config.visual_thematic_breaks {
382            let trimmed_break = trimmed_start.trim_end();
383            if (trimmed_break == "---" || trimmed_break == "***" || trimmed_break == "___")
384                && line_text.len() >= 3
385            {
386                // Structural tag first so tag-driven layout survives concealment;
387                // visual span last so text colors are unchanged.
388                spans.push(StyleSpan::tag(
389                    0..line_text.len(),
390                    HighlightTag::HorizontalRule,
391                ));
392                let tag = delimiter_tag.unwrap_or(HighlightTag::Comment);
393                spans.push(StyleSpan::tag(0..line_text.len(), tag));
394                return spans;
395            }
396        }
397
398        let heading_prefix = if trimmed_start.starts_with("# ") {
399            Some((2, HighlightTag::Heading(1)))
400        } else if trimmed_start.starts_with("## ") {
401            Some((3, HighlightTag::Heading(2)))
402        } else if trimmed_start.starts_with("### ") {
403            Some((4, HighlightTag::Heading(3)))
404        } else if trimmed_start.starts_with("#### ") {
405            Some((5, HighlightTag::Heading(4)))
406        } else if trimmed_start.starts_with("##### ") {
407            Some((6, HighlightTag::Heading(5)))
408        } else if trimmed_start.starts_with("###### ") {
409            Some((7, HighlightTag::Heading(6)))
410        } else {
411            None
412        };
413
414        if let Some((prefix_len, tag)) = heading_prefix {
415            let indent = line_text.len() - trimmed_start.len();
416            if !is_cursor_row && let Some(delim_tag) = delimiter_tag {
417                spans.push(StyleSpan::tag(indent..indent + prefix_len, delim_tag));
418                if indent + prefix_len < line_text.len() {
419                    spans.push(StyleSpan::tag(indent + prefix_len..line_text.len(), tag));
420                }
421            } else {
422                spans.push(StyleSpan::tag(0..line_text.len(), tag));
423            }
424            return spans;
425        }
426
427        if trimmed_start.starts_with("> ") || trimmed_start == ">" {
428            let indent = line_text.len() - trimmed_start.len();
429            let quote_len = if trimmed_start.starts_with("> ") {
430                2
431            } else {
432                1
433            };
434            // Structural tag first; visual span last so colors are unchanged.
435            spans.push(StyleSpan::tag(
436                indent..indent + quote_len,
437                HighlightTag::Blockquote,
438            ));
439            if !is_cursor_row && let Some(delim_tag) = delimiter_tag {
440                let delim_len = if trimmed_start.starts_with("> ") {
441                    2
442                } else {
443                    1
444                };
445                spans.push(StyleSpan::tag(indent..indent + delim_len, delim_tag));
446            } else {
447                spans.push(StyleSpan::tag(indent..indent + 1, HighlightTag::Comment));
448            }
449        }
450
451        let is_task_unchecked = trimmed_start.starts_with("- [ ] ")
452            || trimmed_start == "- [ ]"
453            || trimmed_start.starts_with("* [ ] ")
454            || trimmed_start == "* [ ]";
455        let is_task_checked = trimmed_start.starts_with("- [x] ")
456            || trimmed_start == "- [x]"
457            || trimmed_start.starts_with("- [X] ")
458            || trimmed_start == "- [X]"
459            || trimmed_start.starts_with("* [x] ")
460            || trimmed_start == "* [x]"
461            || trimmed_start.starts_with("* [X] ")
462            || trimmed_start == "* [X]";
463        let is_task_list = is_task_unchecked || is_task_checked;
464
465        if is_task_list {
466            let indent = line_text.len() - trimmed_start.len();
467            // Structural tag first so tag-driven layout works even when the
468            // marker bytes are concealed; visual span last so colors stay.
469            let marker_len = if trimmed_start.len() >= 6 {
470                6
471            } else {
472                trimmed_start.len()
473            };
474            let task_tag = if is_task_checked {
475                HighlightTag::TaskChecked
476            } else {
477                HighlightTag::TaskUnchecked
478            };
479            spans.push(StyleSpan::tag(indent..indent + marker_len, task_tag));
480            if !is_cursor_row && let Some(delim_tag) = delimiter_tag {
481                if delim_tag == HighlightTag::Hidden {
482                    spans.push(StyleSpan::tag(
483                        indent..indent + marker_len,
484                        HighlightTag::Hidden,
485                    ));
486                } else {
487                    spans.push(StyleSpan::tag(indent..indent + 2, delim_tag));
488                }
489            }
490        }
491
492        super::inline::highlight_inline_markdown(
493            line_text,
494            is_cursor_row,
495            delimiter_tag,
496            &mut spans,
497        );
498
499        spans
500    }
501
502    fn extract_links(
503        &self,
504        _buffer: &EditorBuffer,
505        _row: usize,
506        line_text: &str,
507    ) -> Vec<(Range<usize>, String)> {
508        if !(line_text.contains('[') || line_text.contains('<')) {
509            return Vec::new();
510        }
511        extract_markdown_links(line_text)
512    }
513
514    fn expand_line(
515        &self,
516        buffer: &EditorBuffer,
517        row: usize,
518        concealed: &ConcealedLine,
519    ) -> Vec<DisplayPad> {
520        if !(self.config.visual_tables && self.config.table_alignment) {
521            return Vec::new();
522        }
523        let layout = match self.layout_for_row(buffer, row) {
524            Some(layout) => layout,
525            None => return Vec::new(),
526        };
527        let kind = match layout.block.kind_at(row) {
528            Some(kind) => kind,
529            None => return Vec::new(),
530        };
531        let source = clean_table_line(&buffer.line_to_string(row)).to_string();
532        table_row_pads(&layout, kind, &source, concealed)
533    }
534
535    fn should_wrap_line(&self, buffer: &EditorBuffer, row: usize) -> bool {
536        if !(self.config.visual_tables && self.config.table_alignment) {
537            return true;
538        }
539        !self
540            .cached_table_block(buffer, row)
541            .is_some_and(|b| b.contains(row))
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use super::super::links::extract_markdown_links;
548    use super::*;
549    use crate::{ConcealedLine, StyleValue};
550
551    #[test]
552    fn test_markdown_heading_spans() {
553        let buffer = EditorBuffer::new("# Heading 1\n## Heading 2\nplain text\n---");
554        let highlighter = MarkdownHighlighter::new();
555
556        let spans1 = highlighter.highlight_line(&buffer, 0, "# Heading 1");
557        assert_eq!(spans1.len(), 1);
558        assert_eq!(spans1[0].style, StyleValue::Tag(HighlightTag::Heading(1)));
559
560        let spans2 = highlighter.highlight_line(&buffer, 1, "## Heading 2");
561        assert_eq!(spans2.len(), 2);
562        assert_eq!(spans2[0].style, StyleValue::Tag(HighlightTag::Dimmed));
563        assert_eq!(spans2[1].style, StyleValue::Tag(HighlightTag::Heading(2)));
564
565        let spans3 = highlighter.highlight_line(&buffer, 2, "plain text");
566        assert!(spans3.is_empty());
567
568        let spans4 = highlighter.highlight_line(&buffer, 3, "---");
569        assert_eq!(spans4.len(), 2);
570        assert_eq!(
571            spans4[0].style,
572            StyleValue::Tag(HighlightTag::HorizontalRule)
573        );
574        assert_eq!(spans4[1].style, StyleValue::Tag(HighlightTag::Dimmed));
575    }
576
577    #[test]
578    fn test_markdown_heading_levels_4_to_6() {
579        let buffer = EditorBuffer::new("#### H4\n##### H5\n###### H6");
580        let highlighter = MarkdownHighlighter::new();
581
582        for (row, text, level) in [
583            (0, "#### H4", 4u8),
584            (1, "##### H5", 5u8),
585            (2, "###### H6", 6u8),
586        ] {
587            let spans = highlighter.highlight_line(&buffer, row, text);
588            assert!(
589                spans
590                    .iter()
591                    .any(|s| s.style == StyleValue::Tag(HighlightTag::Heading(level))),
592                "row {row} must emit Heading({level})"
593            );
594        }
595    }
596
597    #[test]
598    fn test_markdown_inline_bold_and_code() {
599        let buffer = EditorBuffer::new("This is **bold** and `code` here.");
600        let highlighter = MarkdownHighlighter::new();
601
602        let spans = highlighter.highlight_line(&buffer, 0, "This is **bold** and `code` here.");
603        let bold_span = spans
604            .iter()
605            .find(|s| s.style == StyleValue::Tag(HighlightTag::Bold));
606        assert!(bold_span.is_some());
607
608        let code_span = spans
609            .iter()
610            .find(|s| s.style == StyleValue::Tag(HighlightTag::Code));
611        assert!(code_span.is_some());
612    }
613
614    #[test]
615    fn test_markdown_conceal_modes() {
616        let buffer = EditorBuffer::new("# Heading 1\n## Heading 2");
617
618        let hidden_highlighter = MarkdownHighlighter::with_config(MarkdownConfig {
619            conceal_mode: ConcealMode::Hidden,
620            ..Default::default()
621        });
622        let spans_hidden = hidden_highlighter.highlight_line(&buffer, 1, "## Heading 2");
623        assert_eq!(spans_hidden.len(), 2);
624        assert_eq!(spans_hidden[0].style, StyleValue::Tag(HighlightTag::Hidden));
625        assert_eq!(
626            spans_hidden[1].style,
627            StyleValue::Tag(HighlightTag::Heading(2))
628        );
629
630        let off_highlighter = MarkdownHighlighter::with_config(MarkdownConfig {
631            conceal_mode: ConcealMode::Off,
632            ..Default::default()
633        });
634        let spans_off = off_highlighter.highlight_line(&buffer, 1, "## Heading 2");
635        assert_eq!(spans_off.len(), 1);
636        assert_eq!(
637            spans_off[0].style,
638            StyleValue::Tag(HighlightTag::Heading(2))
639        );
640    }
641
642    #[test]
643    fn test_markdown_task_list_and_quote_concealment() {
644        let buffer = EditorBuffer::new("- [ ] Task 1\n> Quote line\n```rust\nfn main() {}\n```");
645        let hidden_highlighter = MarkdownHighlighter::with_config(MarkdownConfig {
646            conceal_mode: ConcealMode::Hidden,
647            ..Default::default()
648        });
649
650        // Row 0 is cursor row (buffer cursor is at 0)
651        // Row 1 (Quote) is inactive
652        let spans_quote = hidden_highlighter.highlight_line(&buffer, 1, "> Quote line");
653        assert!(!spans_quote.is_empty());
654        // Structural tag first, visual concealment last.
655        assert_eq!(spans_quote[0].range, 0..2);
656        assert_eq!(
657            spans_quote[0].style,
658            StyleValue::Tag(HighlightTag::Blockquote)
659        );
660        assert_eq!(spans_quote[1].range, 0..2);
661        assert_eq!(spans_quote[1].style, StyleValue::Tag(HighlightTag::Hidden));
662        let concealed_quote = ConcealedLine::build("> Quote line", &spans_quote);
663        assert_eq!(concealed_quote.display_text, "Quote line");
664
665        // Row 2 (Opening fence) remains visible with HighlightTag::Code
666        let spans_fence = hidden_highlighter.highlight_line(&buffer, 2, "```rust");
667        assert_eq!(spans_fence.len(), 1);
668        assert_eq!(spans_fence[0].range, 0..7);
669        assert_eq!(spans_fence[0].style, StyleValue::Tag(HighlightTag::Code));
670        let concealed_fence = ConcealedLine::build("```rust", &spans_fence);
671        assert_eq!(concealed_fence.display_text, "```rust");
672
673        // When buffer cursor moves to row 1, row 0 becomes inactive
674        let mut buffer_moved = buffer;
675        buffer_moved.set_cursor_offset(13); // on row 1
676        let spans_task = hidden_highlighter.highlight_line(&buffer_moved, 0, "- [ ] Task 1");
677        assert!(!spans_task.is_empty());
678        assert_eq!(spans_task[0].range, 0..6);
679        assert_eq!(
680            spans_task[0].style,
681            StyleValue::Tag(HighlightTag::TaskUnchecked)
682        );
683        assert_eq!(spans_task[1].range, 0..6);
684        assert_eq!(spans_task[1].style, StyleValue::Tag(HighlightTag::Hidden));
685        let concealed_task = ConcealedLine::build("- [ ] Task 1", &spans_task);
686        assert_eq!(concealed_task.display_text, "Task 1");
687    }
688
689    #[test]
690    fn test_markdown_link_concealment() {
691        let buffer = EditorBuffer::new("[Google](https://google.com)\nActive line");
692        let hidden_highlighter = MarkdownHighlighter::with_config(MarkdownConfig {
693            conceal_mode: ConcealMode::Hidden,
694            ..Default::default()
695        });
696
697        // Buffer cursor is at 0 (row 0), so row 0 is active, full link visible
698        let spans_active =
699            hidden_highlighter.highlight_line(&buffer, 0, "[Google](https://google.com)");
700        let concealed_active = ConcealedLine::build("[Google](https://google.com)", &spans_active);
701        assert_eq!(
702            concealed_active.display_text,
703            "[Google](https://google.com)"
704        );
705
706        // Move cursor to row 1, row 0 becomes inactive
707        let mut buffer_moved = buffer;
708        buffer_moved.set_cursor_offset(30);
709        let spans_hidden =
710            hidden_highlighter.highlight_line(&buffer_moved, 0, "[Google](https://google.com)");
711        let concealed_hidden = ConcealedLine::build("[Google](https://google.com)", &spans_hidden);
712        assert_eq!(concealed_hidden.display_text, "Google");
713
714        let extracted = extract_markdown_links("[Google](https://google.com)");
715        assert_eq!(extracted.len(), 1);
716        assert_eq!(extracted[0].0, 1..7);
717        assert_eq!(extracted[0].1, "https://google.com");
718    }
719
720    #[test]
721    fn test_markdown_structural_tags_in_dimmed_mode() {
722        let buffer = EditorBuffer::new("- [ ] Todo\n- [x] Done\n> Quote\n---");
723        let highlighter = MarkdownHighlighter::new();
724        // Move cursor away so no row is the active cursor row side-effect... row 3 check
725        // uses default cursor at row 0, so rows 1-3 are inactive.
726        let unchecked = highlighter.highlight_line(&buffer, 0, "- [ ] Todo");
727        // Row 0 is the cursor row: structural tag still emitted, no concealment.
728        assert!(
729            unchecked
730                .iter()
731                .any(|s| s.style == StyleValue::Tag(HighlightTag::TaskUnchecked))
732        );
733
734        let mut moved = buffer;
735        moved.set_cursor_offset(30);
736        let unchecked_inactive = highlighter.highlight_line(&moved, 0, "- [ ] Todo");
737        assert!(
738            unchecked_inactive
739                .iter()
740                .any(|s| s.style == StyleValue::Tag(HighlightTag::TaskUnchecked))
741        );
742        let checked = highlighter.highlight_line(&moved, 1, "- [x] Done");
743        assert!(
744            checked
745                .iter()
746                .any(|s| s.style == StyleValue::Tag(HighlightTag::TaskChecked))
747        );
748        let quote = highlighter.highlight_line(&moved, 2, "> Quote");
749        assert!(
750            quote
751                .iter()
752                .any(|s| s.style == StyleValue::Tag(HighlightTag::Blockquote))
753        );
754        let hr = highlighter.highlight_line(&moved, 3, "---");
755        assert!(
756            hr.iter()
757                .any(|s| s.style == StyleValue::Tag(HighlightTag::HorizontalRule))
758        );
759    }
760
761    #[test]
762    fn test_table_highlight_uses_existing_tags_only() {
763        let buffer = EditorBuffer::new("| Name | Age |\n| --- | ---: |\n| Ada | 36 |");
764        let highlighter = MarkdownHighlighter::new();
765
766        let header = highlighter.highlight_line(&buffer, 0, "| Name | Age |");
767        assert!(
768            header
769                .iter()
770                .any(|s| s.style == StyleValue::Tag(HighlightTag::Punctuation))
771        );
772        assert!(
773            header
774                .iter()
775                .any(|s| s.style == StyleValue::Tag(HighlightTag::Bold))
776        );
777        assert!(
778            header
779                .iter()
780                .any(|s| s.style == StyleValue::Tag(HighlightTag::Custom(TABLE_HEADER_TAG)))
781        );
782
783        let body = highlighter.highlight_line(&buffer, 2, "| Ada | 36 |");
784        assert!(
785            body.iter()
786                .any(|s| s.style == StyleValue::Tag(HighlightTag::Custom(TABLE_CELL_TAG)))
787        );
788        assert!(
789            body.iter()
790                .all(|s| s.style != StyleValue::Tag(HighlightTag::Bold))
791        );
792
793        let delim = highlighter.highlight_line(&buffer, 1, "| --- | ---: |");
794        assert!(
795            delim
796                .iter()
797                .any(|s| s.style == StyleValue::Tag(HighlightTag::Custom(TABLE_DELIMITER_TAG)))
798        );
799
800        // Inline code inside cells still highlights.
801        let code_row = EditorBuffer::new("| `x|y` | b |\n| --- | --- |\n| c | d |");
802        let code_spans = highlighter.highlight_line(&code_row, 0, "| `x|y` | b |");
803        assert!(
804            code_spans
805                .iter()
806                .any(|s| s.style == StyleValue::Tag(HighlightTag::Code))
807        );
808
809        // Pipes are never fully concealed: Hidden maps to Dimmed.
810        let hidden = MarkdownHighlighter::with_config(MarkdownConfig {
811            conceal_mode: ConcealMode::Hidden,
812            ..Default::default()
813        });
814        let mut moved = EditorBuffer::new("| Name | Age |\n| --- | ---: |\n| Ada | 36 |");
815        moved.set_cursor_offset(40);
816        let inactive = hidden.highlight_line(&moved, 0, "| Name | Age |");
817        assert!(
818            inactive
819                .iter()
820                .all(|s| s.style != StyleValue::Tag(HighlightTag::Hidden))
821        );
822        assert!(
823            inactive
824                .iter()
825                .any(|s| s.style == StyleValue::Tag(HighlightTag::Dimmed))
826        );
827        let concealed = ConcealedLine::build("| Name | Age |", &inactive);
828        assert_eq!(concealed.display_text, "| Name | Age |");
829
830        // Opt-out flag disables everything.
831        let off = MarkdownHighlighter::with_config(MarkdownConfig {
832            visual_tables: false,
833            ..Default::default()
834        });
835        let plain = off.highlight_line(&buffer, 0, "| Name | Age |");
836        assert!(plain.iter().all(|s| !matches!(
837            s.style,
838            StyleValue::Tag(
839                HighlightTag::Custom(TABLE_HEADER_TAG)
840                    | HighlightTag::Custom(TABLE_CELL_TAG)
841                    | HighlightTag::Custom(TABLE_DELIMITER_TAG)
842            )
843        )));
844    }
845
846    /// Renders one row through highlight + conceal + expand, like the canvas does.
847    fn expanded_display(
848        highlighter: &MarkdownHighlighter,
849        buffer: &EditorBuffer,
850        row: usize,
851        line: &str,
852    ) -> String {
853        let spans = highlighter.highlight_line(buffer, row, line);
854        let concealed = ConcealedLine::build(line, &spans);
855        let pads = highlighter.expand_line(buffer, row, &concealed);
856        concealed.expanded(&pads).display_text
857    }
858
859    #[test]
860    fn test_table_columns_share_widths_when_a_cell_grows() {
861        let buffer = EditorBuffer::new("| a | b |\n| --- | --- |\n| looong | c |\n| d | e |");
862        let highlighter = MarkdownHighlighter::new();
863
864        let header = expanded_display(&highlighter, &buffer, 0, "| a | b |");
865        let body_long = expanded_display(&highlighter, &buffer, 2, "| looong | c |");
866        let body_short = expanded_display(&highlighter, &buffer, 3, "| d | e |");
867        let delim = expanded_display(&highlighter, &buffer, 1, "| --- | --- |");
868
869        // Pipes land on the same display columns in every row.
870        let pipe_cols = |s: &str| {
871            s.char_indices()
872                .filter(|(_, c)| *c == '|')
873                .map(|(i, _)| i)
874                .collect::<Vec<_>>()
875        };
876        assert_eq!(pipe_cols(&header), pipe_cols(&body_long));
877        assert_eq!(pipe_cols(&header), pipe_cols(&body_short));
878        assert_eq!(pipe_cols(&header), pipe_cols(&delim));
879        assert_eq!(header, "| a      | b   |");
880        assert_eq!(body_long, "| looong | c   |");
881        assert_eq!(delim, "| ------ | --- |");
882    }
883
884    #[test]
885    fn test_table_alignment_honors_delimiter_sides() {
886        let buffer = EditorBuffer::new("| ab | c |\n| ---: | --- |\n| d | ef |");
887        let highlighter = MarkdownHighlighter::new();
888
889        // Right-aligned column pads before the content, plain column after.
890        let body = expanded_display(&highlighter, &buffer, 2, "| d | ef |");
891        assert_eq!(body, "|    d | ef  |");
892        // The delimiter already fits, so it renders unchanged and aligned.
893        let delim = expanded_display(&highlighter, &buffer, 1, "| ---: | --- |");
894        assert_eq!(delim, "| ---: | --- |");
895        let pipe_cols = |s: &str| {
896            s.char_indices()
897                .filter(|(_, c)| *c == '|')
898                .map(|(i, _)| i)
899                .collect::<Vec<_>>()
900        };
901        assert_eq!(pipe_cols(&body), pipe_cols(&delim));
902    }
903
904    #[test]
905    fn test_table_rows_opt_out_of_wrapping() {
906        let buffer = EditorBuffer::new("| a | b |\n| --- | --- |\n| c | d |\nplain");
907        let highlighter = MarkdownHighlighter::new();
908        assert!(!highlighter.should_wrap_line(&buffer, 0));
909        assert!(!highlighter.should_wrap_line(&buffer, 1));
910        assert!(!highlighter.should_wrap_line(&buffer, 2));
911        assert!(highlighter.should_wrap_line(&buffer, 3));
912
913        let off = MarkdownHighlighter::with_config(MarkdownConfig {
914            table_alignment: false,
915            ..Default::default()
916        });
917        assert!(off.should_wrap_line(&buffer, 0));
918        assert!(
919            off.expand_line(&buffer, 0, &ConcealedLine::build("| a | b |", &[]))
920                .is_empty()
921        );
922    }
923
924    #[test]
925    fn test_table_layout_cache_invalidates_on_edit() {
926        let mut buffer = EditorBuffer::new("| a |\n| --- |\n| b |");
927        let highlighter = MarkdownHighlighter::new();
928        assert_eq!(
929            expanded_display(&highlighter, &buffer, 2, "| b |"),
930            "| b   |"
931        );
932        buffer.replace_range(16..17, "much-longer");
933        assert_eq!(
934            expanded_display(&highlighter, &buffer, 2, "| much-longer |"),
935            "| much-longer |"
936        );
937    }
938
939    #[test]
940    fn test_large_table_body_rows_stay_detected() {
941        // The cached block lookup must keep working hundreds of rows deep in
942        // one table (a per-row upward walk with a bounded budget would give
943        // up before reaching the delimiter and drop table styling mid-table).
944        let mut s = String::from("| a | b |\n| --- | --- |\n");
945        for i in 0..700 {
946            s.push_str(&format!("| c{i} | d |\n"));
947        }
948        let buffer = EditorBuffer::new(&s);
949        let highlighter = MarkdownHighlighter::new();
950
951        for row in [10usize, 300, 699, 701] {
952            let line = buffer.line_to_string(row);
953            let text = line.trim_end_matches(['\r', '\n']);
954            let spans = highlighter.highlight_line(&buffer, row, text);
955            assert!(
956                spans
957                    .iter()
958                    .any(|sp| sp.style == StyleValue::Tag(HighlightTag::Custom(TABLE_CELL_TAG))),
959                "deep body row {row} must keep its table cell tag"
960            );
961            assert!(
962                !highlighter.should_wrap_line(&buffer, row),
963                "deep body row {row} must opt out of wrapping"
964            );
965            let concealed = ConcealedLine::build(text, &spans);
966            assert!(
967                !highlighter.expand_line(&buffer, row, &concealed).is_empty(),
968                "deep body row {row} must get alignment padding"
969            );
970        }
971    }
972    #[test]
973    fn test_deep_table_rows_highlight_with_fence_at_top() {
974        // Guards the version-cached fence index: a fence pair far above must
975        // not hide a real table 1000+ lines below, and fenced pipe rows must
976        // still highlight as code, not table cells.
977        let mut s = String::from("```rust\nfn f() {}\n```\n");
978        for i in 0..1000 {
979            s.push_str(&format!("plain filler line {i}\n"));
980        }
981        let header_row = 1003;
982        s.push_str("| a | b |\n| --- | --- |\n| c | d |\n");
983        s.push_str("```\n| x |\n| --- |\n```\n");
984        let buffer = EditorBuffer::new(&s);
985        let highlighter = MarkdownHighlighter::new();
986
987        let header = highlighter.highlight_line(&buffer, header_row, "| a | b |");
988        assert!(
989            header
990                .iter()
991                .any(|sp| sp.style == StyleValue::Tag(HighlightTag::Custom(TABLE_HEADER_TAG))),
992            "deep header row must keep its table tag"
993        );
994        assert!(
995            header
996                .iter()
997                .any(|sp| sp.style == StyleValue::Tag(HighlightTag::Bold)),
998            "deep header cells must stay bold"
999        );
1000        let body = highlighter.highlight_line(&buffer, header_row + 2, "| c | d |");
1001        assert!(
1002            body.iter()
1003                .any(|sp| sp.style == StyleValue::Tag(HighlightTag::Custom(TABLE_CELL_TAG)))
1004        );
1005        assert!(!highlighter.should_wrap_line(&buffer, header_row));
1006        assert!(!highlighter.should_wrap_line(&buffer, header_row + 2));
1007
1008        // Pipe rows inside the trailing fence are code, never table cells.
1009        let total = buffer.len_lines();
1010        let fenced_pipe_row = total - 3;
1011        let fenced = highlighter.highlight_line(&buffer, fenced_pipe_row, "| x |");
1012        assert!(
1013            fenced
1014                .iter()
1015                .any(|sp| sp.style == StyleValue::Tag(HighlightTag::Code)),
1016            "fenced pipe row must highlight as code"
1017        );
1018        assert!(
1019            fenced
1020                .iter()
1021                .all(|sp| sp.style != StyleValue::Tag(HighlightTag::Custom(TABLE_CELL_TAG))),
1022            "fenced pipe row must not emit table cell tags"
1023        );
1024    }
1025}