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}