use ropey::Rope;
use std::ops::Range;
use tree_sitter::Node;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Align {
Left,
Right,
Center,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableCell {
pub content: Range<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableRow {
pub line: Range<usize>,
pub cells: Vec<TableCell>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableInfo {
pub block: Range<usize>,
pub header: TableRow,
pub delimiter_line: Range<usize>,
pub aligns: Vec<Align>,
pub body: Vec<TableRow>,
pub ncols: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RowKind {
Header,
Delimiter,
Body(usize),
}
pub fn row_kind_at_line(
table: &TableInfo,
line_idx: usize,
byte_to_line: impl Fn(usize) -> usize,
) -> Option<RowKind> {
if byte_to_line(table.header.line.start) == line_idx {
return Some(RowKind::Header);
}
if byte_to_line(table.delimiter_line.start) == line_idx {
return Some(RowKind::Delimiter);
}
table
.body
.iter()
.position(|r| byte_to_line(r.line.start) == line_idx)
.map(RowKind::Body)
}
fn trim_cell_range(rope: &Rope, mut start: usize, mut end: usize) -> Range<usize> {
while start < end && matches!(rope.get_byte(start), Some(b' ') | Some(b'\t')) {
start += 1;
}
while end > start && matches!(rope.get_byte(end - 1), Some(b' ') | Some(b'\t')) {
end -= 1;
}
start..end
}
fn row_from_node(node: &Node, rope: &Rope) -> TableRow {
let mut cursor = node.walk();
let cells = node
.children(&mut cursor)
.filter(|c| c.kind() == "pipe_table_cell")
.map(|c| TableCell {
content: trim_cell_range(rope, c.start_byte(), c.end_byte()),
})
.collect();
TableRow {
line: node.start_byte()..node.end_byte(),
cells,
}
}
fn align_from_delimiter_cell(cell: &Node) -> Align {
let mut cursor = cell.walk();
let mut left = false;
let mut right = false;
for mark in cell.children(&mut cursor) {
match mark.kind() {
"pipe_table_align_left" => left = true,
"pipe_table_align_right" => right = true,
_ => {}
}
}
match (left, right) {
(true, true) => Align::Center,
(false, true) => Align::Right,
_ => Align::Left,
}
}
pub fn extract_table(node: &Node, rope: &Rope) -> Option<TableInfo> {
if node.kind() != "pipe_table" {
return None;
}
let mut header: Option<TableRow> = None;
let mut delimiter_line: Option<Range<usize>> = None;
let mut aligns: Vec<Align> = Vec::new();
let mut body: Vec<TableRow> = Vec::new();
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"pipe_table_header" => header = Some(row_from_node(&child, rope)),
"pipe_table_delimiter_row" => {
delimiter_line = Some(child.start_byte()..child.end_byte());
let mut dc = child.walk();
aligns = child
.children(&mut dc)
.filter(|c| c.kind() == "pipe_table_delimiter_cell")
.map(|c| align_from_delimiter_cell(&c))
.collect();
}
"pipe_table_row" => body.push(row_from_node(&child, rope)),
_ => {}
}
}
let header = header?;
let delimiter_line = delimiter_line?;
let block_start = rope.char_to_byte(rope.line_to_char(rope.byte_to_line(header.line.start)));
let mut block_end = node.end_byte();
let start_line = body
.last()
.map(|r| r.line.start)
.unwrap_or(delimiter_line.start);
let mut line = rope.byte_to_line(start_line);
while line + 1 < rope.len_lines() {
line += 1;
let ls = rope.char_to_byte(rope.line_to_char(line));
let le = if line + 1 < rope.len_lines() {
rope.char_to_byte(rope.line_to_char(line + 1))
} else {
rope.len_bytes()
};
let text = rope.slice(rope.byte_to_char(ls)..rope.byte_to_char(le));
let is_row = text.chars().any(|c| c == '|') && text.chars().any(|c| !c.is_whitespace());
if !is_row {
break;
}
let content_end = if le > ls && rope.get_byte(le - 1) == Some(b'\n') {
le - 1
} else {
le
};
body.push(row_from_raw_line(rope, ls, content_end));
block_end = content_end;
}
let ncols = std::iter::once(header.cells.len())
.chain(body.iter().map(|r| r.cells.len()))
.max()
.unwrap_or(0);
Some(TableInfo {
block: block_start..block_end,
header,
delimiter_line,
aligns,
body,
ncols,
})
}
fn row_from_raw_line(rope: &Rope, start: usize, end: usize) -> TableRow {
let text: String = rope
.slice(rope.byte_to_char(start)..rope.byte_to_char(end))
.to_string();
let bytes = text.as_bytes();
let mut pipes: Vec<usize> = Vec::new();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'\\' => i += 2, b'|' => {
pipes.push(start + i);
i += 1;
}
_ => i += 1,
}
}
let mut segments: Vec<(usize, usize)> = Vec::new();
let mut seg_start = start;
for &p in &pipes {
segments.push((seg_start, p));
seg_start = p + 1;
}
segments.push((seg_start, end));
if segments.len() > 1 {
if trim_cell_range(rope, segments[0].0, segments[0].1).is_empty() {
segments.remove(0);
}
if let Some(&(s, e)) = segments.last()
&& trim_cell_range(rope, s, e).is_empty()
{
segments.pop();
}
}
let cells = segments
.into_iter()
.map(|(s, e)| TableCell {
content: trim_cell_range(rope, s, e),
})
.collect();
TableRow {
line: start..end,
cells,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::buffer::Buffer;
fn cell_text<'a>(text: &'a str, cell: &TableCell) -> &'a str {
text[cell.content.clone()].trim()
}
fn only_table(buf: &Buffer) -> TableInfo {
let tables = &buf.parsed().tables;
assert_eq!(tables.len(), 1, "expected exactly one table");
tables[0].clone()
}
#[test]
fn row_from_raw_line_handles_missing_outer_pipes() {
let buf: Buffer = "1 | 2 | 3".parse().unwrap();
let text = buf.text();
let row = row_from_raw_line(buf.rope(), 0, text.trim_end().len());
assert_eq!(row.cells.len(), 3);
assert_eq!(cell_text(&text, &row.cells[0]), "1");
assert_eq!(cell_text(&text, &row.cells[1]), "2");
assert_eq!(cell_text(&text, &row.cells[2]), "3");
}
#[test]
fn row_from_raw_line_keeps_empty_interior_cell() {
let buf: Buffer = "| | b |".parse().unwrap();
let text = buf.text();
let row = row_from_raw_line(buf.rope(), 0, text.trim_end().len());
assert_eq!(row.cells.len(), 2);
assert_eq!(cell_text(&text, &row.cells[0]), "");
assert_eq!(cell_text(&text, &row.cells[1]), "b");
}
#[test]
fn trailing_empty_row_is_healed() {
let buf: Buffer = "| A | B |\n| - | - |\n| 1 | 2 |\n| | |\n"
.parse()
.unwrap();
let t = only_table(&buf);
assert_eq!(t.body.len(), 2, "filled + empty row both recovered");
assert_eq!(t.ncols, 2);
let text = buf.text();
assert_eq!(cell_text(&text, &t.body[0].cells[0]), "1");
assert_eq!(cell_text(&text, &t.body[0].cells[1]), "2");
assert!(
t.body[1]
.cells
.iter()
.all(|c| cell_text(&text, c).is_empty())
);
assert!(t.block.contains(&buf.line_to_byte(3)));
}
#[test]
fn mid_table_empty_row_does_not_truncate() {
let buf: Buffer = "| A | B |\n| - | - |\n| 1 | 2 |\n| | |\n| 3 | 4 |\n"
.parse()
.unwrap();
let t = only_table(&buf);
assert_eq!(
t.body.len(),
3,
"an empty row mid-table keeps the rows below it"
);
let text = buf.text();
assert_eq!(cell_text(&text, &t.body[2].cells[0]), "3");
}
#[test]
fn healing_stops_at_blank_line() {
let buf: Buffer = "| A | B |\n| - | - |\n| 1 | 2 |\n\nafter | pipe\n"
.parse()
.unwrap();
let t = only_table(&buf);
assert_eq!(t.body.len(), 1);
}
#[test]
fn empty_row_does_not_corrupt_next_table() {
let src =
"| A | B |\n| - | - |\n| x | y |\n| | |\n\n## H\n| C | D |\n| - | - |\n| 1 | 2 |\n";
let buf: Buffer = src.parse().unwrap();
let ts = &buf.parsed().tables;
assert_eq!(ts.len(), 2, "two distinct tables");
assert!(
ts[0].block.end <= ts[1].block.start,
"table blocks must not overlap: {:?} vs {:?}",
ts[0].block,
ts[1].block
);
let text = buf.text();
assert_eq!(cell_text(&text, &ts[1].header.cells[0]), "C");
assert_eq!(cell_text(&text, &ts[1].body[0].cells[1]), "2");
}
#[test]
fn healed_row_cells_parse() {
let buf: Buffer = "| A | B |\n| - | - |\n| hi | yo |\n| | |\n"
.parse()
.unwrap();
let t = only_table(&buf);
let text = buf.text();
assert_eq!(cell_text(&text, &t.body[0].cells[0]), "hi");
assert_eq!(cell_text(&text, &t.body[0].cells[1]), "yo");
}
#[test]
fn basic_two_col() {
let buf: Buffer = "| a | b |\n|:--|--:|\n| 1 | 2 |\n".parse().unwrap();
let text = buf.text();
let t = only_table(&buf);
assert_eq!(t.ncols, 2);
assert_eq!(t.aligns, vec![Align::Left, Align::Right]);
assert_eq!(t.header.cells.len(), 2);
assert_eq!(cell_text(&text, &t.header.cells[0]), "a");
assert_eq!(cell_text(&text, &t.header.cells[1]), "b");
assert_eq!(t.body.len(), 1);
assert_eq!(cell_text(&text, &t.body[0].cells[0]), "1");
assert_eq!(cell_text(&text, &t.body[0].cells[1]), "2");
}
#[test]
fn center_alignment() {
let buf: Buffer = "| a | b |\n|:-:|:-:|\n| 1 | 2 |\n".parse().unwrap();
let t = only_table(&buf);
assert_eq!(t.aligns, vec![Align::Center, Align::Center]);
}
#[test]
fn default_alignment() {
let buf: Buffer = "| a | b |\n|---|---|\n| 1 | 2 |\n".parse().unwrap();
let t = only_table(&buf);
assert_eq!(t.aligns, vec![Align::Left, Align::Left]);
}
#[test]
fn multi_row_and_ragged() {
let buf: Buffer = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 |\n".parse().unwrap();
let text = buf.text();
let t = only_table(&buf);
assert_eq!(t.ncols, 2);
assert_eq!(t.body.len(), 2);
assert_eq!(t.body[0].cells.len(), 2);
assert_eq!(t.body[1].cells.len(), 1);
assert_eq!(cell_text(&text, &t.body[1].cells[0]), "3");
}
#[test]
fn spike_a_lone_header_is_not_a_table() {
let buf: Buffer = "| a | b |\n".parse().unwrap();
assert!(buf.parsed().tables.is_empty());
let buf2: Buffer = "| a | b |\n|---|---|\n".parse().unwrap();
assert_eq!(buf2.parsed().tables.len(), 1);
}
#[test]
fn snapshot_offset_and_row_lookup() {
let mut buf: Buffer = "| a | b |\n|:--|--:|\n| 1 | 2 |\n| 3 | 4 |\n"
.parse()
.unwrap();
let snap = buf.render_snapshot();
let inside = snap.table_containing_offset(15);
assert!(inside.is_some());
assert_eq!(inside.unwrap().ncols, 2);
assert!(snap.table_containing_offset(1000).is_none());
assert!(matches!(
snap.table_row_at_line(0),
Some((_, RowKind::Header))
));
assert!(matches!(
snap.table_row_at_line(1),
Some((_, RowKind::Delimiter))
));
assert!(matches!(
snap.table_row_at_line(2),
Some((_, RowKind::Body(0)))
));
assert!(matches!(
snap.table_row_at_line(3),
Some((_, RowKind::Body(1)))
));
}
}