Skip to main content

sphinx_ultra/env/
toctree.rs

1//! Sphinx's toctree bookkeeping: the read-phase half of
2//! `sphinx/environment/collectors/toctree.py` plus the two pieces of
3//! `sphinx/directives/other.py` and `sphinx/environment/adapters/toctree.py`
4//! that feed it.
5//!
6//! Three ports live here, in the order the build runs them:
7//!
8//! 1. [`resolve_entries`] — `TocTree.parse_content` (`directives/other.py:88`):
9//!    turns the directive's raw content lines into the `entries`/`includefiles`
10//!    attributes of the `toctree` node, resolving relative docnames, expanding
11//!    `:glob:` patterns and dropping targets that aren't real documents. This
12//!    is environment-dependent (it needs the full docname set), which is why
13//!    the parser takes a `found_docs` set: Sphinx resolves at parse time too,
14//!    from `env.found_docs`.
15//! 2. [`build_toc`] — `TocTreeCollector.process_doc` (`collectors/toctree.py:64`):
16//!    the document's local table of contents as a doctree-shaped
17//!    `bullet_list`, with every `toctree` node copied into it.
18//! 3. [`note_toctree`] — `adapters/toctree.py:32`: the toctree graph
19//!    (`toctree_includes`, `files_to_rebuild`, `glob_toctrees`,
20//!    `numbered_toctrees`).
21//! 4. [`collect_relations`] / [`toctree_ancestors`] / [`check_consistency`]
22//!    — the whole-project reads of that graph
23//!    (`environment/__init__.py:778-823`, `adapters/toctree.py:562`), which
24//!    only make sense once every document has been noted.
25//!
26//! 5. [`resolve_toctree`] — `_resolve_toctree` (`adapters/toctree.py:119`):
27//!    the write-phase expansion of a `toctree` node into nested bullet
28//!    lists, as far as the search index needs it (see its doc comment for
29//!    what the HTML writer will still have to add).
30//!
31//! [`document_title`] is the neighbouring `TitleCollector.process_doc`
32//! (`collectors/title.py:27`), which shares this module's
33//! `SphinxContentsFilter` port.
34//!
35//! NOT here (later wave-4 tasks): `assign_section_numbers` /
36//! `assign_figure_numbers` (they write `toc_secnumbers`/`toc_fignumbers` and
37//! stamp `secnumber` onto the references this module builds).
38
39use std::collections::{BTreeMap, BTreeSet};
40
41use crate::doctree::{kinds, AttrValue, Doctree, Node, Span};
42use crate::env::numbers::clean_astext;
43use crate::env::BuildEnvironment;
44use crate::matching;
45use crate::utils::py_repr_str;
46
47/// Sphinx's `StandardDomain._virtual_doc_names` (`domains/std/__init__.py:784-788`):
48/// docnames that resolve even though no source file produces them.
49///
50/// Careful: `_virtual_doc_names` is a **dict**, and every consumer that
51/// treats it as a name set takes `frozenset(...)` of it — i.e. its *keys*
52/// (`directives/other.py:91`, `collectors/toctree.py:287`,
53/// `adapters/toctree.py:330`). The middle entry is therefore `modindex`,
54/// the label authors write in a toctree; `py-modindex` is that key's
55/// *value*, the docname the page is finally written to, and is not itself
56/// a virtual name.
57pub(crate) const VIRTUAL_DOC_NAMES: [&str; 3] = ["genindex", "modindex", "search"];
58
59// ---------------------------------------------------------------------------
60// 1. Entry resolution (sphinx/directives/other.py TocTree.parse_content)
61// ---------------------------------------------------------------------------
62
63/// Everything one `toctree` directive needs resolved against the project's
64/// document set — the inputs of `TocTree.parse_content`.
65#[derive(Debug, Clone, Copy)]
66pub struct ToctreeContent<'a> {
67    /// The directive's content lines, with blank ones already removed.
68    pub content: &'a [String],
69    /// The containing document.
70    pub docname: &'a str,
71    pub glob: bool,
72    pub reversed: bool,
73    /// Source-table index of the `.. toctree::` marker's line (the node's
74    /// `source`): every diagnostic below is stamped with it.
75    pub source: u16,
76    /// 1-based line of the `.. toctree::` marker. Sphinx logs every
77    /// diagnostic below with `location=toctree`, i.e. the directive node's
78    /// source info — *not* the offending entry's own line.
79    pub line: u32,
80    /// Every docname the project discovered (sphinx `env.found_docs`).
81    pub found_docs: &'a BTreeSet<String>,
82    /// `source_suffix`, in configuration order; the first is what
83    /// `doc2path` appends for a document that does not exist.
84    pub source_suffixes: &'a [&'a str],
85    /// `exclude_patterns`, which decide whether a missing entry is reported
86    /// as excluded or as nonexisting.
87    pub exclude_patterns: &'a [String],
88}
89
90/// What kind of toctree diagnostic this is, kept alongside the Sphinx
91/// message so the builder can map it onto its own coarse
92/// [`crate::error::WarningType`].
93#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
94pub enum ToctreeWarningKind {
95    /// An entry naming a document that is excluded or absent.
96    MissingDocument,
97    /// A `:glob:` pattern that matched nothing.
98    EmptyGlob,
99    /// A document already claimed by an earlier entry.
100    DuplicateEntry,
101    /// Not a Sphinx diagnostic: a `:glob:` pattern this crate could not
102    /// compile. Python's `fnmatch` cannot fail, so Sphinx has no equivalent
103    /// — but silently treating the pattern as matching nothing would hide a
104    /// real bug behind an `empty_glob` warning.
105    PatternError,
106}
107
108/// One diagnostic produced while resolving a toctree's entries.
109///
110/// Carried on the parse record (and therefore through the document cache)
111/// rather than logged on the spot: resolution happens inside the parser,
112/// which has no warning sink, and a cache hit that skipped the parse must
113/// still reproduce the build's warnings.
114#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
115pub struct ToctreeWarning {
116    /// Source-table index of the `.. toctree::` directive's line: a
117    /// toctree written inside an included file warns against THAT file
118    /// (`location=toctree` is the node, whose `source` is the included
119    /// file's). Deliberately not `#[serde(default)]` — the warning rides
120    /// the document cache, and a pre-provenance record decoding with
121    /// source 0 would name the includer again (cache-shape rule,
122    /// [`crate::rst::RegistryExport::program_options`]).
123    pub source: u16,
124    /// 1-based line of the `.. toctree::` directive (Sphinx's
125    /// `location=toctree`), within `source`.
126    pub line: u32,
127    /// The message, formatted exactly as Sphinx formats it.
128    pub message: String,
129    /// Sphinx's `type.subtype` category, or `None` where Sphinx logs the
130    /// warning without a `type` (see [`crate::error::BuildWarning::category`]).
131    pub category: Option<String>,
132    pub kind: ToctreeWarningKind,
133}
134
135/// `toctree['entries']` (title, ref) pairs plus `toctree['includefiles']`.
136#[derive(Debug, Clone, Default, PartialEq, Eq)]
137pub struct ResolvedEntries {
138    /// `(title, ref)`; `ref` is a resolved docname, an external URL, or the
139    /// literal `self`.
140    pub entries: Vec<(Option<String>, String)>,
141    /// The subset of `entries` that are real documents, in the same order.
142    pub includefiles: Vec<String>,
143    /// Diagnostics for the entries that did not resolve. The single source
144    /// of truth for toctree warnings: nothing downstream re-resolves.
145    pub warnings: Vec<ToctreeWarning>,
146}
147
148impl ResolvedEntries {
149    /// The `entries` attribute as docutils renders it: one Python tuple
150    /// repr per item (`(None, 'intro')`), which `pformat` then
151    /// `serial_escape`s and joins.
152    pub fn entries_attr(&self) -> AttrValue {
153        AttrValue::List(
154            self.entries
155                .iter()
156                .map(|(title, target)| {
157                    let title = match title {
158                        Some(t) => py_repr_str(t),
159                        None => "None".to_string(),
160                    };
161                    format!("({title}, {})", py_repr_str(target))
162                })
163                .collect(),
164        )
165    }
166
167    pub fn includefiles_attr(&self) -> AttrValue {
168        AttrValue::List(self.includefiles.clone())
169    }
170}
171
172/// Resolve a toctree directive's content lines against the document set.
173///
174/// Exact port of `TocTree.parse_content` (`sphinx/directives/other.py:88-179`),
175/// **including all four of its `logger.warning` calls** — they come back on
176/// [`ResolvedEntries::warnings`] instead of going to a logger, because this
177/// runs inside the parser. This is the project's only toctree resolver: the
178/// build's user-visible toctree diagnostics are exactly these warnings, so
179/// what warns and what resolves can never disagree.
180///
181/// The one deliberate addition is [`ToctreeWarningKind::PatternError`],
182/// which has no Sphinx counterpart (see its doc comment).
183///
184/// Not ported: `env.note_reread()` on a missing entry (nothing consumes the
185/// re-read set yet).
186pub fn resolve_entries(input: &ToctreeContent<'_>) -> ResolvedEntries {
187    let &ToctreeContent {
188        content,
189        docname,
190        glob,
191        reversed,
192        source,
193        line,
194        found_docs,
195        source_suffixes,
196        exclude_patterns,
197    } = input;
198
199    // `all_docnames` is consumed as entries claim documents (so a glob never
200    // re-lists what an earlier entry named); `frozen` keeps the full set for
201    // the existence check. The current document is not a candidate: a
202    // toctree entry naming its own document is "nonexisting" to Sphinx.
203    let mut all: BTreeSet<&str> = found_docs.iter().map(String::as_str).collect();
204    all.extend(VIRTUAL_DOC_NAMES);
205    all.remove(docname);
206    let frozen = all.clone();
207
208    let mut out = ResolvedEntries::default();
209    let mut warnings: Vec<ToctreeWarning> = Vec::new();
210    // Takes its sink as an argument rather than capturing it, so it holds no
211    // borrow across the loop body that also mutates `out`.
212    let warn = |sink: &mut Vec<ToctreeWarning>,
213                message: String,
214                category: Option<&str>,
215                kind: ToctreeWarningKind| {
216        sink.push(ToctreeWarning {
217            source,
218            line,
219            message,
220            category: category.map(str::to_string),
221            kind,
222        });
223    };
224
225    for entry in content {
226        if entry.is_empty() {
227            continue;
228        }
229        let explicit = split_explicit_title(entry);
230        let url_match = is_url(entry);
231
232        if glob && has_glob_metachars(entry) && explicit.is_none() && !url_match {
233            let pattern = docname_join(docname, entry);
234            // BTreeSet iteration order == sorted(), as sphinx does.
235            let mut matched: Vec<String> = Vec::new();
236            let mut pattern_error = None;
237            for candidate in all.iter().filter(|d| !VIRTUAL_DOC_NAMES.contains(d)) {
238                match matching::pattern_match(candidate, &pattern) {
239                    Ok(true) => matched.push((*candidate).to_string()),
240                    Ok(false) => {}
241                    Err(e) => {
242                        pattern_error.get_or_insert_with(|| e.to_string());
243                    }
244                }
245            }
246            if let Some(error) = pattern_error {
247                warn(
248                    &mut warnings,
249                    format!(
250                        "toctree glob pattern {} is not usable: {error}",
251                        py_repr_str(entry)
252                    ),
253                    None,
254                    ToctreeWarningKind::PatternError,
255                );
256            } else if matched.is_empty() {
257                warn(
258                    &mut warnings,
259                    format!(
260                        "toctree glob pattern {} didn't match any documents",
261                        py_repr_str(entry)
262                    ),
263                    // sphinx passes `subtype='empty_glob'` but no `type`, so
264                    // no `[...]` suffix is appended.
265                    None,
266                    ToctreeWarningKind::EmptyGlob,
267                );
268            }
269            for name in matched {
270                all.remove(name.as_str());
271                out.entries.push((None, name.clone()));
272                out.includefiles.push(name);
273            }
274            continue;
275        }
276
277        let (title, reference) = match explicit {
278            Some((title, target)) => (Some(title.to_string()), target),
279            None => (None, entry.as_str()),
280        };
281        let mut resolved = reference;
282        for suffix in source_suffixes {
283            if let Some(stripped) = resolved.strip_suffix(suffix) {
284                resolved = stripped;
285                break;
286            }
287        }
288        let resolved = docname_join(docname, resolved);
289
290        if url_match || reference == "self" {
291            out.entries.push((title, reference.to_string()));
292            continue;
293        }
294        if !frozen.contains(resolved.as_str()) {
295            // sphinx matches `exclude_patterns` against `doc2path(ref,
296            // base=False)` — the source-relative path, which for a document
297            // that does not exist is the docname plus the *first* configured
298            // source suffix.
299            let path = format!(
300                "{resolved}{}",
301                source_suffixes.first().copied().unwrap_or_default()
302            );
303            let (message, category) = if matches_any(&path, exclude_patterns) {
304                (
305                    "toctree contains reference to excluded document",
306                    "toc.excluded",
307                )
308            } else {
309                (
310                    "toctree contains reference to nonexisting document",
311                    "toc.not_readable",
312                )
313            };
314            warn(
315                &mut warnings,
316                format!("{message} {}", py_repr_str(&resolved)),
317                Some(category),
318                ToctreeWarningKind::MissingDocument,
319            );
320            continue;
321        }
322        // sphinx warns when the document was already claimed, but appends
323        // the entry either way.
324        if !all.remove(resolved.as_str()) {
325            warn(
326                &mut warnings,
327                format!("duplicated entry found in toctree: {resolved}"),
328                Some("toc.duplicate_entry"),
329                ToctreeWarningKind::DuplicateEntry,
330            );
331        }
332        out.entries.push((title, resolved.clone()));
333        out.includefiles.push(resolved);
334    }
335
336    // `:reversed:` flips the two entry lists only; diagnostics keep the
337    // order they were produced in.
338    if reversed {
339        out.entries.reverse();
340        out.includefiles.reverse();
341    }
342    out.warnings = warnings;
343    out
344}
345
346/// Sphinx's `Matcher` (`util/matching.py`): does any pattern match?
347/// A pattern that fails to compile matches nothing — the caller that cares
348/// (glob expansion) checks compilation separately.
349fn matches_any(path: &str, patterns: &[String]) -> bool {
350    patterns
351        .iter()
352        .any(|pattern| matching::pattern_match(path, pattern).unwrap_or(false))
353}
354
355/// Split `Some Title <target>` into its two halves, as Sphinx's
356/// `explicit_title_re` (`^(.+?)\s*<(.*?)>$`, `util/nodes.py`) does: the
357/// **first** `<` that leaves a non-empty title wins, and the line must end
358/// with `>`. A bare `<foo>` is therefore a literal target named `<foo>`,
359/// not an empty-titled reference.
360pub fn split_explicit_title(entry: &str) -> Option<(&str, &str)> {
361    let entry = entry.strip_suffix('>')?;
362    let open = entry.find('<')?;
363    if open == 0 {
364        return None;
365    }
366    let title = entry[..open].trim_end();
367    if title.is_empty() {
368        return None;
369    }
370    Some((title, &entry[open + 1..]))
371}
372
373/// Sphinx `url_re` (`(?P<schema>.+)://.*`, anchored with `.match`): *some*
374/// `://` preceded by at least one character — the regex backtracks, so a
375/// leading `://` does not rule out a later one satisfying the schema part.
376fn is_url(entry: &str) -> bool {
377    entry.match_indices("://").any(|(at, _)| at >= 1)
378}
379
380/// Sphinx `glob_re` (`.*[*?\[].*`).
381fn has_glob_metachars(entry: &str) -> bool {
382    entry.contains(['*', '?', '['])
383}
384
385/// Sphinx `docname_join` (`util/__init__.py`):
386/// `posixpath.normpath(posixpath.join('/' + basedocname, '..', docname))[1:]`
387/// — a leading `/` makes the target source-root-relative, anything else is
388/// relative to the referencing document's directory, and `.`/`..` segments
389/// are normalized.
390pub fn docname_join(base_docname: &str, docname: &str) -> String {
391    let (base, target) = match docname.strip_prefix('/') {
392        Some(stripped) => ("", stripped),
393        None => (
394            base_docname.rsplit_once('/').map(|(d, _)| d).unwrap_or(""),
395            docname,
396        ),
397    };
398
399    let mut segments: Vec<&str> = Vec::new();
400    for seg in base.split('/').chain(target.split('/')) {
401        match seg {
402            "" | "." => {}
403            ".." => {
404                segments.pop();
405            }
406            s => segments.push(s),
407        }
408    }
409    segments.join("/")
410}
411
412// ---------------------------------------------------------------------------
413// 2. build_toc (sphinx/environment/collectors/toctree.py process_doc)
414// ---------------------------------------------------------------------------
415
416/// The document's local table of contents (`env.tocs[docname]`) and its
417/// entry count (`env.toc_num_entries[docname]`).
418///
419/// Exact port of `TocTreeCollector.process_doc`'s nested `build_toc`:
420/// sections (title filtered through [`filter_title_children`], anchor `''`
421/// for the first entry and `'#' + ids[0]` afterwards), `only` wrappers, and
422/// — inside any other element — `toctree` copies plus the object signatures
423/// of `desc` nodes, which contribute
424/// `compact_paragraph[skip_section_number] > reference > literal` entries.
425///
426/// An empty document yields an empty `bullet_list` and 0 entries.
427pub fn build_toc(doctree: &Doctree, docname: &str) -> (Node, u32) {
428    let mut num_entries = 0u32;
429    let toc = build_toc_level(&doctree.root.children, docname, &mut num_entries)
430        .unwrap_or_else(|| Node::elem(kinds::BULLET_LIST, Span::ZERO));
431    (toc, num_entries)
432}
433
434fn build_toc_level(nodes: &[Node], docname: &str, num_entries: &mut u32) -> Option<Node> {
435    let mut entries: Vec<Node> = Vec::new();
436
437    for node in nodes {
438        // docutils Text nodes aren't Elements; sphinx's isinstance chain
439        // skips them entirely.
440        if node.kind == kinds::TEXT {
441            continue;
442        }
443
444        if node.kind == kinds::SECTION {
445            // sphinx: `title = sectionnode[0]` — the section's first child.
446            let title_children = node
447                .children
448                .first()
449                .map(filter_title_children)
450                .unwrap_or_default();
451            let anchorname = make_anchor_name(&node.attrs.ids, num_entries);
452
453            let mut reference = Node::elem(kinds::REFERENCE, Span::ZERO);
454            reference.set("anchorname", AttrValue::Str(anchorname));
455            reference.set("internal", AttrValue::Int(1));
456            reference.set("refuri", AttrValue::Str(docname.to_string()));
457            reference.children = title_children;
458
459            let mut para = Node::elem(kinds::COMPACT_PARAGRAPH, Span::ZERO);
460            para.children.push(reference);
461            let mut item = Node::elem(kinds::LIST_ITEM, Span::ZERO);
462            item.children.push(para);
463            if let Some(sub) = build_toc_level(&node.children, docname, num_entries) {
464                item.children.push(sub);
465            }
466            entries.push(item);
467        } else if node.kind == kinds::ONLY {
468            // Deferred tag filtering: the entries stay in the toc wrapped in
469            // a fresh `only` node carrying the same expression.
470            let mut only = Node::elem(kinds::ONLY, Span::ZERO);
471            if let Some(expr) = node.get("expr") {
472                only.set("expr", expr.clone());
473            }
474            if let Some(sub) = build_toc_level(&node.children, docname, num_entries) {
475                only.children = sub.children;
476                entries.push(only);
477            }
478        } else {
479            // Any other element: `findall()` over the whole subtree for
480            // `toctree` nodes to copy into the toc and `desc` nodes whose
481            // signatures become object entries. (sphinx `continue`s on
482            // nested sections, which only skips *processing* them — findall
483            // still descends.)
484            collect_body_entries(node, docname, num_entries, &mut entries, &[]);
485        }
486    }
487
488    if entries.is_empty() {
489        return None;
490    }
491    let mut list = Node::elem(kinds::BULLET_LIST, Span::ZERO);
492    list.children = entries;
493    Some(list)
494}
495
496/// The `elif isinstance(sectionnode, nodes.Element)` arm of `build_toc`
497/// (`collectors/toctree.py:112-181`), in document order.
498///
499/// Sphinx keeps a `memo_parents` map from each `desc` to the `list_item` its
500/// last signature produced, and attaches a nested description's entries to
501/// the nearest ancestor `desc` that has one. That "nearest memo'd ancestor"
502/// is exactly what recursion already tracks, so it rides along as `path`:
503/// the chain of indices from `entries` down to the list_item currently
504/// standing in for `memo_parents`. `toctree` copies ignore it — sphinx
505/// appends those to the section-level `entries` wherever it finds them.
506fn collect_body_entries(
507    node: &Node,
508    docname: &str,
509    num_entries: &mut u32,
510    entries: &mut Vec<Node>,
511    path: &[usize],
512) {
513    if node.kind == kinds::TEXT {
514        return;
515    }
516    if node.kind == kinds::TOCTREE {
517        entries.push(node.shallow_copy());
518        return;
519    }
520    if node.kind == "desc" {
521        // "Save the latest desc_signature as the one we put sub entries in."
522        let mut child_path: Option<Vec<usize>> = None;
523        for signature in node.children.iter().filter(|c| c.kind == "desc_signature") {
524            let Some(entry) = object_toc_entry(node, signature, docname, num_entries) else {
525                continue;
526            };
527            let target = resolve_attach_point(entries, path);
528            target.push(entry);
529            let mut deeper = path.to_vec();
530            deeper.push(target.len() - 1);
531            child_path = Some(deeper);
532        }
533        let child_path = child_path.unwrap_or_else(|| path.to_vec());
534        for child in &node.children {
535            collect_body_entries(child, docname, num_entries, entries, &child_path);
536        }
537        return;
538    }
539    for child in &node.children {
540        collect_body_entries(child, docname, num_entries, entries, path);
541    }
542}
543
544/// Walk `path` (a chain of list_item indices) down to the entry list a
545/// nested object entry belongs in, creating the `bullet_list` each step
546/// needs — sphinx's `if isinstance(root_entry[-1], bullet_list): ... else:
547/// root_entry.append(bullet_list(...))` (`:167-175`). An empty path is the
548/// section level itself.
549fn resolve_attach_point<'a>(entries: &'a mut Vec<Node>, path: &[usize]) -> &'a mut Vec<Node> {
550    let mut current = entries;
551    for &index in path {
552        let item = &mut current[index];
553        if item.children.last().map(|c| c.kind) != Some(kinds::BULLET_LIST) {
554            item.children
555                .push(Node::elem(kinds::BULLET_LIST, Span::ZERO));
556        }
557        current = &mut item
558            .children
559            .last_mut()
560            .expect("a bullet_list was just ensured")
561            .children;
562    }
563    current
564}
565
566/// One object signature's toc entry (`collectors/toctree.py:129-157`), or
567/// `None` for the three skips: no `_toc_name` (the domain opted out of toc
568/// entries for this objtype), `:no-contents-entry:` on the description, and
569/// no ids (`:no-index:`).
570fn object_toc_entry(
571    desc: &Node,
572    signature: &Node,
573    docname: &str,
574    num_entries: &mut u32,
575) -> Option<Node> {
576    let toc_name = match signature.get("_toc_name") {
577        Some(AttrValue::Str(name)) if !name.is_empty() => name.clone(),
578        _ => return None,
579    };
580    if matches!(desc.get("no-contents-entry"), Some(AttrValue::Int(1))) {
581        return None;
582    }
583    if signature.attrs.ids.is_empty() {
584        return None;
585    }
586    let anchorname = make_anchor_name(&signature.attrs.ids, num_entries);
587
588    let mut literal = Node::elem(kinds::LITERAL, Span::ZERO);
589    literal.children.push(Node::text_node(toc_name, Span::ZERO));
590    let mut reference = Node::elem(kinds::REFERENCE, Span::ZERO);
591    reference.set("anchorname", AttrValue::Str(anchorname));
592    reference.set("internal", AttrValue::Int(1));
593    reference.set("refuri", AttrValue::Str(docname.to_string()));
594    reference.children.push(literal);
595    let mut para = Node::elem(kinds::COMPACT_PARAGRAPH, Span::ZERO);
596    para.set("skip_section_number", AttrValue::Int(1));
597    para.children.push(reference);
598    let mut item = Node::elem(kinds::LIST_ITEM, Span::ZERO);
599    item.children.push(para);
600    Some(item)
601}
602
603/// `_make_anchor_name` (`collectors/toctree.py:381`): the very first entry
604/// of a document gets the empty anchor (it *is* the page), everything after
605/// gets `'#' + ids[0]`.
606fn make_anchor_name(ids: &[String], num_entries: &mut u32) -> String {
607    let anchor = if *num_entries == 0 {
608        String::new()
609    } else {
610        // sphinx indexes ids[0] unconditionally (an id-less section past the
611        // first would raise there); an id-less section yields a bare "#".
612        format!("#{}", ids.first().map(String::as_str).unwrap_or(""))
613    };
614    *num_entries += 1;
615    anchor
616}
617
618/// `SphinxContentsFilter` (`sphinx/transforms/__init__.py:350`) over
619/// docutils' `ContentsFilter`/`TreeCopyVisitor`
620/// (`docutils/transforms/parts.py:154`): a copy of the title's children with
621/// reference-ish wrappers unwrapped (children kept, wrapper dropped) and
622/// footnote/citation references and images dropped whole.
623fn filter_title_children(title: &Node) -> Vec<Node> {
624    let mut out = Vec::new();
625    filter_into(&title.children, &mut out);
626    out
627}
628
629fn filter_into(children: &[Node], out: &mut Vec<Node>) {
630    for child in children {
631        match child.kind {
632            // SkipNode: the node and its children are dropped. (docutils'
633            // base filter would keep an image's `alt` text; sphinx's
634            // override drops images outright.)
635            kinds::FOOTNOTE_REFERENCE | kinds::CITATION_REFERENCE | kinds::IMAGE => {}
636            // SkipDeparture on a visit that never copied the node: the
637            // wrapper vanishes, its children land in the enclosing parent.
638            kinds::REFERENCE | kinds::TARGET | kinds::PROBLEMATIC | kinds::PENDING_XREF => {
639                filter_into(&child.children, out);
640            }
641            kinds::TEXT => out.push(child.clone()),
642            _ => {
643                let mut copy = child.shallow_copy();
644                filter_into(&child.children, &mut copy.children);
645                out.push(copy);
646            }
647        }
648    }
649}
650
651// ---------------------------------------------------------------------------
652// 3. note_toctree (sphinx/environment/adapters/toctree.py)
653// ---------------------------------------------------------------------------
654
655/// Record one `toctree` node's file relations in the environment.
656///
657/// Port of `note_toctree` (`adapters/toctree.py:32-47`). Note the
658/// `setdefault` in `env.toctree_includes.setdefault(docname, []).extend(...)`:
659/// the key is created even when the toctree includes nothing.
660pub fn note_toctree(env: &mut BuildEnvironment, docname: &str, toctree: &Node) {
661    if matches!(toctree.get("glob"), Some(AttrValue::Int(n)) if *n != 0) {
662        env.glob_toctrees.insert(docname.to_string());
663    }
664    if matches!(toctree.get("numbered"), Some(AttrValue::Int(n)) if *n != 0) {
665        env.numbered_toctrees.insert(docname.to_string());
666    }
667
668    let include_files: &[String] = match toctree.get("includefiles") {
669        Some(AttrValue::List(files)) => files,
670        _ => &[],
671    };
672    for include_file in include_files {
673        env.files_to_rebuild
674            .entry(include_file.clone())
675            .or_default()
676            .insert(docname.to_string());
677    }
678    env.toctree_includes
679        .entry(docname.to_string())
680        .or_default()
681        .extend(include_files.iter().cloned());
682}
683
684/// Every `toctree` node inside a built toc, in the order
685/// [`build_toc`] copied them in — which is the order Sphinx calls
686/// `note_toctree` in, since it notes each node at the moment it copies it.
687pub fn toctree_copies(toc: &Node) -> Vec<&Node> {
688    let mut out = Vec::new();
689    fn walk<'a>(node: &'a Node, out: &mut Vec<&'a Node>) {
690        if node.kind == kinds::TOCTREE {
691            out.push(node);
692        }
693        for child in &node.children {
694            walk(child, out);
695        }
696    }
697    walk(toc, &mut out);
698    out
699}
700
701// ---------------------------------------------------------------------------
702// 4. Whole-project reads of the toctree graph
703// ---------------------------------------------------------------------------
704
705/// `[parent, prev, next]` — one document's place in the global document
706/// order, as `env.collect_relations()` records it.
707pub type Relation = (Option<String>, Option<String>, Option<String>);
708
709/// Every document's `[parent, prev, next]`, from a pre-order walk of the
710/// toctree graph rooted at `env.root_doc`.
711///
712/// Port of `BuildEnvironment.collect_relations`
713/// (`environment/__init__.py:778-795`) over [`traverse_toctree`]. `prev`/
714/// `next` are the flattened document order, so the first child of a
715/// document has that document as its `prev` — Sphinx's chain is linear, not
716/// sibling-scoped.
717pub fn collect_relations(env: &BuildEnvironment) -> BTreeMap<String, Relation> {
718    let order = traverse_toctree(&env.toctree_includes, &env.root_doc);
719    let mut relations = BTreeMap::new();
720
721    let mut prev: Option<String> = None;
722    for (index, (parent, docname)) in order.iter().enumerate() {
723        let next = order.get(index + 1).map(|(_, doc)| doc.clone());
724        relations.insert(docname.clone(), (parent.clone(), prev.take(), next));
725        prev = Some(docname.clone());
726    }
727    relations
728}
729
730/// Pre-order depth-first walk of `toctree_includes` from `root`, yielding
731/// `(parent, docname)` once per document — the first visit wins.
732///
733/// Port of `_traverse_toctree` (`environment/__init__.py:914-939`) with one
734/// deliberate difference: **descent is guarded by the visited set, not just
735/// yielding**. Sphinx recurses into every child unconditionally and only
736/// filters the *yields*, so a mutual `A -> B -> A` cycle recurses without
737/// bound and raises `RecursionError` — a real, oracle-verified sphinx 9.1.0
738/// crash (`tests/fixtures/env_differential.json` records `relations: null`
739/// for the `toctree_circular` project because of it). For an acyclic graph
740/// the two are equivalent: re-descending into an already-visited document
741/// can only re-yield documents that were already yielded, and those are
742/// filtered out anyway.
743///
744/// The walk is iterative for the same reason it is guarded: an explicit
745/// stack cannot overflow on a deep document tree.
746///
747/// Sphinx logs `self referenced toctree found. Ignored.` (`toc.circular`)
748/// when a document's toctree includes the document itself, and drops that
749/// subtree. The drop is ported; the warning is not, because the branch is
750/// unreachable in this pipeline — [`resolve_entries`] removes the current
751/// document from its own candidate set, so a self-entry never reaches
752/// `toctree_includes`; it is reported at parse time as
753/// `toctree contains reference to nonexisting document` instead (which is
754/// exactly what the oracle records for the `toctree_self_ref` project).
755pub fn traverse_toctree(
756    toctree_includes: &BTreeMap<String, Vec<String>>,
757    root: &str,
758) -> Vec<(Option<String>, String)> {
759    let mut out: Vec<(Option<String>, String)> = Vec::new();
760    let mut visited: BTreeSet<String> = BTreeSet::new();
761    let mut stack: Vec<(Option<String>, String)> = vec![(None, root.to_string())];
762
763    while let Some((parent, docname)) = stack.pop() {
764        if parent.as_deref() == Some(docname.as_str()) {
765            continue;
766        }
767        if !visited.insert(docname.clone()) {
768            continue;
769        }
770        if let Some(children) = toctree_includes.get(&docname) {
771            // Reversed, so the explicit stack pops them left-to-right.
772            for child in children.iter().rev() {
773                stack.push((Some(docname.clone()), child.clone()));
774            }
775        }
776        out.push((parent, docname));
777    }
778    out
779}
780
781/// The chain of toctree parents above `docname`, nearest first, starting
782/// with `docname` itself.
783///
784/// Port of `_get_toctree_ancestors` (`adapters/toctree.py:562-575`). A
785/// document with no toctree parent has no ancestors at all — not even
786/// itself — and the `d not in ancestors` guard stops the walk on a cycle
787/// (which is why this function, unlike `_traverse_toctree`, survives the
788/// circular corpus project).
789///
790/// When a document has several toctree parents the last one wins, matching
791/// Sphinx's `parent |= dict.fromkeys(children, p)` over `toctree_includes`.
792/// Sphinx iterates that dict in read order and this map iterates in docname
793/// order; both resolve to the same "largest parent docname" for the sorted
794/// read order every build of this crate performs.
795pub fn toctree_ancestors(
796    toctree_includes: &BTreeMap<String, Vec<String>>,
797    docname: &str,
798) -> Vec<String> {
799    let mut parent: BTreeMap<&str, &str> = BTreeMap::new();
800    for (container, children) in toctree_includes {
801        for child in children {
802            parent.insert(child.as_str(), container.as_str());
803        }
804    }
805
806    let mut ancestors: Vec<String> = Vec::new();
807    let mut current = docname;
808    while let Some(next) = parent.get(current) {
809        if ancestors.iter().any(|seen| seen == current) {
810            break;
811        }
812        ancestors.push(current.to_string());
813        current = next;
814    }
815    ancestors
816}
817
818/// Whether a [`ConsistencyMessage`] is a warning (counts toward `-W`) or an
819/// informational note.
820#[derive(Debug, Clone, Copy, PartialEq, Eq)]
821pub enum ConsistencyLevel {
822    Warning,
823    Info,
824}
825
826/// One diagnostic from [`check_consistency`], located at a document rather
827/// than at a source line (Sphinx passes `location=docname`).
828#[derive(Debug, Clone, PartialEq, Eq)]
829pub struct ConsistencyMessage {
830    pub docname: String,
831    pub message: String,
832    /// Sphinx's `type.subtype` category; see
833    /// [`crate::error::BuildWarning::category`].
834    pub category: Option<String>,
835    pub level: ConsistencyLevel,
836}
837
838/// Post-read consistency checks over the finished toctree graph.
839///
840/// Port of `BuildEnvironment.check_consistency`
841/// (`environment/__init__.py:797-823`) and the `_check_toc_parents`
842/// (`:942-960`) it calls: every document that no toctree reaches gets
843/// `document isn't included in any toctree`, and every document reachable
844/// from more than one gets an *informational* note (Sphinx uses
845/// `logger.info` there, so it must not count toward `-W`).
846///
847/// Not ported: the `env-check-consistency` event and the per-domain
848/// `check_consistency` hooks, neither of which exists yet.
849///
850/// `is_sphinx_source` answers, per docname, whether the file it came from
851/// carries a suffix Sphinx's `source_suffix` covers. This crate's discovery
852/// is deliberately wider than Sphinx's — it admits `.md` and `.txt`
853/// alongside `.rst`, where Sphinx's default `source_suffix` is `{'.rst':
854/// 'restructuredtext'}` (`config.py:243`, `project.py:49-88`) — and the
855/// orphan warning is the one check where that difference is user-visible:
856/// a `README.md` sitting in the source tree would earn a
857/// `toc.not_included` warning Sphinx never emits, failing `-W` on a build
858/// Sphinx passes. Everything else keeps treating those files as documents;
859/// full `source_suffix` semantics arrive with MyST in wave 6.
860pub fn check_consistency(
861    env: &BuildEnvironment,
862    is_sphinx_source: &dyn Fn(&str) -> bool,
863) -> Vec<ConsistencyMessage> {
864    let mut messages = Vec::new();
865
866    let included: BTreeSet<&str> = env
867        .included
868        .values()
869        .flatten()
870        .map(String::as_str)
871        .collect();
872
873    // `all_docs` is a BTreeMap, so this is sphinx's `sorted(self.all_docs)`.
874    for docname in env.all_docs.keys() {
875        // Reachable from some toctree, the root itself, textually included
876        // by another document, or explicitly marked `:orphan:`.
877        // ...or not a document Sphinx would have read at all.
878        if env.files_to_rebuild.contains_key(docname)
879            || *docname == env.root_doc
880            || included.contains(docname.as_str())
881            || env
882                .metadata
883                .get(docname)
884                .is_some_and(|meta| meta.contains_key("orphan"))
885            || !is_sphinx_source(docname)
886        {
887            continue;
888        }
889        messages.push(ConsistencyMessage {
890            docname: docname.clone(),
891            message: "document isn't included in any toctree".to_string(),
892            category: Some("toc.not_included".to_string()),
893            level: ConsistencyLevel::Warning,
894        });
895    }
896
897    // The parent list is interpolated verbatim, so its order is visible.
898    // Sphinx builds it from `toctree_includes` in read (insertion) order and
899    // this map iterates in docname order; both are the same order for the
900    // sorted read every build of this crate performs.
901    let mut toc_parents: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
902    for (container, children) in &env.toctree_includes {
903        for child in children {
904            toc_parents
905                .entry(child.as_str())
906                .or_default()
907                .push(container.as_str());
908        }
909    }
910    for (docname, parents) in toc_parents {
911        if parents.len() <= 1 {
912            continue;
913        }
914        // sphinx interpolates the parent list with `%s`, i.e. Python's
915        // `str(list)` — a repr of each element inside brackets.
916        let list = parents
917            .iter()
918            .map(|parent| py_repr_str(parent))
919            .collect::<Vec<_>>()
920            .join(", ");
921        let selected = parents.iter().max().expect("len > 1");
922        messages.push(ConsistencyMessage {
923            docname: docname.to_string(),
924            message: format!(
925                "document is referenced in multiple toctrees: [{list}], \
926                 selecting: {selected} <- {docname}"
927            ),
928            category: Some("toc.multiple_toc_parents".to_string()),
929            level: ConsistencyLevel::Info,
930        });
931    }
932
933    messages
934}
935
936// ---------------------------------------------------------------------------
937// TitleCollector (sphinx/environment/collectors/title.py)
938// ---------------------------------------------------------------------------
939
940/// `env.titles[docname]`: a fresh `title` node holding the first section
941/// title's contents, filtered exactly like a toc entry's.
942///
943/// Port of `TitleCollector.process_doc` (`collectors/title.py:27`). Its
944/// `longtitles` differ only when the document carries a `title` attribute
945/// (set by the `title` directive / `<meta>`), which nothing produces yet —
946/// so the caller stores this same node under both keys.
947pub fn document_title(doctree: &Doctree) -> Node {
948    let mut title = Node::elem(kinds::TITLE, Span::ZERO);
949    match first_section(&doctree.root) {
950        Some(section) => {
951            if let Some(first_child) = section.children.first() {
952                title.children = filter_title_children(first_child);
953            }
954        }
955        // sphinx: `doctree.get('title', '<no title>')`.
956        None => title
957            .children
958            .push(Node::text_node("<no title>", Span::ZERO)),
959    }
960    title
961}
962
963/// First `section` in document order (docutils `findall(nodes.section)`).
964fn first_section(node: &Node) -> Option<&Node> {
965    for child in &node.children {
966        if child.kind == kinds::SECTION {
967            return Some(child);
968        }
969        if let Some(found) = first_section(child) {
970            return Some(found);
971        }
972    }
973    None
974}
975
976// ---------------------------------------------------------------------------
977// 5. _resolve_toctree (sphinx/environment/adapters/toctree.py:119-559)
978// ---------------------------------------------------------------------------
979
980/// The docname and section name each virtual toctree target resolves to:
981/// `StandardDomain._virtual_doc_names` (`domains/std/__init__.py:784-788`),
982/// values included this time.
983const VIRTUAL_DOCS: [(&str, &str, &str); 3] = [
984    ("genindex", "genindex", "Index"),
985    ("modindex", "py-modindex", "Module Index"),
986    ("search", "search", "Search Page"),
987];
988
989/// The `(title, ref)` pairs of a `toctree` node's `entries` attribute, read
990/// back from the Python tuple reprs [`ResolvedEntries::entries_attr`] stores
991/// (`(None, 'intro')`, `('Custom', 'api')`).
992pub(crate) fn toctree_entries(toctree: &Node) -> Vec<(Option<String>, String)> {
993    let Some(AttrValue::List(items)) = toctree.get("entries") else {
994        return Vec::new();
995    };
996    items
997        .iter()
998        .filter_map(|item| parse_entry_repr(item))
999        .collect()
1000}
1001
1002/// One `(None, 'ref')` / `('title', 'ref')` tuple repr.
1003fn parse_entry_repr(item: &str) -> Option<(Option<String>, String)> {
1004    let inner = item.strip_prefix('(')?.strip_suffix(')')?;
1005    let (title, rest) = match inner.strip_prefix("None") {
1006        Some(rest) => (None, rest),
1007        None => {
1008            let (title, rest) = parse_py_str(inner)?;
1009            (Some(title), rest)
1010        }
1011    };
1012    let (target, rest) = parse_py_str(rest.strip_prefix(", ")?)?;
1013    rest.is_empty().then_some((title, target))
1014}
1015
1016/// Inverse of [`py_repr_str`]: the CPython string literal `s` starts with,
1017/// decoded, plus whatever follows it.
1018fn parse_py_str(s: &str) -> Option<(String, &str)> {
1019    let mut chars = s.char_indices();
1020    let (_, quote) = chars.next()?;
1021    if quote != '\'' && quote != '"' {
1022        return None;
1023    }
1024    let mut out = String::new();
1025    while let Some((at, c)) = chars.next() {
1026        match c {
1027            '\\' => {
1028                let (_, escaped) = chars.next()?;
1029                let hex_digits = match escaped {
1030                    'n' => {
1031                        out.push('\n');
1032                        continue;
1033                    }
1034                    'r' => {
1035                        out.push('\r');
1036                        continue;
1037                    }
1038                    't' => {
1039                        out.push('\t');
1040                        continue;
1041                    }
1042                    'x' => 2,
1043                    'u' => 4,
1044                    'U' => 8,
1045                    other => {
1046                        out.push(other);
1047                        continue;
1048                    }
1049                };
1050                let mut code = 0u32;
1051                for _ in 0..hex_digits {
1052                    let (_, digit) = chars.next()?;
1053                    code = code.checked_mul(16)? + digit.to_digit(16)?;
1054                }
1055                out.push(char::from_u32(code)?);
1056            }
1057            c if c == quote => return Some((out, &s[at + c.len_utf8()..])),
1058            c => out.push(c),
1059        }
1060    }
1061    None
1062}
1063
1064fn attr_flag(node: &Node, key: &'static str) -> bool {
1065    matches!(node.get(key), Some(AttrValue::Int(n)) if *n != 0)
1066}
1067
1068/// `toctree.attributes.get('caption')` when it is truthy. The parser
1069/// stores an absent caption the way docutils prints a `None` attribute,
1070/// as the string `True` (`Element.starttag`), so a caption literally
1071/// reading `True` is indistinguishable from none and treated as none.
1072fn toctree_caption(toctree: &Node) -> Option<&String> {
1073    match toctree.get("caption") {
1074        Some(AttrValue::Str(caption)) if !caption.is_empty() && caption != "True" => Some(caption),
1075        _ => None,
1076    }
1077}
1078
1079/// Resolve a `toctree` node into the nested bullet lists Sphinx writes in
1080/// its place: `_resolve_toctree` (`adapters/toctree.py:119-220`) with the
1081/// arguments the write phase passes for a document's own toctrees
1082/// (`get_and_resolve_doctree`: `prune=True`, the node's own `maxdepth`,
1083/// `collapse=False`, `includehidden=False`).
1084///
1085/// `None` is Sphinx's "renders to nothing": a hidden toctree, one with no
1086/// entries, or one whose entries all name documents without a title.
1087///
1088/// The result carries exactly what the search index reads from the resolved
1089/// tree — the caption as a `title`, every entry's title text inside
1090/// `reference` nodes, pruned to `maxdepth`/`titlesonly`/`tocdepth` like
1091/// Sphinx prunes it. Still missing for the HTML writer: the
1092/// `toctree-l<N>`/`current` classes and `iscurrent` marks, the relative
1093/// `refuri`s, and the `circular toctree references detected` warning (the
1094/// circular entry is dropped as Sphinx drops it, but nothing is logged).
1095/// `only` nodes are kept with their children: Sphinx evaluates them against
1096/// the build tags, which this crate does not evaluate anywhere yet.
1097pub fn resolve_toctree(env: &BuildEnvironment, docname: &str, toctree: &Node) -> Option<Node> {
1098    if attr_flag(toctree, "hidden") {
1099        return None;
1100    }
1101    let maxdepth = match toctree.get("maxdepth") {
1102        Some(AttrValue::Int(depth)) => *depth,
1103        _ => -1,
1104    };
1105    let ancestors = toctree_ancestors(&env.toctree_includes, docname);
1106    let walk = ToctreeWalk {
1107        env,
1108        titles_only: attr_flag(toctree, "titlesonly"),
1109        includehidden: attr_flag(toctree, "includehidden"),
1110        ancestors: &ancestors,
1111    };
1112    let entries = walk.entries_from_toctree(toctree, &[], false);
1113    if entries.is_empty() {
1114        return None;
1115    }
1116
1117    let mut newnode = Node::elem(kinds::COMPACT_PARAGRAPH, Span::ZERO);
1118    if let Some(caption) = toctree_caption(toctree) {
1119        let mut title = Node::elem(kinds::TITLE, Span::ZERO);
1120        title
1121            .children
1122            .push(Node::text_node(caption.clone(), Span::ZERO));
1123        newnode.children.push(title);
1124    }
1125    newnode.children.extend(entries);
1126    newnode.set("toctree", AttrValue::Int(1));
1127
1128    // Prune to maxdepth. `_toctree_add_classes` runs here in Sphinx; it only
1129    // stamps classes, which nothing below reads.
1130    let newnode = toctree_copy(&newnode, 1, maxdepth);
1131    match newnode.children.last() {
1132        // `isinstance(newnode[-1], nodes.Element) and len(newnode[-1]) == 0`
1133        // — no titles found.
1134        Some(last) if last.kind != kinds::TEXT && last.children.is_empty() => None,
1135        _ => Some(newnode),
1136    }
1137}
1138
1139/// The per-resolution context `_entries_from_toctree` threads through its
1140/// recursion.
1141struct ToctreeWalk<'a> {
1142    env: &'a BuildEnvironment,
1143    titles_only: bool,
1144    includehidden: bool,
1145    ancestors: &'a [String],
1146}
1147
1148impl ToctreeWalk<'_> {
1149    /// `_entries_from_toctree` (`adapters/toctree.py:223-306`).
1150    fn entries_from_toctree(
1151        &self,
1152        toctreenode: &Node,
1153        parents: &[String],
1154        subtree: bool,
1155    ) -> Vec<Node> {
1156        let mut entries = Vec::new();
1157        for (title, reference) in toctree_entries(toctreenode) {
1158            let Some((mut toc, refdoc)) =
1159                self.toctree_entry(title.as_deref(), &reference, toctreenode, parents)
1160            else {
1161                continue;
1162            };
1163
1164            // `:titlesonly:` keeps each entry's top-level title and its
1165            // sub-toctrees, and nothing else.
1166            if self.titles_only {
1167                for top_level in &mut toc.children {
1168                    if top_level.children.len() > 1 {
1169                        let subtrees = find_toctrees(top_level);
1170                        if subtrees.is_empty() {
1171                            top_level.children.remove(1);
1172                        } else {
1173                            top_level.children[1].children = subtrees;
1174                        }
1175                    }
1176                }
1177            }
1178
1179            let mut sub_parents = Vec::with_capacity(parents.len() + 1);
1180            sub_parents.push(refdoc);
1181            sub_parents.extend_from_slice(parents);
1182            self.expand_sub_toctrees(&mut toc, &sub_parents);
1183
1184            entries.extend(toc.children);
1185        }
1186
1187        if subtree {
1188            entries
1189        } else {
1190            let mut list = Node::elem(kinds::BULLET_LIST, Span::ZERO);
1191            list.children = entries;
1192            vec![list]
1193        }
1194    }
1195
1196    /// `_toctree_entry` (`adapters/toctree.py:309-383`): the toc for one
1197    /// entry plus the docname it refers to (`''` for the non-document
1198    /// forms), or `None` where Sphinx raises `LookupError`/`KeyError` and
1199    /// skips the entry.
1200    fn toctree_entry(
1201        &self,
1202        title: Option<&str>,
1203        reference: &str,
1204        toctreenode: &Node,
1205        parents: &[String],
1206    ) -> Option<(Node, String)> {
1207        if is_url(reference) {
1208            // `_toctree_url_entry`: only a *missing* title falls back to
1209            // the URL; an empty explicit title stays empty.
1210            let text = title.unwrap_or(reference);
1211            return Some((leaf_entry(text, reference), String::new()));
1212        }
1213        if reference == "self" {
1214            // `_toctree_self_entry`: the referencing document's own title.
1215            let parent = match toctreenode.get("parent") {
1216                Some(AttrValue::Str(parent)) => parent.as_str(),
1217                _ => "",
1218            };
1219            let text = match title {
1220                Some(title) if !title.is_empty() => title.to_string(),
1221                _ => self
1222                    .env
1223                    .titles
1224                    .get(parent)
1225                    .map(clean_astext)
1226                    .unwrap_or_default(),
1227            };
1228            return Some((leaf_entry(&text, reference), String::new()));
1229        }
1230        if let Some((_, docname, sectionname)) =
1231            VIRTUAL_DOCS.iter().find(|(name, _, _)| *name == reference)
1232        {
1233            // `_toctree_generated_entry`.
1234            let text = match title {
1235                Some(title) if !title.is_empty() => title,
1236                _ => sectionname,
1237            };
1238            return Some((leaf_entry(text, docname), String::new()));
1239        }
1240        if parents.iter().any(|parent| parent == reference) {
1241            // `circular toctree references detected, ignoring`.
1242            return None;
1243        }
1244        // `env.tocs[ref]` — a KeyError here is the "non-existing document"
1245        // path, which `resolve_entries` already warned about at read time.
1246        let toc = self.env.tocs.get(reference)?;
1247        let tocdepth = self
1248            .env
1249            .metadata
1250            .get(reference)
1251            .and_then(|metadata| metadata.get("tocdepth"))
1252            .and_then(|depth| depth.trim().parse::<i64>().ok())
1253            .unwrap_or(0);
1254        Some((
1255            self.standard_entry(title, reference, tocdepth, toc),
1256            reference.to_string(),
1257        ))
1258    }
1259
1260    /// `_toctree_standard_entry` (`adapters/toctree.py:431-452`).
1261    fn standard_entry(
1262        &self,
1263        title: Option<&str>,
1264        reference: &str,
1265        tocdepth: i64,
1266        toc: &Node,
1267    ) -> Node {
1268        let mut toc =
1269            if self.ancestors.iter().any(|ancestor| ancestor == reference) && tocdepth <= 0 {
1270                toc.clone()
1271            } else {
1272                toctree_copy(toc, 2, tocdepth)
1273            };
1274        if let Some(title) = title.filter(|title| !title.is_empty()) {
1275            if toc.children.len() == 1 {
1276                replace_entry_title(&mut toc.children[0], reference, title);
1277            }
1278        }
1279        toc
1280    }
1281
1282    /// The `for sub_toc_node in list(toc.findall(addnodes.toctree))` loop
1283    /// of `_entries_from_toctree`: every sub-toctree copied into a toc is
1284    /// expanded in place; a hidden one stays as the bare node Sphinx leaves
1285    /// behind (which renders, and indexes, as nothing).
1286    fn expand_sub_toctrees(&self, node: &mut Node, parents: &[String]) {
1287        let mut index = 0;
1288        while index < node.children.len() {
1289            if node.children[index].kind != kinds::TOCTREE {
1290                self.expand_sub_toctrees(&mut node.children[index], parents);
1291                index += 1;
1292                continue;
1293            }
1294            if attr_flag(&node.children[index], "hidden") && !self.includehidden {
1295                index += 1;
1296                continue;
1297            }
1298            let sub_toc_node = node.children.remove(index);
1299            let expanded = self.entries_from_toctree(&sub_toc_node, parents, true);
1300            let inserted = expanded.len();
1301            node.children.splice(index..index, expanded);
1302            index += inserted;
1303        }
1304    }
1305}
1306
1307/// `bullet_list > list_item > compact_paragraph > reference > Text`: the
1308/// shape of the URL, `self` and generated entries.
1309fn leaf_entry(text: &str, refuri: &str) -> Node {
1310    let mut reference = Node::elem(kinds::REFERENCE, Span::ZERO);
1311    reference.set("anchorname", AttrValue::Str(String::new()));
1312    reference.set("refuri", AttrValue::Str(refuri.to_string()));
1313    reference.children.push(Node::text_node(text, Span::ZERO));
1314    let mut para = Node::elem(kinds::COMPACT_PARAGRAPH, Span::ZERO);
1315    para.children.push(reference);
1316    let mut item = Node::elem(kinds::LIST_ITEM, Span::ZERO);
1317    item.children.push(para);
1318    let mut toc = Node::elem(kinds::BULLET_LIST, Span::ZERO);
1319    toc.children.push(item);
1320    toc
1321}
1322
1323/// An explicit `Title <ref>` replaces the text of the entry's own
1324/// reference (`refuri == ref`, no anchor).
1325fn replace_entry_title(node: &mut Node, reference: &str, title: &str) {
1326    let is_entry_reference = node.kind == kinds::REFERENCE
1327        && matches!(node.get("refuri"), Some(AttrValue::Str(uri)) if uri == reference)
1328        && matches!(node.get("anchorname"), Some(AttrValue::Str(anchor)) if anchor.is_empty());
1329    if is_entry_reference {
1330        node.children = vec![Node::text_node(title, Span::ZERO)];
1331        return;
1332    }
1333    for child in &mut node.children {
1334        replace_entry_title(child, reference, title);
1335    }
1336}
1337
1338/// `findall(addnodes.toctree)` below `node`, in document order, as the
1339/// childless copies the toc holds anyway.
1340fn find_toctrees(node: &Node) -> Vec<Node> {
1341    let mut out = Vec::new();
1342    fn walk(node: &Node, out: &mut Vec<Node>) {
1343        for child in &node.children {
1344            if child.kind == kinds::TOCTREE {
1345                out.push(child.shallow_copy());
1346            }
1347            walk(child, out);
1348        }
1349    }
1350    walk(node, &mut out);
1351    out
1352}
1353
1354/// `_toctree_copy` (`adapters/toctree.py:485-493`): cut and copy a toc at
1355/// `maxdepth` (`<= 0` means unlimited).
1356fn toctree_copy(node: &Node, depth: i64, maxdepth: i64) -> Node {
1357    let depth = (depth - 1).max(1);
1358    let mut copied = toctree_copy_seq(node, depth, maxdepth, true);
1359    debug_assert_eq!(copied.len(), 1);
1360    copied.pop().unwrap_or_else(|| node.shallow_copy())
1361}
1362
1363/// `_toctree_copy_seq` (`adapters/toctree.py:496-559`) with `collapse`
1364/// off and `only` conditions taken as true (see [`resolve_toctree`]).
1365fn toctree_copy_seq(node: &Node, depth: i64, maxdepth: i64, initial_call: bool) -> Vec<Node> {
1366    match node.kind {
1367        kinds::COMPACT_PARAGRAPH | kinds::LIST_ITEM => {
1368            let mut copy = node.shallow_copy();
1369            for child in &node.children {
1370                copy.children
1371                    .extend(toctree_copy_seq(child, depth, maxdepth, false));
1372            }
1373            vec![copy]
1374        }
1375        kinds::BULLET_LIST => {
1376            let keep = depth <= 1 || depth <= maxdepth || maxdepth <= 0;
1377            if !keep && !initial_call {
1378                return Vec::new();
1379            }
1380            let mut copy = node.shallow_copy();
1381            for child in &node.children {
1382                copy.children
1383                    .extend(toctree_copy_seq(child, depth + 1, maxdepth, false));
1384            }
1385            vec![copy]
1386        }
1387        // Sub-toctrees are kept for the caller to expand.
1388        kinds::TOCTREE => vec![node.shallow_copy()],
1389        kinds::ONLY => node
1390            .children
1391            .iter()
1392            .flat_map(|child| toctree_copy_seq(child, depth, maxdepth, false))
1393            .collect(),
1394        // References and captions are deep-copied; anything else is
1395        // "Unexpected node type" in Sphinx, kept whole here.
1396        _ => vec![node.clone()],
1397    }
1398}
1399
1400#[cfg(test)]
1401mod tests {
1402    use super::*;
1403    use crate::rst;
1404
1405    fn docs(names: &[&str]) -> BTreeSet<String> {
1406        names.iter().map(|s| (*s).to_string()).collect()
1407    }
1408
1409    fn parse(source: &str, docname: &str, found: &BTreeSet<String>) -> Doctree {
1410        rst::parse_rst(
1411            source,
1412            &rst::ParseOptions {
1413                source_path: "<snippet>".to_string(),
1414                sphinx: true,
1415                docname: docname.to_string(),
1416                exclude_patterns: Vec::new(),
1417                py: Default::default(),
1418                srcdir: None,
1419                found_docs: Some(std::sync::Arc::new(found.clone())),
1420                ..Default::default()
1421            },
1422        )
1423    }
1424
1425    #[test]
1426    fn empty_document_yields_empty_bullet_list() {
1427        let doctree = parse("", "index", &docs(&[]));
1428        let (toc, n) = build_toc(&doctree, "index");
1429        assert_eq!(toc.pformat(), "<bullet_list>\n");
1430        assert_eq!(n, 0);
1431    }
1432
1433    #[test]
1434    fn first_entry_has_empty_anchor_and_later_entries_use_ids() {
1435        let doctree = parse("A\n=\n\nSub\n---\n\nText.\n", "a", &docs(&["a"]));
1436        let (toc, n) = build_toc(&doctree, "a");
1437        assert_eq!(n, 2);
1438        assert_eq!(
1439            toc.pformat(),
1440            concat!(
1441                "<bullet_list>\n",
1442                "    <list_item>\n",
1443                "        <compact_paragraph>\n",
1444                "            <reference anchorname=\"\" internal=\"1\" refuri=\"a\">\n",
1445                "                A\n",
1446                "        <bullet_list>\n",
1447                "            <list_item>\n",
1448                "                <compact_paragraph>\n",
1449                "                    <reference anchorname=\"#sub\" internal=\"1\" refuri=\"a\">\n",
1450                "                        Sub\n",
1451            )
1452        );
1453    }
1454
1455    /// `build_toc`'s `memo_parents` bubbling (`collectors/toctree.py:159-181`),
1456    /// pinned against a sphinx 9.1.0 `env.tocs['a']` for this exact source:
1457    /// nested descriptions nest, a multi-signature description collects its
1458    /// children under its LAST signature, and a description that produced no
1459    /// entry of its own (`describe` has no `_toc_name`) is walked past, so
1460    /// its children land at the section level.
1461    #[test]
1462    fn object_entries_nest_under_the_nearest_description_that_has_one() {
1463        let doctree = parse(
1464            concat!(
1465                "A\n=\n\n",
1466                ".. confval:: outer\n\n   Outer body.\n\n",
1467                "   .. confval:: inner\n\n      Inner body.\n\n",
1468                "      .. confval:: deepest\n\n         Deep body.\n\n",
1469                ".. confval:: sibling\n\n",
1470                ".. confval:: multi_a\n             multi_b\n\n",
1471                "   .. confval:: under_multi\n\n",
1472                ".. describe:: nothing\n\n   .. confval:: under_describe\n",
1473            ),
1474            "a",
1475            &docs(&["a"]),
1476        );
1477        let (toc, n) = build_toc(&doctree, "a");
1478        assert_eq!(n, 9);
1479        assert_eq!(
1480            toc.pformat(),
1481            concat!(
1482                "<bullet_list>\n",
1483                "    <list_item>\n",
1484                "        <compact_paragraph>\n",
1485                "            <reference anchorname=\"\" internal=\"1\" refuri=\"a\">\n",
1486                "                A\n",
1487                "        <bullet_list>\n",
1488                "            <list_item>\n",
1489                "                <compact_paragraph skip_section_number=\"1\">\n",
1490                "                    <reference anchorname=\"#confval-outer\" internal=\"1\" refuri=\"a\">\n",
1491                "                        <literal>\n",
1492                "                            outer\n",
1493                "                <bullet_list>\n",
1494                "                    <list_item>\n",
1495                "                        <compact_paragraph skip_section_number=\"1\">\n",
1496                "                            <reference anchorname=\"#confval-inner\" internal=\"1\" refuri=\"a\">\n",
1497                "                                <literal>\n",
1498                "                                    inner\n",
1499                "                        <bullet_list>\n",
1500                "                            <list_item>\n",
1501                "                                <compact_paragraph skip_section_number=\"1\">\n",
1502                "                                    <reference anchorname=\"#confval-deepest\" internal=\"1\" refuri=\"a\">\n",
1503                "                                        <literal>\n",
1504                "                                            deepest\n",
1505                "            <list_item>\n",
1506                "                <compact_paragraph skip_section_number=\"1\">\n",
1507                "                    <reference anchorname=\"#confval-sibling\" internal=\"1\" refuri=\"a\">\n",
1508                "                        <literal>\n",
1509                "                            sibling\n",
1510                "            <list_item>\n",
1511                "                <compact_paragraph skip_section_number=\"1\">\n",
1512                "                    <reference anchorname=\"#confval-multi_a\" internal=\"1\" refuri=\"a\">\n",
1513                "                        <literal>\n",
1514                "                            multi_a\n",
1515                "            <list_item>\n",
1516                "                <compact_paragraph skip_section_number=\"1\">\n",
1517                "                    <reference anchorname=\"#confval-multi_b\" internal=\"1\" refuri=\"a\">\n",
1518                "                        <literal>\n",
1519                "                            multi_b\n",
1520                "                <bullet_list>\n",
1521                "                    <list_item>\n",
1522                "                        <compact_paragraph skip_section_number=\"1\">\n",
1523                "                            <reference anchorname=\"#confval-under_multi\" internal=\"1\" refuri=\"a\">\n",
1524                "                                <literal>\n",
1525                "                                    under_multi\n",
1526                "            <list_item>\n",
1527                "                <compact_paragraph skip_section_number=\"1\">\n",
1528                "                    <reference anchorname=\"#confval-under_describe\" internal=\"1\" refuri=\"a\">\n",
1529                "                        <literal>\n",
1530                "                            under_describe\n",
1531            )
1532        );
1533    }
1534
1535    /// The three `build_toc` skips: no `_toc_name` (only `confval` sets one
1536    /// in the std domain), `:no-contents-entry:`, and an id-less signature
1537    /// (`:no-index:`).
1538    #[test]
1539    fn object_entries_skip_unnamed_opted_out_and_id_less_signatures() {
1540        let doctree = parse(
1541            concat!(
1542                "A\n=\n\n",
1543                ".. envvar:: HOME\n\n",
1544                ".. confval:: hidden\n   :no-contents-entry:\n\n",
1545                ".. confval:: unindexed\n   :no-index:\n\n",
1546                ".. confval:: kept\n",
1547            ),
1548            "a",
1549            &docs(&["a"]),
1550        );
1551        let (toc, n) = build_toc(&doctree, "a");
1552        // The section plus exactly one object entry.
1553        assert_eq!(n, 2);
1554        let out = toc.pformat();
1555        assert!(out.contains("#confval-kept"), "{out}");
1556        assert!(!out.contains("HOME"), "{out}");
1557        assert!(!out.contains("hidden"), "{out}");
1558        assert!(!out.contains("unindexed"), "{out}");
1559    }
1560
1561    #[test]
1562    fn title_inline_markup_survives_but_references_are_unwrapped() {
1563        let doctree = parse(
1564            "A `link <https://x/>`_ and *em* [#f]_\n=====================================\n\n.. [#f] note\n",
1565            "a",
1566            &docs(&["a"]),
1567        );
1568        let (toc, _) = build_toc(&doctree, "a");
1569        // reference wrapper dropped (its text kept), footnote_reference
1570        // dropped whole, emphasis kept.
1571        assert!(toc.pformat().contains("<emphasis>\n"), "{}", toc.pformat());
1572        assert!(!toc.pformat().contains("<reference anchorname=\"\" internal=\"1\" refuri=\"a\">\n                A\n                <reference"));
1573        assert!(
1574            !toc.pformat().contains("footnote_reference"),
1575            "{}",
1576            toc.pformat()
1577        );
1578        assert!(toc.pformat().contains("link"), "{}", toc.pformat());
1579    }
1580
1581    /// An `only` node's toc entries are re-wrapped in a fresh `only`
1582    /// carrying the same expression, so the tag filtering can happen at
1583    /// render time. (A section inside `.. only::` would be the other half of
1584    /// this branch, but the parser rejects section titles in nested content,
1585    /// so a toctree is what reaches the collector today.)
1586    #[test]
1587    fn only_directive_wraps_its_entries() {
1588        let doctree = parse(
1589            ".. only:: html\n\n   .. toctree::\n\n      a\n",
1590            "index",
1591            &docs(&["index", "a"]),
1592        );
1593        let (toc, n) = build_toc(&doctree, "index");
1594        assert_eq!(n, 0, "a copied toctree is not a numbered entry");
1595        assert!(
1596            toc.pformat()
1597                .starts_with("<bullet_list>\n    <only expr=\"html\">\n        <toctree "),
1598            "{}",
1599            toc.pformat()
1600        );
1601
1602        let mut env = BuildEnvironment::default();
1603        for node in toctree_copies(&toc) {
1604            note_toctree(&mut env, "index", node);
1605        }
1606        assert_eq!(
1607            env.toctree_includes.get("index"),
1608            Some(&vec!["a".to_string()]),
1609            "a toctree inside `only` still contributes to the graph"
1610        );
1611    }
1612
1613    #[test]
1614    fn toctree_node_is_copied_into_the_toc_and_noted() {
1615        let found = docs(&["index", "a", "b"]);
1616        let doctree = parse(
1617            "Index\n=====\n\n.. toctree::\n\n   a\n   b\n",
1618            "index",
1619            &found,
1620        );
1621        let (toc, n) = build_toc(&doctree, "index");
1622        assert_eq!(n, 1, "the copied toctree is not an entry");
1623
1624        let mut env = BuildEnvironment::default();
1625        for node in toctree_copies(&toc) {
1626            note_toctree(&mut env, "index", node);
1627        }
1628        assert_eq!(
1629            env.toctree_includes.get("index"),
1630            Some(&vec!["a".to_string(), "b".to_string()])
1631        );
1632        assert_eq!(
1633            env.files_to_rebuild.get("a"),
1634            Some(&BTreeSet::from(["index".to_string()]))
1635        );
1636        assert!(env.glob_toctrees.is_empty());
1637        assert!(env.numbered_toctrees.is_empty());
1638    }
1639
1640    #[test]
1641    fn note_toctree_creates_the_includes_key_even_when_empty() {
1642        // sphinx `setdefault(docname, []).extend([])`.
1643        let mut env = BuildEnvironment::default();
1644        let mut toctree = Node::elem(kinds::TOCTREE, Span::ZERO);
1645        toctree.set("includefiles", AttrValue::List(vec![]));
1646        note_toctree(&mut env, "index", &toctree);
1647        assert_eq!(env.toctree_includes.get("index"), Some(&Vec::new()));
1648        assert!(env.files_to_rebuild.is_empty());
1649    }
1650
1651    #[test]
1652    fn glob_and_numbered_flags_reach_the_environment() {
1653        let mut env = BuildEnvironment::default();
1654        let mut toctree = Node::elem(kinds::TOCTREE, Span::ZERO);
1655        toctree.set("glob", AttrValue::Int(1));
1656        toctree.set("numbered", AttrValue::Int(999));
1657        toctree.set("includefiles", AttrValue::List(vec!["a".into()]));
1658        note_toctree(&mut env, "index", &toctree);
1659        assert!(env.glob_toctrees.contains("index"));
1660        assert!(env.numbered_toctrees.contains("index"));
1661    }
1662
1663    /// Every `resolve_entries` input a test needs, defaulted.
1664    fn content<'a>(
1665        lines: &'a [String],
1666        docname: &'a str,
1667        found: &'a BTreeSet<String>,
1668    ) -> ToctreeContent<'a> {
1669        ToctreeContent {
1670            content: lines,
1671            docname,
1672            glob: false,
1673            reversed: false,
1674            source: 0,
1675            line: 1,
1676            found_docs: found,
1677            source_suffixes: &[".rst"],
1678            exclude_patterns: &[],
1679        }
1680    }
1681
1682    fn lines(entries: &[&str]) -> Vec<String> {
1683        entries.iter().map(|s| (*s).to_string()).collect()
1684    }
1685
1686    #[test]
1687    fn entries_resolve_relative_absolute_and_self_targets() {
1688        let found = docs(&["index", "sub/b", "sub/c", "a"]);
1689        let entries = lines(&[
1690            "c",
1691            "/a",
1692            "self",
1693            "https://example.invalid/x",
1694            "missing",
1695            "sub/b",
1696        ]);
1697        let resolved = resolve_entries(&content(&entries, "sub/b", &found));
1698        assert_eq!(
1699            resolved.entries,
1700            vec![
1701                (None, "sub/c".to_string()),
1702                (None, "a".to_string()),
1703                (None, "self".to_string()),
1704                (None, "https://example.invalid/x".to_string()),
1705            ],
1706            "missing docs drop; `sub/b` is the current document, which is not \
1707             a candidate for its own toctree"
1708        );
1709        assert_eq!(
1710            resolved.includefiles,
1711            vec!["sub/c".to_string(), "a".to_string()]
1712        );
1713        assert_eq!(
1714            resolved
1715                .warnings
1716                .iter()
1717                .map(|w| w.message.as_str())
1718                .collect::<Vec<_>>(),
1719            vec![
1720                "toctree contains reference to nonexisting document 'sub/missing'",
1721                "toctree contains reference to nonexisting document 'sub/sub/b'",
1722            ],
1723            "sphinx reports the *joined* docname, so a document-relative miss \
1724             names the directory it was resolved against"
1725        );
1726    }
1727
1728    /// The oracle's `toctree_self_ref` project: a toctree entry naming its
1729    /// own document is not a `self referenced toctree` — parse_content
1730    /// removes the current document from the candidate set first, so it
1731    /// comes out as an ordinary missing-document warning, and never reaches
1732    /// `toctree_includes`.
1733    #[test]
1734    fn an_entry_naming_its_own_document_is_reported_as_nonexisting() {
1735        let found = docs(&["index", "a"]);
1736        let entries = lines(&["index", "a"]);
1737        let resolved = resolve_entries(&content(&entries, "index", &found));
1738        assert_eq!(resolved.includefiles, vec!["a".to_string()]);
1739        assert_eq!(
1740            resolved
1741                .warnings
1742                .iter()
1743                .map(|w| (w.message.as_str(), w.category.as_deref()))
1744                .collect::<Vec<_>>(),
1745            vec![(
1746                "toctree contains reference to nonexisting document 'index'",
1747                Some("toc.not_readable")
1748            )]
1749        );
1750    }
1751
1752    /// Every diagnostic is located at the `.. toctree::` marker, not at the
1753    /// offending entry: sphinx passes `location=toctree`, the directive node.
1754    #[test]
1755    fn diagnostics_are_located_at_the_directive() {
1756        let found = docs(&["index"]);
1757        let entries = lines(&["missing"]);
1758        let resolved = resolve_entries(&ToctreeContent {
1759            line: 12,
1760            ..content(&entries, "index", &found)
1761        });
1762        assert_eq!(resolved.warnings.len(), 1);
1763        assert_eq!(resolved.warnings[0].line, 12);
1764        assert_eq!(
1765            resolved.warnings[0].category.as_deref(),
1766            Some("toc.not_readable")
1767        );
1768    }
1769
1770    /// A missing target that `exclude_patterns` covers is reported as
1771    /// *excluded* rather than *nonexisting* (`directives/other.py:150-153`),
1772    /// matched against `doc2path(ref, base=False)` — the docname plus the
1773    /// first source suffix.
1774    #[test]
1775    fn excluded_targets_get_their_own_message() {
1776        let found = docs(&["index"]);
1777        let excluded = vec!["drafts/*".to_string()];
1778        let entries = lines(&["drafts/wip"]);
1779        let resolved = resolve_entries(&ToctreeContent {
1780            exclude_patterns: &excluded,
1781            ..content(&entries, "index", &found)
1782        });
1783        assert_eq!(
1784            resolved.warnings[0].message,
1785            "toctree contains reference to excluded document 'drafts/wip'"
1786        );
1787        assert_eq!(
1788            resolved.warnings[0].category.as_deref(),
1789            Some("toc.excluded")
1790        );
1791    }
1792
1793    #[test]
1794    fn a_document_claimed_twice_warns_but_is_still_listed() {
1795        let found = docs(&["index", "a"]);
1796        let entries = lines(&["a", "a"]);
1797        let resolved = resolve_entries(&content(&entries, "index", &found));
1798        assert_eq!(
1799            resolved.includefiles,
1800            vec!["a".to_string(), "a".to_string()],
1801            "sphinx appends the duplicate either way"
1802        );
1803        assert_eq!(
1804            resolved.warnings[0].message,
1805            "duplicated entry found in toctree: a"
1806        );
1807        assert_eq!(
1808            resolved.warnings[0].category.as_deref(),
1809            Some("toc.duplicate_entry")
1810        );
1811    }
1812
1813    /// `_virtual_doc_names` is a dict, and `parse_content` unions
1814    /// `frozenset(...)` of it into the candidate set — so the virtual names
1815    /// are its **keys**. `modindex` is the one an author writes in a
1816    /// toctree; `py-modindex` is that key's value (the docname the module
1817    /// index is finally written to) and is not itself a virtual name.
1818    #[test]
1819    fn the_virtual_docnames_are_the_dict_keys_not_its_values() {
1820        let found = docs(&["index"]);
1821        let entries = lines(&["genindex", "modindex", "search"]);
1822        let resolved = resolve_entries(&content(&entries, "index", &found));
1823        assert_eq!(
1824            resolved.includefiles,
1825            vec![
1826                "genindex".to_string(),
1827                "modindex".to_string(),
1828                "search".to_string()
1829            ]
1830        );
1831        assert!(resolved.warnings.is_empty(), "{:?}", resolved.warnings);
1832
1833        let entries = lines(&["py-modindex"]);
1834        let resolved = resolve_entries(&content(&entries, "index", &found));
1835        assert!(resolved.includefiles.is_empty());
1836        assert_eq!(
1837            resolved
1838                .warnings
1839                .iter()
1840                .map(|w| w.message.as_str())
1841                .collect::<Vec<_>>(),
1842            vec!["toctree contains reference to nonexisting document 'py-modindex'"]
1843        );
1844    }
1845
1846    #[test]
1847    fn glob_entries_expand_sorted_and_skip_virtual_docs() {
1848        let found = docs(&["index", "pages/a", "pages/b"]);
1849        let entries = lines(&["pages/*"]);
1850        let resolved = resolve_entries(&ToctreeContent {
1851            glob: true,
1852            ..content(&entries, "index", &found)
1853        });
1854        assert_eq!(
1855            resolved.includefiles,
1856            vec!["pages/a".to_string(), "pages/b".to_string()]
1857        );
1858        assert_eq!(
1859            resolved.entries_attr(),
1860            AttrValue::List(vec![
1861                "(None, 'pages/a')".to_string(),
1862                "(None, 'pages/b')".to_string()
1863            ])
1864        );
1865        assert!(resolved.warnings.is_empty());
1866    }
1867
1868    /// A dead glob warns with the entry as authored (sphinx `%r` of `entry`,
1869    /// not of the joined pattern), and — because sphinx passes no `type` —
1870    /// carries no `[type.subtype]` category.
1871    #[test]
1872    fn a_glob_that_matches_nothing_warns() {
1873        let found = docs(&["index", "pages/a"]);
1874        let entries = lines(&["missing*"]);
1875        let resolved = resolve_entries(&ToctreeContent {
1876            glob: true,
1877            ..content(&entries, "index", &found)
1878        });
1879        assert!(resolved.includefiles.is_empty());
1880        assert_eq!(
1881            resolved.warnings,
1882            vec![ToctreeWarning {
1883                source: 0,
1884                line: 1,
1885                message: "toctree glob pattern 'missing*' didn't match any documents".to_string(),
1886                category: None,
1887                kind: ToctreeWarningKind::EmptyGlob,
1888            }]
1889        );
1890    }
1891
1892    /// An uncompilable pattern must not masquerade as "matched nothing":
1893    /// that would hide the bug behind a plausible Sphinx warning.
1894    #[test]
1895    fn an_uncompilable_glob_pattern_is_reported_as_such() {
1896        let found = docs(&["index", "a"]);
1897        // `[z-a]` is a character class with a reversed range: our pattern
1898        // translation hands it to `regex`, which refuses it.
1899        let entries = lines(&["[z-a]*"]);
1900        let resolved = resolve_entries(&ToctreeContent {
1901            glob: true,
1902            ..content(&entries, "index", &found)
1903        });
1904        assert_eq!(resolved.warnings.len(), 1, "{:?}", resolved.warnings);
1905        assert_eq!(resolved.warnings[0].kind, ToctreeWarningKind::PatternError);
1906        assert!(
1907            resolved.warnings[0]
1908                .message
1909                .starts_with("toctree glob pattern '[z-a]*' is not usable:"),
1910            "{}",
1911            resolved.warnings[0].message
1912        );
1913    }
1914
1915    #[test]
1916    fn explicit_titles_and_suffixes() {
1917        let found = docs(&["index", "other"]);
1918        let entries = lines(&["Linked <other.rst>", "<foo>"]);
1919        let resolved = resolve_entries(&content(&entries, "index", &found));
1920        assert_eq!(
1921            resolved.entries,
1922            vec![(Some("Linked".to_string()), "other".to_string())],
1923            "`<foo>` is a literal (missing) target, not an empty title"
1924        );
1925        assert_eq!(
1926            resolved.entries_attr(),
1927            AttrValue::List(vec!["('Linked', 'other')".to_string()])
1928        );
1929        assert_eq!(
1930            resolved.warnings[0].message,
1931            "toctree contains reference to nonexisting document '<foo>'"
1932        );
1933    }
1934
1935    #[test]
1936    fn reversed_flips_both_lists() {
1937        let found = docs(&["index", "a", "b"]);
1938        let entries = lines(&["a", "b"]);
1939        let resolved = resolve_entries(&ToctreeContent {
1940            reversed: true,
1941            ..content(&entries, "index", &found)
1942        });
1943        assert_eq!(
1944            resolved.includefiles,
1945            vec!["b".to_string(), "a".to_string()]
1946        );
1947    }
1948
1949    /// `url_re` is `(?P<schema>.+)://.*` matched (not fullmatched) from the
1950    /// start, and `.+` backtracks: only a `://` at offset 0 with no other
1951    /// occurrence fails to be a URL.
1952    #[test]
1953    fn url_detection_matches_the_backtracking_regex() {
1954        assert!(is_url("https://example.invalid/x"));
1955        assert!(is_url("a://b"));
1956        assert!(!is_url("://leading"));
1957        assert!(
1958            is_url("://a://b"),
1959            "the second `://` has a non-empty schema before it"
1960        );
1961        assert!(!is_url("plain/docname"));
1962    }
1963
1964    #[test]
1965    fn virtual_docnames_are_valid_entries() {
1966        let found = docs(&["index"]);
1967        let entries = lines(&["genindex"]);
1968        let resolved = resolve_entries(&content(&entries, "index", &found));
1969        assert_eq!(resolved.includefiles, vec!["genindex".to_string()]);
1970        assert!(resolved.warnings.is_empty());
1971    }
1972
1973    // -----------------------------------------------------------------
1974    // Whole-project reads of the graph
1975    // -----------------------------------------------------------------
1976
1977    /// An environment holding nothing but a toctree graph and a document
1978    /// set, which is all the graph reads look at.
1979    fn graph(root: &str, includes: &[(&str, &[&str])]) -> BuildEnvironment {
1980        let mut env = BuildEnvironment {
1981            root_doc: root.to_string(),
1982            ..Default::default()
1983        };
1984        for (container, children) in includes {
1985            let children: Vec<String> = children.iter().map(|c| (*c).to_string()).collect();
1986            for child in &children {
1987                env.files_to_rebuild
1988                    .entry(child.clone())
1989                    .or_default()
1990                    .insert((*container).to_string());
1991            }
1992            env.toctree_includes
1993                .insert((*container).to_string(), children);
1994        }
1995        for docname in env
1996            .toctree_includes
1997            .keys()
1998            .cloned()
1999            .chain(env.files_to_rebuild.keys().cloned())
2000            .collect::<Vec<_>>()
2001        {
2002            env.all_docs.insert(docname, 0);
2003        }
2004        env.all_docs.insert(root.to_string(), 0);
2005        env
2006    }
2007
2008    fn relation(env: &BuildEnvironment, docname: &str) -> (String, String, String) {
2009        let show = |value: &Option<String>| value.clone().unwrap_or_else(|| "-".to_string());
2010        let (parent, prev, next) = collect_relations(env)[docname].clone();
2011        (show(&parent), show(&prev), show(&next))
2012    }
2013
2014    /// The oracle's `toctree_nested` shape: `index -> [a, b]`, `a -> [a1,
2015    /// a2]`. `prev`/`next` chain the flattened pre-order, so `a1`'s `prev`
2016    /// is its own parent `a`, not a sibling.
2017    #[test]
2018    fn relations_chain_the_preorder_walk() {
2019        let env = graph("index", &[("index", &["a", "b"]), ("a", &["a1", "a2"])]);
2020        assert_eq!(
2021            relation(&env, "index"),
2022            ("-".into(), "-".into(), "a".into())
2023        );
2024        assert_eq!(
2025            relation(&env, "a"),
2026            ("index".into(), "index".into(), "a1".into())
2027        );
2028        assert_eq!(relation(&env, "a1"), ("a".into(), "a".into(), "a2".into()));
2029        assert_eq!(relation(&env, "a2"), ("a".into(), "a1".into(), "b".into()));
2030        assert_eq!(
2031            relation(&env, "b"),
2032            ("index".into(), "a2".into(), "-".into())
2033        );
2034    }
2035
2036    /// The oracle's `toctree_multi_parent` shape: `c` is reachable from
2037    /// both `a` and `b`, and the *first* visit — depth-first through `a` —
2038    /// is the one that sets its parent.
2039    #[test]
2040    fn a_document_with_two_parents_keeps_the_first_visit() {
2041        let env = graph(
2042            "index",
2043            &[("index", &["a", "b"]), ("a", &["c"]), ("b", &["c"])],
2044        );
2045        assert_eq!(relation(&env, "c"), ("a".into(), "a".into(), "b".into()));
2046        assert_eq!(
2047            relation(&env, "b"),
2048            ("index".into(), "c".into(), "-".into())
2049        );
2050    }
2051
2052    #[test]
2053    fn a_project_without_toctrees_relates_only_its_root() {
2054        let env = graph("index", &[]);
2055        assert_eq!(
2056            relation(&env, "index"),
2057            ("-".into(), "-".into(), "-".into())
2058        );
2059        assert_eq!(collect_relations(&env).len(), 1);
2060    }
2061
2062    /// Sphinx 9.1.0 raises `RecursionError` here (its `traversed` set filters
2063    /// yields but never guards descent). The port must terminate and answer.
2064    #[test]
2065    fn a_mutual_cycle_terminates_instead_of_recursing_forever() {
2066        let env = graph("index", &[("index", &["a"]), ("a", &["b"]), ("b", &["a"])]);
2067        let relations = collect_relations(&env);
2068        assert_eq!(
2069            relations.keys().collect::<Vec<_>>(),
2070            vec!["a", "b", "index"],
2071            "every document is still reached exactly once"
2072        );
2073        assert_eq!(relation(&env, "b"), ("a".into(), "a".into(), "-".into()));
2074    }
2075
2076    /// A document whose own toctree lists it (only reachable from a stale
2077    /// or hand-built environment — [`resolve_entries`] never produces it):
2078    /// sphinx drops that subtree, and so does this.
2079    #[test]
2080    fn a_self_parenting_toctree_drops_its_subtree() {
2081        let env = graph("index", &[("index", &["a"]), ("a", &["a"])]);
2082        let relations = collect_relations(&env);
2083        assert_eq!(relations.keys().collect::<Vec<_>>(), vec!["a", "index"]);
2084    }
2085
2086    #[test]
2087    fn ancestors_walk_up_to_the_root_and_stop_on_cycles() {
2088        let includes = graph("index", &[("index", &["a"]), ("a", &["b"])]).toctree_includes;
2089        assert_eq!(toctree_ancestors(&includes, "b"), vec!["b", "a"]);
2090        assert_eq!(
2091            toctree_ancestors(&includes, "index"),
2092            Vec::<String>::new(),
2093            "a document with no toctree parent has no ancestors, not even itself"
2094        );
2095
2096        let cyclic = graph("index", &[("a", &["b"]), ("b", &["a"])]).toctree_includes;
2097        assert_eq!(toctree_ancestors(&cyclic, "a"), vec!["a", "b"]);
2098    }
2099
2100    #[test]
2101    fn documents_no_toctree_reaches_are_reported_as_orphans() {
2102        let mut env = graph("index", &[("index", &["a"])]);
2103        env.all_docs.insert("stray".to_string(), 0);
2104        env.all_docs.insert("textually_included".to_string(), 0);
2105        env.all_docs.insert("marked".to_string(), 0);
2106        env.included.insert(
2107            "a".to_string(),
2108            BTreeSet::from(["textually_included".to_string()]),
2109        );
2110        env.metadata.insert(
2111            "marked".to_string(),
2112            BTreeMap::from([("orphan".to_string(), String::new())]),
2113        );
2114
2115        let messages = check_consistency(&env, &|_| true);
2116        assert_eq!(
2117            messages
2118                .iter()
2119                .map(|m| (m.docname.as_str(), m.level))
2120                .collect::<Vec<_>>(),
2121            vec![("stray", ConsistencyLevel::Warning)],
2122            "the root, toctree'd, textually included and `:orphan:` \
2123             documents are all exempt; {messages:?}"
2124        );
2125        assert_eq!(
2126            messages[0].message,
2127            "document isn't included in any toctree"
2128        );
2129        assert_eq!(messages[0].category.as_deref(), Some("toc.not_included"));
2130    }
2131
2132    /// Several toctree parents is an *info*, never a warning: sphinx uses
2133    /// `logger.info`, so it must not be able to fail a `-W` build.
2134    #[test]
2135    fn several_toctree_parents_is_informational() {
2136        let env = graph(
2137            "index",
2138            &[("index", &["a", "b"]), ("a", &["c"]), ("b", &["c"])],
2139        );
2140        let messages = check_consistency(&env, &|_| true);
2141        assert_eq!(messages.len(), 1, "{messages:?}");
2142        assert_eq!(messages[0].level, ConsistencyLevel::Info);
2143        assert_eq!(messages[0].docname, "c");
2144        assert_eq!(
2145            messages[0].message,
2146            "document is referenced in multiple toctrees: ['a', 'b'], selecting: b <- c"
2147        );
2148    }
2149
2150    #[test]
2151    fn docname_join_normalizes() {
2152        assert_eq!(docname_join("sub/b", "c"), "sub/c");
2153        assert_eq!(docname_join("sub/b", "/a"), "a");
2154        assert_eq!(docname_join("sub/b", "../a"), "a");
2155        assert_eq!(docname_join("index", "a"), "a");
2156    }
2157
2158    #[test]
2159    fn document_title_filters_like_a_toc_entry() {
2160        let doctree = parse("A *b* c\n=======\n\nText.\n", "a", &docs(&["a"]));
2161        assert_eq!(
2162            document_title(&doctree).pformat(),
2163            "<title>\n    A \n    <emphasis>\n        b\n     c\n"
2164        );
2165    }
2166
2167    #[test]
2168    fn document_title_without_a_section_says_no_title() {
2169        let doctree = parse("Just a paragraph.\n", "a", &docs(&["a"]));
2170        assert_eq!(
2171            document_title(&doctree).pformat(),
2172            "<title>\n    <no title>\n"
2173        );
2174    }
2175
2176    #[test]
2177    fn toctree_entries_read_back_what_entries_attr_wrote() {
2178        let entries = ResolvedEntries {
2179            entries: vec![
2180                (None, "intro".to_string()),
2181                (Some("Custom <x>".to_string()), "api/it's".to_string()),
2182                (
2183                    Some("say \"hi\"".to_string()),
2184                    "https://example.org/a?b=c".to_string(),
2185                ),
2186                (Some("tab\there".to_string()), "self".to_string()),
2187            ],
2188            ..Default::default()
2189        };
2190        let mut toctree = Node::elem(kinds::TOCTREE, Span::ZERO);
2191        toctree.set("entries", entries.entries_attr());
2192        assert_eq!(toctree_entries(&toctree), entries.entries);
2193    }
2194
2195    /// A three-document project: `index` lists `child`, a URL and an
2196    /// explicitly titled `leaf` at `:maxdepth: 2` with a caption; `child`
2197    /// lists `leaf` itself from under its second-level section.
2198    #[test]
2199    fn resolve_toctree_expands_like_sphinx() {
2200        let found = docs(&["index", "child", "leaf"]);
2201        let mut env = BuildEnvironment::default();
2202        let sources = [
2203            ("index", "Root\n====\n\n.. toctree::\n   :maxdepth: 2\n   :caption: Contents\n\n   child\n   https://example.org\n   Explicit <leaf>\n"),
2204            ("child", "Child\n=====\n\nChild Section\n-------------\n\nChild Sub\n~~~~~~~~~\n\n.. toctree::\n\n   leaf\n"),
2205            ("leaf", "Leaf\n====\n\nLeaf Section\n------------\n"),
2206        ];
2207        let mut doctrees = BTreeMap::new();
2208        for (docname, source) in sources {
2209            let doctree = parse(source, docname, &found);
2210            env.titles
2211                .insert(docname.to_string(), document_title(&doctree));
2212            let (toc, _) = build_toc(&doctree, docname);
2213            for toctree in toctree_copies(&toc) {
2214                note_toctree(&mut env, docname, toctree);
2215            }
2216            env.tocs.insert(docname.to_string(), toc);
2217            doctrees.insert(docname, doctree);
2218        }
2219
2220        let toctree = find_toctrees(&doctrees["index"].root)
2221            .into_iter()
2222            .next()
2223            .expect("index has a toctree");
2224        // The childless copy carries the attributes; resolution reads only those.
2225        let resolved = resolve_toctree(&env, "index", &toctree).expect("renders");
2226        assert_eq!(
2227            resolved.pformat(),
2228            concat!(
2229                "<compact_paragraph toctree=\"1\">\n",
2230                "    <title>\n",
2231                "        Contents\n",
2232                "    <bullet_list>\n",
2233                "        <list_item>\n",
2234                "            <compact_paragraph>\n",
2235                "                <reference anchorname=\"\" internal=\"1\" refuri=\"child\">\n",
2236                "                    Child\n",
2237                "            <bullet_list>\n",
2238                "                <list_item>\n",
2239                "                    <compact_paragraph>\n",
2240                "                        <reference anchorname=\"#child-section\" internal=\"1\" refuri=\"child\">\n",
2241                "                            Child Section\n",
2242                "        <list_item>\n",
2243                "            <compact_paragraph>\n",
2244                "                <reference anchorname=\"\" refuri=\"https://example.org\">\n",
2245                "                    https://example.org\n",
2246                "        <list_item>\n",
2247                "            <compact_paragraph>\n",
2248                "                <reference anchorname=\"\" internal=\"1\" refuri=\"leaf\">\n",
2249                "                    Explicit\n",
2250                "            <bullet_list>\n",
2251                "                <list_item>\n",
2252                "                    <compact_paragraph>\n",
2253                "                        <reference anchorname=\"#leaf-section\" internal=\"1\" refuri=\"leaf\">\n",
2254                "                            Leaf Section\n",
2255            )
2256        );
2257        // (Verified against sphinx-build 9.1.0 on the same three sources:
2258        // the child's own sub-toctree lives under its second-level section
2259        // and is pruned by `:maxdepth: 2`, while the explicit entry keeps
2260        // the leaf's first-level section.)
2261
2262        // A hidden toctree renders to nothing.
2263        let mut hidden = toctree.clone();
2264        hidden.set("hidden", AttrValue::Int(1));
2265        assert!(resolve_toctree(&env, "index", &hidden).is_none());
2266
2267        // `:titlesonly:` keeps the child's title and its sub-toctree only.
2268        let mut titles_only = toctree.clone();
2269        titles_only.set("titlesonly", AttrValue::Int(1));
2270        let resolved = resolve_toctree(&env, "index", &titles_only).expect("renders");
2271        let text = resolved.astext();
2272        assert!(text.contains("Child") && text.contains("Leaf"));
2273        assert!(!text.contains("Child Section"));
2274    }
2275}