table.rs
//! GFM pipe-table parsing for testmark table hunks.
//!
//! Entry point: [`parse_table`], which takes the raw lines of the table
//! (header row, separator row, zero or more data rows) and returns a
//! [`ParsedTable`].
use crate::document::{ParseError, ParsedTable};
/// Parse raw pipe-table lines into a [`ParsedTable`].
///
/// `lines` must be the contiguous table lines (those starting with `|`) and
/// `first_line` is the 1-based line number of the first one, used for error
/// reporting.
pub fn parse_table(lines: &[(usize, &str)]) -> Result<ParsedTable, ParseError> {
if lines.is_empty() {
// Caller checked for this; provide a sentinel line of 0 (unreachable in practice).
return Err(ParseError::EmptyTable {
line: 0,
});
}
let header_line_no = lines[0].0;
let headers = split_row(lines[0].1);
let ncols = headers.len();
if lines.len() < 2 {
// Need at least header + separator.
return Err(ParseError::MissingTableSeparator {
line: header_line_no,
});
}
let sep_line_no = lines[1].0;
let sep_cells = split_row(lines[1].1);
if sep_cells.len() != ncols {
return Err(ParseError::TableColumnMismatch {
line: sep_line_no,
expected: ncols,
got: sep_cells.len(),
});
}
if !sep_cells.iter().all(|c| is_separator_cell(c)) {
return Err(ParseError::MissingTableSeparator {
line: sep_line_no,
});
}
let mut rows: Vec<Vec<String>> = Vec::new();
for &(line_no, raw) in &lines[2..] {
let cells = split_row(raw);
if cells.len() != ncols {
return Err(ParseError::TableColumnMismatch {
line: line_no,
expected: ncols,
got: cells.len(),
});
}
rows.push(cells);
}
Ok(ParsedTable {
headers,
rows,
})
}
/// Emit a [`ParsedTable`] back as markdown lines (without trailing newline on
/// the last line; callers join with `\n`).
pub fn emit_table(table: &ParsedTable) -> Vec<String> {
let header_row = format_row(&table.headers);
let sep_row = format_separator(table.headers.len());
let mut out = vec![header_row, sep_row];
for row in &table.rows {
out.push(format_row(row));
}
out
}
// --- internal helpers --------------------------------------------------------
/// Split a raw pipe-table row into trimmed cell strings.
///
/// Leading and trailing empty cells (from surrounding `|` characters) are
/// dropped; all other cells are trimmed of ASCII whitespace.
fn split_row(raw: &str) -> Vec<String> {
let mut parts: Vec<&str> = raw.split('|').collect();
// Drop leading empty element from a leading `|`.
if parts.first().map(|s| s.trim().is_empty()).unwrap_or(false) {
parts.remove(0);
}
// Drop trailing empty element from a trailing `|`.
if parts.last().map(|s| s.trim().is_empty()).unwrap_or(false) {
parts.pop();
}
parts.iter().map(|s| s.trim().to_string()).collect()
}
/// Returns true if `cell` is a GFM separator cell: optional colons, then one
/// or more dashes, then optional colons. Leading/trailing whitespace already
/// stripped by `split_row`.
fn is_separator_cell(cell: &str) -> bool {
let inner = cell.trim_matches(':');
!inner.is_empty() && inner.chars().all(|c| c == '-')
}
fn format_row(cells: &[String]) -> String {
format!("| {} |", cells.join(" | "))
}
fn format_separator(ncols: usize) -> String {
let dashes: Vec<&str> = vec!["---"; ncols];
format!("| {} |", dashes.join(" | "))
}
#[cfg(test)]
mod table_tests {
use super::*;
#[test]
fn split_row_with_leading_trailing_pipes() {
assert_eq!(split_row("| a | b | c |"), vec!["a", "b", "c"]);
}
#[test]
fn split_row_no_outer_pipes() {
assert_eq!(split_row("a | b | c"), vec!["a", "b", "c"]);
}
#[test]
fn is_separator_cell_variants() {
assert!(is_separator_cell("---"));
assert!(is_separator_cell("-"));
assert!(is_separator_cell(":---"));
assert!(is_separator_cell("---:"));
assert!(is_separator_cell(":---:"));
assert!(!is_separator_cell("---a"));
assert!(!is_separator_cell(""));
assert!(!is_separator_cell(":"));
}
#[test]
fn emit_roundtrip() {
let table = ParsedTable {
headers: vec!["A".into(), "B".into()],
rows: vec![vec!["1".into(), "2".into()]],
};
let lines = emit_table(&table);
assert_eq!(lines[0], "| A | B |");
assert_eq!(lines[1], "| --- | --- |");
assert_eq!(lines[2], "| 1 | 2 |");
}
}
crates/testmark/src/table.rs · 4.1 KiB · 9049dd4f1c