Skip to main content

memstead_base/
markdown.rs

1//! The CommonMark referee — one definition of "code" for every content
2//! reader in the engine.
3//!
4//! The engine used to carry two referees for the same markdown. Section
5//! splitting and wiki-link scanning ran on a hand-rolled line scanner
6//! that recognised exactly one shape of code block (a column-0 backtick
7//! fence, closed by any backtick-prefixed line); section *content*
8//! validation ([`crate::section_format`], agent-toolbox plan 08) ran on
9//! `pulldown-cmark`. They disagreed in six verified ways, and the
10//! disagreement sat on the write path — the validator judged content the
11//! splitter had already mis-partitioned:
12//!
13//! 1. indented code blocks were not masked at all;
14//! 2. legally indented fences (1–3 spaces — the normal shape inside a
15//!    list item) did not open a block;
16//! 3. tilde fences were unhandled;
17//! 4. a closing line carrying an info string — content, per CommonMark —
18//!    closed the block early;
19//! 5. fences inside blockquotes were not masked;
20//! 6. the opening fence was stored but never compared on close, so a
21//!    ```` ```` ````-fenced block ended on the first ```` ``` ````.
22//!
23//! `section_format`'s header states the thesis this module generalises:
24//! a reader that disagrees with the renderer every agent uses sends
25//! repair loops that cannot converge. **The parser is the referee.**
26//!
27//! Both masks preserve byte offsets and line counts exactly — every
28//! masked byte becomes an ASCII space except `\n` and `\r`, so a caller
29//! may scan the masked copy and slice the original by the offsets it
30//! finds. That is the whole mechanism: boundaries come from the parser,
31//! bytes come from the original.
32//!
33//! Heading recognition is deliberately *not* widened here. A section is
34//! still a column-0 ATX `## ` line and nothing else — setext headings
35//! and indented ATX create sections nowhere. This module fixes what code
36//! blocks hide; it does not change what counts as a heading.
37//!
38//! # Give these functions a BODY, never a whole entity file
39//!
40//! Frontmatter is not markdown. Handing it to a CommonMark parser
41//! invents block structure that is not there: a YAML value that reads
42//! as a fence opener — legal at 1–3 spaces, and honoured here since
43//! indented fences were fixed — opens a code block that runs past the
44//! `---` terminator to end of file and blanks the entire body. Every
45//! `## ` heading, every `[[link]]`, and every git conflict marker in
46//! that file becomes invisible to whatever scans the result.
47//!
48//! Callers holding a section body are already safe — section bodies
49//! are frontmatter-free by construction. A caller holding a raw file
50//! or a git blob is not, and must trim it first with
51//! [`crate::entity::parser::body_after_frontmatter`]. This bit the
52//! engine three times during the migration that introduced these
53//! masks: in `parse_markdown`, in the git-branch ripple scanner, and
54//! in the merge-conflict guard — the last one silently defeating a
55//! data-integrity check. Any new caller is the fourth unless it trims.
56
57use std::ops::Range;
58
59use pulldown_cmark::{Event, Options, Parser, Tag};
60
61/// The engine's CommonMark dialect — one `Options` for every reader, so
62/// the block model the masks see is the block model
63/// [`crate::section_format`] checks against.
64pub fn parser_options() -> Options {
65    let mut options = Options::empty();
66    options.insert(Options::ENABLE_TABLES);
67    options
68}
69
70/// Replace every byte of each range with a space, keeping `\n` and `\r`
71/// so line counts and byte offsets survive.
72///
73/// Parser ranges are always on char boundaries and every replacement is
74/// ASCII, so the result is valid UTF-8 of exactly the input's length.
75fn mask_ranges(text: &str, ranges: &[Range<usize>]) -> String {
76    let mut bytes = text.as_bytes().to_vec();
77    for range in ranges {
78        let end = range.end.min(bytes.len());
79        let start = range.start.min(end);
80        for b in &mut bytes[start..end] {
81            if *b != b'\n' && *b != b'\r' {
82                *b = b' ';
83            }
84        }
85    }
86    // Safe by construction: every replaced byte was inside a
87    // char-boundary-aligned range and became ASCII.
88    String::from_utf8(bytes).unwrap_or_else(|_| text.to_string())
89}
90
91/// Source ranges of every CommonMark code block (fenced — backtick or
92/// tilde, at any legal indent, inside any container — and indented) and,
93/// when `spans` is set, every inline code span.
94fn code_ranges(text: &str, spans: bool) -> Vec<Range<usize>> {
95    let mut out = Vec::new();
96    // Depth of open code blocks: nested containers never nest code
97    // blocks, but the offset iterator hands us the whole block on
98    // `Start`, so inline events inside it are already covered.
99    let mut block_end = 0usize;
100    for (event, range) in Parser::new_ext(text, parser_options()).into_offset_iter() {
101        match event {
102            Event::Start(Tag::CodeBlock(_)) => {
103                block_end = block_end.max(range.end);
104                out.push(range);
105            }
106            Event::Code(_) if spans && range.start >= block_end => out.push(range),
107            _ => {}
108        }
109    }
110    out
111}
112
113/// Mask every CommonMark code block, preserving byte offsets and line
114/// count.
115///
116/// Content inside a masked block is whitespace to every line scanner
117/// that runs over the result — it can never open a section, register a
118/// heading, become a title, or yield a wiki-link.
119///
120/// **Pass a body, not a whole entity file** — see the module header.
121pub fn mask_code_blocks(text: &str) -> String {
122    mask_ranges(text, &code_ranges(text, false))
123}
124
125/// Mask every CommonMark code block *and* every inline code span,
126/// preserving byte offsets and line count.
127///
128/// This is the single definition of "not visible to a link scanner".
129/// Extraction, rewriting, and strict validation all use it, so a link
130/// one path cannot see is a link no other path synthesises an edge
131/// from. Multi-backtick delimiters (`` `` ``, `` ``` ``) are handled by
132/// the parser, not by a delimiter-count regex.
133///
134/// **Pass a body, not a whole entity file** — see the module header.
135pub fn mask_code_blocks_and_spans(text: &str) -> String {
136    mask_ranges(text, &code_ranges(text, true))
137}
138
139/// When `text` — a section body — ends inside an unterminated fenced
140/// code block that would swallow whatever the caller writes after it,
141/// return the closing fence that terminates it. `None` when the text is
142/// safely self-delimiting: balanced fences, indented code (a column-0
143/// heading line ends it), or a fence inside a container a following
144/// column-0 line closes implicitly (blockquote, list item).
145///
146/// The referee itself is the oracle — no second fence model exists here
147/// to drift from the parser's. A probe line is appended and the mask
148/// consulted: if the probe comes back masked, the open block would
149/// swallow following content; the candidate closer (same char, the
150/// opening fence's length) is then verified the same way.
151///
152/// The caller that needs this is the entity generator: a section whose
153/// stored content ends inside an open fence would otherwise absorb every
154/// section heading the generator writes after it on the next parse — a
155/// document that grows and shifts content between sections on every
156/// parse→generate round.
157pub fn closing_fence_if_unterminated(text: &str) -> Option<String> {
158    // Fast path: no run of three fence characters, nothing to leave open.
159    if !text.contains("```") && !text.contains("~~~") {
160        return None;
161    }
162    const PROBE: &str = "memstead-fence-probe";
163    // `ends_with`, not `contains`: the probe is appended as the last
164    // line, so only its own (un)masked state is consulted — a prose
165    // occurrence of the probe string elsewhere cannot fake a pass.
166    let survives = |t: &str| mask_code_blocks(&format!("{t}\n{PROBE}")).ends_with(PROBE);
167    if survives(text) {
168        return None;
169    }
170    // The open block is the one whose range reaches end of text; its
171    // first line is the opening fence. Containers (blockquote, list)
172    // never reach here — their fences close implicitly at the probe's
173    // column-0 line, so the probe survives above.
174    let ranges = code_ranges(text, false);
175    let open = ranges.iter().rfind(|r| r.end >= text.len())?;
176    let first_line = text[open.start..].lines().next().unwrap_or("");
177    let fence = first_line.trim_start();
178    let ch = fence.chars().next()?;
179    if ch != '`' && ch != '~' {
180        return None;
181    }
182    let count = fence.chars().take_while(|c| *c == ch).count().max(3);
183    let cand = ch.to_string().repeat(count);
184    if survives(&format!("{text}\n{cand}")) {
185        Some(cand)
186    } else {
187        None
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    /// Every mask must be a byte-for-byte length match with the same
196    /// newline positions — callers slice the original by masked offsets.
197    fn assert_offset_preserving(input: &str, masked: &str) {
198        assert_eq!(input.len(), masked.len(), "byte length must be preserved");
199        assert_eq!(
200            input.match_indices('\n').collect::<Vec<_>>(),
201            masked.match_indices('\n').collect::<Vec<_>>(),
202            "newline positions must be preserved"
203        );
204    }
205
206    fn mask(input: &str) -> String {
207        let masked = mask_code_blocks(input);
208        assert_offset_preserving(input, &masked);
209        masked
210    }
211
212    fn mask_all(input: &str) -> String {
213        let masked = mask_code_blocks_and_spans(input);
214        assert_offset_preserving(input, &masked);
215        masked
216    }
217
218    // --- the six verified misparse classes -------------------------
219
220    /// Class 1: indented code blocks were not masked at all.
221    #[test]
222    fn class_1_indented_code_block_is_masked() {
223        let input = "Text:\n\n    ## Not A Heading\n    [[not-a-link]]\n\nAfter\n";
224        let masked = mask(input);
225        assert!(!masked.contains("## Not A Heading"));
226        assert!(!masked.contains("[[not-a-link]]"));
227        assert!(masked.contains("After"));
228    }
229
230    /// Class 2: a fence indented 1–3 spaces (the normal shape inside a
231    /// list item) is a legal fence and must open a block.
232    #[test]
233    fn class_2_indented_fence_opens_a_block() {
234        let input = "- item\n\n   ```\n   ## Not A Heading\n   ```\n\nAfter\n";
235        let masked = mask(input);
236        assert!(!masked.contains("## Not A Heading"));
237        assert!(masked.contains("After"));
238    }
239
240    /// Class 3: tilde fences were unhandled.
241    #[test]
242    fn class_3_tilde_fence_is_masked() {
243        let input = "~~~\n## Not A Heading\n[[not-a-link]]\n~~~\n\nAfter\n";
244        let masked = mask(input);
245        assert!(!masked.contains("## Not A Heading"));
246        assert!(!masked.contains("[[not-a-link]]"));
247        assert!(masked.contains("After"));
248    }
249
250    /// Class 4: a line that looks like a closer but carries an info
251    /// string is content, not a closer — the block runs on.
252    #[test]
253    fn class_4_info_string_on_a_closing_line_does_not_close() {
254        let input = "```\ncode\n``` not-a-closer\n## Not A Heading\n```\n\nAfter\n";
255        let masked = mask(input);
256        assert!(!masked.contains("## Not A Heading"));
257        assert!(masked.contains("After"));
258    }
259
260    /// Class 5: fences inside blockquotes were not masked.
261    #[test]
262    fn class_5_fence_inside_a_blockquote_is_masked() {
263        let input = "> ```\n> ## Not A Heading\n> [[not-a-link]]\n> ```\n\nAfter\n";
264        let masked = mask(input);
265        assert!(!masked.contains("## Not A Heading"));
266        assert!(!masked.contains("[[not-a-link]]"));
267        assert!(masked.contains("After"));
268    }
269
270    /// Class 6: the opening fence's length must be honoured on close —
271    /// a four-backtick block does not end on a three-backtick line.
272    #[test]
273    fn class_6_longer_fence_is_not_closed_by_a_shorter_one() {
274        let input = "````\n```\n## Not A Heading\n```\n````\n\nAfter\n";
275        let masked = mask(input);
276        assert!(!masked.contains("## Not A Heading"));
277        assert!(masked.contains("After"));
278    }
279
280    // --- the complement: nothing outside the classes changes -------
281
282    #[test]
283    fn prose_heading_and_links_survive() {
284        let input = "# Title\n\n## Section\n\nSee [[other-entity]] and [[a|b]].\n";
285        let masked = mask(input);
286        assert_eq!(masked, input);
287    }
288
289    #[test]
290    fn fenced_block_masking_matches_the_old_bare_fence_behaviour() {
291        let input = "before\n```rust\nfn f() {}\n```\nafter\n";
292        let masked = mask(input);
293        assert!(masked.starts_with("before\n"));
294        assert!(masked.ends_with("after\n"));
295        assert!(!masked.contains("fn f()"));
296        assert!(!masked.contains("```"));
297    }
298
299    #[test]
300    fn unclosed_fence_masks_to_end_of_text() {
301        let input = "before\n```\n## Not A Heading\nstill inside\n";
302        let masked = mask(input);
303        assert!(masked.starts_with("before\n"));
304        assert!(!masked.contains("## Not A Heading"));
305        assert!(!masked.contains("still inside"));
306    }
307
308    #[test]
309    fn multibyte_content_masks_without_corruption() {
310        let input = "```\nGrüße — ünïcødé ✓\n```\nafter ✓\n";
311        let masked = mask(input);
312        assert!(!masked.contains("Grüße"));
313        assert!(masked.contains("after ✓"));
314    }
315
316    #[test]
317    fn crlf_line_endings_survive_masking() {
318        let input = "before\r\n```\r\n## Not A Heading\r\n```\r\nafter\r\n";
319        let masked = mask(input);
320        assert!(!masked.contains("## Not A Heading"));
321        assert!(masked.contains("after"));
322        assert_eq!(input.matches('\r').count(), masked.matches('\r').count());
323    }
324
325    // --- inline spans ----------------------------------------------
326
327    #[test]
328    fn inline_span_hides_a_link() {
329        let input = "See `[[not-a-link]]` but [[real-link]].\n";
330        let masked = mask_all(input);
331        assert!(!masked.contains("[[not-a-link]]"));
332        assert!(masked.contains("[[real-link]]"));
333    }
334
335    #[test]
336    fn double_backtick_span_hides_a_link() {
337        let input = "See `` [[not-a-link]] `` but [[real-link]].\n";
338        let masked = mask_all(input);
339        assert!(!masked.contains("[[not-a-link]]"));
340        assert!(masked.contains("[[real-link]]"));
341    }
342
343    /// A single-backtick regex slices into the middle of a
344    /// double-backtick span and leaves `` ` ``/`[[` remnants behind;
345    /// the parser does not.
346    #[test]
347    fn backtick_inside_a_double_backtick_span_leaves_no_remnant() {
348        let input = "Literal ``a ` b`` then [[real-link]].\n";
349        let masked = mask_all(input);
350        assert!(!masked.contains('`'));
351        assert!(masked.contains("[[real-link]]"));
352    }
353
354    // --- open-fence termination helper -----------------------------
355
356    #[test]
357    fn unterminated_backtick_fence_yields_matching_closer() {
358        assert_eq!(
359            closing_fence_if_unterminated("```\ncode with no closer"),
360            Some("```".to_string())
361        );
362        // The closer must honour the opening fence's length (class 6).
363        assert_eq!(
364            closing_fence_if_unterminated("````\n```\nstill inside"),
365            Some("````".to_string())
366        );
367        assert_eq!(
368            closing_fence_if_unterminated("~~~\ntilde block"),
369            Some("~~~".to_string())
370        );
371    }
372
373    #[test]
374    fn balanced_and_container_fences_need_no_closer() {
375        assert_eq!(closing_fence_if_unterminated("```\ncode\n```"), None);
376        assert_eq!(closing_fence_if_unterminated("no fences at all"), None);
377        // A blockquote fence closes implicitly when the quote ends at the
378        // next column-0 line — nothing bleeds, nothing to terminate.
379        assert_eq!(closing_fence_if_unterminated("> ```\n> quoted"), None);
380        // Indented code ends at any column-0 line.
381        assert_eq!(closing_fence_if_unterminated("text:\n\n    code"), None);
382    }
383
384    #[test]
385    fn block_mask_does_not_double_count_inline_spans_inside_blocks() {
386        let input = "```\nlet s = `x`;\n```\nafter `y`.\n";
387        let masked = mask_all(input);
388        assert!(!masked.contains('`'));
389        assert!(masked.contains("after"));
390    }
391}