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}