2be0e6586e
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>
217 lines
8.0 KiB
Rust
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);
|
|
}
|
|
}
|
|
}
|