Skip to main content

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::math_text::{math_inline, math_source};
27use super::node::{Block, CalloutKind, Fold, Inline};
28use super::shortcode_extract::{extract_shortcodes_with_config, parse_placeholder, ExtractedShortcode};
29use super::url::Url;
30use crate::heading::anchor::obsidian_heading_anchor;
31
32/// Parser configuration flags.
33///
34/// Threaded through [`parse_with_config`] to gate optional parser behaviors
35/// that the renderer needs to coordinate with (source-line tracking for
36/// preview scroll sync, implicit-figure promotion).
37///
38/// [`Default`] = "production preview off" — `emit_source_lines: false`,
39/// `implicit_figure: true`. The `implicit_figure` default mirrors today's
40/// always-on behavior of the parser before this config existed; flipping it
41/// off is opt-in for the small set of fragment-render call sites that need
42/// bare `<img>` (none today, but the flag exists for symmetry with the
43/// legacy `transform_events` API and the production `site_config` field).
44#[derive(Debug, Clone, Copy)]
45pub struct ParseConfig {
46    /// When true, populates [`BlockMeta::source_line`] for top-level
47    /// blocks. The renderer emits `data-source-line="N"` on the opening
48    /// tag for any block whose meta carries `Some(N)`.
49    ///
50    /// Production wires this from `process_markdown_file`'s
51    /// `emit_source_lines` argument (`true` during preview builds, `false`
52    /// during ship-stage publish builds — `data-source-line` is stripped
53    /// at ship time anyway, but emitting fewer attrs upstream is cheaper
54    /// and keeps published HTML clean from earlier stages).
55    pub emit_source_lines: bool,
56
57    /// When true (default), image-only paragraphs promote to
58    /// [`Block::Figure`] via [`try_promote_to_figure`]. When false, they
59    /// stay as [`Block::Paragraph`] containing one [`Inline::Image`].
60    ///
61    /// Production wires this from `site_config.implicit_figure` (default
62    /// `true`). The flag mirrors the legacy `transform_events`
63    /// implicit-figure pass: sites that prefer bare `<img>` (no `<figure>`
64    /// wrap) can opt out.
65    pub implicit_figure: bool,
66
67    /// Added to every computed `source_line` so the emitted
68    /// `data-source-line` / `data-source-range` values match the editor's
69    /// REAL FILE line numbers (CM6 `doc.lineAt`), not body-relative lines.
70    ///
71    /// The parser only ever sees the markdown BODY (frontmatter is stripped
72    /// upstream), so its byte offsets — and thus `LineLookup` — are
73    /// body-relative. The editor, however, reports raw-file lines including
74    /// the frontmatter. Without this offset, every annotation is short by the
75    /// frontmatter line count, so editor→preview scroll-sync maps to the wrong
76    /// element (the home page's grid scrolled the preview to the bottom). Set
77    /// to the number of lines the frontmatter consumes (0 when there is none).
78    /// See `process_markdown_file` and docs/architecture/editor-preview-sync.md
79    /// "Known defect — source-line coordinate-system mismatch".
80    pub source_line_offset: usize,
81
82    /// When true, `$…$` / `$$…$$` parse as math ([`Options::ENABLE_MATH`])
83    /// and render as escaped LaTeX source in `<code class="moss-math">`.
84    /// When false (default), `$` is an ordinary character and math source
85    /// passes through as literal text.
86    ///
87    /// Default is `false` — unlike the other flags, this one changes what
88    /// the *characters* mean, so every in-crate `parse()` caller and every
89    /// committed snapshot fixture keeps today's behavior until a site opts
90    /// in. Production wires it from `site_config.math` (`[site].math`,
91    /// default on), which is where the "is `$5` currency or an unclosed
92    /// equation?" judgment belongs.
93    pub math: bool,
94}
95
96impl Default for ParseConfig {
97    fn default() -> Self {
98        Self {
99            emit_source_lines: false,
100            // `true` matches today's always-on behavior of the parser
101            // before ParseConfig existed; the ~40 in-crate `parse()`
102            // callers all assume figure promotion happens.
103            implicit_figure: true,
104            source_line_offset: 0,
105            // Off by default so the ~40 in-crate `parse()` callers and every
106            // committed snapshot fixture are untouched by math landing.
107            // Production opts in via `[site].math`.
108            math: false,
109        }
110    }
111}
112
113/// **The** pulldown-cmark option set moss parses markdown with.
114///
115/// Every parser construction site in the repo must call this rather than
116/// hand-assembling its own `Options` — moss previously had five independent
117/// `Options` blocks (typed AST, newsletter ×2, `llms_txt`, the markdown
118/// pipeline), and each one that drifted became a surface where the same
119/// document parsed differently depending on which output it was headed for.
120/// A site that legitimately needs a different set (the newsletter walker
121/// deliberately omits `ENABLE_FOOTNOTES`, because footnote backlinks are
122/// meaningless in an inbox) calls this and then removes the one option, so
123/// the divergence reads as an explicit delta at the call site instead of
124/// being invisibly re-hand-rolled.
125///
126/// `math` gates `ENABLE_MATH` (`$…$` / `$$…$$` → [`Event::InlineMath`] /
127/// [`Event::DisplayMath`]). It is a parameter rather than part of the base
128/// set because it changes the meaning of a character that appears in
129/// ordinary prose (`$5`), so it is the one option a site must opt into —
130/// production wires it from `[site].math` on `SiteConfig`.
131///
132/// **Enabling `math` obliges the caller's event walker to handle both math
133/// events.** pulldown emits them as leaf inline events; a walker that
134/// pattern-matches known events and ignores the rest will *silently delete*
135/// every equation in the document (measured: `Energy $E = mc^2$.` →
136/// `<p>Energy .</p>`). See `src-tauri/tests/math_wiring_invariant_test.rs`,
137/// which fails any site that turns math on without arms in the same walker.
138pub fn parser_options(math: bool) -> Options {
139    let mut options = Options::empty();
140    options.insert(Options::ENABLE_STRIKETHROUGH);
141    options.insert(Options::ENABLE_TABLES);
142    options.insert(Options::ENABLE_FOOTNOTES);
143    // Phase 3 PR2: pulldown-cmark emits `LinkType::WikiLink` events for
144    // `[[…]]` / `![[…]]` natively. The typed-AST parser preserves them as
145    // `Inline::Link`/`Inline::Image` with `Url::Unresolved`; resolution
146    // happens in the later `visit_urls_mut` pass.
147    options.insert(Options::ENABLE_WIKILINKS);
148    if math {
149        options.insert(Options::ENABLE_MATH);
150    }
151    options
152}
153
154/// Parse markdown into a typed [`Document`] using the default config.
155///
156/// Equivalent to `parse_with_config(markdown, &ParseConfig::default())`.
157/// This is the entry point for the ~40 in-crate callers that don't need
158/// per-parse configuration (URL resolution tests, frontmatter round-trip
159/// tests, etc.). Production paths that need source-line tracking or
160/// implicit-figure toggling call [`parse_with_config`].
161pub fn parse(markdown: &str) -> Document {
162    parse_with_config(markdown, &ParseConfig::default())
163}
164
165/// Parse markdown into a typed [`Document`].
166///
167/// This is the AST entry point. The input is post-resolve markdown (the
168/// upstream resolve pipeline has already rewritten wikilinks into standard
169/// markdown links with `moss-resolved:` prefixes).
170///
171/// Two-stage parse:
172/// 1. [`extract_shortcodes`] pre-scans for `:::name` blocks, replacing
173///    each with a sentinel HTML comment.
174/// 2. Pulldown-cmark parses the substituted markdown into events; each
175///    sentinel comes back as a `Block::Other` raw HTML.
176/// 3. A final pass walks the AST and substitutes `Block::Other` sentinel
177///    payloads with the corresponding typed [`Block::Shortcode`].
178///
179/// When `config.emit_source_lines` is true, the parser walks events via
180/// `into_offset_iter()` so each top-level block carries the byte offset
181/// of its first event; a [`LineLookup`] converts the offset to a 1-based
182/// line number stored in [`BlockMeta::source_line`].
183pub fn parse_with_config(markdown: &str, config: &ParseConfig) -> Document {
184    let extraction = extract_shortcodes_with_config(markdown, config);
185
186    let options = parser_options(config.math);
187
188    // Source-line tracking requires the `into_offset_iter` form of the
189    // parser, which yields (Event, Range<usize>). When tracking is off,
190    // we use the plain iterator (no per-event offset overhead).
191    let (events, offsets): (Vec<Event<'_>>, Vec<Option<std::ops::Range<usize>>>) =
192        if config.emit_source_lines {
193            let mut evs = Vec::new();
194            let mut offs = Vec::new();
195            for (event, range) in
196                Parser::new_ext(&extraction.markdown_with_placeholders, options).into_offset_iter()
197            {
198                evs.push(event);
199                offs.push(Some(range));
200            }
201            (evs, offs)
202        } else {
203            let evs: Vec<Event<'_>> =
204                Parser::new_ext(&extraction.markdown_with_placeholders, options).collect();
205            let len = evs.len();
206            (evs, vec![None; len])
207        };
208
209    // Build the prefix-sum line table once (only when needed).
210    //
211    // CAVEAT: the markdown that the offsets index into is
212    // `extraction.markdown_with_placeholders`, NOT the original
213    // `markdown` passed in. Shortcode extraction may rewrite some bytes
214    // into sentinel HTML comments of a different length; line numbers
215    // would be off for blocks following an extracted shortcode if we
216    // built the lookup against the original. We build against the
217    // post-extraction string, so the line numbers match the
218    // post-extraction view — which is what users see in their editor
219    // before shortcode-block lines, and is "close enough" after (the
220    // sentinel preserves one line per extracted block, so line counts
221    // after the extracted block are within one of the source). See the
222    // architecture note in `shortcode_extract.rs` for the placeholder
223    // shape.
224    //
225    // For the source-line-off path, lookup is unused.
226    let line_lookup = if config.emit_source_lines {
227        Some(LineLookup::build(
228            &extraction.markdown_with_placeholders,
229            config.source_line_offset,
230        ))
231    } else {
232        None
233    };
234
235    // Line-tracking context handed to every recursive parser entry; the
236    // Tag::List / Tag::Table arms consult it to annotate per-item / per-row
237    // source lines. `None` when `emit_source_lines` is off; the inner
238    // arms see this as "skip annotation" and emit empty parallel vecs.
239    let line_ctx: Option<LineCtx<'_>> = line_lookup.as_ref().map(|lookup| LineCtx {
240        lookup,
241        offsets: &offsets,
242    });
243
244    let mut blocks = Vec::new();
245    let mut block_meta: Vec<BlockMeta> = Vec::new();
246    let mut i = 0;
247    while i < events.len() {
248        let event_start_idx = i;
249        let (block, advance) = parse_block(&events, i, line_ctx.as_ref());
250        if let Some(b) = block {
251            // Compute source_line from the first event's byte offset, if
252            // we collected offsets and a lookup is in scope.
253            let source_line = match (line_lookup.as_ref(), offsets.get(event_start_idx)) {
254                (Some(lookup), Some(Some(range))) => Some(lookup.line_at(range.start)),
255                _ => None,
256            };
257            blocks.push(b);
258            block_meta.push(BlockMeta { source_line });
259        }
260        i += advance.max(1);
261    }
262
263    // Substitute sentinel placeholders with their typed Shortcode variants.
264    substitute_shortcode_placeholders(&mut blocks, &extraction.nonce, &extraction.extracted);
265
266    // Implicit-figure gating: the per-paragraph `try_promote_to_figure`
267    // inside `parse_block_with_tag` always runs (so the figure promotion
268    // happens at parse time inside the Tag::Paragraph arm). When
269    // `config.implicit_figure` is false, we walk the assembled blocks
270    // and "undo" the promotion — converting `Block::Figure { image, ..}`
271    // back to `Block::Paragraph(vec![image])`.
272    //
273    // The unwinding-at-the-end approach was chosen over threading the
274    // flag into `parse_block_with_tag` because the latter would mean
275    // propagating `config` through ~14 inner parser functions whose
276    // signatures are already tight. The unwind is O(N) and only fires
277    // on the rare opt-out path; production keeps the default `true`.
278    if !config.implicit_figure {
279        for block in blocks.iter_mut() {
280            unwrap_implicit_figure(block);
281        }
282    }
283
284    // Apply duplicate-suffix numbering to heading IDs in document order.
285    // Each Tag::Heading arm computes the base slug; this pass disambiguates
286    // collisions across the whole document, matching production's id_counts
287    // HashMap behavior in pipeline.rs::transform_events.
288    assign_heading_id_suffixes(&mut blocks);
289
290    Document::from_blocks_with_meta(blocks, block_meta)
291}
292
293/// Recursively undo implicit-figure promotion in `block` and its children.
294///
295/// Called when `ParseConfig::implicit_figure` is false. Walks the block
296/// tree (descending into containers — `BlockQuote`, `Callout`, `List`,
297/// `LinkCard`) and rewrites any `Block::Figure` back to
298/// `Block::Paragraph(vec![image])` with the original alt text preserved.
299/// The caption is discarded (matches the legacy bare-`<img>` shape).
300fn unwrap_implicit_figure(block: &mut Block) {
301    // Replace this block if it's a Figure.
302    if let Block::Figure { image, .. } = block {
303        let img = std::mem::replace(
304            image,
305            Inline::Text(String::new()), // placeholder, overwritten below
306        );
307        *block = Block::Paragraph(vec![img]);
308        return;
309    }
310    // Recurse into containers.
311    match block {
312        Block::BlockQuote(children) => {
313            for child in children.iter_mut() {
314                unwrap_implicit_figure(child);
315            }
316        }
317        Block::Callout { children, .. } => {
318            for child in children.iter_mut() {
319                unwrap_implicit_figure(child);
320            }
321        }
322        Block::List { items, .. } => {
323            for item in items.iter_mut() {
324                for child in item.iter_mut() {
325                    unwrap_implicit_figure(child);
326                }
327            }
328        }
329        Block::LinkCard { children, .. } => {
330            for child in children.iter_mut() {
331                unwrap_implicit_figure(child);
332            }
333        }
334        _ => {}
335    }
336}
337
338/// Bundle of borrowed line-tracking state threaded through recursive
339/// parser entries. Constructed once per `parse_with_config` when
340/// `emit_source_lines` is true; `None` everywhere else.
341///
342/// `parse_block` / `parse_block_with_tag` consult `line_at_event` to
343/// annotate per-`<li>` and per-`<tr>` source lines. The outer
344/// top-level-block source line is computed at the parse loop itself
345/// (already in place), not here.
346struct LineCtx<'a> {
347    lookup: &'a LineLookup,
348    offsets: &'a [Option<std::ops::Range<usize>>],
349}
350
351impl<'a> LineCtx<'a> {
352    /// 1-based source line of the event at `event_index`, or `None` if
353    /// the offset is missing (defensive — shouldn't happen when the
354    /// parser is operating with `emit_source_lines: true`).
355    fn line_at_event(&self, event_index: usize) -> Option<usize> {
356        match self.offsets.get(event_index) {
357            Some(Some(range)) => Some(self.lookup.line_at(range.start)),
358            _ => None,
359        }
360    }
361}
362
363/// Prefix-sum line-number lookup for byte offsets in a source string.
364///
365/// Built once per parse (when `emit_source_lines` is on). Stores the byte
366/// offset of every `\n` in `source`; `line_at(offset)` returns the
367/// 1-based line number containing that offset via binary search.
368///
369/// Equivalent (slower) form: `source[..offset].matches('\n').count() + 1`
370/// — O(N) per call vs. O(log N) here. For documents with ~25 blocks the
371/// difference is negligible, but the binary-search form is the canonical
372/// pattern and is the cheaper hot-path shape.
373struct LineLookup {
374    /// Byte offsets of every `\n` in the source. Sorted ascending by
375    /// construction. `newline_offsets[i]` is the byte index of the i-th
376    /// newline (0-based).
377    newline_offsets: Vec<usize>,
378    /// Added to every `line_at` result so body-relative lines become
379    /// raw-file lines (the frontmatter line count). See
380    /// `ParseConfig::source_line_offset`.
381    line_offset: usize,
382}
383
384impl LineLookup {
385    fn build(source: &str, line_offset: usize) -> Self {
386        let mut newline_offsets = Vec::new();
387        for (i, b) in source.bytes().enumerate() {
388            if b == b'\n' {
389                newline_offsets.push(i);
390            }
391        }
392        Self {
393            newline_offsets,
394            line_offset,
395        }
396    }
397
398    /// 1-based line number containing `byte_offset`, plus `line_offset`.
399    ///
400    /// Offset 0 (before any newline) → line 1. After the first newline →
401    /// line 2. Etc. Offsets past the end of the source clamp to the last
402    /// line + 1. `line_offset` (the frontmatter line count) is added so the
403    /// result is a raw-file line, matching the editor's `doc.lineAt`.
404    fn line_at(&self, byte_offset: usize) -> usize {
405        // Find the number of newlines strictly before `byte_offset`.
406        // That count + 1 is the 1-based line number.
407        let body_line = match self.newline_offsets.binary_search(&byte_offset) {
408            // Exact match: offset IS a newline byte; the newline belongs
409            // to the line that ENDS at it, so line number = idx + 1.
410            // (The next byte starts line idx + 2; this matches the legacy
411            // count-and-add-1 semantics, which counts newlines BEFORE the
412            // offset.)
413            Ok(idx) => idx + 1,
414            Err(idx) => idx + 1,
415        };
416        body_line + self.line_offset
417    }
418}
419
420/// Walk top-level blocks; replace any `Block::Other` whose payload is a
421/// `<!--MOSS_SC_{nonce}_{index}-->` sentinel with the corresponding typed
422/// [`Block::Shortcode`].
423fn substitute_shortcode_placeholders(
424    blocks: &mut Vec<Block>,
425    nonce: &str,
426    extracted: &[ExtractedShortcode],
427) {
428    for block in blocks.iter_mut() {
429        if let Block::Other(html) = block {
430            if let Some(index) = parse_placeholder(nonce, html) {
431                if let Some(entry) = extracted.iter().find(|e| e.index == index) {
432                    *block = Block::Shortcode(entry.shortcode.clone());
433                }
434            }
435        }
436        // Future: descend into BlockQuote / List items / Callouts when
437        // shortcodes inside those constructs are modeled. Phase B Tasks
438        // 7-10 only need top-level shortcodes.
439    }
440}
441
442/// Parse one block-level construct starting at `events[start]`. Returns
443/// the parsed block (or `None` if `events[start]` was a closing tag /
444/// stray event we skip) and how many events to advance.
445///
446/// `line_ctx` carries the optional line-tracking context for per-item
447/// (`<li>`) and per-row (`<tr>`) source-line annotation; threaded through
448/// to `parse_block_with_tag`.
449fn parse_block(
450    events: &[Event<'_>],
451    start: usize,
452    line_ctx: Option<&LineCtx<'_>>,
453) -> (Option<Block>, usize) {
454    // Block-level dispatch. pulldown always wraps loose inlines (math
455    // included) in Tag::Paragraph at top level, so no math event ever reaches
456    // this match; the paragraph's inlines are collected by the math-aware
457    // parse_inline. Pinned by `display_math_block_survives_on_its_own_lines`.
458    // allow:math-events-ignored — see above.
459    match &events[start] {
460        Event::Start(tag) => parse_block_with_tag(events, start, tag, line_ctx),
461        Event::Text(_) | Event::Code(_) | Event::Html(_) | Event::SoftBreak | Event::HardBreak => {
462            // Top-level stray inlines: pulldown-cmark always wraps these in
463            // `Tag::Paragraph` at top level, so this branch is dead in practice.
464            //
465            // The tight-list-item case where the inlines are emitted directly
466            // (no Tag::Paragraph wrap) was the load-bearing reason this branch
467            // looked relevant; PR0.6 moved that responsibility into
468            // `collect_item_blocks`, which synthesizes a Block::Paragraph for
469            // stray inlines inside Tag::Item. See parser.rs's collect_item_blocks
470            // helper.
471            (None, 1)
472        }
473        Event::End(_) => (None, 1),
474        Event::Rule => (Some(Block::ThematicBreak), 1),
475        _ => (None, 1),
476    }
477}
478
479fn parse_block_with_tag(
480    events: &[Event<'_>],
481    start: usize,
482    tag: &Tag<'_>,
483    line_ctx: Option<&LineCtx<'_>>,
484) -> (Option<Block>, usize) {
485    match tag {
486        Tag::Heading { level, .. } => {
487            let (children, end) = collect_inlines_until(events, start + 1, |e| {
488                matches!(e, Event::End(TagEnd::Heading(_)))
489            });
490            let level_num = match level {
491                HeadingLevel::H1 => 1,
492                HeadingLevel::H2 => 2,
493                HeadingLevel::H3 => 3,
494                HeadingLevel::H4 => 4,
495                HeadingLevel::H5 => 5,
496                HeadingLevel::H6 => 6,
497            };
498            // Phase 4 PR2: compute the heading-anchor base slug from the
499            // text/code content between Start(Heading) and End(Heading),
500            // matching production's transform_events behavior. Inline HTML
501            // (`<br>` etc.), images, and link href text are NOT included —
502            // only Event::Text and Event::Code. The post-parse
503            // `assign_heading_id_suffixes` pass disambiguates collisions.
504            let heading_text = crate::heading::text::events_to_text(events, start + 1, end);
505            let base_slug = obsidian_heading_anchor(&heading_text);
506            (
507                Some(Block::Heading {
508                    level: level_num,
509                    children,
510                    id: Some(base_slug),
511                }),
512                end - start + 1,
513            )
514        }
515        Tag::Paragraph => {
516            let (children, end) = collect_inlines_until(events, start + 1, |e| {
517                matches!(e, Event::End(TagEnd::Paragraph))
518            });
519            // Phase 4 PR3 (2026-05-27): detect image-only paragraphs and
520            // promote to `Block::Figure`. See shape-spec § 1 detection
521            // rule: exactly one `Inline::Image` plus any number of
522            // whitespace-only `Inline::Text` / `Inline::LineBreak`
523            // siblings qualifies. Caption defaults to the image's alt
524            // text (mirroring transform_events' implicit-figure path);
525            // empty alt yields `caption: None` so no `<figcaption>` is
526            // emitted.
527            //
528            // A paragraph with image+prose (e.g. `![img](src) caption text`)
529            // does NOT qualify; it stays as `Block::Paragraph`. This is the
530            // critical regression guard — see PR1 v2 (commit 71c657af3)
531            // for the analogous shape decision at the inline image hook
532            // level: inline images use `MarkdownInline` (no figure wrap);
533            // only the standalone figure case here uses the figure wrap.
534            let block = match try_promote_to_figure(children, events, start) {
535                Ok(figure) => figure,
536                Err(original_inlines) => Block::Paragraph(original_inlines),
537            };
538            (Some(block), end - start + 1)
539        }
540        Tag::CodeBlock(kind) => {
541            let lang = match kind {
542                pulldown_cmark::CodeBlockKind::Fenced(s) if !s.is_empty() => Some(s.to_string()),
543                _ => None,
544            };
545            let mut value = String::new();
546            let mut i = start + 1;
547            while i < events.len() {
548                match &events[i] {
549                    // allow:math-events-ignored — pulldown does not parse math
550                    // inside a code fence, so it emits no math event here;
551                    // ```\n$x^2$\n``` is byte-identical at math on and off.
552                    Event::End(TagEnd::CodeBlock) => break,
553                    Event::Text(t) => value.push_str(t),
554                    _ => {}
555                }
556                i += 1;
557            }
558            (Some(Block::CodeBlock { lang, value }), i - start + 1)
559        }
560        Tag::BlockQuote(_) => {
561            // Phase 4 PR4: detect Obsidian-style callouts. A blockquote
562            // whose first paragraph's leading text matches `[!<kind>]`
563            // (with optional `+`/`-` foldable suffix and optional
564            // inline title) promotes to `Block::Callout`. Otherwise it
565            // stays a plain blockquote. See shape-spec § 1.
566            //
567            // Detection works on the EVENT stream (not the parsed
568            // children) because pulldown-cmark's SoftBreak events
569            // become `Inline::Text("\n")` during inline parsing
570            // (PR4.5 aligned to CommonMark spec — see
571            // `parse_inline` SoftBreak handling). Working on events
572            // preserves the structural break before inline
573            // collapse, which is what the marker-line-vs-body-line
574            // boundary check needs.
575            match detect_and_assemble_callout(events, start + 1, line_ctx) {
576                Some((block, body_end)) => (Some(block), body_end - start + 1),
577                None => {
578                    let (children, end) = collect_blocks_until(events, start + 1, line_ctx, |e| {
579                        matches!(e, Event::End(TagEnd::BlockQuote(_)))
580                    });
581                    (Some(Block::BlockQuote(children)), end - start + 1)
582                }
583            }
584        }
585        Tag::List(start_num) => {
586            let ordered = start_num.is_some();
587            // Preserve explicit ordered-list start number when it's not
588            // the implicit default `1`. `3. foo` → `Some(3)` so the
589            // renderer can emit `<ol start="3">`. pulldown-cmark
590            // normalizes `1. foo` to `Some(1)`, which we collapse to
591            // `None` because `<ol>` and `<ol start="1">` are
592            // semantically identical and we prefer the cleaner attr-free
593            // shape for the common case. Bound name is `list_start` to
594            // avoid shadowing the outer `start: usize` event-index
595            // parameter.
596            let list_start = match start_num {
597                Some(n) if *n != 1 => Some(*n),
598                _ => None,
599            };
600            let mut items: Vec<Vec<Block>> = Vec::new();
601            // Parallel-to-`items` per-`<li>` source-line annotations.
602            // Empty when `line_ctx` is None; otherwise tracks each
603            // `Event::Start(Tag::Item)`'s byte offset → line. The renderer
604            // emits `<li data-source-line="N">` for entries that are Some.
605            let mut item_source_lines: Vec<Option<usize>> = Vec::new();
606            let track_lines = line_ctx.is_some();
607            let mut i = start + 1;
608            while i < events.len() {
609                match &events[i] {
610                    // allow:math-events-ignored — structural walk that only
611                    // locates item boundaries; every item's content is parsed
612                    // by the math-aware collect_item_blocks. Pinned by
613                    // `math_survives_inside_list_items`.
614                    Event::End(TagEnd::List(_)) => break,
615                    Event::Start(Tag::Item) => {
616                        if track_lines {
617                            item_source_lines.push(line_ctx.and_then(|ctx| ctx.line_at_event(i)));
618                        }
619                        let (item_blocks, end) = collect_item_blocks(events, i + 1, line_ctx);
620                        items.push(item_blocks);
621                        i = end + 1;
622                    }
623                    _ => i += 1,
624                }
625            }
626            (
627                Some(Block::List {
628                    ordered,
629                    start: list_start,
630                    items,
631                    item_source_lines,
632                }),
633                i - start + 1,
634            )
635        }
636        Tag::Table(_) => {
637            let mut header: Vec<Vec<Inline>> = Vec::new();
638            let mut rows: Vec<Vec<Vec<Inline>>> = Vec::new();
639            // Per-`<tr>` source-line tracking. `header_source_line` is the
640            // `<thead><tr>` line; `row_source_lines` is parallel to `rows`.
641            // Both stay empty / None when `line_ctx` is None.
642            let mut header_source_line: Option<usize> = None;
643            let mut row_source_lines: Vec<Option<usize>> = Vec::new();
644            let track_lines = line_ctx.is_some();
645            let mut current_row: Vec<Vec<Inline>> = Vec::new();
646            let mut in_head = false;
647            let mut in_body_row = false;
648            let mut i = start + 1;
649            while i < events.len() {
650                match &events[i] {
651                    // allow:math-events-ignored — structural walk over table
652                    // section/row/cell boundaries; cell content is collected by
653                    // the math-aware collect_inlines_until. Pinned by
654                    // `math_survives_inside_a_table_cell`.
655                    Event::End(TagEnd::Table) => break,
656                    Event::Start(Tag::TableHead) => {
657                        in_head = true;
658                        // pulldown-cmark does NOT emit `Tag::TableRow` for the
659                        // header row — it goes straight from `Tag::TableHead`
660                        // to the cells. So we anchor the header `<tr>` line
661                        // to the `TableHead` event itself (line of the
662                        // markdown `| h |` row).
663                        if track_lines {
664                            header_source_line = line_ctx.and_then(|ctx| ctx.line_at_event(i));
665                        }
666                        i += 1;
667                    }
668                    Event::End(TagEnd::TableHead) => {
669                        in_head = false;
670                        i += 1;
671                    }
672                    Event::Start(Tag::TableRow) => {
673                        in_body_row = true;
674                        current_row = Vec::new();
675                        if track_lines {
676                            // pulldown-cmark only emits `TableRow` for body
677                            // rows (header cells live directly inside
678                            // `TableHead`). Always push to body lines here.
679                            row_source_lines.push(line_ctx.and_then(|ctx| ctx.line_at_event(i)));
680                        }
681                        i += 1;
682                    }
683                    Event::End(TagEnd::TableRow) => {
684                        if in_body_row {
685                            rows.push(std::mem::take(&mut current_row));
686                            in_body_row = false;
687                        }
688                        i += 1;
689                    }
690                    Event::Start(Tag::TableCell) => {
691                        let (cell_inlines, end) = collect_inlines_until(events, i + 1, |e| {
692                            matches!(e, Event::End(TagEnd::TableCell))
693                        });
694                        if in_head {
695                            header.push(cell_inlines);
696                        } else {
697                            current_row.push(cell_inlines);
698                        }
699                        i = end + 1;
700                    }
701                    _ => i += 1,
702                }
703            }
704            (
705                Some(Block::Table {
706                    header,
707                    rows,
708                    header_source_line,
709                    row_source_lines,
710                }),
711                i - start + 1,
712            )
713        }
714        Tag::HtmlBlock => {
715            let mut html = String::new();
716            let mut i = start + 1;
717            while i < events.len() {
718                match &events[i] {
719                    // allow:math-events-ignored — a raw HTML block is passed
720                    // through verbatim; pulldown emits only Html/Text inside
721                    // one, never a math event.
722                    Event::End(TagEnd::HtmlBlock) => break,
723                    Event::Html(s) | Event::Text(s) => html.push_str(s),
724                    _ => {}
725                }
726                i += 1;
727            }
728            (Some(Block::Other(html)), i - start + 1)
729        }
730        // Unmodeled containers: skip to End and emit nothing. The events
731        // inside are dropped — anything moss cares about should be modeled
732        // explicitly.
733        _ => (None, 1),
734    }
735}
736
737/// Decide whether a paragraph's inlines qualify for promotion to
738/// [`Block::Figure`]. Per shape-spec § 1: exactly one [`Inline::Image`]
739/// plus any number of whitespace-only [`Inline::Text`] /
740/// [`Inline::LineBreak`] siblings. Any other inline shape (Emphasis,
741/// Strong, Link, Code, non-whitespace Text, …) disqualifies the
742/// paragraph and it stays as [`Block::Paragraph`].
743///
744/// **Empty-alt guard:** if the matched image has an empty alt (decorative
745/// image), the paragraph is NOT promoted. This mirrors production's
746/// `transform_events` implicit-figure pass which gates on non-empty alt
747/// (a `<figure>` whose caption duplicates a missing alt would be useless
748/// for assistive tech and adds visual noise). The empty-alt image stays
749/// as `<p><img></p>`, matching the production byte shape for the same
750/// input — verified via the parity probe's `other` category on 刘果 CJK
751/// fixtures (image-only paragraphs with empty alt).
752///
753/// On qualification, returns `Ok(Block::Figure { image, caption })`. For a
754/// standard-markdown image the caption renders the alt as INLINE MARKDOWN
755/// (option B, matching Pandoc's implicit-figure model): `*em*`, links,
756/// `` `code` `` and typeset math survive, built from the image's parsed
757/// inline children (`events`/`para_start` re-parse the alt event span). The
758/// `alt=` attribute stays the flat plain-text source. A plain-text alt (no
759/// inline markup) keeps the flat single-[`Inline::Text`] caption, byte-
760/// identical to before, so only captions that actually carry markup change.
761///
762/// On disqualification, returns `Err(original_inlines)` so the caller
763/// can fall back to constructing the standard `Block::Paragraph` without
764/// re-walking events.
765fn try_promote_to_figure(
766    inlines: Vec<Inline>,
767    events: &[Event<'_>],
768    para_start: usize,
769) -> Result<Block, Vec<Inline>> {
770    let mut image_count = 0;
771    for inline in &inlines {
772        match inline {
773            Inline::Image { .. } => image_count += 1,
774            Inline::Text(s) if s.trim().is_empty() => {} // whitespace OK
775            Inline::LineBreak => {}                      // line break OK
776            _ => return Err(inlines),
777        }
778    }
779    if image_count != 1 {
780        return Err(inlines);
781    }
782
783    // Non-image wikilink embeds never promote. pulldown-cmark parses every
784    // `![[…]]` as an Image event, but Figure is an image concept: a video /
785    // pdf / audio wikilink promoted here bypasses `dispatch_wikilink_embeds`
786    // (which only dispatches Paragraph-shaped lone embeds), so its typed
787    // synthesizer never runs and the page ships `<figure><img src="clip.mov">`
788    // — a broken image. The gate keys off the same classifier the dispatcher
789    // uses (`resolve::ext_kind`), so parse-time promotion and dispatch-time
790    // synthesis cannot disagree about who owns the block. Extension-less
791    // wikilinks (`![[draft|55%]]`) also stay Paragraph: only the with-graph
792    // dispatcher can resolve their kind, and committing them to an image
793    // Figure here would be a guess.
794    if let Some(Inline::Image {
795        src,
796        is_wikilink: true,
797        ..
798    }) = inlines.iter().find(|i| matches!(i, Inline::Image { .. }))
799    {
800        let dest = match src {
801            Url::Unresolved(s) => s.as_str(),
802            Url::Resolved(r) => r.href.as_str(),
803        };
804        let ext = crate::path_ext::path_extension_lower(dest);
805        if !matches!(
806            crate::resolve::ext_kind::reference_kind_for_ext(&ext),
807            crate::resolve::ext_kind::ExtKind::Image
808        ) {
809            return Err(inlines);
810        }
811    }
812
813    // Probe the width + remaining alt on a BORROW first, so the empty-alt
814    // guard can still return `Err(inlines)` with the original whitespace /
815    // line-break siblings intact (production `<p><img>…</p>` parity).
816    //
817    // Standard-markdown images carry no structured pothole — a `|55%`/`|wide`
818    // width rides in the raw alt text. Split it out so the figure carries the
819    // width and the caption is the remaining alt.
820    //
821    // Wikilink images carry the raw pothole in `wikilink_pothole`; named width
822    // tokens are already classified by `parse_pothole_params` (WidthToken arm),
823    // but a content-relative percent (`55%`) is classified as `Alias` and
824    // lands in `alt` (or is stripped from alt by our parser-level Alias fix).
825    // Recover the percent from `wikilink_pothole` directly so the figure
826    // carries the width on both the with-graph path (wikilink_dispatch) and
827    // the no-graph path (fragment/test render with no ContentGraph).
828    let mut figure_width: Option<String> = None;
829    let mut rewritten_alt: Option<String> = None;
830    match inlines.iter().find(|i| matches!(i, Inline::Image { .. })) {
831        Some(Inline::Image {
832            alt,
833            is_wikilink: false,
834            ..
835        }) => {
836            let (rest_alt, w) = crate::media::split_alt_width(alt);
837            if w.is_some() {
838                figure_width = w;
839                rewritten_alt = Some(rest_alt);
840            }
841        }
842        Some(Inline::Image {
843            is_wikilink: true,
844            wikilink_pothole,
845            ..
846        }) => {
847            // Recover a content-relative percent from the raw pothole.
848            // Named tokens are already absent from `alt` (WidthToken arm in
849            // parse_pothole_params clears them); only the percent case falls
850            // through as `Alias` and still needs extracting.
851            // Sync: the with-graph twin lives in resolve/wikilink_dispatch.rs
852            // (image branch, ~line 565) — both split width via media::split_alt_width.
853            if let Some(pothole) = wikilink_pothole {
854                let (remaining, w) = crate::media::split_alt_width(pothole);
855                if w.is_some() {
856                    figure_width = w;
857                    // The remaining pothole (caption after stripping the %) is
858                    // the intended caption; propagate it as the rewritten alt if
859                    // the current alt is empty (percent-only pothole) or already
860                    // stripped to the same value.
861                    rewritten_alt = Some(remaining);
862                }
863            }
864        }
865        _ => {}
866    }
867
868    // The figure's caption text is the effective alt (width-stripped if a
869    // width was present, else the raw alt), trimmed.
870    let raw_alt = inlines.iter().find_map(|i| match i {
871        Inline::Image { alt, .. } => Some(alt.as_str()),
872        _ => None,
873    });
874    let alt_text = rewritten_alt
875        .as_deref()
876        .or(raw_alt)
877        .map(|s| s.trim().to_string())
878        .unwrap_or_default();
879
880    // Empty-alt guard: refuse to promote a decorative image (preserve the
881    // original `<p><img></p>` shape with its whitespace siblings) — UNLESS it
882    // carries a width, which needs a figure to hold the inline
883    // `style="width:NN%"` / `data-width=`.
884    if alt_text.is_empty() && figure_width.is_none() {
885        return Err(inlines);
886    }
887
888    // Extract the single image, applying the width-stripped alt if any.
889    let mut image_owned: Option<Inline> = None;
890    for inline in inlines.into_iter() {
891        if matches!(inline, Inline::Image { .. }) {
892            image_owned = Some(inline);
893            break;
894        }
895    }
896    let mut image =
897        image_owned.expect("invariant: image_count == 1 implies one Image present");
898    if let (Some(new_alt), Inline::Image { alt, .. }) = (rewritten_alt, &mut image) {
899        *alt = new_alt;
900    }
901
902    // Caption. Empty alt yields None so no empty <figcaption> is emitted.
903    // Otherwise, for a standard-markdown image, render the alt as inline
904    // markdown (option B) — `*em*`, links, `` `code` ``, typeset math — built
905    // from the image's parsed inline children. A wikilink image keeps its
906    // flat pothole-derived caption (its alias is a literal string, not
907    // markdown), and a plain-text alt keeps the flat single-Text caption so
908    // the byte shape is unchanged for the common case.
909    let caption = if alt_text.is_empty() {
910        None
911    } else {
912        Some(build_caption_inlines(
913            &image,
914            events,
915            para_start,
916            alt_text,
917            figure_width.is_some(),
918        ))
919    };
920
921    Ok(Block::Figure {
922        image,
923        caption,
924        width: figure_width,
925        align: None,
926        class_names: Vec::new(),
927        img_style: None,
928    })
929}
930
931/// Build the implicit-figure caption inlines for the promoted image.
932///
933/// Option B (matching Pandoc's implicit-figure model): a standard-markdown
934/// image's caption is the alt CONTENT parsed as inline markdown — the typed
935/// `Emphasis` / `Link` / `Code` / math nodes from the image's own event
936/// span — so the renderer's hook-aware inline path (`render_inlines`)
937/// emits `<em>`, `<a>`, and typeset math in the `<figcaption>`. The
938/// `alt=` attribute (the `Inline::Image.alt` string) is untouched: it stays
939/// the flat plain-text source (math as `$…$`) for assistive tech and
940/// blocked-image fallback.
941///
942/// Falls back to the flat single-`Inline::Text` caption (byte-identical to
943/// the pre-option-B shape) when:
944/// - the image is a wikilink embed — its pothole alias is a literal
945///   caption string by grammar, not markdown; and
946/// - a width token was split out of the alt (`![cap|50%](p)`) — the raw
947///   event span still contains the `|50%` text, so re-parsing it would
948///   leak the width token into the caption.
949fn build_caption_inlines(
950    image: &Inline,
951    events: &[Event<'_>],
952    para_start: usize,
953    alt_text: String,
954    has_width: bool,
955) -> Vec<Inline> {
956    let is_wikilink = matches!(
957        image,
958        Inline::Image {
959            is_wikilink: true,
960            ..
961        }
962    );
963    if is_wikilink || has_width {
964        return vec![Inline::Text(alt_text)];
965    }
966
967    // Locate the image's own event span inside the paragraph:
968    // Start(Tag::Image) … End(TagEnd::Image). The promotion invariant
969    // guarantees exactly one image among the paragraph's inlines; scanning
970    // for the FIRST Start(Tag::Image) and stopping at the FIRST
971    // End(TagEnd::Image) matches the alt-flattening loop in `parse_inline`
972    // (Tag::Image arm), so both surfaces agree on the span.
973    let mut img_children_start: Option<usize> = None;
974    let mut i = para_start + 1;
975    while i < events.len() {
976        // This arm set only LOCATES the image span (routes on event kind:
977        // where Start(Image) is); it builds no output. The alt payload, math
978        // included, is collected right below by the math-aware
979        // collect_inlines_until/parse_inline, pinned by
980        // implicit_figure_caption_carries_link_and_math_nodes.
981        // allow:math-events-ignored — span locator, payload survives below.
982        match &events[i] {
983            Event::Start(Tag::Image { .. }) => {
984                img_children_start = Some(i + 1);
985                break;
986            }
987            Event::End(TagEnd::Paragraph) => break,
988            _ => {}
989        }
990        i += 1;
991    }
992    let Some(children_start) = img_children_start else {
993        // Defensive: no image span found (should be unreachable given the
994        // promotion invariant) — keep the flat caption rather than guess.
995        return vec![Inline::Text(alt_text)];
996    };
997
998    // Re-parse the alt event span through the SAME inline machinery as body
999    // text, so `*em*` → Inline::Emphasis, `[l](/x)` → Inline::Link, and
1000    // `$x^2$` → the math Inline::Other node (which the renderer routes
1001    // through PipelineHooks::render_math for typesetting).
1002    let (mut caption, _end) = collect_inlines_until(events, children_start, |e| {
1003        matches!(e, Event::End(TagEnd::Image))
1004    });
1005
1006    // A plain-text alt (every child is bare Text) keeps the flat trimmed
1007    // single-Text caption — byte-identical to the pre-option-B shape, so
1008    // only captions that actually carry markup change output.
1009    if caption.iter().all(|c| matches!(c, Inline::Text(_))) {
1010        return vec![Inline::Text(alt_text)];
1011    }
1012
1013    // Trim the caption edges the way the flat path's `.trim()` did: leading
1014    // whitespace off the first Text node, trailing off the last, dropping
1015    // nodes that become empty.
1016    if let Some(Inline::Text(first)) = caption.first_mut() {
1017        *first = first.trim_start().to_string();
1018        if first.is_empty() {
1019            caption.remove(0);
1020        }
1021    }
1022    if let Some(Inline::Text(last)) = caption.last_mut() {
1023        *last = last.trim_end().to_string();
1024        if last.is_empty() {
1025            caption.pop();
1026        }
1027    }
1028    if caption.is_empty() {
1029        // Defensive: markup collapsed to nothing — fall back to the flat
1030        // alt so we never emit an empty <figcaption>.
1031        return vec![Inline::Text(alt_text)];
1032    }
1033    caption
1034}
1035
1036/// Collect a contiguous run of inline events into `Vec<Inline>`. Stops
1037/// when `is_end(event)` returns true or events run out. Returns the
1038/// collected inlines and the end-event index.
1039fn collect_inlines_until<F>(events: &[Event<'_>], start: usize, is_end: F) -> (Vec<Inline>, usize)
1040where
1041    F: Fn(&Event<'_>) -> bool,
1042{
1043    let mut out: Vec<Inline> = Vec::new();
1044    let mut i = start;
1045    while i < events.len() {
1046        if is_end(&events[i]) {
1047            return (out, i);
1048        }
1049        let (inline, advance) = parse_inline(events, i);
1050        if let Some(node) = inline {
1051            out.push(node);
1052        }
1053        i += advance.max(1);
1054    }
1055    (out, i)
1056}
1057
1058/// Parse one inline construct starting at `events[start]`.
1059fn parse_inline(events: &[Event<'_>], start: usize) -> (Option<Inline>, usize) {
1060    match &events[start] {
1061        Event::Text(t) => (Some(Inline::Text(t.to_string())), 1),
1062        Event::Code(c) => (Some(Inline::Code(c.to_string())), 1),
1063        // Phase 4 PR4.5 (2026-05-28): match pulldown-cmark's `push_html`
1064        // byte shape — SoftBreak emits `\n` between inline siblings, not a
1065        // space. The space form was a long-standing AST quirk surfaced
1066        // by Grid cells now flowing through the AST renderer; production
1067        // baselines (chps-site, SoCiviC, snapshot fixtures) preserve the
1068        // newline (e.g. `Flamboyan Theater · The Clemente\n107 Suffolk
1069        // Street`). Aligning here closes one row of the parity probe's
1070        // `whitespace_attribute_order` category.
1071        Event::SoftBreak => (Some(Inline::Text("\n".to_string())), 1),
1072        Event::HardBreak => (Some(Inline::LineBreak), 1),
1073        Event::Html(s) | Event::InlineHtml(s) => (Some(Inline::Other(s.to_string())), 1),
1074        // Math (ADR-030). Both are LEAF inline events carrying the raw TeX.
1075        // These arms are load-bearing: without them the two catch-alls below
1076        // return `(None, 1)` and every equation is silently deleted from the
1077        // document (`Energy $E = mc^2$.` → `<p>Energy .</p>`).
1078        //
1079        // P1 has no typesetting engine, so math renders as its own escaped
1080        // source — honest, never blank. `Inline::Other` is a RAW passthrough
1081        // at render time (render.rs), which is exactly why the escaping has
1082        // to happen HERE, at construction: the TeX is author input and is
1083        // full of `<`, `>` and `&`. ADR-030 §4 records why this rides
1084        // `Inline::Other` instead of a new `Inline::Math` variant (the enum
1085        // is published, serialized and not `#[non_exhaustive]`, so a variant
1086        // is a semver one-way door).
1087        Event::InlineMath(tex) => (Some(math_inline(tex, false)), 1),
1088        Event::DisplayMath(tex) => (Some(math_inline(tex, true)), 1),
1089        Event::Start(tag) => match tag {
1090            Tag::Emphasis => {
1091                let (children, end) = collect_inlines_until(events, start + 1, |e| {
1092                    matches!(e, Event::End(TagEnd::Emphasis))
1093                });
1094                (Some(Inline::Emphasis(children)), end - start + 1)
1095            }
1096            Tag::Strong => {
1097                let (children, end) = collect_inlines_until(events, start + 1, |e| {
1098                    matches!(e, Event::End(TagEnd::Strong))
1099                });
1100                (Some(Inline::Strong(children)), end - start + 1)
1101            }
1102            Tag::Link {
1103                link_type,
1104                dest_url,
1105                title,
1106                ..
1107            } => {
1108                let (children, end) = collect_inlines_until(events, start + 1, |e| {
1109                    matches!(e, Event::End(TagEnd::Link))
1110                });
1111                let title_opt = if title.is_empty() {
1112                    None
1113                } else {
1114                    Some(title.to_string())
1115                };
1116                // Phase 4 PR7a (2026-05-28): preserve pulldown-cmark's
1117                // `LinkType::WikiLink` discriminator on the typed AST so
1118                // the renderer can emit `class="wikilink"` and graph
1119                // builders can identify wikilink targets.
1120                let is_wikilink = matches!(*link_type, pulldown_cmark::LinkType::WikiLink { .. });
1121                (
1122                    Some(Inline::Link {
1123                        url: Url::unresolved(dest_url.to_string()),
1124                        title: title_opt,
1125                        children,
1126                        is_wikilink,
1127                    }),
1128                    end - start + 1,
1129                )
1130            }
1131            Tag::Image {
1132                link_type,
1133                dest_url,
1134                title,
1135                ..
1136            } => {
1137                // Collect alt text from text events between Start/End.
1138                let mut alt = String::new();
1139                let mut i = start + 1;
1140                while i < events.len() {
1141                    match &events[i] {
1142                        Event::End(TagEnd::Image) => break,
1143                        Event::Text(t) => alt.push_str(t),
1144                        Event::Code(c) => alt.push_str(c),
1145                        // `alt` is a plain-text attribute AND (via the
1146                        // implicit-figure path) the visible `<figcaption>`,
1147                        // so math is carried as its markdown source, not as
1148                        // the `<code>` node. Dropping it deleted the
1149                        // equation from both surfaces.
1150                        Event::InlineMath(t) => alt.push_str(&math_source(t, false)),
1151                        Event::DisplayMath(t) => alt.push_str(&math_source(t, true)),
1152                        _ => {}
1153                    }
1154                    i += 1;
1155                }
1156                // PR3.5 (2026-05-28): for wikilink images (`![[file]]` /
1157                // `![[file|pothole]]`), pulldown-cmark synthesizes text
1158                // events that aren't always author-intended alt:
1159                //   - `![[logo.png]]` → text "logo.png" (synthesized from
1160                //     dest); production treats as empty alt.
1161                //   - `![[logo.png|contain center]]` → text "contain center"
1162                //     (display-attrs); production classifies as styling,
1163                //     NOT alt.
1164                //   - `![[logo.png|width=400]]` → text "width=400" (typed
1165                //     params); production classifies as params, NOT alt.
1166                //   - `![[logo.png|My caption]]` → text "My caption";
1167                //     genuine alt.
1168                //
1169                // Without this classification, PR3's Block::Figure
1170                // detection (Wave 1) promotes wikilink-image paragraphs
1171                // with synth-derived "alt" to Figure with bogus
1172                // figcaptions ("logo.png", "contain center"). Match
1173                // production's transform_events wikilink-dispatch by
1174                // running the same classifiers (`is_all_display_keywords`
1175                // + `parse_pothole_params`) here.
1176                //
1177                // PR7a-flip-core-B (2026-05-28): preserve the ORIGINAL
1178                // pothole text on `Inline::Image.wikilink_pothole`
1179                // BEFORE alt-classification consumes it.
1180                // `dispatch_wikilink_embeds` needs the raw pothole to
1181                // route `![[v.mp4|width=400]]` → typed video synth with
1182                // the `width=400` param intact (alt-classification would
1183                // erase it). The pothole is the substring after `|`;
1184                // pulldown-cmark gives us the synthesized text, so we
1185                // strip the dest synth case (text == dest_url ⇒ no
1186                // pothole) and otherwise carry the trimmed alt.
1187                let is_wikilink_image =
1188                    matches!(link_type, pulldown_cmark::LinkType::WikiLink { .. });
1189                let wikilink_pothole: Option<String> = if is_wikilink_image {
1190                    let dest_str: &str = dest_url;
1191                    let trimmed = alt.trim();
1192                    if trimmed.is_empty() || trimmed == dest_str {
1193                        None
1194                    } else {
1195                        Some(trimmed.to_string())
1196                    }
1197                } else {
1198                    None
1199                };
1200                if is_wikilink_image {
1201                    let dest_str: &str = dest_url;
1202                    let trimmed = alt.trim().to_string();
1203                    if trimmed.is_empty() || trimmed == dest_str {
1204                        // Empty pothole OR pulldown-cmark synthesized
1205                        // dest_url as text → no author alt.
1206                        alt.clear();
1207                    } else if crate::media::is_all_display_keywords(&trimmed) {
1208                        // `contain center`, `left top`, etc. → display
1209                        // attrs (production maps to style), not alt.
1210                        alt.clear();
1211                    } else {
1212                        use crate::resolve::wikilink_dispatch::{
1213                            parse_pothole_params, PotholeContent,
1214                        };
1215                        match parse_pothole_params(&trimmed) {
1216                            PotholeContent::Empty | PotholeContent::Params(_) => {
1217                                alt.clear();
1218                            }
1219                            PotholeContent::WidthToken { rest_alias, .. } => {
1220                                alt = rest_alias;
1221                            }
1222                            PotholeContent::Alias(text) => {
1223                                // `parse_pothole_params` classifies a content-relative
1224                                // percent (e.g. `55%`) as `Alias` because it is not a
1225                                // named width token. Intercept it here: a bare percent
1226                                // is NOT a caption — strip it from the alt so it does
1227                                // not leak to `<figcaption>`. The actual width is
1228                                // recovered from `wikilink_pothole` by
1229                                // `dispatch_wikilink_embeds` (with-graph path) or
1230                                // directly from `split_alt_width` in the parser's
1231                                // `try_promote_to_figure` (no-graph path via `alt`).
1232                                //
1233                                // `split_alt_width` returns the remaining caption and
1234                                // the width token. If the whole alias was a width
1235                                // (nothing remaining), clear alt.
1236                                let (remaining, _w) = crate::media::split_alt_width(&text);
1237                                alt = remaining;
1238                            }
1239                        }
1240                    }
1241                }
1242                let title_opt = if title.is_empty() {
1243                    None
1244                } else {
1245                    Some(title.to_string())
1246                };
1247                (
1248                    Some(Inline::Image {
1249                        src: Url::unresolved(dest_url.to_string()),
1250                        alt,
1251                        title: title_opt,
1252                        is_wikilink: is_wikilink_image,
1253                        wikilink_pothole,
1254                    }),
1255                    i - start + 1,
1256                )
1257            }
1258            // Unmodeled inline container: skip to its End.
1259            _ => (None, 1),
1260        },
1261        // End / unhandled — caller handles.
1262        _ => (None, 1),
1263    }
1264}
1265
1266/// Collect a contiguous run of block events into `Vec<Block>`. Stops when
1267/// `is_end(event)` returns true or events run out.
1268fn collect_blocks_until<F>(
1269    events: &[Event<'_>],
1270    start: usize,
1271    line_ctx: Option<&LineCtx<'_>>,
1272    is_end: F,
1273) -> (Vec<Block>, usize)
1274where
1275    F: Fn(&Event<'_>) -> bool,
1276{
1277    let mut out: Vec<Block> = Vec::new();
1278    let mut i = start;
1279    while i < events.len() {
1280        if is_end(&events[i]) {
1281            return (out, i);
1282        }
1283        let (block, advance) = parse_block(events, i, line_ctx);
1284        if let Some(b) = block {
1285            out.push(b);
1286        }
1287        i += advance.max(1);
1288    }
1289    (out, i)
1290}
1291
1292/// Collect the children of a `Tag::Item` until the matching `End(Item)`.
1293///
1294/// Pulldown-cmark's **tight-list** mode emits item contents as inline
1295/// events (Text/Code/SoftBreak/inline-tag Start...) DIRECTLY inside
1296/// `Tag::Item` without wrapping in `Tag::Paragraph`. The plain
1297/// [`collect_blocks_until`] dispatcher would route those events through
1298/// [`parse_block`], which drops stray inlines — yielding empty `<li></li>`.
1299///
1300/// This helper preserves both modes:
1301/// - Inline events accumulate into a synthesized [`Block::Paragraph`] that
1302///   is flushed when a block-level event (Tag::Paragraph, Tag::List,
1303///   nested Tag::Item, etc.) appears or at the end of the item.
1304/// - Block-level events are parsed via [`parse_block_with_tag`] (the
1305///   standard path).
1306///
1307/// The renderer recognises a single-paragraph item shape and emits
1308/// `<li>...inline...</li>` without an inner `<p>`, matching production's
1309/// tight-list output byte-for-byte.
1310fn collect_item_blocks(
1311    events: &[Event<'_>],
1312    start: usize,
1313    line_ctx: Option<&LineCtx<'_>>,
1314) -> (Vec<Block>, usize) {
1315    let mut out: Vec<Block> = Vec::new();
1316    let mut pending_inlines: Vec<Inline> = Vec::new();
1317    let mut i = start;
1318    while i < events.len() {
1319        if matches!(&events[i], Event::End(TagEnd::Item)) {
1320            flush_pending_paragraph(&mut out, &mut pending_inlines);
1321            return (out, i);
1322        }
1323        if let Some((inline, advance)) = parse_inline_event(events, i) {
1324            if let Some(node) = inline {
1325                pending_inlines.push(node);
1326            }
1327            i += advance.max(1);
1328            continue;
1329        }
1330        // Block-level event: flush any accumulated inlines, then parse
1331        // through the standard dispatcher.
1332        flush_pending_paragraph(&mut out, &mut pending_inlines);
1333        let (block, advance) = parse_block(events, i, line_ctx);
1334        if let Some(b) = block {
1335            out.push(b);
1336        }
1337        i += advance.max(1);
1338    }
1339    flush_pending_paragraph(&mut out, &mut pending_inlines);
1340    (out, i)
1341}
1342
1343/// Phase 4 PR4: detect a callout marker inside a blockquote and, if
1344/// found, assemble the entire `Block::Callout` (with body blocks).
1345///
1346/// `start` is the event index AFTER `Start(BlockQuote)`. Returns
1347/// `Some((Block::Callout, end_index))` where `end_index` is the event
1348/// index of the matching `End(TagEnd::BlockQuote(_))`, so the outer
1349/// caller can compute the advance. Returns `None` for plain
1350/// blockquotes (no `[!type]` marker on the first paragraph).
1351///
1352/// Detection rule (shape-spec § 1):
1353/// - The first event must be `Start(Tag::Paragraph)`.
1354/// - The leading `Event::Text` run (before the first `SoftBreak` or
1355///   any non-Text inline event) must match `[!<kind>]`, optionally
1356///   followed by `+` or `-` for foldable callouts, optionally followed
1357///   by space + inline title.
1358/// - The kind is canonicalized via [`CalloutKind::from_raw`]; unknown
1359///   kinds fall back to [`CalloutKind::Note`]. (Diagnostic threading
1360///   is a Phase 4 followup — `validation::Diagnostic` is scoped to
1361///   frontmatter validation today.)
1362///
1363/// Why detection runs on events (not parsed children): the inline
1364/// parser collapses `SoftBreak` events into `Inline::Text` (in PR4.5,
1365/// emitting `"\n"` to match pulldown-cmark's `push_html`), which makes
1366/// the marker-line vs body-line boundary an embedded `\n` rather than a
1367/// distinct AST node. Working at the event layer preserves the
1368/// SoftBreak boundary so we can split "title" (before SoftBreak) from
1369/// "body" (after SoftBreak) correctly.
1370fn detect_and_assemble_callout(
1371    events: &[Event<'_>],
1372    start: usize,
1373    line_ctx: Option<&LineCtx<'_>>,
1374) -> Option<(Block, usize)> {
1375    if !matches!(events.get(start), Some(Event::Start(Tag::Paragraph))) {
1376        return None;
1377    }
1378    // Coalesce the leading run of `Event::Text` into one logical
1379    // string. Stops at SoftBreak, HardBreak, any Start/End tag, or
1380    // any non-Text inline.
1381    //
1382    // Math events join the run as their markdown source. `Callout.title`
1383    // is a `String`, so source text is the only shape it can hold — and
1384    // breaking here instead would not merely drop the equation, it would
1385    // TRUNCATE the title at the first `$` and spill the remainder into the
1386    // callout body (`[!note] Energy $E=mc^2$ explained` → title "Energy ").
1387    // If a later phase needs a typed title, this is the line that has to
1388    // become `Vec<Inline>`.
1389    let mut leading = String::new();
1390    let mut i = start + 1;
1391    while let Some(event) = events.get(i) {
1392        match event {
1393            Event::Text(t) => {
1394                leading.push_str(t);
1395                i += 1;
1396            }
1397            Event::InlineMath(t) => {
1398                leading.push_str(&math_source(t, false));
1399                i += 1;
1400            }
1401            Event::DisplayMath(t) => {
1402                leading.push_str(&math_source(t, true));
1403                i += 1;
1404            }
1405            _ => break,
1406        }
1407    }
1408    if leading.is_empty() {
1409        return None;
1410    }
1411
1412    let (raw_kind, fold, title, _marker_byte_len) = parse_callout_marker(&leading)?;
1413    let kind = CalloutKind::from_raw(raw_kind).unwrap_or(CalloutKind::Note);
1414    let title: Option<String> = title.map(|s| s.to_string()).filter(|s| !s.is_empty());
1415
1416    // We've consumed the leading Text events. `i` now points at the
1417    // first non-Text event in the (still-open) marker paragraph.
1418    //
1419    // Three shapes from here:
1420    //   (A) SoftBreak / HardBreak → body lines continue in the same
1421    //       Paragraph. Skip the break, then collect inlines until
1422    //       End(Paragraph). Wrap them in a synthetic Block::Paragraph.
1423    //   (B) End(Paragraph) immediately → marker-only callout (no body
1424    //       in the marker paragraph). Skip End(Paragraph).
1425    //   (C) Another inline event (Start(Emphasis), Code, etc.) → the
1426    //       marker was actually followed by inline markup on the same
1427    //       line. Currently treated as title continuation — but we
1428    //       lack a clean event-level coalescer for inline tags, so we
1429    //       just collect remaining inlines and wrap them as a body
1430    //       paragraph. The author can use a separator paragraph for
1431    //       clarity if they want clean title isolation.
1432    let mut body_blocks: Vec<Block> = Vec::new();
1433    // This match chooses WHERE the callout body starts; it does not collect
1434    // content. A math event directly after the marker falls into the `_` arm,
1435    // which starts the body at `i` and hands it to the math-aware
1436    // collect_inlines_until. Pinned by
1437    // `callout_title_is_not_truncated_at_the_first_dollar`.
1438    // allow:math-events-ignored — see above.
1439    let body_paragraph_start: Option<usize> = match events.get(i) {
1440        Some(Event::SoftBreak) | Some(Event::HardBreak) => {
1441            // Skip the break; collect remaining inlines for the body
1442            // paragraph.
1443            Some(i + 1)
1444        }
1445        Some(Event::End(TagEnd::Paragraph)) => {
1446            // Marker was the entire paragraph. Skip past End.
1447            i += 1;
1448            None
1449        }
1450        _ => {
1451            // Other inline events directly following the marker —
1452            // collect them as body paragraph content. (Edge case;
1453            // see method comment.)
1454            Some(i)
1455        }
1456    };
1457
1458    if let Some(body_start) = body_paragraph_start {
1459        // Collect inlines until End(Paragraph) and synthesize a
1460        // Block::Paragraph for the marker-paragraph body content.
1461        let (body_inlines, after_para) = collect_inlines_until(events, body_start, |e| {
1462            matches!(e, Event::End(TagEnd::Paragraph))
1463        });
1464        // Skip past End(Paragraph) itself.
1465        i = after_para + 1;
1466        // Trim leading whitespace-only Text inlines (e.g. if the
1467        // line-break Text(" ") leaks through).
1468        let trimmed_empty = body_inlines.iter().all(|x| match x {
1469            Inline::Text(t) => t.trim().is_empty(),
1470            _ => false,
1471        });
1472        if !trimmed_empty {
1473            body_blocks.push(Block::Paragraph(body_inlines));
1474        }
1475    }
1476
1477    // Continue collecting subsequent blocks until End(BlockQuote).
1478    while let Some(event) = events.get(i) {
1479        if matches!(event, Event::End(TagEnd::BlockQuote(_))) {
1480            break;
1481        }
1482        let (block, advance) = parse_block(events, i, line_ctx);
1483        if let Some(b) = block {
1484            body_blocks.push(b);
1485        }
1486        i += advance.max(1);
1487    }
1488
1489    // `i` now points at `End(BlockQuote)`. Return total event
1490    // span: outer caller computes `i - start + 1` (where `start` here
1491    // is the pre-Start-BlockQuote index in the outer scope; but we
1492    // were called with `start = outer_start + 1`, so the outer
1493    // caller's `start` correctly indexes the opening `Start(BlockQuote)`).
1494    // Per the call shape in `parse_block_with_tag` Tag::BlockQuote arm:
1495    //   `match detect_and_assemble_callout(events, start + 1)`
1496    //   `Some((block, body_end)) => (Some(block), body_end - start + 1)`
1497    // we must return `body_end = i` (the `End(BlockQuote)` index).
1498    let block = Block::Callout {
1499        kind,
1500        fold,
1501        title,
1502        children: body_blocks,
1503    };
1504    Some((block, i))
1505}
1506
1507/// Parse the leading text of a callout-shaped paragraph.
1508///
1509/// Accepts text shaped like `[!kind] title text…`, `[!kind]+ title`,
1510/// `[!kind]-`, etc. Returns:
1511/// - `raw_kind` — the kind identifier verbatim (lowercased on
1512///   canonicalization, not here).
1513/// - `fold` — `Some(Fold::Open)` for `+`, `Some(Fold::Closed)` for `-`,
1514///   `None` otherwise.
1515/// - `title` — `Some(title_text)` when text follows the marker (space
1516///   separator consumed); `None` when the marker is the entire string.
1517///   Title may be empty (`""`) if author wrote `[!note] ` with trailing
1518///   whitespace only — caller treats empty as None.
1519/// - `marker_byte_len` — number of bytes from the start of `text` that
1520///   constituted the marker + the single separator space (if any). The
1521///   caller slices `&text[marker_byte_len..]` to recover trailing body
1522///   text that should stay in the paragraph (multi-line callouts where
1523///   pulldown-cmark concatenated lines).
1524fn parse_callout_marker(text: &str) -> Option<(&str, Option<Fold>, Option<&str>, usize)> {
1525    let after_open = text.strip_prefix("[!")?;
1526    let close_offset = after_open.find(']')?;
1527    let raw_kind = &after_open[..close_offset];
1528    if raw_kind.is_empty() || raw_kind.chars().any(|c| c.is_whitespace()) {
1529        return None;
1530    }
1531    // Offset within `text` immediately after the `]`.
1532    let after_bracket_offset = 2 + close_offset + 1;
1533    let rest = &text[after_bracket_offset..];
1534
1535    let (fold, after_fold_offset) = match rest.chars().next() {
1536        Some('+') => (Some(Fold::Open), after_bracket_offset + 1),
1537        Some('-') => (Some(Fold::Closed), after_bracket_offset + 1),
1538        _ => (None, after_bracket_offset),
1539    };
1540
1541    let rest_after_fold = &text[after_fold_offset..];
1542    let (title, marker_byte_len) = if rest_after_fold.is_empty() {
1543        // Marker only, no title segment.
1544        (None, after_fold_offset)
1545    } else if let Some(remainder) = rest_after_fold.strip_prefix(' ') {
1546        // ` title text…` — title is everything in this coalesced
1547        // leading-text string. Pulldown-cmark splits line breaks into
1548        // SoftBreak inlines, so this Text inline never contains
1549        // newlines; the title is bounded by the next non-Text inline.
1550        let title_str = remainder;
1551        let consumed = after_fold_offset + 1 + remainder.len();
1552        (Some(title_str), consumed)
1553    } else {
1554        // No separator after marker but more text follows (e.g.
1555        // `[!note]+body` with no space). Treat as no title; keep the
1556        // text intact.
1557        (None, after_fold_offset)
1558    };
1559
1560    Some((raw_kind, fold, title, marker_byte_len))
1561}
1562
1563/// If `events[i]` is an inline-level event, parse it via the existing
1564/// [`parse_inline`] machinery and return `(inline, advance)`. Returns
1565/// `None` for block-level events, end tags, or anything the inline
1566/// dispatcher doesn't own — letting the caller fall back to the block
1567/// path.
1568fn parse_inline_event(events: &[Event<'_>], i: usize) -> Option<(Option<Inline>, usize)> {
1569    match &events[i] {
1570        Event::Text(_)
1571        | Event::Code(_)
1572        | Event::Html(_)
1573        | Event::InlineHtml(_)
1574        | Event::SoftBreak
1575        | Event::HardBreak
1576        // Math events are inline leaves. This whitelist is the ONLY way they
1577        // reach `parse_inline` from `collect_item_blocks` (its sole caller),
1578        // so omitting them deletes math in LIST ITEMS while paragraph math
1579        // still looks fine — a wiring failure a mechanism test cannot see.
1580        // Table cells/blockquotes take other routes (tests/math_parsing.rs).
1581        | Event::InlineMath(_)
1582        | Event::DisplayMath(_) => Some(parse_inline(events, i)),
1583        Event::Start(tag) => match tag {
1584            Tag::Emphasis | Tag::Strong | Tag::Link { .. } | Tag::Image { .. } => {
1585                Some(parse_inline(events, i))
1586            }
1587            _ => None,
1588        },
1589        _ => None,
1590    }
1591}
1592
1593/// Drain `pending_inlines` into a [`Block::Paragraph`] appended to `out`,
1594/// unless it's empty. No-op when there are no pending inlines.
1595fn flush_pending_paragraph(out: &mut Vec<Block>, pending_inlines: &mut Vec<Inline>) {
1596    if !pending_inlines.is_empty() {
1597        out.push(Block::Paragraph(std::mem::take(pending_inlines)));
1598    }
1599}
1600
1601/// Post-parse pass: walk every heading in document order (recursively
1602/// descending into BlockQuote, List items, and Callout children) and
1603/// disambiguate duplicate IDs by appending `-1`, `-2`, … to the slug.
1604///
1605/// Mirrors the `id_counts: HashMap<String, usize>` behavior at
1606/// `src-tauri/src/build/markdown/pipeline.rs:1798-1805`:
1607///
1608/// - First occurrence of slug `foo` keeps id `foo`; counter starts at 1.
1609/// - Second occurrence becomes `foo-1`; counter becomes 2.
1610/// - Third occurrence becomes `foo-2`; counter becomes 3.
1611///
1612/// Headings whose base slug is `None` (shouldn't happen post-PR2, but
1613/// safe-guarded) are left untouched.
1614fn assign_heading_id_suffixes(blocks: &mut [Block]) {
1615    let mut id_counts: HashMap<String, usize> = HashMap::new();
1616    assign_heading_id_suffixes_walk(blocks, &mut id_counts);
1617}
1618
1619fn assign_heading_id_suffixes_walk(blocks: &mut [Block], id_counts: &mut HashMap<String, usize>) {
1620    for block in blocks.iter_mut() {
1621        match block {
1622            Block::Heading { id, .. } => {
1623                if let Some(slug) = id {
1624                    let count_entry = id_counts.entry(slug.clone()).or_insert(0);
1625                    let count = *count_entry;
1626                    if count > 0 {
1627                        *id = Some(format!("{}-{}", slug, count));
1628                    }
1629                    *count_entry = count + 1;
1630                }
1631            }
1632            Block::BlockQuote(children) | Block::Callout { children, .. } => {
1633                assign_heading_id_suffixes_walk(children, id_counts);
1634            }
1635            Block::List { items, .. } => {
1636                for item in items.iter_mut() {
1637                    assign_heading_id_suffixes_walk(item, id_counts);
1638                }
1639            }
1640            // Tables/CodeBlocks/Shortcodes/Paragraphs/ThematicBreak/Other
1641            // cannot contain block-level headings — nothing to descend
1642            // into. Shortcode bodies (Hero overlay, Grid cells) currently
1643            // carry their content as String (pre-PR4.5); once promoted to
1644            // Vec<Block>, this walker will need to descend there too.
1645            _ => {}
1646        }
1647    }
1648}
1649
1650#[cfg(test)]
1651mod tests {
1652    use super::super::node::{CalloutKind, Fold, Inline};
1653    use super::*;
1654
1655    fn first_block(md: &str) -> Block {
1656        parse(md)
1657            .blocks
1658            .into_iter()
1659            .next()
1660            .expect("at least one block")
1661    }
1662
1663    // -----------------------------------------------------------------
1664    // Phase 4 PR4: Block::Callout migration + Obsidian alias canonicalization
1665    // -----------------------------------------------------------------
1666
1667    #[test]
1668    fn parses_basic_callout_with_inline_title() {
1669        match first_block("> [!note] Heads up\n> Body line 1.\n") {
1670            Block::Callout {
1671                kind,
1672                fold,
1673                title,
1674                children,
1675            } => {
1676                assert_eq!(kind, CalloutKind::Note);
1677                assert!(fold.is_none(), "non-foldable callout");
1678                assert_eq!(title.as_deref(), Some("Heads up"));
1679                assert!(!children.is_empty(), "body should remain");
1680            }
1681            other => panic!("expected Callout, got {other:?}"),
1682        }
1683    }
1684
1685    #[test]
1686    fn parses_titleless_callout() {
1687        match first_block("> [!warning]\n> Watch out.\n") {
1688            Block::Callout {
1689                kind,
1690                fold,
1691                title,
1692                children,
1693            } => {
1694                assert_eq!(kind, CalloutKind::Warning);
1695                assert!(fold.is_none());
1696                assert!(title.is_none(), "no inline title");
1697                assert!(!children.is_empty());
1698            }
1699            other => panic!("expected Callout, got {other:?}"),
1700        }
1701    }
1702
1703    #[test]
1704    fn callout_alias_tldr_canonicalizes_to_abstract() {
1705        match first_block("> [!tldr] Short summary\n> body\n") {
1706            Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Abstract),
1707            other => panic!("expected Callout, got {other:?}"),
1708        }
1709    }
1710
1711    #[test]
1712    fn callout_alias_hint_canonicalizes_to_tip() {
1713        match first_block("> [!hint] Pro tip\n> body\n") {
1714            Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Tip),
1715            other => panic!("expected Callout, got {other:?}"),
1716        }
1717    }
1718
1719    #[test]
1720    fn callout_alias_important_canonicalizes_to_tip() {
1721        match first_block("> [!important] Read this\n> body\n") {
1722            Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Tip),
1723            other => panic!("expected Callout, got {other:?}"),
1724        }
1725    }
1726
1727    #[test]
1728    fn callout_alias_check_done_canonicalizes_to_success() {
1729        for alias in &["check", "done"] {
1730            let md = format!("> [!{alias}] Yes\n> body\n");
1731            match first_block(&md) {
1732                Block::Callout { kind, .. } => assert_eq!(
1733                    kind,
1734                    CalloutKind::Success,
1735                    "alias `{alias}` should canonicalize to Success"
1736                ),
1737                other => panic!("alias `{alias}` — expected Callout, got {other:?}"),
1738            }
1739        }
1740    }
1741
1742    #[test]
1743    fn callout_alias_help_faq_canonicalizes_to_question() {
1744        for alias in &["help", "faq"] {
1745            let md = format!("> [!{alias}] question\n> body\n");
1746            match first_block(&md) {
1747                Block::Callout { kind, .. } => assert_eq!(
1748                    kind,
1749                    CalloutKind::Question,
1750                    "alias `{alias}` should canonicalize to Question"
1751                ),
1752                other => panic!("alias `{alias}` — expected Callout, got {other:?}"),
1753            }
1754        }
1755    }
1756
1757    #[test]
1758    fn callout_alias_caution_attention_canonicalizes_to_warning() {
1759        for alias in &["caution", "attention"] {
1760            let md = format!("> [!{alias}] careful\n> body\n");
1761            match first_block(&md) {
1762                Block::Callout { kind, .. } => assert_eq!(
1763                    kind,
1764                    CalloutKind::Warning,
1765                    "alias `{alias}` should canonicalize to Warning"
1766                ),
1767                other => panic!("alias `{alias}` — expected Callout, got {other:?}"),
1768            }
1769        }
1770    }
1771
1772    #[test]
1773    fn callout_alias_fail_missing_canonicalizes_to_failure() {
1774        for alias in &["fail", "missing"] {
1775            let md = format!("> [!{alias}] oops\n> body\n");
1776            match first_block(&md) {
1777                Block::Callout { kind, .. } => assert_eq!(
1778                    kind,
1779                    CalloutKind::Failure,
1780                    "alias `{alias}` should canonicalize to Failure"
1781                ),
1782                other => panic!("alias `{alias}` — expected Callout, got {other:?}"),
1783            }
1784        }
1785    }
1786
1787    #[test]
1788    fn callout_alias_error_canonicalizes_to_danger() {
1789        match first_block("> [!error] bad\n> body\n") {
1790            Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Danger),
1791            other => panic!("expected Callout, got {other:?}"),
1792        }
1793    }
1794
1795    #[test]
1796    fn callout_alias_cite_canonicalizes_to_quote() {
1797        match first_block("> [!cite] source\n> body\n") {
1798            Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Quote),
1799            other => panic!("expected Callout, got {other:?}"),
1800        }
1801    }
1802
1803    #[test]
1804    fn callout_foldable_open_suffix() {
1805        match first_block("> [!note]+ Open by default\n> body\n") {
1806            Block::Callout {
1807                kind, fold, title, ..
1808            } => {
1809                assert_eq!(kind, CalloutKind::Note);
1810                assert_eq!(fold, Some(Fold::Open));
1811                assert_eq!(title.as_deref(), Some("Open by default"));
1812            }
1813            other => panic!("expected Callout, got {other:?}"),
1814        }
1815    }
1816
1817    #[test]
1818    fn callout_foldable_closed_suffix() {
1819        match first_block("> [!note]- Closed by default\n> body\n") {
1820            Block::Callout {
1821                kind, fold, title, ..
1822            } => {
1823                assert_eq!(kind, CalloutKind::Note);
1824                assert_eq!(fold, Some(Fold::Closed));
1825                assert_eq!(title.as_deref(), Some("Closed by default"));
1826            }
1827            other => panic!("expected Callout, got {other:?}"),
1828        }
1829    }
1830
1831    #[test]
1832    fn callout_foldable_without_title() {
1833        match first_block("> [!tip]+\n> body\n") {
1834            Block::Callout {
1835                kind, fold, title, ..
1836            } => {
1837                assert_eq!(kind, CalloutKind::Tip);
1838                assert_eq!(fold, Some(Fold::Open));
1839                assert!(title.is_none());
1840            }
1841            other => panic!("expected Callout, got {other:?}"),
1842        }
1843    }
1844
1845    #[test]
1846    fn callout_unknown_kind_falls_back_to_note() {
1847        // Per shape-spec § 1 — unknown kind canonicalizes to Note.
1848        // Diagnostic emission is a Phase 4 followup (see parser.rs
1849        // `promote_callout` comment).
1850        match first_block("> [!unknownkind] body\n") {
1851            Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Note),
1852            other => panic!("expected Callout (fallback to Note), got {other:?}"),
1853        }
1854    }
1855
1856    #[test]
1857    fn callout_multi_paragraph_body_preserves_blocks() {
1858        let md = "> [!info] Multi\n> First paragraph.\n>\n> Second paragraph.\n";
1859        match first_block(md) {
1860            Block::Callout {
1861                kind,
1862                title,
1863                children,
1864                ..
1865            } => {
1866                assert_eq!(kind, CalloutKind::Info);
1867                assert_eq!(title.as_deref(), Some("Multi"));
1868                // pulldown-cmark emits two paragraphs in the blockquote
1869                // body when separated by an empty `>` line.
1870                let para_count = children
1871                    .iter()
1872                    .filter(|b| matches!(b, Block::Paragraph(_)))
1873                    .count();
1874                assert!(
1875                    para_count >= 2,
1876                    "expected at least 2 paragraphs, got {children:?}"
1877                );
1878            }
1879            other => panic!("expected Callout, got {other:?}"),
1880        }
1881    }
1882
1883    #[test]
1884    fn callout_nested_inside_callout() {
1885        // The docs promise nested callouts. After PR4 the outer is
1886        // Block::Callout containing an inner Block::Callout in its
1887        // children (no Stage 1 rewrite needed).
1888        let md = "> [!warning] Outer\n> Outer content.\n>\n> > [!tip] Inner\n> > Inner content.\n";
1889        match first_block(md) {
1890            Block::Callout {
1891                kind: outer_kind,
1892                children,
1893                ..
1894            } => {
1895                assert_eq!(outer_kind, CalloutKind::Warning);
1896                let inner = children.iter().find_map(|b| match b {
1897                    Block::Callout { kind, title, .. } => Some((*kind, title.clone())),
1898                    _ => None,
1899                });
1900                let (inner_kind, inner_title) =
1901                    inner.expect("inner Block::Callout missing from outer's children");
1902                assert_eq!(inner_kind, CalloutKind::Tip);
1903                assert_eq!(inner_title.as_deref(), Some("Inner"));
1904            }
1905            other => panic!("expected outer Callout, got {other:?}"),
1906        }
1907    }
1908
1909    #[test]
1910    fn plain_blockquote_without_marker_stays_blockquote() {
1911        // Regression: an ordinary blockquote (no `[!type]` marker) must
1912        // remain Block::BlockQuote — only callout-shaped blockquotes
1913        // promote.
1914        match first_block("> Just a quote.\n> More of the quote.\n") {
1915            Block::BlockQuote(_) => {} // expected
1916            other => panic!("expected BlockQuote, got {other:?}"),
1917        }
1918    }
1919
1920    #[test]
1921    fn blockquote_with_text_starting_like_callout_but_unknown_kind_still_promotes() {
1922        // The marker `[!xyz]` is structurally a callout — we promote
1923        // and fall back to Note (per shape-spec). The author can fix
1924        // by removing the bracket prefix if they wanted a plain quote.
1925        match first_block("> [!xyz] not a real kind\n> body\n") {
1926            Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Note),
1927            other => panic!("expected Callout fallback, got {other:?}"),
1928        }
1929    }
1930
1931    #[test]
1932    fn callout_case_insensitive_kind() {
1933        // Stage 1 was case-insensitive; preserve that contract.
1934        match first_block("> [!WARNING] Loud\n> body\n") {
1935            Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Warning),
1936            other => panic!("expected Callout, got {other:?}"),
1937        }
1938    }
1939
1940    #[test]
1941    fn callout_pending_alias_canonicalizes_to_todo() {
1942        // SoCiviC Theatre's voices.md uses `> [!pending]` — carried
1943        // over from Stage 1 support.
1944        match first_block("> [!pending] Trailer video\n> Add when ready.\n") {
1945            Block::Callout { kind, title, .. } => {
1946                assert_eq!(kind, CalloutKind::Todo);
1947                assert_eq!(title.as_deref(), Some("Trailer video"));
1948            }
1949            other => panic!("expected Callout, got {other:?}"),
1950        }
1951    }
1952
1953    #[test]
1954    fn empty_input_yields_empty_document() {
1955        let d = parse("");
1956        assert!(d.blocks.is_empty());
1957    }
1958
1959    #[test]
1960    fn parses_h1_heading() {
1961        match first_block("# Hello\n") {
1962            Block::Heading {
1963                level,
1964                children,
1965                id,
1966            } => {
1967                assert_eq!(level, 1);
1968                // Phase 4 PR2: parser populates id with the Obsidian anchor slug.
1969                assert_eq!(id.as_deref(), Some("hello"));
1970                assert!(matches!(&children[0], Inline::Text(t) if t == "Hello"));
1971            }
1972            other => panic!("expected Heading, got {other:?}"),
1973        }
1974    }
1975
1976    #[test]
1977    fn parses_h6_heading() {
1978        match first_block("###### tiny\n") {
1979            Block::Heading { level, .. } => assert_eq!(level, 6),
1980            other => panic!("expected Heading, got {other:?}"),
1981        }
1982    }
1983
1984    #[test]
1985    fn parses_paragraph_with_text() {
1986        match first_block("hello world\n") {
1987            Block::Paragraph(children) => {
1988                // pulldown-cmark may split into multiple Text events; merge.
1989                let s: String = children
1990                    .iter()
1991                    .filter_map(|i| match i {
1992                        Inline::Text(t) => Some(t.as_str()),
1993                        _ => None,
1994                    })
1995                    .collect();
1996                assert_eq!(s, "hello world");
1997            }
1998            other => panic!("expected Paragraph, got {other:?}"),
1999        }
2000    }
2001
2002    #[test]
2003    fn parses_link_with_unresolved_url() {
2004        // Critical contract: every URL starts as Unresolved.
2005        match first_block("[Docs](docs/)\n") {
2006            Block::Paragraph(children) => match &children[0] {
2007                Inline::Link {
2008                    url,
2009                    title,
2010                    children,
2011                    is_wikilink,
2012                } => {
2013                    assert!(url.is_unresolved());
2014                    match url {
2015                        Url::Unresolved(s) => assert_eq!(s, "docs/"),
2016                        _ => unreachable!(),
2017                    }
2018                    assert!(title.is_none());
2019                    assert!(!is_wikilink, "standard markdown link is not a wikilink");
2020                    assert!(matches!(&children[0], Inline::Text(t) if t == "Docs"));
2021                }
2022                other => panic!("expected Link, got {other:?}"),
2023            },
2024            other => panic!("expected Paragraph, got {other:?}"),
2025        }
2026    }
2027
2028    #[test]
2029    fn parses_link_with_moss_resolved_prefix_unchanged() {
2030        // The upstream resolve pipeline emits this shape; the parser must
2031        // preserve it verbatim for the visitor to classify later.
2032        match first_block("[t](moss-resolved:foo.md)\n") {
2033            Block::Paragraph(children) => match &children[0] {
2034                Inline::Link {
2035                    url: Url::Unresolved(s),
2036                    ..
2037                } => assert_eq!(s, "moss-resolved:foo.md"),
2038                other => panic!("expected unresolved Link, got {other:?}"),
2039            },
2040            other => panic!("expected Paragraph, got {other:?}"),
2041        }
2042    }
2043
2044    #[test]
2045    fn parser_link_inherits_wikilink_from_pulldown_cmark() {
2046        // PR7a Decision 2: pulldown-cmark with ENABLE_WIKILINKS emits
2047        // `Tag::Link { link_type: LinkType::WikiLink, .. }` for `[[target]]`
2048        // syntax. The typed AST must preserve that discriminator via
2049        // `Inline::Link::is_wikilink`. After PR7a flips render_document
2050        // to production, this flag drives the `class="wikilink"` emission
2051        // on the <a> tag.
2052        match first_block("[[wikilink-target]]\n") {
2053            Block::Paragraph(children) => {
2054                let link = children
2055                    .iter()
2056                    .find(|i| matches!(i, Inline::Link { .. }))
2057                    .expect("expected an Inline::Link from [[…]]");
2058                match link {
2059                    Inline::Link { is_wikilink, .. } => {
2060                        assert!(
2061                            *is_wikilink,
2062                            "[[…]] must set is_wikilink: true on the typed AST"
2063                        );
2064                    }
2065                    _ => unreachable!(),
2066                }
2067            }
2068            other => panic!("expected Paragraph, got {other:?}"),
2069        }
2070
2071        // Negative case: a standard markdown link is NOT a wikilink.
2072        match first_block("[text](href)\n") {
2073            Block::Paragraph(children) => match &children[0] {
2074                Inline::Link { is_wikilink, .. } => {
2075                    assert!(!is_wikilink, "[](…) must set is_wikilink: false");
2076                }
2077                _ => panic!("expected Link"),
2078            },
2079            _ => panic!("expected Paragraph"),
2080        }
2081    }
2082
2083    #[test]
2084    fn parses_link_with_title() {
2085        match first_block(r#"[t](u "the title")"#) {
2086            Block::Paragraph(children) => match &children[0] {
2087                Inline::Link { title, .. } => assert_eq!(title.as_deref(), Some("the title")),
2088                other => panic!("expected Link, got {other:?}"),
2089            },
2090            other => panic!("expected Paragraph, got {other:?}"),
2091        }
2092    }
2093
2094    #[test]
2095    fn parses_image_with_alt() {
2096        // Phase 4 PR3 (2026-05-27): an image-only paragraph is now
2097        // promoted to Block::Figure. Inline::Image lives inside the
2098        // Figure variant; the URL/alt/title contract is unchanged.
2099        // For image+text (where Block::Paragraph still applies), see
2100        // `image_with_caption_text_does_not_promote` below.
2101        match first_block("![cat photo](cat.jpg)\n") {
2102            Block::Figure { image, caption, .. } => {
2103                match image {
2104                    Inline::Image {
2105                        src, alt, title, ..
2106                    } => {
2107                        assert!(src.is_unresolved());
2108                        assert_eq!(alt, "cat photo");
2109                        assert!(title.is_none());
2110                    }
2111                    other => panic!("expected Image inside Figure, got {other:?}"),
2112                }
2113                let cap = caption.expect("caption from alt text");
2114                assert_eq!(cap.len(), 1);
2115            }
2116            other => panic!("expected Figure, got {other:?}"),
2117        }
2118    }
2119
2120    #[test]
2121    fn parses_image_inside_paragraph_with_text() {
2122        // Companion to `parses_image_with_alt`: an image with sibling
2123        // prose stays as Block::Paragraph (no figure promotion). Holds
2124        // the parser's image-extraction contract for the non-figure case.
2125        match first_block("see ![cat photo](cat.jpg) here\n") {
2126            Block::Paragraph(children) => {
2127                let img = children
2128                    .iter()
2129                    .find(|i| matches!(i, Inline::Image { .. }))
2130                    .expect("expected Inline::Image among siblings");
2131                match img {
2132                    Inline::Image { src, alt, .. } => {
2133                        assert!(src.is_unresolved());
2134                        assert_eq!(alt, "cat photo");
2135                    }
2136                    _ => unreachable!(),
2137                }
2138            }
2139            other => panic!("expected Paragraph, got {other:?}"),
2140        }
2141    }
2142
2143    #[test]
2144    fn parses_emphasis_and_strong() {
2145        let para = parse("*em* and **strong**\n")
2146            .blocks
2147            .into_iter()
2148            .next()
2149            .unwrap();
2150        match para {
2151            Block::Paragraph(children) => {
2152                let has_em = children.iter().any(|i| matches!(i, Inline::Emphasis(_)));
2153                let has_strong = children.iter().any(|i| matches!(i, Inline::Strong(_)));
2154                assert!(has_em, "missing Emphasis: {children:?}");
2155                assert!(has_strong, "missing Strong: {children:?}");
2156            }
2157            _ => panic!("expected Paragraph"),
2158        }
2159    }
2160
2161    #[test]
2162    fn parses_inline_code() {
2163        match first_block("`some code`\n") {
2164            Block::Paragraph(children) => {
2165                assert!(matches!(&children[0], Inline::Code(c) if c == "some code"));
2166            }
2167            other => panic!("expected Paragraph, got {other:?}"),
2168        }
2169    }
2170
2171    #[test]
2172    fn parses_unordered_list() {
2173        match first_block("- one\n- two\n") {
2174            Block::List { ordered, items, .. } => {
2175                assert!(!ordered);
2176                assert_eq!(items.len(), 2);
2177            }
2178            other => panic!("expected List, got {other:?}"),
2179        }
2180    }
2181
2182    #[test]
2183    fn parser_handles_tight_list_items_with_inline_content() {
2184        // Phase 4 PR0.6 regression — pulldown-cmark's tight-list mode emits
2185        // inline events (Text/Strong/etc.) directly inside Tag::Item without
2186        // wrapping in Tag::Paragraph. Previously `parse_block` dropped these
2187        // stray inlines, producing empty <li></li> instead of the expected
2188        // <li><strong>bold</strong> text</li>.
2189        match first_block("- **bold** text\n- another item\n") {
2190            Block::List { ordered, items, .. } => {
2191                assert!(!ordered);
2192                assert_eq!(items.len(), 2, "expected two items, got {items:?}");
2193                let first_item = &items[0];
2194                assert_eq!(
2195                    first_item.len(),
2196                    1,
2197                    "tight item should synthesize a single Paragraph, got {first_item:?}"
2198                );
2199                match &first_item[0] {
2200                    Block::Paragraph(inlines) => {
2201                        let has_strong = inlines.iter().any(|i| matches!(i, Inline::Strong(_)));
2202                        let has_text = inlines
2203                            .iter()
2204                            .any(|i| matches!(i, Inline::Text(t) if t.contains("text")));
2205                        assert!(
2206                            has_strong,
2207                            "expected Inline::Strong inside item, got {inlines:?}"
2208                        );
2209                        assert!(has_text, "expected ' text' Inline::Text, got {inlines:?}");
2210                    }
2211                    other => panic!("expected Paragraph inside tight item, got {other:?}"),
2212                }
2213            }
2214            other => panic!("expected List, got {other:?}"),
2215        }
2216    }
2217
2218    #[test]
2219    fn tight_list_items_with_links_preserved() {
2220        // Mirrors folder-note-site/obsidian/index.md — wikilinks + images
2221        // inside list items. Today these parse as Inline::Link / Inline::Image;
2222        // the contract is just that the inline content is NOT dropped.
2223        match first_block("- [link](url)\n- ![alt](img.jpg)\n") {
2224            Block::List { items, .. } => {
2225                assert_eq!(items.len(), 2);
2226                let first = &items[0];
2227                assert_eq!(
2228                    first.len(),
2229                    1,
2230                    "expected one Block::Paragraph, got {first:?}"
2231                );
2232                match &first[0] {
2233                    Block::Paragraph(inlines) => {
2234                        assert!(
2235                            inlines.iter().any(|i| matches!(i, Inline::Link { .. })),
2236                            "expected Inline::Link, got {inlines:?}"
2237                        );
2238                    }
2239                    other => panic!("expected Paragraph, got {other:?}"),
2240                }
2241                let second = &items[1];
2242                match &second[0] {
2243                    Block::Paragraph(inlines) => {
2244                        assert!(
2245                            inlines.iter().any(|i| matches!(i, Inline::Image { .. })),
2246                            "expected Inline::Image, got {inlines:?}"
2247                        );
2248                    }
2249                    other => panic!("expected Paragraph, got {other:?}"),
2250                }
2251            }
2252            other => panic!("expected List, got {other:?}"),
2253        }
2254    }
2255
2256    #[test]
2257    fn loose_list_items_with_paragraphs_still_work() {
2258        // Loose-list mode (blank lines between items) emits items as
2259        // Tag::Paragraph-wrapped blocks. The fix must not break this path.
2260        let md = "- first item\n\n- second item\n";
2261        match first_block(md) {
2262            Block::List { items, .. } => {
2263                assert_eq!(items.len(), 2);
2264                for item in &items {
2265                    assert_eq!(item.len(), 1, "expected one block per item");
2266                    assert!(
2267                        matches!(&item[0], Block::Paragraph(_)),
2268                        "expected Paragraph, got {:?}",
2269                        item[0]
2270                    );
2271                }
2272            }
2273            other => panic!("expected List, got {other:?}"),
2274        }
2275    }
2276
2277    #[test]
2278    fn tight_list_items_with_nested_list_preserve_structure() {
2279        // - first
2280        //   - nested
2281        // The outer item carries inline "first" + a nested Block::List.
2282        let md = "- first\n  - nested\n";
2283        match first_block(md) {
2284            Block::List { items, .. } => {
2285                assert_eq!(items.len(), 1);
2286                let outer = &items[0];
2287                assert!(
2288                    outer.iter().any(|b| matches!(b, Block::Paragraph(_))),
2289                    "expected outer item to carry a Paragraph for 'first', got {outer:?}"
2290                );
2291                assert!(
2292                    outer.iter().any(|b| matches!(b, Block::List { .. })),
2293                    "expected outer item to carry a nested List, got {outer:?}"
2294                );
2295            }
2296            other => panic!("expected List, got {other:?}"),
2297        }
2298    }
2299
2300    #[test]
2301    fn parses_ordered_list() {
2302        match first_block("1. first\n2. second\n") {
2303            Block::List { ordered, items, .. } => {
2304                assert!(ordered);
2305                assert_eq!(items.len(), 2);
2306            }
2307            other => panic!("expected List, got {other:?}"),
2308        }
2309    }
2310
2311    #[test]
2312    fn parses_fenced_code_block_with_lang() {
2313        match first_block("```rust\nfn main() {}\n```\n") {
2314            Block::CodeBlock { lang, value } => {
2315                assert_eq!(lang.as_deref(), Some("rust"));
2316                assert!(value.contains("fn main"));
2317            }
2318            other => panic!("expected CodeBlock, got {other:?}"),
2319        }
2320    }
2321
2322    #[test]
2323    fn parses_fenced_code_block_without_lang() {
2324        match first_block("```\nbare\n```\n") {
2325            Block::CodeBlock { lang, value } => {
2326                assert!(lang.is_none());
2327                assert!(value.contains("bare"));
2328            }
2329            other => panic!("expected CodeBlock, got {other:?}"),
2330        }
2331    }
2332
2333    #[test]
2334    fn code_block_is_not_parsed_as_shortcode() {
2335        // Adversarial: the literal `:::buttons` inside a fenced code block
2336        // must NOT be treated as a shortcode. (Phase A's parser doesn't
2337        // recognize :::buttons at all yet; this test locks the contract.)
2338        let md = "```\n:::buttons\n[t](u)\n:::\n```\n";
2339        match first_block(md) {
2340            Block::CodeBlock { value, .. } => assert!(value.contains(":::buttons")),
2341            other => panic!("expected CodeBlock, got {other:?}"),
2342        }
2343    }
2344
2345    #[test]
2346    fn parses_blockquote() {
2347        match first_block("> quoted\n") {
2348            Block::BlockQuote(children) => {
2349                assert!(!children.is_empty());
2350            }
2351            other => panic!("expected BlockQuote, got {other:?}"),
2352        }
2353    }
2354
2355    #[test]
2356    fn parses_thematic_break() {
2357        match first_block("---\n") {
2358            Block::ThematicBreak => {}
2359            // Pulldown-cmark may emit a thematic break or treat `---` at the
2360            // start of a doc as a heading underline. Accept either by
2361            // checking that the parse produces SOMETHING.
2362            _other => {
2363                // Test the unambiguous mid-doc case.
2364                let d = parse("para\n\n---\n\nmore\n");
2365                let has_break = d.blocks.iter().any(|b| matches!(b, Block::ThematicBreak));
2366                assert!(
2367                    has_break,
2368                    "expected at least one ThematicBreak: {:?}",
2369                    d.blocks
2370                );
2371            }
2372        }
2373    }
2374
2375    #[test]
2376    fn parses_table() {
2377        let md = "| h1 | h2 |\n| --- | --- |\n| a | b |\n| c | d |\n";
2378        match first_block(md) {
2379            Block::Table { header, rows, .. } => {
2380                assert_eq!(header.len(), 2);
2381                assert_eq!(rows.len(), 2);
2382                assert_eq!(rows[0].len(), 2);
2383            }
2384            other => panic!("expected Table, got {other:?}"),
2385        }
2386    }
2387
2388    #[test]
2389    fn html_block_passes_through_as_other() {
2390        match first_block("<div class=\"raw\">hi</div>\n\n") {
2391            Block::Other(html) => assert!(html.contains("<div")),
2392            other => panic!("expected Other, got {other:?}"),
2393        }
2394    }
2395
2396    #[test]
2397    fn parses_multiple_blocks() {
2398        let d = parse("# T\n\npara\n\n- li\n");
2399        assert_eq!(d.blocks.len(), 3);
2400        assert!(matches!(d.blocks[0], Block::Heading { .. }));
2401        assert!(matches!(d.blocks[1], Block::Paragraph(_)));
2402        assert!(matches!(d.blocks[2], Block::List { .. }));
2403    }
2404
2405    #[test]
2406    fn frontmatter_only_input_is_handled() {
2407        // Frontmatter is stripped by upstream code before reaching the
2408        // parser. If somehow a `---\nfoo:bar\n---` reaches us, the parser
2409        // must not panic.
2410        let _ = parse("---\nfoo: bar\n---\n");
2411    }
2412
2413    #[test]
2414    fn link_inside_heading_is_preserved() {
2415        match first_block("# [t](u)\n") {
2416            Block::Heading { children, .. } => {
2417                assert!(matches!(&children[0], Inline::Link { .. }));
2418            }
2419            other => panic!("expected Heading, got {other:?}"),
2420        }
2421    }
2422
2423    // -----------------------------------------------------------------
2424    // Phase 4 PR2: heading ID injection
2425    // -----------------------------------------------------------------
2426
2427    fn heading_id(md: &str) -> Option<String> {
2428        let blocks = parse(md).blocks;
2429        for block in &blocks {
2430            if let Block::Heading { id, .. } = block {
2431                return id.clone();
2432            }
2433        }
2434        None
2435    }
2436
2437    #[test]
2438    fn heading_id_simple_phrase() {
2439        // SoCiviC `## Mission` baseline case.
2440        assert_eq!(heading_id("## Mission\n"), Some("mission".to_string()));
2441    }
2442
2443    #[test]
2444    fn heading_id_spaces_become_hyphens() {
2445        assert_eq!(
2446            heading_id("# Getting Started\n"),
2447            Some("getting-started".to_string())
2448        );
2449    }
2450
2451    #[test]
2452    fn heading_id_with_emphasis_uses_text_content() {
2453        // `*em*` inside a heading: the inner text is `em`, no surrounding
2454        // chars come from emphasis itself (production captures only Text/Code).
2455        assert_eq!(
2456            heading_id("# Hello *world*\n"),
2457            Some("hello-world".to_string())
2458        );
2459    }
2460
2461    #[test]
2462    fn heading_id_with_strong_uses_text_content() {
2463        assert_eq!(
2464            heading_id("# Bold **stuff**\n"),
2465            Some("bold-stuff".to_string())
2466        );
2467    }
2468
2469    #[test]
2470    fn heading_id_with_inline_link_uses_link_text() {
2471        // `# [Docs](url)` — the link label "Docs" comes through as Event::Text.
2472        assert_eq!(heading_id("# [Docs](url)\n"), Some("docs".to_string()));
2473    }
2474
2475    #[test]
2476    fn heading_id_with_inline_code_includes_code_payload() {
2477        // Production captures Event::Code, so `` `fn(x)` `` enters the slug.
2478        assert_eq!(
2479            heading_id("# call `fn(x)`\n"),
2480            Some("call-fn(x)".to_string())
2481        );
2482    }
2483
2484    #[test]
2485    fn heading_id_with_inline_html_strips_html() {
2486        // SoCiviC `# FAREWELL,<br>AND ERASE` — the `<br>` is Event::InlineHtml
2487        // and must NOT appear in the slug. Production's slug for this is
2488        // derived from "FAREWELL,AND ERASE".
2489        let id = heading_id("# FAREWELL,<br>AND ERASE\n").expect("heading id");
2490        // No `<br>` or `br` injected; punctuation preserved (`,`), spaces → `-`.
2491        assert!(!id.contains("br"), "got: {id}");
2492        assert_eq!(id, "farewell,and-erase");
2493    }
2494
2495    #[test]
2496    fn heading_id_cjk_preserved() {
2497        // 刘果's CJK headings exercise Unicode anchor normalization —
2498        // characters pass through unchanged (lowercase already, no whitespace).
2499        assert_eq!(heading_id("## 视频\n"), Some("视频".to_string()));
2500        assert_eq!(heading_id("## 中文标题\n"), Some("中文标题".to_string()));
2501    }
2502
2503    #[test]
2504    fn heading_id_obsidian_strip_chars() {
2505        // Pipes / brackets / hashes / backslashes / carets are stripped.
2506        assert_eq!(heading_id("# Note ^ref\n"), Some("note-ref".to_string()));
2507        assert_eq!(heading_id("# A | B\n"), Some("a-b".to_string()));
2508    }
2509
2510    #[test]
2511    fn duplicate_headings_get_suffixed_ids() {
2512        // Production behavior: first occurrence keeps slug; second gets `-1`,
2513        // third gets `-2`. The HashMap in pipeline.rs:1798 is the contract.
2514        let md = "# Mission\n\n# Mission\n\n# Mission\n";
2515        let doc = parse(md);
2516        let ids: Vec<Option<String>> = doc
2517            .blocks
2518            .iter()
2519            .filter_map(|b| match b {
2520                Block::Heading { id, .. } => Some(id.clone()),
2521                _ => None,
2522            })
2523            .collect();
2524        assert_eq!(
2525            ids,
2526            vec![
2527                Some("mission".to_string()),
2528                Some("mission-1".to_string()),
2529                Some("mission-2".to_string()),
2530            ]
2531        );
2532    }
2533
2534    #[test]
2535    fn duplicate_suffix_descends_into_blockquote() {
2536        // Headings inside a blockquote share the same id-counter as top-level.
2537        let md = "# Notes\n\n> # Notes\n";
2538        let doc = parse(md);
2539        let mut found_ids: Vec<String> = Vec::new();
2540        collect_heading_ids_recursive(&doc.blocks, &mut found_ids);
2541        assert_eq!(found_ids, vec!["notes".to_string(), "notes-1".to_string()]);
2542    }
2543
2544    fn collect_heading_ids_recursive(blocks: &[Block], out: &mut Vec<String>) {
2545        for b in blocks {
2546            match b {
2547                Block::Heading { id, .. } => {
2548                    if let Some(s) = id {
2549                        out.push(s.clone());
2550                    }
2551                }
2552                Block::BlockQuote(children) | Block::Callout { children, .. } => {
2553                    collect_heading_ids_recursive(children, out);
2554                }
2555                Block::List { items, .. } => {
2556                    for item in items {
2557                        collect_heading_ids_recursive(item, out);
2558                    }
2559                }
2560                _ => {}
2561            }
2562        }
2563    }
2564
2565    #[test]
2566    fn heading_id_empty_text_yields_empty_slug() {
2567        // Edge case: `# ###` strips to empty slug; suffix counter still ticks.
2568        // (obsidian_heading_anchor("") == "")
2569        let md = "# ###\n";
2570        let id = heading_id(md);
2571        assert_eq!(id, Some(String::new()));
2572    }
2573
2574    #[test]
2575    fn link_inside_emphasis_unwraps_correctly() {
2576        // *[link](u)* — emphasis wrapping a link is a real authoring pattern.
2577        match first_block("*[t](u)*\n") {
2578            Block::Paragraph(children) => match &children[0] {
2579                Inline::Emphasis(inner) => {
2580                    assert!(matches!(&inner[0], Inline::Link { .. }));
2581                }
2582                other => panic!("expected Emphasis, got {other:?}"),
2583            },
2584            other => panic!("expected Paragraph, got {other:?}"),
2585        }
2586    }
2587
2588    // -----------------------------------------------------------------
2589    // Phase 4 PR3 (2026-05-27): Block::Figure detection in Tag::Paragraph
2590    // -----------------------------------------------------------------
2591
2592    #[test]
2593    fn image_only_paragraph_promotes_to_figure() {
2594        // Canonical case: a paragraph containing exactly one image, no
2595        // sibling inline content, becomes Block::Figure. Caption defaults
2596        // to the image's alt text.
2597        match first_block("![A logo](logo.png)\n") {
2598            Block::Figure { image, caption, .. } => {
2599                match image {
2600                    Inline::Image { src, alt, .. } => {
2601                        assert!(src.is_unresolved());
2602                        assert_eq!(alt, "A logo");
2603                    }
2604                    other => panic!("expected Image inside Figure, got {other:?}"),
2605                }
2606                let cap = caption.expect("caption from alt text");
2607                assert_eq!(cap.len(), 1);
2608                assert!(matches!(&cap[0], Inline::Text(t) if t == "A logo"));
2609            }
2610            other => panic!("expected Figure, got {other:?}"),
2611        }
2612    }
2613
2614    #[test]
2615    fn image_only_paragraph_with_empty_alt_stays_as_paragraph() {
2616        // Empty-alt guard: a decorative image (no alt) does NOT promote
2617        // to Figure. Production's implicit-figure pass gates on
2618        // non-empty alt — wrapping a no-alt image in `<figure>` adds
2619        // visual noise (no figcaption text) without a11y benefit. The
2620        // bytes match production's `<p><img></p>` shape.
2621        //
2622        // Parity-probe evidence: pre-guard, 7 CJK 刘果 fixtures with
2623        // trailing empty-alt images flipped to "other" because the AST
2624        // emitted `<figure>` and prod did not. Guard restores parity.
2625        match first_block("![](logo.png)\n") {
2626            Block::Paragraph(children) => {
2627                assert_eq!(children.len(), 1);
2628                match &children[0] {
2629                    Inline::Image { alt, .. } => assert_eq!(alt, ""),
2630                    other => panic!("expected Image inside Paragraph, got {other:?}"),
2631                }
2632            }
2633            other => panic!("empty-alt image-only paragraph must stay as Paragraph, got {other:?}"),
2634        }
2635    }
2636
2637    #[test]
2638    fn image_with_whitespace_text_still_promotes_to_figure() {
2639        // Whitespace-only text or line-break siblings don't disqualify
2640        // (matches transform_events' "image-only modulo whitespace"
2641        // behavior). Verifying via a wikilink + trailing whitespace would
2642        // require an actual whitespace event; pulldown-cmark typically
2643        // strips this. The detector is defensive for the cases that
2644        // DO surface whitespace inlines (line breaks after the image).
2645        let md = "![alt](a.jpg)  \n";
2646        // The trailing "  \n" inside a paragraph emits a HardBreak event
2647        // (Inline::LineBreak). Promotion must still succeed.
2648        match first_block(md) {
2649            Block::Figure { image, .. } => assert!(matches!(image, Inline::Image { .. })),
2650            // pulldown-cmark may also collapse this differently; accept
2651            // Paragraph(LineBreak) as a tolerated fallback so the test is
2652            // not over-specified on pulldown-cmark whitespace semantics.
2653            // The critical regression we want to lock is that genuine
2654            // image+text mixes DON'T promote (covered by the test below).
2655            Block::Paragraph(_) => {}
2656            other => panic!("expected Figure or Paragraph, got {other:?}"),
2657        }
2658    }
2659
2660    #[test]
2661    fn image_with_caption_text_does_not_promote() {
2662        // Critical regression guard (cf. PR1 v2 commit 71c657af3): a
2663        // paragraph carrying image + prose / emphasis must NOT be
2664        // promoted to a figure. If we promoted, the caption text would
2665        // be lost and we'd produce a malformed figure with sibling
2666        // content swallowed.
2667        match first_block("![alt](a.jpg) plain caption text\n") {
2668            Block::Paragraph(children) => {
2669                assert!(children.iter().any(|i| matches!(i, Inline::Image { .. })));
2670                assert!(
2671                    children
2672                        .iter()
2673                        .any(|i| matches!(i, Inline::Text(t) if t.contains("plain"))),
2674                    "expected sibling Text to remain, got {children:?}"
2675                );
2676            }
2677            other => panic!("expected Paragraph, got {other:?}"),
2678        }
2679    }
2680
2681    #[test]
2682    fn image_with_emphasis_sibling_does_not_promote() {
2683        // Pandoc-style "image + emphasis caption" is recognized in the
2684        // legacy transform_events as a captioned figure, but PR3's
2685        // simplified detection (one Image, no other content modulo
2686        // whitespace) leaves these as Paragraph. PR0's parity probe
2687        // already classifies these under image_emission / image_figures
2688        // depending on production behavior; PR3 owns ONLY the simple
2689        // image-only case. The downstream image+emphasis case is closed
2690        // out at PR7a when production flips.
2691        match first_block("![alt](a.jpg) *caption*\n") {
2692            Block::Paragraph(children) => {
2693                assert!(children.iter().any(|i| matches!(i, Inline::Image { .. })));
2694                assert!(
2695                    children.iter().any(|i| matches!(i, Inline::Emphasis(_))),
2696                    "expected Emphasis to remain, got {children:?}"
2697                );
2698            }
2699            other => panic!("expected Paragraph, got {other:?}"),
2700        }
2701    }
2702
2703    #[test]
2704    fn two_images_in_one_paragraph_do_not_promote() {
2705        // Detection rule requires EXACTLY one image. Two images stay as
2706        // a paragraph (no figure wrap chosen — production would also
2707        // not wrap this in a figure).
2708        match first_block("![a](a.jpg) ![b](b.jpg)\n") {
2709            Block::Paragraph(children) => {
2710                let img_count = children
2711                    .iter()
2712                    .filter(|i| matches!(i, Inline::Image { .. }))
2713                    .count();
2714                assert_eq!(img_count, 2);
2715            }
2716            other => panic!("expected Paragraph (two images), got {other:?}"),
2717        }
2718    }
2719
2720    // Editor Image UX (2026-06-04): standard-image `|NN%` width carries
2721    // into Block::Figure.width instead of leaking into the caption.
2722    // ------------------------------------------------------------------
2723
2724    #[test]
2725    fn standard_image_percent_promotes_with_width() {
2726        // ![alt|55%](pic.jpg) → Figure { width: Some("55%"), caption "alt" }
2727        match first_block("![alt|55%](pic.jpg)\n") {
2728            Block::Figure { width, caption, .. } => {
2729                assert_eq!(width.as_deref(), Some("55%"));
2730                // caption is the remaining alt (width segment removed)
2731                let cap = caption.expect("caption from remaining alt");
2732                assert!(matches!(cap.as_slice(), [Inline::Text(t)] if t == "alt"));
2733            }
2734            other => panic!("expected a Figure, got {other:?}"),
2735        }
2736    }
2737
2738    #[test]
2739    fn standard_image_percent_empty_alt_still_promotes() {
2740        // ![|55%](pic.jpg) → Figure (no caption) carrying the width.
2741        match first_block("![|55%](pic.jpg)\n") {
2742            Block::Figure { width, caption, .. } => {
2743                assert_eq!(width.as_deref(), Some("55%"));
2744                assert!(
2745                    caption.is_none() || matches!(caption.as_deref(), Some([])),
2746                    "empty-alt-with-width figure must not carry a caption: {caption:?}"
2747                );
2748            }
2749            other => panic!("expected a Figure even with empty alt when width present, got {other:?}"),
2750        }
2751    }
2752
2753    #[test]
2754    fn standard_image_no_width_unchanged() {
2755        // ![alt](pic.jpg) → Figure { width: None } (existing behavior)
2756        match first_block("![alt](pic.jpg)\n") {
2757            Block::Figure { width, .. } => assert_eq!(width, None),
2758            other => panic!("expected a Figure, got {other:?}"),
2759        }
2760    }
2761
2762    #[test]
2763    fn plain_paragraph_still_parses_as_paragraph() {
2764        // No regression: a normal text paragraph stays as Block::Paragraph.
2765        match first_block("just some prose\n") {
2766            Block::Paragraph(_) => {}
2767            other => panic!("expected Paragraph, got {other:?}"),
2768        }
2769    }
2770
2771    // -----------------------------------------------------------------
2772    // Phase B Task 7: :::subscribe end-to-end
2773    // -----------------------------------------------------------------
2774
2775    use super::super::shortcode::Shortcode;
2776
2777    #[test]
2778    fn parses_subscribe_block_into_typed_shortcode() {
2779        let md = r#":::subscribe {placeholder="you@domain.com" button="Sign me up"}
2780:::
2781"#;
2782        let doc = parse(md);
2783        // Should find one Block::Shortcode(Subscribe) at top level.
2784        let mut found: Option<&Shortcode> = None;
2785        for block in &doc.blocks {
2786            if let Block::Shortcode(sc) = block {
2787                found = Some(sc);
2788                break;
2789            }
2790        }
2791        let sc = found.expect("expected Block::Shortcode");
2792        match sc {
2793            Shortcode::Subscribe(args) => {
2794                assert_eq!(args.placeholder.as_deref(), Some("you@domain.com"));
2795                assert_eq!(args.button.as_deref(), Some("Sign me up"));
2796            }
2797            other => panic!("expected Subscribe, got {other:?}"),
2798        }
2799    }
2800
2801    #[test]
2802    fn subscribe_block_does_not_leave_sentinel_in_other_block() {
2803        let md = ":::subscribe\n:::\n";
2804        let doc = parse(md);
2805        // No Block::Other should contain the sentinel string.
2806        for block in &doc.blocks {
2807            if let Block::Other(html) = block {
2808                assert!(
2809                    !html.contains("MOSS_SHORTCODE"),
2810                    "unsubstituted sentinel remained in AST: {html:?}"
2811                );
2812            }
2813        }
2814    }
2815
2816    #[test]
2817    fn subscribe_inside_paragraph_text_is_not_extracted() {
2818        // Adversarial: `:::subscribe` appearing inside running prose
2819        // (not as a block opener on its own line) is not a shortcode.
2820        // The extractor only matches when `:::name` is on its own line.
2821        let md = "Read more about :::subscribe in the docs.\n";
2822        let doc = parse(md);
2823        for block in &doc.blocks {
2824            assert!(
2825                !matches!(block, Block::Shortcode(_)),
2826                "`:::subscribe` inline-text was wrongly extracted as a shortcode"
2827            );
2828        }
2829    }
2830
2831    #[test]
2832    fn subscribe_block_alongside_other_content_preserves_order() {
2833        let md = "# H\n\nfirst para\n\n:::subscribe\ndescription: d\n:::\n\nlast para\n";
2834        let doc = parse(md);
2835        let kinds: Vec<&'static str> = doc
2836            .blocks
2837            .iter()
2838            .map(|b| match b {
2839                Block::Heading { .. } => "h",
2840                Block::Paragraph(_) => "p",
2841                Block::Shortcode(_) => "sc",
2842                _ => "x",
2843            })
2844            .collect();
2845        assert_eq!(kinds, vec!["h", "p", "sc", "p"]);
2846    }
2847
2848    // -----------------------------------------------------------------
2849    // 2026-05-28 (Phase 4 source-line wiring): ParseConfig threading
2850    // -----------------------------------------------------------------
2851
2852    #[test]
2853    fn parse_default_config_keeps_block_meta_empty() {
2854        let doc = parse("# H1\n\npara one\n\npara two\n");
2855        assert_eq!(doc.blocks.len(), 3);
2856        assert_eq!(doc.block_meta.len(), doc.blocks.len());
2857        for meta in &doc.block_meta {
2858            assert!(
2859                meta.source_line.is_none(),
2860                "default parse should not populate source_line: {meta:?}"
2861            );
2862        }
2863    }
2864
2865    #[test]
2866    fn parse_with_source_lines_assigns_1_based_line_numbers() {
2867        let md = "# H1\n\npara on line 3\n\n## H2 on line 5\n\npara on line 7\n";
2868        let config = ParseConfig {
2869            emit_source_lines: true,
2870            implicit_figure: true,
2871            source_line_offset: 0,
2872            math: false,
2873        };
2874        let doc = parse_with_config(md, &config);
2875        // Expected blocks: H1, P, H2, P (4 blocks).
2876        assert_eq!(doc.blocks.len(), 4);
2877        assert_eq!(doc.block_meta.len(), 4);
2878        // Line numbers should track the markdown source.
2879        assert_eq!(doc.block_meta[0].source_line, Some(1), "H1 on line 1");
2880        assert_eq!(doc.block_meta[1].source_line, Some(3), "P on line 3");
2881        assert_eq!(doc.block_meta[2].source_line, Some(5), "H2 on line 5");
2882        assert_eq!(doc.block_meta[3].source_line, Some(7), "P on line 7");
2883    }
2884
2885    #[test]
2886    fn source_line_offset_is_applied_additively() {
2887        // The parser applies `source_line_offset` additively to every block's
2888        // body-relative line. What the offset MEANS (how it maps the body back
2889        // to the editor's CM6 buffer) is decided by the caller in pipeline.rs —
2890        // this test only pins the additive mechanism, not a coordinate model.
2891        let md = "# H1\n\npara on line 3\n";
2892        let config = ParseConfig {
2893            emit_source_lines: true,
2894            implicit_figure: true,
2895            source_line_offset: 7,
2896            math: false,
2897        };
2898        let doc = parse_with_config(md, &config);
2899        assert_eq!(
2900            doc.block_meta[0].source_line,
2901            Some(8),
2902            "H1 body-line 1 + offset 7"
2903        );
2904        assert_eq!(
2905            doc.block_meta[1].source_line,
2906            Some(10),
2907            "P body-line 3 + offset 7"
2908        );
2909    }
2910
2911    #[test]
2912    fn source_lines_not_collapsed_across_multiline_shortcode() {
2913        // A multi-line shortcode (grid) must NOT collapse the source lines of
2914        // blocks after it. The grid spans lines 3–11; the heading after is on
2915        // line 13. Before the line-count-preserving placeholder fix it
2916        // collapsed to ~line 5, so editor→preview scroll-sync sent any cursor
2917        // past the block to the page bottom.
2918        let md = "# Title\n\n:::grid 3\n[\n![](a.jpg)\n](/x)\n+++\n[\n![](b.jpg)\n](/y)\n:::\n\n## After\n";
2919        let config = ParseConfig {
2920            emit_source_lines: true,
2921            implicit_figure: true,
2922            source_line_offset: 0,
2923            math: false,
2924        };
2925        let doc = parse_with_config(md, &config);
2926        // Blocks: H1 (line 1), Shortcode grid (line 3), H2 "After" (line 13).
2927        let last = doc
2928            .block_meta
2929            .last()
2930            .expect("at least one block")
2931            .source_line;
2932        assert_eq!(
2933            last,
2934            Some(13),
2935            "heading after a multi-line grid must keep its real line 13, not a collapsed line"
2936        );
2937    }
2938
2939    #[test]
2940    fn parse_with_source_lines_lists_and_blockquotes() {
2941        let md = "- item one\n- item two\n\n> quote on line 4\n";
2942        let config = ParseConfig {
2943            emit_source_lines: true,
2944            implicit_figure: true,
2945            source_line_offset: 0,
2946            math: false,
2947        };
2948        let doc = parse_with_config(md, &config);
2949        assert_eq!(doc.blocks.len(), 2);
2950        assert_eq!(doc.block_meta[0].source_line, Some(1), "ul on line 1");
2951        assert_eq!(doc.block_meta[1].source_line, Some(4), "bq on line 4");
2952    }
2953
2954    // -----------------------------------------------------------------
2955    // 2026-05-28 (Phase 4 source-line followup): per-<li> + per-<tr>
2956    // line tracking on Block::List and Block::Table.
2957    // -----------------------------------------------------------------
2958
2959    #[test]
2960    fn parse_with_source_lines_populates_item_lines_on_list() {
2961        // Multi-item list spanning consecutive source lines; the parser
2962        // must capture the 1-based line of each `Tag::Item` start.
2963        let md = "- one\n- two\n- three\n";
2964        let config = ParseConfig {
2965            emit_source_lines: true,
2966            implicit_figure: true,
2967            source_line_offset: 0,
2968            math: false,
2969        };
2970        let doc = parse_with_config(md, &config);
2971        assert_eq!(doc.blocks.len(), 1);
2972        match &doc.blocks[0] {
2973            Block::List {
2974                items,
2975                item_source_lines,
2976                ..
2977            } => {
2978                assert_eq!(items.len(), 3);
2979                assert_eq!(
2980                    item_source_lines.len(),
2981                    3,
2982                    "item_source_lines must be parallel to items"
2983                );
2984                assert_eq!(item_source_lines[0], Some(1));
2985                assert_eq!(item_source_lines[1], Some(2));
2986                assert_eq!(item_source_lines[2], Some(3));
2987            }
2988            other => panic!("expected List, got {other:?}"),
2989        }
2990    }
2991
2992    #[test]
2993    fn parse_default_config_leaves_item_source_lines_empty() {
2994        // Production publish builds (default config — `emit_source_lines:
2995        // false`) must NOT populate `item_source_lines`. The renderer
2996        // treats empty as "no annotations" so the published HTML is
2997        // byte-identical to the pre-followup output.
2998        let doc = parse("- one\n- two\n");
2999        assert_eq!(doc.blocks.len(), 1);
3000        match &doc.blocks[0] {
3001            Block::List {
3002                item_source_lines, ..
3003            } => {
3004                assert!(
3005                    item_source_lines.is_empty(),
3006                    "default config must NOT populate item_source_lines (publish builds): {item_source_lines:?}"
3007                );
3008            }
3009            other => panic!("expected List, got {other:?}"),
3010        }
3011    }
3012
3013    #[test]
3014    fn parse_with_source_lines_populates_row_lines_on_table() {
3015        // Multi-row table: header on line 1, separator on line 2, body
3016        // rows on lines 3, 4, 5. The parser must capture the 1-based
3017        // line of each `Tag::TableRow` start.
3018        let md = "| h1 | h2 |\n| --- | --- |\n| a | b |\n| c | d |\n| e | f |\n";
3019        let config = ParseConfig {
3020            emit_source_lines: true,
3021            implicit_figure: true,
3022            source_line_offset: 0,
3023            math: false,
3024        };
3025        let doc = parse_with_config(md, &config);
3026        assert_eq!(doc.blocks.len(), 1);
3027        match &doc.blocks[0] {
3028            Block::Table {
3029                rows,
3030                header_source_line,
3031                row_source_lines,
3032                ..
3033            } => {
3034                assert_eq!(rows.len(), 3);
3035                // The header tr anchors at the markdown header row (line 1).
3036                assert_eq!(*header_source_line, Some(1), "header tr line");
3037                assert_eq!(
3038                    row_source_lines.len(),
3039                    3,
3040                    "row_source_lines must be parallel to rows"
3041                );
3042                assert_eq!(row_source_lines[0], Some(3));
3043                assert_eq!(row_source_lines[1], Some(4));
3044                assert_eq!(row_source_lines[2], Some(5));
3045            }
3046            other => panic!("expected Table, got {other:?}"),
3047        }
3048    }
3049
3050    #[test]
3051    fn parse_default_config_leaves_row_source_lines_empty() {
3052        // Production publish builds must not populate table row lines.
3053        let md = "| h1 | h2 |\n| --- | --- |\n| a | b |\n";
3054        let doc = parse(md);
3055        assert_eq!(doc.blocks.len(), 1);
3056        match &doc.blocks[0] {
3057            Block::Table {
3058                header_source_line,
3059                row_source_lines,
3060                ..
3061            } => {
3062                assert!(header_source_line.is_none());
3063                assert!(row_source_lines.is_empty());
3064            }
3065            other => panic!("expected Table, got {other:?}"),
3066        }
3067    }
3068
3069    // -----------------------------------------------------------------
3070    // 2026-05-28 (Phase 4 followup B): ordered-list explicit start
3071    // number captured from pulldown-cmark's `Tag::List(Option<u64>)`
3072    // payload and round-tripped to the renderer as `<ol start="N">`.
3073    // -----------------------------------------------------------------
3074
3075    #[test]
3076    fn parse_ordered_list_start_3_captures_start_number() {
3077        // `3. foo` should capture `start: Some(3)` so the renderer can
3078        // emit `<ol start="3">`. CommonMark only honors the first
3079        // item's number — subsequent items are re-derived.
3080        let doc = parse("3. foo\n4. bar\n");
3081        assert_eq!(doc.blocks.len(), 1);
3082        match &doc.blocks[0] {
3083            Block::List {
3084                ordered,
3085                start,
3086                items,
3087                ..
3088            } => {
3089                assert!(ordered, "ordered list");
3090                assert_eq!(*start, Some(3), "explicit start number captured");
3091                assert_eq!(items.len(), 2);
3092            }
3093            other => panic!("expected ordered List, got {other:?}"),
3094        }
3095    }
3096
3097    #[test]
3098    fn parse_ordered_list_default_start_collapses_to_none() {
3099        // pulldown-cmark normalizes `1. foo` to `Tag::List(Some(1))`,
3100        // but the AST canonicalizes this to `start: None` (semantically
3101        // identical to `<ol>` without a `start=` attribute, but cleaner).
3102        let doc = parse("1. foo\n2. bar\n");
3103        assert_eq!(doc.blocks.len(), 1);
3104        match &doc.blocks[0] {
3105            Block::List { ordered, start, .. } => {
3106                assert!(ordered);
3107                assert!(
3108                    start.is_none(),
3109                    "implicit start=1 must collapse to None, got {start:?}"
3110                );
3111            }
3112            other => panic!("expected ordered List, got {other:?}"),
3113        }
3114    }
3115
3116    #[test]
3117    fn parse_unordered_list_has_no_start() {
3118        // `- foo` is unordered (`Tag::List(None)`). `start` must always
3119        // be `None` regardless of any subsequent reasoning.
3120        let doc = parse("- foo\n- bar\n");
3121        assert_eq!(doc.blocks.len(), 1);
3122        match &doc.blocks[0] {
3123            Block::List { ordered, start, .. } => {
3124                assert!(!ordered, "unordered list");
3125                assert!(
3126                    start.is_none(),
3127                    "unordered list must have start=None, got {start:?}"
3128                );
3129            }
3130            other => panic!("expected unordered List, got {other:?}"),
3131        }
3132    }
3133
3134    #[test]
3135    fn parse_with_source_lines_handles_list_after_blank_line_offset() {
3136        // List items can start past the document start; verify the
3137        // 1-based numbering tracks the actual source line, not a
3138        // 0-based index from the list opener.
3139        let md = "intro paragraph\n\n- item on line 3\n- item on line 4\n";
3140        let config = ParseConfig {
3141            emit_source_lines: true,
3142            implicit_figure: true,
3143            source_line_offset: 0,
3144            math: false,
3145        };
3146        let doc = parse_with_config(md, &config);
3147        assert_eq!(doc.blocks.len(), 2);
3148        match &doc.blocks[1] {
3149            Block::List {
3150                item_source_lines, ..
3151            } => {
3152                assert_eq!(item_source_lines.len(), 2);
3153                assert_eq!(item_source_lines[0], Some(3));
3154                assert_eq!(item_source_lines[1], Some(4));
3155            }
3156            other => panic!("expected List as second block, got {other:?}"),
3157        }
3158    }
3159
3160    #[test]
3161    fn parse_implicit_figure_default_promotes_image_only_paragraph() {
3162        // Image-only paragraph with non-empty alt → promoted to Block::Figure.
3163        let doc = parse("![alt](photo.jpg)\n");
3164        assert_eq!(doc.blocks.len(), 1);
3165        assert!(
3166            matches!(doc.blocks[0], Block::Figure { .. }),
3167            "default config (implicit_figure=true) should promote: got {:?}",
3168            doc.blocks[0]
3169        );
3170    }
3171
3172    #[test]
3173    fn parse_implicit_figure_off_leaves_image_paragraph_unpromoted() {
3174        let config = ParseConfig {
3175            emit_source_lines: false,
3176            implicit_figure: false,
3177            source_line_offset: 0,
3178            math: false,
3179        };
3180        let doc = parse_with_config("![alt](photo.jpg)\n", &config);
3181        assert_eq!(doc.blocks.len(), 1);
3182        match &doc.blocks[0] {
3183            Block::Paragraph(inlines) => {
3184                assert!(matches!(inlines[0], Inline::Image { .. }));
3185            }
3186            other => panic!("expected Paragraph with image, got {other:?}"),
3187        }
3188    }
3189
3190    // -----------------------------------------------------------------
3191    // Implicit-figure caption renders inline markdown (option B)
3192    //
3193    // The implicit-figure caption is the image's alt content parsed as
3194    // inline markdown — `*em*`, links, `` `code` `` and typeset math — while
3195    // the `alt=` attribute keeps the flat plain-text source (math verbatim).
3196    // Matches Pandoc's implicit-figure model. See
3197    // docs/plans/2026-07-05-target-architecture and the caption fix design.
3198    // -----------------------------------------------------------------
3199
3200    #[test]
3201    fn implicit_figure_caption_preserves_inline_markup() {
3202        // `![before *em* after](img.png)` → caption holds a typed
3203        // `Inline::Emphasis`, NOT a single flattened `Inline::Text`. The
3204        // image's `alt` stays the flat plain-text source (markers stripped).
3205        let block = first_block("![before *em* after](img.png)\n");
3206        match block {
3207            Block::Figure { caption, image, .. } => {
3208                let cap = caption.expect("caption must be present");
3209                assert!(
3210                    cap.iter().any(|i| matches!(i, Inline::Emphasis(_))),
3211                    "caption must carry a typed Emphasis node, got {cap:?}"
3212                );
3213                // The em markers must not survive as a flattened Text run.
3214                assert!(
3215                    !cap.iter().any(|i| matches!(i, Inline::Text(t) if t.contains('*'))),
3216                    "caption must not contain raw `*` markers, got {cap:?}"
3217                );
3218                match image {
3219                    Inline::Image { alt, .. } => {
3220                        assert_eq!(
3221                            alt, "before em after",
3222                            "alt attribute must stay flat plain-text source"
3223                        );
3224                    }
3225                    other => panic!("expected Image, got {other:?}"),
3226                }
3227            }
3228            other => panic!("expected Figure, got {other:?}"),
3229        }
3230    }
3231
3232    #[test]
3233    fn implicit_figure_caption_carries_link_and_math_nodes() {
3234        // `![a [link](/x) and $x^2$ end](img.png)` (math on) → caption holds
3235        // a typed `Inline::Link` AND a math `Inline::Other` node so the
3236        // renderer's link + math hooks fire; the flat `alt` keeps the link
3237        // label and the math as its `$…$` source.
3238        let config = ParseConfig {
3239            emit_source_lines: false,
3240            implicit_figure: true,
3241            source_line_offset: 0,
3242            math: true,
3243        };
3244        let doc = parse_with_config("![a [link](/x) and $x^2$ end](img.png)\n", &config);
3245        match doc.blocks.into_iter().next().expect("one block") {
3246            Block::Figure { caption, image, .. } => {
3247                let cap = caption.expect("caption must be present");
3248                assert!(
3249                    cap.iter().any(|i| matches!(i, Inline::Link { .. })),
3250                    "caption must carry a typed Link node, got {cap:?}"
3251                );
3252                assert!(
3253                    cap.iter().any(|i| matches!(
3254                        i,
3255                        Inline::Other(html) if super::super::math_text::math_node_parts(html).is_some()
3256                    )),
3257                    "caption must carry a typed math node, got {cap:?}"
3258                );
3259                match image {
3260                    Inline::Image { alt, .. } => {
3261                        assert_eq!(
3262                            alt, "a link and $x^2$ end",
3263                            "alt must stay flat: link label inlined, math as source"
3264                        );
3265                    }
3266                    other => panic!("expected Image, got {other:?}"),
3267                }
3268            }
3269            other => panic!("expected Figure, got {other:?}"),
3270        }
3271    }
3272
3273    #[test]
3274    fn implicit_figure_caption_only_math_is_a_math_node() {
3275        // `![$E=mc^2$](img.png)` (math on) → caption is a single math
3276        // `Inline::Other` node (so it typesets), alt is the `$…$` source.
3277        let config = ParseConfig {
3278            emit_source_lines: false,
3279            implicit_figure: true,
3280            source_line_offset: 0,
3281            math: true,
3282        };
3283        let doc = parse_with_config("![$E=mc^2$](img.png)\n", &config);
3284        match doc.blocks.into_iter().next().expect("one block") {
3285            Block::Figure { caption, image, .. } => {
3286                let cap = caption.expect("caption must be present");
3287                assert!(
3288                    cap.iter().any(|i| matches!(
3289                        i,
3290                        Inline::Other(html) if super::super::math_text::math_node_parts(html).is_some()
3291                    )),
3292                    "math-only caption must carry a math node, got {cap:?}"
3293                );
3294                match image {
3295                    Inline::Image { alt, .. } => {
3296                        assert_eq!(alt, "$E=mc^2$", "alt must be the math source verbatim");
3297                    }
3298                    other => panic!("expected Image, got {other:?}"),
3299                }
3300            }
3301            other => panic!("expected Figure, got {other:?}"),
3302        }
3303    }
3304
3305    #[test]
3306    fn implicit_figure_empty_alt_still_yields_no_caption() {
3307        // Regression guard: an empty-alt image is not promoted (no figure,
3308        // hence no figcaption) — the rich-caption change must not regress it.
3309        let doc = parse("![](img.png)\n");
3310        assert!(
3311            !matches!(doc.blocks.first(), Some(Block::Figure { .. })),
3312            "empty-alt image must not promote to a figure: {:?}",
3313            doc.blocks
3314        );
3315    }
3316
3317    // -----------------------------------------------------------------
3318    // LineLookup unit tests (binary-search prefix-sum line table)
3319    // -----------------------------------------------------------------
3320
3321    #[test]
3322    fn line_lookup_offset_zero_is_line_one() {
3323        let lookup = LineLookup::build("hello\nworld\n", 0);
3324        assert_eq!(lookup.line_at(0), 1);
3325    }
3326
3327    #[test]
3328    fn line_lookup_after_first_newline_is_line_two() {
3329        let lookup = LineLookup::build("hello\nworld\n", 0);
3330        // Byte 6 is the 'w' of "world", which is on line 2.
3331        assert_eq!(lookup.line_at(6), 2);
3332    }
3333
3334    #[test]
3335    fn line_lookup_handles_multiline_block_starts() {
3336        let lookup = LineLookup::build("line1\nline2\nline3\n", 0);
3337        // First non-newline byte of each line.
3338        assert_eq!(lookup.line_at(0), 1, "byte 0 → line 1");
3339        assert_eq!(lookup.line_at(6), 2, "byte 6 → line 2");
3340        assert_eq!(lookup.line_at(12), 3, "byte 12 → line 3");
3341    }
3342
3343    #[test]
3344    fn line_lookup_empty_source() {
3345        let lookup = LineLookup::build("", 0);
3346        assert_eq!(lookup.line_at(0), 1, "empty source still has line 1");
3347    }
3348
3349    // -----------------------------------------------------------------
3350    // Wikilink percent-width — no-ContentGraph path
3351    //
3352    // `try_promote_to_figure` recovers a content-relative percent (`|55%`)
3353    // from `wikilink_pothole` so the no-graph parse path (fragment/test
3354    // render) carries width in `Block::Figure.width`, not as a spurious
3355    // caption. These are regression guards for the no-graph fix.
3356    //
3357    // Sync: the with-graph twin lives in
3358    // resolve/wikilink_dispatch.rs (image branch, `split_alt_width` call)
3359    // — both split width via `media::split_alt_width`.
3360    // -----------------------------------------------------------------
3361
3362    #[test]
3363    fn wikilink_image_percent_no_graph_promotes_with_width() {
3364        // ![[pic.jpg|55%]] must carry |55% into Figure.width, not leak it
3365        // into the caption (regression guard for the no-graph fix).
3366        let block = first_block("![[pic.jpg|55%]]\n");
3367        match block {
3368            Block::Figure { width, caption, .. } => {
3369                assert_eq!(width.as_deref(), Some("55%"));
3370                assert!(caption.is_none(), "percent must not become a caption");
3371            }
3372            other => panic!("expected Figure, got {other:?}"),
3373        }
3374    }
3375
3376    #[test]
3377    fn wikilink_image_percent_with_caption_no_graph() {
3378        // ![[pic.jpg|My cap|55%]] — width Some("55%"), caption "My cap".
3379        let block = first_block("![[pic.jpg|My cap|55%]]\n");
3380        match block {
3381            Block::Figure { width, caption, .. } => {
3382                assert_eq!(width.as_deref(), Some("55%"));
3383                let cap = caption.as_ref().expect("caption must be present");
3384                assert!(
3385                    matches!(cap.as_slice(), [Inline::Text(t)] if t == "My cap"),
3386                    "caption should be the non-width segment, got {cap:?}"
3387                );
3388            }
3389            other => panic!("expected Figure, got {other:?}"),
3390        }
3391    }
3392
3393    // -----------------------------------------------------------------
3394    // Non-image wikilink embeds must NOT promote to Figure
3395    //
3396    // Figure is an image concept. pulldown-cmark parses every `![[…]]`
3397    // as an Image event, so without a kind gate a video pothole
3398    // (`![[clip.mov|77%]]`) was hijacked into Block::Figure — and
3399    // `dispatch_wikilink_embeds` only dispatches Paragraph-shaped lone
3400    // embeds, so the video synthesizer never ran: the page shipped
3401    // `<figure><img src="clip.mov">` (broken image). The gate keys off
3402    // the same classifier the dispatcher uses (`resolve::ext_kind`), so
3403    // parse-time promotion and dispatch-time synthesis can never
3404    // disagree about who owns the block.
3405    // -----------------------------------------------------------------
3406
3407    #[test]
3408    fn wikilink_video_percent_stays_paragraph() {
3409        let block = first_block("![[clip.mov|77%]]\n");
3410        match block {
3411            Block::Paragraph(inlines) => assert!(
3412                matches!(
3413                    inlines.as_slice(),
3414                    [Inline::Image {
3415                        is_wikilink: true,
3416                        ..
3417                    }]
3418                ),
3419                "paragraph must hold the lone wikilink image, got {inlines:?}"
3420            ),
3421            other => panic!("video embed must stay Paragraph for dispatch, got {other:?}"),
3422        }
3423    }
3424
3425    #[test]
3426    fn wikilink_video_box_sizing_stays_paragraph() {
3427        // `|640x360` is the documented video sizing alias — it must reach
3428        // the dispatcher, not become a figcaption.
3429        let block = first_block("![[clip.mov|640x360]]\n");
3430        assert!(
3431            matches!(block, Block::Paragraph(_)),
3432            "expected Paragraph, got {block:?}"
3433        );
3434    }
3435
3436    #[test]
3437    fn wikilink_pdf_alias_stays_paragraph() {
3438        let block = first_block("![[report.pdf|80%]]\n");
3439        assert!(
3440            matches!(block, Block::Paragraph(_)),
3441            "expected Paragraph, got {block:?}"
3442        );
3443    }
3444
3445    #[test]
3446    fn wikilink_extensionless_stays_paragraph() {
3447        // `![[draft|55%]]` carries no extension intent — only the
3448        // with-graph dispatcher can resolve its kind, so the parser must
3449        // not commit it to an image Figure.
3450        let block = first_block("![[draft|55%]]\n");
3451        assert!(
3452            matches!(block, Block::Paragraph(_)),
3453            "expected Paragraph, got {block:?}"
3454        );
3455    }
3456
3457    #[test]
3458    fn wikilink_uppercase_image_ext_still_promotes() {
3459        // Extension matching is case-insensitive (vault files like
3460        // `photo.JPG` are common iPhone/camera exports).
3461        let block = first_block("![[photo.JPG|55%]]\n");
3462        assert!(
3463            matches!(block, Block::Figure { .. }),
3464            "expected Figure, got {block:?}"
3465        );
3466    }
3467}