testmark-rs

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