use carta_ast::{Alignment, Attr};
#[derive(Debug, Clone)]
pub(crate) struct GridTable {
pub columns: Vec<Column>,
pub head: Vec<Row>,
pub body: Vec<Row>,
pub foot: Vec<Row>,
pub caption: Option<String>,
pub attr: Attr,
}
#[derive(Debug, Clone)]
pub(crate) struct Column {
pub align: Alignment,
pub width: f64,
}
#[derive(Debug, Clone)]
pub(crate) struct Row {
pub cells: Vec<Cell>,
}
#[derive(Debug, Clone)]
pub(crate) struct Cell {
pub text: String,
pub tight: bool,
}
const TEXT_WIDTH: f64 = 72.0;
pub(crate) fn is_top_border(line: &str) -> bool {
is_ruling(trim(line), false)
}
pub(crate) fn is_grid_line(line: &str) -> bool {
let trimmed = trim(line);
trimmed.starts_with('|') || is_ruling(trimmed, true)
}
fn trim(line: &str) -> &str {
line.trim_start_matches(' ').trim_end()
}
fn is_ruling(s: &str, allow_eq: bool) -> bool {
let bytes = s.as_bytes();
bytes.len() >= 3
&& bytes.first() == Some(&b'+')
&& bytes.last() == Some(&b'+')
&& !s.contains("++")
&& s.bytes()
.all(|c| matches!(c, b'+' | b'-' | b':') || (allow_eq && c == b'='))
}
pub(crate) fn parse(text: &str) -> Option<GridTable> {
let lines: Vec<&str> = text.lines().map(trim).collect();
let top = *lines.first()?;
if !is_ruling(top, false) {
return None;
}
let boundaries = border_columns(top);
let column_count = boundaries.len().checked_sub(1)?;
if column_count == 0 {
return None;
}
if !lines.last().is_some_and(|line| is_ruling(line, true)) {
return None;
}
let mut rows: Vec<Row> = Vec::new();
let mut pending: Vec<&str> = Vec::new();
let mut dividers: Vec<usize> = Vec::new();
for (idx, line) in lines.iter().enumerate() {
if is_ruling(line, true) {
if border_columns(line) != boundaries {
return None;
}
if idx > 0 && !line.contains('=') && line.contains(':') {
return None;
}
if idx > 0 {
rows.push(build_row(&pending, &boundaries));
pending.clear();
}
if line.contains('=') {
dividers.push(rows.len());
}
} else if line.starts_with('|') {
if !has_column_separators(line, &boundaries) {
return None;
}
pending.push(line);
} else {
return None;
}
}
if rows.is_empty() {
return None;
}
let mut aligns = vec![Alignment::AlignDefault; column_count];
let align_source = lines
.iter()
.copied()
.find(|line| is_ruling(line, true) && line.contains('='))
.unwrap_or(top);
collect_aligns(align_source, &boundaries, &mut aligns);
let columns = (0..column_count)
.map(|i| Column {
align: aligns.get(i).cloned().unwrap_or(Alignment::AlignDefault),
width: column_width(&boundaries, i),
})
.collect();
let (head, body, foot) = split_sections(rows, ÷rs);
Some(GridTable {
columns,
head,
body,
foot,
caption: None,
attr: Attr::default(),
})
}
fn split_sections(mut rows: Vec<Row>, dividers: &[usize]) -> (Vec<Row>, Vec<Row>, Vec<Row>) {
let total = rows.len();
let Some(&first) = dividers.first() else {
return (Vec::new(), rows, Vec::new());
};
let closes_with_divider = dividers.last() == Some(&total);
if closes_with_divider && dividers.len() >= 2 {
let foot_start = dividers.get(dividers.len() - 2).copied().unwrap_or(total);
let foot = rows.split_off(foot_start.min(rows.len()));
let body = rows.split_off(first.min(rows.len()));
(rows, body, foot)
} else {
let body = rows.split_off(first.min(rows.len()));
(rows, body, Vec::new())
}
}
fn has_column_separators(line: &str, boundaries: &[usize]) -> bool {
let chars: Vec<char> = line.chars().collect();
boundaries.iter().all(|&b| match chars.get(b) {
Some(&c) => c == '|' || c == '+',
None => false,
})
}
fn border_columns(border: &str) -> Vec<usize> {
border
.chars()
.enumerate()
.filter(|&(_, c)| c == '+')
.map(|(i, _)| i)
.collect()
}
#[allow(clippy::cast_precision_loss)] fn column_width(boundaries: &[usize], i: usize) -> f64 {
match (boundaries.get(i), boundaries.get(i + 1)) {
(Some(&start), Some(&end)) => (end.saturating_sub(start) as f64) / TEXT_WIDTH,
_ => 0.0,
}
}
fn collect_aligns(border: &str, boundaries: &[usize], aligns: &mut [Alignment]) {
let chars: Vec<char> = border.chars().collect();
for (i, slot) in aligns.iter_mut().enumerate() {
let segment = column_segment(&chars, boundaries, i);
let left = segment.first() == Some(&':');
let right = segment.last() == Some(&':');
*slot = match (left, right) {
(true, true) => Alignment::AlignCenter,
(true, false) => Alignment::AlignLeft,
(false, true) => Alignment::AlignRight,
(false, false) => Alignment::AlignDefault,
};
}
}
fn column_segment<'a>(chars: &'a [char], boundaries: &[usize], i: usize) -> &'a [char] {
let Some(&start) = boundaries.get(i) else {
return &[];
};
let Some(&end) = boundaries.get(i + 1) else {
return &[];
};
let lo = (start + 1).min(chars.len());
let hi = end.min(chars.len());
chars.get(lo..hi).unwrap_or(&[])
}
fn build_row(content: &[&str], boundaries: &[usize]) -> Row {
let column_count = boundaries.len().saturating_sub(1);
let line_chars: Vec<Vec<char>> = content.iter().map(|line| line.chars().collect()).collect();
let cells = (0..column_count)
.map(|i| {
let column_lines: Vec<String> = line_chars
.iter()
.map(|chars| column_segment(chars, boundaries, i).iter().collect())
.collect();
cell_text(column_lines)
})
.collect();
Row { cells }
}
fn cell_text(lines: Vec<String>) -> Cell {
let mut lines: Vec<String> = lines
.into_iter()
.map(|line| {
line.strip_prefix(' ')
.unwrap_or(&line)
.trim_end()
.to_owned()
})
.collect();
while lines.first().is_some_and(String::is_empty) {
lines.remove(0);
}
while lines.last().is_some_and(String::is_empty) {
lines.pop();
}
let text = lines.join("\n");
let tight = !text.contains("\n\n");
Cell { text, tight }
}
#[cfg(test)]
mod tests {
use super::*;
fn text_at(rows: &[Row], row: usize, col: usize) -> &str {
rows.get(row)
.and_then(|r| r.cells.get(col))
.map(|c| c.text.as_str())
.expect("cell present")
}
fn tight_at(rows: &[Row], row: usize, col: usize) -> bool {
rows.get(row)
.and_then(|r| r.cells.get(col))
.map(|c| c.tight)
.expect("cell present")
}
fn col_align(table: &GridTable, col: usize) -> Option<&Alignment> {
table.columns.get(col).map(|c| &c.align)
}
fn col_width(table: &GridTable, col: usize) -> f64 {
table
.columns
.get(col)
.map(|c| c.width)
.expect("column present")
}
#[test]
fn top_border_accepts_dashes_and_colons_only() {
assert!(is_top_border("+---+---+"));
assert!(is_top_border("+:--+--:+"));
assert!(is_top_border("+-+"));
assert!(is_top_border(" +---+"));
assert!(!is_top_border("+===+"));
assert!(!is_top_border("+ item"));
assert!(!is_top_border("++"));
assert!(!is_top_border("+++"));
assert!(!is_top_border("| a |"));
}
#[test]
fn grid_line_accepts_content_and_any_border() {
assert!(is_grid_line("| a | b |"));
assert!(is_grid_line("+===+===+"));
assert!(is_grid_line("+---+---+"));
assert!(!is_grid_line("+ bullet"));
assert!(!is_grid_line("plain text"));
}
#[test]
fn parse_rejects_incomplete_tables() {
assert!(parse("+---+\n| a |").is_none());
assert!(parse("+---+").is_none());
assert!(parse("+---+\nplain\n+---+").is_none());
}
#[test]
fn parse_splits_header_at_the_equals_divider() {
let table = parse("+---+---+\n| a | b |\n+===+===+\n| c | d |\n+---+---+").unwrap();
assert_eq!(table.head.len(), 1);
assert_eq!(table.body.len(), 1);
assert_eq!(table.columns.len(), 2);
assert_eq!(text_at(&table.head, 0, 0), "a");
assert_eq!(text_at(&table.body, 0, 1), "d");
}
#[test]
fn parse_without_divider_is_all_body() {
let table = parse("+---+\n| a |\n+---+\n| b |\n+---+").unwrap();
assert!(table.head.is_empty());
assert_eq!(table.body.len(), 2);
}
#[test]
fn alignment_comes_from_the_top_border_without_a_divider() {
let table = parse("+:--+--:+\n| a | b |\n+---+---+").unwrap();
assert!(matches!(col_align(&table, 0), Some(Alignment::AlignLeft)));
assert!(matches!(col_align(&table, 1), Some(Alignment::AlignRight)));
let centered = parse("+:-:+\n| a |\n+---+").unwrap();
assert!(matches!(
col_align(¢ered, 0),
Some(Alignment::AlignCenter)
));
}
#[test]
fn the_header_divider_overrides_the_top_border_for_alignment() {
let table = parse("+:--+:--+\n| a | b |\n+==:+==:+\n| c | d |\n+---+---+").unwrap();
assert!(matches!(col_align(&table, 0), Some(Alignment::AlignRight)));
assert!(matches!(col_align(&table, 1), Some(Alignment::AlignRight)));
}
#[test]
fn a_colon_bearing_closing_border_is_not_a_table() {
assert!(parse("+---+\n| a |\n+:-:+").is_none());
assert!(parse("+---+\n| a |\n+--:+").is_none());
}
#[test]
fn deep_cell_indentation_survives_as_code() {
let table = parse("+----------+\n| code |\n+----------+").unwrap();
assert_eq!(text_at(&table.body, 0, 0), " code");
}
#[test]
fn width_is_the_border_span_over_text_width() {
let table = parse("+---+\n| a |\n+---+").unwrap();
assert!((col_width(&table, 0) - 4.0 / 72.0).abs() < 1e-12);
}
#[test]
fn multi_line_cell_is_tight_until_a_blank_line() {
let tight = parse("+------+\n| one |\n| two |\n+------+").unwrap();
assert!(tight_at(&tight.body, 0, 0));
assert_eq!(text_at(&tight.body, 0, 0), "one\ntwo");
let loose = parse("+------+\n| one |\n| |\n| two |\n+------+").unwrap();
assert!(!tight_at(&loose.body, 0, 0));
assert_eq!(text_at(&loose.body, 0, 0), "one\n\ntwo");
}
#[test]
fn empty_cell_has_empty_text() {
let table = parse("+---+---+\n| a | |\n+---+---+").unwrap();
assert_eq!(text_at(&table.body, 0, 1), "");
}
#[test]
fn a_second_divider_before_an_equals_close_makes_a_footer() {
let table = parse("+---+\n| h |\n+===+\n| b |\n+===+\n| f |\n+===+").unwrap();
assert_eq!(table.head.len(), 1);
assert_eq!(table.body.len(), 1);
assert_eq!(table.foot.len(), 1);
assert_eq!(text_at(&table.head, 0, 0), "h");
assert_eq!(text_at(&table.body, 0, 0), "b");
assert_eq!(text_at(&table.foot, 0, 0), "f");
let no_foot = parse("+---+\n| h |\n+===+\n| b |\n+===+\n| f |\n+---+").unwrap();
assert!(no_foot.foot.is_empty());
assert_eq!(no_foot.body.len(), 2);
}
#[test]
fn spanning_candidates_are_declined() {
assert!(parse("+---+---+\n| ab |\n+---+---+").is_none());
assert!(parse("+---+---+\n| a | b |\n+-------+\n| cd |\n+---+---+").is_none());
}
#[test]
fn adjacent_borders_make_an_empty_row() {
let table = parse("+---+\n| a |\n+---+\n+---+\n| b |\n+---+").unwrap();
assert_eq!(table.body.len(), 3);
assert_eq!(text_at(&table.body, 0, 0), "a");
assert_eq!(text_at(&table.body, 1, 0), "");
assert_eq!(text_at(&table.body, 2, 0), "b");
}
}