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 |"); + } +}