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
17type TableCache = Arc<RwLock<Option<(usize, Vec<TableLayout>)>>>;
19
20type FenceCache = Arc<RwLock<Option<(usize, Vec<usize>)>>>;
22
23#[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 pub fn new() -> Self {
40 Self::with_config(MarkdownConfig::default())
41 }
42
43 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 pub fn config(&self) -> &MarkdownConfig {
54 &self.config
55 }
56
57 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 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 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 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 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 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
151fn 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
173fn 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
182fn 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
191fn 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 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 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 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 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 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 }
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 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 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 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 let spans_quote = hidden_highlighter.highlight_line(&buffer, 1, "> Quote line");
653 assert!(!spans_quote.is_empty());
654 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 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 let mut buffer_moved = buffer;
675 buffer_moved.set_cursor_offset(13); 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 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 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 let unchecked = highlighter.highlight_line(&buffer, 0, "- [ ] Todo");
727 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 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 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 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 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 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 let body = expanded_display(&highlighter, &buffer, 2, "| d | ef |");
891 assert_eq!(body, "| d | ef |");
892 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 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 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 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}