testmark: add PatchAccumulator and Document::patched_all
commit 9049dd4f1c38e7ea0dc38578ef56c9e665b2664d · parent 9ec06e8229
· browse this tree
Ximianth, 2026-08-10 18:31:03 UTC
Accumulated patching, so that many hunk rewrites against one document can be recorded as they are produced and applied in a single pure pass -- replacing the read-patch-write-per-hunk cycle downstream harnesses did until now, which used the filesystem as the accumulator and is both inefficient and only correct when every writer is fully serialized. This layer is pure format manipulation with no awareness of testing: put is last-write-wins (re-recording a produced body is a normal use here), while judgements like 'two different values for one hunk is a bug' belong to the checking layer that comes next. Applying a patch whose hunk name is not in the document is an error, and table hunks are not patchable through the accumulator for now (nothing generates tables yet; the one-shot Document::patched still can). Hunk line indices become pub(crate) so the patch module can splice; they stay private outside the crate.
diff --git a/crates/testmark/src/document.rs b/crates/testmark/src/document.rs index 669f13d..0b63208 100644 --- a/crates/testmark/src/document.rs +++ b/crates/testmark/src/document.rs @@ -42,10 +42,10 @@ pub struct Hunk { /// The content of this hunk. pub content: HunkContent, /// Line index (0-based) of the `[testmark]:#` comment line. - line_comment: usize, + pub(crate) line_comment: 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, + pub(crate) line_end: usize, } impl Hunk { @@ -103,7 +103,7 @@ impl<'a> From<&'a String> for NewContent<'a> { #[derive(Clone, Debug)] pub struct Document { - lines: Vec<String>, + pub(crate) lines: Vec<String>, pub hunks: Vec<Hunk>, } diff --git a/crates/testmark/src/lib.rs b/crates/testmark/src/lib.rs index 5e0b3af..64b0a22 100644 --- a/crates/testmark/src/lib.rs +++ b/crates/testmark/src/lib.rs @@ -17,11 +17,19 @@ //! (and preserved byte-for-byte by the patcher). //! The format is specified upstream at <https://github.com/warpfork/go-testmark>. //! +//! The crate is built in three layers, each usable without the ones above it: +//! +//! - [`Document`]: parse a document; look up hunks. Reads are borrow-only. +//! - [`PatchAccumulator`]: pending hunk rewrites, applied in one pure pass +//! by [`Document::patched_all`]. Still pure format manipulation: +//! any tool that runs hunks and writes results back can use these two layers. //! 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; +mod patch; pub use document::{Document, Hunk, HunkContent, NewContent, ParseError, ParsedTable}; +pub use patch::{PatchAccumulator, PatchError}; diff --git a/crates/testmark/src/patch.rs b/crates/testmark/src/patch.rs new file mode 100644 index 0000000..efda307 --- /dev/null +++ b/crates/testmark/src/patch.rs @@ -0,0 +1,129 @@ +//! Accumulating hunk rewrites, and applying them in one pass. +//! +//! `PatchAccumulator` is pure format manipulation, with no awareness of testing: +//! it holds owned (hunk name, new body) pairs, +//! and `Document::patched_all` splices them all into new document text. +//! The compare-or-regen logic that test harnesses want lives a layer up, in the `check` module; +//! this layer is equally at home in any tool that runs hunks and writes results back +//! (the accumulator owns its contents because the new bodies are newly produced values, +//! while reads of a `Document` stay borrow-only throughout). + +use std::fmt; + +use crate::document::{Document, Hunk, HunkContent}; + +/// Pending rewrites of code-hunk bodies, keyed by hunk name. +/// +/// Recording the same hunk again replaces the earlier value: +/// at this layer, re-recording is a normal use (think: re-running a producer of the body). +/// Treating a re-record as an error is a testing judgement, and is made a layer up. +#[derive(Default, Clone, Debug)] +pub struct PatchAccumulator { + /// Insertion-ordered; names unique (`put` replaces in place). + patches: Vec<(String, String)>, +} + +impl PatchAccumulator { + pub fn new() -> PatchAccumulator { + PatchAccumulator::default() + } + + /// Record (or replace) the pending body for a hunk. + pub fn put(&mut self, hunk: impl Into<String>, body: impl Into<String>) { + let hunk = hunk.into(); + let body = body.into(); + match self.patches.iter_mut().find(|(name, _)| *name == hunk) { + Some(entry) => entry.1 = body, + None => self.patches.push((hunk, body)), + } + } + + /// The pending body for a hunk, if one is recorded. + pub fn get(&self, hunk: &str) -> Option<&str> { + self.patches + .iter() + .find(|(name, _)| name == hunk) + .map(|(_, body)| body.as_str()) + } + + pub fn is_empty(&self) -> bool { + self.patches.is_empty() + } +} + +/// Problems applying a `PatchAccumulator` to a document. +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum PatchError { + /// A patch names a hunk not present in the document. + NoSuchHunk { + name: String, + }, + /// A patch names a table hunk; accumulated patches are code-hunk bodies. + /// (Table rewriting stays one-shot via `Document::patched` until something + /// actually generates tables.) + NotACodeHunk { + name: String, + }, +} + +impl fmt::Display for PatchError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + PatchError::NoSuchHunk { + name, + } => write!(f, "patch names hunk {name:?}, which is not in the document"), + PatchError::NotACodeHunk { + name, + } => write!(f, "patch names hunk {name:?}, which is not a code hunk"), + } + } +} + +impl std::error::Error for PatchError {} + +impl Document { + /// Apply all accumulated patches, producing new document text. + /// Pure: the caller writes the result back to wherever the text came from. + /// + /// Each patched hunk keeps its fence lines (and block_tag) unchanged; + /// all prose and formatting outside the patched bodies is preserved + /// byte-for-byte, in one splice pass, with no intermediate re-parse. + pub fn patched_all(&self, acc: &PatchAccumulator) -> Result<String, PatchError> { + // Resolve every patch to its hunk up front: fail before splicing anything. + let mut targets: Vec<(&Hunk, &str)> = Vec::with_capacity(acc.patches.len()); + for (name, body) in &acc.patches { + let h = self.hunk(name).ok_or_else(|| PatchError::NoSuchHunk { + name: name.clone(), + })?; + if !matches!(h.content, HunkContent::Code { .. }) { + return Err(PatchError::NotACodeHunk { + name: name.clone(), + }); + } + targets.push((h, body)); + } + targets.sort_by_key(|(h, _)| h.line_comment); + + let mut out: Vec<&str> = Vec::new(); + let mut cursor = 0; + for (h, body) in targets { + // Emit through the fence-open line, then the new body. + // The closing fence (at line_end) is emitted by the next segment. + for l in &self.lines[cursor..=h.line_comment + 1] { + out.push(l); + } + // A multi-line body goes in as one element; the final join flattens it. + let trimmed = body.strip_suffix('\n').unwrap_or(body); + if !trimmed.is_empty() { + out.push(trimmed); + } + cursor = h.line_end; + } + for l in &self.lines[cursor..] { + out.push(l); + } + Ok(out.join("\n")) + } +} + +include!("patch_test.rs"); diff --git a/crates/testmark/src/patch_test.rs b/crates/testmark/src/patch_test.rs new file mode 100644 index 0000000..654b71a --- /dev/null +++ b/crates/testmark/src/patch_test.rs @@ -0,0 +1,125 @@ +#[cfg(test)] +mod patch_tests { + use super::*; + + const DOC: &str = "\ +Title\n\ +=====\n\ +\n\ +[testmark]:# (one/input)\n\ +```text\n\ +hello\n\ +```\n\ +\n\ +prose between\n\ +\n\ +[testmark]:# (one/output)\n\ +```\n\ +old\n\ +```\n\ +\n\ +[testmark]:# (two/output)\n\ +```\n\ +also old\n\ +```\n"; + + #[test] + fn empty_accumulator_applies_as_identity() { + let doc = Document::parse(DOC).expect("parses"); + let acc = PatchAccumulator::new(); + assert!(acc.is_empty()); + assert_eq!(doc.patched_all(&acc).unwrap(), DOC); + } + + #[test] + fn same_body_applies_as_identity() { + let doc = Document::parse(DOC).expect("parses"); + let mut acc = PatchAccumulator::new(); + acc.put("one/output", "old\n"); + assert_eq!(doc.patched_all(&acc).unwrap(), DOC); + } + + #[test] + fn multiple_patches_apply_in_one_pass() { + let doc = Document::parse(DOC).expect("parses"); + let mut acc = PatchAccumulator::new(); + // Deliberately recorded in reverse document order: + // application order comes from the document, not from insertion. + acc.put("two/output", "fresh two\n"); + acc.put("one/output", "fresh one\nwith two lines\n"); + let text = doc.patched_all(&acc).unwrap(); + let redoc = Document::parse(&text).expect("reparses"); + assert_eq!(redoc.hunk("one/input").unwrap().body().unwrap(), "hello\n"); + assert_eq!( + redoc.hunk("one/output").unwrap().body().unwrap(), + "fresh one\nwith two lines\n" + ); + assert_eq!(redoc.hunk("two/output").unwrap().body().unwrap(), "fresh two\n"); + assert!(text.contains("prose between")); + // Untouched hunks keep their block_tag; patched hunks keep theirs too. + assert_eq!(redoc.hunk("one/input").unwrap().block_tag().unwrap(), "text"); + } + + #[test] + fn multi_patch_agrees_with_sequential_single_patches() { + let doc = Document::parse(DOC).expect("parses"); + let mut acc = PatchAccumulator::new(); + acc.put("one/output", "a\n"); + acc.put("two/output", "b\n"); + let all_at_once = doc.patched_all(&acc).unwrap(); + let step_one = doc.patched("one/output", "a\n").unwrap(); + let step_two = Document::parse(&step_one) + .expect("reparses") + .patched("two/output", "b\n") + .unwrap(); + assert_eq!(all_at_once, step_two); + } + + #[test] + fn put_replaces_earlier_value() { + let mut acc = PatchAccumulator::new(); + acc.put("a", "first\n"); + acc.put("a", "second\n"); + assert_eq!(acc.get("a"), Some("second\n")); + let doc = Document::parse("[testmark]:# (a)\n```\nx\n```\n").expect("parses"); + let text = doc.patched_all(&acc).unwrap(); + let redoc = Document::parse(&text).expect("reparses"); + assert_eq!(redoc.hunk("a").unwrap().body().unwrap(), "second\n"); + } + + #[test] + fn patch_can_empty_a_body() { + let doc = Document::parse(DOC).expect("parses"); + let mut acc = PatchAccumulator::new(); + acc.put("one/output", ""); + let text = doc.patched_all(&acc).unwrap(); + let redoc = Document::parse(&text).expect("reparses"); + assert_eq!(redoc.hunk("one/output").unwrap().body().unwrap(), ""); + } + + #[test] + fn unknown_hunk_is_an_error() { + let doc = Document::parse(DOC).expect("parses"); + let mut acc = PatchAccumulator::new(); + acc.put("no/such/hunk", "x\n"); + assert_eq!( + doc.patched_all(&acc).unwrap_err(), + PatchError::NoSuchHunk { + name: "no/such/hunk".into(), + } + ); + } + + #[test] + fn table_hunk_is_an_error() { + let doc = Document::parse("[testmark]:# (t)\n| a |\n| --- |\n| 1 |\n").expect("parses"); + let mut acc = PatchAccumulator::new(); + acc.put("t", "x\n"); + assert_eq!( + doc.patched_all(&acc).unwrap_err(), + PatchError::NotACodeHunk { + name: "t".into(), + } + ); + } +}