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/// When `text` ends inside an open HTML block of a kind no blank line
192/// ends (CommonMark types 1–5: a `<script>`/`<pre>`/`<style>`/`<textarea>`
193/// tag, `<!--`, `<?`, `<!` + letter, `<![CDATA[`), return the line that
194/// terminates it. `None` when the trailing context is neutral: no such
195/// block open, or a type 6/7 block that the caller's blank-line join
196/// already ends.
197///
198/// Inside such a block the referee reads no fence at all, so whatever a
199/// caller appends after `text` is parsed differently than it was where
200/// it came from: a fence opener that hid a `## ` line in situ is plain
201/// text after the append, the line becomes a heading, and the document
202/// shifts structure on every parse→generate round. Same oracle discipline
203/// as [`closing_fence_if_unterminated`]: the referee decides via a probe
204/// (a fence-plus-marker appended after a blank line must come back
205/// masked), the candidate closer is derived from the open block's own
206/// start condition, and the probe verifies it. A `text` that ends inside
207/// an open fence is the fence oracle's case, not this one: it returns
208/// `None`, so callers run the fence close first.
209pub fn closing_html_block_if_unterminated(text: &str) -> Option<String> {
210    if !text.contains('<') {
211        return None;
212    }
213    const PROBE: &str = "memstead-html-probe";
214    // A fence appended after a blank line must mask the marker; if the
215    // marker survives, the trailing context hides fences.
216    let fences_work =
217        |t: &str| !mask_code_blocks(&format!("{t}\n\n```\n{PROBE}\n```")).contains(PROBE);
218    if fences_work(text) {
219        return None;
220    }
221    // Inside an open fence the probe's own fence line closes it and the
222    // marker survives too — that case belongs to the fence oracle.
223    if closing_fence_if_unterminated(text).is_some() {
224        return None;
225    }
226    // The open block is the last HTML block whose range reaches the end
227    // of the text; its first line carries the start condition.
228    let open = Parser::new_ext(text, parser_options())
229        .into_offset_iter()
230        .filter(|(event, _)| matches!(event, Event::Start(Tag::HtmlBlock)))
231        .map(|(_, range)| range)
232        .filter(|r| r.end >= text.trim_end().len())
233        .last()?;
234    let first_line = text[open.start..].lines().next().unwrap_or("").trim_start();
235    let lower = first_line.to_ascii_lowercase();
236    let closer = if lower.starts_with("<!--") {
237        "-->".to_string()
238    } else if lower.starts_with("<?") {
239        "?>".to_string()
240    } else if lower.starts_with("<![cdata[") {
241        "]]>".to_string()
242    } else if lower.starts_with("<!") {
243        ">".to_string()
244    } else {
245        let tag = ["script", "pre", "style", "textarea"]
246            .into_iter()
247            .find(|tag| {
248                lower
249                    .strip_prefix('<')
250                    .and_then(|rest| rest.strip_prefix(tag))
251                    .is_some_and(|rest| rest.is_empty() || rest.starts_with([' ', '\t', '>']))
252            })?;
253        format!("</{tag}>")
254    };
255    if fences_work(&format!("{text}\n{closer}")) {
256        Some(closer)
257    } else {
258        None
259    }
260}
261
262/// The one question both concatenation sites ask: does `text` end inside
263/// a block context that would change how the next appended piece is
264/// read? A fence first (it swallows headings), then an HTML block (it
265/// hides fences); never both, since neither can open inside the other.
266pub fn closing_context_if_unterminated(text: &str) -> Option<String> {
267    closing_fence_if_unterminated(text).or_else(|| closing_html_block_if_unterminated(text))
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    /// Every mask must be a byte-for-byte length match with the same
275    /// newline positions — callers slice the original by masked offsets.
276    fn assert_offset_preserving(input: &str, masked: &str) {
277        assert_eq!(input.len(), masked.len(), "byte length must be preserved");
278        assert_eq!(
279            input.match_indices('\n').collect::<Vec<_>>(),
280            masked.match_indices('\n').collect::<Vec<_>>(),
281            "newline positions must be preserved"
282        );
283    }
284
285    fn mask(input: &str) -> String {
286        let masked = mask_code_blocks(input);
287        assert_offset_preserving(input, &masked);
288        masked
289    }
290
291    fn mask_all(input: &str) -> String {
292        let masked = mask_code_blocks_and_spans(input);
293        assert_offset_preserving(input, &masked);
294        masked
295    }
296
297    // --- the six verified misparse classes -------------------------
298
299    /// Class 1: indented code blocks were not masked at all.
300    #[test]
301    fn class_1_indented_code_block_is_masked() {
302        let input = "Text:\n\n    ## Not A Heading\n    [[not-a-link]]\n\nAfter\n";
303        let masked = mask(input);
304        assert!(!masked.contains("## Not A Heading"));
305        assert!(!masked.contains("[[not-a-link]]"));
306        assert!(masked.contains("After"));
307    }
308
309    /// Class 2: a fence indented 1–3 spaces (the normal shape inside a
310    /// list item) is a legal fence and must open a block.
311    #[test]
312    fn class_2_indented_fence_opens_a_block() {
313        let input = "- item\n\n   ```\n   ## Not A Heading\n   ```\n\nAfter\n";
314        let masked = mask(input);
315        assert!(!masked.contains("## Not A Heading"));
316        assert!(masked.contains("After"));
317    }
318
319    /// Class 3: tilde fences were unhandled.
320    #[test]
321    fn class_3_tilde_fence_is_masked() {
322        let input = "~~~\n## Not A Heading\n[[not-a-link]]\n~~~\n\nAfter\n";
323        let masked = mask(input);
324        assert!(!masked.contains("## Not A Heading"));
325        assert!(!masked.contains("[[not-a-link]]"));
326        assert!(masked.contains("After"));
327    }
328
329    /// Class 4: a line that looks like a closer but carries an info
330    /// string is content, not a closer — the block runs on.
331    #[test]
332    fn class_4_info_string_on_a_closing_line_does_not_close() {
333        let input = "```\ncode\n``` not-a-closer\n## Not A Heading\n```\n\nAfter\n";
334        let masked = mask(input);
335        assert!(!masked.contains("## Not A Heading"));
336        assert!(masked.contains("After"));
337    }
338
339    /// Class 5: fences inside blockquotes were not masked.
340    #[test]
341    fn class_5_fence_inside_a_blockquote_is_masked() {
342        let input = "> ```\n> ## Not A Heading\n> [[not-a-link]]\n> ```\n\nAfter\n";
343        let masked = mask(input);
344        assert!(!masked.contains("## Not A Heading"));
345        assert!(!masked.contains("[[not-a-link]]"));
346        assert!(masked.contains("After"));
347    }
348
349    /// Class 6: the opening fence's length must be honoured on close —
350    /// a four-backtick block does not end on a three-backtick line.
351    #[test]
352    fn class_6_longer_fence_is_not_closed_by_a_shorter_one() {
353        let input = "````\n```\n## Not A Heading\n```\n````\n\nAfter\n";
354        let masked = mask(input);
355        assert!(!masked.contains("## Not A Heading"));
356        assert!(masked.contains("After"));
357    }
358
359    // --- the complement: nothing outside the classes changes -------
360
361    #[test]
362    fn prose_heading_and_links_survive() {
363        let input = "# Title\n\n## Section\n\nSee [[other-entity]] and [[a|b]].\n";
364        let masked = mask(input);
365        assert_eq!(masked, input);
366    }
367
368    #[test]
369    fn fenced_block_masking_matches_the_old_bare_fence_behaviour() {
370        let input = "before\n```rust\nfn f() {}\n```\nafter\n";
371        let masked = mask(input);
372        assert!(masked.starts_with("before\n"));
373        assert!(masked.ends_with("after\n"));
374        assert!(!masked.contains("fn f()"));
375        assert!(!masked.contains("```"));
376    }
377
378    #[test]
379    fn unclosed_fence_masks_to_end_of_text() {
380        let input = "before\n```\n## Not A Heading\nstill inside\n";
381        let masked = mask(input);
382        assert!(masked.starts_with("before\n"));
383        assert!(!masked.contains("## Not A Heading"));
384        assert!(!masked.contains("still inside"));
385    }
386
387    #[test]
388    fn multibyte_content_masks_without_corruption() {
389        let input = "```\nGrüße — ünïcødé ✓\n```\nafter ✓\n";
390        let masked = mask(input);
391        assert!(!masked.contains("Grüße"));
392        assert!(masked.contains("after ✓"));
393    }
394
395    #[test]
396    fn crlf_line_endings_survive_masking() {
397        let input = "before\r\n```\r\n## Not A Heading\r\n```\r\nafter\r\n";
398        let masked = mask(input);
399        assert!(!masked.contains("## Not A Heading"));
400        assert!(masked.contains("after"));
401        assert_eq!(input.matches('\r').count(), masked.matches('\r').count());
402    }
403
404    // --- inline spans ----------------------------------------------
405
406    #[test]
407    fn inline_span_hides_a_link() {
408        let input = "See `[[not-a-link]]` but [[real-link]].\n";
409        let masked = mask_all(input);
410        assert!(!masked.contains("[[not-a-link]]"));
411        assert!(masked.contains("[[real-link]]"));
412    }
413
414    #[test]
415    fn double_backtick_span_hides_a_link() {
416        let input = "See `` [[not-a-link]] `` but [[real-link]].\n";
417        let masked = mask_all(input);
418        assert!(!masked.contains("[[not-a-link]]"));
419        assert!(masked.contains("[[real-link]]"));
420    }
421
422    /// A single-backtick regex slices into the middle of a
423    /// double-backtick span and leaves `` ` ``/`[[` remnants behind;
424    /// the parser does not.
425    #[test]
426    fn backtick_inside_a_double_backtick_span_leaves_no_remnant() {
427        let input = "Literal ``a ` b`` then [[real-link]].\n";
428        let masked = mask_all(input);
429        assert!(!masked.contains('`'));
430        assert!(masked.contains("[[real-link]]"));
431    }
432
433    // --- open-fence termination helper -----------------------------
434
435    #[test]
436    fn unterminated_backtick_fence_yields_matching_closer() {
437        assert_eq!(
438            closing_fence_if_unterminated("```\ncode with no closer"),
439            Some("```".to_string())
440        );
441        // The closer must honour the opening fence's length (class 6).
442        assert_eq!(
443            closing_fence_if_unterminated("````\n```\nstill inside"),
444            Some("````".to_string())
445        );
446        assert_eq!(
447            closing_fence_if_unterminated("~~~\ntilde block"),
448            Some("~~~".to_string())
449        );
450    }
451
452    #[test]
453    fn balanced_and_container_fences_need_no_closer() {
454        assert_eq!(closing_fence_if_unterminated("```\ncode\n```"), None);
455        assert_eq!(closing_fence_if_unterminated("no fences at all"), None);
456        // A blockquote fence closes implicitly when the quote ends at the
457        // next column-0 line — nothing bleeds, nothing to terminate.
458        assert_eq!(closing_fence_if_unterminated("> ```\n> quoted"), None);
459        // Indented code ends at any column-0 line.
460        assert_eq!(closing_fence_if_unterminated("text:\n\n    code"), None);
461    }
462
463    #[test]
464    fn block_mask_does_not_double_count_inline_spans_inside_blocks() {
465        let input = "```\nlet s = `x`;\n```\nafter `y`.\n";
466        let masked = mask_all(input);
467        assert!(!masked.contains('`'));
468        assert!(masked.contains("after"));
469    }
470
471    #[test]
472    fn unterminated_html_block_yields_its_own_closer() {
473        // Type 4: `<!` + letter, ended only by a line containing `>`.
474        assert_eq!(
475            closing_html_block_if_unterminated("<!S**: [[-----\nmore"),
476            Some(">".to_string())
477        );
478        // Type 2: a comment.
479        assert_eq!(
480            closing_html_block_if_unterminated("<!-- draft"),
481            Some("-->".to_string())
482        );
483        // Type 3: a processing instruction.
484        assert_eq!(
485            closing_html_block_if_unterminated("<?php"),
486            Some("?>".to_string())
487        );
488        // Type 5: CDATA.
489        assert_eq!(
490            closing_html_block_if_unterminated("<![CDATA[ raw"),
491            Some("]]>".to_string())
492        );
493        // Type 1: the raw-text tags, case-insensitive, closed by their own tag.
494        assert_eq!(
495            closing_html_block_if_unterminated("<Script>\nlet x;"),
496            Some("</script>".to_string())
497        );
498        assert_eq!(
499            closing_html_block_if_unterminated("<pre class=\"x\">\ntext"),
500            Some("</pre>".to_string())
501        );
502        // The combined oracle picks the same line.
503        assert_eq!(
504            closing_context_if_unterminated("<!-- draft"),
505            Some("-->".to_string())
506        );
507    }
508
509    #[test]
510    fn closed_and_blank_line_ended_html_blocks_need_no_closer() {
511        assert_eq!(closing_html_block_if_unterminated("<!-- a -->\ntext"), None);
512        assert_eq!(
513            closing_html_block_if_unterminated("<!S open\nclosed here >"),
514            None
515        );
516        // Type 6/7 end at the blank line every caller joins with.
517        assert_eq!(
518            closing_html_block_if_unterminated("<div>\nstill html"),
519            None
520        );
521        assert_eq!(
522            closing_html_block_if_unterminated("<span>inline</span> prose"),
523            None
524        );
525        assert_eq!(closing_html_block_if_unterminated("no markup"), None);
526        // An open fence is the fence oracle's case; the combined oracle
527        // returns the fence closer, not an HTML one.
528        assert_eq!(
529            closing_html_block_if_unterminated("```\n<!-- inside code"),
530            None
531        );
532        assert_eq!(
533            closing_context_if_unterminated("```\n<!-- inside code"),
534            Some("```".to_string())
535        );
536    }
537}