Skip to main content

moss_core/heading/
extract.rs

1//! Pure extraction of a document's headings (text + slug + level) for the
2//! editor's `[[Page#Heading]]` autocomplete. Reuses `parse()` (which runs
3//! `assign_heading_id_suffixes`) so the returned slugs are byte-identical
4//! to the rendered `<hN id="...">` attributes — the keystone invariant.
5//!
6//! The plain-text flattening is [`crate::ast::plain_text::inlines_to_plain_text`]
7//! — shared with the event-stream walker the parser slugs from, so the label
8//! this returns and the `<hN id>` cannot describe the heading differently.
9//!
10//! v1 extracts TOP-LEVEL headings only (the common case). Headings nested
11//! inside callouts / blockquotes / lists are not offered for autocomplete;
12//! a recursive walk is a follow-up if needed.
13
14use crate::ast::parser::ParseConfig;
15use crate::ast::plain_text::inlines_to_plain_text;
16use crate::ast::{parse_with_config, Block};
17
18/// A heading discovered in a document, in document order.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct HeadingInfo {
21    /// Plain-text heading title (inline markup flattened).
22    pub text: String,
23    /// Final deduped slug — matches the rendered `<hN id="...">`.
24    pub slug: String,
25    /// Heading level 1..=6.
26    pub level: u8,
27}
28
29/// Extract all top-level headings from `markdown` in document order, with
30/// final (deduped) slugs identical to the rendered `<hN id>`.
31///
32/// Uses [`ParseConfig::default`], which has math **off**. A site with
33/// `[site].math` on must call [`extract_headings_with_config`] instead:
34/// with math off, `$…$` is ordinary text and the slug happens to come out
35/// right, but that is a coincidence of this release's delimiter-preserving
36/// design and not something callers should lean on.
37pub fn extract_headings(markdown: &str) -> Vec<HeadingInfo> {
38    extract_headings_with_config(markdown, &ParseConfig::default())
39}
40
41/// [`extract_headings`], parsing with the caller's [`ParseConfig`].
42///
43/// The keystone invariant is byte-identity with the rendered `<hN id>`, and
44/// the render path parses with the *site's* config. Parsing here with a
45/// different one is therefore a way to violate the invariant without
46/// touching any slug logic, which is exactly what happened while this
47/// function called the bare `parse()`.
48pub fn extract_headings_with_config(markdown: &str, config: &ParseConfig) -> Vec<HeadingInfo> {
49    let doc = parse_with_config(markdown, config);
50    let mut out = Vec::new();
51    for block in &doc.blocks {
52        if let Block::Heading {
53            level,
54            children,
55            id,
56        } = block
57        {
58            let text = inlines_to_plain_text(children);
59            out.push(HeadingInfo {
60                text: text.trim().to_string(),
61                slug: id.clone().unwrap_or_default(),
62                level: *level,
63            });
64        }
65    }
66    out
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn extracts_text_slug_level() {
75        let md = "# Title\n\n## Getting Started\n\ntext\n\n### Sub *em*\n";
76        let hs = extract_headings(md);
77        assert_eq!(hs.len(), 3);
78        assert_eq!(hs[0], HeadingInfo { text: "Title".into(), slug: "title".into(), level: 1 });
79        assert_eq!(hs[1], HeadingInfo { text: "Getting Started".into(), slug: "getting-started".into(), level: 2 });
80        assert_eq!(hs[2], HeadingInfo { text: "Sub em".into(), slug: "sub-em".into(), level: 3 });
81    }
82
83    #[test]
84    fn dedups_duplicate_slugs() {
85        let md = "## Setup\n\n## Setup\n";
86        let hs = extract_headings(md);
87        assert_eq!(hs[0].slug, "setup");
88        assert_eq!(hs[1].slug, "setup-1");
89    }
90
91    #[test]
92    fn preserves_cjk() {
93        let md = "## 中文标题\n";
94        let hs = extract_headings(md);
95        assert_eq!(hs[0].slug, "中文标题");
96        assert_eq!(hs[0].text, "中文标题");
97    }
98
99    #[test]
100    fn empty_doc_no_headings() {
101        assert!(extract_headings("just a paragraph\n").is_empty());
102    }
103
104    #[test]
105    fn slug_matches_obsidian_anchor_for_punctuation() {
106        // Keystone: slug must equal what obsidian_heading_anchor produces
107        // (the same fn the renderer uses for id=). Spot-check a heading with
108        // punctuation that the algorithm keeps/strips distinctively.
109        let md = "## Step 1: Install\n";
110        let hs = extract_headings(md);
111        assert_eq!(hs[0].slug, crate::heading::anchor::obsidian_heading_anchor("Step 1: Install"));
112    }
113
114    /// The keystone invariant, with math in the heading — the case that
115    /// broke it. The slug the walker extracts, the `<hN id>` the renderer
116    /// emits, and the raw-line slug the wikilink graph computes in
117    /// `build/scan/scan.rs` must agree byte-for-byte. That is the invariant
118    /// that holds unconditionally, because all three see the same `$` bytes.
119    ///
120    /// **Agreement with the math=OFF slug is NOT universal**, and asserting
121    /// it as such was wrong. With math off the TeX is ordinary markdown, so
122    /// any markdown-active character inside it is consumed before slugging:
123    /// `$f*g$ and $h*k$` has its two `*` eaten as emphasis, and `$V^*$ and
124    /// $W^*$` — plain dual-space notation — likewise. See the `*` cases
125    /// below and ADR-030 §"Upgrade-time anchor movement".
126    #[test]
127    fn math_heading_slug_is_identical_across_every_surface() {
128        let md = "# Euler $e^{i\\pi}=-1$ identity\n";
129        let math_on = ParseConfig { math: true, ..Default::default() };
130
131        let extracted = extract_headings_with_config(md, &math_on);
132        assert_eq!(extracted.len(), 1);
133
134        // 1 ↔ 2: extracted slug == the id the renderer emits.
135        let doc = crate::ast::parse_with_config(md, &math_on);
136        let Block::Heading { id, .. } = &doc.blocks[0] else {
137            panic!("expected a heading, got {:?}", doc.blocks[0]);
138        };
139        assert_eq!(extracted[0].slug, *id.as_ref().expect("heading must have an id"));
140
141        // 3: the wikilink graph slugs the RAW heading line. THIS is the
142        // strong one — a mismatch resolves a link to a fragment the page
143        // does not have.
144        assert_eq!(
145            extracted[0].slug,
146            crate::heading::anchor::obsidian_heading_anchor("Euler $e^{i\\pi}=-1$ identity")
147        );
148
149        // 4: this TeX has no markdown-active characters, so the math=OFF
150        // slug happens to coincide too. Conditional, not universal.
151        let off = extract_headings_with_config(md, &ParseConfig::default());
152        assert_eq!(
153            extracted[0].slug, off[0].slug,
154            "TeX with no markdown-active characters must slug identically either way"
155        );
156
157        // And the human-readable label keeps the equation rather than
158        // showing a hole where it used to be.
159        assert_eq!(extracted[0].text, "Euler $e^{i\\pi}=-1$ identity");
160        assert_eq!(extracted[0].text, off[0].text);
161    }
162
163    /// Pins the exception, so nobody re-asserts the false universal.
164    ///
165    /// `*` inside TeX is emphasis to a math-OFF parser. Turning `[site].math`
166    /// on therefore MOVES these anchors — a real, user-visible upgrade cost
167    /// recorded in ADR-030 and the moss-core CHANGELOG. What must still hold
168    /// is graph agreement: math-ON slug == the raw-line slug the wikilink
169    /// scanner computes, so links and anchors never disagree on a live site.
170    #[test]
171    fn markdown_active_chars_in_tex_move_the_anchor_but_keep_graph_agreement() {
172        let math_on = ParseConfig { math: true, ..Default::default() };
173        for (md, raw, expect_off) in [
174            (
175                "# Convolution $f*g$ and $h*k$ end\n",
176                "Convolution $f*g$ and $h*k$ end",
177                "convolution-$fg$-and-$hk$-end",
178            ),
179            ("# Dual $V^*$ and $W^*$ end\n", "Dual $V^*$ and $W^*$ end", "dual-$v$-and-$w$-end"),
180        ] {
181            let on = extract_headings_with_config(md, &math_on);
182            let off = extract_headings_with_config(md, &ParseConfig::default());
183
184            // Graph agreement — unconditional.
185            assert_eq!(
186                on[0].slug,
187                crate::heading::anchor::obsidian_heading_anchor(raw),
188                "math-ON slug diverged from the raw-line slug the wikilink graph computes"
189            );
190
191            // The documented divergence: emphasis ate the `*` with math off.
192            assert_eq!(off[0].slug, expect_off, "math-OFF slug drifted from what ADR-030 records");
193            assert_ne!(
194                on[0].slug, off[0].slug,
195                "expected this heading's anchor to MOVE when math is enabled"
196            );
197        }
198    }
199
200    /// `$$…$$` has no markdown-active characters here, so both slugs agree.
201    #[test]
202    fn display_math_in_a_heading_keeps_both_delimiters() {
203        let math_on = ParseConfig { math: true, ..Default::default() };
204        let hs = extract_headings_with_config("# Case $$a+b$$ tail\n", &math_on);
205        assert_eq!(hs[0].text, "Case $$a+b$$ tail");
206        assert_eq!(
207            hs[0].slug,
208            crate::heading::anchor::obsidian_heading_anchor("Case $$a+b$$ tail"),
209            "graph agreement — the invariant that always holds"
210        );
211        assert_eq!(
212            hs[0].slug,
213            extract_headings_with_config("# Case $$a+b$$ tail\n", &ParseConfig::default())[0].slug
214        );
215    }
216
217    /// Two headings differing only inside their math must stay distinct.
218    /// Dropping the TeX collapsed them to the same base slug, so the second
219    /// silently acquired a `-1` suffix and the anchors became order-dependent.
220    #[test]
221    fn headings_differing_only_inside_math_do_not_collide() {
222        let math_on = ParseConfig { math: true, ..Default::default() };
223        let hs = extract_headings_with_config("## Case $a$\n\n## Case $b$\n", &math_on);
224        assert_ne!(hs[0].slug, hs[1].slug);
225        assert!(!hs[1].slug.ends_with("-1"), "slug {:?} collided", hs[1].slug);
226    }
227
228    /// The keystone invariant across a shortcode boundary. A `:::grid` cell
229    /// is parsed by a recursive `parse_fragment_with_config`, which is told
230    /// to SKIP `assign_heading_id_suffixes` precisely so its heading arrives
231    /// holding the bare slug — the cell renders into the SAME page, so the
232    /// page's own parse must be the only pass that numbers. When the nested
233    /// parse numbered too, a cell was disambiguated against its own private
234    /// counter and then again against the page's, which both re-collided and
235    /// produced impossible shapes like `notes-1-1`.
236    ///
237    /// The counter has to be shared, or the page emits two `id="notes"` and
238    /// the browser resolves `#notes` to whichever comes first in the DOM
239    /// (the card), never the author's section.
240    #[test]
241    fn grid_cell_heading_shares_the_page_id_counter() {
242        let md = ":::grid\n### Notes\n:::\n\n## Notes\n";
243        let hs = extract_headings(md);
244        assert_eq!(hs.len(), 1, "only top-level headings are offered: {hs:?}");
245        assert_eq!(
246            hs[0].slug, "notes-1",
247            "the body section must report the id it actually renders with"
248        );
249
250        let mut doc = crate::ast::parse(md);
251        crate::ast::classify_remaining_urls(&mut doc);
252        let html = crate::ast::render_document(&doc, &crate::ast::DefaultHooks::new());
253        assert_eq!(
254            html.matches(r#"id="notes""#).count(),
255            1,
256            "duplicate DOM id: {html}"
257        );
258        assert!(
259            html.contains(r#"id="notes-1""#),
260            "the body heading lost its disambiguated id: {html}"
261        );
262    }
263
264    /// Every `id=` a page publishes, in DOM order.
265    fn rendered_ids(md: &str) -> Vec<String> {
266        let mut doc = crate::ast::parse(md);
267        crate::ast::classify_remaining_urls(&mut doc);
268        let html = crate::ast::render_document(&doc, &crate::ast::DefaultHooks::new());
269        let mut ids = Vec::new();
270        let mut rest = html.as_str();
271        while let Some(at) = rest.find("id=\"") {
272            let Some(after) = rest.get(at + 4..) else { break };
273            let Some(end) = after.find('"') else { break };
274            let Some(id) = after.get(..end) else { break };
275            ids.push(id.to_string());
276            rest = after.get(end + 1..).unwrap_or("");
277        }
278        ids
279    }
280
281    /// One cell holding TWO same-titled headings. `grid_cell_heading_shares_
282    /// the_page_id_counter` puts one heading per cell, where the nested parse
283    /// assigns no suffix at all — so it cannot see a cell that arrives already
284    /// carrying `notes-1` and gets suffixed a second time by the page walk.
285    /// Assert on the whole id set, not one slug: the collision lands on
286    /// whatever slug the double-suffixing produced, which counting `id="notes"`
287    /// never sees.
288    #[test]
289    fn a_cell_holding_two_same_titled_headings_still_yields_unique_ids() {
290        for (name, md) in [
291            ("grid", "## Notes\n\n:::grid\n### Notes\n\n### Notes\n:::\n"),
292            (
293                "hero overlay",
294                ":::hero\ncover.jpg\n---\n### Notes\n\n### Notes\n:::\n\n## Notes\n",
295            ),
296            (
297                "compound-link card",
298                ":::grid\n[### Notes\n\n### Notes](https://example.com/a)\n:::\n\n## Notes\n",
299            ),
300        ] {
301            let ids = rendered_ids(md);
302            let unique: std::collections::HashSet<&String> = ids.iter().collect();
303            assert_eq!(
304                unique.len(),
305                ids.len(),
306                "{name}: duplicate DOM id among {ids:?}"
307            );
308        }
309    }
310
311    /// A slug rule can never mint `notes-1-1`; only a second suffixing pass
312    /// over an already-suffixed id can. Two cells, two same-titled headings
313    /// each, is the shape that made it visible.
314    #[test]
315    fn no_id_carries_a_doubled_suffix() {
316        let ids = rendered_ids(":::grid 2\n### Notes\n\n### Notes\n+++\n### Notes\n\n### Notes\n:::\n\n## Notes\n");
317        for id in &ids {
318            assert!(
319                !id.contains("-1-"),
320                "id {id:?} was suffixed twice — the nested parse numbered it \
321                 and the page walk numbered it again: {ids:?}"
322            );
323        }
324        let unique: std::collections::HashSet<&String> = ids.iter().collect();
325        assert_eq!(unique.len(), ids.len(), "duplicate DOM id among {ids:?}");
326    }
327}