testmark-rs

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 · b6c25bffdc