document.rs
//! 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");
crates/testmark/src/document.rs · 6.0 KiB · 820b6fe5c2