use std::ops::Range;
use std::sync::{Arc, RwLock};
use crate::{
ConcealedLine, DisplayPad, EditorBuffer, HighlightTag, StyleSpan, SyntaxHighlighter,
display_width,
};
use super::config::{ConcealMode, MarkdownConfig};
use super::links::extract_markdown_links;
use super::table::{
TABLE_CELL_TAG, TABLE_DELIMITER_TAG, TABLE_HEADER_TAG, TableAlignment, TableBlock, TableLayout,
TableRowKind, clean_table_line, find_unescaped_pipes, is_fenced_row, split_table_cells,
table_layouts_with_fences,
};
type TableCache = Arc<RwLock<Option<(usize, Vec<TableLayout>)>>>;
type FenceCache = Arc<RwLock<Option<(usize, Vec<usize>)>>>;
#[derive(Debug, Clone)]
pub struct MarkdownHighlighter {
config: MarkdownConfig,
cached_fences: FenceCache,
cached_tables: TableCache,
}
impl Default for MarkdownHighlighter {
fn default() -> Self {
Self::new()
}
}
impl MarkdownHighlighter {
pub fn new() -> Self {
Self::with_config(MarkdownConfig::default())
}
pub fn with_config(config: MarkdownConfig) -> Self {
Self {
config,
cached_fences: Arc::new(RwLock::new(None)),
cached_tables: Arc::new(RwLock::new(None)),
}
}
pub fn config(&self) -> &MarkdownConfig {
&self.config
}
pub fn set_config(&mut self, config: MarkdownConfig) {
self.config = config;
}
fn is_in_fenced_code_block(&self, buffer: &EditorBuffer, current_row: usize) -> bool {
let fences = self.cached_fence_rows(buffer);
is_fenced_row(&fences, current_row)
}
fn cached_fence_rows(&self, buffer: &EditorBuffer) -> Vec<usize> {
let version = buffer.version();
if let Ok(guard) = self.cached_fences.read()
&& let Some((v, ref fences)) = *guard
&& v == version
{
return fences.clone();
}
if let Ok(mut guard) = self.cached_fences.write() {
if let Some((v, ref fences)) = *guard
&& v == version
{
return fences.clone();
}
let fences = scan_fence_rows(buffer);
*guard = Some((version, fences.clone()));
fences
} else {
scan_fence_rows(buffer)
}
}
fn cached_table_block(&self, buffer: &EditorBuffer, row: usize) -> Option<TableBlock> {
if row >= buffer.len_lines() {
return None;
}
self.cached_layouts(buffer)
.into_iter()
.find(|l| l.block.contains(row))
.map(|l| l.block)
}
fn layout_for_row(&self, buffer: &EditorBuffer, row: usize) -> Option<TableLayout> {
self.cached_layouts(buffer)
.into_iter()
.find(|l| l.block.contains(row))
}
fn cached_layouts(&self, buffer: &EditorBuffer) -> Vec<TableLayout> {
let version = buffer.version();
if let Ok(guard) = self.cached_tables.read()
&& let Some((v, ref layouts)) = *guard
&& v == version
{
return layouts.clone();
}
if let Ok(mut guard) = self.cached_tables.write() {
if let Some((v, ref layouts)) = *guard
&& v == version
{
return layouts.clone();
}
let fences = self.cached_fence_rows(buffer);
let layouts = table_layouts_with_fences(buffer, &fences);
*guard = Some((version, layouts.clone()));
layouts
} else {
let fences = self.cached_fence_rows(buffer);
table_layouts_with_fences(buffer, &fences)
}
}
}
fn scan_fence_rows(buffer: &EditorBuffer) -> Vec<usize> {
let mut fences = Vec::new();
let total_lines = buffer.len_lines();
let rope = buffer.text();
for r in 0..total_lines {
let line = rope.line(r);
let mut chars = line.chars();
while let Some(c) = chars.next() {
if !c.is_whitespace() {
if (c == '`' && chars.next() == Some('`') && chars.next() == Some('`'))
|| (c == '~' && chars.next() == Some('~') && chars.next() == Some('~'))
{
fences.push(r);
}
break;
}
}
}
fences
}
fn snap_display_fwd(display: &str, mut i: usize) -> usize {
i = i.min(display.len());
while i < display.len() && !display.is_char_boundary(i) {
i += 1;
}
i
}
fn snap_display_back(display: &str, mut i: usize) -> usize {
i = i.min(display.len());
while i > 0 && !display.is_char_boundary(i) {
i -= 1;
}
i
}
fn table_row_pads(
layout: &TableLayout,
kind: TableRowKind,
source: &str,
concealed: &ConcealedLine,
) -> Vec<DisplayPad> {
let display = &concealed.display_text;
let (_, cells) = split_table_cells(source);
let mut pads = Vec::new();
for (i, cell) in cells.iter().enumerate().take(layout.col_widths.len()) {
let width = layout.col_widths[i];
let ds = snap_display_fwd(
display,
concealed.source_to_display(cell.start.min(source.len())),
);
let de = snap_display_back(
display,
concealed.source_to_display(cell.end.min(source.len())),
);
if ds >= de {
continue;
}
let bytes = display.as_bytes();
let mut cs = ds;
while cs < de && (bytes[cs] == b' ' || bytes[cs] == b'\t') {
cs += 1;
}
let mut ce = de;
while ce > cs && (bytes[ce - 1] == b' ' || bytes[ce - 1] == b'\t') {
ce -= 1;
}
let content_width = display_width(&display[cs..ce]);
if content_width >= width {
continue;
}
let need = width - content_width;
if kind == TableRowKind::Delimiter {
let at = if display[cs..ce].ends_with(':') {
ce - 1
} else {
ce
};
pads.push(DisplayPad {
display_at: at,
fill: '-',
len: need,
});
continue;
}
match layout
.block
.aligns
.get(i)
.copied()
.unwrap_or(TableAlignment::None)
{
TableAlignment::Right => pads.push(DisplayPad {
display_at: cs,
fill: ' ',
len: need,
}),
TableAlignment::Center => {
let left = need / 2;
let right = need - left;
if left > 0 {
pads.push(DisplayPad {
display_at: cs,
fill: ' ',
len: left,
});
}
if right > 0 {
pads.push(DisplayPad {
display_at: ce,
fill: ' ',
len: right,
});
}
}
TableAlignment::Left | TableAlignment::None => pads.push(DisplayPad {
display_at: ce,
fill: ' ',
len: need,
}),
}
}
pads
}
impl SyntaxHighlighter for MarkdownHighlighter {
fn highlight_line(&self, buffer: &EditorBuffer, row: usize, line_text: &str) -> Vec<StyleSpan> {
let mut spans = Vec::new();
let trimmed_start = line_text.trim_start();
let delimiter_tag = match self.config.conceal_mode {
ConcealMode::Off => None,
ConcealMode::Dimmed => Some(HighlightTag::Dimmed),
ConcealMode::Hidden => Some(HighlightTag::Hidden),
};
let is_cursor_row = row == buffer.cursor_point().row;
if trimmed_start.starts_with("```") || trimmed_start.starts_with("~~~") {
spans.push(StyleSpan::tag(0..line_text.len(), HighlightTag::Code));
return spans;
}
if self.is_in_fenced_code_block(buffer, row) {
spans.push(StyleSpan::tag(0..line_text.len(), HighlightTag::Code));
return spans;
}
if self.config.visual_tables
&& let Some(block) = self.cached_table_block(buffer, row)
&& let Some(kind) = block.kind_at(row)
{
let pipe_dim = match self.config.conceal_mode {
ConcealMode::Off => None,
ConcealMode::Dimmed | ConcealMode::Hidden => Some(HighlightTag::Dimmed),
};
let pipes = find_unescaped_pipes(line_text);
match kind {
TableRowKind::Delimiter => {
spans.push(StyleSpan::tag(
0..line_text.len(),
HighlightTag::Custom(TABLE_DELIMITER_TAG),
));
for p in &pipes {
spans.push(StyleSpan::tag(*p..*p + 1, HighlightTag::Punctuation));
}
if !is_cursor_row {
let tag = delimiter_tag.unwrap_or(HighlightTag::Comment);
let tag = if tag == HighlightTag::Hidden {
HighlightTag::Dimmed
} else {
tag
};
spans.push(StyleSpan::tag(0..line_text.len(), tag));
}
return spans;
}
TableRowKind::Header | TableRowKind::Body => {
let cell_tag = if kind == TableRowKind::Header {
HighlightTag::Custom(TABLE_HEADER_TAG)
} else {
HighlightTag::Custom(TABLE_CELL_TAG)
};
let (_, cells) = split_table_cells(line_text);
for cell in &cells {
let end = cell.end.min(line_text.len());
if cell.start < end {
spans.push(StyleSpan::tag(cell.start..end, cell_tag));
if kind == TableRowKind::Header
&& let Some(content) = line_text.get(cell.start..end)
&& !content.trim().is_empty()
{
spans.push(StyleSpan::tag(cell.start..end, HighlightTag::Bold));
}
}
}
for p in &pipes {
spans.push(StyleSpan::tag(*p..*p + 1, HighlightTag::Punctuation));
if !is_cursor_row && let Some(dim) = pipe_dim {
spans.push(StyleSpan::tag(*p..*p + 1, dim));
}
}
}
}
}
if self.config.visual_thematic_breaks {
let trimmed_break = trimmed_start.trim_end();
if (trimmed_break == "---" || trimmed_break == "***" || trimmed_break == "___")
&& line_text.len() >= 3
{
spans.push(StyleSpan::tag(
0..line_text.len(),
HighlightTag::HorizontalRule,
));
let tag = delimiter_tag.unwrap_or(HighlightTag::Comment);
spans.push(StyleSpan::tag(0..line_text.len(), tag));
return spans;
}
}
let heading_prefix = if trimmed_start.starts_with("# ") {
Some((2, HighlightTag::Heading(1)))
} else if trimmed_start.starts_with("## ") {
Some((3, HighlightTag::Heading(2)))
} else if trimmed_start.starts_with("### ") {
Some((4, HighlightTag::Heading(3)))
} else if trimmed_start.starts_with("#### ") {
Some((5, HighlightTag::Heading(4)))
} else if trimmed_start.starts_with("##### ") {
Some((6, HighlightTag::Heading(5)))
} else if trimmed_start.starts_with("###### ") {
Some((7, HighlightTag::Heading(6)))
} else {
None
};
if let Some((prefix_len, tag)) = heading_prefix {
let indent = line_text.len() - trimmed_start.len();
if !is_cursor_row && let Some(delim_tag) = delimiter_tag {
spans.push(StyleSpan::tag(indent..indent + prefix_len, delim_tag));
if indent + prefix_len < line_text.len() {
spans.push(StyleSpan::tag(indent + prefix_len..line_text.len(), tag));
}
} else {
spans.push(StyleSpan::tag(0..line_text.len(), tag));
}
return spans;
}
if trimmed_start.starts_with("> ") || trimmed_start == ">" {
let indent = line_text.len() - trimmed_start.len();
let quote_len = if trimmed_start.starts_with("> ") {
2
} else {
1
};
spans.push(StyleSpan::tag(
indent..indent + quote_len,
HighlightTag::Blockquote,
));
if !is_cursor_row && let Some(delim_tag) = delimiter_tag {
let delim_len = if trimmed_start.starts_with("> ") {
2
} else {
1
};
spans.push(StyleSpan::tag(indent..indent + delim_len, delim_tag));
} else {
spans.push(StyleSpan::tag(indent..indent + 1, HighlightTag::Comment));
}
}
let is_task_unchecked = trimmed_start.starts_with("- [ ] ")
|| trimmed_start == "- [ ]"
|| trimmed_start.starts_with("* [ ] ")
|| trimmed_start == "* [ ]";
let is_task_checked = trimmed_start.starts_with("- [x] ")
|| trimmed_start == "- [x]"
|| trimmed_start.starts_with("- [X] ")
|| trimmed_start == "- [X]"
|| trimmed_start.starts_with("* [x] ")
|| trimmed_start == "* [x]"
|| trimmed_start.starts_with("* [X] ")
|| trimmed_start == "* [X]";
let is_task_list = is_task_unchecked || is_task_checked;
if is_task_list {
let indent = line_text.len() - trimmed_start.len();
let marker_len = if trimmed_start.len() >= 6 {
6
} else {
trimmed_start.len()
};
let task_tag = if is_task_checked {
HighlightTag::TaskChecked
} else {
HighlightTag::TaskUnchecked
};
spans.push(StyleSpan::tag(indent..indent + marker_len, task_tag));
if !is_cursor_row && let Some(delim_tag) = delimiter_tag {
if delim_tag == HighlightTag::Hidden {
spans.push(StyleSpan::tag(
indent..indent + marker_len,
HighlightTag::Hidden,
));
} else {
spans.push(StyleSpan::tag(indent..indent + 2, delim_tag));
}
}
}
super::inline::highlight_inline_markdown(
line_text,
is_cursor_row,
delimiter_tag,
&mut spans,
);
spans
}
fn extract_links(
&self,
_buffer: &EditorBuffer,
_row: usize,
line_text: &str,
) -> Vec<(Range<usize>, String)> {
if !(line_text.contains('[') || line_text.contains('<')) {
return Vec::new();
}
extract_markdown_links(line_text)
}
fn expand_line(
&self,
buffer: &EditorBuffer,
row: usize,
concealed: &ConcealedLine,
) -> Vec<DisplayPad> {
if !(self.config.visual_tables && self.config.table_alignment) {
return Vec::new();
}
let layout = match self.layout_for_row(buffer, row) {
Some(layout) => layout,
None => return Vec::new(),
};
let kind = match layout.block.kind_at(row) {
Some(kind) => kind,
None => return Vec::new(),
};
let source = clean_table_line(&buffer.line_to_string(row)).to_string();
table_row_pads(&layout, kind, &source, concealed)
}
fn should_wrap_line(&self, buffer: &EditorBuffer, row: usize) -> bool {
if !(self.config.visual_tables && self.config.table_alignment) {
return true;
}
!self
.cached_table_block(buffer, row)
.is_some_and(|b| b.contains(row))
}
}
#[cfg(test)]
mod tests {
use super::super::links::extract_markdown_links;
use super::*;
use crate::{ConcealedLine, StyleValue};
#[test]
fn test_markdown_heading_spans() {
let buffer = EditorBuffer::new("# Heading 1\n## Heading 2\nplain text\n---");
let highlighter = MarkdownHighlighter::new();
let spans1 = highlighter.highlight_line(&buffer, 0, "# Heading 1");
assert_eq!(spans1.len(), 1);
assert_eq!(spans1[0].style, StyleValue::Tag(HighlightTag::Heading(1)));
let spans2 = highlighter.highlight_line(&buffer, 1, "## Heading 2");
assert_eq!(spans2.len(), 2);
assert_eq!(spans2[0].style, StyleValue::Tag(HighlightTag::Dimmed));
assert_eq!(spans2[1].style, StyleValue::Tag(HighlightTag::Heading(2)));
let spans3 = highlighter.highlight_line(&buffer, 2, "plain text");
assert!(spans3.is_empty());
let spans4 = highlighter.highlight_line(&buffer, 3, "---");
assert_eq!(spans4.len(), 2);
assert_eq!(
spans4[0].style,
StyleValue::Tag(HighlightTag::HorizontalRule)
);
assert_eq!(spans4[1].style, StyleValue::Tag(HighlightTag::Dimmed));
}
#[test]
fn test_markdown_heading_levels_4_to_6() {
let buffer = EditorBuffer::new("#### H4\n##### H5\n###### H6");
let highlighter = MarkdownHighlighter::new();
for (row, text, level) in [
(0, "#### H4", 4u8),
(1, "##### H5", 5u8),
(2, "###### H6", 6u8),
] {
let spans = highlighter.highlight_line(&buffer, row, text);
assert!(
spans
.iter()
.any(|s| s.style == StyleValue::Tag(HighlightTag::Heading(level))),
"row {row} must emit Heading({level})"
);
}
}
#[test]
fn test_markdown_inline_bold_and_code() {
let buffer = EditorBuffer::new("This is **bold** and `code` here.");
let highlighter = MarkdownHighlighter::new();
let spans = highlighter.highlight_line(&buffer, 0, "This is **bold** and `code` here.");
let bold_span = spans
.iter()
.find(|s| s.style == StyleValue::Tag(HighlightTag::Bold));
assert!(bold_span.is_some());
let code_span = spans
.iter()
.find(|s| s.style == StyleValue::Tag(HighlightTag::Code));
assert!(code_span.is_some());
}
#[test]
fn test_markdown_conceal_modes() {
let buffer = EditorBuffer::new("# Heading 1\n## Heading 2");
let hidden_highlighter = MarkdownHighlighter::with_config(MarkdownConfig {
conceal_mode: ConcealMode::Hidden,
..Default::default()
});
let spans_hidden = hidden_highlighter.highlight_line(&buffer, 1, "## Heading 2");
assert_eq!(spans_hidden.len(), 2);
assert_eq!(spans_hidden[0].style, StyleValue::Tag(HighlightTag::Hidden));
assert_eq!(
spans_hidden[1].style,
StyleValue::Tag(HighlightTag::Heading(2))
);
let off_highlighter = MarkdownHighlighter::with_config(MarkdownConfig {
conceal_mode: ConcealMode::Off,
..Default::default()
});
let spans_off = off_highlighter.highlight_line(&buffer, 1, "## Heading 2");
assert_eq!(spans_off.len(), 1);
assert_eq!(
spans_off[0].style,
StyleValue::Tag(HighlightTag::Heading(2))
);
}
#[test]
fn test_markdown_task_list_and_quote_concealment() {
let buffer = EditorBuffer::new("- [ ] Task 1\n> Quote line\n```rust\nfn main() {}\n```");
let hidden_highlighter = MarkdownHighlighter::with_config(MarkdownConfig {
conceal_mode: ConcealMode::Hidden,
..Default::default()
});
let spans_quote = hidden_highlighter.highlight_line(&buffer, 1, "> Quote line");
assert!(!spans_quote.is_empty());
assert_eq!(spans_quote[0].range, 0..2);
assert_eq!(
spans_quote[0].style,
StyleValue::Tag(HighlightTag::Blockquote)
);
assert_eq!(spans_quote[1].range, 0..2);
assert_eq!(spans_quote[1].style, StyleValue::Tag(HighlightTag::Hidden));
let concealed_quote = ConcealedLine::build("> Quote line", &spans_quote);
assert_eq!(concealed_quote.display_text, "Quote line");
let spans_fence = hidden_highlighter.highlight_line(&buffer, 2, "```rust");
assert_eq!(spans_fence.len(), 1);
assert_eq!(spans_fence[0].range, 0..7);
assert_eq!(spans_fence[0].style, StyleValue::Tag(HighlightTag::Code));
let concealed_fence = ConcealedLine::build("```rust", &spans_fence);
assert_eq!(concealed_fence.display_text, "```rust");
let mut buffer_moved = buffer;
buffer_moved.set_cursor_offset(13); let spans_task = hidden_highlighter.highlight_line(&buffer_moved, 0, "- [ ] Task 1");
assert!(!spans_task.is_empty());
assert_eq!(spans_task[0].range, 0..6);
assert_eq!(
spans_task[0].style,
StyleValue::Tag(HighlightTag::TaskUnchecked)
);
assert_eq!(spans_task[1].range, 0..6);
assert_eq!(spans_task[1].style, StyleValue::Tag(HighlightTag::Hidden));
let concealed_task = ConcealedLine::build("- [ ] Task 1", &spans_task);
assert_eq!(concealed_task.display_text, "Task 1");
}
#[test]
fn test_markdown_link_concealment() {
let buffer = EditorBuffer::new("[Google](https://google.com)\nActive line");
let hidden_highlighter = MarkdownHighlighter::with_config(MarkdownConfig {
conceal_mode: ConcealMode::Hidden,
..Default::default()
});
let spans_active =
hidden_highlighter.highlight_line(&buffer, 0, "[Google](https://google.com)");
let concealed_active = ConcealedLine::build("[Google](https://google.com)", &spans_active);
assert_eq!(
concealed_active.display_text,
"[Google](https://google.com)"
);
let mut buffer_moved = buffer;
buffer_moved.set_cursor_offset(30);
let spans_hidden =
hidden_highlighter.highlight_line(&buffer_moved, 0, "[Google](https://google.com)");
let concealed_hidden = ConcealedLine::build("[Google](https://google.com)", &spans_hidden);
assert_eq!(concealed_hidden.display_text, "Google");
let extracted = extract_markdown_links("[Google](https://google.com)");
assert_eq!(extracted.len(), 1);
assert_eq!(extracted[0].0, 1..7);
assert_eq!(extracted[0].1, "https://google.com");
}
#[test]
fn test_markdown_structural_tags_in_dimmed_mode() {
let buffer = EditorBuffer::new("- [ ] Todo\n- [x] Done\n> Quote\n---");
let highlighter = MarkdownHighlighter::new();
let unchecked = highlighter.highlight_line(&buffer, 0, "- [ ] Todo");
assert!(
unchecked
.iter()
.any(|s| s.style == StyleValue::Tag(HighlightTag::TaskUnchecked))
);
let mut moved = buffer;
moved.set_cursor_offset(30);
let unchecked_inactive = highlighter.highlight_line(&moved, 0, "- [ ] Todo");
assert!(
unchecked_inactive
.iter()
.any(|s| s.style == StyleValue::Tag(HighlightTag::TaskUnchecked))
);
let checked = highlighter.highlight_line(&moved, 1, "- [x] Done");
assert!(
checked
.iter()
.any(|s| s.style == StyleValue::Tag(HighlightTag::TaskChecked))
);
let quote = highlighter.highlight_line(&moved, 2, "> Quote");
assert!(
quote
.iter()
.any(|s| s.style == StyleValue::Tag(HighlightTag::Blockquote))
);
let hr = highlighter.highlight_line(&moved, 3, "---");
assert!(
hr.iter()
.any(|s| s.style == StyleValue::Tag(HighlightTag::HorizontalRule))
);
}
#[test]
fn test_table_highlight_uses_existing_tags_only() {
let buffer = EditorBuffer::new("| Name | Age |\n| --- | ---: |\n| Ada | 36 |");
let highlighter = MarkdownHighlighter::new();
let header = highlighter.highlight_line(&buffer, 0, "| Name | Age |");
assert!(
header
.iter()
.any(|s| s.style == StyleValue::Tag(HighlightTag::Punctuation))
);
assert!(
header
.iter()
.any(|s| s.style == StyleValue::Tag(HighlightTag::Bold))
);
assert!(
header
.iter()
.any(|s| s.style == StyleValue::Tag(HighlightTag::Custom(TABLE_HEADER_TAG)))
);
let body = highlighter.highlight_line(&buffer, 2, "| Ada | 36 |");
assert!(
body.iter()
.any(|s| s.style == StyleValue::Tag(HighlightTag::Custom(TABLE_CELL_TAG)))
);
assert!(
body.iter()
.all(|s| s.style != StyleValue::Tag(HighlightTag::Bold))
);
let delim = highlighter.highlight_line(&buffer, 1, "| --- | ---: |");
assert!(
delim
.iter()
.any(|s| s.style == StyleValue::Tag(HighlightTag::Custom(TABLE_DELIMITER_TAG)))
);
let code_row = EditorBuffer::new("| `x|y` | b |\n| --- | --- |\n| c | d |");
let code_spans = highlighter.highlight_line(&code_row, 0, "| `x|y` | b |");
assert!(
code_spans
.iter()
.any(|s| s.style == StyleValue::Tag(HighlightTag::Code))
);
let hidden = MarkdownHighlighter::with_config(MarkdownConfig {
conceal_mode: ConcealMode::Hidden,
..Default::default()
});
let mut moved = EditorBuffer::new("| Name | Age |\n| --- | ---: |\n| Ada | 36 |");
moved.set_cursor_offset(40);
let inactive = hidden.highlight_line(&moved, 0, "| Name | Age |");
assert!(
inactive
.iter()
.all(|s| s.style != StyleValue::Tag(HighlightTag::Hidden))
);
assert!(
inactive
.iter()
.any(|s| s.style == StyleValue::Tag(HighlightTag::Dimmed))
);
let concealed = ConcealedLine::build("| Name | Age |", &inactive);
assert_eq!(concealed.display_text, "| Name | Age |");
let off = MarkdownHighlighter::with_config(MarkdownConfig {
visual_tables: false,
..Default::default()
});
let plain = off.highlight_line(&buffer, 0, "| Name | Age |");
assert!(plain.iter().all(|s| !matches!(
s.style,
StyleValue::Tag(
HighlightTag::Custom(TABLE_HEADER_TAG)
| HighlightTag::Custom(TABLE_CELL_TAG)
| HighlightTag::Custom(TABLE_DELIMITER_TAG)
)
)));
}
fn expanded_display(
highlighter: &MarkdownHighlighter,
buffer: &EditorBuffer,
row: usize,
line: &str,
) -> String {
let spans = highlighter.highlight_line(buffer, row, line);
let concealed = ConcealedLine::build(line, &spans);
let pads = highlighter.expand_line(buffer, row, &concealed);
concealed.expanded(&pads).display_text
}
#[test]
fn test_table_columns_share_widths_when_a_cell_grows() {
let buffer = EditorBuffer::new("| a | b |\n| --- | --- |\n| looong | c |\n| d | e |");
let highlighter = MarkdownHighlighter::new();
let header = expanded_display(&highlighter, &buffer, 0, "| a | b |");
let body_long = expanded_display(&highlighter, &buffer, 2, "| looong | c |");
let body_short = expanded_display(&highlighter, &buffer, 3, "| d | e |");
let delim = expanded_display(&highlighter, &buffer, 1, "| --- | --- |");
let pipe_cols = |s: &str| {
s.char_indices()
.filter(|(_, c)| *c == '|')
.map(|(i, _)| i)
.collect::<Vec<_>>()
};
assert_eq!(pipe_cols(&header), pipe_cols(&body_long));
assert_eq!(pipe_cols(&header), pipe_cols(&body_short));
assert_eq!(pipe_cols(&header), pipe_cols(&delim));
assert_eq!(header, "| a | b |");
assert_eq!(body_long, "| looong | c |");
assert_eq!(delim, "| ------ | --- |");
}
#[test]
fn test_table_alignment_honors_delimiter_sides() {
let buffer = EditorBuffer::new("| ab | c |\n| ---: | --- |\n| d | ef |");
let highlighter = MarkdownHighlighter::new();
let body = expanded_display(&highlighter, &buffer, 2, "| d | ef |");
assert_eq!(body, "| d | ef |");
let delim = expanded_display(&highlighter, &buffer, 1, "| ---: | --- |");
assert_eq!(delim, "| ---: | --- |");
let pipe_cols = |s: &str| {
s.char_indices()
.filter(|(_, c)| *c == '|')
.map(|(i, _)| i)
.collect::<Vec<_>>()
};
assert_eq!(pipe_cols(&body), pipe_cols(&delim));
}
#[test]
fn test_table_rows_opt_out_of_wrapping() {
let buffer = EditorBuffer::new("| a | b |\n| --- | --- |\n| c | d |\nplain");
let highlighter = MarkdownHighlighter::new();
assert!(!highlighter.should_wrap_line(&buffer, 0));
assert!(!highlighter.should_wrap_line(&buffer, 1));
assert!(!highlighter.should_wrap_line(&buffer, 2));
assert!(highlighter.should_wrap_line(&buffer, 3));
let off = MarkdownHighlighter::with_config(MarkdownConfig {
table_alignment: false,
..Default::default()
});
assert!(off.should_wrap_line(&buffer, 0));
assert!(
off.expand_line(&buffer, 0, &ConcealedLine::build("| a | b |", &[]))
.is_empty()
);
}
#[test]
fn test_table_layout_cache_invalidates_on_edit() {
let mut buffer = EditorBuffer::new("| a |\n| --- |\n| b |");
let highlighter = MarkdownHighlighter::new();
assert_eq!(
expanded_display(&highlighter, &buffer, 2, "| b |"),
"| b |"
);
buffer.replace_range(16..17, "much-longer");
assert_eq!(
expanded_display(&highlighter, &buffer, 2, "| much-longer |"),
"| much-longer |"
);
}
#[test]
fn test_large_table_body_rows_stay_detected() {
let mut s = String::from("| a | b |\n| --- | --- |\n");
for i in 0..700 {
s.push_str(&format!("| c{i} | d |\n"));
}
let buffer = EditorBuffer::new(&s);
let highlighter = MarkdownHighlighter::new();
for row in [10usize, 300, 699, 701] {
let line = buffer.line_to_string(row);
let text = line.trim_end_matches(['\r', '\n']);
let spans = highlighter.highlight_line(&buffer, row, text);
assert!(
spans
.iter()
.any(|sp| sp.style == StyleValue::Tag(HighlightTag::Custom(TABLE_CELL_TAG))),
"deep body row {row} must keep its table cell tag"
);
assert!(
!highlighter.should_wrap_line(&buffer, row),
"deep body row {row} must opt out of wrapping"
);
let concealed = ConcealedLine::build(text, &spans);
assert!(
!highlighter.expand_line(&buffer, row, &concealed).is_empty(),
"deep body row {row} must get alignment padding"
);
}
}
#[test]
fn test_deep_table_rows_highlight_with_fence_at_top() {
let mut s = String::from("```rust\nfn f() {}\n```\n");
for i in 0..1000 {
s.push_str(&format!("plain filler line {i}\n"));
}
let header_row = 1003;
s.push_str("| a | b |\n| --- | --- |\n| c | d |\n");
s.push_str("```\n| x |\n| --- |\n```\n");
let buffer = EditorBuffer::new(&s);
let highlighter = MarkdownHighlighter::new();
let header = highlighter.highlight_line(&buffer, header_row, "| a | b |");
assert!(
header
.iter()
.any(|sp| sp.style == StyleValue::Tag(HighlightTag::Custom(TABLE_HEADER_TAG))),
"deep header row must keep its table tag"
);
assert!(
header
.iter()
.any(|sp| sp.style == StyleValue::Tag(HighlightTag::Bold)),
"deep header cells must stay bold"
);
let body = highlighter.highlight_line(&buffer, header_row + 2, "| c | d |");
assert!(
body.iter()
.any(|sp| sp.style == StyleValue::Tag(HighlightTag::Custom(TABLE_CELL_TAG)))
);
assert!(!highlighter.should_wrap_line(&buffer, header_row));
assert!(!highlighter.should_wrap_line(&buffer, header_row + 2));
let total = buffer.len_lines();
let fenced_pipe_row = total - 3;
let fenced = highlighter.highlight_line(&buffer, fenced_pipe_row, "| x |");
assert!(
fenced
.iter()
.any(|sp| sp.style == StyleValue::Tag(HighlightTag::Code)),
"fenced pipe row must highlight as code"
);
assert!(
fenced
.iter()
.all(|sp| sp.style != StyleValue::Tag(HighlightTag::Custom(TABLE_CELL_TAG))),
"fenced pipe row must not emit table cell tags"
);
}
}