testmark-rs

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};