Skip to main content

moss_core/heading/
text.rs

1//! "Inline content → plain text", once.
2//!
3//! Two walkers need this operation and they cannot be merged, because they
4//! read different inputs at different times:
5//!
6//! | walker | input | when | consumer |
7//! |---|---|---|---|
8//! | [`events_to_text`] | `&[pulldown_cmark::Event]` | during parse, before the AST exists | the `<hN id>` slug |
9//! | [`crate::ast::plain_text::inlines_to_plain_text`] | `&[Inline]` | after parse | [`super::extract`]'s autocomplete label |
10//!
11//! What they must NOT do is disagree about what a piece of content looks
12//! like as text. They did: `collect_heading_text` in `ast/parser.rs` and
13//! `inlines_to_text` in `extract_headings.rs` were independent `match`
14//! arms over independent enums, so a heading's slug and its autocomplete
15//! label could drift apart — which is exactly what the July 2026 math
16//! cluster found (`$f*g$` came out of one and `$fg$` out of the other).
17//!
18//! So: **one policy, two adapters.** [`crate::ast::plain_text::TextAtom`] is
19//! the vocabulary the policy speaks; `push_atom` IS the policy and is the
20//! only place that decides what an atom's text is; each walker's job is
21//! reduced to classifying its own node type into an atom. Changing what math
22//! (or a line break, or code) looks like in plain text is a one-line edit in
23//! one function, and both surfaces move together by construction.
24//!
25//! The `&[Inline]` half of the policy (and its adapter,
26//! [`crate::ast::plain_text::inlines_to_plain_text`]) moved to
27//! `ast/plain_text.rs` (ADR-036) once a third non-heading consumer
28//! (`build::page::meta::extract_description`) appeared — exactly the
29//! trigger that module's promotion doc comment named in advance. This
30//! module keeps only [`events_to_text`], the mid-parse event-stream half
31//! that has no AST to walk yet.
32//!
33//! ## The one difference that remains, and why it is not a bug to fix here
34//!
35//! The two walkers see different *vocabularies*, not different policies:
36//! the event stream carries `SoftBreak`/`HardBreak` events that the slug
37//! walker has always ignored, while the AST folds a `SoftBreak` into
38//! `Inline::Text("\n")` and a `HardBreak` into [`Inline::LineBreak`]. A
39//! multi-line setext heading therefore slugs as `foobar` but labels as
40//! `foo bar`. That predates this module and is a *behavioral* question —
41//! changing it moves live anchors. It is pinned by
42//! `setext_soft_break_divergence_is_pinned_not_fixed` below so the next
43//! person meets it as a decision rather than as a surprise.
44
45use std::borrow::Cow;
46
47use crate::ast::math_text::math_source;
48use crate::ast::plain_text::{push_atom, TextAtom};
49use pulldown_cmark::Event;
50
51/// Flatten the parser events in `events[start..end]` to plain text.
52///
53/// Runs mid-parse, where the only thing that exists is the event stream —
54/// this is what `ast/parser.rs` slugs into the rendered `<hN id="…">`. The
55/// caller passes the range *inside* the heading tags (exclusive of the
56/// matching `Event::End(TagEnd::Heading)`).
57///
58/// Mirrors production's `transform_events` heading-text collection at
59/// `src-tauri/src/build/markdown/pipeline.rs`. Inline HTML
60/// (`Event::InlineHtml` / `Event::Html`) is intentionally skipped, so
61/// `# FAREWELL,<br>AND ERASE` slugs as `FAREWELL,AND ERASE` with no `<br>`
62/// in the anchor. Link and image *labels* are captured: pulldown walks the
63/// events inside `Tag::Link` / `Tag::Image` transparently and their
64/// `Event::Text` payloads land here, matching production; the href does
65/// not.
66pub(crate) fn events_to_text(events: &[Event<'_>], start: usize, end: usize) -> String {
67    let mut out = String::new();
68    for event in &events[start..end] {
69        match event {
70            Event::Text(t) => push_atom(&mut out, TextAtom::Verbatim(t)),
71            Event::Code(c) => push_atom(&mut out, TextAtom::Verbatim(c)),
72            Event::InlineMath(t) => push_atom(&mut out, math_atom(t, false)),
73            Event::DisplayMath(t) => push_atom(&mut out, math_atom(t, true)),
74            // Start/End tags carry no text of their own; their contents
75            // arrive as their own Text events (image `alt` included).
76            // Soft/HardBreak deliberately contribute nothing — see the
77            // module doc's "one difference that remains".
78            _ => {}
79        }
80    }
81    out
82}
83
84/// Build a [`TextAtom::Math`] from the inner TeX pulldown hands this
85/// walker (delimiters stripped) plus its display flag. `events_to_text`'s
86/// own convenience — the `&[Inline]` walker recovers math from
87/// `Inline::Other` via `math_source_from_other` instead, since it has no
88/// original event to read the display flag from.
89fn math_atom(tex: &str, display: bool) -> TextAtom<'static> {
90    TextAtom::Math(Cow::Owned(math_source(tex, display)))
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::ast::parser::ParseConfig;
97    use crate::ast::{parse_with_config, Block};
98    use crate::heading::anchor::obsidian_heading_anchor;
99
100    /// The unification's reason to exist: the slug (event walk, mid-parse)
101    /// and the label (inline walk, post-parse) must describe the same
102    /// heading. `$f*g$` and `$V^*$` are the vectors that caught them apart
103    /// — with math OFF the `*` is emphasis markup, so a walker that drops
104    /// what it does not recognize produces `$fg$` on one side and `$f*g$`
105    /// on the other.
106    #[test]
107    fn event_walk_and_inline_walk_agree_on_every_heading() {
108        for md in [
109            "# Euler $e^{i\\pi}=-1$ identity\n",
110            "# Convolution $f*g$ and $h*k$ end\n",
111            "# Dual $V^*$ and $W^*$ end\n",
112            "# Case $$a+b$$ tail\n",
113            "# Mixed `code` and *em* and **strong** here\n",
114            "# A [link](/x) and ![some alt](/i.png) inline\n",
115            "# 中文 $\\alpha$ 标题\n",
116            "# Nested *em with `code` and $x^2$* tail\n",
117        ] {
118            for math in [false, true] {
119                let cfg = ParseConfig { math, ..Default::default() };
120                let doc = parse_with_config(md, &cfg);
121                let Block::Heading { children, id, .. } = &doc.blocks[0] else {
122                    panic!("expected a heading for {md:?}");
123                };
124                let label = crate::ast::plain_text::inlines_to_plain_text(children);
125                // `id` was produced by `events_to_text` during the parse.
126                assert_eq!(
127                    id.as_deref().expect("heading must have an id"),
128                    obsidian_heading_anchor(&label),
129                    "slug and label disagree for {md:?} (math={math})"
130                );
131            }
132        }
133    }
134
135    /// Pins the one place the two walkers still differ, so it is a recorded
136    /// decision and not a latent surprise. A setext heading is the only
137    /// heading shape that can contain a break at all; the event walk drops
138    /// it, the AST folds a SoftBreak into `Text("\n")`. Fixing it would
139    /// move live anchors, which is out of scope for a structure-only
140    /// consolidation.
141    #[test]
142    fn setext_soft_break_divergence_is_pinned_not_fixed() {
143        let doc = parse_with_config("foo\nbar\n===\n", &ParseConfig::default());
144        let Block::Heading { children, id, .. } = &doc.blocks[0] else {
145            panic!("expected a setext heading, got {:?}", doc.blocks[0]);
146        };
147        let label = crate::ast::plain_text::inlines_to_plain_text(children);
148        assert_eq!(label, "foo\nbar", "AST keeps the SoftBreak as a newline");
149        assert_eq!(id.as_deref(), Some("foobar"), "the event walk drops it");
150    }
151
152    #[test]
153    fn policy_is_one_function() {
154        let mut out = String::new();
155        push_atom(&mut out, TextAtom::Verbatim("a"));
156        push_atom(&mut out, TextAtom::Break);
157        push_atom(&mut out, math_atom("x^2", false));
158        push_atom(&mut out, math_atom("y", true));
159        assert_eq!(out, "a $x^2$$$y$$");
160    }
161}