use std::ops::Range;
use crate::{EditorBuffer, display_width};
pub const TABLE_HEADER_TAG: &str = "markdown.table.header";
pub const TABLE_CELL_TAG: &str = "markdown.table.cell";
pub const TABLE_DELIMITER_TAG: &str = "markdown.table.delimiter";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TableAlignment {
Left,
Center,
Right,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TableRowKind {
Header,
Delimiter,
Body,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableBlock {
pub header_row: usize,
pub delimiter_row: usize,
pub end_row: usize,
pub col_count: usize,
pub aligns: Vec<TableAlignment>,
}
impl TableBlock {
pub fn kind_at(&self, row: usize) -> Option<TableRowKind> {
if row == self.header_row {
Some(TableRowKind::Header)
} else if row == self.delimiter_row {
Some(TableRowKind::Delimiter)
} else if row > self.delimiter_row && row <= self.end_row {
Some(TableRowKind::Body)
} else {
None
}
}
pub fn contains(&self, row: usize) -> bool {
self.kind_at(row).is_some()
}
}
pub(crate) fn clean_table_line(raw: &str) -> &str {
raw.strip_suffix("\r\n")
.or_else(|| raw.strip_suffix('\n'))
.unwrap_or(raw)
}
fn is_fence_line(cleaned: &str) -> bool {
let t = cleaned.trim_start();
t.starts_with("```") || t.starts_with("~~~")
}
pub fn fence_rows(buffer: &EditorBuffer) -> Vec<usize> {
let total = buffer.len_lines();
let mut fences = Vec::new();
for r in 0..total {
if is_fence_line(clean_table_line(&buffer.line_to_string(r))) {
fences.push(r);
}
}
fences
}
pub fn is_fenced_row(fences: &[usize], row: usize) -> bool {
fences.partition_point(|&f_row| f_row < row) % 2 == 1
}
fn code_span_ranges(line: &str) -> Vec<Range<usize>> {
let bytes = line.as_bytes();
let mut ranges = Vec::new();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\\' {
i += if i + 1 < bytes.len() { 2 } else { 1 };
continue;
}
if bytes[i] != b'`' {
i += 1;
continue;
}
let mut run = 0;
while i + run < bytes.len() && bytes[i + run] == b'`' {
run += 1;
}
let mut j = i + run;
let mut closed = None;
while j < bytes.len() {
if bytes[j] == b'\\' {
j += if j + 1 < bytes.len() { 2 } else { 1 };
continue;
}
if bytes[j] == b'`' {
let mut k = 0;
while j + k < bytes.len() && bytes[j + k] == b'`' {
k += 1;
}
if k == run {
closed = Some(j + k);
break;
}
j += k;
continue;
}
j += 1;
}
if let Some(end) = closed {
ranges.push(i..end);
i = end;
} else {
i += run;
}
}
ranges
}
fn is_escaped_at(line: &str, pos: usize) -> bool {
let bytes = line.as_bytes();
let mut slashes = 0;
let mut i = pos;
while i > 0 && bytes[i - 1] == b'\\' {
slashes += 1;
i -= 1;
}
slashes % 2 == 1
}
pub fn find_unescaped_pipes(line: &str) -> Vec<usize> {
let cleaned = clean_table_line(line);
let code = code_span_ranges(cleaned);
let mut pipes = Vec::new();
for (idx, ch) in cleaned.char_indices() {
if ch != '|' {
continue;
}
if is_escaped_at(cleaned, idx) {
continue;
}
if code.iter().any(|r| r.contains(&idx)) {
continue;
}
pipes.push(idx);
}
pipes
}
pub fn split_table_cells(line: &str) -> (Vec<usize>, Vec<Range<usize>>) {
let cleaned = clean_table_line(line);
let pipes = find_unescaped_pipes(cleaned);
if pipes.is_empty() {
let whole = 0..cleaned.len();
return (Vec::new(), vec![whole]);
}
let mut bounds = Vec::with_capacity(pipes.len() + 2);
bounds.push(0);
bounds.extend(pipes.iter().copied());
bounds.push(cleaned.len());
let mut cells: Vec<Range<usize>> = Vec::with_capacity(bounds.len() - 1);
for i in 0..bounds.len() - 1 {
let s = if i > 0 { bounds[i] + 1 } else { bounds[i] };
let e = bounds[i + 1];
cells.push(s..e.min(cleaned.len()));
}
if cleaned.trim_start().starts_with('|') && !cells.is_empty() {
cells.remove(0);
}
if cleaned.trim_end().ends_with('|') && !cells.is_empty() {
cells.pop();
}
(pipes, cells)
}
pub fn parse_delimiter_row(line: &str) -> Option<Vec<TableAlignment>> {
let cleaned = clean_table_line(line);
if cleaned.trim().is_empty() {
return None;
}
let (_, cells) = split_table_cells(cleaned);
if cells.is_empty() {
return None;
}
let mut aligns = Vec::with_capacity(cells.len());
for cell in &cells {
let content = cleaned
.get(cell.clone())
.unwrap_or("")
.trim()
.trim_matches(['\r', '\n']);
if content.is_empty() || !content.contains('-') {
return None;
}
let inner = content.trim_matches(':');
if inner.is_empty() || !inner.chars().all(|c| c == '-') {
return None;
}
let stripped_leading = content.strip_prefix(':').unwrap_or(content);
let stripped_both = stripped_leading
.strip_suffix(':')
.unwrap_or(stripped_leading);
if stripped_both.contains(':') {
return None;
}
let left = content.starts_with(':');
let right = content.ends_with(':');
aligns.push(match (left, right) {
(true, true) => TableAlignment::Center,
(true, false) => TableAlignment::Left,
(false, true) => TableAlignment::Right,
(false, false) => TableAlignment::None,
});
}
Some(aligns)
}
fn table_line_has_pipe(cleaned: &str) -> bool {
!find_unescaped_pipes(cleaned).is_empty()
}
pub fn table_block_at(buffer: &EditorBuffer, row: usize) -> Option<TableBlock> {
let fences = fence_rows(buffer);
table_block_at_with_fences(buffer, row, &fences)
}
pub fn table_block_at_with_fences(
buffer: &EditorBuffer,
row: usize,
fences: &[usize],
) -> Option<TableBlock> {
let total = buffer.len_lines();
if row >= total {
return None;
}
let cur = clean_table_line(&buffer.line_to_string(row)).to_string();
if cur.trim().is_empty() || is_fence_line(&cur) || cur.trim_start().starts_with('>') {
return None;
}
if is_fenced_row(fences, row) {
return None;
}
let mut delim: Option<usize> = None;
if parse_delimiter_row(&cur).is_some() {
delim = Some(row);
} else if row + 1 < total {
let next = clean_table_line(&buffer.line_to_string(row + 1)).to_string();
if !is_fence_line(&next) && parse_delimiter_row(&next).is_some() {
delim = Some(row + 1);
}
}
if delim.is_none() {
let mut r = row.checked_sub(1);
let mut budget = 512;
while let Some(j) = r {
if budget == 0 {
break;
}
budget -= 1;
let text = clean_table_line(&buffer.line_to_string(j)).to_string();
if text.trim().is_empty() || is_fence_line(&text) {
break;
}
if parse_delimiter_row(&text).is_some() {
delim = Some(j);
break;
}
if !table_line_has_pipe(&text) {
break;
}
r = j.checked_sub(1);
}
}
let d = delim?;
if d == 0 {
return None; }
let header = clean_table_line(&buffer.line_to_string(d - 1)).to_string();
if header.trim().is_empty() || is_fence_line(&header) {
return None;
}
let header_pipes = table_line_has_pipe(&header);
let delim_text = clean_table_line(&buffer.line_to_string(d)).to_string();
let delim_pipes = table_line_has_pipe(&delim_text);
if !header_pipes && !delim_pipes {
return None;
}
let aligns = parse_delimiter_row(&delim_text)?;
let (_, header_cells) = split_table_cells(&header);
if header_cells.len() != aligns.len() {
return None;
}
let header_row = d - 1;
let mut end = d;
let mut r = d + 1;
while r < total {
let text = clean_table_line(&buffer.line_to_string(r)).to_string();
if text.trim().is_empty() || is_fence_line(&text) || !table_line_has_pipe(&text) {
break;
}
end = r;
r += 1;
if r - d > 4096 {
break;
}
}
if row < header_row || row > end {
return None;
}
Some(TableBlock {
header_row,
delimiter_row: d,
end_row: end,
col_count: aligns.len(),
aligns,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableLayout {
pub block: TableBlock,
pub col_widths: Vec<usize>,
}
impl TableLayout {
pub fn build(buffer: &EditorBuffer, block: &TableBlock) -> Self {
let mut col_widths = vec![3; block.col_count];
for row in block.header_row..=block.end_row {
let line = clean_table_line(&buffer.line_to_string(row)).to_string();
let (_, cells) = split_table_cells(&line);
for (i, cell) in cells.iter().enumerate().take(block.col_count) {
let content = line.get(cell.clone()).unwrap_or("").trim();
col_widths[i] = col_widths[i].max(display_width(content));
}
}
Self {
block: block.clone(),
col_widths,
}
}
}
pub fn table_layouts(buffer: &EditorBuffer) -> Vec<TableLayout> {
let fences = fence_rows(buffer);
table_layouts_with_fences(buffer, &fences)
}
pub fn table_layouts_with_fences(buffer: &EditorBuffer, fences: &[usize]) -> Vec<TableLayout> {
let mut layouts = Vec::new();
let mut row = 0;
let total = buffer.len_lines();
while row < total {
if let Some(block) = table_block_at_with_fences(buffer, row, fences) {
let end = block.end_row;
layouts.push(TableLayout::build(buffer, &block));
row = end + 1;
} else {
row += 1;
}
}
layouts
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_table_pipes_ignore_escapes_and_code() {
assert_eq!(find_unescaped_pipes("| a | b |"), vec![0, 4, 8]);
assert_eq!(find_unescaped_pipes("| a \\| b |"), vec![0, 9]);
assert_eq!(find_unescaped_pipes("| `a|b` | c |"), vec![0, 8, 12]);
assert_eq!(find_unescaped_pipes("| a \\\\| b |"), vec![0, 6, 10]);
}
#[test]
fn test_table_split_cells_outer_pipes_optional() {
let (pipes, cells) = split_table_cells("| a | b |");
assert_eq!(pipes, vec![0, 4, 8]);
assert_eq!(cells.len(), 2);
let (pipes_bare, cells_bare) = split_table_cells("a | b");
assert_eq!(pipes_bare, vec![2]);
assert_eq!(cells_bare.len(), 2);
let (pipes_none, cells_none) = split_table_cells("plain");
assert!(pipes_none.is_empty());
assert_eq!(cells_none, vec![0..5]);
}
#[test]
fn test_table_parse_delimiter_alignments() {
assert_eq!(
parse_delimiter_row("| --- | :--- | ---: | :---: |"),
Some(vec![
TableAlignment::None,
TableAlignment::Left,
TableAlignment::Right,
TableAlignment::Center
])
);
assert_eq!(
parse_delimiter_row("--- | :-:"),
Some(vec![TableAlignment::None, TableAlignment::Center])
);
assert!(parse_delimiter_row("| --- | nope |").is_none());
assert!(parse_delimiter_row("| --- | :: |").is_none());
assert!(parse_delimiter_row("").is_none());
}
#[test]
fn test_table_block_detection_and_kinds() {
let buffer = EditorBuffer::new("| a | b |\n| --- | --- |\n| c | d |\n| e | f |");
let block = table_block_at(&buffer, 0).expect("header must detect block");
assert_eq!(block.header_row, 0);
assert_eq!(block.delimiter_row, 1);
assert_eq!(block.end_row, 3);
assert_eq!(block.col_count, 2);
assert_eq!(block.kind_at(0), Some(TableRowKind::Header));
assert_eq!(block.kind_at(1), Some(TableRowKind::Delimiter));
assert_eq!(block.kind_at(2), Some(TableRowKind::Body));
assert!(block.contains(3));
assert!(!block.contains(4));
let bare = EditorBuffer::new("a | b\n--- | ---\n c | d ");
let bare_block = table_block_at(&bare, 2).expect("bare pipes must detect");
assert_eq!(bare_block.col_count, 2);
let trailed = EditorBuffer::new("| a |\n| --- |\n| b |\nplain");
assert!(table_block_at(&trailed, 3).is_none());
}
#[test]
fn test_table_rejects_setext_hr_fence_and_quote() {
let setext = EditorBuffer::new("foo\n---\n");
assert!(table_block_at(&setext, 0).is_none());
assert!(table_block_at(&setext, 1).is_none());
let hr = EditorBuffer::new("---\n");
assert!(table_block_at(&hr, 0).is_none());
let uneven = EditorBuffer::new("| a | b |\n| --- |\n| c | d |");
assert!(table_block_at(&uneven, 0).is_none());
let fence = EditorBuffer::new("```\n| a |\n| --- |\n```");
assert!(table_block_at(&fence, 1).is_none());
assert!(table_block_at(&fence, 2).is_none());
let quote = EditorBuffer::new("> | a |\n> | --- |");
assert!(table_block_at("e, 0).is_none());
}
#[test]
fn test_table_layout_measures_max_source_width() {
let buffer = EditorBuffer::new("| a | b |\n| --- | --- |\n| looong | c |\n| d | e |");
let layouts = table_layouts(&buffer);
assert_eq!(layouts.len(), 1);
assert_eq!(layouts[0].col_widths, vec![6, 3]);
let two = EditorBuffer::new("| a |\n| --- |\n| b |\n\n| x | yy |\n| --- | --- |");
assert_eq!(table_layouts(&two).len(), 2);
}
#[test]
fn test_fenced_index_queries_agree_with_single_shot() {
let mut s = String::from("```rust\nfn f() {}\n```\n");
for i in 0..200 {
s.push_str(&format!("plain line {i}\n"));
}
s.push_str("| a | b |\n| --- | --- |\n| c | d |\n");
s.push_str("```\n| x |\n| --- |\n```\n");
let buffer = EditorBuffer::new(&s);
let fences = fence_rows(&buffer);
assert!(fences.contains(&0) && fences.contains(&2));
assert_eq!(fences.len(), 4);
let total = buffer.len_lines();
for row in 0..total {
assert_eq!(
table_block_at_with_fences(&buffer, row, &fences),
table_block_at(&buffer, row),
"indexed query must agree with single-shot at row {row}"
);
}
assert_eq!(
table_layouts_with_fences(&buffer, &fences),
table_layouts(&buffer),
"indexed layouts must agree with single-shot layouts"
);
let table_row = 203;
assert!(table_block_at(&buffer, table_row).is_some());
assert!(!is_fenced_row(&fences, table_row));
let fenced_row = total - 3;
assert!(is_fenced_row(&fences, fenced_row));
assert!(table_block_at(&buffer, fenced_row).is_none());
}
}