testmark-rs

testmark: add GFM pipe table hunks as a first-class content type

commit 71242d077808c3280e2de510808c620a8b1c2fd5 · parent 820b6fe5c2 · browse this tree
Ximianth, 2026-08-05 21:24:59 UTC

Why: some test fixtures are a lot better expressed as tabular data
(e.g. columns for operands and expected results).
Representing them as pipe tables is more readable and structured than
embedding CSV in a code block, and with this code we give the user
a ParsedTable to work from rather than leaving parsing to be their problem.

Format: a testmark comment whose next line starts with '|' is a table hunk.
The parser consumes all consecutive '|'-starting lines (header row, separator
row, zero or more data rows). The separator row (cells matching :?-+:?) must
appear as the second row and is excluded from ParsedTable.rows. Cells are
trimmed. Column counts are validated across all rows.

Error model: three new ParseError variants are added --
  MissingTableSeparator: separator absent or not in position 2,
  TableColumnMismatch: a row has the wrong number of columns,
  EmptyTable: a testmark comment followed by no pipe rows at all.
These follow the same fail-closed philosophy as the existing errors.

Breaking change to Hunk: the former public fields 'body: String' and
'block_tag: String' are removed and replaced by HunkContent::Code { body,
block_tag } and HunkContent::Table(ParsedTable). Convenience methods
body() -> Option<&str> and block_tag() -> Option<&str> are provided on Hunk
so existing call sites update minimally.

Patching: patched() now returns None for table hunks (code-body patch
rejected for tables). A new patched_table() method handles table replacement.
Table reconstruction is handled by the table submodule (table.rs).

Table internals live in crates/testmark/src/table.rs (referenced via
parsing logic.
diff --git a/crates/testmark/src/document.rs b/crates/testmark/src/document.rs
index 17fdeff..3d32522 100644
--- a/crates/testmark/src/document.rs
+++ b/crates/testmark/src/document.rs
@@ -4,25 +4,97 @@
 //! this keeps it deterministic and makes patching a pure line-splice operation
 //! that never disturbs prose outside the tagged code blocks.
 
+#[path = "table.rs"]
+mod table;
+
 use std::fmt;
 
+/// A parsed GFM pipe table extracted from a testmark hunk.
+#[derive(Clone, PartialEq, Eq, Debug)]
+pub struct ParsedTable {
+	/// Header cells, trimmed.
+	pub headers: Vec<String>,
+	/// Data rows (separator row excluded), each a `Vec` of trimmed cell strings.
+	/// Column count equals `headers.len()`.
+	pub rows: Vec<Vec<String>>,
+}
+
+/// The content of a testmark hunk: either a fenced code block or a pipe table.
+#[derive(Clone, PartialEq, Eq, Debug)]
+pub enum HunkContent {
+	/// A fenced code block (the original hunk kind).
+	Code {
+		/// The info string on the fence line (syntax highlight hint); may be empty.
+		block_tag: String,
+		/// Block body. Always LF line endings; always ends with a trailing newline
+		/// unless the block is empty.
+		body: String,
+	},
+	/// A GFM pipe table.
+	Table(ParsedTable),
+}
+
 /// One named data block parsed from a testmark document.
 #[derive(Clone, PartialEq, Eq, Debug)]
 pub struct Hunk {
 	/// The name from the testmark comment, e.g. "fib/input".
 	pub name: String,
-	/// The info string on the fence line (syntax highlight hint); may be empty.
-	pub block_tag: String,
-	/// Block body. Always LF line endings; always ends with a trailing newline
-	/// unless the block is empty.
-	pub body: String,
+	/// The content of this hunk.
+	pub content: HunkContent,
 	/// Line index (0-based) of the `[testmark]:#` comment line.
 	line_comment: usize,
-	/// Line index (0-based) of the closing fence line.
-	line_fence_close: usize,
+	/// Line index (0-based) of the last line belonging to the hunk
+	/// (closing fence for code; last table row for table).
+	line_end: usize,
+}
+
+impl Hunk {
+	/// Returns the body string if this is a code hunk, or `None`.
+	pub fn body(&self) -> Option<&str> {
+		match &self.content {
+			HunkContent::Code { body, .. } => Some(body),
+			HunkContent::Table(_) => None,
+		}
+	}
+
+	/// Returns the block tag if this is a code hunk, or `None`.
+	pub fn block_tag(&self) -> Option<&str> {
+		match &self.content {
+			HunkContent::Code { block_tag, .. } => Some(block_tag),
+			HunkContent::Table(_) => None,
+		}
+	}
+}
+
+/// Argument to [`Document::patched`]: describes what to splice in.
+///
+/// The most convenient form is a plain `&str`, which updates the body of a
+/// code hunk while preserving its existing block_tag.  The explicit variants
+/// are available when you need full control or when patching a table hunk.
+pub enum NewContent<'a> {
+	/// Update the body of a code hunk; preserve the existing block_tag.
+	Body(&'a str),
+	/// Replace a code hunk with an explicit block_tag and body.
+	Code {
+		block_tag: &'a str,
+		body: &'a str,
+	},
+	/// Replace (or set) a table hunk.
+	Table(&'a ParsedTable),
+}
+
+impl<'a> From<&'a str> for NewContent<'a> {
+	fn from(body: &'a str) -> Self {
+		NewContent::Body(body)
+	}
+}
+
+impl<'a> From<&'a String> for NewContent<'a> {
+	fn from(body: &'a String) -> Self {
+		NewContent::Body(body.as_str())
+	}
 }
 
