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}