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  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}