-/// A parsed testmark document: the original lines, plus the located hunks.
 #[derive(Clone, Debug)]
 pub struct Document {
 	lines: Vec<String>,
@@ -40,7 +112,7 @@ pub enum ParseError {
 	MalformedComment {
 		line: usize,
 	},
-	/// A testmark comment not immediately followed by a code fence.
+	/// A testmark comment not immediately followed by a code fence or pipe table.
 	DanglingComment {
 		line: usize,
 	},
@@ -53,6 +125,20 @@ pub enum ParseError {
 		name: String,
 		line: usize,
 	},
+	/// A table hunk with no separator row in the expected position (second row).
+	MissingTableSeparator {
+		line: usize,
+	},
+	/// A table row with the wrong number of columns.
+	TableColumnMismatch {
+		line: usize,
+		expected: usize,
+		got: usize,
+	},
+	/// A table hunk with no rows at all (not even a header).
+	EmptyTable {
+		line: usize,
+	},
 }
 
 impl fmt::Display for ParseError {
@@ -77,6 +163,20 @@ impl fmt::Display for ParseError {
 				name,
 				line,
 			} => write!(f, "line {line}: duplicate hunk name {name:?}"),
+			ParseError::MissingTableSeparator {
+				line,
+			} => write!(f, "line {line}: table hunk missing separator row"),
+			ParseError::TableColumnMismatch {
+				line,
+				expected,
+				got,
+			} => write!(
+				f,
+				"line {line}: table row has {got} columns, expected {expected}"
+			),
+			ParseError::EmptyTable {
+				line,
+			} => write!(f, "line {line}: table hunk is empty"),
 		}
 	}
 }
@@ -125,27 +225,53 @@ impl Document {
 					line: i + 1,
 				});
 			}
