Skip to main content

docutils_core/
transforms.rs

1//! Standard transforms: mirrors `docutils.transforms` (frontmatter excluded,
2//! handled in the parser) — references, footnotes, citations,
3//! substitutions, contents, and section-id deduplication.
4//!
5//! [`apply_transforms`] runs the full default set over a parsed document.
6
7use std::collections::{HashMap, HashSet};
8
9use crate::nodes::*;
10use crate::parser::norm_name;
11
12/// Run the default transform set. Problems append to `doc.messages`.
13pub fn apply_transforms(doc: &mut Document) {
14    dedupe_section_ids(doc);
15    let mut cx = Collect::default();
16    cx.collect_blocks(&doc.children);
17    if expand_target_note_markers(doc) {
18        // Re-collect so the new footnotes join numbering.
19        cx = Collect::default();
20        cx.collect_blocks(&doc.children);
21    }
22    cx.assign_footnote_numbers(doc);
23    let loose = resolve_inlines(doc, &cx);
24    rewrite_target_note_refs(doc, &cx);
25    expand_contents(doc);
26    if !loose.is_empty() {
27        // Oracle `Messages` transform: loose (transform-time) messages
28        // gather into a closing section, after contents expansion so the
29        // section itself is never listed.
30        doc.children.push(Block::Section(Section {
31            title: vec![Inline::Text("Docutils System Messages".into())],
32            level: 1,
33            ids: Vec::new(),
34            children: loose,
35            classes: vec!["system-messages".into()],
36        }));
37    }
38}
39
40/// `.. target-notes::`: one auto footnote per external named target.
41/// The footnote definition carries the link; references to the target are
42/// rewritten to footnote references afterwards (oracle parity).
43/// `.. target-notes::`: one auto footnote per external named target, in
44/// place of the marker (the oracle inserts at the directive position).
45/// Returns true when a marker was expanded.
46fn expand_target_note_markers(doc: &mut Document) -> bool {
47    // Footnotes, deduplicated by URI (the oracle shares one footnote per
48    // URI), in first-target document order.
49    let mut seen_uris: std::collections::HashSet<String> = Default::default();
50    let mut notes: Vec<(String, String)> = Vec::new();
51    fn walk(blocks: &[Block], notes: &mut Vec<(String, String)>) {
52        for b in blocks {
53            match b {
54                Block::HyperlinkTarget(t)
55                    if !t.anonymous && !t.uri.is_empty() && !is_indirect_uri(&t.uri) =>
56                {
57                    notes.push((t.name.clone(), t.uri.clone()));
58                }
59                Block::Section(s) => walk(&s.children, notes),
60                Block::Topic(t) => walk(&t.children, notes),
61                Block::Container(c) => walk(&c.children, notes),
62                _ => {}
63            }
64        }
65    }
66    walk(&doc.children, &mut notes);
67    let mut expanded = false;
68    let mut footnotes: Vec<Block> = Vec::new();
69    for (name, uri) in notes {
70        if seen_uris.insert(uri.clone()) {
71            footnotes.push(Block::Footnote(Footnote {
72                name: format!("target-note-{name}"),
73                auto_numbered: true,
74                symbol: false,
75                number: None,
76                children: vec![Block::Paragraph(vec![Inline::Reference(
77                    RefTarget::External(uri.clone()),
78                    vec![Inline::Text(uri)],
79                )])],
80            }));
81        }
82    }
83    // Splice the footnotes in place of every marker (usually one).
84    fn splice(blocks: &mut Vec<Block>, footnotes: &[Block], expanded: &mut bool) {
85        let mut out: Vec<Block> = Vec::with_capacity(blocks.len());
86        for b in blocks.drain(..) {
87            match b {
88                Block::Topic(t) if t.classes.iter().any(|c| c == "__target_notes__") => {
89                    *expanded = true;
90                    out.extend(footnotes.iter().cloned());
91                }
92                Block::Section(mut s) => {
93                    splice(&mut s.children, footnotes, expanded);
94                    out.push(Block::Section(s));
95                }
96                Block::Topic(mut t) => {
97                    splice(&mut t.children, footnotes, expanded);
98                    out.push(Block::Topic(t));
99                }
100                Block::Container(mut c) => {
101                    splice(&mut c.children, footnotes, expanded);
102                    out.push(Block::Container(c));
103                }
104                other => out.push(other),
105            }
106        }
107        *blocks = out;
108    }
109    splice(&mut doc.children, &footnotes, &mut expanded);
110    expanded
111}
112
113/// Rewrite references to external targets as footnote references to the
114/// matching target-note footnote (see above).
115fn rewrite_target_note_refs(doc: &mut Document, cx: &Collect) {
116    // Footnote URI -> definition name, read back from the tree
117    // (target-note footnotes carry a single linked paragraph). Shared
118    // URIs resolve to the one shared footnote, matching the oracle.
119    let mut fn_of_uri: std::collections::HashMap<String, String> = Default::default();
120    fn collect(blocks: &[Block], fn_of_uri: &mut std::collections::HashMap<String, String>) {
121        for b in blocks {
122            match b {
123                Block::Footnote(f) => {
124                    if f.name.starts_with("target-note-")
125                        && let Some(Block::Paragraph(inlines)) = f.children.first()
126                        && let Some(Inline::Reference(RefTarget::External(uri), _)) =
127                            inlines.first()
128                    {
129                        fn_of_uri.insert(uri.clone(), f.name.clone());
130                    }
131                    collect(&f.children, fn_of_uri);
132                }
133                Block::Section(s) => collect(&s.children, fn_of_uri),
134                Block::Topic(t) => collect(&t.children, fn_of_uri),
135                Block::Container(c) => collect(&c.children, fn_of_uri),
136                _ => {}
137            }
138        }
139    }
140    collect(&doc.children, &mut fn_of_uri);
141    fn walk_inlines(
142        inlines: &mut Vec<Inline>,
143        fn_of_uri: &std::collections::HashMap<String, String>,
144        cx: &Collect,
145    ) {
146        // The oracle appends a footnote reference (plus a space) after
147        // (not instead of) the resolved link, so splice sequences here.
148        let mut out: Vec<Inline> = Vec::with_capacity(inlines.len() + 2);
149        for node in inlines.drain(..) {
150            match node {
151                Inline::Reference(RefTarget::External(uri), text) => {
152                    let footnote = fn_of_uri.get(&uri).map(|fn_name| {
153                        Inline::FootnoteRef(FootnoteRef {
154                            label: display_number(fn_name, cx),
155                            auto: true,
156                            symbol: false,
157                            raw: String::new(),
158                        })
159                    });
160                    out.push(Inline::Reference(RefTarget::External(uri), text));
161                    if footnote.is_some() {
162                        out.push(Inline::Text(" ".into()));
163                    }
164                    if let Some(f) = footnote {
165                        out.push(f);
166                    }
167                }
168                other => out.push(other),
169            }
170        }
171        *inlines = out;
172        // Recurse for links nested inside emphasis etc.
173        for node in inlines.iter_mut() {
174            match node {
175                Inline::Emphasis(c) | Inline::Strong(c) | Inline::Cite(c) => {
176                    walk_inlines(c, fn_of_uri, cx);
177                }
178                _ => {}
179            }
180        }
181    }
182    fn walk(
183        blocks: &mut [Block],
184        fn_of_uri: &std::collections::HashMap<String, String>,
185        cx: &Collect,
186    ) {
187        for b in blocks.iter_mut() {
188            match b {
189                Block::Paragraph(inlines) => walk_inlines(inlines, fn_of_uri, cx),
190                Block::Section(s) => walk(&mut s.children, fn_of_uri, cx),
191                Block::Topic(t) => walk(&mut t.children, fn_of_uri, cx),
192                Block::Container(c) => walk(&mut c.children, fn_of_uri, cx),
193                Block::BulletList(l) => {
194                    for item in &mut l.items {
195                        walk(item, fn_of_uri, cx);
196                    }
197                }
198                Block::EnumList(l) => {
199                    for item in &mut l.items {
200                        walk(item, fn_of_uri, cx);
201                    }
202                }
203                Block::BlockQuote(q) => walk(&mut q.children, fn_of_uri, cx),
204                Block::Admonition(a) => walk(&mut a.children, fn_of_uri, cx),
205                _ => {}
206            }
207        }
208    }
209    walk(&mut doc.children, &fn_of_uri, cx);
210}
211
212#[derive(Default)]
213struct Collect {
214    /// Named hyperlink targets (first wins, like docutils).
215    targets: HashMap<String, String>,
216    /// Names defined more than once (references to them error).
217    duplicate_targets: HashSet<String>,
218    anonymous_targets: Vec<String>,
219    /// Footnote definitions in document order: (name, auto, symbol).
220    footnotes: Vec<(String, bool, bool)>,
221    citations: HashSet<String>,
222    substitutions: HashMap<String, Vec<Block>>,
223    /// Auto-footnote numbering, assigned by definition order.
224    footnote_numbers: HashMap<String, u32>,
225    /// Order of `.. [#]` / `.. [*]` definitions for anonymous refs.
226    auto_footnotes: Vec<String>,
227    symbol_footnotes: Vec<String>,
228}
229
230impl Collect {
231    fn collect_blocks(&mut self, blocks: &[Block]) {
232        for b in blocks {
233            match b {
234                Block::HyperlinkTarget(t) => {
235                    if t.anonymous {
236                        self.anonymous_targets.push(t.uri.clone());
237                    } else if !t.name.is_empty() {
238                        if self.targets.contains_key(&t.name) {
239                            self.duplicate_targets.insert(t.name.clone());
240                        } else {
241                            self.targets.insert(t.name.clone(), t.uri.clone());
242                        }
243                    }
244                }
245                Block::Footnote(f) => {
246                    self.footnotes
247                        .push((f.name.clone(), f.auto_numbered, f.symbol));
248                    if f.auto_numbered && !f.symbol {
249                        self.auto_footnotes.push(f.name.clone());
250                    }
251                    if f.symbol {
252                        self.symbol_footnotes.push(f.name.clone());
253                    }
254                }
255                Block::Citation(c) => {
256                    self.citations.insert(c.name.clone());
257                }
258                Block::SubstitutionDef(s) => {
259                    self.substitutions
260                        .entry(s.name.clone())
261                        .or_insert_with(|| s.replacement.clone());
262                }
263                Block::Section(s) => self.collect_blocks(&s.children),
264                Block::Topic(t) => self.collect_blocks(&t.children),
265                Block::Container(c) => self.collect_blocks(&c.children),
266                Block::BulletList(l) => {
267                    for item in &l.items {
268                        self.collect_blocks(item);
269                    }
270                }
271                Block::EnumList(l) => {
272                    for item in &l.items {
273                        self.collect_blocks(item);
274                    }
275                }
276                Block::DefinitionList(items) => {
277                    for it in items {
278                        self.collect_blocks(&it.definition);
279                    }
280                }
281                Block::FieldList(fields) => {
282                    for f in fields {
283                        self.collect_blocks(&f.body);
284                    }
285                }
286                Block::OptionList(groups) => {
287                    for g in groups {
288                        self.collect_blocks(&g.body);
289                    }
290                }
291                Block::BlockQuote(q) => self.collect_blocks(&q.children),
292                Block::Admonition(a) => self.collect_blocks(&a.children),
293                Block::Figure(f) => self.collect_blocks(&f.caption),
294                Block::Table(t) => {
295                    for row in t.head.iter().chain(t.body.iter()) {
296                        for cell in row {
297                            self.collect_blocks(&cell.blocks);
298                        }
299                    }
300                }
301                _ => {}
302            }
303        }
304    }
305
306    fn assign_footnote_numbers(&mut self, doc: &mut Document) {
307        let mut next: u32 = 0;
308        // Manual labels occupy their numeric value to avoid collisions.
309        for (name, auto, symbol) in &self.footnotes {
310            if !auto
311                && !symbol
312                && let Ok(n) = name.parse::<u32>()
313            {
314                next = next.max(n);
315            }
316        }
317        let mut numbers: HashMap<String, u32> = HashMap::new();
318        for (name, auto, symbol) in &self.footnotes {
319            if *auto && !*symbol && !numbers.contains_key(name) {
320                next += 1;
321                numbers.insert(name.clone(), next);
322            }
323        }
324        self.footnote_numbers = numbers;
325        // Write numbers back into the tree.
326        fn walk(blocks: &mut [Block], numbers: &HashMap<String, u32>) {
327            for b in blocks {
328                match b {
329                    Block::Footnote(f) => {
330                        f.number = numbers.get(&f.name).copied();
331                    }
332                    Block::Section(s) => walk(&mut s.children, numbers),
333                    Block::Topic(t) => walk(&mut t.children, numbers),
334                    Block::Container(c) => walk(&mut c.children, numbers),
335                    Block::BulletList(l) => {
336                        for item in &mut l.items {
337                            walk(item, numbers);
338                        }
339                    }
340                    Block::EnumList(l) => {
341                        for item in &mut l.items {
342                            walk(item, numbers);
343                        }
344                    }
345                    Block::DefinitionList(items) => {
346                        for it in items.iter_mut() {
347                            walk(&mut it.definition, numbers);
348                        }
349                    }
350                    Block::FieldList(fields) => {
351                        for f in fields.iter_mut() {
352                            walk(&mut f.body, numbers);
353                        }
354                    }
355                    Block::OptionList(groups) => {
356                        for g in groups.iter_mut() {
357                            walk(&mut g.body, numbers);
358                        }
359                    }
360                    Block::BlockQuote(q) => walk(&mut q.children, numbers),
361                    Block::Admonition(a) => walk(&mut a.children, numbers),
362                    Block::Figure(f) => walk(&mut f.caption, numbers),
363                    Block::Table(t) => {
364                        for row in t.head.iter_mut().chain(t.body.iter_mut()) {
365                            for cell in row {
366                                walk(&mut cell.blocks, numbers);
367                            }
368                        }
369                    }
370                    _ => {}
371                }
372            }
373        }
374        walk(&mut doc.children, &self.footnote_numbers);
375    }
376}
377
378/// Resolve one hyperlink name through direct/indirect/anonymous targets.
379fn resolve_target(name: &str, cx: &Collect) -> Option<RefTarget> {
380    let mut key = norm_name(name);
381    let mut seen = HashSet::new();
382    for _ in 0..10 {
383        if !seen.insert(key.clone()) {
384            return None; // indirect loop
385        }
386        let uri = cx.targets.get(&key)?;
387        if is_indirect_uri(uri) {
388            key = norm_name(uri.trim_end_matches('_'));
389            continue;
390        }
391        if uri.is_empty() {
392            return Some(RefTarget::Internal(slugify(&key)));
393        }
394        return Some(RefTarget::External(uri.clone()));
395    }
396    None
397}
398
399/// An indirect target reference: `b_` with no scheme (not anonymous `__`).
400fn is_indirect_uri(uri: &str) -> bool {
401    uri.ends_with('_')
402        && !uri.ends_with("__")
403        && !uri.contains("://")
404        && !uri.starts_with("mailto:")
405        && !uri.starts_with('#')
406}
407
408/// Follow an indirect chain; if it ends at an undefined name, return that
409/// final name for the oracle-style indirect error. `None` when the chain
410/// resolves or the start is not indirect.
411fn indirect_final(name: &str, cx: &Collect) -> Option<String> {
412    let mut key = norm_name(name);
413    let first = cx.targets.get(&key)?;
414    if !is_indirect_uri(first) {
415        return None;
416    }
417    let mut seen = HashSet::new();
418    for _ in 0..10 {
419        if !seen.insert(key.clone()) {
420            return None;
421        }
422        let uri = cx.targets.get(&key)?;
423        if !is_indirect_uri(uri) {
424            return None; // chain resolves
425        }
426        key = norm_name(uri.trim_end_matches('_'));
427        if !cx.targets.contains_key(&key) {
428            return Some(key);
429        }
430    }
431    None
432}
433
434/// Display label for a footnote definition name: auto-numbered definitions
435/// show their assigned number, manual ones keep their label.
436fn display_number(name: &str, cx: &Collect) -> String {
437    cx.footnote_numbers
438        .get(name)
439        .map(|n| n.to_string())
440        .unwrap_or_else(|| name.to_string())
441}
442
443struct Resolver<'a> {
444    cx: &'a Collect,
445    anon_targets: Vec<String>,
446    anon_footnotes: Vec<String>,
447    anon_symbols: Vec<String>,
448    anon_refs: usize,
449    anon_link: Option<usize>,
450    next_id: usize,
451    messages: Vec<SystemMessage>,
452    /// In-tree nodes for transform errors (the oracle gathers loose
453    /// messages into a closing section).
454    loose: Vec<Block>,
455}
456
457fn resolve_inlines(doc: &mut Document, cx: &Collect) -> Vec<Block> {
458    let mut r = Resolver {
459        cx,
460        anon_targets: cx.anonymous_targets.clone(),
461        anon_footnotes: cx.auto_footnotes.clone(),
462        anon_symbols: cx.symbol_footnotes.clone(),
463        anon_refs: 0,
464        anon_link: None,
465        next_id: doc.next_id,
466        messages: Vec::new(),
467        loose: Vec::new(),
468    };
469    r.walk_blocks(&mut doc.children);
470    // Anonymous mismatch is reported once, with totals (oracle wording).
471    if r.anon_refs > cx.anonymous_targets.len() {
472        let text = format!(
473            "Anonymous hyperlink mismatch: {} references but {} targets.",
474            r.anon_refs,
475            cx.anonymous_targets.len()
476        );
477        r.messages.push(SystemMessage {
478            level: 3,
479            text: text.clone(),
480            line: None,
481            id: r.anon_link,
482        });
483        r.loose.push(Block::SystemMessage(SystemMessage {
484            level: 3,
485            text,
486            line: None,
487            id: r.anon_link,
488        }));
489    }
490    doc.next_id = r.next_id;
491    doc.messages.append(&mut r.messages);
492    std::mem::take(&mut r.loose)
493}
494
495impl Resolver<'_> {
496    fn walk_blocks(&mut self, blocks: &mut [Block]) {
497        // SubstitutionDefs are definitions, not output: keep them (the
498        // writer skips them) but still resolve nested inlines.
499        for b in blocks.iter_mut() {
500            match b {
501                Block::Paragraph(inlines) => self.walk_inlines(inlines),
502                Block::Section(s) => {
503                    self.walk_inlines(&mut s.title);
504                    self.walk_blocks(&mut s.children);
505                }
506                Block::Rubric(inlines) => self.walk_inlines(inlines),
507                Block::Topic(t) => {
508                    self.walk_inlines(&mut t.title);
509                    self.walk_blocks(&mut t.children);
510                }
511                Block::Container(c) => self.walk_blocks(&mut c.children),
512                Block::BulletList(l) => {
513                    for item in &mut l.items {
514                        self.walk_blocks(item);
515                    }
516                }
517                Block::EnumList(l) => {
518                    for item in &mut l.items {
519                        self.walk_blocks(item);
520                    }
521                }
522                Block::DefinitionList(items) => {
523                    for it in items.iter_mut() {
524                        self.walk_inlines(&mut it.term);
525                        self.walk_blocks(&mut it.definition);
526                    }
527                }
528                Block::FieldList(fields) => {
529                    for f in fields.iter_mut() {
530                        self.walk_inlines(&mut f.name);
531                        self.walk_blocks(&mut f.body);
532                    }
533                }
534                Block::OptionList(groups) => {
535                    for g in groups.iter_mut() {
536                        self.walk_blocks(&mut g.body);
537                    }
538                }
539                Block::BlockQuote(q) => {
540                    self.walk_blocks(&mut q.children);
541                    if let Some(a) = &mut q.attribution {
542                        self.walk_inlines(a);
543                    }
544                }
545                Block::LiteralBlock(_) => {}
546                Block::LineBlock(rows) => {
547                    let mut stack: Vec<&mut [LineRow]> = vec![rows];
548                    while let Some(rs) = stack.pop() {
549                        for r in rs.iter_mut() {
550                            match r {
551                                LineRow::Line(l) => self.walk_inlines(l),
552                                LineRow::Nested(n) => stack.push(n),
553                            }
554                        }
555                    }
556                }
557                Block::ParsedLiteral(rows) => {
558                    for row in rows {
559                        self.walk_inlines(row);
560                    }
561                }
562                Block::Table(t) => {
563                    for row in t.head.iter_mut().chain(t.body.iter_mut()) {
564                        for cell in row {
565                            self.walk_blocks(&mut cell.blocks);
566                        }
567                    }
568                }
569                Block::Admonition(a) => {
570                    if let Some(t) = &mut a.title {
571                        self.walk_inlines(t);
572                    }
573                    self.walk_blocks(&mut a.children);
574                }
575                Block::Footnote(f) => self.walk_blocks(&mut f.children),
576                Block::Citation(c) => self.walk_blocks(&mut c.children),
577                Block::SubstitutionDef(s) => {
578                    self.walk_blocks(&mut s.replacement);
579                }
580                Block::Figure(f) => self.walk_blocks(&mut f.caption),
581                Block::HyperlinkTarget(_)
582                | Block::Comment(_)
583                | Block::Transition
584                | Block::SystemMessage(_)
585                | Block::Raw(_)
586                | Block::Image(_)
587                | Block::PendingContents { .. } => {}
588            }
589        }
590    }
591    /// Record a linked resolution failure: the message and its problematic
592    /// share one linkage number (oracle backlink parity). The message node
593    /// joins the loose-messages section via the caller.
594    fn fail(&mut self, level: u8, text: String, raw: String) -> Inline {
595        let n = self.next_id;
596        self.next_id += 1;
597        let message = SystemMessage {
598            level,
599            text,
600            line: None,
601            id: Some(n),
602        };
603        self.messages.push(message.clone());
604        self.loose.push(Block::SystemMessage(SystemMessage {
605            level,
606            text: message.text.clone(),
607            line: None,
608            id: Some(n),
609        }));
610        Inline::Problematic(Problematic {
611            text: raw,
612            id: Some(n),
613        })
614    }
615
616    fn walk_inlines(&mut self, inlines: &mut Vec<Inline>) {
617        // Resolve in place; substitution expansion may splice sequences.
618        let mut out: Vec<Inline> = Vec::with_capacity(inlines.len());
619        for node in inlines.drain(..) {
620            match node {
621                Inline::PendingRef(p) => {
622                    if p.anonymous {
623                        self.anon_refs += 1;
624                        match self.anon_targets.first().cloned() {
625                            Some(uri) => {
626                                self.anon_targets.remove(0);
627                                out.push(Inline::Reference(RefTarget::External(uri), p.text));
628                            }
629                            None => {
630                                // Message deferred to the end (with totals,
631                                // matching the oracle's wording); the first
632                                // excess problematic links it.
633                                let n = self.next_id;
634                                self.next_id += 1;
635                                if self.anon_link.is_none() {
636                                    self.anon_link = Some(n);
637                                }
638                                out.push(Inline::Problematic(Problematic {
639                                    text: p.raw,
640                                    id: Some(n),
641                                }));
642                            }
643                        }
644                    } else {
645                        let key = norm_name(&p.target);
646                        if self.cx.duplicate_targets.contains(&key) {
647                            out.push(self.fail(
648                                3,
649                                format!(
650                                    "Duplicate target name, cannot be used as a unique reference: \"{key}\"."
651                                ),
652                                p.raw,
653                            ));
654                        } else {
655                            match resolve_target(&p.target, self.cx) {
656                                Some(t) => out.push(Inline::Reference(t, p.text)),
657                                None => {
658                                    // Indirect chain to nowhere?
659                                    if let Some(final_name) = indirect_final(&p.target, self.cx) {
660                                        out.push(self.fail(
661                                            3,
662                                            format!(
663                                                "Indirect hyperlink target \"{key}\" (id=\"{key}\") refers to target \"{final_name}\", which does not exist."
664                                            ),
665                                            p.raw,
666                                        ));
667                                    } else {
668                                        out.push(self.fail(
669                                            3,
670                                            format!(
671                                                "Unknown target name: \"{}\".",
672                                                p.target.to_lowercase()
673                                            ),
674                                            p.raw,
675                                        ));
676                                    }
677                                }
678                            }
679                        }
680                    }
681                }
682                Inline::FootnoteRef(f) => {
683                    if f.symbol {
684                        match self.anon_symbols.first().cloned() {
685                            Some(_) => {
686                                self.anon_symbols.remove(0);
687                                // Display is always `*` for the (single)
688                                // supported symbol footnote; multi-symbol
689                                // documents share the first entry.
690                                out.push(Inline::FootnoteRef(f));
691                            }
692                            None => {
693                                out.push(self.fail(
694                                    3,
695                                    "too many symbol footnote references".into(),
696                                    f.raw,
697                                ));
698                            }
699                        }
700                    } else if f.auto && f.label == "#" {
701                        match self.anon_footnotes.first().cloned() {
702                            Some(name) => {
703                                self.anon_footnotes.remove(0);
704                                out.push(Inline::FootnoteRef(FootnoteRef {
705                                    label: display_number(&name, self.cx),
706                                    auto: true,
707                                    symbol: false,
708                                    raw: f.raw,
709                                }));
710                            }
711                            None => {
712                                out.push(self.fail(
713                                    3,
714                                    "too many autonumbered footnote references".into(),
715                                    f.raw,
716                                ));
717                            }
718                        }
719                    } else if self
720                        .cx
721                        .footnotes
722                        .iter()
723                        .any(|(n, _, _)| n == &f.label.to_lowercase())
724                    {
725                        // Auto-numbered definitions display their number.
726                        let key = f.label.to_lowercase();
727                        out.push(Inline::FootnoteRef(FootnoteRef {
728                            label: display_number(&key, self.cx),
729                            auto: f.auto,
730                            symbol: false,
731                            raw: f.raw,
732                        }));
733                    } else {
734                        out.push(self.fail(
735                            3,
736                            format!("Unknown footnote: \"{}\".", f.label),
737                            f.raw,
738                        ));
739                    }
740                }
741                Inline::CitationRef(c) => {
742                    if self.cx.citations.contains(&c.label.to_lowercase()) {
743                        out.push(Inline::CitationRef(c));
744                    } else {
745                        out.push(self.fail(
746                            3,
747                            format!("Unknown citation: \"{}\".", c.label),
748                            c.raw,
749                        ));
750                    }
751                }
752                Inline::SubstitutionRef(s) => {
753                    match self.cx.substitutions.get(&s.name.to_lowercase()) {
754                        Some(repl) => out.extend(substitution_inlines(repl, &mut self.messages)),
755                        None => {
756                            out.push(self.fail(
757                                3,
758                                format!("Undefined substitution referenced: \"{}\".", s.name),
759                                s.raw,
760                            ));
761                        }
762                    }
763                }
764                Inline::Emphasis(inner) => {
765                    let mut inner = inner;
766                    self.walk_inlines(&mut inner);
767                    out.push(Inline::Emphasis(inner));
768                }
769                Inline::Strong(inner) => {
770                    let mut inner = inner;
771                    self.walk_inlines(&mut inner);
772                    out.push(Inline::Strong(inner));
773                }
774                Inline::Cite(inner) => {
775                    let mut inner = inner;
776                    self.walk_inlines(&mut inner);
777                    out.push(Inline::Cite(inner));
778                }
779                Inline::Reference(t, inner) => {
780                    let mut inner = inner;
781                    self.walk_inlines(&mut inner);
782                    out.push(Inline::Reference(t, inner));
783                }
784                other => out.push(other),
785            }
786        }
787        *inlines = out;
788    }
789}
790
791/// Convert substitution replacement blocks to an inline sequence.
792/// Paragraph content splices; images become inline images; anything else
793/// is a structural error (mirrors docutils' substitution constraints).
794fn substitution_inlines(repl: &[Block], messages: &mut Vec<SystemMessage>) -> Vec<Inline> {
795    let mut out = Vec::new();
796    for b in repl {
797        match b {
798            Block::Paragraph(inlines) => out.extend(inlines.clone()),
799            Block::Image(img) => out.push(Inline::Image {
800                uri: img.uri.clone(),
801                alt: img.alt.clone(),
802            }),
803            _ => {
804                messages.push(SystemMessage {
805                    level: 3,
806                    text: "substitution replacement must be a paragraph or image".into(),
807                    line: None,
808                    id: None,
809                });
810            }
811        }
812    }
813    out
814}
815
816/// Assign unique ids to sections (`slug`, `slug-1`, ...).
817fn dedupe_section_ids(doc: &mut Document) {
818    let mut seen: HashSet<String> = HashSet::new();
819    fn walk(blocks: &mut [Block], seen: &mut HashSet<String>) {
820        for b in blocks {
821            match b {
822                Block::Section(s) => {
823                    let base = s.ids.first().cloned().unwrap_or_default();
824                    let mut id = base.clone();
825                    let mut n = 1;
826                    while !seen.insert(id.clone()) {
827                        id = format!("{base}-{n}");
828                        n += 1;
829                    }
830                    s.ids = vec![id];
831                    walk(&mut s.children, seen);
832                }
833                Block::Topic(t) => walk(&mut t.children, seen),
834                Block::Container(c) => walk(&mut c.children, seen),
835                _ => {}
836            }
837        }
838    }
839    walk(&mut doc.children, &mut seen);
840}
841
842/// Expand `.. contents::` into a topic with nested link lists.
843fn expand_contents(doc: &mut Document) {
844    // Collect (level, id, title) in document order.
845    let mut entries: Vec<(u8, String, Vec<Inline>)> = Vec::new();
846    fn walk(blocks: &[Block], entries: &mut Vec<(u8, String, Vec<Inline>)>) {
847        for b in blocks {
848            if let Block::Section(s) = b {
849                entries.push((
850                    s.level,
851                    s.ids.first().cloned().unwrap_or_default(),
852                    s.title.clone(),
853                ));
854                walk(&s.children, entries);
855            }
856        }
857    }
858    walk(&doc.children, &mut entries);
859    if entries.is_empty() {
860        // Still replace the node with an empty topic (docutils warns).
861    }
862    fn build(blocks: &mut [Block], entries: &[(u8, String, Vec<Inline>)], depth: Option<u32>) {
863        for b in blocks.iter_mut() {
864            match b {
865                Block::PendingContents { depth: d, title: t } => {
866                    let start = entries.iter().map(|e| e.0).min().unwrap_or(1);
867                    let max_depth = d
868                        .or(depth)
869                        .map(|dd| start as u32 + dd - 1)
870                        .unwrap_or(u32::MAX);
871                    let items = contents_list(entries, start, max_depth);
872                    // Oracle default title is "Contents", but only when the
873                    // topic has entries (an entry-less contents is empty).
874                    let title = t
875                        .clone()
876                        .or_else(|| {
877                            if items.is_empty() {
878                                None
879                            } else {
880                                Some(vec![Inline::Text("Contents".into())])
881                            }
882                        })
883                        .unwrap_or_default();
884                    *b = Block::Topic(Topic {
885                        title,
886                        children: if items.is_empty() {
887                            vec![]
888                        } else {
889                            vec![Block::BulletList(BulletList { bullet: '-', items })]
890                        },
891                        classes: vec!["contents".into(), "topic".into()],
892                    });
893                }
894                Block::Section(s) => build(&mut s.children, entries, depth),
895                Block::Topic(t) => build(&mut t.children, entries, depth),
896                Block::Container(c) => build(&mut c.children, entries, depth),
897                _ => {}
898            }
899        }
900    }
901    build(&mut doc.children, &entries, None);
902}
903
904/// Nested bullet items for sections at `level..=max_depth`.
905fn contents_list(
906    entries: &[(u8, String, Vec<Inline>)],
907    level: u8,
908    max_depth: u32,
909) -> Vec<Vec<Block>> {
910    let mut items: Vec<Vec<Block>> = Vec::new();
911    let mut i = 0;
912    while i < entries.len() {
913        let (lv, id, title) = &entries[i];
914        if *lv < level {
915            break;
916        }
917        if *lv > level {
918            // Deeper section without a parent at this level (can happen
919            // after title promotion splices); attach to the previous item.
920            if let Some(last) = items.last_mut() {
921                let sub = contents_list(&entries[i..], *lv, max_depth);
922                let skip = contents_span(&entries[i..], *lv);
923                last.push(Block::BulletList(BulletList {
924                    bullet: '-',
925                    items: sub,
926                }));
927                i += skip;
928                continue;
929            }
930            i += 1;
931            continue;
932        }
933        if (*lv as u32) > max_depth {
934            i += 1 + contents_span(&entries[i + 1..], (*lv) + 1);
935            continue;
936        }
937        let mut blocks = vec![Block::Paragraph(vec![Inline::Reference(
938            RefTarget::Internal(id.clone()),
939            title.clone(),
940        )])];
941        let span = contents_span(&entries[i + 1..], level + 1);
942        if span > 0 {
943            let sub = contents_list(&entries[i + 1..i + 1 + span], level + 1, max_depth);
944            if !sub.is_empty() {
945                blocks.push(Block::BulletList(BulletList {
946                    bullet: '-',
947                    items: sub,
948                }));
949            }
950        }
951        items.push(blocks);
952        i += 1 + span;
953    }
954    items
955}
956
957/// Number of leading entries with level >= `level`.
958fn contents_span(entries: &[(u8, String, Vec<Inline>)], level: u8) -> usize {
959    let mut n = 0;
960    for (lv, _, _) in entries {
961        if *lv < level {
962            break;
963        }
964        n += 1;
965    }
966    n
967}