testmark: parser and patcher for markdown-embedded test fixtures
commit b6c25bffdc43f3eb89f0a8e04ca9da2609c9c805 · parent 2bfc146991
· browse this tree
Ximianth, 2026-07-23 20:19:03 UTC
Broadly this is matching the standards of go-testmark, which is its inspiration and the first implementation of testmark. Several fail-closed behaviors are a tightening vs go-testmark: malformed annotation, dangling comment, unclosed fence, and duplicate hunk names are all errors intead of silent skips.
diff --git a/crates/testmark/Cargo.toml b/crates/testmark/Cargo.toml new file mode 100644 index 0000000..6f562e0 --- /dev/null +++ b/crates/testmark/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "testmark" +version = "0.0.1" +edition.workspace = true +license.workspace = true + +[dependencies] diff --git a/crates/testmark/src/document.rs b/crates/testmark/src/document.rs new file mode 100644 index 0000000..17fdeff --- /dev/null +++ b/crates/testmark/src/document.rs @@ -0,0 +1,199 @@ +//! The testmark document model: parse, look up hunks, patch hunk bodies. +//! +//! The parser is line-oriented and does not parse markdown at large; +//! this keeps it deterministic and makes patching a pure line-splice operation +//! that never disturbs prose outside the tagged code blocks. + +use std::fmt; + +/// 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, + /// 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, +} + +/// A parsed testmark document: the original lines, plus the located hunks. +#[derive(Clone, Debug)] +pub struct Document { + lines: Vec<String>, + pub hunks: Vec<Hunk>, +} + +/// Problems encountered while parsing. +/// Testmark tolerates plain markdown around it, so the only errors are +/// malformed testmark structures; we fail closed on those rather than guessing +/// (a hunk silently skipped is a test silently unexercised). +/// All line numbers are 1-based, matching how editors count. +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum ParseError { + /// A `[testmark]:#` line without a wellformed `(name)` portion. + MalformedComment { + line: usize, + }, + /// A testmark comment not immediately followed by a code fence. + DanglingComment { + line: usize, + }, + /// A code fence opened but never closed before end of file. + UnclosedFence { + line: usize, + }, + /// Two hunks with the same name. `line` is the second occurrence. + DuplicateName { + name: String, + line: usize, + }, +} + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + ParseError::MalformedComment { + line, + } => write!( + f, + "line {line}: malformed testmark comment (expected `[testmark]:# (name)`)" + ), + ParseError::DanglingComment { + line, + } => write!( + f, + "line {line}: testmark comment not followed by a code fence" + ), + ParseError::UnclosedFence { + line, + } => write!(f, "line {line}: code fence never closed"), + ParseError::DuplicateName { + name, + line, + } => write!(f, "line {line}: duplicate hunk name {name:?}"), + } + } +} + +impl std::error::Error for ParseError {} + +const COMMENT_PREFIX: &str = "[testmark]:# ("; +const FENCE: &str = "```"; + +impl Document { + /// Parse a testmark document from text. + /// CRLF line endings are normalized to LF (matching go-testmark's behavior, + /// which shields fixtures from git's newline mangling on windows checkouts). + pub fn parse(text: &str) -> Result<Document, ParseError> { + let text = text.replace("\r\n", "\n"); + let lines: Vec<String> = text.split('\n').map(str::to_string).collect(); + // (split yields one trailing empty string for a newline-terminated file; that's + // fine: joining with '\n' on output reproduces the original bytes exactly.) + + let mut hunks: Vec<Hunk> = Vec::new(); + let mut i = 0; + while i < lines.len() { + let line = &lines[i]; + if line.starts_with(FENCE) { + // An untagged code block: skip to its closing fence. + i = skip_fenced_block(&lines, i)?; + continue; + } + if !line.starts_with("[testmark]:#") { + i += 1; + continue; + } + // Testmark comment line: extract the name. + let name = line + .strip_prefix(COMMENT_PREFIX) + .and_then(|rest| rest.strip_suffix(')')) + // Content after the first whitespace is reserved by the spec; ignore it. + .map(|inner| inner.split_whitespace().next().unwrap_or("").to_string()) + .filter(|n| !n.is_empty()) + .ok_or(ParseError::MalformedComment { + line: i + 1, + })?; + if hunks.iter().any(|h| h.name == name) { + return Err(ParseError::DuplicateName { + name, + 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, + }); + } + 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, + block_tag, + body, + line_comment: i, + line_fence_close: fence_close, + }); + i = fence_close + 1; + } + Ok(Document { + lines, + hunks, + }) + } + + /// Look up a hunk by name. + pub fn hunk(&self, name: &str) -> Option<&Hunk> { + 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> { + 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); + } + Some(out.join("\n")) + } +} + +/// Given the index of a fence-opening line, return the index just past the +/// fence-closing line. +fn skip_fenced_block(lines: &[String], open: usize) -> Result<usize, ParseError> { + for (j, line) in lines.iter().enumerate().skip(open + 1) { + if line.starts_with(FENCE) { + return Ok(j + 1); + } + } + Err(ParseError::UnclosedFence { + line: open + 1, + }) +} + +include!("document_test.rs"); diff --git a/crates/testmark/src/document_test.rs b/crates/testmark/src/document_test.rs new file mode 100644 index 0000000..a700ee2 --- /dev/null +++ b/crates/testmark/src/document_test.rs @@ -0,0 +1,185 @@ +#[cfg(test)] +mod tests { + use super::*; + + const DOC: &str = "\ +Title\n\ +=====\n\ +\n\ +Some prose.\n\ +\n\ +[testmark]:# (one/input)\n\ +```text\n\ +hello\n\ +world\n\ +```\n\ +\n\ +More prose, and an untagged block that must be ignored:\n\ +\n\ +```sh\n\ +not a fixture\n\ +```\n\ +\n\ +[testmark]:# (one/output)\n\ +```\n\ +```\n"; + + #[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, ""); + } + + #[test] + fn patch_roundtrip_is_identity() { + let doc = Document::parse(DOC).expect("parses"); + let same = doc.patched("one/input", "hello\nworld\n").unwrap(); + assert_eq!(same, DOC); + } + + #[test] + fn patch_replaces_only_the_target_hunk() { + 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!(patched.contains("not a fixture")); + assert!(patched.contains("Some prose.")); + } + + #[test] + fn patch_can_empty_a_hunk() { + 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, ""); + } + + #[test] + fn patch_of_unknown_hunk_is_none() { + let doc = Document::parse(DOC).expect("parses"); + assert_eq!(doc.patched("no/such/hunk", "x\n"), None); + } + + #[test] + fn comment_lookalikes_inside_fences_are_inert() { + // A `[testmark]:#` line inside a fenced block is content, not an annotation + // (this is what lets fixture files document the testmark format itself). + let doc = Document::parse("```\n[testmark]:# (ghost)\n```\n").expect("parses"); + assert_eq!(doc.hunks.len(), 0); + } + + #[test] + fn comment_trailing_content_is_reserved_and_ignored() { + // The spec reserves everything after the first whitespace inside the parens. + let doc = Document::parse("[testmark]:# (a future extension)\n```\nx\n```\n") + .expect("parses"); + assert_eq!(doc.hunks.len(), 1); + assert_eq!(doc.hunks[0].name, "a"); + } + + #[test] + fn empty_document_parses() { + let doc = Document::parse("").expect("parses"); + assert_eq!(doc.hunks.len(), 0); + } + + #[test] + fn rejects_dangling_comment() { + let err = Document::parse("[testmark]:# (a)\nno fence here\n").unwrap_err(); + assert_eq!( + err, + ParseError::DanglingComment { + line: 1, + } + ); + } + + #[test] + fn rejects_comment_at_end_of_file() { + // Dangling in the harshest way: nothing at all after the comment. + let err = Document::parse("[testmark]:# (a)").unwrap_err(); + assert_eq!( + err, + ParseError::DanglingComment { + line: 1, + } + ); + } + + #[test] + fn rejects_malformed_comment() { + let err = Document::parse("[testmark]:# missing-parens\n```\n```\n").unwrap_err(); + assert_eq!( + err, + ParseError::MalformedComment { + line: 1, + } + ); + } + + #[test] + fn rejects_empty_name() { + let err = Document::parse("[testmark]:# ()\n```\n```\n").unwrap_err(); + assert_eq!( + err, + ParseError::MalformedComment { + line: 1, + } + ); + } + + #[test] + fn rejects_unclosed_fence() { + let err = Document::parse("[testmark]:# (a)\n```\nstuff\n").unwrap_err(); + assert_eq!( + err, + ParseError::UnclosedFence { + line: 2, + } + ); + } + + #[test] + fn rejects_unclosed_untagged_fence() { + let err = Document::parse("prose\n```\nstuff\n").unwrap_err(); + assert_eq!( + err, + ParseError::UnclosedFence { + line: 2, + } + ); + } + + #[test] + fn rejects_duplicate_names() { + let doc = "[testmark]:# (a)\n```\nx\n```\n[testmark]:# (a)\n```\ny\n```\n"; + let err = Document::parse(doc).unwrap_err(); + assert_eq!( + err, + ParseError::DuplicateName { + name: "a".into(), + line: 5, + } + ); + } + + #[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"); + } + + #[test] + fn error_display_uses_one_based_lines() { + let err = Document::parse("prose\n[testmark]:# (a)\nno fence\n").unwrap_err(); + assert_eq!( + err.to_string(), + "line 2: testmark comment not followed by a code fence" + ); + } +} diff --git a/crates/testmark/src/lib.rs b/crates/testmark/src/lib.rs new file mode 100644 index 0000000..126bb7d --- /dev/null +++ b/crates/testmark/src/lib.rs @@ -0,0 +1,27 @@ +//! Parser and patcher for the testmark format: named data hunks embedded in markdown. +//! +//! Testmark lets a markdown document double as documentation and test fixture data. +//! A data hunk is a fenced code block immediately preceded by a comment line +//! of the form `[testmark]:# (hunk-name)`: +//! +//! ````markdown +//! Some prose explaining the scenario. +//! +//! [testmark]:# (scenario/input) +//! ```text +//! data goes here +//! ``` +//! ```` +//! +//! Everything else in the document is plain markdown and is ignored +//! (and preserved byte-for-byte by the patcher). +//! The format is specified upstream at <https://github.com/warpfork/go-testmark>. +//! +//! This implementation fails closed where the input looks like testmark but isn't quite: +//! malformed annotations, annotations without a following fence, unclosed fences, +//! and duplicate hunk names are all errors rather than silently skipped hunks. +//! (A silently skipped hunk would mean a silently unexercised test.) + +mod document; + +pub use document::{Document, Hunk, ParseError};