Skip to main content

sphinx_ultra/env/
std_domain.rs

1//! The `std` domain: cross-reference labels, glossary terms, program
2//! options and generic objects — Sphinx's `StandardDomain`
3//! (`domains/std/__init__.py`).
4//!
5//! This module owns the *collection* half (Sphinx's `process_doc`,
6//! `note_object`, `_note_term`, `add_program_option`); the *resolution*
7//! half lives in [`crate::env::resolve`].
8//!
9//! See `docs/superpowers/plans/2026-08-31-m2-wave4-research-spec-sphinx-env-toctree-domains.md`
10//! §4 for the attribute-by-attribute mapping this port is drawn from.
11
12use std::collections::{BTreeMap, HashMap};
13use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16
17use crate::doctree::{kinds, AttrValue, Doctree, Node};
18use crate::env::numbers::{clean_astext, std_numfig_title};
19use crate::env::BuildEnvironment;
20use crate::error::{BuildWarning, WarningType};
21use crate::rst::RegistryExport;
22
23/// Standard-domain (`std`) registries: cross-reference labels, generic
24/// objects (`:option:`, `:envvar:`, ...), program options, and glossary
25/// terms. Field shapes mirror Sphinx's `StandardDomain.data` exactly
26/// (`domains/std/__init__.py:768-781`).
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct StdDomainData {
29    /// labelname -> (docname, labelid, sectionname).
30    pub labels: BTreeMap<String, (String, String, String)>,
31    /// labelname -> (docname, labelid).
32    pub anonlabels: BTreeMap<String, (String, String)>,
33    /// (objtype, name) -> (docname, labelid).
34    pub objects: BTreeMap<(String, String), (String, String)>,
35    /// (program, optname) -> (docname, labelid).
36    pub progoptions: BTreeMap<(Option<String>, String), (String, String)>,
37    /// lowercased term -> (docname, labelid).
38    pub terms: BTreeMap<String, (String, String)>,
39}
40
41/// The three virtual pages Sphinx's `initial_data` preseeds
42/// (`domains/std/__init__.py:768-781`), plus `py-modindex`, which the
43/// *python* domain adds on top through `Domain.setup`
44/// (`domains/__init__.py:136-142`: every domain index gets a hyperlink
45/// target named `{domain}-{index}`). All four are part of the oracle
46/// contract — a `:ref:` to any of them resolves in an otherwise empty
47/// project.
48const PRESEEDED_LABELS: &[(&str, &str, &str)] = &[
49    ("genindex", "genindex", "Index"),
50    ("modindex", "py-modindex", "Module Index"),
51    ("py-modindex", "py-modindex", "Python Module Index"),
52    ("search", "search", "Search Page"),
53];
54
55impl Default for StdDomainData {
56    fn default() -> Self {
57        let mut labels = BTreeMap::new();
58        let mut anonlabels = BTreeMap::new();
59        for (name, docname, title) in PRESEEDED_LABELS {
60            labels.insert(
61                (*name).to_string(),
62                ((*docname).to_string(), String::new(), (*title).to_string()),
63            );
64            anonlabels.insert((*name).to_string(), ((*docname).to_string(), String::new()));
65        }
66        Self {
67            labels,
68            anonlabels,
69            objects: BTreeMap::new(),
70            progoptions: BTreeMap::new(),
71            terms: BTreeMap::new(),
72        }
73    }
74}
75
76impl StdDomainData {
77    /// `StandardDomain.note_object` (`:848-864`). Returns the docname of a
78    /// previous description of the same object, which the caller reports as
79    /// warning [ENV §8 #2] — note Sphinx names the *docname* here, not
80    /// `doc2path`, and offers no `:no-index:` hint (unlike the py domain).
81    pub fn note_object(
82        &mut self,
83        objtype: &str,
84        name: &str,
85        docname: &str,
86        labelid: &str,
87    ) -> Option<String> {
88        let key = (objtype.to_string(), name.to_string());
89        let previous = self.objects.get(&key).map(|(doc, _)| doc.clone());
90        self.objects
91            .insert(key, (docname.to_string(), labelid.to_string()));
92        previous
93    }
94
95    /// `StandardDomain._note_term` (`:871-878`): a glossary term is an
96    /// object *and* a lowercased entry in `terms`, which is what makes
97    /// `:term:` resolution case-insensitive.
98    pub fn note_term(&mut self, term: &str, docname: &str, labelid: &str) -> Option<String> {
99        let previous = self.note_object("term", term, docname, labelid);
100        self.terms.insert(
101            term.to_lowercase(),
102            (docname.to_string(), labelid.to_string()),
103        );
104        previous
105    }
106
107    /// `StandardDomain.add_program_option` (`:995-1000`) — **first entry
108    /// wins**, unlike every other registry here.
109    pub fn add_program_option(
110        &mut self,
111        program: Option<&str>,
112        name: &str,
113        docname: &str,
114        labelid: &str,
115    ) {
116        self.progoptions
117            .entry((program.map(str::to_string), name.to_string()))
118            .or_insert_with(|| (docname.to_string(), labelid.to_string()));
119    }
120}
121
122/// One document's parse output, as [`process_doc`] consumes it.
123pub struct DocumentSource<'a> {
124    pub docname: &'a str,
125    pub doctree: &'a Doctree,
126    /// docutils `document.nameids`/`nametypes`, harvested at the end of the
127    /// parse.
128    pub registry: &'a RegistryExport,
129    pub path: &'a Path,
130}
131
132/// The path a warning about source-table entry `source` should name —
133/// resolved through the doctree's table, falling back to the document's
134/// own path for an id the table doesn't know.
135pub(crate) fn source_path_of(doc: &DocumentSource<'_>, source: u16) -> PathBuf {
136    doc.doctree
137        .sources
138        .get(source as usize)
139        .map(PathBuf::from)
140        .unwrap_or_else(|| doc.path.to_path_buf())
141}
142
143/// `StandardDomain.process_doc` (`domains/std/__init__.py:937-993`) plus the
144/// registrations Sphinx performs from directives at parse time, which our
145/// parse layer has no domain callbacks to run: glossary terms
146/// (`make_glossary_term`) are replayed from the finished doctree, and the
147/// `option`/`envvar`/`confval` registrations
148/// (`ObjectDescription.add_target_and_index`) from the records the parse
149/// layer kept — see [`RegistryExport::program_options`] for why the doctree
150/// cannot carry those. The **py domain's** registrations
151/// ([`crate::env::py_domain::collect_registrations`]) replay in the same
152/// parse-time pass, because that is where they fire in Sphinx — their
153/// duplicate warnings interleave with std's in document order.
154///
155/// `doc2path` renders another document's source path for the duplicate-label
156/// warning [ENV §8 #1], which names the *path*, not the docname.
157pub fn process_doc(
158    env: &mut BuildEnvironment,
159    doc: &DocumentSource<'_>,
160    doc2path: &dyn Fn(&str) -> PathBuf,
161    warnings: &mut Vec<BuildWarning>,
162) {
163    let ids = DocumentIds::of(doc.doctree);
164    // Order matters, and it is Sphinx's. Glossary terms and object
165    // descriptions — the py domain's included — register *during the
166    // parse* (`make_glossary_term` -> `_note_term`,
167    // `ObjectDescription.add_target_and_index` -> `note_object`), while
168    // `StandardDomain.process_doc`'s label pass runs only once the parse
169    // has finished. So Sphinx's duplicate-term and duplicate-object
170    // warnings always precede the same document's duplicate-label
171    // warnings, and come out interleaved with each other in document
172    // order — across domains too: a document carrying an envvar
173    // duplicate, a py duplicate and a term duplicate warns in document
174    // position order, not grouped by domain (probe-verified against
175    // sphinx 9.1.0; see `py_domain`'s
176    // `py_duplicate_warnings_interleave_with_std_s_in_document_order`).
177    // This crate has no domain callbacks in the parse, so every
178    // registration pass runs here: each replays in its own record
179    // sequence, and the warning streams merge on DOCTREE order — where
180    // each registration's node sits in the finished tree. (The old merge
181    // sorted by line, which only reproduced document order while every
182    // line came from one source; an included file's registrations would
183    // be shuffled into the includer's. Tree order is document order
184    // whatever the source, and a warning's line stays display data.)
185    //
186    // Still not Sphinx: these warnings interleave with the document's
187    // *parse* warnings there, where the builder emits the whole parse
188    // stream before calling this. That is the cross-category ordering the
189    // ledger defers to a later wave.
190    let mut parse_time: Vec<(usize, BuildWarning)> = Vec::new();
191    collect_glossary_terms(env, doc, &ids, &mut parse_time);
192    collect_descriptions(env, doc, &ids, &mut parse_time);
193    crate::env::py_domain::collect_registrations(env, doc, &ids, &mut parse_time);
194    parse_time.sort_by_key(|(order, _)| *order);
195    warnings.extend(parse_time.into_iter().map(|(_, warning)| warning));
196    collect_labels(env, doc, &ids, doc2path, warnings);
197}
198
199/// The label half of `process_doc` (`:938-993`).
200fn collect_labels(
201    env: &mut BuildEnvironment,
202    doc: &DocumentSource<'_>,
203    ids: &DocumentIds<'_>,
204    doc2path: &dyn Fn(&str) -> PathBuf,
205    warnings: &mut Vec<BuildWarning>,
206) {
207    // Sphinx iterates `document.nametypes` — a dict, so in the order names
208    // were registered, which is document order. Our registry is a hash map,
209    // so the order is recovered from where each name's node sits in the
210    // document instead (same sequence, and deterministic either way).
211    let mut named: Vec<(&str, &str, usize, &Node)> = Vec::new();
212    for (name, labelid, explicit) in &doc.registry.nameids {
213        // `if not explicit: continue` / `if labelid is None: continue`.
214        if !explicit {
215            continue;
216        }
217        let Some(labelid) = labelid else { continue };
218        // Sphinx indexes `document.ids[labelid]` unconditionally; a name
219        // whose id names no node in the tree can only mean our parse layer
220        // registered an id it never stamped onto a node, so skip rather
221        // than crash.
222        let Some((order, node)) = ids.get(labelid) else {
223            continue;
224        };
225        named.push((name, labelid, order, node));
226    }
227    named.sort_by_key(|(name, _, order, _)| (*order, *name));
228
229    for (name, labelid, _, node) in named {
230        // "ignore footnote labels, labels automatically generated from a
231        // link and object descriptions" (`:951-958`).
232        if node.kind == kinds::FOOTNOTE
233            || node.get("refuri").is_some()
234            || node.kind.starts_with("desc_")
235        {
236            continue;
237        }
238        if let Some((other, _, _)) = env.std.labels.get(name) {
239            let (source_path, line) = doc.doctree.source_and_line(node.span);
240            warnings.push(
241                BuildWarning::new(
242                    PathBuf::from(source_path),
243                    Some(line as usize),
244                    format!(
245                        "duplicate label {name}, other instance in {}",
246                        doc2path(other).display()
247                    ),
248                    WarningType::DuplicateLabel,
249                )
250                // `logger.warning(...)` with no `type=`/`subtype=`: this one
251                // carries no `[type.subtype]` suffix.
252                .with_category(None),
253            );
254        }
255        env.std.anonlabels.insert(
256            name.to_string(),
257            (doc.docname.to_string(), labelid.to_string()),
258        );
259
260        let Some(sectname) = section_name(node) else {
261            // "anonymous-only labels": an anonlabel, but nothing `:ref:`
262            // can title itself from.
263            continue;
264        };
265        env.std.labels.insert(
266            name.to_string(),
267            (doc.docname.to_string(), labelid.to_string(), sectname),
268        );
269    }
270}
271
272/// The `sectname` ladder of `process_doc` (`:967-992`). `None` is Sphinx's
273/// `continue` — the label stays anonymous-only.
274fn section_name(node: &Node) -> Option<String> {
275    if node.kind == kinds::SECTION {
276        // `title = node[0]` — Sphinx indexes blindly; a section always has
277        // its title first.
278        return Some(clean_astext(node.children.first()?));
279    }
280    if node.kind == "rubric" {
281        return Some(clean_astext(node));
282    }
283    if is_enumerable_node(node) {
284        let title = numfig_title(node).unwrap_or_default();
285        // "if not sectname: continue" — an uncaptioned figure/table/code
286        // block is not titled by its label.
287        return (!title.is_empty()).then_some(title);
288    }
289
290    let mut node = node;
291    if matches!(node.kind, kinds::DEFINITION_LIST | kinds::FIELD_LIST) && !node.children.is_empty()
292    {
293        node = &node.children[0];
294    }
295    if matches!(node.kind, kinds::FIELD | kinds::DEFINITION_LIST_ITEM) {
296        node = node.children.first()?;
297    }
298    if matches!(node.kind, kinds::TERM | kinds::FIELD_NAME) {
299        return Some(clean_astext(node));
300    }
301    // `next(node.findall(addnodes.toctree), None)` with a caption —
302    // `if toctree and toctree.get('caption')`, so a captionless toctree
303    // leaves the label anonymous-only.
304    let toctree = find_first(node, kinds::TOCTREE)?;
305    match toctree.get("caption") {
306        Some(AttrValue::Str(caption)) if !caption.is_empty() && !is_none_sentinel(caption) => {
307            Some(caption.clone())
308        }
309        _ => None,
310    }
311}
312
313/// docutils renders a `None` attribute value as the string `True`
314/// (`nodes.Element.starttag`: a value of `None` prints as `name="True"`),
315/// and our parse layer stores that rendering directly — `toctree[caption]`,
316/// `math_block[label]`/`[number]`, `pending_xref[py:class]`/`[py:module]`
317/// and `pending_xref[std:program]`. A consumer testing such an attribute for
318/// Python truthiness has to treat the sentinel as absent, or a captionless
319/// toctree ends up named "True" and every `:option:` written outside a
320/// `.. program::` looks scoped to a program called "True".
321///
322/// The other attributes read across this crate are not affected: `refuri`,
323/// `refid` and `refname` are presence-tested on `target` nodes, which never
324/// carry the sentinel (our parser sets them only to real values), and the
325/// `desc` attributes are string-valued by construction. Any *new* read of a
326/// possibly-`None` attribute belongs behind this test.
327///
328/// The encoding cannot tell a missing value from the literal string
329/// `"True"`, so `:caption: True` is read as no caption. Fixing that means
330/// giving the parse layer an optional attribute value rather than the
331/// rendered sentinel — a doctree-wide change, not one this module can make.
332pub(crate) fn is_none_sentinel(value: &str) -> bool {
333    value == "True"
334}
335
336/// `StandardDomain.is_enumerable_node` over `enumerable_nodes` (`:798-803`).
337fn is_enumerable_node(node: &Node) -> bool {
338    matches!(node.kind, "figure" | kinds::TABLE | "container")
339}
340
341/// `StandardDomain.get_numfig_title` (`:1366-1378`).
342fn numfig_title(node: &Node) -> Option<String> {
343    is_enumerable_node(node)
344        .then(|| std_numfig_title(node).map(clean_astext))
345        .flatten()
346}
347
348/// Glossary terms: `make_glossary_term` (`domains/std/__init__.py:375-407`)
349/// calls `_note_term(term.astext(), node_id)` while the directive runs. Our
350/// parse layer emits the finished `glossary`/`definition_list` anatomy
351/// without calling back into a domain, so the registration is replayed from
352/// the tree: every `term` carrying an id inside a `definition_list` classed
353/// `glossary`.
354fn collect_glossary_terms(
355    env: &mut BuildEnvironment,
356    doc: &DocumentSource<'_>,
357    ids: &DocumentIds<'_>,
358    warnings: &mut Vec<(usize, BuildWarning)>,
359) {
360    let mut terms: Vec<&Node> = Vec::new();
361    collect_glossary_term_nodes(&doc.doctree.root, &mut terms);
362    for term in terms {
363        let Some(node_id) = term.attrs.ids.first() else {
364            continue;
365        };
366        // `termtext = term.astext()` is taken before the index node is
367        // appended; an `index` node contributes no text either way.
368        let text = term.astext();
369        if let Some(other) = env.std.note_term(&text, doc.docname, node_id) {
370            let (source_path, _) = doc.doctree.source_and_line(term.span);
371            let order = ids
372                .get(node_id)
373                .map(|(order, _)| order)
374                .unwrap_or(usize::MAX);
375            warnings.push((
376                order,
377                duplicate_object_warning(
378                    PathBuf::from(source_path),
379                    glossary_term_line(term),
380                    "term",
381                    &text,
382                    &other,
383                ),
384            ));
385        }
386    }
387}
388
389fn collect_glossary_term_nodes<'a>(node: &'a Node, out: &mut Vec<&'a Node>) {
390    if node.kind == kinds::DEFINITION_LIST
391        && node.attrs.classes.iter().any(|class| class == "glossary")
392    {
393        for item in &node.children {
394            for child in &item.children {
395                if child.kind == kinds::TERM {
396                    out.push(child);
397                }
398            }
399        }
400        return;
401    }
402    for child in &node.children {
403        collect_glossary_term_nodes(child, out);
404    }
405}
406
407/// Object descriptions: Sphinx registers these from inside the directive
408/// (`ObjectDescription.add_target_and_index` → `note_object` /
409/// `add_program_option`, `domains/std/__init__.py:226-330`), against state
410/// the finished doctree does not carry — see
411/// [`RegistryExport::program_options`] — so the parse layer records the
412/// calls and this replays them.
413///
414/// `describe`/`object` produce no records at all: the base
415/// `add_target_and_index` is a no-op, so they contribute neither an object
416/// nor an id.
417fn collect_descriptions(
418    env: &mut BuildEnvironment,
419    doc: &DocumentSource<'_>,
420    ids: &DocumentIds<'_>,
421    warnings: &mut Vec<(usize, BuildWarning)>,
422) {
423    for record in &doc.registry.program_options {
424        env.std.add_program_option(
425            record.program.as_deref(),
426            &record.name,
427            doc.docname,
428            &record.node_id,
429        );
430    }
431    for record in &doc.registry.std_objects {
432        if let Some(other) =
433            env.std
434                .note_object(&record.objtype, &record.name, doc.docname, &record.node_id)
435        {
436            // Tree position of the registered id (the signature node — or,
437            // for `:no-typesetting:`, the target that replaced the desc):
438            // the document-order merge key shared with the glossary pass.
439            let order = ids
440                .get(&record.node_id)
441                .map(|(order, _)| order)
442                .unwrap_or(usize::MAX);
443            warnings.push((
444                order,
445                duplicate_object_warning(
446                    source_path_of(doc, record.source),
447                    record.line as usize,
448                    &record.objtype,
449                    &record.name,
450                    &other,
451                ),
452            ));
453        }
454    }
455}
456
457/// The line Sphinx reports for a glossary term, which is one *less* than
458/// the term's own: `make_glossary_term` is handed the linenos of
459/// `self.content.items`, and a directive's content items carry docutils'
460/// **0-based** line offsets (`content_offset` comes from
461/// `abs_line_offset()`), while everything else in a warning location is
462/// 1-based. Verified against sphinx 9.1.0: a term on source line 8 reports
463/// `b.rst:7`, one on line 11 reports `b.rst:10`.
464fn glossary_term_line(term: &Node) -> usize {
465    (term.span.line as usize).saturating_sub(1)
466}
467
468/// Warning [ENV §8 #2]: `duplicate %s description of %s, other instance in %s`.
469fn duplicate_object_warning(
470    source_path: PathBuf,
471    line: usize,
472    objtype: &str,
473    name: &str,
474    other: &str,
475) -> BuildWarning {
476    BuildWarning::new(
477        source_path,
478        Some(line),
479        format!("duplicate {objtype} description of {name}, other instance in {other}"),
480        WarningType::DuplicateLabel,
481    )
482    .with_category(None)
483}
484
485/// docutils `document.ids` *after* the `PropagateTargets` transform
486/// (`docutils/transforms/references.py:17-95`), which is the map
487/// `process_doc` indexes.
488///
489/// Our parse layer does not run that transform — a `.. _label:` before a
490/// section stays its own `target` node instead of donating its id and name
491/// to the section — so the propagation is replayed here, read-only, over
492/// the tree we do produce. Everything Sphinx's `document.ids` would point at
493/// is therefore reachable by id; only the *serialized* doctree still shows
494/// the unpropagated shape (which is why the oracle's `resolved_pformat` for
495/// such documents is still exempted).
496///
497/// Reachability *by id* is not the whole of the transform, though: a
498/// consumer that reads `node['ids']` straight off the tree still sees the
499/// unpropagated list. [`PropagatedIds`] replays it in that direction, and
500/// is what the numbering passes and `get_fignumber` use.
501pub(crate) struct DocumentIds<'a> {
502    map: HashMap<&'a str, (usize, &'a Node)>,
503}
504
505impl<'a> DocumentIds<'a> {
506    pub(crate) fn of(doctree: &'a Doctree) -> Self {
507        // Document (pre-order) order, exactly the sequence
508        // `Node.next_node(ascend=True)` walks.
509        let mut flat: Vec<FlatNode<'a>> = Vec::new();
510        flatten(&doctree.root, kinds::DOCUMENT, &mut flat);
511
512        let mut map: HashMap<&str, (usize, &Node)> = HashMap::new();
513        for (order, entry) in flat.iter().enumerate() {
514            for id in &entry.node.attrs.ids {
515                map.insert(id.as_str(), (order, entry.node));
516            }
517        }
518
519        // PropagateTargets, in document order so that chained targets
520        // collapse onto the same final node.
521        for donation in propagations(&flat) {
522            for id in donation.ids {
523                map.insert(id.as_str(), (donation.order, donation.receiver));
524            }
525        }
526        Self { map }
527    }
528
529    pub(crate) fn get(&self, id: &str) -> Option<(usize, &'a Node)> {
530        self.map.get(id).copied()
531    }
532
533    /// The node an id names, after propagation.
534    pub(crate) fn node(&self, id: &str) -> Option<&'a Node> {
535        self.map.get(id).map(|(_, node)| *node)
536    }
537}
538
539/// One id donation `PropagateTargets` would make: the node that receives
540/// the ids, where it sits in the pre-order walk, and the donor target's ids.
541struct Donation<'a> {
542    order: usize,
543    receiver: &'a Node,
544    ids: &'a [String],
545}
546
547/// Every donation docutils' `PropagateTargets` (`references.py:17-95`)
548/// would make over `flat`, in document order — so that chained targets
549/// collapse onto the same final node.
550fn propagations<'a>(flat: &[FlatNode<'a>]) -> Vec<Donation<'a>> {
551    let mut donations = Vec::new();
552    for (index, entry) in flat.iter().enumerate() {
553        if !is_propagating_target(entry.node, entry.parent) {
554            continue;
555        }
556        let Some((order, receiver)) = next_propagation_target(flat, index) else {
557            continue;
558        };
559        donations.push(Donation {
560            order,
561            receiver,
562            ids: entry.node.attrs.ids.as_slice(),
563        });
564    }
565    donations
566}
567
568/// `node['ids']` as docutils leaves it once `PropagateTargets` has run
569/// (`references.py:71-72` *extends* the receiving node's list), keyed by
570/// node identity inside one doctree.
571///
572/// [`DocumentIds`] replays the same transform the other way round — id to
573/// node — which is what a lookup by label needs. The numbering passes walk
574/// the tree instead, and both halves of a figure number key off the node's
575/// own id list: `register_fignumber` files the number under
576/// `fignode['ids'][0]` (`collectors/toctree.py:320-336`) and
577/// `get_fignumber` reads it back with `target_node['ids'][0]`
578/// (`domains/std/__init__.py:1395-1422`). Without this, a `.. _label:`
579/// written above a figure/table/code-block — the classic docutils spelling,
580/// as opposed to the `:name:` option — leaves the enumerable node with an
581/// empty `ids`, so it is never numbered and every `:numref:` to it fails.
582///
583/// Still missing, and out of scope here: Sphinx's `AutoNumbering` transform
584/// (`transforms/__init__.py:200-214`), which hands an *implicit* id to a
585/// captioned enumerable node carrying no label at all. Such a node is still
586/// skipped by the numbering walk, where Sphinx numbers it.
587pub(crate) struct PropagatedIds {
588    /// Receiving node's address -> the ids donated to it, in donation
589    /// order. Addresses are stable for as long as the doctree the map was
590    /// built from is borrowed, which is the only window a `PropagatedIds`
591    /// is used in.
592    donations: HashMap<usize, Vec<String>>,
593}
594
595impl PropagatedIds {
596    pub(crate) fn of(doctree: &Doctree) -> Self {
597        let mut flat: Vec<FlatNode<'_>> = Vec::new();
598        flatten(&doctree.root, kinds::DOCUMENT, &mut flat);
599
600        let mut donations: HashMap<usize, Vec<String>> = HashMap::new();
601        for donation in propagations(&flat) {
602            donations
603                .entry(node_identity(donation.receiver))
604                .or_default()
605                .extend(donation.ids.iter().cloned());
606        }
607        Self { donations }
608    }
609
610    /// The node's own ids first, then every id donated to it — the exact
611    /// list `next_node['ids'].extend(target['ids'])` leaves behind.
612    pub(crate) fn effective_ids(&self, node: &Node) -> Vec<String> {
613        let mut ids = node.attrs.ids.clone();
614        if let Some(donated) = self.donations.get(&node_identity(node)) {
615            ids.extend(donated.iter().cloned());
616        }
617        ids
618    }
619}
620
621fn node_identity(node: &Node) -> usize {
622    std::ptr::from_ref(node) as usize
623}
624
625/// One node in the pre-order walk, with what it hangs off and where its
626/// own subtree ends — the index a `descend=False` step jumps to.
627struct FlatNode<'a> {
628    node: &'a Node,
629    parent: &'static str,
630    subtree_end: usize,
631}
632
633fn flatten<'a>(node: &'a Node, parent: &'static str, out: &mut Vec<FlatNode<'a>>) {
634    let index = out.len();
635    out.push(FlatNode {
636        node,
637        parent,
638        // Patched below, once the subtree is laid out.
639        subtree_end: 0,
640    });
641    for child in &node.children {
642        flatten(child, node.kind, out);
643    }
644    out[index].subtree_end = out.len();
645}
646
647/// "Only block-level targets without reference (like `.. _target:`)"
648/// (`references.py:44-49`). `TextElement` parents mean an inline target;
649/// `refid`/`refuri`/`refname` mean the target already points somewhere.
650fn is_propagating_target(node: &Node, parent: &'static str) -> bool {
651    node.kind == kinds::TARGET
652        && !matches!(
653            parent,
654            kinds::PARAGRAPH | kinds::TITLE | kinds::TERM | kinds::FIELD_NAME | "caption"
655        )
656        // `assert len(target) == 0` — docutils only ever propagates a
657        // childless target, so an inline one (which carries its own text)
658        // is excluded whatever its parent element happens to be.
659        && node.children.is_empty()
660        && node.get("refid").is_none()
661        && node.get("refuri").is_none()
662        && node.get("refname").is_none()
663}
664
665/// The node a target donates its ids to: the next node in document order,
666/// skipping `system_message`s, and never an `Invisible`/`Targetable` other
667/// than a `target` (`references.py:50-59`).
668///
669/// The skip is `next_node(ascend=True, descend=False)` — the message's
670/// *sibling*, so its whole subtree is jumped over, not its first child. A
671/// `system_message` always has children (the problem text), so descending
672/// into one would hand the target's ids to a `paragraph` inside a warning
673/// and leave the section behind it unlabelled.
674fn next_propagation_target<'a>(flat: &[FlatNode<'a>], index: usize) -> Option<(usize, &'a Node)> {
675    let mut next = index + 1;
676    while let Some(entry) = flat.get(next) {
677        if entry.node.kind != kinds::SYSTEM_MESSAGE {
678            break;
679        }
680        next = entry.subtree_end;
681    }
682    let node = flat.get(next)?.node;
683    let blocked = matches!(
684        node.kind,
685        kinds::COMMENT
686            | "substitution_definition"
687            | "pending"
688            | kinds::FOOTNOTE
689            | kinds::CITATION
690            | kinds::TEXT
691    );
692    (!blocked).then_some((next, node))
693}
694
695/// The first descendant of `node` with the given kind, in document order.
696fn find_first<'a>(node: &'a Node, kind: &str) -> Option<&'a Node> {
697    if node.kind == kind {
698        return Some(node);
699    }
700    node.children
701        .iter()
702        .find_map(|child| find_first(child, kind))
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708    use crate::rst::{parse_rst_full, ParseOptions};
709
710    fn parse(source: &str, docname: &str) -> crate::rst::ParseOutput {
711        parse_rst_full(
712            source,
713            &ParseOptions {
714                source_path: format!("<{docname}>"),
715                sphinx: true,
716                docname: docname.to_string(),
717                found_docs: None,
718                exclude_patterns: Vec::new(),
719                py: Default::default(),
720                srcdir: None,
721                ..Default::default()
722            },
723        )
724    }
725
726    /// Fold one source into a fresh environment and return the warnings.
727    fn read(sources: &[(&str, &str)]) -> (BuildEnvironment, Vec<BuildWarning>) {
728        let mut env = BuildEnvironment::default();
729        let mut warnings = Vec::new();
730        let doc2path = |docname: &str| PathBuf::from(format!("/src/{docname}.rst"));
731        for (docname, source) in sources {
732            let parsed = parse(source, docname);
733            let path = PathBuf::from(format!("/src/{docname}.rst"));
734            process_doc(
735                &mut env,
736                &DocumentSource {
737                    docname,
738                    doctree: &parsed.doctree,
739                    registry: &parsed.registry,
740                    path: &path,
741                },
742                &doc2path,
743                &mut warnings,
744            );
745        }
746        (env, warnings)
747    }
748
749    /// Sphinx registers glossary terms and object descriptions during the
750    /// parse and runs the label pass afterwards, so a document carrying all
751    /// three kinds of duplicate reports them in source order with the label
752    /// last. Verified against sphinx 9.1.0 on this exact pair of documents.
753    #[test]
754    fn parse_time_diagnostics_precede_label_diagnostics() {
755        let document = ".. envvar:: MYVAR\n\n\
756                        .. glossary::\n\n   \
757                        alpha\n      \
758                        The first.\n\n\
759                        .. _dup:\n\n\
760                        Sec\n---\n\nx\n";
761        let (_, warnings) = read(&[("a", document), ("b", document)]);
762
763        assert_eq!(
764            warnings
765                .iter()
766                .map(|warning| (warning.line, warning.message.as_str()))
767                .collect::<Vec<_>>(),
768            vec![
769                (
770                    Some(1),
771                    "duplicate envvar description of MYVAR, other instance in a"
772                ),
773                (
774                    Some(4),
775                    "duplicate term description of alpha, other instance in a"
776                ),
777                (
778                    Some(11),
779                    "duplicate label dup, other instance in /src/a.rst"
780                ),
781            ],
782            "{warnings:?}"
783        );
784    }
785
786    /// `envvar`/`confval` register std objects from the `desc` anatomy;
787    /// `option` registers program options instead (and never an object);
788    /// `describe`/`object` register nothing at all, because the base
789    /// `ObjectDescription.add_target_and_index` is a no-op. Both tables
790    /// below are the `env.domaindata['std']` a sphinx 9.1.0 dummy build
791    /// produces for this exact source.
792    #[test]
793    fn object_descriptions_register_per_directive() {
794        let (env, warnings) = read(&[(
795            "a",
796            ".. envvar:: HOME_A\n\n\
797             .. confval:: my_setting\n\n\
798             .. program:: myprog\n\n\
799             .. option:: --verbose, -v\n\n\
800             .. program:: None\n\n\
801             .. option:: --global-opt\n\n\
802             .. describe:: widget\n\n\
803             .. object:: thing\n",
804        )]);
805        assert!(warnings.is_empty(), "{warnings:?}");
806        assert_eq!(
807            env.std.objects,
808            [
809                (
810                    ("confval".to_string(), "my_setting".to_string()),
811                    ("a".to_string(), "confval-my_setting".to_string())
812                ),
813                (
814                    ("envvar".to_string(), "HOME_A".to_string()),
815                    ("a".to_string(), "envvar-HOME_A".to_string())
816                ),
817            ]
818            .into_iter()
819            .collect(),
820            "`option` contributes no object, and neither `describe` nor \
821             `object` contributes anything"
822        );
823        assert_eq!(
824            env.std.progoptions,
825            [
826                (
827                    (None, "--global-opt".to_string()),
828                    ("a".to_string(), "cmdoption-global-opt".to_string())
829                ),
830                // Both spellings of one signature register against its FIRST id.
831                (
832                    (Some("myprog".to_string()), "--verbose".to_string()),
833                    ("a".to_string(), "cmdoption-myprog-verbose".to_string())
834                ),
835                (
836                    (Some("myprog".to_string()), "-v".to_string()),
837                    ("a".to_string(), "cmdoption-myprog-verbose".to_string())
838                ),
839            ]
840            .into_iter()
841            .collect()
842        );
843    }
844
845    /// `:no-typesetting:` registers the object and then throws the whole
846    /// `desc` node away, leaving only an `index` node and a bare target
847    /// (`ObjectDescription.run:299-313`) — so nothing about the object is
848    /// left in the doctree to harvest. Verified against a sphinx 9.1.0
849    /// build of this source, which registers all three.
850    #[test]
851    fn no_typesetting_registers_the_object_it_refuses_to_render() {
852        let (env, warnings) = read(&[(
853            "a",
854            ".. confval:: hidden_setting\n   :no-typesetting:\n\n\
855             .. envvar:: HIDDEN\n   :no-typesetting:\n\n\
856             .. option:: --hidden\n   :no-typesetting:\n",
857        )]);
858        assert!(warnings.is_empty(), "{warnings:?}");
859        assert_eq!(
860            env.std.objects,
861            [
862                (
863                    ("confval".to_string(), "hidden_setting".to_string()),
864                    ("a".to_string(), "confval-hidden_setting".to_string())
865                ),
866                (
867                    ("envvar".to_string(), "HIDDEN".to_string()),
868                    ("a".to_string(), "envvar-HIDDEN".to_string())
869                ),
870            ]
871            .into_iter()
872            .collect()
873        );
874        assert_eq!(
875            env.std.progoptions,
876            [(
877                (None, "--hidden".to_string()),
878                ("a".to_string(), "cmdoption-hidden".to_string())
879            )]
880            .into_iter()
881            .collect()
882        );
883    }
884
885    /// `note_object`'s duplicate warning [ENV §8 #2], which the description
886    /// walk raises with the signature's own line — byte-checked against a
887    /// sphinx 9.1.0 build of the same two documents.
888    #[test]
889    fn a_duplicate_object_description_warns_with_the_sphinx_text() {
890        let (_, warnings) = read(&[
891            ("a", ".. envvar:: HOME\n"),
892            ("b", "B\n=\n\n.. envvar:: HOME\n"),
893        ]);
894        assert_eq!(
895            warnings.iter().map(|w| w.render()).collect::<Vec<_>>(),
896            vec![
897                // The location's path comes from the doctree's source
898                // table (the `<b>` the parse stamped), not the document
899                // path handed to `process_doc` — in the real pipeline the
900                // two are the same string; here they differ to pin which
901                // one the warning reads.
902                "<b>:4: WARNING: duplicate envvar description of HOME, \
903                 other instance in a"
904            ]
905        );
906    }
907
908    #[test]
909    fn the_four_virtual_labels_are_preseeded() {
910        let std = StdDomainData::default();
911        assert_eq!(
912            std.labels.get("genindex"),
913            Some(&("genindex".to_string(), String::new(), "Index".to_string()))
914        );
915        assert_eq!(
916            std.labels.get("modindex"),
917            Some(&(
918                "py-modindex".to_string(),
919                String::new(),
920                "Module Index".to_string()
921            ))
922        );
923        assert_eq!(
924            std.labels.get("py-modindex"),
925            Some(&(
926                "py-modindex".to_string(),
927                String::new(),
928                "Python Module Index".to_string()
929            ))
930        );
931        assert_eq!(
932            std.anonlabels.get("search"),
933            Some(&("search".to_string(), String::new()))
934        );
935        assert_eq!(std.anonlabels.len(), 4);
936    }
937
938    #[test]
939    fn a_label_before_a_section_is_titled_by_that_section() {
940        let (env, warnings) = read(&[(
941            "a",
942            "A\n=\n\n.. _dup-label:\n\nSection One\n-----------\n\nText.\n",
943        )]);
944        assert!(warnings.is_empty(), "{warnings:?}");
945        assert_eq!(
946            env.std.labels.get("dup-label"),
947            Some(&(
948                "a".to_string(),
949                "dup-label".to_string(),
950                "Section One".to_string()
951            )),
952            "the target donates its id to the following section, whose \
953             title becomes the label's section name"
954        );
955        assert_eq!(
956            env.std.anonlabels.get("dup-label"),
957            Some(&("a".to_string(), "dup-label".to_string()))
958        );
959        // Implicit section names are never labels: only explicit targets.
960        assert!(!env.std.labels.contains_key("section one"));
961    }
962
963    #[test]
964    fn a_duplicate_label_warns_at_the_second_definition() {
965        let (env, warnings) = read(&[
966            (
967                "a",
968                "A\n=\n\n.. _dup-label:\n\nSection One\n-----------\n\nText.\n",
969            ),
970            (
971                "b",
972                "B\n=\n\n.. _dup-label:\n\nSection Two\n-----------\n\nText.\n",
973            ),
974        ]);
975        assert_eq!(warnings.len(), 1, "{warnings:?}");
976        assert_eq!(
977            warnings[0].render(),
978            "<b>:7: WARNING: duplicate label dup-label, other instance in /src/a.rst",
979            "the location is the *section* the target propagated onto, whose \
980             docutils line is its title underline; its path is the doctree \
981             source table's, not the document path handed to process_doc"
982        );
983        // Last definition wins.
984        assert_eq!(
985            env.std.labels["dup-label"],
986            (
987                "b".to_string(),
988                "dup-label".to_string(),
989                "Section Two".to_string()
990            )
991        );
992    }
993
994    #[test]
995    fn a_label_on_a_captioned_figure_is_titled_by_its_caption() {
996        let (env, _) = read(&[(
997            "a",
998            "A\n=\n\n.. figure:: pic.png\n   :name: fig-a\n\n   The Caption\n",
999        )]);
1000        assert_eq!(
1001            env.std.labels.get("fig-a"),
1002            Some(&(
1003                "a".to_string(),
1004                "fig-a".to_string(),
1005                "The Caption".to_string()
1006            ))
1007        );
1008    }
1009
1010    #[test]
1011    fn a_label_on_an_uncaptioned_enumerable_stays_anonymous_only() {
1012        let (env, _) = read(
1013            &["a"]
1014                .iter()
1015                .map(|d| (*d, "A\n=\n\n.. figure:: pic.png\n   :name: fig-a\n"))
1016                .collect::<Vec<_>>(),
1017        );
1018        assert!(
1019            !env.std.labels.contains_key("fig-a"),
1020            "an uncaptioned figure has no numfig title, so `continue`"
1021        );
1022        assert!(env.std.anonlabels.contains_key("fig-a"));
1023    }
1024
1025    /// The node id keeps the term's case: `make_glossary_term` goes through
1026    /// sphinx's own `_make_id` fork, not docutils' lowercasing `make_id`
1027    /// (the corpus case `sx_directives.glossary_case_and_underscores` pins
1028    /// `term-HTTP_Method` against the oracle).
1029    #[test]
1030    fn glossary_terms_register_as_objects_and_lowercased_terms() {
1031        let (env, warnings) = read(&[(
1032            "a",
1033            "A\n=\n\n.. glossary::\n\n   Environment\n      A thing.\n\n   template engine\n      Another.\n",
1034        )]);
1035        assert!(warnings.is_empty(), "{warnings:?}");
1036        assert_eq!(
1037            env.std
1038                .objects
1039                .get(&("term".to_string(), "Environment".to_string())),
1040            Some(&("a".to_string(), "term-Environment".to_string()))
1041        );
1042        assert_eq!(
1043            env.std.terms.get("environment"),
1044            Some(&("a".to_string(), "term-Environment".to_string())),
1045            "`terms` is keyed by the lowercased term, `objects` by the term as written"
1046        );
1047        assert!(env.std.terms.contains_key("template engine"));
1048        // A glossary term is not a label.
1049        assert_eq!(env.std.labels.len(), PRESEEDED_LABELS.len());
1050    }
1051
1052    /// The location is pinned to what sphinx 9.1.0 actually prints for this
1053    /// exact source, checked by building it with `sphinx -b dummy`:
1054    ///
1055    /// ```text
1056    /// b.rst:5: WARNING: duplicate term description of environment, other instance in a
1057    /// ```
1058    ///
1059    /// The term is on line 6 — see [`glossary_term_line`] for why Sphinx
1060    /// says 5 (the same run reports 7 and 10 for terms on lines 8 and 11).
1061    #[test]
1062    fn a_term_defined_twice_warns_naming_the_other_document() {
1063        let glossary = "A\n=\n\n.. glossary::\n\n   environment\n      A thing.\n";
1064        let (env, warnings) = read(&[("a", glossary), ("b", glossary)]);
1065
1066        assert_eq!(warnings.len(), 1, "{warnings:?}");
1067        assert_eq!(
1068            warnings[0].render(),
1069            "<b>:5: WARNING: duplicate term description of environment, \
1070             other instance in a",
1071            "an object duplicate names the other *docname*, not its path — \
1072             and offers no `:no-index:` hint, unlike the py domain's"
1073        );
1074        assert_eq!(
1075            env.std.terms["environment"],
1076            ("b".to_string(), "term-environment".to_string())
1077        );
1078    }
1079
1080    /// A `system_message` between the target and the section it labels —
1081    /// an unknown directive, say — must be stepped *over*, not into:
1082    /// docutils skips it with `next_node(ascend=True, descend=False)`, so
1083    /// the ids land on the section, not on a paragraph inside the warning.
1084    #[test]
1085    fn a_system_message_between_a_target_and_its_section_is_stepped_over() {
1086        let (env, _) = read(&[(
1087            "a",
1088            "A\n=\n\n.. _lbl:\n\n.. nosuchdirective::\n\n   body\n\nSection\n-------\n\nText.\n",
1089        )]);
1090
1091        assert_eq!(
1092            env.std.labels.get("lbl"),
1093            Some(&("a".to_string(), "lbl".to_string(), "Section".to_string())),
1094            "the label belongs to the section behind the message"
1095        );
1096    }
1097
1098    /// A captionless `toctree` stores docutils' `None` rendering (`"True"`)
1099    /// in its caption attribute; Sphinx tests the real value's truthiness,
1100    /// so the label stays anonymous-only rather than being named "True".
1101    #[test]
1102    fn a_label_on_a_captionless_toctree_is_not_named_by_the_none_sentinel() {
1103        let (env, _) = read(&[("a", "A\n=\n\n.. _lbl:\n\n.. toctree::\n\n   other\n")]);
1104
1105        assert!(
1106            !env.std.labels.contains_key("lbl"),
1107            "got {:?}",
1108            env.std.labels.get("lbl")
1109        );
1110        assert!(env.std.anonlabels.contains_key("lbl"));
1111
1112        // A real caption still names it.
1113        let (env, _) = read(&[(
1114            "a",
1115            "A\n=\n\n.. _lbl:\n\n.. toctree::\n   :caption: Real Caption\n\n   other\n",
1116        )]);
1117        assert_eq!(env.std.labels["lbl"].2, "Real Caption".to_string());
1118    }
1119
1120    #[test]
1121    fn a_label_pointing_at_a_link_target_is_skipped() {
1122        // `.. _elsewhere: https://example.com/` — an external target, which
1123        // Sphinx skips ("labels automatically generated from a link").
1124        let (env, _) = read(&[("a", "A\n=\n\n.. _elsewhere: https://example.com/\n")]);
1125        assert!(!env.std.labels.contains_key("elsewhere"));
1126        assert!(!env.std.anonlabels.contains_key("elsewhere"));
1127    }
1128
1129    #[test]
1130    fn first_program_option_entry_wins() {
1131        let mut std = StdDomainData::default();
1132        std.add_program_option(Some("prog"), "--opt", "a", "cmdoption-prog-opt");
1133        std.add_program_option(Some("prog"), "--opt", "b", "other-id");
1134        assert_eq!(
1135            std.progoptions[&(Some("prog".to_string()), "--opt".to_string())],
1136            ("a".to_string(), "cmdoption-prog-opt".to_string())
1137        );
1138    }
1139
1140    #[test]
1141    fn note_object_reports_the_previous_docname() {
1142        let mut std = StdDomainData::default();
1143        assert_eq!(std.note_object("envvar", "PATH", "a", "envvar-PATH"), None);
1144        assert_eq!(
1145            std.note_object("envvar", "PATH", "b", "envvar-PATH"),
1146            Some("a".to_string())
1147        );
1148        assert_eq!(
1149            std.objects[&("envvar".to_string(), "PATH".to_string())],
1150            ("b".to_string(), "envvar-PATH".to_string()),
1151            "the later description still wins, exactly like Sphinx"
1152        );
1153    }
1154
1155    /// The docutils location conventions the deleted `node_line` used to
1156    /// derive by counting newlines are now STAMPED on `Span::line` by the
1157    /// parser; this pins the stamped values to what `node_line` returned
1158    /// for every kind it covered — plus the overline form, whose reported
1159    /// line is the TITLE line (span first line + 1 = overline + 1),
1160    /// matching the old `line_of(span.start) + 1` arithmetic.
1161    #[test]
1162    fn node_lines_follow_docutils_conventions() {
1163        let source = "Top\n===\n\nUnder\n-----\n\nBody.\n";
1164        let parsed = parse(source, "a");
1165        let doctree = &parsed.doctree;
1166        let top = &doctree.root.children[0];
1167        assert_eq!(
1168            doctree.source_and_line(top.span),
1169            ("<a>", 2),
1170            "section line = its underline"
1171        );
1172        let under = top
1173            .children
1174            .iter()
1175            .find(|c| c.kind == kinds::SECTION)
1176            .unwrap();
1177        assert_eq!(doctree.source_and_line(under.span), ("<a>", 5));
1178        let body = under
1179            .children
1180            .iter()
1181            .find(|c| c.kind == kinds::PARAGRAPH)
1182            .unwrap();
1183        assert_eq!(
1184            doctree.source_and_line(body.span),
1185            ("<a>", 7),
1186            "other nodes: their first line"
1187        );
1188
1189        let overlined = parse(
1190            "=====
1191 Top 
1192=====
1193
1194Body.
1195",
1196            "b",
1197        );
1198        let top = &overlined.doctree.root.children[0];
1199        assert_eq!(
1200            overlined.doctree.source_and_line(top.span),
1201            ("<b>", 2),
1202            "overline form: span first line + 1 = the title line"
1203        );
1204    }
1205}