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