Skip to main content

moss_core/ast/
node.rs

1//! Block-level and inline-level AST nodes.
2//!
3//! Closed enums; pattern matching is the visitor framework. The variants
4//! model every construct [`super::parser::parser_options`] turns on —
5//! CommonMark plus the GFM tables, strikethrough, footnotes and task lists
6//! moss enables (ADR-035 § Task lists, amended) — so nothing pulldown-cmark
7//! emits for those constructs reaches a catch-all.
8//!
9//! `Block::Other` / `Inline::Other` are NOT a general catch-all. They carry
10//! **raw HTML only** — `Tag::HtmlBlock` and `Event::Html`/`InlineHtml` in
11//! [`super::parser`] — plus payloads moss synthesizes itself (math, ADR-030;
12//! dispatched wikilink embeds). Anything pulldown-cmark emits that has no arm
13//! is **dropped, not passed through**: `parse_block`, `parse_block_with_tag`
14//! and `parse_inline` all end in `_ => (None, 1)`, and
15//! `parse_inline_event`'s whitelist ends in `_ => None`.
16//!
17//! So turning on a new `Options` bit is never a one-line flag flip. It needs a
18//! variant here PLUS arms in `parse_block_with_tag` / `parse_inline` /
19//! `parse_inline_event`'s whitelist, in the same change, or the construct
20//! silently disappears from published pages. That is not hypothetical: before
21//! ADR-035, `~~` was a construct the AST hadn't modeled and it did not flow
22//! through `Inline::Other` — `~~gone~~ stays` published `gone stays` for two
23//! months. See ADR-035 § Why now.
24
25use serde::{Deserialize, Serialize};
26
27use super::shortcode::Shortcode;
28use super::url::Url;
29
30/// Canonical callout kind. Obsidian-dialect aliases canonicalize via
31/// [`CalloutKind::from_raw`] (e.g. `tldr`/`summary` → [`CalloutKind::Abstract`]).
32/// Unknown kinds fall back to [`CalloutKind::Note`]; the parser logs at
33/// trace level (Diagnostic threading is a Phase 4 followup — see
34/// `validation::Diagnostic`, today scoped to frontmatter validation).
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum CalloutKind {
38    Note,
39    Abstract,
40    Info,
41    Todo,
42    Tip,
43    Success,
44    Question,
45    Warning,
46    Failure,
47    Danger,
48    Bug,
49    Example,
50    Quote,
51    Important,
52    Summary,
53    Help,
54}
55
56/// Per-column table alignment declared in GFM source (`|:--|` left,
57/// `|:-:|` center, `|--:|` right; a bare `|---|` is [`ColumnAlignment::None`]).
58///
59/// This is **source-faithful**: it records only what the author wrote, so the
60/// editor and any AST consumer see the same alignment the source declares.
61/// Numeric auto-right-alignment (for columns the author left unaligned) is a
62/// render-time transform in `ast::render`, deliberately *not* stored here.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum ColumnAlignment {
66    None,
67    Left,
68    Center,
69    Right,
70}
71
72impl CalloutKind {
73    /// Canonicalize a raw callout name (case-insensitive) to a
74    /// [`CalloutKind`]. Returns `None` if the name is not a recognized
75    /// canonical kind or alias.
76    ///
77    /// Alias table (Obsidian-dialect, per shape-spec § 1):
78    /// - `tldr` / `summary` → `Abstract`
79    /// - `hint` / `important` → `Tip`
80    /// - `check` / `done` → `Success`
81    /// - `help` / `faq` → `Question`
82    /// - `caution` / `attention` → `Warning`
83    /// - `fail` / `missing` → `Failure`
84    /// - `error` → `Danger`
85    /// - `cite` → `Quote`
86    ///
87    /// `pending` is also accepted as an alias for `Todo` (used by
88    /// SoCiviC Theatre voices.md; carried over from pre-Phase-4 Stage 1
89    /// support in `crates/moss-core/src/resolve/callouts.rs`).
90    ///
91    /// Note: the [`CalloutKind`] enum reserves `Important`, `Summary`,
92    /// and `Help` as canonical variants for future Stage 2 use (e.g.
93    /// editor-emitted callouts that should not name-clash with the
94    /// Obsidian aliases above). Author markdown can't currently produce
95    /// these three through `from_raw`; they're reachable only via
96    /// programmatic construction.
97    pub fn from_raw(raw: &str) -> Option<Self> {
98        let lower = raw.to_lowercase();
99        let canonical = match lower.as_str() {
100            // Canonical kinds (exact match, alias-free names)
101            "note" => Self::Note,
102            "abstract" => Self::Abstract,
103            "info" => Self::Info,
104            "todo" => Self::Todo,
105            "tip" => Self::Tip,
106            "success" => Self::Success,
107            "question" => Self::Question,
108            "warning" => Self::Warning,
109            "failure" => Self::Failure,
110            "danger" => Self::Danger,
111            "bug" => Self::Bug,
112            "example" => Self::Example,
113            "quote" => Self::Quote,
114            // Obsidian-dialect aliases (shape-spec § 1)
115            "tldr" | "summary" => Self::Abstract,
116            "hint" | "important" => Self::Tip,
117            "check" | "done" => Self::Success,
118            "help" | "faq" => Self::Question,
119            "caution" | "attention" => Self::Warning,
120            "fail" | "missing" => Self::Failure,
121            "error" => Self::Danger,
122            "cite" => Self::Quote,
123            // Legacy alias retained from pre-Phase-4 Stage 1
124            // (`crates/moss-core/src/resolve/callouts.rs`). SoCiviC
125            // Theatre's voices.md uses `> [!pending]`; map to Todo.
126            "pending" => Self::Todo,
127            _ => return None,
128        };
129        Some(canonical)
130    }
131
132    /// Slug form used in the rendered `data-type` attribute.
133    pub fn as_slug(self) -> &'static str {
134        match self {
135            Self::Note => "note",
136            Self::Abstract => "abstract",
137            Self::Info => "info",
138            Self::Todo => "todo",
139            Self::Tip => "tip",
140            Self::Success => "success",
141            Self::Question => "question",
142            Self::Warning => "warning",
143            Self::Failure => "failure",
144            Self::Danger => "danger",
145            Self::Bug => "bug",
146            Self::Example => "example",
147            Self::Quote => "quote",
148            Self::Important => "important",
149            Self::Summary => "summary",
150            Self::Help => "help",
151        }
152    }
153
154    /// Default display title (capitalized canonical kind) used when the
155    /// author wrote `> [!type]` with no inline title text.
156    pub fn default_title(self) -> &'static str {
157        match self {
158            Self::Note => "Note",
159            Self::Abstract => "Abstract",
160            Self::Info => "Info",
161            Self::Todo => "Todo",
162            Self::Tip => "Tip",
163            Self::Success => "Success",
164            Self::Question => "Question",
165            Self::Warning => "Warning",
166            Self::Failure => "Failure",
167            Self::Danger => "Danger",
168            Self::Bug => "Bug",
169            Self::Example => "Example",
170            Self::Quote => "Quote",
171            Self::Important => "Important",
172            Self::Summary => "Summary",
173            Self::Help => "Help",
174        }
175    }
176}
177
178/// Foldable callout state. `> [!type]+` → [`Fold::Open`] (foldable,
179/// open by default); `> [!type]-` → [`Fold::Closed`] (foldable, closed
180/// by default). Non-foldable callouts have `fold: None` on the
181/// containing [`Block::Callout`].
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
183#[serde(rename_all = "snake_case")]
184pub enum Fold {
185    Open,
186    Closed,
187}
188
189/// A block-level AST node.
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(rename_all = "snake_case")]
192pub enum Block {
193    /// `# Heading` (level 1) through `###### Heading` (level 6).
194    Heading {
195        level: u8,
196        children: Vec<Inline>,
197        /// Heading anchor id (slug). Computed by the parser via
198        /// [`crate::heading::anchor::obsidian_heading_anchor`].
199        id: Option<String>,
200    },
201    /// A paragraph of inline content.
202    Paragraph(Vec<Inline>),
203    /// `> [!type] body` — typed callouts. The `kind` is canonicalized
204    /// via [`CalloutKind::from_raw`] (Obsidian-dialect aliases collapse
205    /// to the canonical 16-kind set). Foldable callouts (`> [!type]+`
206    /// open by default, `> [!type]-` closed) carry the [`Fold`] state;
207    /// non-foldable callouts have `fold: None`.
208    ///
209    /// Phase 4 PR4 extended the shape from `kind: String` to
210    /// `kind: CalloutKind` + added `fold: Option<Fold>` and `title: Option<String>`.
211    /// Title is the optional inline text following the marker
212    /// (`> [!note] My title` → `title: Some("My title")`).
213    Callout {
214        kind: CalloutKind,
215        fold: Option<Fold>,
216        title: Option<String>,
217        children: Vec<Block>,
218    },
219    /// `- item` / `1. item`. Each item is a list of blocks (so list items
220    /// can carry paragraphs, sub-lists, etc).
221    ///
222    /// `item_source_lines` is a parallel-to-`items` vec of 1-based source
223    /// line numbers, populated by the parser only when
224    /// [`crate::ast::ParseConfig::emit_source_lines`] is true. When tracking
225    /// is off (production publish builds, the ~40 in-crate `parse()` callers
226    /// that use the default config), the vec is empty (`vec![]`) and the
227    /// renderer treats every item as `None` — no `data-source-line` on the
228    /// emitted `<li>`. When tracking is on, length matches `items.len()`
229    /// exactly; individual entries may still be `None` for synthesized
230    /// items that have no faithful source position (none today, but kept
231    /// for symmetry with [`crate::ast::document::BlockMeta::source_line`]).
232    ///
233    /// Phase 4 source-lines followup (2026-05-28): added because the
234    /// preview's scroll-sync (cm-scroll-sync via
235    /// `frontend/bridge/iframe-bridge.ts`) interpolates editor positions
236    /// proportionally between annotated DOM elements. A 30-item list
237    /// spanning 50 source lines without per-`<li>` annotations forces
238    /// interpolation between the outer `<ul>` and the next top-level
239    /// block — potentially 100 lines away. Legacy `transform_events`
240    /// (commit f91aca8fa, 2026-04-01) emitted on `<li>` and `<tr>` for
241    /// this reason; the typed-AST renderer now matches.
242    List {
243        ordered: bool,
244        /// Explicit ordered-list start number (pulldown-cmark's
245        /// `Tag::List(Option<u64>)` payload). `Some(N)` when the source
246        /// is `N. item` and the renderer should emit `<ol start="N">`;
247        /// `None` for unordered lists and for ordered lists where N is
248        /// the implicit default `1`. CommonMark only honors the FIRST
249        /// item's number as the list start; subsequent numbers are
250        /// re-derived. Phase 4 followup B (2026-05-28): added because
251        /// `<ol>` was previously emitted for any ordered list,
252        /// silently dropping the explicit start number — `3. foo`
253        /// rendered as `<ol><li>foo</li></ol>` instead of
254        /// `<ol start="3"><li>foo</li></ol>`.
255        #[serde(default)]
256        start: Option<u64>,
257        items: Vec<Vec<Block>>,
258        #[serde(default)]
259        item_source_lines: Vec<Option<usize>>,
260    },
261    /// A fenced code block.
262    CodeBlock { lang: Option<String>, value: String },
263    /// Markdown table.
264    ///
265    /// `header_source_line` and `row_source_lines` are populated by the
266    /// parser only when [`crate::ast::ParseConfig::emit_source_lines`] is
267    /// true. When tracking is off, `header_source_line` is `None` and
268    /// `row_source_lines` is empty (`vec![]`); the renderer emits no
269    /// `data-source-line` attributes. When tracking is on,
270    /// `row_source_lines.len() == rows.len()`.
271    ///
272    /// Phase 4 source-lines followup (2026-05-28): see the corresponding
273    /// doc comment on `Block::List` for the scroll-sync interpolation
274    /// rationale.
275    Table {
276        header: Vec<Vec<Inline>>,
277        rows: Vec<Vec<Vec<Inline>>>,
278        /// Per-column GFM alignment, parallel to `header`. Empty (the common
279        /// case) means the author declared no alignment on any column.
280        /// `skip_serializing_if` keeps previously-serialized ASTs and snapshot
281        /// fixtures byte-stable when there is no alignment to record.
282        #[serde(default, skip_serializing_if = "Vec::is_empty")]
283        alignments: Vec<ColumnAlignment>,
284        #[serde(default)]
285        header_source_line: Option<usize>,
286        #[serde(default)]
287        row_source_lines: Vec<Option<usize>>,
288    },
289    /// `> blockquote`
290    BlockQuote(Vec<Block>),
291    /// A typed shortcode block (`:::name ...args\n body :::`).
292    Shortcode(Shortcode),
293    /// `<hr>` thematic break.
294    ThematicBreak,
295    /// Image-only paragraph promoted to a typed figure.
296    ///
297    /// Detected by the parser's `Tag::Paragraph` arm (Phase 4 PR3,
298    /// 2026-05-27): a paragraph that contains exactly one
299    /// [`Inline::Image`] modulo whitespace text and line breaks. The
300    /// renderer emits `<figure class="moss-image">…<figcaption>…</figcaption></figure>`,
301    /// wrapping the image hook's output and appending the caption when
302    /// present.
303    ///
304    /// `image` is constrained by the parser to be an [`Inline::Image`];
305    /// the renderer pattern-matches and falls back gracefully if the
306    /// variant is anything else.
307    ///
308    /// `caption` defaults to the image's alt text at parse time. `None`
309    /// means "figure wrap but no `<figcaption>`" — reserved for the
310    /// empty-alt case (omit caption when there is nothing to read).
311    ///
312    /// The figure-level display params (`width`, `align`, `class_names`,
313    /// `img_style`) are populated only when a figure originates from a
314    /// parameterized wikilink embed (`![[photo.jpg|wide cover]]`) — the
315    /// image-embed synth-collapse routes such embeds through this typed
316    /// node so width/fit/position/align survive (previously dropped by the
317    /// markdown round-trip). The CommonMark `![](url)` promotion path
318    /// (`try_promote_to_figure`) leaves them at their defaults, so its
319    /// rendered output is byte-identical to before the collapse.
320    Figure {
321        image: Inline,
322        caption: Option<Vec<Inline>>,
323        /// Canonical width token (`body | wide | page | screen`) emitted as
324        /// `data-width="…"` on the `<figure>`. `None` omits the attribute.
325        /// `String` (not `&'static str`) so `Block` keeps its `Deserialize`
326        /// derive; the value is always one of the canonical tokens.
327        width: Option<String>,
328        /// Figure-level align class (`moss-align-left` / `moss-align-right`),
329        /// appended to the `<figure>` class list. `None` omits it.
330        align: Option<String>,
331        /// Extra author-supplied class names appended to the `<figure>` class
332        /// list (after `moss-image` and any align class). Empty = none.
333        class_names: Vec<String>,
334        /// Inline `style=` fragment for the INNER `<img>` (e.g.
335        /// `object-fit:cover;object-position:left` from a `|cover left`
336        /// embed). `None` omits it. Distinct from any figure-level attribute:
337        /// fit/position style belongs on the image element, not the figure.
338        img_style: Option<String>,
339    },
340    /// Compound-link grid cell: the entire cell is a single markdown
341    /// link `[inner](url)` whose `inner` is parsed as block-level content
342    /// (images, headings, paragraphs, emphasis). The SoCiviC Theatre
343    /// pattern: `[![[poster]] ### Title *date* description](/url)`.
344    ///
345    /// Phase 4 PR4.5 (2026-05-28): added because CommonMark restricts
346    /// `Inline::Link.children` to inline-level content; a markdown link
347    /// wrapping `### Heading` + paragraphs cannot round-trip through
348    /// pulldown-cmark's inline parser. The cell-string-level shape
349    /// (`[...](url)` with multi-paragraph inner content) is detected by
350    /// `crate::ast::shortcode_extract::parse_grid` BEFORE the cell flows
351    /// through `crate::ast::parser::parse`; the matched cell yields a
352    /// single-element `vec![Block::LinkCard { url, children }]` with the
353    /// inner markdown parsed into typed blocks.
354    ///
355    /// Render shape (matches today's `render_compound_link_cell` byte
356    /// shape):
357    /// - External URL (`http(s)://...`): `<a href=URL class="moss-grid-card link-preview" target="_blank" rel="noopener">children</a>`.
358    /// - Internal URL: `<a href=URL class="moss-grid-card" data-kind="link">children</a>`.
359    LinkCard { url: Url, children: Vec<Block> },
360    /// `[^label]: body` — a GFM footnote definition, wherever the author
361    /// wrote it (pulldown-cmark nests one written inside a blockquote or a
362    /// list item under that container). The renderer hoists it out to the
363    /// document's endnote section; see ADR-035.
364    FootnoteDefinition { label: String, children: Vec<Block> },
365    /// Raw HTML passthrough: a `Tag::HtmlBlock` the author wrote, or a
366    /// payload moss synthesized itself (the shortcode sentinel pass in
367    /// `dispatch_wikilink_embeds`). Emitted verbatim. NOT a fallback for
368    /// unmodeled pulldown constructs — see the module doc.
369    Other(String),
370}
371
372/// An inline-level AST node.
373#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
374#[serde(rename_all = "snake_case")]
375pub enum Inline {
376    Text(String),
377    /// `[content](url "title")` or `[[wikilink]]`.
378    ///
379    /// `is_wikilink` preserves pulldown-cmark's `LinkType::WikiLink`
380    /// discriminator at parse time so the renderer can emit
381    /// `class="wikilink"` on the `<a>` tag and downstream consumers
382    /// (graph builder, link-resolver) can distinguish wikilink targets
383    /// from standard markdown links. Added Phase 4 PR7a (2026-05-28) as
384    /// the smallest AST change matching mdast convention (Link node +
385    /// extension flag, mirroring `LinkType::WikiLink` as a tag).
386    Link {
387        url: Url,
388        title: Option<String>,
389        children: Vec<Inline>,
390        /// True when pulldown-cmark emitted `Tag::Link { link_type:
391        /// LinkType::WikiLink, .. }` (i.e. the markdown source was
392        /// `[[target]]` or `[[target|alias]]`, post-Stage-1 rewrite).
393        /// Renderer adds `class="wikilink"` for true.
394        #[serde(default)]
395        is_wikilink: bool,
396    },
397    /// `![alt](src "title")` or `![[wikilink]]`.
398    ///
399    /// `is_wikilink` + `wikilink_pothole` (Phase 4 PR7a-flip-core-B,
400    /// 2026-05-28) preserve pulldown-cmark's `LinkType::WikiLink`
401    /// discriminator and the original pothole text so the
402    /// `dispatch_wikilink_embeds` visitor can route `![[v.mp4|width=400]]`
403    /// → per-extension renderer (video / pdf / audio / iframe / 3D /
404    /// notebook / etc) with the typed params intact. The parser's
405    /// `Tag::Image` arm captures the raw pothole BEFORE PR3.5's
406    /// wikilink-alt classification consumes it into the `alt` field;
407    /// without preservation, the `width=400` token is erased after
408    /// alt-classification.
409    ///
410    /// `is_wikilink: false` and `wikilink_pothole: None` for standard
411    /// `![alt](src)` markdown images.
412    Image {
413        src: Url,
414        alt: String,
415        title: Option<String>,
416        /// True when pulldown-cmark emitted `Tag::Image { link_type:
417        /// LinkType::WikiLink, .. }` (i.e. the markdown source was
418        /// `![[target]]` / `![[target|pothole]]`). Mirrors
419        /// `Inline::Link.is_wikilink`.
420        #[serde(default)]
421        is_wikilink: bool,
422        /// Original pothole text (after `|`) preserved verbatim from the
423        /// pulldown-cmark text events for wikilink images. `None` for
424        /// non-wikilink images and for wikilinks without a pothole
425        /// (pulldown-cmark synthesizes the dest as text when no pothole
426        /// is present).
427        #[serde(default)]
428        wikilink_pothole: Option<String>,
429    },
430    /// `*emphasis*`
431    Emphasis(Vec<Inline>),
432    /// `**strong**`
433    Strong(Vec<Inline>),
434    /// `` `code` ``
435    Code(String),
436    /// Hard line break.
437    LineBreak,
438    /// `~~struck~~`
439    Strikethrough(Vec<Inline>),
440    /// `[^label]` — a GFM footnote marker. Carries the author's label, not
441    /// the printed number: numbering is first-reference order over the whole
442    /// document, which is a render-time fact (same rule as
443    /// [`ColumnAlignment`]'s numeric auto-alignment). See ADR-035.
444    FootnoteRef(String),
445    /// The `[ ]` / `[x]` of a GFM task-list item, carrying its checked state.
446    ///
447    /// Modeled as an INLINE, not as a field on [`Block::List`], because that is
448    /// where pulldown-cmark puts it: `Event::TaskListMarker` is the first event
449    /// *inside* `Tag::Item`, ahead of the item's own content. Keeping it inline
450    /// leaves `Block::List`'s shape untouched and avoids a third vector parallel
451    /// to `items` / `item_source_lines`. See ADR-035 § Task lists.
452    TaskMarker(bool),
453    /// Raw inline HTML passthrough (`Event::Html` / `Event::InlineHtml`),
454    /// plus the math node `math_text::math_inline` synthesizes (ADR-030).
455    /// NOT a fallback for unmodeled pulldown constructs — see the module doc.
456    Other(String),
457}
458
459#[cfg(test)]
460mod tests {
461    use super::super::url::{Url, UrlKind};
462    use super::*;
463
464    fn text(s: &str) -> Inline {
465        Inline::Text(s.to_string())
466    }
467
468    #[test]
469    fn block_heading_constructable() {
470        let b = Block::Heading {
471            level: 1,
472            children: vec![text("Hello")],
473            id: Some("hello".to_string()),
474        };
475        match b {
476            Block::Heading {
477                level,
478                children,
479                id,
480            } => {
481                assert_eq!(level, 1);
482                assert_eq!(children.len(), 1);
483                assert_eq!(id.as_deref(), Some("hello"));
484            }
485            _ => panic!("expected Heading"),
486        }
487    }
488
489    #[test]
490    fn block_paragraph_holds_inlines() {
491        let b = Block::Paragraph(vec![text("hi"), Inline::LineBreak, text("there")]);
492        match b {
493            Block::Paragraph(items) => assert_eq!(items.len(), 3),
494            _ => panic!("expected Paragraph"),
495        }
496    }
497
498    #[test]
499    fn block_list_each_item_is_block_vec() {
500        let b = Block::List {
501            ordered: false,
502            start: None,
503            items: vec![
504                vec![Block::Paragraph(vec![text("first")])],
505                vec![Block::Paragraph(vec![text("second")])],
506            ],
507            item_source_lines: vec![],
508        };
509        match b {
510            Block::List {
511                ordered,
512                start,
513                items,
514                ..
515            } => {
516                assert!(!ordered);
517                assert!(start.is_none());
518                assert_eq!(items.len(), 2);
519            }
520            _ => panic!("expected List"),
521        }
522    }
523
524    #[test]
525    fn block_list_carries_explicit_start_number() {
526        // Phase 4 followup B (2026-05-28): ordered lists with an
527        // explicit non-default start number round-trip through the AST.
528        let b = Block::List {
529            ordered: true,
530            start: Some(3),
531            items: vec![vec![Block::Paragraph(vec![text("foo")])]],
532            item_source_lines: vec![],
533        };
534        match b {
535            Block::List {
536                ordered,
537                start,
538                items,
539                ..
540            } => {
541                assert!(ordered);
542                assert_eq!(start, Some(3));
543                assert_eq!(items.len(), 1);
544            }
545            _ => panic!("expected List"),
546        }
547    }
548
549    #[test]
550    fn block_table_two_dim_rows() {
551        let b = Block::Table {
552            header: vec![vec![text("A")], vec![text("B")]],
553            rows: vec![
554                vec![vec![text("1")], vec![text("2")]],
555                vec![vec![text("3")], vec![text("4")]],
556            ],
557            alignments: Vec::new(),
558            header_source_line: None,
559            row_source_lines: vec![],
560        };
561        match b {
562            Block::Table { header, rows, .. } => {
563                assert_eq!(header.len(), 2);
564                assert_eq!(rows.len(), 2);
565                assert_eq!(rows[0].len(), 2);
566            }
567            _ => panic!("expected Table"),
568        }
569    }
570
571    #[test]
572    fn block_other_carries_raw_html() {
573        let b = Block::Other("<custom>raw</custom>".to_string());
574        match b {
575            Block::Other(s) => assert_eq!(s, "<custom>raw</custom>"),
576            _ => panic!("expected Other"),
577        }
578    }
579
580    #[test]
581    fn block_thematic_break_is_unit_variant() {
582        let b = Block::ThematicBreak;
583        assert!(matches!(b, Block::ThematicBreak));
584    }
585
586    #[test]
587    fn block_figure_carries_image_and_optional_caption() {
588        // Phase 4 PR3: Block::Figure wraps a single Inline::Image and an
589        // optional caption (vector of inlines so emphasis/strong can ride
590        // through). Caption defaults to the image's alt text at parse time;
591        // None is reserved for the empty-alt case.
592        let image = Inline::Image {
593            src: Url::resolved("photo.jpg", UrlKind::Asset),
594            alt: "A photo".to_string(),
595            title: None,
596            is_wikilink: false,
597            wikilink_pothole: None,
598        };
599        let b = Block::Figure {
600            image: image.clone(),
601            caption: Some(vec![text("A photo")]),
602            width: None,
603            align: None,
604            class_names: Vec::new(),
605            img_style: None,
606        };
607        match b {
608            Block::Figure {
609                image: img,
610                caption,
611                ..
612            } => {
613                assert!(matches!(img, Inline::Image { .. }));
614                let cap = caption.expect("caption present");
615                assert_eq!(cap.len(), 1);
616            }
617            _ => panic!("expected Figure"),
618        }
619    }
620
621    #[test]
622    fn block_figure_without_caption_serializes() {
623        // Empty-alt case: caption: None means "no figcaption emission."
624        let b = Block::Figure {
625            image: Inline::Image {
626                src: Url::resolved("x.jpg", UrlKind::Asset),
627                alt: String::new(),
628                title: None,
629                is_wikilink: false,
630                wikilink_pothole: None,
631            },
632            caption: None,
633            width: None,
634            align: None,
635            class_names: Vec::new(),
636            img_style: None,
637        };
638        let s = serde_json::to_string(&b).expect("serialize");
639        let back: Block = serde_json::from_str(&s).expect("deserialize");
640        assert_eq!(b, back);
641    }
642
643    #[test]
644    fn inline_link_carries_url_and_children() {
645        let i = Inline::Link {
646            url: Url::unresolved("docs/"),
647            title: None,
648            children: vec![text("Documentation")],
649            is_wikilink: false,
650        };
651        match i {
652            Inline::Link {
653                url,
654                title,
655                children,
656                is_wikilink,
657            } => {
658                assert!(url.is_unresolved());
659                assert!(title.is_none());
660                assert_eq!(children.len(), 1);
661                assert!(!is_wikilink);
662            }
663            _ => panic!("expected Link"),
664        }
665    }
666
667    #[test]
668    fn inline_image_uses_url_for_src() {
669        // Per R6: Inline::Image carries Url (not a separate Src type).
670        // UrlKind::Asset is the relevant variant after resolution.
671        let i = Inline::Image {
672            src: Url::resolved("img/cat.jpg", UrlKind::Asset),
673            alt: "Cat".to_string(),
674            title: None,
675            is_wikilink: false,
676            wikilink_pothole: None,
677        };
678        match i {
679            Inline::Image {
680                src,
681                alt,
682                title: _,
683                is_wikilink: _,
684                wikilink_pothole: _,
685            } => {
686                let Url::Resolved(r) = src else {
687                    panic!("expected Resolved, got {src:?}")
688                };
689                assert_eq!(r.kind, UrlKind::Asset);
690                assert_eq!(alt, "Cat");
691            }
692            _ => panic!("expected Image"),
693        }
694    }
695
696    #[test]
697    fn inline_emphasis_and_strong_nest() {
698        let i = Inline::Strong(vec![Inline::Emphasis(vec![text("nested")])]);
699        match i {
700            Inline::Strong(children) => match &children[0] {
701                Inline::Emphasis(inner) => assert_eq!(inner.len(), 1),
702                _ => panic!("expected Emphasis"),
703            },
704            _ => panic!("expected Strong"),
705        }
706    }
707
708    #[test]
709    fn block_round_trips_through_serde() {
710        let original = Block::Heading {
711            level: 2,
712            children: vec![Inline::Text("Setup".to_string())],
713            id: Some("setup".to_string()),
714        };
715        let s = serde_json::to_string(&original).expect("serialize");
716        let back: Block = serde_json::from_str(&s).expect("deserialize");
717        assert_eq!(original, back);
718    }
719
720    // -----------------------------------------------------------------
721    // Phase 4 PR4: CalloutKind canonicalization
722    // -----------------------------------------------------------------
723
724    #[test]
725    fn callout_kind_canonicalizes_canonical_names() {
726        assert_eq!(CalloutKind::from_raw("note"), Some(CalloutKind::Note));
727        assert_eq!(CalloutKind::from_raw("tip"), Some(CalloutKind::Tip));
728        assert_eq!(CalloutKind::from_raw("warning"), Some(CalloutKind::Warning));
729        assert_eq!(CalloutKind::from_raw("danger"), Some(CalloutKind::Danger));
730        assert_eq!(CalloutKind::from_raw("info"), Some(CalloutKind::Info));
731        assert_eq!(CalloutKind::from_raw("todo"), Some(CalloutKind::Todo));
732        assert_eq!(CalloutKind::from_raw("success"), Some(CalloutKind::Success));
733        assert_eq!(
734            CalloutKind::from_raw("question"),
735            Some(CalloutKind::Question)
736        );
737        assert_eq!(CalloutKind::from_raw("failure"), Some(CalloutKind::Failure));
738        assert_eq!(CalloutKind::from_raw("bug"), Some(CalloutKind::Bug));
739        assert_eq!(CalloutKind::from_raw("example"), Some(CalloutKind::Example));
740        assert_eq!(CalloutKind::from_raw("quote"), Some(CalloutKind::Quote));
741        assert_eq!(
742            CalloutKind::from_raw("abstract"),
743            Some(CalloutKind::Abstract)
744        );
745    }
746
747    #[test]
748    fn callout_kind_canonicalizes_all_obsidian_aliases() {
749        // The 8 alias mappings from shape-spec § 1.
750        assert_eq!(CalloutKind::from_raw("tldr"), Some(CalloutKind::Abstract));
751        assert_eq!(
752            CalloutKind::from_raw("summary"),
753            Some(CalloutKind::Abstract)
754        );
755        assert_eq!(CalloutKind::from_raw("hint"), Some(CalloutKind::Tip));
756        assert_eq!(CalloutKind::from_raw("important"), Some(CalloutKind::Tip));
757        assert_eq!(CalloutKind::from_raw("check"), Some(CalloutKind::Success));
758        assert_eq!(CalloutKind::from_raw("done"), Some(CalloutKind::Success));
759        assert_eq!(CalloutKind::from_raw("help"), Some(CalloutKind::Question));
760        assert_eq!(CalloutKind::from_raw("faq"), Some(CalloutKind::Question));
761        assert_eq!(CalloutKind::from_raw("caution"), Some(CalloutKind::Warning));
762        assert_eq!(
763            CalloutKind::from_raw("attention"),
764            Some(CalloutKind::Warning)
765        );
766        assert_eq!(CalloutKind::from_raw("fail"), Some(CalloutKind::Failure));
767        assert_eq!(CalloutKind::from_raw("missing"), Some(CalloutKind::Failure));
768        assert_eq!(CalloutKind::from_raw("error"), Some(CalloutKind::Danger));
769        assert_eq!(CalloutKind::from_raw("cite"), Some(CalloutKind::Quote));
770        // Legacy alias for SoCiviC Theatre's `> [!pending]` syntax.
771        assert_eq!(CalloutKind::from_raw("pending"), Some(CalloutKind::Todo));
772    }
773
774    #[test]
775    fn callout_kind_is_case_insensitive() {
776        assert_eq!(CalloutKind::from_raw("NOTE"), Some(CalloutKind::Note));
777        assert_eq!(CalloutKind::from_raw("Warning"), Some(CalloutKind::Warning));
778        assert_eq!(CalloutKind::from_raw("TLDR"), Some(CalloutKind::Abstract));
779    }
780
781    #[test]
782    fn callout_kind_unknown_returns_none() {
783        assert_eq!(CalloutKind::from_raw("xyz"), None);
784        assert_eq!(CalloutKind::from_raw(""), None);
785        assert_eq!(CalloutKind::from_raw("not-a-kind"), None);
786    }
787
788    #[test]
789    fn callout_kind_slug_matches_canonical_name() {
790        assert_eq!(CalloutKind::Note.as_slug(), "note");
791        assert_eq!(CalloutKind::Abstract.as_slug(), "abstract");
792        assert_eq!(CalloutKind::Warning.as_slug(), "warning");
793        assert_eq!(CalloutKind::Danger.as_slug(), "danger");
794    }
795
796    #[test]
797    fn callout_kind_default_title_is_capitalized() {
798        assert_eq!(CalloutKind::Note.default_title(), "Note");
799        assert_eq!(CalloutKind::Warning.default_title(), "Warning");
800        assert_eq!(CalloutKind::Abstract.default_title(), "Abstract");
801    }
802
803    #[test]
804    fn block_callout_round_trips_through_serde() {
805        let original = Block::Callout {
806            kind: CalloutKind::Warning,
807            fold: Some(Fold::Open),
808            title: Some("Hey".to_string()),
809            children: vec![Block::Paragraph(vec![Inline::Text("body".into())])],
810        };
811        let s = serde_json::to_string(&original).expect("serialize");
812        let back: Block = serde_json::from_str(&s).expect("deserialize");
813        assert_eq!(original, back);
814    }
815
816    #[test]
817    fn inline_link_with_resolved_url_round_trips() {
818        let original = Inline::Link {
819            url: Url::resolved("../docs/", UrlKind::Wikilink),
820            title: Some("Docs".to_string()),
821            children: vec![Inline::Text("see".to_string())],
822            is_wikilink: true,
823        };
824        let s = serde_json::to_string(&original).expect("serialize");
825        let back: Inline = serde_json::from_str(&s).expect("deserialize");
826        assert_eq!(original, back);
827    }
828
829    #[test]
830    fn inline_link_is_wikilink_serde_defaults_to_false() {
831        // When deserializing AST JSON authored before PR7a, missing
832        // `is_wikilink` field must default to `false` (back-compat for
833        // any serialized snapshots that pre-date the wikilink AST work).
834        let json =
835            r#"{"link":{"url":{"unresolved":"docs/"},"title":null,"children":[{"text":"Docs"}]}}"#;
836        let back: Inline = serde_json::from_str(json).expect("deserialize");
837        match back {
838            Inline::Link { is_wikilink, .. } => assert!(!is_wikilink),
839            _ => panic!("expected Link"),
840        }
841    }
842}