-			// The next line must open a code fence.
-			let fence_open = i + 1;
-			if fence_open >= lines.len() || !lines[fence_open].starts_with(FENCE) {
-				return Err(ParseError::DanglingComment {
-					line: i + 1,
-				});
+
+			// Peek at the next line to decide: code fence or pipe table?
+			let next = i + 1;
+			if next >= lines.len() {
+				return Err(ParseError::DanglingComment { line: i + 1 });
 			}
-			let block_tag = lines[fence_open][FENCE.len()..].trim().to_string();
-			let fence_close = skip_fenced_block(&lines, fence_open)? - 1;
-			let mut body = lines[fence_open + 1..fence_close].join("\n");
-			if !body.is_empty() {
-				body.push('\n');
+			if lines[next].starts_with('|') {
+				// Table hunk: consume all consecutive pipe-starting lines.
+				let table_start = next;
+				let mut table_end = table_start;
+				while table_end < lines.len() && lines[table_end].starts_with('|') {
+					table_end += 1;
+				}
+				// table_end is exclusive; last table line index is table_end - 1.
+				if table_end == table_start {
+					return Err(ParseError::EmptyTable { line: next + 1 });
+				}
+				let raw_lines: Vec<(usize, &str)> = (table_start..table_end)
+					.map(|idx| (idx + 1, lines[idx].as_str()))
+					.collect();
+				let parsed = table::parse_table(&raw_lines)?;
+				hunks.push(Hunk {
+					name,
+					content: HunkContent::Table(parsed),
+					line_comment: i,
+					line_end: table_end - 1,
+				});
+				i = table_end;
+			} else if lines[next].starts_with(FENCE) {
+				// Code hunk.
+				let fence_open = next;
+				let block_tag = lines[fence_open][FENCE.len()..].trim().to_string();
+				let fence_close = skip_fenced_block(&lines, fence_open)? - 1;
+				let mut body = lines[fence_open + 1..fence_close].join("\n");
+				if !body.is_empty() {
+					body.push('\n');
+				}
+				hunks.push(Hunk {
+					name,
+					content: HunkContent::Code { block_tag, body },
+					line_comment: i,
+					line_end: fence_close,
+				});
+				i = fence_close + 1;
+			} else {
+				return Err(ParseError::DanglingComment { line: i + 1 });
 			}
-			hunks.push(Hunk {
-				name,
-				block_tag,
-				body,
-				line_comment: i,
-				line_fence_close: fence_close,
-			});
-			i = fence_close + 1;
 		}
 		Ok(Document {
 			lines,
@@ -158,26 +284,58 @@ impl Document {
 		self.hunks.iter().find(|h| h.name == name)
 	}
 
-	/// Replace the body of the named hunk, returning the new document text.
-	/// Only lines inside the code fence change; all prose and formatting outside
-	/// the fence is preserved byte-for-byte.
-	/// Returns None if no hunk has that name (patching never creates hunks;
-	/// authors control document structure, tests only refresh content).
-	pub fn patched(&self, name: &str, new_body: &str) -> Option<String> {
+	/// Replace the named hunk with new content, returning the new document text.
+	/// All prose and formatting outside the hunk is preserved byte-for-byte.
+	///
+	/// `new_content` accepts:
+	/// - a `&str` body (DWIM: existing block_tag is preserved, code fence kept),
+	/// - `NewContent::Code` (block_tag + body both explicit),
+	/// - `NewContent::Table` (replaces or sets a table hunk).
+	///
+	/// Returns `None` if no hunk has that name.
+	pub fn patched<'a>(&self, name: &str, new_content: impl Into<NewContent<'a>>) -> Option<String> {
 		let h = self.hunk(name)?;
-		let mut out: Vec<&str> = Vec::new();
-		// Everything up to and including the opening fence line.
-		for l in &self.lines[..=h.line_comment + 1] {
-			out.push(l);
-		}
-		// The new body, sans trailing newline (the join adds separators).
-		let trimmed = new_body.strip_suffix('\n').unwrap_or(new_body);
-		if !trimmed.is_empty() {
-			out.push(trimmed);
-		}
-		// The closing fence and everything after.
-		for l in &self.lines[h.line_fence_close..] {
-			out.push(l);
+		let new_content = new_content.into();
+		let mut out: Vec<String> = Vec::new();
+		match new_content {
+			NewContent::Body(body) => {
+				// Preserve the existing fence line (and its block_tag) unchanged.
+				for l in &self.lines[..=h.line_comment + 1] {
+					out.push(l.clone());
+				}
+				let trimmed = body.strip_suffix('\n').unwrap_or(body);
+				if !trimmed.is_empty() {
+					out.push(trimmed.to_string());
+				}
+				for l in &self.lines[h.line_end..] {
+					out.push(l.clone());
+				}
+			},
+			NewContent::Code { block_tag, body } => {
+				for l in &self.lines[..=h.line_comment] {
+					out.push(l.clone());
+				}
+				// Reconstruct the fence open line with the new block_tag.
+				out.push(format!("{FENCE}{block_tag}"));
+				let trimmed = body.strip_suffix('\n').unwrap_or(body);
+				if !trimmed.is_empty() {
+					out.push(trimmed.to_string());
+				}
+				for l in &self.lines[h.line_end..] {
+					out.push(l.clone());
+				}
+			},
+			NewContent::Table(new_table) => {
+				for l in &self.lines[..=h.line_comment] {
+					out.push(l.clone());
+				}
+				for tl in table::emit_table(new_table) {
+					out.push(tl);
+				}
+				for l in &self.lines[h.line_end + 1..] {
+					out.push(l.clone());
+				}
+			},
 		}
 		Some(out.join("\n"))
 	}
diff --git a/crates/testmark/src/document_test.rs b/crates/testmark/src/document_test.rs
index a700ee2..7eb3602 100644
--- a/crates/testmark/src/document_test.rs
+++ b/crates/testmark/src/document_test.rs
@@ -24,13 +24,15 @@ not a fixture\n\
 ```\n\
 ```\n";
 
+	// ----- existing code-hunk tests (minimally updated for new accessor API) -----
+
 	#[test]
 	fn parses_hunks_and_ignores_untagged_blocks() {
 		let doc = Document::parse(DOC).expect("parses");
 		assert_eq!(doc.hunks.len(), 2);
-		assert_eq!(doc.hunk("one/input").unwrap().body, "hello\nworld\n");
-		assert_eq!(doc.hunk("one/input").unwrap().block_tag, "text");
-		assert_eq!(doc.hunk("one/output").unwrap().body, "");
+		assert_eq!(doc.hunk("one/input").unwrap().body().unwrap(), "hello\nworld\n");
+		assert_eq!(doc.hunk("one/input").unwrap().block_tag().unwrap(), "text");
+		assert_eq!(doc.hunk("one/output").unwrap().body().unwrap(), "");
 	}
 
 	#[test]
@@ -45,8 +47,8 @@ not a fixture\n\
 		let doc = Document::parse(DOC).expect("parses");
 		let patched = doc.patched("one/output", "fresh content\n").unwrap();
 		let redoc = Document::parse(&patched).expect("reparses");
-		assert_eq!(redoc.hunk("one/output").unwrap().body, "fresh content\n");
-		assert_eq!(redoc.hunk("one/input").unwrap().body, "hello\nworld\n");
+		assert_eq!(redoc.hunk("one/output").unwrap().body().unwrap(), "fresh content\n");
+		assert_eq!(redoc.hunk("one/input").unwrap().body().unwrap(), "hello\nworld\n");
 		assert!(patched.contains("not a fixture"));
 		assert!(patched.contains("Some prose."));
 	}
@@ -56,7 +58,7 @@ not a fixture\n\
 		let doc = Document::parse(DOC).expect("parses");
 		let patched = doc.patched("one/input", "").unwrap();
 		let redoc = Document::parse(&patched).expect("reparses");
-		assert_eq!(redoc.hunk("one/input").unwrap().body, "");
+		assert_eq!(redoc.hunk("one/input").unwrap().body().unwrap(), "");
 	}
 
 	#[test]
@@ -171,7 +173,7 @@ not a fixture\n\
 	#[test]
 	fn normalizes_crlf() {
 		let doc = Document::parse("[testmark]:# (a)\r\n```\r\nx\r\n```\r\n").expect("parses");
-		assert_eq!(doc.hunk("a").unwrap().body, "x\n");
+		assert_eq!(doc.hunk("a").unwrap().body().unwrap(), "x\n");
 	}
 
 	#[test]
@@ -182,4 +184,172 @@ not a fixture\n\
 			"line 2: testmark comment not followed by a code fence"
 		);
 	}
