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.
#[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 content of this hunk.
pub content: HunkContent,
/// Line index (0-based) of the `[testmark]:#` comment line.
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).
pub(crate) 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())
}
}
#[derive(Clone, Debug)]
pub struct Document {
pub(crate) 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 or pipe table.
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,
},
/// 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 {
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:?}"),
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"),
}
}
}
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,
});
}
// 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,
});
}
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,
});
}
}
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 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 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"))
}
}
/// 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 · 10.5 KiB · 20c0b098ea