moss_core/ast/parser.rs
1//! Pulldown-cmark → typed AST parser.
2//!
3//! Walks `pulldown_cmark::Event` and assembles a [`Document`]. The parser
4//! enables the same extensions moss's pipeline does: tables, footnotes,
5//! strikethrough.
6//!
7//! All URL nodes start as [`Url::Unresolved`]; classifying into
8//! [`Url::Resolved`] is the job of [`crate::ast::visit::visit_urls_mut`]
9//! (a separate pass).
10//!
11//! Heading IDs ARE assigned by this parser. Phase 4 PR2: each
12//! `Tag::Heading` arm computes the Obsidian-compatible anchor slug from
13//! the heading's text content (only `Event::Text` / `Event::Code`,
14//! matching production's `transform_events` behavior in
15//! `src-tauri/src/build/markdown/pipeline.rs` lines 1776-1845); a
16//! post-parse pass ([`assign_heading_id_suffixes`]) walks all headings in
17//! document order (recursively into BlockQuotes, lists, callouts) and
18//! applies duplicate-suffix numbering (`{slug}-1`, `-2`, …) matching the
19//! `id_counts` HashMap behavior at `pipeline.rs:1798`.
20
21use std::collections::HashMap;
22
23use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
24
25use super::document::{BlockMeta, Document};
26use super::node::{Block, CalloutKind, Fold, Inline};
27use super::shortcode_extract::{extract_shortcodes, parse_placeholder, ExtractedShortcode};
28use super::url::Url;
29use crate::heading_anchor::obsidian_heading_anchor;
30
31/// Parser configuration flags.
32///
33/// Threaded through [`parse_with_config`] to gate optional parser behaviors
34/// that the renderer needs to coordinate with (source-line tracking for
35/// preview scroll sync, implicit-figure promotion).
36///
37/// [`Default`] = "production preview off" — `emit_source_lines: false`,
38/// `implicit_figure: true`. The `implicit_figure` default mirrors today's
39/// always-on behavior of the parser before this config existed; flipping it
40/// off is opt-in for the small set of fragment-render call sites that need
41/// bare `<img>` (none today, but the flag exists for symmetry with the
42/// legacy `transform_events` API and the production `site_config` field).
43#[derive(Debug, Clone, Copy)]
44pub struct ParseConfig {
45 /// When true, populates [`BlockMeta::source_line`] for top-level
46 /// blocks. The renderer emits `data-source-line="N"` on the opening
47 /// tag for any block whose meta carries `Some(N)`.
48 ///
49 /// Production wires this from `process_markdown_file`'s
50 /// `emit_source_lines` argument (`true` during preview builds, `false`
51 /// during ship-stage publish builds — `data-source-line` is stripped
52 /// at ship time anyway, but emitting fewer attrs upstream is cheaper
53 /// and keeps published HTML clean from earlier stages).
54 pub emit_source_lines: bool,
55
56 /// When true (default), image-only paragraphs promote to
57 /// [`Block::Figure`] via [`try_promote_to_figure`]. When false, they
58 /// stay as [`Block::Paragraph`] containing one [`Inline::Image`].
59 ///
60 /// Production wires this from `site_config.implicit_figure` (default
61 /// `true`). The flag mirrors the legacy `transform_events`
62 /// implicit-figure pass: sites that prefer bare `<img>` (no `<figure>`
63 /// wrap) can opt out.
64 pub implicit_figure: bool,
65}
66
67impl Default for ParseConfig {
68 fn default() -> Self {
69 Self {
70 emit_source_lines: false,
71 // `true` matches today's always-on behavior of the parser
72 // before ParseConfig existed; the ~40 in-crate `parse()`
73 // callers all assume figure promotion happens.
74 implicit_figure: true,
75 }
76 }
77}
78
79/// Parse markdown into a typed [`Document`] using the default config.
80///
81/// Equivalent to `parse_with_config(markdown, &ParseConfig::default())`.
82/// This is the entry point for the ~40 in-crate callers that don't need
83/// per-parse configuration (URL resolution tests, frontmatter round-trip
84/// tests, etc.). Production paths that need source-line tracking or
85/// implicit-figure toggling call [`parse_with_config`].
86pub fn parse(markdown: &str) -> Document {
87 parse_with_config(markdown, &ParseConfig::default())
88}
89
90/// Parse markdown into a typed [`Document`].
91///
92/// This is the AST entry point. The input is post-resolve markdown (the
93/// upstream resolve pipeline has already rewritten wikilinks into standard
94/// markdown links with `moss-resolved:` prefixes).
95///
96/// Two-stage parse:
97/// 1. [`extract_shortcodes`] pre-scans for `:::name` blocks, replacing
98/// each with a sentinel HTML comment.
99/// 2. Pulldown-cmark parses the substituted markdown into events; each
100/// sentinel comes back as a `Block::Other` raw HTML.
101/// 3. A final pass walks the AST and substitutes `Block::Other` sentinel
102/// payloads with the corresponding typed [`Block::Shortcode`].
103///
104/// When `config.emit_source_lines` is true, the parser walks events via
105/// `into_offset_iter()` so each top-level block carries the byte offset
106/// of its first event; a [`LineLookup`] converts the offset to a 1-based
107/// line number stored in [`BlockMeta::source_line`].
108pub fn parse_with_config(markdown: &str, config: &ParseConfig) -> Document {
109 let extraction = extract_shortcodes(markdown);
110
111 let mut options = Options::empty();
112 options.insert(Options::ENABLE_STRIKETHROUGH);
113 options.insert(Options::ENABLE_TABLES);
114 options.insert(Options::ENABLE_FOOTNOTES);
115 // Phase 3 PR2: pulldown-cmark emits `LinkType::WikiLink` events for
116 // `[[…]]` / `![[…]]` natively. The typed-AST parser preserves them as
117 // `Inline::Link`/`Inline::Image` with `Url::Unresolved`; resolution
118 // happens in the later `visit_urls_mut` pass.
119 options.insert(Options::ENABLE_WIKILINKS);
120
121 // Source-line tracking requires the `into_offset_iter` form of the
122 // parser, which yields (Event, Range<usize>). When tracking is off,
123 // we use the plain iterator (no per-event offset overhead).
124 let (events, offsets): (Vec<Event<'_>>, Vec<Option<std::ops::Range<usize>>>) =
125 if config.emit_source_lines {
126 let mut evs = Vec::new();
127 let mut offs = Vec::new();
128 for (event, range) in
129 Parser::new_ext(&extraction.markdown_with_placeholders, options).into_offset_iter()
130 {
131 evs.push(event);
132 offs.push(Some(range));
133 }
134 (evs, offs)
135 } else {
136 let evs: Vec<Event<'_>> =
137 Parser::new_ext(&extraction.markdown_with_placeholders, options).collect();
138 let len = evs.len();
139 (evs, vec![None; len])
140 };
141
142 // Build the prefix-sum line table once (only when needed).
143 //
144 // CAVEAT: the markdown that the offsets index into is
145 // `extraction.markdown_with_placeholders`, NOT the original
146 // `markdown` passed in. Shortcode extraction may rewrite some bytes
147 // into sentinel HTML comments of a different length; line numbers
148 // would be off for blocks following an extracted shortcode if we
149 // built the lookup against the original. We build against the
150 // post-extraction string, so the line numbers match the
151 // post-extraction view — which is what users see in their editor
152 // before shortcode-block lines, and is "close enough" after (the
153 // sentinel preserves one line per extracted block, so line counts
154 // after the extracted block are within one of the source). See the
155 // architecture note in `shortcode_extract.rs` for the placeholder
156 // shape.
157 //
158 // For the source-line-off path, lookup is unused.
159 let line_lookup = if config.emit_source_lines {
160 Some(LineLookup::build(&extraction.markdown_with_placeholders))
161 } else {
162 None
163 };
164
165 // Line-tracking context handed to every recursive parser entry; the
166 // Tag::List / Tag::Table arms consult it to annotate per-item / per-row
167 // source lines. `None` when `emit_source_lines` is off; the inner
168 // arms see this as "skip annotation" and emit empty parallel vecs.
169 let line_ctx: Option<LineCtx<'_>> = line_lookup.as_ref().map(|lookup| LineCtx {
170 lookup,
171 offsets: &offsets,
172 });
173
174 let mut blocks = Vec::new();
175 let mut block_meta: Vec<BlockMeta> = Vec::new();
176 let mut i = 0;
177 while i < events.len() {
178 let event_start_idx = i;
179 let (block, advance) = parse_block(&events, i, line_ctx.as_ref());
180 if let Some(b) = block {
181 // Compute source_line from the first event's byte offset, if
182 // we collected offsets and a lookup is in scope.
183 let source_line = match (line_lookup.as_ref(), offsets.get(event_start_idx)) {
184 (Some(lookup), Some(Some(range))) => Some(lookup.line_at(range.start)),
185 _ => None,
186 };
187 blocks.push(b);
188 block_meta.push(BlockMeta { source_line });
189 }
190 i += advance.max(1);
191 }
192
193 // Substitute sentinel placeholders with their typed Shortcode variants.
194 substitute_shortcode_placeholders(&mut blocks, &extraction.nonce, &extraction.extracted);
195
196 // Implicit-figure gating: the per-paragraph `try_promote_to_figure`
197 // inside `parse_block_with_tag` always runs (so the figure promotion
198 // happens at parse time inside the Tag::Paragraph arm). When
199 // `config.implicit_figure` is false, we walk the assembled blocks
200 // and "undo" the promotion — converting `Block::Figure { image, ..}`
201 // back to `Block::Paragraph(vec![image])`.
202 //
203 // The unwinding-at-the-end approach was chosen over threading the
204 // flag into `parse_block_with_tag` because the latter would mean
205 // propagating `config` through ~14 inner parser functions whose
206 // signatures are already tight. The unwind is O(N) and only fires
207 // on the rare opt-out path; production keeps the default `true`.
208 if !config.implicit_figure {
209 for block in blocks.iter_mut() {
210 unwrap_implicit_figure(block);
211 }
212 }
213
214 // Apply duplicate-suffix numbering to heading IDs in document order.
215 // Each Tag::Heading arm computes the base slug; this pass disambiguates
216 // collisions across the whole document, matching production's id_counts
217 // HashMap behavior in pipeline.rs::transform_events.
218 assign_heading_id_suffixes(&mut blocks);
219
220 Document::from_blocks_with_meta(blocks, block_meta)
221}
222
223/// Recursively undo implicit-figure promotion in `block` and its children.
224///
225/// Called when `ParseConfig::implicit_figure` is false. Walks the block
226/// tree (descending into containers — `BlockQuote`, `Callout`, `List`,
227/// `LinkCard`) and rewrites any `Block::Figure` back to
228/// `Block::Paragraph(vec![image])` with the original alt text preserved.
229/// The caption is discarded (matches the legacy bare-`<img>` shape).
230fn unwrap_implicit_figure(block: &mut Block) {
231 // Replace this block if it's a Figure.
232 if let Block::Figure { image, .. } = block {
233 let img = std::mem::replace(
234 image,
235 Inline::Text(String::new()), // placeholder, overwritten below
236 );
237 *block = Block::Paragraph(vec![img]);
238 return;
239 }
240 // Recurse into containers.
241 match block {
242 Block::BlockQuote(children) => {
243 for child in children.iter_mut() {
244 unwrap_implicit_figure(child);
245 }
246 }
247 Block::Callout { children, .. } => {
248 for child in children.iter_mut() {
249 unwrap_implicit_figure(child);
250 }
251 }
252 Block::List { items, .. } => {
253 for item in items.iter_mut() {
254 for child in item.iter_mut() {
255 unwrap_implicit_figure(child);
256 }
257 }
258 }
259 Block::LinkCard { children, .. } => {
260 for child in children.iter_mut() {
261 unwrap_implicit_figure(child);
262 }
263 }
264 _ => {}
265 }
266}
267
268/// Bundle of borrowed line-tracking state threaded through recursive
269/// parser entries. Constructed once per `parse_with_config` when
270/// `emit_source_lines` is true; `None` everywhere else.
271///
272/// `parse_block` / `parse_block_with_tag` consult `line_at_event` to
273/// annotate per-`<li>` and per-`<tr>` source lines. The outer
274/// top-level-block source line is computed at the parse loop itself
275/// (already in place), not here.
276struct LineCtx<'a> {
277 lookup: &'a LineLookup,
278 offsets: &'a [Option<std::ops::Range<usize>>],
279}
280
281impl<'a> LineCtx<'a> {
282 /// 1-based source line of the event at `event_index`, or `None` if
283 /// the offset is missing (defensive — shouldn't happen when the
284 /// parser is operating with `emit_source_lines: true`).
285 fn line_at_event(&self, event_index: usize) -> Option<usize> {
286 match self.offsets.get(event_index) {
287 Some(Some(range)) => Some(self.lookup.line_at(range.start)),
288 _ => None,
289 }
290 }
291}
292
293/// Prefix-sum line-number lookup for byte offsets in a source string.
294///
295/// Built once per parse (when `emit_source_lines` is on). Stores the byte
296/// offset of every `\n` in `source`; `line_at(offset)` returns the
297/// 1-based line number containing that offset via binary search.
298///
299/// Equivalent (slower) form: `source[..offset].matches('\n').count() + 1`
300/// — O(N) per call vs. O(log N) here. For documents with ~25 blocks the
301/// difference is negligible, but the binary-search form is the canonical
302/// pattern and is the cheaper hot-path shape.
303struct LineLookup {
304 /// Byte offsets of every `\n` in the source. Sorted ascending by
305 /// construction. `newline_offsets[i]` is the byte index of the i-th
306 /// newline (0-based).
307 newline_offsets: Vec<usize>,
308}
309
310impl LineLookup {
311 fn build(source: &str) -> Self {
312 let mut newline_offsets = Vec::new();
313 for (i, b) in source.bytes().enumerate() {
314 if b == b'\n' {
315 newline_offsets.push(i);
316 }
317 }
318 Self { newline_offsets }
319 }
320
321 /// 1-based line number containing `byte_offset`.
322 ///
323 /// Offset 0 (before any newline) → line 1. After the first newline →
324 /// line 2. Etc. Offsets past the end of the source clamp to the last
325 /// line + 1.
326 fn line_at(&self, byte_offset: usize) -> usize {
327 // Find the number of newlines strictly before `byte_offset`.
328 // That count + 1 is the 1-based line number.
329 match self.newline_offsets.binary_search(&byte_offset) {
330 // Exact match: offset IS a newline byte; the newline belongs
331 // to the line that ENDS at it, so line number = idx + 1.
332 // (The next byte starts line idx + 2; this matches the legacy
333 // count-and-add-1 semantics, which counts newlines BEFORE the
334 // offset.)
335 Ok(idx) => idx + 1,
336 Err(idx) => idx + 1,
337 }
338 }
339}
340
341/// Walk top-level blocks; replace any `Block::Other` whose payload is a
342/// `<!--MOSS_SC_{nonce}_{index}-->` sentinel with the corresponding typed
343/// [`Block::Shortcode`].
344fn substitute_shortcode_placeholders(
345 blocks: &mut Vec<Block>,
346 nonce: &str,
347 extracted: &[ExtractedShortcode],
348) {
349 for block in blocks.iter_mut() {
350 if let Block::Other(html) = block {
351 if let Some(index) = parse_placeholder(nonce, html) {
352 if let Some(entry) = extracted.iter().find(|e| e.index == index) {
353 *block = Block::Shortcode(entry.shortcode.clone());
354 }
355 }
356 }
357 // Future: descend into BlockQuote / List items / Callouts when
358 // shortcodes inside those constructs are modeled. Phase B Tasks
359 // 7-10 only need top-level shortcodes.
360 }
361}
362
363/// Parse one block-level construct starting at `events[start]`. Returns
364/// the parsed block (or `None` if `events[start]` was a closing tag /
365/// stray event we skip) and how many events to advance.
366///
367/// `line_ctx` carries the optional line-tracking context for per-item
368/// (`<li>`) and per-row (`<tr>`) source-line annotation; threaded through
369/// to `parse_block_with_tag`.
370fn parse_block(
371 events: &[Event<'_>],
372 start: usize,
373 line_ctx: Option<&LineCtx<'_>>,
374) -> (Option<Block>, usize) {
375 match &events[start] {
376 Event::Start(tag) => parse_block_with_tag(events, start, tag, line_ctx),
377 Event::Text(_) | Event::Code(_) | Event::Html(_) | Event::SoftBreak | Event::HardBreak => {
378 // Top-level stray inlines: pulldown-cmark always wraps these in
379 // `Tag::Paragraph` at top level, so this branch is dead in practice.
380 //
381 // The tight-list-item case where the inlines are emitted directly
382 // (no Tag::Paragraph wrap) was the load-bearing reason this branch
383 // looked relevant; PR0.6 moved that responsibility into
384 // `collect_item_blocks`, which synthesizes a Block::Paragraph for
385 // stray inlines inside Tag::Item. See parser.rs's collect_item_blocks
386 // helper.
387 (None, 1)
388 }
389 Event::End(_) => (None, 1),
390 Event::Rule => (Some(Block::ThematicBreak), 1),
391 _ => (None, 1),
392 }
393}
394
395fn parse_block_with_tag(
396 events: &[Event<'_>],
397 start: usize,
398 tag: &Tag<'_>,
399 line_ctx: Option<&LineCtx<'_>>,
400) -> (Option<Block>, usize) {
401 match tag {
402 Tag::Heading { level, .. } => {
403 let (children, end) = collect_inlines_until(events, start + 1, |e| {
404 matches!(e, Event::End(TagEnd::Heading(_)))
405 });
406 let level_num = match level {
407 HeadingLevel::H1 => 1,
408 HeadingLevel::H2 => 2,
409 HeadingLevel::H3 => 3,
410 HeadingLevel::H4 => 4,
411 HeadingLevel::H5 => 5,
412 HeadingLevel::H6 => 6,
413 };
414 // Phase 4 PR2: compute the heading-anchor base slug from the
415 // text/code content between Start(Heading) and End(Heading),
416 // matching production's transform_events behavior. Inline HTML
417 // (`<br>` etc.), images, and link href text are NOT included —
418 // only Event::Text and Event::Code. The post-parse
419 // `assign_heading_id_suffixes` pass disambiguates collisions.
420 let heading_text = collect_heading_text(events, start + 1, end);
421 let base_slug = obsidian_heading_anchor(&heading_text);
422 (
423 Some(Block::Heading {
424 level: level_num,
425 children,
426 id: Some(base_slug),
427 }),
428 end - start + 1,
429 )
430 }
431 Tag::Paragraph => {
432 let (children, end) = collect_inlines_until(events, start + 1, |e| {
433 matches!(e, Event::End(TagEnd::Paragraph))
434 });
435 // Phase 4 PR3 (2026-05-27): detect image-only paragraphs and
436 // promote to `Block::Figure`. See shape-spec § 1 detection
437 // rule: exactly one `Inline::Image` plus any number of
438 // whitespace-only `Inline::Text` / `Inline::LineBreak`
439 // siblings qualifies. Caption defaults to the image's alt
440 // text (mirroring transform_events' implicit-figure path);
441 // empty alt yields `caption: None` so no `<figcaption>` is
442 // emitted.
443 //
444 // A paragraph with image+prose (e.g. ` caption text`)
445 // does NOT qualify; it stays as `Block::Paragraph`. This is the
446 // critical regression guard — see PR1 v2 (commit 71c657af3)
447 // for the analogous shape decision at the inline image hook
448 // level: inline images use `MarkdownInline` (no figure wrap);
449 // only the standalone figure case here uses the figure wrap.
450 let block = match try_promote_to_figure(children) {
451 Ok(figure) => figure,
452 Err(original_inlines) => Block::Paragraph(original_inlines),
453 };
454 (Some(block), end - start + 1)
455 }
456 Tag::CodeBlock(kind) => {
457 let lang = match kind {
458 pulldown_cmark::CodeBlockKind::Fenced(s) if !s.is_empty() => Some(s.to_string()),
459 _ => None,
460 };
461 let mut value = String::new();
462 let mut i = start + 1;
463 while i < events.len() {
464 match &events[i] {
465 Event::End(TagEnd::CodeBlock) => break,
466 Event::Text(t) => value.push_str(t),
467 _ => {}
468 }
469 i += 1;
470 }
471 (Some(Block::CodeBlock { lang, value }), i - start + 1)
472 }
473 Tag::BlockQuote(_) => {
474 // Phase 4 PR4: detect Obsidian-style callouts. A blockquote
475 // whose first paragraph's leading text matches `[!<kind>]`
476 // (with optional `+`/`-` foldable suffix and optional
477 // inline title) promotes to `Block::Callout`. Otherwise it
478 // stays a plain blockquote. See shape-spec § 1.
479 //
480 // Detection works on the EVENT stream (not the parsed
481 // children) because pulldown-cmark's SoftBreak events
482 // become `Inline::Text("\n")` during inline parsing
483 // (PR4.5 aligned to CommonMark spec — see
484 // `parse_inline` SoftBreak handling). Working on events
485 // preserves the structural break before inline
486 // collapse, which is what the marker-line-vs-body-line
487 // boundary check needs.
488 match detect_and_assemble_callout(events, start + 1, line_ctx) {
489 Some((block, body_end)) => (Some(block), body_end - start + 1),
490 None => {
491 let (children, end) = collect_blocks_until(events, start + 1, line_ctx, |e| {
492 matches!(e, Event::End(TagEnd::BlockQuote(_)))
493 });
494 (Some(Block::BlockQuote(children)), end - start + 1)
495 }
496 }
497 }
498 Tag::List(start_num) => {
499 let ordered = start_num.is_some();
500 // Preserve explicit ordered-list start number when it's not
501 // the implicit default `1`. `3. foo` → `Some(3)` so the
502 // renderer can emit `<ol start="3">`. pulldown-cmark
503 // normalizes `1. foo` to `Some(1)`, which we collapse to
504 // `None` because `<ol>` and `<ol start="1">` are
505 // semantically identical and we prefer the cleaner attr-free
506 // shape for the common case. Bound name is `list_start` to
507 // avoid shadowing the outer `start: usize` event-index
508 // parameter.
509 let list_start = match start_num {
510 Some(n) if *n != 1 => Some(*n),
511 _ => None,
512 };
513 let mut items: Vec<Vec<Block>> = Vec::new();
514 // Parallel-to-`items` per-`<li>` source-line annotations.
515 // Empty when `line_ctx` is None; otherwise tracks each
516 // `Event::Start(Tag::Item)`'s byte offset → line. The renderer
517 // emits `<li data-source-line="N">` for entries that are Some.
518 let mut item_source_lines: Vec<Option<usize>> = Vec::new();
519 let track_lines = line_ctx.is_some();
520 let mut i = start + 1;
521 while i < events.len() {
522 match &events[i] {
523 Event::End(TagEnd::List(_)) => break,
524 Event::Start(Tag::Item) => {
525 if track_lines {
526 item_source_lines.push(line_ctx.and_then(|ctx| ctx.line_at_event(i)));
527 }
528 let (item_blocks, end) = collect_item_blocks(events, i + 1, line_ctx);
529 items.push(item_blocks);
530 i = end + 1;
531 }
532 _ => i += 1,
533 }
534 }
535 (
536 Some(Block::List {
537 ordered,
538 start: list_start,
539 items,
540 item_source_lines,
541 }),
542 i - start + 1,
543 )
544 }
545 Tag::Table(_) => {
546 let mut header: Vec<Vec<Inline>> = Vec::new();
547 let mut rows: Vec<Vec<Vec<Inline>>> = Vec::new();
548 // Per-`<tr>` source-line tracking. `header_source_line` is the
549 // `<thead><tr>` line; `row_source_lines` is parallel to `rows`.
550 // Both stay empty / None when `line_ctx` is None.
551 let mut header_source_line: Option<usize> = None;
552 let mut row_source_lines: Vec<Option<usize>> = Vec::new();
553 let track_lines = line_ctx.is_some();
554 let mut current_row: Vec<Vec<Inline>> = Vec::new();
555 let mut in_head = false;
556 let mut in_body_row = false;
557 let mut i = start + 1;
558 while i < events.len() {
559 match &events[i] {
560 Event::End(TagEnd::Table) => break,
561 Event::Start(Tag::TableHead) => {
562 in_head = true;
563 // pulldown-cmark does NOT emit `Tag::TableRow` for the
564 // header row — it goes straight from `Tag::TableHead`
565 // to the cells. So we anchor the header `<tr>` line
566 // to the `TableHead` event itself (line of the
567 // markdown `| h |` row).
568 if track_lines {
569 header_source_line = line_ctx.and_then(|ctx| ctx.line_at_event(i));
570 }
571 i += 1;
572 }
573 Event::End(TagEnd::TableHead) => {
574 in_head = false;
575 i += 1;
576 }
577 Event::Start(Tag::TableRow) => {
578 in_body_row = true;
579 current_row = Vec::new();
580 if track_lines {
581 // pulldown-cmark only emits `TableRow` for body
582 // rows (header cells live directly inside
583 // `TableHead`). Always push to body lines here.
584 row_source_lines.push(line_ctx.and_then(|ctx| ctx.line_at_event(i)));
585 }
586 i += 1;
587 }
588 Event::End(TagEnd::TableRow) => {
589 if in_body_row {
590 rows.push(std::mem::take(&mut current_row));
591 in_body_row = false;
592 }
593 i += 1;
594 }
595 Event::Start(Tag::TableCell) => {
596 let (cell_inlines, end) = collect_inlines_until(events, i + 1, |e| {
597 matches!(e, Event::End(TagEnd::TableCell))
598 });
599 if in_head {
600 header.push(cell_inlines);
601 } else {
602 current_row.push(cell_inlines);
603 }
604 i = end + 1;
605 }
606 _ => i += 1,
607 }
608 }
609 (
610 Some(Block::Table {
611 header,
612 rows,
613 header_source_line,
614 row_source_lines,
615 }),
616 i - start + 1,
617 )
618 }
619 Tag::HtmlBlock => {
620 let mut html = String::new();
621 let mut i = start + 1;
622 while i < events.len() {
623 match &events[i] {
624 Event::End(TagEnd::HtmlBlock) => break,
625 Event::Html(s) | Event::Text(s) => html.push_str(s),
626 _ => {}
627 }
628 i += 1;
629 }
630 (Some(Block::Other(html)), i - start + 1)
631 }
632 // Unmodeled containers: skip to End and emit nothing. The events
633 // inside are dropped — anything moss cares about should be modeled
634 // explicitly.
635 _ => (None, 1),
636 }
637}
638
639/// Decide whether a paragraph's inlines qualify for promotion to
640/// [`Block::Figure`]. Per shape-spec § 1: exactly one [`Inline::Image`]
641/// plus any number of whitespace-only [`Inline::Text`] /
642/// [`Inline::LineBreak`] siblings. Any other inline shape (Emphasis,
643/// Strong, Link, Code, non-whitespace Text, …) disqualifies the
644/// paragraph and it stays as [`Block::Paragraph`].
645///
646/// **Empty-alt guard:** if the matched image has an empty alt (decorative
647/// image), the paragraph is NOT promoted. This mirrors production's
648/// `transform_events` implicit-figure pass which gates on non-empty alt
649/// (a `<figure>` whose caption duplicates a missing alt would be useless
650/// for assistive tech and adds visual noise). The empty-alt image stays
651/// as `<p><img></p>`, matching the production byte shape for the same
652/// input — verified via the parity probe's `other` category on 刘果 CJK
653/// fixtures (image-only paragraphs with empty alt).
654///
655/// On qualification, returns `Ok(Block::Figure { image, caption })` with
656/// caption defaulting to the image's alt text (parsed as a single
657/// [`Inline::Text`] so the renderer's figcaption emission can escape it
658/// uniformly with other inline content).
659///
660/// On disqualification, returns `Err(original_inlines)` so the caller
661/// can fall back to constructing the standard `Block::Paragraph` without
662/// re-walking events.
663fn try_promote_to_figure(inlines: Vec<Inline>) -> Result<Block, Vec<Inline>> {
664 let mut image_count = 0;
665 for inline in &inlines {
666 match inline {
667 Inline::Image { .. } => image_count += 1,
668 Inline::Text(s) if s.trim().is_empty() => {} // whitespace OK
669 Inline::LineBreak => {} // line break OK
670 _ => return Err(inlines),
671 }
672 }
673 if image_count != 1 {
674 return Err(inlines);
675 }
676 // Empty-alt guard: refuse to promote so production-equivalent
677 // `<p><img></p>` output is preserved for decorative images.
678 let image_has_alt = inlines.iter().any(|i| match i {
679 Inline::Image { alt, .. } => !alt.trim().is_empty(),
680 _ => false,
681 });
682 if !image_has_alt {
683 return Err(inlines);
684 }
685 // Extract the single image; keep ownership of the original vec
686 // simple by re-walking with into_iter so we move out instead of
687 // cloning.
688 let mut image_owned: Option<Inline> = None;
689 for inline in inlines.into_iter() {
690 if matches!(inline, Inline::Image { .. }) {
691 image_owned = Some(inline);
692 break;
693 }
694 }
695 let image = image_owned.expect("invariant: image_count == 1 implies one Image present");
696 // Caption is always Some here (empty-alt was filtered above), but
697 // keep the Option<Vec<Inline>> shape per shape-spec § 1.
698 let caption = match &image {
699 Inline::Image { alt, .. } => Some(vec![Inline::Text(alt.clone())]),
700 _ => None,
701 };
702 // CommonMark `` promotion carries no pipe params — the
703 // figure-level display fields stay at their defaults so this path's
704 // rendered output is byte-identical to before the synth-collapse.
705 Ok(Block::Figure {
706 image,
707 caption,
708 width: None,
709 align: None,
710 class_names: Vec::new(),
711 img_style: None,
712 })
713}
714
715/// Collect a contiguous run of inline events into `Vec<Inline>`. Stops
716/// when `is_end(event)` returns true or events run out. Returns the
717/// collected inlines and the end-event index.
718fn collect_inlines_until<F>(events: &[Event<'_>], start: usize, is_end: F) -> (Vec<Inline>, usize)
719where
720 F: Fn(&Event<'_>) -> bool,
721{
722 let mut out: Vec<Inline> = Vec::new();
723 let mut i = start;
724 while i < events.len() {
725 if is_end(&events[i]) {
726 return (out, i);
727 }
728 let (inline, advance) = parse_inline(events, i);
729 if let Some(node) = inline {
730 out.push(node);
731 }
732 i += advance.max(1);
733 }
734 (out, i)
735}
736
737/// Parse one inline construct starting at `events[start]`.
738fn parse_inline(events: &[Event<'_>], start: usize) -> (Option<Inline>, usize) {
739 match &events[start] {
740 Event::Text(t) => (Some(Inline::Text(t.to_string())), 1),
741 Event::Code(c) => (Some(Inline::Code(c.to_string())), 1),
742 // Phase 4 PR4.5 (2026-05-28): match pulldown-cmark's `push_html`
743 // byte shape — SoftBreak emits `\n` between inline siblings, not a
744 // space. The space form was a long-standing AST quirk surfaced
745 // by Grid cells now flowing through the AST renderer; production
746 // baselines (chps-site, SoCiviC, snapshot fixtures) preserve the
747 // newline (e.g. `Flamboyan Theater · The Clemente\n107 Suffolk
748 // Street`). Aligning here closes one row of the parity probe's
749 // `whitespace_attribute_order` category.
750 Event::SoftBreak => (Some(Inline::Text("\n".to_string())), 1),
751 Event::HardBreak => (Some(Inline::LineBreak), 1),
752 Event::Html(s) | Event::InlineHtml(s) => (Some(Inline::Other(s.to_string())), 1),
753 Event::Start(tag) => match tag {
754 Tag::Emphasis => {
755 let (children, end) = collect_inlines_until(events, start + 1, |e| {
756 matches!(e, Event::End(TagEnd::Emphasis))
757 });
758 (Some(Inline::Emphasis(children)), end - start + 1)
759 }
760 Tag::Strong => {
761 let (children, end) = collect_inlines_until(events, start + 1, |e| {
762 matches!(e, Event::End(TagEnd::Strong))
763 });
764 (Some(Inline::Strong(children)), end - start + 1)
765 }
766 Tag::Link {
767 link_type,
768 dest_url,
769 title,
770 ..
771 } => {
772 let (children, end) = collect_inlines_until(events, start + 1, |e| {
773 matches!(e, Event::End(TagEnd::Link))
774 });
775 let title_opt = if title.is_empty() {
776 None
777 } else {
778 Some(title.to_string())
779 };
780 // Phase 4 PR7a (2026-05-28): preserve pulldown-cmark's
781 // `LinkType::WikiLink` discriminator on the typed AST so
782 // the renderer can emit `class="wikilink"` and graph
783 // builders can identify wikilink targets.
784 let is_wikilink = matches!(*link_type, pulldown_cmark::LinkType::WikiLink { .. });
785 (
786 Some(Inline::Link {
787 url: Url::unresolved(dest_url.to_string()),
788 title: title_opt,
789 children,
790 is_wikilink,
791 }),
792 end - start + 1,
793 )
794 }
795 Tag::Image {
796 link_type,
797 dest_url,
798 title,
799 ..
800 } => {
801 // Collect alt text from text events between Start/End.
802 let mut alt = String::new();
803 let mut i = start + 1;
804 while i < events.len() {
805 match &events[i] {
806 Event::End(TagEnd::Image) => break,
807 Event::Text(t) => alt.push_str(t),
808 Event::Code(c) => alt.push_str(c),
809 _ => {}
810 }
811 i += 1;
812 }
813 // PR3.5 (2026-05-28): for wikilink images (`![[file]]` /
814 // `![[file|pothole]]`), pulldown-cmark synthesizes text
815 // events that aren't always author-intended alt:
816 // - `![[logo.png]]` → text "logo.png" (synthesized from
817 // dest); production treats as empty alt.
818 // - `![[logo.png|contain center]]` → text "contain center"
819 // (display-attrs); production classifies as styling,
820 // NOT alt.
821 // - `![[logo.png|width=400]]` → text "width=400" (typed
822 // params); production classifies as params, NOT alt.
823 // - `![[logo.png|My caption]]` → text "My caption";
824 // genuine alt.
825 //
826 // Without this classification, PR3's Block::Figure
827 // detection (Wave 1) promotes wikilink-image paragraphs
828 // with synth-derived "alt" to Figure with bogus
829 // figcaptions ("logo.png", "contain center"). Match
830 // production's transform_events wikilink-dispatch by
831 // running the same classifiers (`is_all_display_keywords`
832 // + `parse_pothole_params`) here.
833 //
834 // PR7a-flip-core-B (2026-05-28): preserve the ORIGINAL
835 // pothole text on `Inline::Image.wikilink_pothole`
836 // BEFORE alt-classification consumes it.
837 // `dispatch_wikilink_embeds` needs the raw pothole to
838 // route `![[v.mp4|width=400]]` → typed video synth with
839 // the `width=400` param intact (alt-classification would
840 // erase it). The pothole is the substring after `|`;
841 // pulldown-cmark gives us the synthesized text, so we
842 // strip the dest synth case (text == dest_url ⇒ no
843 // pothole) and otherwise carry the trimmed alt.
844 let is_wikilink_image =
845 matches!(link_type, pulldown_cmark::LinkType::WikiLink { .. });
846 let wikilink_pothole: Option<String> = if is_wikilink_image {
847 let dest_str: &str = dest_url;
848 let trimmed = alt.trim();
849 if trimmed.is_empty() || trimmed == dest_str {
850 None
851 } else {
852 Some(trimmed.to_string())
853 }
854 } else {
855 None
856 };
857 if is_wikilink_image {
858 let dest_str: &str = dest_url;
859 let trimmed = alt.trim().to_string();
860 if trimmed.is_empty() || trimmed == dest_str {
861 // Empty pothole OR pulldown-cmark synthesized
862 // dest_url as text → no author alt.
863 alt.clear();
864 } else if crate::media::is_all_display_keywords(&trimmed) {
865 // `contain center`, `left top`, etc. → display
866 // attrs (production maps to style), not alt.
867 alt.clear();
868 } else {
869 use crate::resolve::wikilink_dispatch::{
870 parse_pothole_params, PotholeContent,
871 };
872 match parse_pothole_params(&trimmed) {
873 PotholeContent::Empty | PotholeContent::Params(_) => {
874 alt.clear();
875 }
876 PotholeContent::WidthToken { rest_alias, .. } => {
877 alt = rest_alias;
878 }
879 PotholeContent::Alias(text) => {
880 alt = text;
881 }
882 }
883 }
884 }
885 let title_opt = if title.is_empty() {
886 None
887 } else {
888 Some(title.to_string())
889 };
890 (
891 Some(Inline::Image {
892 src: Url::unresolved(dest_url.to_string()),
893 alt,
894 title: title_opt,
895 is_wikilink: is_wikilink_image,
896 wikilink_pothole,
897 }),
898 i - start + 1,
899 )
900 }
901 // Unmodeled inline container: skip to its End.
902 _ => (None, 1),
903 },
904 // End / unhandled — caller handles.
905 _ => (None, 1),
906 }
907}
908
909/// Collect a contiguous run of block events into `Vec<Block>`. Stops when
910/// `is_end(event)` returns true or events run out.
911fn collect_blocks_until<F>(
912 events: &[Event<'_>],
913 start: usize,
914 line_ctx: Option<&LineCtx<'_>>,
915 is_end: F,
916) -> (Vec<Block>, usize)
917where
918 F: Fn(&Event<'_>) -> bool,
919{
920 let mut out: Vec<Block> = Vec::new();
921 let mut i = start;
922 while i < events.len() {
923 if is_end(&events[i]) {
924 return (out, i);
925 }
926 let (block, advance) = parse_block(events, i, line_ctx);
927 if let Some(b) = block {
928 out.push(b);
929 }
930 i += advance.max(1);
931 }
932 (out, i)
933}
934
935/// Collect the children of a `Tag::Item` until the matching `End(Item)`.
936///
937/// Pulldown-cmark's **tight-list** mode emits item contents as inline
938/// events (Text/Code/SoftBreak/inline-tag Start...) DIRECTLY inside
939/// `Tag::Item` without wrapping in `Tag::Paragraph`. The plain
940/// [`collect_blocks_until`] dispatcher would route those events through
941/// [`parse_block`], which drops stray inlines — yielding empty `<li></li>`.
942///
943/// This helper preserves both modes:
944/// - Inline events accumulate into a synthesized [`Block::Paragraph`] that
945/// is flushed when a block-level event (Tag::Paragraph, Tag::List,
946/// nested Tag::Item, etc.) appears or at the end of the item.
947/// - Block-level events are parsed via [`parse_block_with_tag`] (the
948/// standard path).
949///
950/// The renderer recognises a single-paragraph item shape and emits
951/// `<li>...inline...</li>` without an inner `<p>`, matching production's
952/// tight-list output byte-for-byte.
953fn collect_item_blocks(
954 events: &[Event<'_>],
955 start: usize,
956 line_ctx: Option<&LineCtx<'_>>,
957) -> (Vec<Block>, usize) {
958 let mut out: Vec<Block> = Vec::new();
959 let mut pending_inlines: Vec<Inline> = Vec::new();
960 let mut i = start;
961 while i < events.len() {
962 if matches!(&events[i], Event::End(TagEnd::Item)) {
963 flush_pending_paragraph(&mut out, &mut pending_inlines);
964 return (out, i);
965 }
966 if let Some((inline, advance)) = parse_inline_event(events, i) {
967 if let Some(node) = inline {
968 pending_inlines.push(node);
969 }
970 i += advance.max(1);
971 continue;
972 }
973 // Block-level event: flush any accumulated inlines, then parse
974 // through the standard dispatcher.
975 flush_pending_paragraph(&mut out, &mut pending_inlines);
976 let (block, advance) = parse_block(events, i, line_ctx);
977 if let Some(b) = block {
978 out.push(b);
979 }
980 i += advance.max(1);
981 }
982 flush_pending_paragraph(&mut out, &mut pending_inlines);
983 (out, i)
984}
985
986/// Phase 4 PR4: detect a callout marker inside a blockquote and, if
987/// found, assemble the entire `Block::Callout` (with body blocks).
988///
989/// `start` is the event index AFTER `Start(BlockQuote)`. Returns
990/// `Some((Block::Callout, end_index))` where `end_index` is the event
991/// index of the matching `End(TagEnd::BlockQuote(_))`, so the outer
992/// caller can compute the advance. Returns `None` for plain
993/// blockquotes (no `[!type]` marker on the first paragraph).
994///
995/// Detection rule (shape-spec § 1):
996/// - The first event must be `Start(Tag::Paragraph)`.
997/// - The leading `Event::Text` run (before the first `SoftBreak` or
998/// any non-Text inline event) must match `[!<kind>]`, optionally
999/// followed by `+` or `-` for foldable callouts, optionally followed
1000/// by space + inline title.
1001/// - The kind is canonicalized via [`CalloutKind::from_raw`]; unknown
1002/// kinds fall back to [`CalloutKind::Note`]. (Diagnostic threading
1003/// is a Phase 4 followup — `validation::Diagnostic` is scoped to
1004/// frontmatter validation today.)
1005///
1006/// Why detection runs on events (not parsed children): the inline
1007/// parser collapses `SoftBreak` events into `Inline::Text` (in PR4.5,
1008/// emitting `"\n"` to match pulldown-cmark's `push_html`), which makes
1009/// the marker-line vs body-line boundary an embedded `\n` rather than a
1010/// distinct AST node. Working at the event layer preserves the
1011/// SoftBreak boundary so we can split "title" (before SoftBreak) from
1012/// "body" (after SoftBreak) correctly.
1013fn detect_and_assemble_callout(
1014 events: &[Event<'_>],
1015 start: usize,
1016 line_ctx: Option<&LineCtx<'_>>,
1017) -> Option<(Block, usize)> {
1018 if !matches!(events.get(start), Some(Event::Start(Tag::Paragraph))) {
1019 return None;
1020 }
1021 // Coalesce the leading run of `Event::Text` into one logical
1022 // string. Stops at SoftBreak, HardBreak, any Start/End tag, or
1023 // any non-Text inline.
1024 let mut leading = String::new();
1025 let mut i = start + 1;
1026 while let Some(event) = events.get(i) {
1027 match event {
1028 Event::Text(t) => {
1029 leading.push_str(t);
1030 i += 1;
1031 }
1032 _ => break,
1033 }
1034 }
1035 if leading.is_empty() {
1036 return None;
1037 }
1038
1039 let (raw_kind, fold, title, _marker_byte_len) = parse_callout_marker(&leading)?;
1040 let kind = CalloutKind::from_raw(raw_kind).unwrap_or(CalloutKind::Note);
1041 let title: Option<String> = title.map(|s| s.to_string()).filter(|s| !s.is_empty());
1042
1043 // We've consumed the leading Text events. `i` now points at the
1044 // first non-Text event in the (still-open) marker paragraph.
1045 //
1046 // Three shapes from here:
1047 // (A) SoftBreak / HardBreak → body lines continue in the same
1048 // Paragraph. Skip the break, then collect inlines until
1049 // End(Paragraph). Wrap them in a synthetic Block::Paragraph.
1050 // (B) End(Paragraph) immediately → marker-only callout (no body
1051 // in the marker paragraph). Skip End(Paragraph).
1052 // (C) Another inline event (Start(Emphasis), Code, etc.) → the
1053 // marker was actually followed by inline markup on the same
1054 // line. Currently treated as title continuation — but we
1055 // lack a clean event-level coalescer for inline tags, so we
1056 // just collect remaining inlines and wrap them as a body
1057 // paragraph. The author can use a separator paragraph for
1058 // clarity if they want clean title isolation.
1059 let mut body_blocks: Vec<Block> = Vec::new();
1060 let body_paragraph_start: Option<usize> = match events.get(i) {
1061 Some(Event::SoftBreak) | Some(Event::HardBreak) => {
1062 // Skip the break; collect remaining inlines for the body
1063 // paragraph.
1064 Some(i + 1)
1065 }
1066 Some(Event::End(TagEnd::Paragraph)) => {
1067 // Marker was the entire paragraph. Skip past End.
1068 i += 1;
1069 None
1070 }
1071 _ => {
1072 // Other inline events directly following the marker —
1073 // collect them as body paragraph content. (Edge case;
1074 // see method comment.)
1075 Some(i)
1076 }
1077 };
1078
1079 if let Some(body_start) = body_paragraph_start {
1080 // Collect inlines until End(Paragraph) and synthesize a
1081 // Block::Paragraph for the marker-paragraph body content.
1082 let (body_inlines, after_para) = collect_inlines_until(events, body_start, |e| {
1083 matches!(e, Event::End(TagEnd::Paragraph))
1084 });
1085 // Skip past End(Paragraph) itself.
1086 i = after_para + 1;
1087 // Trim leading whitespace-only Text inlines (e.g. if the
1088 // line-break Text(" ") leaks through).
1089 let trimmed_empty = body_inlines.iter().all(|x| match x {
1090 Inline::Text(t) => t.trim().is_empty(),
1091 _ => false,
1092 });
1093 if !trimmed_empty {
1094 body_blocks.push(Block::Paragraph(body_inlines));
1095 }
1096 }
1097
1098 // Continue collecting subsequent blocks until End(BlockQuote).
1099 while let Some(event) = events.get(i) {
1100 if matches!(event, Event::End(TagEnd::BlockQuote(_))) {
1101 break;
1102 }
1103 let (block, advance) = parse_block(events, i, line_ctx);
1104 if let Some(b) = block {
1105 body_blocks.push(b);
1106 }
1107 i += advance.max(1);
1108 }
1109
1110 // `i` now points at `End(BlockQuote)`. Return total event
1111 // span: outer caller computes `i - start + 1` (where `start` here
1112 // is the pre-Start-BlockQuote index in the outer scope; but we
1113 // were called with `start = outer_start + 1`, so the outer
1114 // caller's `start` correctly indexes the opening `Start(BlockQuote)`).
1115 // Per the call shape in `parse_block_with_tag` Tag::BlockQuote arm:
1116 // `match detect_and_assemble_callout(events, start + 1)`
1117 // `Some((block, body_end)) => (Some(block), body_end - start + 1)`
1118 // we must return `body_end = i` (the `End(BlockQuote)` index).
1119 let block = Block::Callout {
1120 kind,
1121 fold,
1122 title,
1123 children: body_blocks,
1124 };
1125 Some((block, i))
1126}
1127
1128/// Parse the leading text of a callout-shaped paragraph.
1129///
1130/// Accepts text shaped like `[!kind] title text…`, `[!kind]+ title`,
1131/// `[!kind]-`, etc. Returns:
1132/// - `raw_kind` — the kind identifier verbatim (lowercased on
1133/// canonicalization, not here).
1134/// - `fold` — `Some(Fold::Open)` for `+`, `Some(Fold::Closed)` for `-`,
1135/// `None` otherwise.
1136/// - `title` — `Some(title_text)` when text follows the marker (space
1137/// separator consumed); `None` when the marker is the entire string.
1138/// Title may be empty (`""`) if author wrote `[!note] ` with trailing
1139/// whitespace only — caller treats empty as None.
1140/// - `marker_byte_len` — number of bytes from the start of `text` that
1141/// constituted the marker + the single separator space (if any). The
1142/// caller slices `&text[marker_byte_len..]` to recover trailing body
1143/// text that should stay in the paragraph (multi-line callouts where
1144/// pulldown-cmark concatenated lines).
1145fn parse_callout_marker(text: &str) -> Option<(&str, Option<Fold>, Option<&str>, usize)> {
1146 let after_open = text.strip_prefix("[!")?;
1147 let close_offset = after_open.find(']')?;
1148 let raw_kind = &after_open[..close_offset];
1149 if raw_kind.is_empty() || raw_kind.chars().any(|c| c.is_whitespace()) {
1150 return None;
1151 }
1152 // Offset within `text` immediately after the `]`.
1153 let after_bracket_offset = 2 + close_offset + 1;
1154 let rest = &text[after_bracket_offset..];
1155
1156 let (fold, after_fold_offset) = match rest.chars().next() {
1157 Some('+') => (Some(Fold::Open), after_bracket_offset + 1),
1158 Some('-') => (Some(Fold::Closed), after_bracket_offset + 1),
1159 _ => (None, after_bracket_offset),
1160 };
1161
1162 let rest_after_fold = &text[after_fold_offset..];
1163 let (title, marker_byte_len) = if rest_after_fold.is_empty() {
1164 // Marker only, no title segment.
1165 (None, after_fold_offset)
1166 } else if let Some(remainder) = rest_after_fold.strip_prefix(' ') {
1167 // ` title text…` — title is everything in this coalesced
1168 // leading-text string. Pulldown-cmark splits line breaks into
1169 // SoftBreak inlines, so this Text inline never contains
1170 // newlines; the title is bounded by the next non-Text inline.
1171 let title_str = remainder;
1172 let consumed = after_fold_offset + 1 + remainder.len();
1173 (Some(title_str), consumed)
1174 } else {
1175 // No separator after marker but more text follows (e.g.
1176 // `[!note]+body` with no space). Treat as no title; keep the
1177 // text intact.
1178 (None, after_fold_offset)
1179 };
1180
1181 Some((raw_kind, fold, title, marker_byte_len))
1182}
1183
1184/// If `events[i]` is an inline-level event, parse it via the existing
1185/// [`parse_inline`] machinery and return `(inline, advance)`. Returns
1186/// `None` for block-level events, end tags, or anything the inline
1187/// dispatcher doesn't own — letting the caller fall back to the block
1188/// path.
1189fn parse_inline_event(events: &[Event<'_>], i: usize) -> Option<(Option<Inline>, usize)> {
1190 match &events[i] {
1191 Event::Text(_)
1192 | Event::Code(_)
1193 | Event::Html(_)
1194 | Event::InlineHtml(_)
1195 | Event::SoftBreak
1196 | Event::HardBreak => Some(parse_inline(events, i)),
1197 Event::Start(tag) => match tag {
1198 Tag::Emphasis | Tag::Strong | Tag::Link { .. } | Tag::Image { .. } => {
1199 Some(parse_inline(events, i))
1200 }
1201 _ => None,
1202 },
1203 _ => None,
1204 }
1205}
1206
1207/// Drain `pending_inlines` into a [`Block::Paragraph`] appended to `out`,
1208/// unless it's empty. No-op when there are no pending inlines.
1209fn flush_pending_paragraph(out: &mut Vec<Block>, pending_inlines: &mut Vec<Inline>) {
1210 if !pending_inlines.is_empty() {
1211 out.push(Block::Paragraph(std::mem::take(pending_inlines)));
1212 }
1213}
1214
1215/// Collect the text content of a heading by walking events between
1216/// `start..end` (exclusive of the matching `Event::End(TagEnd::Heading)`)
1217/// and concatenating every `Event::Text` and `Event::Code` payload.
1218///
1219/// Mirrors production's `transform_events` heading-text collection at
1220/// `src-tauri/src/build/markdown/pipeline.rs:1784-1795`. Inline HTML
1221/// (`Event::InlineHtml` / `Event::Html`) is intentionally skipped so that
1222/// e.g. `# FAREWELL,<br>AND ERASE` yields the slug for
1223/// `FAREWELL,AND ERASE` (no `<br>` in the slug). Soft/hard breaks are
1224/// skipped — production only captures Text + Code. Image alt text and
1225/// link href text are NOT included; the events inside `Tag::Link` /
1226/// `Tag::Image` are walked transparently and their `Event::Text`
1227/// payloads (the link/image label) ARE captured, matching production.
1228fn collect_heading_text(events: &[Event<'_>], start: usize, end: usize) -> String {
1229 let mut text = String::new();
1230 for i in start..end {
1231 match &events[i] {
1232 Event::Text(t) => text.push_str(t),
1233 Event::Code(c) => text.push_str(c),
1234 _ => {}
1235 }
1236 }
1237 text
1238}
1239
1240/// Post-parse pass: walk every heading in document order (recursively
1241/// descending into BlockQuote, List items, and Callout children) and
1242/// disambiguate duplicate IDs by appending `-1`, `-2`, … to the slug.
1243///
1244/// Mirrors the `id_counts: HashMap<String, usize>` behavior at
1245/// `src-tauri/src/build/markdown/pipeline.rs:1798-1805`:
1246///
1247/// - First occurrence of slug `foo` keeps id `foo`; counter starts at 1.
1248/// - Second occurrence becomes `foo-1`; counter becomes 2.
1249/// - Third occurrence becomes `foo-2`; counter becomes 3.
1250///
1251/// Headings whose base slug is `None` (shouldn't happen post-PR2, but
1252/// safe-guarded) are left untouched.
1253fn assign_heading_id_suffixes(blocks: &mut [Block]) {
1254 let mut id_counts: HashMap<String, usize> = HashMap::new();
1255 assign_heading_id_suffixes_walk(blocks, &mut id_counts);
1256}
1257
1258fn assign_heading_id_suffixes_walk(blocks: &mut [Block], id_counts: &mut HashMap<String, usize>) {
1259 for block in blocks.iter_mut() {
1260 match block {
1261 Block::Heading { id, .. } => {
1262 if let Some(slug) = id {
1263 let count_entry = id_counts.entry(slug.clone()).or_insert(0);
1264 let count = *count_entry;
1265 if count > 0 {
1266 *id = Some(format!("{}-{}", slug, count));
1267 }
1268 *count_entry = count + 1;
1269 }
1270 }
1271 Block::BlockQuote(children) | Block::Callout { children, .. } => {
1272 assign_heading_id_suffixes_walk(children, id_counts);
1273 }
1274 Block::List { items, .. } => {
1275 for item in items.iter_mut() {
1276 assign_heading_id_suffixes_walk(item, id_counts);
1277 }
1278 }
1279 // Tables/CodeBlocks/Shortcodes/Paragraphs/ThematicBreak/Other
1280 // cannot contain block-level headings — nothing to descend
1281 // into. Shortcode bodies (Hero overlay, Grid cells) currently
1282 // carry their content as String (pre-PR4.5); once promoted to
1283 // Vec<Block>, this walker will need to descend there too.
1284 _ => {}
1285 }
1286 }
1287}
1288
1289#[cfg(test)]
1290mod tests {
1291 use super::super::node::{CalloutKind, Fold, Inline};
1292 use super::*;
1293
1294 fn first_block(md: &str) -> Block {
1295 parse(md)
1296 .blocks
1297 .into_iter()
1298 .next()
1299 .expect("at least one block")
1300 }
1301
1302 // -----------------------------------------------------------------
1303 // Phase 4 PR4: Block::Callout migration + Obsidian alias canonicalization
1304 // -----------------------------------------------------------------
1305
1306 #[test]
1307 fn parses_basic_callout_with_inline_title() {
1308 match first_block("> [!note] Heads up\n> Body line 1.\n") {
1309 Block::Callout {
1310 kind,
1311 fold,
1312 title,
1313 children,
1314 } => {
1315 assert_eq!(kind, CalloutKind::Note);
1316 assert!(fold.is_none(), "non-foldable callout");
1317 assert_eq!(title.as_deref(), Some("Heads up"));
1318 assert!(!children.is_empty(), "body should remain");
1319 }
1320 other => panic!("expected Callout, got {other:?}"),
1321 }
1322 }
1323
1324 #[test]
1325 fn parses_titleless_callout() {
1326 match first_block("> [!warning]\n> Watch out.\n") {
1327 Block::Callout {
1328 kind,
1329 fold,
1330 title,
1331 children,
1332 } => {
1333 assert_eq!(kind, CalloutKind::Warning);
1334 assert!(fold.is_none());
1335 assert!(title.is_none(), "no inline title");
1336 assert!(!children.is_empty());
1337 }
1338 other => panic!("expected Callout, got {other:?}"),
1339 }
1340 }
1341
1342 #[test]
1343 fn callout_alias_tldr_canonicalizes_to_abstract() {
1344 match first_block("> [!tldr] Short summary\n> body\n") {
1345 Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Abstract),
1346 other => panic!("expected Callout, got {other:?}"),
1347 }
1348 }
1349
1350 #[test]
1351 fn callout_alias_hint_canonicalizes_to_tip() {
1352 match first_block("> [!hint] Pro tip\n> body\n") {
1353 Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Tip),
1354 other => panic!("expected Callout, got {other:?}"),
1355 }
1356 }
1357
1358 #[test]
1359 fn callout_alias_important_canonicalizes_to_tip() {
1360 match first_block("> [!important] Read this\n> body\n") {
1361 Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Tip),
1362 other => panic!("expected Callout, got {other:?}"),
1363 }
1364 }
1365
1366 #[test]
1367 fn callout_alias_check_done_canonicalizes_to_success() {
1368 for alias in &["check", "done"] {
1369 let md = format!("> [!{alias}] Yes\n> body\n");
1370 match first_block(&md) {
1371 Block::Callout { kind, .. } => assert_eq!(
1372 kind,
1373 CalloutKind::Success,
1374 "alias `{alias}` should canonicalize to Success"
1375 ),
1376 other => panic!("alias `{alias}` — expected Callout, got {other:?}"),
1377 }
1378 }
1379 }
1380
1381 #[test]
1382 fn callout_alias_help_faq_canonicalizes_to_question() {
1383 for alias in &["help", "faq"] {
1384 let md = format!("> [!{alias}] question\n> body\n");
1385 match first_block(&md) {
1386 Block::Callout { kind, .. } => assert_eq!(
1387 kind,
1388 CalloutKind::Question,
1389 "alias `{alias}` should canonicalize to Question"
1390 ),
1391 other => panic!("alias `{alias}` — expected Callout, got {other:?}"),
1392 }
1393 }
1394 }
1395
1396 #[test]
1397 fn callout_alias_caution_attention_canonicalizes_to_warning() {
1398 for alias in &["caution", "attention"] {
1399 let md = format!("> [!{alias}] careful\n> body\n");
1400 match first_block(&md) {
1401 Block::Callout { kind, .. } => assert_eq!(
1402 kind,
1403 CalloutKind::Warning,
1404 "alias `{alias}` should canonicalize to Warning"
1405 ),
1406 other => panic!("alias `{alias}` — expected Callout, got {other:?}"),
1407 }
1408 }
1409 }
1410
1411 #[test]
1412 fn callout_alias_fail_missing_canonicalizes_to_failure() {
1413 for alias in &["fail", "missing"] {
1414 let md = format!("> [!{alias}] oops\n> body\n");
1415 match first_block(&md) {
1416 Block::Callout { kind, .. } => assert_eq!(
1417 kind,
1418 CalloutKind::Failure,
1419 "alias `{alias}` should canonicalize to Failure"
1420 ),
1421 other => panic!("alias `{alias}` — expected Callout, got {other:?}"),
1422 }
1423 }
1424 }
1425
1426 #[test]
1427 fn callout_alias_error_canonicalizes_to_danger() {
1428 match first_block("> [!error] bad\n> body\n") {
1429 Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Danger),
1430 other => panic!("expected Callout, got {other:?}"),
1431 }
1432 }
1433
1434 #[test]
1435 fn callout_alias_cite_canonicalizes_to_quote() {
1436 match first_block("> [!cite] source\n> body\n") {
1437 Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Quote),
1438 other => panic!("expected Callout, got {other:?}"),
1439 }
1440 }
1441
1442 #[test]
1443 fn callout_foldable_open_suffix() {
1444 match first_block("> [!note]+ Open by default\n> body\n") {
1445 Block::Callout {
1446 kind, fold, title, ..
1447 } => {
1448 assert_eq!(kind, CalloutKind::Note);
1449 assert_eq!(fold, Some(Fold::Open));
1450 assert_eq!(title.as_deref(), Some("Open by default"));
1451 }
1452 other => panic!("expected Callout, got {other:?}"),
1453 }
1454 }
1455
1456 #[test]
1457 fn callout_foldable_closed_suffix() {
1458 match first_block("> [!note]- Closed by default\n> body\n") {
1459 Block::Callout {
1460 kind, fold, title, ..
1461 } => {
1462 assert_eq!(kind, CalloutKind::Note);
1463 assert_eq!(fold, Some(Fold::Closed));
1464 assert_eq!(title.as_deref(), Some("Closed by default"));
1465 }
1466 other => panic!("expected Callout, got {other:?}"),
1467 }
1468 }
1469
1470 #[test]
1471 fn callout_foldable_without_title() {
1472 match first_block("> [!tip]+\n> body\n") {
1473 Block::Callout {
1474 kind, fold, title, ..
1475 } => {
1476 assert_eq!(kind, CalloutKind::Tip);
1477 assert_eq!(fold, Some(Fold::Open));
1478 assert!(title.is_none());
1479 }
1480 other => panic!("expected Callout, got {other:?}"),
1481 }
1482 }
1483
1484 #[test]
1485 fn callout_unknown_kind_falls_back_to_note() {
1486 // Per shape-spec § 1 — unknown kind canonicalizes to Note.
1487 // Diagnostic emission is a Phase 4 followup (see parser.rs
1488 // `promote_callout` comment).
1489 match first_block("> [!unknownkind] body\n") {
1490 Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Note),
1491 other => panic!("expected Callout (fallback to Note), got {other:?}"),
1492 }
1493 }
1494
1495 #[test]
1496 fn callout_multi_paragraph_body_preserves_blocks() {
1497 let md = "> [!info] Multi\n> First paragraph.\n>\n> Second paragraph.\n";
1498 match first_block(md) {
1499 Block::Callout {
1500 kind,
1501 title,
1502 children,
1503 ..
1504 } => {
1505 assert_eq!(kind, CalloutKind::Info);
1506 assert_eq!(title.as_deref(), Some("Multi"));
1507 // pulldown-cmark emits two paragraphs in the blockquote
1508 // body when separated by an empty `>` line.
1509 let para_count = children
1510 .iter()
1511 .filter(|b| matches!(b, Block::Paragraph(_)))
1512 .count();
1513 assert!(
1514 para_count >= 2,
1515 "expected at least 2 paragraphs, got {children:?}"
1516 );
1517 }
1518 other => panic!("expected Callout, got {other:?}"),
1519 }
1520 }
1521
1522 #[test]
1523 fn callout_nested_inside_callout() {
1524 // The docs promise nested callouts. After PR4 the outer is
1525 // Block::Callout containing an inner Block::Callout in its
1526 // children (no Stage 1 rewrite needed).
1527 let md = "> [!warning] Outer\n> Outer content.\n>\n> > [!tip] Inner\n> > Inner content.\n";
1528 match first_block(md) {
1529 Block::Callout {
1530 kind: outer_kind,
1531 children,
1532 ..
1533 } => {
1534 assert_eq!(outer_kind, CalloutKind::Warning);
1535 let inner = children.iter().find_map(|b| match b {
1536 Block::Callout { kind, title, .. } => Some((*kind, title.clone())),
1537 _ => None,
1538 });
1539 let (inner_kind, inner_title) =
1540 inner.expect("inner Block::Callout missing from outer's children");
1541 assert_eq!(inner_kind, CalloutKind::Tip);
1542 assert_eq!(inner_title.as_deref(), Some("Inner"));
1543 }
1544 other => panic!("expected outer Callout, got {other:?}"),
1545 }
1546 }
1547
1548 #[test]
1549 fn plain_blockquote_without_marker_stays_blockquote() {
1550 // Regression: an ordinary blockquote (no `[!type]` marker) must
1551 // remain Block::BlockQuote — only callout-shaped blockquotes
1552 // promote.
1553 match first_block("> Just a quote.\n> More of the quote.\n") {
1554 Block::BlockQuote(_) => {} // expected
1555 other => panic!("expected BlockQuote, got {other:?}"),
1556 }
1557 }
1558
1559 #[test]
1560 fn blockquote_with_text_starting_like_callout_but_unknown_kind_still_promotes() {
1561 // The marker `[!xyz]` is structurally a callout — we promote
1562 // and fall back to Note (per shape-spec). The author can fix
1563 // by removing the bracket prefix if they wanted a plain quote.
1564 match first_block("> [!xyz] not a real kind\n> body\n") {
1565 Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Note),
1566 other => panic!("expected Callout fallback, got {other:?}"),
1567 }
1568 }
1569
1570 #[test]
1571 fn callout_case_insensitive_kind() {
1572 // Stage 1 was case-insensitive; preserve that contract.
1573 match first_block("> [!WARNING] Loud\n> body\n") {
1574 Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Warning),
1575 other => panic!("expected Callout, got {other:?}"),
1576 }
1577 }
1578
1579 #[test]
1580 fn callout_pending_alias_canonicalizes_to_todo() {
1581 // SoCiviC Theatre's voices.md uses `> [!pending]` — carried
1582 // over from Stage 1 support.
1583 match first_block("> [!pending] Trailer video\n> Add when ready.\n") {
1584 Block::Callout { kind, title, .. } => {
1585 assert_eq!(kind, CalloutKind::Todo);
1586 assert_eq!(title.as_deref(), Some("Trailer video"));
1587 }
1588 other => panic!("expected Callout, got {other:?}"),
1589 }
1590 }
1591
1592 #[test]
1593 fn empty_input_yields_empty_document() {
1594 let d = parse("");
1595 assert!(d.blocks.is_empty());
1596 }
1597
1598 #[test]
1599 fn parses_h1_heading() {
1600 match first_block("# Hello\n") {
1601 Block::Heading {
1602 level,
1603 children,
1604 id,
1605 } => {
1606 assert_eq!(level, 1);
1607 // Phase 4 PR2: parser populates id with the Obsidian anchor slug.
1608 assert_eq!(id.as_deref(), Some("hello"));
1609 assert!(matches!(&children[0], Inline::Text(t) if t == "Hello"));
1610 }
1611 other => panic!("expected Heading, got {other:?}"),
1612 }
1613 }
1614
1615 #[test]
1616 fn parses_h6_heading() {
1617 match first_block("###### tiny\n") {
1618 Block::Heading { level, .. } => assert_eq!(level, 6),
1619 other => panic!("expected Heading, got {other:?}"),
1620 }
1621 }
1622
1623 #[test]
1624 fn parses_paragraph_with_text() {
1625 match first_block("hello world\n") {
1626 Block::Paragraph(children) => {
1627 // pulldown-cmark may split into multiple Text events; merge.
1628 let s: String = children
1629 .iter()
1630 .filter_map(|i| match i {
1631 Inline::Text(t) => Some(t.as_str()),
1632 _ => None,
1633 })
1634 .collect();
1635 assert_eq!(s, "hello world");
1636 }
1637 other => panic!("expected Paragraph, got {other:?}"),
1638 }
1639 }
1640
1641 #[test]
1642 fn parses_link_with_unresolved_url() {
1643 // Critical contract: every URL starts as Unresolved.
1644 match first_block("[Docs](docs/)\n") {
1645 Block::Paragraph(children) => match &children[0] {
1646 Inline::Link {
1647 url,
1648 title,
1649 children,
1650 is_wikilink,
1651 } => {
1652 assert!(url.is_unresolved());
1653 match url {
1654 Url::Unresolved(s) => assert_eq!(s, "docs/"),
1655 _ => unreachable!(),
1656 }
1657 assert!(title.is_none());
1658 assert!(!is_wikilink, "standard markdown link is not a wikilink");
1659 assert!(matches!(&children[0], Inline::Text(t) if t == "Docs"));
1660 }
1661 other => panic!("expected Link, got {other:?}"),
1662 },
1663 other => panic!("expected Paragraph, got {other:?}"),
1664 }
1665 }
1666
1667 #[test]
1668 fn parses_link_with_moss_resolved_prefix_unchanged() {
1669 // The upstream resolve pipeline emits this shape; the parser must
1670 // preserve it verbatim for the visitor to classify later.
1671 match first_block("[t](moss-resolved:foo.md)\n") {
1672 Block::Paragraph(children) => match &children[0] {
1673 Inline::Link {
1674 url: Url::Unresolved(s),
1675 ..
1676 } => assert_eq!(s, "moss-resolved:foo.md"),
1677 other => panic!("expected unresolved Link, got {other:?}"),
1678 },
1679 other => panic!("expected Paragraph, got {other:?}"),
1680 }
1681 }
1682
1683 #[test]
1684 fn parser_link_inherits_wikilink_from_pulldown_cmark() {
1685 // PR7a Decision 2: pulldown-cmark with ENABLE_WIKILINKS emits
1686 // `Tag::Link { link_type: LinkType::WikiLink, .. }` for `[[target]]`
1687 // syntax. The typed AST must preserve that discriminator via
1688 // `Inline::Link::is_wikilink`. After PR7a flips render_document
1689 // to production, this flag drives the `class="wikilink"` emission
1690 // on the <a> tag.
1691 match first_block("[[wikilink-target]]\n") {
1692 Block::Paragraph(children) => {
1693 let link = children
1694 .iter()
1695 .find(|i| matches!(i, Inline::Link { .. }))
1696 .expect("expected an Inline::Link from [[…]]");
1697 match link {
1698 Inline::Link { is_wikilink, .. } => {
1699 assert!(
1700 *is_wikilink,
1701 "[[…]] must set is_wikilink: true on the typed AST"
1702 );
1703 }
1704 _ => unreachable!(),
1705 }
1706 }
1707 other => panic!("expected Paragraph, got {other:?}"),
1708 }
1709
1710 // Negative case: a standard markdown link is NOT a wikilink.
1711 match first_block("[text](href)\n") {
1712 Block::Paragraph(children) => match &children[0] {
1713 Inline::Link { is_wikilink, .. } => {
1714 assert!(!is_wikilink, "[](…) must set is_wikilink: false");
1715 }
1716 _ => panic!("expected Link"),
1717 },
1718 _ => panic!("expected Paragraph"),
1719 }
1720 }
1721
1722 #[test]
1723 fn parses_link_with_title() {
1724 match first_block(r#"[t](u "the title")"#) {
1725 Block::Paragraph(children) => match &children[0] {
1726 Inline::Link { title, .. } => assert_eq!(title.as_deref(), Some("the title")),
1727 other => panic!("expected Link, got {other:?}"),
1728 },
1729 other => panic!("expected Paragraph, got {other:?}"),
1730 }
1731 }
1732
1733 #[test]
1734 fn parses_image_with_alt() {
1735 // Phase 4 PR3 (2026-05-27): an image-only paragraph is now
1736 // promoted to Block::Figure. Inline::Image lives inside the
1737 // Figure variant; the URL/alt/title contract is unchanged.
1738 // For image+text (where Block::Paragraph still applies), see
1739 // `image_with_caption_text_does_not_promote` below.
1740 match first_block("\n") {
1741 Block::Figure { image, caption, .. } => {
1742 match image {
1743 Inline::Image {
1744 src, alt, title, ..
1745 } => {
1746 assert!(src.is_unresolved());
1747 assert_eq!(alt, "cat photo");
1748 assert!(title.is_none());
1749 }
1750 other => panic!("expected Image inside Figure, got {other:?}"),
1751 }
1752 let cap = caption.expect("caption from alt text");
1753 assert_eq!(cap.len(), 1);
1754 }
1755 other => panic!("expected Figure, got {other:?}"),
1756 }
1757 }
1758
1759 #[test]
1760 fn parses_image_inside_paragraph_with_text() {
1761 // Companion to `parses_image_with_alt`: an image with sibling
1762 // prose stays as Block::Paragraph (no figure promotion). Holds
1763 // the parser's image-extraction contract for the non-figure case.
1764 match first_block("see  here\n") {
1765 Block::Paragraph(children) => {
1766 let img = children
1767 .iter()
1768 .find(|i| matches!(i, Inline::Image { .. }))
1769 .expect("expected Inline::Image among siblings");
1770 match img {
1771 Inline::Image { src, alt, .. } => {
1772 assert!(src.is_unresolved());
1773 assert_eq!(alt, "cat photo");
1774 }
1775 _ => unreachable!(),
1776 }
1777 }
1778 other => panic!("expected Paragraph, got {other:?}"),
1779 }
1780 }
1781
1782 #[test]
1783 fn parses_emphasis_and_strong() {
1784 let para = parse("*em* and **strong**\n")
1785 .blocks
1786 .into_iter()
1787 .next()
1788 .unwrap();
1789 match para {
1790 Block::Paragraph(children) => {
1791 let has_em = children.iter().any(|i| matches!(i, Inline::Emphasis(_)));
1792 let has_strong = children.iter().any(|i| matches!(i, Inline::Strong(_)));
1793 assert!(has_em, "missing Emphasis: {children:?}");
1794 assert!(has_strong, "missing Strong: {children:?}");
1795 }
1796 _ => panic!("expected Paragraph"),
1797 }
1798 }
1799
1800 #[test]
1801 fn parses_inline_code() {
1802 match first_block("`some code`\n") {
1803 Block::Paragraph(children) => {
1804 assert!(matches!(&children[0], Inline::Code(c) if c == "some code"));
1805 }
1806 other => panic!("expected Paragraph, got {other:?}"),
1807 }
1808 }
1809
1810 #[test]
1811 fn parses_unordered_list() {
1812 match first_block("- one\n- two\n") {
1813 Block::List { ordered, items, .. } => {
1814 assert!(!ordered);
1815 assert_eq!(items.len(), 2);
1816 }
1817 other => panic!("expected List, got {other:?}"),
1818 }
1819 }
1820
1821 #[test]
1822 fn parser_handles_tight_list_items_with_inline_content() {
1823 // Phase 4 PR0.6 regression — pulldown-cmark's tight-list mode emits
1824 // inline events (Text/Strong/etc.) directly inside Tag::Item without
1825 // wrapping in Tag::Paragraph. Previously `parse_block` dropped these
1826 // stray inlines, producing empty <li></li> instead of the expected
1827 // <li><strong>bold</strong> text</li>.
1828 match first_block("- **bold** text\n- another item\n") {
1829 Block::List { ordered, items, .. } => {
1830 assert!(!ordered);
1831 assert_eq!(items.len(), 2, "expected two items, got {items:?}");
1832 let first_item = &items[0];
1833 assert_eq!(
1834 first_item.len(),
1835 1,
1836 "tight item should synthesize a single Paragraph, got {first_item:?}"
1837 );
1838 match &first_item[0] {
1839 Block::Paragraph(inlines) => {
1840 let has_strong = inlines.iter().any(|i| matches!(i, Inline::Strong(_)));
1841 let has_text = inlines
1842 .iter()
1843 .any(|i| matches!(i, Inline::Text(t) if t.contains("text")));
1844 assert!(
1845 has_strong,
1846 "expected Inline::Strong inside item, got {inlines:?}"
1847 );
1848 assert!(has_text, "expected ' text' Inline::Text, got {inlines:?}");
1849 }
1850 other => panic!("expected Paragraph inside tight item, got {other:?}"),
1851 }
1852 }
1853 other => panic!("expected List, got {other:?}"),
1854 }
1855 }
1856
1857 #[test]
1858 fn tight_list_items_with_links_preserved() {
1859 // Mirrors folder-note-site/obsidian/index.md — wikilinks + images
1860 // inside list items. Today these parse as Inline::Link / Inline::Image;
1861 // the contract is just that the inline content is NOT dropped.
1862 match first_block("- [link](url)\n- \n") {
1863 Block::List { items, .. } => {
1864 assert_eq!(items.len(), 2);
1865 let first = &items[0];
1866 assert_eq!(
1867 first.len(),
1868 1,
1869 "expected one Block::Paragraph, got {first:?}"
1870 );
1871 match &first[0] {
1872 Block::Paragraph(inlines) => {
1873 assert!(
1874 inlines.iter().any(|i| matches!(i, Inline::Link { .. })),
1875 "expected Inline::Link, got {inlines:?}"
1876 );
1877 }
1878 other => panic!("expected Paragraph, got {other:?}"),
1879 }
1880 let second = &items[1];
1881 match &second[0] {
1882 Block::Paragraph(inlines) => {
1883 assert!(
1884 inlines.iter().any(|i| matches!(i, Inline::Image { .. })),
1885 "expected Inline::Image, got {inlines:?}"
1886 );
1887 }
1888 other => panic!("expected Paragraph, got {other:?}"),
1889 }
1890 }
1891 other => panic!("expected List, got {other:?}"),
1892 }
1893 }
1894
1895 #[test]
1896 fn loose_list_items_with_paragraphs_still_work() {
1897 // Loose-list mode (blank lines between items) emits items as
1898 // Tag::Paragraph-wrapped blocks. The fix must not break this path.
1899 let md = "- first item\n\n- second item\n";
1900 match first_block(md) {
1901 Block::List { items, .. } => {
1902 assert_eq!(items.len(), 2);
1903 for item in &items {
1904 assert_eq!(item.len(), 1, "expected one block per item");
1905 assert!(
1906 matches!(&item[0], Block::Paragraph(_)),
1907 "expected Paragraph, got {:?}",
1908 item[0]
1909 );
1910 }
1911 }
1912 other => panic!("expected List, got {other:?}"),
1913 }
1914 }
1915
1916 #[test]
1917 fn tight_list_items_with_nested_list_preserve_structure() {
1918 // - first
1919 // - nested
1920 // The outer item carries inline "first" + a nested Block::List.
1921 let md = "- first\n - nested\n";
1922 match first_block(md) {
1923 Block::List { items, .. } => {
1924 assert_eq!(items.len(), 1);
1925 let outer = &items[0];
1926 assert!(
1927 outer.iter().any(|b| matches!(b, Block::Paragraph(_))),
1928 "expected outer item to carry a Paragraph for 'first', got {outer:?}"
1929 );
1930 assert!(
1931 outer.iter().any(|b| matches!(b, Block::List { .. })),
1932 "expected outer item to carry a nested List, got {outer:?}"
1933 );
1934 }
1935 other => panic!("expected List, got {other:?}"),
1936 }
1937 }
1938
1939 #[test]
1940 fn parses_ordered_list() {
1941 match first_block("1. first\n2. second\n") {
1942 Block::List { ordered, items, .. } => {
1943 assert!(ordered);
1944 assert_eq!(items.len(), 2);
1945 }
1946 other => panic!("expected List, got {other:?}"),
1947 }
1948 }
1949
1950 #[test]
1951 fn parses_fenced_code_block_with_lang() {
1952 match first_block("```rust\nfn main() {}\n```\n") {
1953 Block::CodeBlock { lang, value } => {
1954 assert_eq!(lang.as_deref(), Some("rust"));
1955 assert!(value.contains("fn main"));
1956 }
1957 other => panic!("expected CodeBlock, got {other:?}"),
1958 }
1959 }
1960
1961 #[test]
1962 fn parses_fenced_code_block_without_lang() {
1963 match first_block("```\nbare\n```\n") {
1964 Block::CodeBlock { lang, value } => {
1965 assert!(lang.is_none());
1966 assert!(value.contains("bare"));
1967 }
1968 other => panic!("expected CodeBlock, got {other:?}"),
1969 }
1970 }
1971
1972 #[test]
1973 fn code_block_is_not_parsed_as_shortcode() {
1974 // Adversarial: the literal `:::buttons` inside a fenced code block
1975 // must NOT be treated as a shortcode. (Phase A's parser doesn't
1976 // recognize :::buttons at all yet; this test locks the contract.)
1977 let md = "```\n:::buttons\n[t](u)\n:::\n```\n";
1978 match first_block(md) {
1979 Block::CodeBlock { value, .. } => assert!(value.contains(":::buttons")),
1980 other => panic!("expected CodeBlock, got {other:?}"),
1981 }
1982 }
1983
1984 #[test]
1985 fn parses_blockquote() {
1986 match first_block("> quoted\n") {
1987 Block::BlockQuote(children) => {
1988 assert!(!children.is_empty());
1989 }
1990 other => panic!("expected BlockQuote, got {other:?}"),
1991 }
1992 }
1993
1994 #[test]
1995 fn parses_thematic_break() {
1996 match first_block("---\n") {
1997 Block::ThematicBreak => {}
1998 // Pulldown-cmark may emit a thematic break or treat `---` at the
1999 // start of a doc as a heading underline. Accept either by
2000 // checking that the parse produces SOMETHING.
2001 _other => {
2002 // Test the unambiguous mid-doc case.
2003 let d = parse("para\n\n---\n\nmore\n");
2004 let has_break = d.blocks.iter().any(|b| matches!(b, Block::ThematicBreak));
2005 assert!(
2006 has_break,
2007 "expected at least one ThematicBreak: {:?}",
2008 d.blocks
2009 );
2010 }
2011 }
2012 }
2013
2014 #[test]
2015 fn parses_table() {
2016 let md = "| h1 | h2 |\n| --- | --- |\n| a | b |\n| c | d |\n";
2017 match first_block(md) {
2018 Block::Table { header, rows, .. } => {
2019 assert_eq!(header.len(), 2);
2020 assert_eq!(rows.len(), 2);
2021 assert_eq!(rows[0].len(), 2);
2022 }
2023 other => panic!("expected Table, got {other:?}"),
2024 }
2025 }
2026
2027 #[test]
2028 fn html_block_passes_through_as_other() {
2029 match first_block("<div class=\"raw\">hi</div>\n\n") {
2030 Block::Other(html) => assert!(html.contains("<div")),
2031 other => panic!("expected Other, got {other:?}"),
2032 }
2033 }
2034
2035 #[test]
2036 fn parses_multiple_blocks() {
2037 let d = parse("# T\n\npara\n\n- li\n");
2038 assert_eq!(d.blocks.len(), 3);
2039 assert!(matches!(d.blocks[0], Block::Heading { .. }));
2040 assert!(matches!(d.blocks[1], Block::Paragraph(_)));
2041 assert!(matches!(d.blocks[2], Block::List { .. }));
2042 }
2043
2044 #[test]
2045 fn frontmatter_only_input_is_handled() {
2046 // Frontmatter is stripped by upstream code before reaching the
2047 // parser. If somehow a `---\nfoo:bar\n---` reaches us, the parser
2048 // must not panic.
2049 let _ = parse("---\nfoo: bar\n---\n");
2050 }
2051
2052 #[test]
2053 fn link_inside_heading_is_preserved() {
2054 match first_block("# [t](u)\n") {
2055 Block::Heading { children, .. } => {
2056 assert!(matches!(&children[0], Inline::Link { .. }));
2057 }
2058 other => panic!("expected Heading, got {other:?}"),
2059 }
2060 }
2061
2062 // -----------------------------------------------------------------
2063 // Phase 4 PR2: heading ID injection
2064 // -----------------------------------------------------------------
2065
2066 fn heading_id(md: &str) -> Option<String> {
2067 let blocks = parse(md).blocks;
2068 for block in &blocks {
2069 if let Block::Heading { id, .. } = block {
2070 return id.clone();
2071 }
2072 }
2073 None
2074 }
2075
2076 #[test]
2077 fn heading_id_simple_phrase() {
2078 // SoCiviC `## Mission` baseline case.
2079 assert_eq!(heading_id("## Mission\n"), Some("mission".to_string()));
2080 }
2081
2082 #[test]
2083 fn heading_id_spaces_become_hyphens() {
2084 assert_eq!(
2085 heading_id("# Getting Started\n"),
2086 Some("getting-started".to_string())
2087 );
2088 }
2089
2090 #[test]
2091 fn heading_id_with_emphasis_uses_text_content() {
2092 // `*em*` inside a heading: the inner text is `em`, no surrounding
2093 // chars come from emphasis itself (production captures only Text/Code).
2094 assert_eq!(
2095 heading_id("# Hello *world*\n"),
2096 Some("hello-world".to_string())
2097 );
2098 }
2099
2100 #[test]
2101 fn heading_id_with_strong_uses_text_content() {
2102 assert_eq!(
2103 heading_id("# Bold **stuff**\n"),
2104 Some("bold-stuff".to_string())
2105 );
2106 }
2107
2108 #[test]
2109 fn heading_id_with_inline_link_uses_link_text() {
2110 // `# [Docs](url)` — the link label "Docs" comes through as Event::Text.
2111 assert_eq!(heading_id("# [Docs](url)\n"), Some("docs".to_string()));
2112 }
2113
2114 #[test]
2115 fn heading_id_with_inline_code_includes_code_payload() {
2116 // Production captures Event::Code, so `` `fn(x)` `` enters the slug.
2117 assert_eq!(
2118 heading_id("# call `fn(x)`\n"),
2119 Some("call-fn(x)".to_string())
2120 );
2121 }
2122
2123 #[test]
2124 fn heading_id_with_inline_html_strips_html() {
2125 // SoCiviC `# FAREWELL,<br>AND ERASE` — the `<br>` is Event::InlineHtml
2126 // and must NOT appear in the slug. Production's slug for this is
2127 // derived from "FAREWELL,AND ERASE".
2128 let id = heading_id("# FAREWELL,<br>AND ERASE\n").expect("heading id");
2129 // No `<br>` or `br` injected; punctuation preserved (`,`), spaces → `-`.
2130 assert!(!id.contains("br"), "got: {id}");
2131 assert_eq!(id, "farewell,and-erase");
2132 }
2133
2134 #[test]
2135 fn heading_id_cjk_preserved() {
2136 // 刘果's CJK headings exercise Unicode anchor normalization —
2137 // characters pass through unchanged (lowercase already, no whitespace).
2138 assert_eq!(heading_id("## 视频\n"), Some("视频".to_string()));
2139 assert_eq!(heading_id("## 中文标题\n"), Some("中文标题".to_string()));
2140 }
2141
2142 #[test]
2143 fn heading_id_obsidian_strip_chars() {
2144 // Pipes / brackets / hashes / backslashes / carets are stripped.
2145 assert_eq!(heading_id("# Note ^ref\n"), Some("note-ref".to_string()));
2146 assert_eq!(heading_id("# A | B\n"), Some("a-b".to_string()));
2147 }
2148
2149 #[test]
2150 fn duplicate_headings_get_suffixed_ids() {
2151 // Production behavior: first occurrence keeps slug; second gets `-1`,
2152 // third gets `-2`. The HashMap in pipeline.rs:1798 is the contract.
2153 let md = "# Mission\n\n# Mission\n\n# Mission\n";
2154 let doc = parse(md);
2155 let ids: Vec<Option<String>> = doc
2156 .blocks
2157 .iter()
2158 .filter_map(|b| match b {
2159 Block::Heading { id, .. } => Some(id.clone()),
2160 _ => None,
2161 })
2162 .collect();
2163 assert_eq!(
2164 ids,
2165 vec![
2166 Some("mission".to_string()),
2167 Some("mission-1".to_string()),
2168 Some("mission-2".to_string()),
2169 ]
2170 );
2171 }
2172
2173 #[test]
2174 fn duplicate_suffix_descends_into_blockquote() {
2175 // Headings inside a blockquote share the same id-counter as top-level.
2176 let md = "# Notes\n\n> # Notes\n";
2177 let doc = parse(md);
2178 let mut found_ids: Vec<String> = Vec::new();
2179 collect_heading_ids_recursive(&doc.blocks, &mut found_ids);
2180 assert_eq!(found_ids, vec!["notes".to_string(), "notes-1".to_string()]);
2181 }
2182
2183 fn collect_heading_ids_recursive(blocks: &[Block], out: &mut Vec<String>) {
2184 for b in blocks {
2185 match b {
2186 Block::Heading { id, .. } => {
2187 if let Some(s) = id {
2188 out.push(s.clone());
2189 }
2190 }
2191 Block::BlockQuote(children) | Block::Callout { children, .. } => {
2192 collect_heading_ids_recursive(children, out);
2193 }
2194 Block::List { items, .. } => {
2195 for item in items {
2196 collect_heading_ids_recursive(item, out);
2197 }
2198 }
2199 _ => {}
2200 }
2201 }
2202 }
2203
2204 #[test]
2205 fn heading_id_empty_text_yields_empty_slug() {
2206 // Edge case: `# ###` strips to empty slug; suffix counter still ticks.
2207 // (obsidian_heading_anchor("") == "")
2208 let md = "# ###\n";
2209 let id = heading_id(md);
2210 assert_eq!(id, Some(String::new()));
2211 }
2212
2213 #[test]
2214 fn link_inside_emphasis_unwraps_correctly() {
2215 // *[link](u)* — emphasis wrapping a link is a real authoring pattern.
2216 match first_block("*[t](u)*\n") {
2217 Block::Paragraph(children) => match &children[0] {
2218 Inline::Emphasis(inner) => {
2219 assert!(matches!(&inner[0], Inline::Link { .. }));
2220 }
2221 other => panic!("expected Emphasis, got {other:?}"),
2222 },
2223 other => panic!("expected Paragraph, got {other:?}"),
2224 }
2225 }
2226
2227 // -----------------------------------------------------------------
2228 // Phase 4 PR3 (2026-05-27): Block::Figure detection in Tag::Paragraph
2229 // -----------------------------------------------------------------
2230
2231 #[test]
2232 fn image_only_paragraph_promotes_to_figure() {
2233 // Canonical case: a paragraph containing exactly one image, no
2234 // sibling inline content, becomes Block::Figure. Caption defaults
2235 // to the image's alt text.
2236 match first_block("\n") {
2237 Block::Figure { image, caption, .. } => {
2238 match image {
2239 Inline::Image { src, alt, .. } => {
2240 assert!(src.is_unresolved());
2241 assert_eq!(alt, "A logo");
2242 }
2243 other => panic!("expected Image inside Figure, got {other:?}"),
2244 }
2245 let cap = caption.expect("caption from alt text");
2246 assert_eq!(cap.len(), 1);
2247 assert!(matches!(&cap[0], Inline::Text(t) if t == "A logo"));
2248 }
2249 other => panic!("expected Figure, got {other:?}"),
2250 }
2251 }
2252
2253 #[test]
2254 fn image_only_paragraph_with_empty_alt_stays_as_paragraph() {
2255 // Empty-alt guard: a decorative image (no alt) does NOT promote
2256 // to Figure. Production's implicit-figure pass gates on
2257 // non-empty alt — wrapping a no-alt image in `<figure>` adds
2258 // visual noise (no figcaption text) without a11y benefit. The
2259 // bytes match production's `<p><img></p>` shape.
2260 //
2261 // Parity-probe evidence: pre-guard, 7 CJK 刘果 fixtures with
2262 // trailing empty-alt images flipped to "other" because the AST
2263 // emitted `<figure>` and prod did not. Guard restores parity.
2264 match first_block("\n") {
2265 Block::Paragraph(children) => {
2266 assert_eq!(children.len(), 1);
2267 match &children[0] {
2268 Inline::Image { alt, .. } => assert_eq!(alt, ""),
2269 other => panic!("expected Image inside Paragraph, got {other:?}"),
2270 }
2271 }
2272 other => panic!("empty-alt image-only paragraph must stay as Paragraph, got {other:?}"),
2273 }
2274 }
2275
2276 #[test]
2277 fn image_with_whitespace_text_still_promotes_to_figure() {
2278 // Whitespace-only text or line-break siblings don't disqualify
2279 // (matches transform_events' "image-only modulo whitespace"
2280 // behavior). Verifying via a wikilink + trailing whitespace would
2281 // require an actual whitespace event; pulldown-cmark typically
2282 // strips this. The detector is defensive for the cases that
2283 // DO surface whitespace inlines (line breaks after the image).
2284 let md = " \n";
2285 // The trailing " \n" inside a paragraph emits a HardBreak event
2286 // (Inline::LineBreak). Promotion must still succeed.
2287 match first_block(md) {
2288 Block::Figure { image, .. } => assert!(matches!(image, Inline::Image { .. })),
2289 // pulldown-cmark may also collapse this differently; accept
2290 // Paragraph(LineBreak) as a tolerated fallback so the test is
2291 // not over-specified on pulldown-cmark whitespace semantics.
2292 // The critical regression we want to lock is that genuine
2293 // image+text mixes DON'T promote (covered by the test below).
2294 Block::Paragraph(_) => {}
2295 other => panic!("expected Figure or Paragraph, got {other:?}"),
2296 }
2297 }
2298
2299 #[test]
2300 fn image_with_caption_text_does_not_promote() {
2301 // Critical regression guard (cf. PR1 v2 commit 71c657af3): a
2302 // paragraph carrying image + prose / emphasis must NOT be
2303 // promoted to a figure. If we promoted, the caption text would
2304 // be lost and we'd produce a malformed figure with sibling
2305 // content swallowed.
2306 match first_block(" plain caption text\n") {
2307 Block::Paragraph(children) => {
2308 assert!(children.iter().any(|i| matches!(i, Inline::Image { .. })));
2309 assert!(
2310 children
2311 .iter()
2312 .any(|i| matches!(i, Inline::Text(t) if t.contains("plain"))),
2313 "expected sibling Text to remain, got {children:?}"
2314 );
2315 }
2316 other => panic!("expected Paragraph, got {other:?}"),
2317 }
2318 }
2319
2320 #[test]
2321 fn image_with_emphasis_sibling_does_not_promote() {
2322 // Pandoc-style "image + emphasis caption" is recognized in the
2323 // legacy transform_events as a captioned figure, but PR3's
2324 // simplified detection (one Image, no other content modulo
2325 // whitespace) leaves these as Paragraph. PR0's parity probe
2326 // already classifies these under image_emission / image_figures
2327 // depending on production behavior; PR3 owns ONLY the simple
2328 // image-only case. The downstream image+emphasis case is closed
2329 // out at PR7a when production flips.
2330 match first_block(" *caption*\n") {
2331 Block::Paragraph(children) => {
2332 assert!(children.iter().any(|i| matches!(i, Inline::Image { .. })));
2333 assert!(
2334 children.iter().any(|i| matches!(i, Inline::Emphasis(_))),
2335 "expected Emphasis to remain, got {children:?}"
2336 );
2337 }
2338 other => panic!("expected Paragraph, got {other:?}"),
2339 }
2340 }
2341
2342 #[test]
2343 fn two_images_in_one_paragraph_do_not_promote() {
2344 // Detection rule requires EXACTLY one image. Two images stay as
2345 // a paragraph (no figure wrap chosen — production would also
2346 // not wrap this in a figure).
2347 match first_block(" \n") {
2348 Block::Paragraph(children) => {
2349 let img_count = children
2350 .iter()
2351 .filter(|i| matches!(i, Inline::Image { .. }))
2352 .count();
2353 assert_eq!(img_count, 2);
2354 }
2355 other => panic!("expected Paragraph (two images), got {other:?}"),
2356 }
2357 }
2358
2359 #[test]
2360 fn plain_paragraph_still_parses_as_paragraph() {
2361 // No regression: a normal text paragraph stays as Block::Paragraph.
2362 match first_block("just some prose\n") {
2363 Block::Paragraph(_) => {}
2364 other => panic!("expected Paragraph, got {other:?}"),
2365 }
2366 }
2367
2368 // -----------------------------------------------------------------
2369 // Phase B Task 7: :::subscribe end-to-end
2370 // -----------------------------------------------------------------
2371
2372 use super::super::shortcode::Shortcode;
2373
2374 #[test]
2375 fn parses_subscribe_block_into_typed_shortcode() {
2376 let md = r#":::subscribe {placeholder="you@domain.com" button="Sign me up"}
2377:::
2378"#;
2379 let doc = parse(md);
2380 // Should find one Block::Shortcode(Subscribe) at top level.
2381 let mut found: Option<&Shortcode> = None;
2382 for block in &doc.blocks {
2383 if let Block::Shortcode(sc) = block {
2384 found = Some(sc);
2385 break;
2386 }
2387 }
2388 let sc = found.expect("expected Block::Shortcode");
2389 match sc {
2390 Shortcode::Subscribe(args) => {
2391 assert_eq!(args.placeholder.as_deref(), Some("you@domain.com"));
2392 assert_eq!(args.button.as_deref(), Some("Sign me up"));
2393 }
2394 other => panic!("expected Subscribe, got {other:?}"),
2395 }
2396 }
2397
2398 #[test]
2399 fn subscribe_block_does_not_leave_sentinel_in_other_block() {
2400 let md = ":::subscribe\n:::\n";
2401 let doc = parse(md);
2402 // No Block::Other should contain the sentinel string.
2403 for block in &doc.blocks {
2404 if let Block::Other(html) = block {
2405 assert!(
2406 !html.contains("MOSS_SHORTCODE"),
2407 "unsubstituted sentinel remained in AST: {html:?}"
2408 );
2409 }
2410 }
2411 }
2412
2413 #[test]
2414 fn subscribe_inside_paragraph_text_is_not_extracted() {
2415 // Adversarial: `:::subscribe` appearing inside running prose
2416 // (not as a block opener on its own line) is not a shortcode.
2417 // The extractor only matches when `:::name` is on its own line.
2418 let md = "Read more about :::subscribe in the docs.\n";
2419 let doc = parse(md);
2420 for block in &doc.blocks {
2421 assert!(
2422 !matches!(block, Block::Shortcode(_)),
2423 "`:::subscribe` inline-text was wrongly extracted as a shortcode"
2424 );
2425 }
2426 }
2427
2428 #[test]
2429 fn subscribe_block_alongside_other_content_preserves_order() {
2430 let md = "# H\n\nfirst para\n\n:::subscribe\ndescription: d\n:::\n\nlast para\n";
2431 let doc = parse(md);
2432 let kinds: Vec<&'static str> = doc
2433 .blocks
2434 .iter()
2435 .map(|b| match b {
2436 Block::Heading { .. } => "h",
2437 Block::Paragraph(_) => "p",
2438 Block::Shortcode(_) => "sc",
2439 _ => "x",
2440 })
2441 .collect();
2442 assert_eq!(kinds, vec!["h", "p", "sc", "p"]);
2443 }
2444
2445 // -----------------------------------------------------------------
2446 // 2026-05-28 (Phase 4 source-line wiring): ParseConfig threading
2447 // -----------------------------------------------------------------
2448
2449 #[test]
2450 fn parse_default_config_keeps_block_meta_empty() {
2451 let doc = parse("# H1\n\npara one\n\npara two\n");
2452 assert_eq!(doc.blocks.len(), 3);
2453 assert_eq!(doc.block_meta.len(), doc.blocks.len());
2454 for meta in &doc.block_meta {
2455 assert!(
2456 meta.source_line.is_none(),
2457 "default parse should not populate source_line: {meta:?}"
2458 );
2459 }
2460 }
2461
2462 #[test]
2463 fn parse_with_source_lines_assigns_1_based_line_numbers() {
2464 let md = "# H1\n\npara on line 3\n\n## H2 on line 5\n\npara on line 7\n";
2465 let config = ParseConfig {
2466 emit_source_lines: true,
2467 implicit_figure: true,
2468 };
2469 let doc = parse_with_config(md, &config);
2470 // Expected blocks: H1, P, H2, P (4 blocks).
2471 assert_eq!(doc.blocks.len(), 4);
2472 assert_eq!(doc.block_meta.len(), 4);
2473 // Line numbers should track the markdown source.
2474 assert_eq!(doc.block_meta[0].source_line, Some(1), "H1 on line 1");
2475 assert_eq!(doc.block_meta[1].source_line, Some(3), "P on line 3");
2476 assert_eq!(doc.block_meta[2].source_line, Some(5), "H2 on line 5");
2477 assert_eq!(doc.block_meta[3].source_line, Some(7), "P on line 7");
2478 }
2479
2480 #[test]
2481 fn parse_with_source_lines_lists_and_blockquotes() {
2482 let md = "- item one\n- item two\n\n> quote on line 4\n";
2483 let config = ParseConfig {
2484 emit_source_lines: true,
2485 implicit_figure: true,
2486 };
2487 let doc = parse_with_config(md, &config);
2488 assert_eq!(doc.blocks.len(), 2);
2489 assert_eq!(doc.block_meta[0].source_line, Some(1), "ul on line 1");
2490 assert_eq!(doc.block_meta[1].source_line, Some(4), "bq on line 4");
2491 }
2492
2493 // -----------------------------------------------------------------
2494 // 2026-05-28 (Phase 4 source-line followup): per-<li> + per-<tr>
2495 // line tracking on Block::List and Block::Table.
2496 // -----------------------------------------------------------------
2497
2498 #[test]
2499 fn parse_with_source_lines_populates_item_lines_on_list() {
2500 // Multi-item list spanning consecutive source lines; the parser
2501 // must capture the 1-based line of each `Tag::Item` start.
2502 let md = "- one\n- two\n- three\n";
2503 let config = ParseConfig {
2504 emit_source_lines: true,
2505 implicit_figure: true,
2506 };
2507 let doc = parse_with_config(md, &config);
2508 assert_eq!(doc.blocks.len(), 1);
2509 match &doc.blocks[0] {
2510 Block::List {
2511 items,
2512 item_source_lines,
2513 ..
2514 } => {
2515 assert_eq!(items.len(), 3);
2516 assert_eq!(
2517 item_source_lines.len(),
2518 3,
2519 "item_source_lines must be parallel to items"
2520 );
2521 assert_eq!(item_source_lines[0], Some(1));
2522 assert_eq!(item_source_lines[1], Some(2));
2523 assert_eq!(item_source_lines[2], Some(3));
2524 }
2525 other => panic!("expected List, got {other:?}"),
2526 }
2527 }
2528
2529 #[test]
2530 fn parse_default_config_leaves_item_source_lines_empty() {
2531 // Production publish builds (default config — `emit_source_lines:
2532 // false`) must NOT populate `item_source_lines`. The renderer
2533 // treats empty as "no annotations" so the published HTML is
2534 // byte-identical to the pre-followup output.
2535 let doc = parse("- one\n- two\n");
2536 assert_eq!(doc.blocks.len(), 1);
2537 match &doc.blocks[0] {
2538 Block::List {
2539 item_source_lines, ..
2540 } => {
2541 assert!(
2542 item_source_lines.is_empty(),
2543 "default config must NOT populate item_source_lines (publish builds): {item_source_lines:?}"
2544 );
2545 }
2546 other => panic!("expected List, got {other:?}"),
2547 }
2548 }
2549
2550 #[test]
2551 fn parse_with_source_lines_populates_row_lines_on_table() {
2552 // Multi-row table: header on line 1, separator on line 2, body
2553 // rows on lines 3, 4, 5. The parser must capture the 1-based
2554 // line of each `Tag::TableRow` start.
2555 let md = "| h1 | h2 |\n| --- | --- |\n| a | b |\n| c | d |\n| e | f |\n";
2556 let config = ParseConfig {
2557 emit_source_lines: true,
2558 implicit_figure: true,
2559 };
2560 let doc = parse_with_config(md, &config);
2561 assert_eq!(doc.blocks.len(), 1);
2562 match &doc.blocks[0] {
2563 Block::Table {
2564 rows,
2565 header_source_line,
2566 row_source_lines,
2567 ..
2568 } => {
2569 assert_eq!(rows.len(), 3);
2570 // The header tr anchors at the markdown header row (line 1).
2571 assert_eq!(*header_source_line, Some(1), "header tr line");
2572 assert_eq!(
2573 row_source_lines.len(),
2574 3,
2575 "row_source_lines must be parallel to rows"
2576 );
2577 assert_eq!(row_source_lines[0], Some(3));
2578 assert_eq!(row_source_lines[1], Some(4));
2579 assert_eq!(row_source_lines[2], Some(5));
2580 }
2581 other => panic!("expected Table, got {other:?}"),
2582 }
2583 }
2584
2585 #[test]
2586 fn parse_default_config_leaves_row_source_lines_empty() {
2587 // Production publish builds must not populate table row lines.
2588 let md = "| h1 | h2 |\n| --- | --- |\n| a | b |\n";
2589 let doc = parse(md);
2590 assert_eq!(doc.blocks.len(), 1);
2591 match &doc.blocks[0] {
2592 Block::Table {
2593 header_source_line,
2594 row_source_lines,
2595 ..
2596 } => {
2597 assert!(header_source_line.is_none());
2598 assert!(row_source_lines.is_empty());
2599 }
2600 other => panic!("expected Table, got {other:?}"),
2601 }
2602 }
2603
2604 // -----------------------------------------------------------------
2605 // 2026-05-28 (Phase 4 followup B): ordered-list explicit start
2606 // number captured from pulldown-cmark's `Tag::List(Option<u64>)`
2607 // payload and round-tripped to the renderer as `<ol start="N">`.
2608 // -----------------------------------------------------------------
2609
2610 #[test]
2611 fn parse_ordered_list_start_3_captures_start_number() {
2612 // `3. foo` should capture `start: Some(3)` so the renderer can
2613 // emit `<ol start="3">`. CommonMark only honors the first
2614 // item's number — subsequent items are re-derived.
2615 let doc = parse("3. foo\n4. bar\n");
2616 assert_eq!(doc.blocks.len(), 1);
2617 match &doc.blocks[0] {
2618 Block::List {
2619 ordered,
2620 start,
2621 items,
2622 ..
2623 } => {
2624 assert!(ordered, "ordered list");
2625 assert_eq!(*start, Some(3), "explicit start number captured");
2626 assert_eq!(items.len(), 2);
2627 }
2628 other => panic!("expected ordered List, got {other:?}"),
2629 }
2630 }
2631
2632 #[test]
2633 fn parse_ordered_list_default_start_collapses_to_none() {
2634 // pulldown-cmark normalizes `1. foo` to `Tag::List(Some(1))`,
2635 // but the AST canonicalizes this to `start: None` (semantically
2636 // identical to `<ol>` without a `start=` attribute, but cleaner).
2637 let doc = parse("1. foo\n2. bar\n");
2638 assert_eq!(doc.blocks.len(), 1);
2639 match &doc.blocks[0] {
2640 Block::List { ordered, start, .. } => {
2641 assert!(ordered);
2642 assert!(
2643 start.is_none(),
2644 "implicit start=1 must collapse to None, got {start:?}"
2645 );
2646 }
2647 other => panic!("expected ordered List, got {other:?}"),
2648 }
2649 }
2650
2651 #[test]
2652 fn parse_unordered_list_has_no_start() {
2653 // `- foo` is unordered (`Tag::List(None)`). `start` must always
2654 // be `None` regardless of any subsequent reasoning.
2655 let doc = parse("- foo\n- bar\n");
2656 assert_eq!(doc.blocks.len(), 1);
2657 match &doc.blocks[0] {
2658 Block::List { ordered, start, .. } => {
2659 assert!(!ordered, "unordered list");
2660 assert!(
2661 start.is_none(),
2662 "unordered list must have start=None, got {start:?}"
2663 );
2664 }
2665 other => panic!("expected unordered List, got {other:?}"),
2666 }
2667 }
2668
2669 #[test]
2670 fn parse_with_source_lines_handles_list_after_blank_line_offset() {
2671 // List items can start past the document start; verify the
2672 // 1-based numbering tracks the actual source line, not a
2673 // 0-based index from the list opener.
2674 let md = "intro paragraph\n\n- item on line 3\n- item on line 4\n";
2675 let config = ParseConfig {
2676 emit_source_lines: true,
2677 implicit_figure: true,
2678 };
2679 let doc = parse_with_config(md, &config);
2680 assert_eq!(doc.blocks.len(), 2);
2681 match &doc.blocks[1] {
2682 Block::List {
2683 item_source_lines, ..
2684 } => {
2685 assert_eq!(item_source_lines.len(), 2);
2686 assert_eq!(item_source_lines[0], Some(3));
2687 assert_eq!(item_source_lines[1], Some(4));
2688 }
2689 other => panic!("expected List as second block, got {other:?}"),
2690 }
2691 }
2692
2693 #[test]
2694 fn parse_implicit_figure_default_promotes_image_only_paragraph() {
2695 // Image-only paragraph with non-empty alt → promoted to Block::Figure.
2696 let doc = parse("\n");
2697 assert_eq!(doc.blocks.len(), 1);
2698 assert!(
2699 matches!(doc.blocks[0], Block::Figure { .. }),
2700 "default config (implicit_figure=true) should promote: got {:?}",
2701 doc.blocks[0]
2702 );
2703 }
2704
2705 #[test]
2706 fn parse_implicit_figure_off_leaves_image_paragraph_unpromoted() {
2707 let config = ParseConfig {
2708 emit_source_lines: false,
2709 implicit_figure: false,
2710 };
2711 let doc = parse_with_config("\n", &config);
2712 assert_eq!(doc.blocks.len(), 1);
2713 match &doc.blocks[0] {
2714 Block::Paragraph(inlines) => {
2715 assert!(matches!(inlines[0], Inline::Image { .. }));
2716 }
2717 other => panic!("expected Paragraph with image, got {other:?}"),
2718 }
2719 }
2720
2721 // -----------------------------------------------------------------
2722 // LineLookup unit tests (binary-search prefix-sum line table)
2723 // -----------------------------------------------------------------
2724
2725 #[test]
2726 fn line_lookup_offset_zero_is_line_one() {
2727 let lookup = LineLookup::build("hello\nworld\n");
2728 assert_eq!(lookup.line_at(0), 1);
2729 }
2730
2731 #[test]
2732 fn line_lookup_after_first_newline_is_line_two() {
2733 let lookup = LineLookup::build("hello\nworld\n");
2734 // Byte 6 is the 'w' of "world", which is on line 2.
2735 assert_eq!(lookup.line_at(6), 2);
2736 }
2737
2738 #[test]
2739 fn line_lookup_handles_multiline_block_starts() {
2740 let lookup = LineLookup::build("line1\nline2\nline3\n");
2741 // First non-newline byte of each line.
2742 assert_eq!(lookup.line_at(0), 1, "byte 0 → line 1");
2743 assert_eq!(lookup.line_at(6), 2, "byte 6 → line 2");
2744 assert_eq!(lookup.line_at(12), 3, "byte 12 → line 3");
2745 }
2746
2747 #[test]
2748 fn line_lookup_empty_source() {
2749 let lookup = LineLookup::build("");
2750 assert_eq!(lookup.line_at(0), 1, "empty source still has line 1");
2751 }
2752}