+
+	// ----- table hunk tests -----
+
+	#[test]
+	fn parses_basic_table_hunk() {
+		let src = "[testmark]:# (arith/cases)\n\
+			| a | b | sum |\n\
+			| --- | --- | --- |\n\
+			| 1 | 2 | 3 |\n";
+		let doc = Document::parse(src).expect("parses");
+		assert_eq!(doc.hunks.len(), 1);
+		let h = doc.hunk("arith/cases").unwrap();
+		let HunkContent::Table(ref t) = h.content else { panic!("expected table") };
+		assert_eq!(t.headers, vec!["a", "b", "sum"]);
+		assert_eq!(t.rows.len(), 1);
+		assert_eq!(t.rows[0], vec!["1", "2", "3"]);
+	}
+
+	#[test]
+	fn parses_table_with_multiple_rows() {
+		let src = "[testmark]:# (t)\n\
+			| x | y |\n\
+			| --- | --- |\n\
+			| 10 | 20 |\n\
+			| 30 | 40 |\n\
+			| 50 | 60 |\n";
+		let doc = Document::parse(src).expect("parses");
+		let HunkContent::Table(ref t) = doc.hunk("t").unwrap().content else { panic!() };
+		assert_eq!(t.headers, vec!["x", "y"]);
+		assert_eq!(t.rows.len(), 3);
+		assert_eq!(t.rows[2], vec!["50", "60"]);
+	}
+
+	#[test]
+	fn separator_row_not_in_rows() {
+		let src = "[testmark]:# (t)\n\
+			| h |\n\
+			| --- |\n\
+			| d |\n";
+		let doc = Document::parse(src).expect("parses");
+		let HunkContent::Table(ref t) = doc.hunk("t").unwrap().content else { panic!() };
+		// Separator must not appear in rows.
+		assert_eq!(t.rows.len(), 1);
+		assert_eq!(t.rows[0], vec!["d"]);
+	}
+
+	#[test]
+	fn cells_are_trimmed() {
+		let src = "[testmark]:# (t)\n\
+			|  spaces  |  around  |\n\
+			| --- | --- |\n\
+			|  val  |  ue  |\n";
+		let doc = Document::parse(src).expect("parses");
+		let HunkContent::Table(ref t) = doc.hunk("t").unwrap().content else { panic!() };
+		assert_eq!(t.headers, vec!["spaces", "around"]);
+		assert_eq!(t.rows[0], vec!["val", "ue"]);
+	}
+
+	#[test]
+	fn table_and_code_hunk_coexist() {
+		let src = "[testmark]:# (tbl)\n\
+			| a | b |\n\
+			| --- | --- |\n\
+			| 1 | 2 |\n\
+			\n\
+			[testmark]:# (code)\n\
+			```\n\
+			hello\n\
+			```\n";
+		let doc = Document::parse(src).expect("parses");
+		assert_eq!(doc.hunks.len(), 2);
+		assert!(matches!(doc.hunk("tbl").unwrap().content, HunkContent::Table(_)));
+		assert!(matches!(doc.hunk("code").unwrap().content, HunkContent::Code { .. }));
+		assert_eq!(doc.hunk("code").unwrap().body().unwrap(), "hello\n");
+	}
+
+	#[test]
+	fn rejects_table_missing_separator() {
+		// Only a header row, no separator.
+		let src = "[testmark]:# (t)\n| a | b |\n";
+		let err = Document::parse(src).unwrap_err();
+		assert_eq!(err, ParseError::MissingTableSeparator { line: 2 });
+	}
+
+	#[test]
+	fn rejects_table_bad_separator() {
+		// Second row present but not a valid separator.
+		let src = "[testmark]:# (t)\n| a | b |\n| x | y |\n| 1 | 2 |\n";
+		let err = Document::parse(src).unwrap_err();
+		assert_eq!(err, ParseError::MissingTableSeparator { line: 3 });
+	}
+
+	#[test]
+	fn rejects_table_column_mismatch_in_data_row() {
+		let src = "[testmark]:# (t)\n\
+			| a | b |\n\
+			| --- | --- |\n\
+			| 1 | 2 | 3 |\n";
+		let err = Document::parse(src).unwrap_err();
+		assert_eq!(
+			err,
+			ParseError::TableColumnMismatch {
+				line: 4,
+				expected: 2,
+				got: 3,
+			}
+		);
+	}
+
+	#[test]
+	fn rejects_table_column_mismatch_in_separator() {
+		let src = "[testmark]:# (t)\n\
+			| a | b |\n\
+			| --- |\n\
+			| 1 | 2 |\n";
+		let err = Document::parse(src).unwrap_err();
+		assert_eq!(
+			err,
+			ParseError::TableColumnMismatch {
+				line: 3,
+				expected: 2,
+				got: 1,
+			}
+		);
+	}
+
+	#[test]
+	fn patched_table_roundtrip() {
+		let src = "prose\n\
+			[testmark]:# (t)\n\
+			| a | b |\n\
+			| --- | --- |\n\
+			| 1 | 2 |\n\
+			more prose\n";
+		let doc = Document::parse(src).expect("parses");
+		let new_table = ParsedTable {
+			headers: vec!["a".into(), "b".into()],
+			rows: vec![vec!["1".into(), "2".into()]],
+		};
+		let patched = doc.patched("t", NewContent::Table(&new_table)).unwrap();
+		let redoc = Document::parse(&patched).expect("reparses");
+		let HunkContent::Table(ref t) = redoc.hunk("t").unwrap().content else { panic!() };
+		assert_eq!(t.headers, vec!["a", "b"]);
+		assert_eq!(t.rows, vec![vec!["1", "2"]]);
+		// Prose preserved.
+		assert!(patched.contains("prose"));
+		assert!(patched.contains("more prose"));
+	}
+
+	#[test]
+	fn patch_with_explicit_code_changes_block_tag() {
+		let doc = Document::parse(DOC).expect("parses");
+		let patched = doc
+			.patched("one/input", NewContent::Code { block_tag: "rust", body: "let x = 1;\n" })
+			.unwrap();
+		let redoc = Document::parse(&patched).expect("reparses");
+		assert_eq!(redoc.hunk("one/input").unwrap().block_tag().unwrap(), "rust");
+		assert_eq!(redoc.hunk("one/input").unwrap().body().unwrap(), "let x = 1;\n");
+	}
+
+	#[test]
+	fn body_and_block_tag_return_none_for_table_hunk() {
+		let src = "[testmark]:# (t)\n| a |\n| --- |\n| 1 |\n";
+		let doc = Document::parse(src).expect("parses");
+		let h = doc.hunk("t").unwrap();
+		assert_eq!(h.body(), None);
+		assert_eq!(h.block_tag(), None);
+	}
 }
diff --git a/crates/testmark/src/lib.rs b/crates/testmark/src/lib.rs
index 126bb7d..154c9ff 100644
--- a/crates/testmark/src/lib.rs
+++ b/crates/testmark/src/lib.rs
@@ -24,4 +24,4 @@
 
 mod document;
 
-pub use document::{Document, Hunk, ParseError};
+pub use document::{Document, Hunk, HunkContent, NewContent, ParsedTable, ParseError};
diff --git a/crates/testmark/src/table.rs b/crates/testmark/src/table.rs
new file mode 100644
index 0000000..77c8276
--- /dev/null
+++ b/crates/testmark/src/table.rs
@@ -0,0 +1,143 @@
+//! 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::{ParsedTable, ParseError};
+
+/// 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 |");
+	}
+}