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.

#[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.
	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,
}

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 {
	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.4 KiB · 71242d0778