Files
Marcos Paulo 2be0e6586e fix: markdown fidelity in the core crates: fences, math environments, text blocks
CommonMark fence scanner (a closing fence is at least as long as the opening one, tilde fences, quote prefixes), used by every fence scan in the note view; math: Bmatrix, Vmatrix, smallmatrix, array, align, hline, lenient parse where an unknown command becomes one red atom; plain/text/txt code blocks are no unknown language; corpus test against pulldown-cmark over the help vault (0 disagreements on 179 notes). Adds the render audit of the help vault (20 findings).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 23:59:11 -03:00

217 lines
8.0 KiB
Rust

//! Fenced code blocks by the `CommonMark` fence rule, for line-based consumers.
//!
//! An opening fence is a run of at least three backticks or tildes (a backtick
//! fence may not have a backtick in its info string). A closing fence uses the
//! same character, is at least as long as the opener and has nothing after it
//! but blanks; a shorter fence line inside is content. An unclosed fence runs
//! to the end of its container (the document, or the blockquote it started in).
//!
//! Indentation is deliberately lenient: a fence nested in a list item is
//! indented by more than three columns and still counts, because this scanner
//! does not track list containers. One linear pass, no recursion.
use std::ops::Range;
/// Fewest marker characters in a fence.
const MIN_FENCE_LEN: usize = 3;
/// An opening fence line: its marker character and run length.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Fence {
/// `` ` `` or `~`.
pub marker: char,
/// Length of the opening run (at least 3).
pub len: usize,
}
impl Fence {
/// Whether `line` (container prefix already stripped) closes this fence.
///
/// ```
/// use notescraft_markdown::fence_open;
/// let outer = fence_open("````md").unwrap_or_else(|| unreachable!());
/// assert!(!outer.closes("```"));
/// assert!(outer.closes("````"));
/// ```
#[must_use]
pub fn closes(&self, line: &str) -> bool {
let rest = line.trim_start_matches([' ', '\t']);
let run = rest.chars().take_while(|c| *c == self.marker).count();
run >= self.len && rest.trim_start_matches(self.marker).trim().is_empty()
}
}
/// The fence `line` opens, if any (container prefix already stripped).
///
/// ```
/// use notescraft_markdown::fence_open;
/// assert_eq!(fence_open("```rust").map(|f| f.len), Some(3));
/// assert!(fence_open("``` a`b").is_none());
/// assert!(fence_open("~~").is_none());
/// ```
#[must_use]
pub fn fence_open(line: &str) -> Option<Fence> {
let rest = line.trim_start_matches([' ', '\t']);
let marker = rest.chars().next().filter(|c| ['`', '~'].contains(c))?;
let len = rest.chars().take_while(|c| *c == marker).count();
let info = rest.get(len..)?;
(len >= MIN_FENCE_LEN && !(marker == '`' && info.contains('`'))).then_some(Fence { marker, len })
}
/// One fenced code block of a source text.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FenceBlock {
/// The opening fence.
pub fence: Fence,
/// Blockquote depth the fence sits in.
pub quote_depth: usize,
/// Byte range of the opening line, without its line ending.
pub opener: Range<usize>,
/// Byte range of the body lines, from the start of the first to the end of the last (empty when none).
pub body: Range<usize>,
/// Number of body lines.
pub body_lines: usize,
/// Byte range of the closing line, `None` when the fence is unclosed.
pub closer: Option<Range<usize>>,
}
/// `(quote depth, offset of the content after the markers)` of a line.
fn quote_prefix(line: &str, max: usize) -> (usize, usize) {
let mut depth = 0;
let mut at = 0;
while depth < max {
let rest = line.get(at..).unwrap_or("");
let trimmed = rest.trim_start_matches([' ', '\t']);
let Some(after) = trimmed.strip_prefix('>') else { break };
at += rest.len() - trimmed.len() + 1;
at += usize::from(after.starts_with(' '));
depth += 1;
}
(depth, at)
}
/// Most blockquote levels the scanner unwraps from one line.
const MAX_QUOTE_DEPTH: usize = 64;
/// Every fenced code block of `src`, in order.
///
/// Lines inside a blockquote are read without their `>` markers; a fence
/// opened at depth `d` ends (unclosed) at the first line with fewer markers.
///
/// ```
/// use notescraft_markdown::fenced_blocks;
/// let src = "````md\n```mermaid\nA\n```\n````\n";
/// let blocks = fenced_blocks(src);
/// assert_eq!(blocks.len(), 1);
/// assert_eq!(blocks[0].body_lines, 3);
/// ```
#[must_use]
pub fn fenced_blocks(src: &str) -> Vec<FenceBlock> {
let mut out = Vec::new();
let mut open: Option<FenceBlock> = None;
let mut pos = 0;
for raw in src.split_inclusive('\n') {
let line_start = pos;
pos += raw.len();
let line = raw.strip_suffix('\n').unwrap_or(raw);
let line = line.strip_suffix('\r').unwrap_or(line);
let line_end = line_start + line.len();
let (depth, content_at) = quote_prefix(line, MAX_QUOTE_DEPTH);
if let Some(block) = open.as_mut() {
if depth >= block.quote_depth {
let (_, inner_at) = quote_prefix(line, block.quote_depth);
if block.fence.closes(line.get(inner_at..).unwrap_or("")) {
block.closer = Some(line_start..line_end);
out.extend(open.take());
} else {
if block.body_lines == 0 {
block.body.start = line_start;
}
block.body.end = line_end;
block.body_lines += 1;
}
continue;
}
out.extend(open.take());
}
if let Some(fence) = fence_open(line.get(content_at..).unwrap_or("")) {
open = Some(FenceBlock { fence, quote_depth: depth, opener: line_start..line_end, body: line_end..line_end, body_lines: 0, closer: None });
}
}
out.extend(open);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn bodies(src: &str) -> Vec<(usize, bool)> {
fenced_blocks(src).iter().map(|b| (b.body_lines, b.closer.is_some())).collect()
}
#[test]
fn a_shorter_inner_fence_is_content() {
let src = "````md\n```mermaid\ngraph TD\n```\n````\nafter\n";
let blocks = fenced_blocks(src);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].body_lines, 3);
assert_eq!(src.get(blocks[0].body.clone()), Some("```mermaid\ngraph TD\n```"));
assert_eq!(src.get(blocks[0].closer.clone().unwrap_or(0..0)), Some("````"));
}
#[test]
fn a_longer_closer_closes_and_a_different_marker_does_not() {
assert_eq!(bodies("```\na\n~~~\nb\n`````\n"), vec![(3, true)]);
assert_eq!(bodies("~~~\na\n```\n~~~~\n"), vec![(2, true)]);
}
#[test]
fn a_closer_with_an_info_string_is_content() {
assert_eq!(bodies("```\na\n```js\nb\n```\n"), vec![(3, true)]);
}
#[test]
fn unclosed_runs_to_the_end() {
assert_eq!(bodies("```\na\nb"), vec![(2, false)]);
assert_eq!(bodies("```"), vec![(0, false)]);
}
#[test]
fn indented_fences_count() {
assert_eq!(bodies(" ```\n a\n ```\n"), vec![(1, true)]);
assert_eq!(bodies("- item\n ```\n a\n ```\n"), vec![(1, true)]);
}
#[test]
fn backtick_info_with_a_backtick_is_not_a_fence() {
assert_eq!(bodies("``` a`b\nx\n"), Vec::new());
assert_eq!(bodies("~~~ a`b\nx\n~~~\n"), vec![(1, true)]);
}
#[test]
fn fences_in_a_blockquote_end_with_it() {
let src = "> ```md\n> a\n> ```\n\n> ```\n> b\ntext\n";
let blocks = fenced_blocks(src);
assert_eq!(blocks.iter().map(|b| (b.quote_depth, b.body_lines, b.closer.is_some())).collect::<Vec<_>>(), vec![(1, 1, true), (1, 1, false)]);
assert_eq!(src.get(blocks[0].body.clone()), Some("> a"));
}
#[test]
fn nested_quote_depth_is_capped_not_fatal() {
let deep = "> ".repeat(MAX_QUOTE_DEPTH);
let blocks = fenced_blocks(&format!("{deep}```\n{deep}x\n{deep}```\n"));
assert_eq!(blocks.iter().map(|b| (b.quote_depth, b.body_lines)).collect::<Vec<_>>(), vec![(MAX_QUOTE_DEPTH, 1)]);
let too_deep = "> ".repeat(MAX_QUOTE_DEPTH + 1);
assert_eq!(fenced_blocks(&format!("{too_deep}```\nx\n")), Vec::new(), "markers past the cap are text");
}
#[test]
fn crlf_and_hostile_input_do_not_panic() {
assert_eq!(bodies("```\r\na\r\n```\r\n"), vec![(1, true)]);
for src in ["", "`", "é```", "```é\n```é\n", ">\n```\n>"] {
let _ = fenced_blocks(src);
}
}
}