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}