Skip to main content

sphinx_ultra/env/
py_domain.rs

1//! The `py` domain: object and module registration with Sphinx's
2//! duplicate semantics — `PythonDomain.note_object` / `note_module` /
3//! `clear_doc` / `merge_domaindata`
4//! (`sphinx/domains/python/__init__.py:780-832` and `:744-757`) — and the
5//! resolution half: [`find_obj`] / [`resolve_xref`] (`:855-994`) plus the
6//! [`builtin_resolver`] missing-reference listener (`:1077-1098`).
7//!
8//! Registrations replay from the parse layer's records
9//! ([`crate::rst::RegistryExport::py_objects`]/[`py_modules`]) inside
10//! [`crate::env::std_domain::process_doc`]'s parse-time pass: in Sphinx
11//! every one of these calls fires *while the directive runs*, so a
12//! document's py duplicate warnings interleave with its std
13//! description/term duplicates in document order — probe-verified against
14//! sphinx 9.1.0 (a doc with an envvar duplicate at line 8, a py duplicate
15//! at line 15 and a term duplicate at line 18 warns 8 → 15 → 18).
16//! `PythonDomain` defines **no** `process_doc` hook at all, so the `py`
17//! slot of `_DomainsContainer._process_doc` (dispatch order `c, changeset,
18//! citation, cpp, index, js, math, py, rst, std`) contributes nothing of
19//! its own.
20//!
21//! [`py_modules`]: crate::rst::RegistryExport::py_modules
22
23use std::collections::{BTreeMap, BTreeSet};
24
25use serde::{Deserialize, Serialize};
26
27use crate::env::std_domain::{source_path_of, DocumentIds, DocumentSource};
28use crate::env::BuildEnvironment;
29use crate::error::{BuildWarning, WarningType};
30
31/// Sphinx's `ObjectEntry` (`__init__.py:60-65`), keyed by fullname in
32/// [`PyDomainData::objects`].
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct PyObjectEntry {
35    pub docname: String,
36    pub node_id: String,
37    pub objtype: String,
38    /// `:canonical:` alias registrations carry `true`; resolve-time
39    /// disambiguation prefers non-aliased entries, and the duplicate rules
40    /// below treat aliased entries as overridable.
41    pub aliased: bool,
42}
43
44/// Sphinx's `ModuleEntry` (`__init__.py:67-73`), keyed by module name in
45/// [`PyDomainData::modules`].
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct PyModuleEntry {
48    pub docname: String,
49    pub node_id: String,
50    pub synopsis: String,
51    pub platform: String,
52    pub deprecated: bool,
53}
54
55/// Python-domain (`py`) registries: `domaindata['py']['objects']` and
56/// `['modules']`.
57///
58/// INSERTION-ORDERED, not a plain `BTreeMap`: Sphinx's fuzzy resolution
59/// pass iterates the objects dict in **insertion order**
60/// (`__init__.py:901-908`) and ambiguity takes the FIRST match, with the
61/// candidates listed in match order — lexicographic iteration would
62/// diverge on both the resolved target and the warning bytes whenever
63/// registration order isn't alphabetical. Registration order is the
64/// docname-ordered merge, record order within a document — and Python
65/// dict assignment on an existing key keeps the original insertion slot,
66/// so every overwrite here is **in place** (probe: a `:canonical:` alias
67/// registered between two real definitions keeps its middle slot after
68/// the second definition overwrites it).
69///
70/// The side `*_index` maps give O(log n) exact lookup; they always name
71/// the entry's position in the paired `Vec` and carry no information of
72/// their own.
73#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
74pub struct PyDomainData {
75    /// fullname -> entry, in registration order.
76    pub objects: Vec<(String, PyObjectEntry)>,
77    /// fullname -> index into [`Self::objects`].
78    pub objects_index: BTreeMap<String, usize>,
79    /// modname -> entry, in registration order.
80    pub modules: Vec<(String, PyModuleEntry)>,
81    /// modname -> index into [`Self::modules`].
82    pub modules_index: BTreeMap<String, usize>,
83}
84
85impl PyDomainData {
86    /// `PythonDomain.note_object` (`__init__.py:780-813`). Returns the
87    /// docname of the entry the caller must warn about — `Some` exactly
88    /// when Sphinx's `logger.warning` fires. The aliased-vs-real matrix
89    /// (each cell probe-verified against sphinx 9.1.0):
90    ///
91    /// | existing \ new | real                    | aliased                 |
92    /// |----------------|-------------------------|-------------------------|
93    /// | real           | warn + overwrite        | silent keep (no write)  |
94    /// | aliased        | silent overwrite        | warn + overwrite        |
95    ///
96    /// Overwrites land **in place** (Python dict assignment keeps the
97    /// original insertion slot).
98    pub fn note_object(&mut self, name: &str, entry: PyObjectEntry) -> Option<String> {
99        if let Some(&index) = self.objects_index.get(name) {
100            let other = &self.objects[index].1;
101            if !other.aliased && entry.aliased {
102                // "The original definition is already registered" — the
103                // alias is dropped without touching the real entry.
104                return None;
105            }
106            // `other.aliased && !entry.aliased`: "The original definition
107            // found. Override it!" — silently. Every other combination
108            // falls through Sphinx's `else` and warns; both overwrite.
109            let warn = (other.aliased == entry.aliased).then(|| other.docname.clone());
110            self.objects[index].1 = entry;
111            warn
112        } else {
113            self.objects_index
114                .insert(name.to_string(), self.objects.len());
115            self.objects.push((name.to_string(), entry));
116            None
117        }
118    }
119
120    /// `PythonDomain.note_module` (`__init__.py:819-832`) — an
121    /// unconditional dict assignment: never warns, last value wins, an
122    /// existing name keeps its insertion slot. (The duplicate-module
123    /// *warning* comes from the `note_object(modname, 'module', ...)` call
124    /// `PyModule.run` makes alongside this one.)
125    pub fn note_module(&mut self, name: &str, entry: PyModuleEntry) {
126        if let Some(&index) = self.modules_index.get(name) {
127            self.modules[index].1 = entry;
128        } else {
129            self.modules_index
130                .insert(name.to_string(), self.modules.len());
131            self.modules.push((name.to_string(), entry));
132        }
133    }
134
135    /// `PythonDomain.clear_doc` (`__init__.py:744-751`): drop every entry
136    /// the document owns. Survivors keep their relative order — deleting
137    /// from a Python dict never reorders what stays — and the indices are
138    /// rebuilt to match.
139    pub fn clear_doc(&mut self, docname: &str) {
140        self.objects.retain(|(_, entry)| entry.docname != docname);
141        self.modules.retain(|(_, entry)| entry.docname != docname);
142        self.rebuild_indices();
143    }
144
145    /// `PythonDomain.merge_domaindata` (`__init__.py:753-757`): fold in
146    /// `other`'s entries whose docname is in `docnames`, in `other`'s
147    /// registration order. Like Sphinx's, this is a plain dict assignment
148    /// per entry — no duplicate checks ("XXX check duplicates?"), an
149    /// existing name is overwritten in place, a new one appended.
150    pub fn merge(&mut self, other: &PyDomainData, docnames: &BTreeSet<String>) {
151        for (name, entry) in &other.objects {
152            if !docnames.contains(&entry.docname) {
153                continue;
154            }
155            if let Some(&index) = self.objects_index.get(name) {
156                self.objects[index].1 = entry.clone();
157            } else {
158                self.objects_index.insert(name.clone(), self.objects.len());
159                self.objects.push((name.clone(), entry.clone()));
160            }
161        }
162        for (name, entry) in &other.modules {
163            if !docnames.contains(&entry.docname) {
164                continue;
165            }
166            if let Some(&index) = self.modules_index.get(name) {
167                self.modules[index].1 = entry.clone();
168            } else {
169                self.modules_index.insert(name.clone(), self.modules.len());
170                self.modules.push((name.clone(), entry.clone()));
171            }
172        }
173    }
174
175    fn rebuild_indices(&mut self) {
176        self.objects_index = self
177            .objects
178            .iter()
179            .enumerate()
180            .map(|(index, (name, _))| (name.clone(), index))
181            .collect();
182        self.modules_index = self
183            .modules
184            .iter()
185            .enumerate()
186            .map(|(index, (name, _))| (name.clone(), index))
187            .collect();
188    }
189}
190
191// ---------------------------------------------------------------------------
192// Resolution ([PY §3.2/§3.3/§3.5])
193// ---------------------------------------------------------------------------
194
195/// `PythonDomain.object_types`' keys, in declaration order — the objtype
196/// universe `find_obj` uses when `:any:`-style resolution passes no role
197/// (`type is None` → `list(self.object_types)`).
198const OBJECT_TYPES: &[&str] = &[
199    "function",
200    "data",
201    "class",
202    "exception",
203    "method",
204    "classmethod",
205    "staticmethod",
206    "attribute",
207    "property",
208    "type",
209    "module",
210];
211
212/// `Domain.objtypes_for_role` for the py domain: the `_role2type` reverse
213/// map `Domain.__init__` builds from `object_types` (each ObjType's roles,
214/// appended in `object_types` declaration order). `None` for a role no
215/// ObjType names — `deco` and `const` — which in refspecific search mode
216/// disables the whole candidate walk *and* the fuzzy pass (probe:
217/// `:py:deco:`.mydeco`` never resolves while `:py:deco:`pkg.mydeco``
218/// does).
219pub(crate) fn objtypes_for_role(role: &str) -> Option<&'static [&'static str]> {
220    Some(match role {
221        "func" => &["function"],
222        "data" => &["data"],
223        "class" => &["class", "exception", "type"],
224        "exc" => &["class", "exception"],
225        "meth" => &["method", "classmethod", "staticmethod"],
226        "attr" => &["attribute", "property"],
227        // The "secret role only for internal look-up" behind the
228        // meth→property fallback.
229        "_prop" => &["property"],
230        "type" => &["type"],
231        "mod" => &["module"],
232        "obj" => OBJECT_TYPES,
233        _ => return None,
234    })
235}
236
237/// `Domain.role_for_objtype`: `_role2type`'s inverse — each ObjType's FIRST
238/// role (`sphinx/domains/__init__.py`, `_type2role[name] = roles[0]`).
239/// `None` is the defensive stand-in for an objtype no directive of ours can
240/// register (Sphinx would raise concatenating `'py:' + None`).
241pub(crate) fn role_for_objtype(objtype: &str) -> Option<&'static str> {
242    Some(match objtype {
243        "function" => "func",
244        "data" => "data",
245        "class" => "class",
246        "exception" => "exc",
247        "method" | "classmethod" | "staticmethod" => "meth",
248        "attribute" | "property" => "attr",
249        "type" => "type",
250        "module" => "mod",
251        _ => return None,
252    })
253}
254
255/// `PythonDomain.find_obj` (`__init__.py:855-928`): find candidates for
256/// `name`, perhaps using the given module/class context. Returns `(fullname,
257/// entry)` pairs in match order.
258///
259/// - The `()` strip is the FIRST statement (`:868`), so every caller —
260///   `resolve_xref`'s fallback retries and a future `resolve_any_xref` —
261///   inherits it.
262/// - **searchmode 0 (exact)**: `name` → `classname.name` → `modname.name` →
263///   `modname.classname.name`, object type NOT checked; a `mod` role takes
264///   only the bare-name match (`:913-915`) — which may be a non-module
265///   object, since the type isn't checked.
266/// - **searchmode 1 (refspecific)**: candidates gated on
267///   [`objtypes_for_role`], reversed order `modname.classname.name` →
268///   `modname.name` → `name`; only when every exact candidate failed, the
269///   fuzzy pass collects each registered object whose fullname ends with
270///   `.name` — iterating [`PyDomainData::objects`] in REGISTRATION order,
271///   which is what makes the ambiguity warning's candidate list and the
272///   first-match winner reproducible.
273pub fn find_obj<'a>(
274    data: &'a PyDomainData,
275    modname: Option<&str>,
276    classname: Option<&str>,
277    name: &str,
278    typ: Option<&str>,
279    searchmode: u8,
280) -> Vec<(String, &'a PyObjectEntry)> {
281    // skip parens
282    let name = name.strip_suffix("()").unwrap_or(name);
283    if name.is_empty() {
284        return Vec::new();
285    }
286    // Python truthiness: an empty modname/classname never joins a candidate.
287    let modname = modname.filter(|m| !m.is_empty());
288    let classname = classname.filter(|c| !c.is_empty());
289
290    let entry_of = |fullname: &str| {
291        data.objects_index
292            .get(fullname)
293            .map(|&index| &data.objects[index].1)
294    };
295
296    let newname: Option<String> = if searchmode == 1 {
297        let objtypes = match typ {
298            None => Some(OBJECT_TYPES),
299            Some(role) => objtypes_for_role(role),
300        };
301        let Some(objtypes) = objtypes else {
302            // A role with no objtypes matches nothing in this mode.
303            return Vec::new();
304        };
305        let gated = |fullname: &str| {
306            entry_of(fullname).is_some_and(|entry| objtypes.contains(&entry.objtype.as_str()))
307        };
308        let qualified = match (modname, classname) {
309            (Some(modname), Some(classname)) => {
310                Some(format!("{modname}.{classname}.{name}")).filter(|fullname| gated(fullname))
311            }
312            _ => None,
313        };
314        if qualified.is_some() {
315            qualified
316        } else if let Some(dotted) = modname
317            .map(|modname| format!("{modname}.{name}"))
318            .filter(|dotted| gated(dotted))
319        {
320            Some(dotted)
321        } else if gated(name) {
322            Some(name.to_string())
323        } else {
324            // "fuzzy" searching mode (`:901-908`), reached only when every
325            // exact candidate failed.
326            let searchname = format!(".{name}");
327            return data
328                .objects
329                .iter()
330                .filter(|(oname, entry)| {
331                    oname.ends_with(&searchname) && objtypes.contains(&entry.objtype.as_str())
332                })
333                .map(|(oname, entry)| (oname.clone(), entry))
334                .collect();
335        }
336    } else {
337        // NOTE: searching for exact match, object type is not considered.
338        if entry_of(name).is_some() {
339            Some(name.to_string())
340        } else if typ == Some("mod") {
341            // only exact matches allowed for modules
342            return Vec::new();
343        } else {
344            [
345                classname.map(|classname| format!("{classname}.{name}")),
346                modname.map(|modname| format!("{modname}.{name}")),
347                match (modname, classname) {
348                    (Some(modname), Some(classname)) => {
349                        Some(format!("{modname}.{classname}.{name}"))
350                    }
351                    _ => None,
352                },
353            ]
354            .into_iter()
355            .flatten()
356            .find(|candidate| entry_of(candidate).is_some())
357        }
358    };
359    newname
360        .map(|newname| {
361            let entry = entry_of(&newname).expect("candidate was just found");
362            vec![(newname, entry)]
363        })
364        .unwrap_or_default()
365}
366
367/// A resolved py cross-reference: what the resolver needs to build the
368/// `reference` node `make_refnode` / `_make_module_refnode` would.
369#[derive(Debug, PartialEq)]
370pub struct PyXrefTarget<'a> {
371    pub docname: &'a str,
372    pub node_id: &'a str,
373    /// `make_refnode`'s title: the matched fullname, or for modules
374    /// `{name}[: {synopsis}][ (deprecated)][ ({platform})]` — deprecated
375    /// BEFORE platform (`_make_module_refnode`, `:1039-1054`; probe: a
376    /// module with all three shows
377    /// `both: Some synopsis. (deprecated) (Unix, Windows)`).
378    pub reftitle: String,
379    /// A module target keeps the content node even when the pending_xref
380    /// carries `pending_xref_condition` children (`:983-984` passes
381    /// `contnode` straight through).
382    pub is_module: bool,
383}
384
385/// `PythonDomain.resolve_xref` minus the node plumbing (`:930-994`): the
386/// type-fallback retries, the ambiguity rule, and the module/object split.
387/// Returns the target (None = dangling, silent here — the warning is the
388/// resolver's) and the ambiguity warning to log, `type='ref',
389/// subtype='python'` → `[ref.python]`, which fires even on a successful
390/// resolution.
391pub fn resolve_xref<'a>(
392    data: &'a PyDomainData,
393    modname: Option<&str>,
394    classname: Option<&str>,
395    reftype: &str,
396    target: &str,
397    searchmode: u8,
398) -> (Option<PyXrefTarget<'a>>, Option<String>) {
399    let retry = |typ: &str| find_obj(data, modname, classname, target, Some(typ), searchmode);
400    let mut matches = retry(reftype);
401    if matches.is_empty() && reftype == "class" {
402        // fallback to data/attr (for type aliases)
403        matches = retry("data");
404        if matches.is_empty() {
405            matches = retry("attr");
406        }
407    }
408    if matches.is_empty() && reftype == "attr" {
409        // fallback to meth (for property; Sphinx 2.4.x)
410        matches = retry("meth");
411    }
412    if matches.is_empty() && reftype == "meth" {
413        // fallback to attr (for property), via the secret `_prop` role.
414        matches = retry("_prop");
415    }
416
417    if matches.is_empty() {
418        return (None, None);
419    }
420    let mut warning = None;
421    let (name, entry) = if matches.len() > 1 {
422        let canonicals: Vec<&(String, &PyObjectEntry)> =
423            matches.iter().filter(|(_, entry)| !entry.aliased).collect();
424        if canonicals.len() == 1 {
425            // Exactly one non-aliased match wins silently.
426            let (name, entry) = canonicals[0];
427            (name.clone(), *entry)
428        } else {
429            warning = Some(format!(
430                "more than one target found for cross-reference {}: {}",
431                crate::utils::py_repr_str(target),
432                matches
433                    .iter()
434                    .map(|(name, _)| name.as_str())
435                    .collect::<Vec<_>>()
436                    .join(", ")
437            ));
438            // ... and the FIRST match (aliased or not) is used (`:981`).
439            let (name, entry) = &matches[0];
440            (name.clone(), *entry)
441        }
442    } else {
443        let (name, entry) = matches.remove(0);
444        (name, entry)
445    };
446
447    if entry.objtype == "module" {
448        (module_xref_target(data, name), warning)
449    } else {
450        (
451            Some(PyXrefTarget {
452                docname: &entry.docname,
453                node_id: &entry.node_id,
454                reftitle: name,
455                is_module: false,
456            }),
457            warning,
458        )
459    }
460}
461
462/// `_make_module_refnode`'s target (`:1039-1054`): the module entry with
463/// the `{name}[: {synopsis}][ (deprecated)][ ({platform})]` reftitle —
464/// deprecated BEFORE platform (probe: a module with all three shows
465/// `both: Some synopsis. (deprecated) (Unix, Windows)`).
466///
467/// Sphinx reads `self.modules[name]` — a module *object* entry is only
468/// ever written alongside its module entry (and cleared with it), so the
469/// lookup cannot miss; `None` is the defensive stand-in for Sphinx's
470/// would-be KeyError.
471fn module_xref_target(data: &PyDomainData, name: String) -> Option<PyXrefTarget<'_>> {
472    let &index = data.modules_index.get(&name)?;
473    let module = &data.modules[index].1;
474    let mut reftitle = name;
475    if !module.synopsis.is_empty() {
476        reftitle.push_str(": ");
477        reftitle.push_str(&module.synopsis);
478    }
479    if module.deprecated {
480        reftitle.push_str(" (deprecated)");
481    }
482    if !module.platform.is_empty() {
483        reftitle.push_str(" (");
484        reftitle.push_str(&module.platform);
485        reftitle.push(')');
486    }
487    Some(PyXrefTarget {
488        docname: &module.docname,
489        node_id: &module.node_id,
490        reftitle,
491        is_module: true,
492    })
493}
494
495/// `PythonDomain.resolve_any_xref` (`__init__.py:996-1037`): always
496/// `find_obj(..., type=None, searchmode=1)`; when there are several
497/// matches, aliased entries are skipped; a module match yields
498/// `('py:mod', module_refnode)`, everything else
499/// `('py:' + role_for_objtype(objtype), refnode)` — in `find_obj`'s match
500/// order, which is what the generic any-resolver's first-wins rule and its
501/// ambiguity candidate list run on.
502pub fn resolve_any_xref<'a>(
503    data: &'a PyDomainData,
504    modname: Option<&str>,
505    classname: Option<&str>,
506    target: &str,
507) -> Vec<(String, PyXrefTarget<'a>)> {
508    let matches = find_obj(data, modname, classname, target, None, 1);
509    let multiple = matches.len() > 1;
510    let mut results = Vec::new();
511    for (name, entry) in matches {
512        if multiple && entry.aliased {
513            // "Skip duplicated matches" (`:1013-1016`).
514            continue;
515        }
516        if entry.objtype == "module" {
517            if let Some(target) = module_xref_target(data, name) {
518                results.push(("py:mod".to_string(), target));
519            }
520        } else if let Some(role) = role_for_objtype(&entry.objtype) {
521            results.push((
522                format!("py:{role}"),
523                PyXrefTarget {
524                    docname: &entry.docname,
525                    node_id: &entry.node_id,
526                    reftitle: name,
527                    is_module: false,
528                },
529            ));
530        }
531    }
532    results
533}
534
535// ---------------------------------------------------------------------------
536// py-modindex ([PY §4]: `PythonModuleIndex.generate`, `__init__.py:620-717`)
537// ---------------------------------------------------------------------------
538
539/// One py-modindex row — Sphinx's 7-field `IndexEntry` NamedTuple
540/// (`sphinx/domains/_index.py:17-52`). `subtype`: 0 = top-level module,
541/// 1 = group head (a parent with listed submodules — possibly a dummy with
542/// every other field empty), 2 = submodule.
543#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
544pub struct ModindexEntry {
545    pub name: String,
546    pub subtype: u8,
547    pub docname: String,
548    pub anchor: String,
549    pub extra: String,
550    pub qualifier: String,
551    pub descr: String,
552}
553
554/// One first-letter group of the module index.
555#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
556pub struct ModindexGroup {
557    pub letter: String,
558    pub entries: Vec<ModindexEntry>,
559}
560
561/// `PythonModuleIndex.generate()`'s `(sorted_content, collapse)`.
562#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
563pub struct PyModindex {
564    pub groups: Vec<ModindexGroup>,
565    pub collapse: bool,
566}
567
568/// `PythonModuleIndex.generate` (`__init__.py:628-717`), verbatim:
569/// `modindex_common_prefix` sorted longest-first (stable, so equal lengths
570/// keep config order); modules sorted by `lower()` (stable over
571/// registration order); the FIRST matching prefix is stripped (and
572/// restored when it swallowed the whole name, clearing `stripped`);
573/// letter buckets key on the first character of the *stripped* name,
574/// lowercased; a submodule (`package != modname` on the stripped name)
575/// gets subtype 2, promoting the bucket's previous entry to a group head
576/// when it IS the parent, or inserting an all-empty dummy parent when no
577/// `prev_modname.startswith(package)` entry preceded it; display names
578/// keep the stripped prefix (`stripped + modname`); `collapse` iff
579/// submodules outnumber top-levels; groups come out letter-sorted.
580pub fn generate_modindex(data: &PyDomainData, common_prefix: &[String]) -> PyModindex {
581    let mut ignores: Vec<&str> = common_prefix.iter().map(String::as_str).collect();
582    ignores.sort_by_key(|prefix| std::cmp::Reverse(prefix.len()));
583
584    let mut modules: Vec<(&str, &PyModuleEntry)> = data
585        .modules
586        .iter()
587        .map(|(name, entry)| (name.as_str(), entry))
588        .collect();
589    modules.sort_by_key(|(name, _)| name.to_lowercase());
590
591    let mut content: BTreeMap<String, Vec<ModindexEntry>> = BTreeMap::new();
592    let mut prev_modname = String::new();
593    let mut num_top_levels = 0usize;
594    for (full_name, module) in &modules {
595        let mut modname = *full_name;
596        let mut stripped = "";
597        for ignore in &ignores {
598            if let Some(rest) = modname.strip_prefix(ignore) {
599                modname = rest;
600                stripped = ignore;
601                break;
602            }
603        }
604        // "we stripped the whole module name?"
605        if modname.is_empty() {
606            (modname, stripped) = (stripped, "");
607        }
608
609        // `modname[0].lower()` — Python would IndexError on a name that is
610        // still empty (an empty module name cannot register here; the guard
611        // is the defensive stand-in).
612        let Some(first) = modname.chars().next() else {
613            continue;
614        };
615        let entries = content
616            .entry(first.to_lowercase().collect::<String>())
617            .or_default();
618
619        let package = modname.split('.').next().unwrap_or(modname);
620        let subtype = if package != modname {
621            // it's a submodule
622            if prev_modname == package {
623                // first submodule - make parent a group head
624                if let Some(last) = entries.last_mut() {
625                    last.subtype = 1;
626                }
627            } else if !prev_modname.starts_with(package) {
628                // submodule without parent in list, add dummy entry
629                entries.push(ModindexEntry {
630                    name: format!("{stripped}{package}"),
631                    subtype: 1,
632                    docname: String::new(),
633                    anchor: String::new(),
634                    extra: String::new(),
635                    qualifier: String::new(),
636                    descr: String::new(),
637                });
638            }
639            2
640        } else {
641            num_top_levels += 1;
642            0
643        };
644
645        entries.push(ModindexEntry {
646            name: format!("{stripped}{modname}"),
647            subtype,
648            docname: module.docname.clone(),
649            anchor: module.node_id.clone(),
650            extra: module.platform.clone(),
651            qualifier: if module.deprecated {
652                "Deprecated".to_string()
653            } else {
654                String::new()
655            },
656            descr: module.synopsis.clone(),
657        });
658        prev_modname = modname.to_string();
659    }
660
661    // "only collapse if number of toplevel modules is larger than number
662    // of submodules".
663    let collapse = modules.len() - num_top_levels < num_top_levels;
664
665    PyModindex {
666        // `sorted(content.items())`: BTreeMap iteration is byte order,
667        // which equals Python's codepoint order for UTF-8 strings.
668        groups: content
669            .into_iter()
670            .map(|(letter, entries)| ModindexGroup { letter, entries })
671            .collect(),
672        collapse,
673    }
674}
675
676/// The `py_modindex` slice of the environment snapshot, mirroring
677/// [`crate::env::genindex::snapshot`]'s serde shape.
678pub fn modindex_snapshot(modindex: &PyModindex) -> serde_json::Value {
679    serde_json::to_value(modindex).unwrap_or(serde_json::Value::Null)
680}
681
682/// The names `inspect.isclass(getattr(builtins, name, None))` accepts under
683/// the pinned oracle toolchain (CPython 3.12, the interpreter every fixture
684/// oracle is generated with): every built-in class, exceptions included —
685/// plus `__loader__`, which getattr happily hands back
686/// (`_frozen_importlib.BuiltinImporter` *is* a class). Sorted for
687/// `binary_search`.
688const BUILTIN_CLASSES: &[&str] = &[
689    "ArithmeticError",
690    "AssertionError",
691    "AttributeError",
692    "BaseException",
693    "BaseExceptionGroup",
694    "BlockingIOError",
695    "BrokenPipeError",
696    "BufferError",
697    "BytesWarning",
698    "ChildProcessError",
699    "ConnectionAbortedError",
700    "ConnectionError",
701    "ConnectionRefusedError",
702    "ConnectionResetError",
703    "DeprecationWarning",
704    "EOFError",
705    "EncodingWarning",
706    "EnvironmentError",
707    "Exception",
708    "ExceptionGroup",
709    "FileExistsError",
710    "FileNotFoundError",
711    "FloatingPointError",
712    "FutureWarning",
713    "GeneratorExit",
714    "IOError",
715    "ImportError",
716    "ImportWarning",
717    "IndentationError",
718    "IndexError",
719    "InterruptedError",
720    "IsADirectoryError",
721    "KeyError",
722    "KeyboardInterrupt",
723    "LookupError",
724    "MemoryError",
725    "ModuleNotFoundError",
726    "NameError",
727    "NotADirectoryError",
728    "NotImplementedError",
729    "OSError",
730    "OverflowError",
731    "PendingDeprecationWarning",
732    "PermissionError",
733    "ProcessLookupError",
734    "RecursionError",
735    "ReferenceError",
736    "ResourceWarning",
737    "RuntimeError",
738    "RuntimeWarning",
739    "StopAsyncIteration",
740    "StopIteration",
741    "SyntaxError",
742    "SyntaxWarning",
743    "SystemError",
744    "SystemExit",
745    "TabError",
746    "TimeoutError",
747    "TypeError",
748    "UnboundLocalError",
749    "UnicodeDecodeError",
750    "UnicodeEncodeError",
751    "UnicodeError",
752    "UnicodeTranslateError",
753    "UnicodeWarning",
754    "UserWarning",
755    "ValueError",
756    "Warning",
757    "ZeroDivisionError",
758    "__loader__",
759    "bool",
760    "bytearray",
761    "bytes",
762    "classmethod",
763    "complex",
764    "dict",
765    "enumerate",
766    "filter",
767    "float",
768    "frozenset",
769    "int",
770    "list",
771    "map",
772    "memoryview",
773    "object",
774    "property",
775    "range",
776    "reversed",
777    "set",
778    "slice",
779    "staticmethod",
780    "str",
781    "super",
782    "tuple",
783    "type",
784    "zip",
785];
786
787/// `_TYPING_ALL = frozenset(typing.__all__)` (`__init__.py:55`) under
788/// CPython 3.12. Sorted for `binary_search`.
789const TYPING_ALL: &[&str] = &[
790    "AbstractSet",
791    "Annotated",
792    "Any",
793    "AnyStr",
794    "AsyncContextManager",
795    "AsyncGenerator",
796    "AsyncIterable",
797    "AsyncIterator",
798    "Awaitable",
799    "BinaryIO",
800    "ByteString",
801    "Callable",
802    "ChainMap",
803    "ClassVar",
804    "Collection",
805    "Concatenate",
806    "Container",
807    "ContextManager",
808    "Coroutine",
809    "Counter",
810    "DefaultDict",
811    "Deque",
812    "Dict",
813    "Final",
814    "ForwardRef",
815    "FrozenSet",
816    "Generator",
817    "Generic",
818    "Hashable",
819    "IO",
820    "ItemsView",
821    "Iterable",
822    "Iterator",
823    "KeysView",
824    "List",
825    "Literal",
826    "LiteralString",
827    "Mapping",
828    "MappingView",
829    "Match",
830    "MutableMapping",
831    "MutableSequence",
832    "MutableSet",
833    "NamedTuple",
834    "Never",
835    "NewType",
836    "NoReturn",
837    "NotRequired",
838    "Optional",
839    "OrderedDict",
840    "ParamSpec",
841    "ParamSpecArgs",
842    "ParamSpecKwargs",
843    "Pattern",
844    "Protocol",
845    "Required",
846    "Reversible",
847    "Self",
848    "Sequence",
849    "Set",
850    "Sized",
851    "SupportsAbs",
852    "SupportsBytes",
853    "SupportsComplex",
854    "SupportsFloat",
855    "SupportsIndex",
856    "SupportsInt",
857    "SupportsRound",
858    "TYPE_CHECKING",
859    "Text",
860    "TextIO",
861    "Tuple",
862    "Type",
863    "TypeAlias",
864    "TypeAliasType",
865    "TypeGuard",
866    "TypeVar",
867    "TypeVarTuple",
868    "TypedDict",
869    "Union",
870    "Unpack",
871    "ValuesView",
872    "assert_never",
873    "assert_type",
874    "cast",
875    "clear_overloads",
876    "dataclass_transform",
877    "final",
878    "get_args",
879    "get_origin",
880    "get_overloads",
881    "get_type_hints",
882    "is_typeddict",
883    "no_type_check",
884    "no_type_check_decorator",
885    "overload",
886    "override",
887    "reveal_type",
888    "runtime_checkable",
889];
890
891/// `builtin_resolver` (`__init__.py:1077-1098`), the py domain's
892/// missing-reference listener at priority 900 — AFTER intersphinx's
893/// default-priority (500) handler, so a builtin name that a loaded
894/// inventory carries resolves externally instead of being silenced (probe:
895/// `:py:class:`int`` with `int` in a mapped inventory renders the external
896/// reference; `:py:class:`bool``, absent from it, is silenced).
897///
898/// `true` means "do not emit nitpicky warnings for built-in types": the
899/// pending_xref is replaced by its content node with no reference wrapper
900/// and no warning — for `class`/`obj` targeting `None`, and for
901/// `class`/`obj`/`exc` targeting a `builtins` class or a `typing` name
902/// (with one leading `typing.` removed).
903pub fn builtin_resolver(reftype: &str, target: &str) -> bool {
904    match reftype {
905        "class" | "obj" if target == "None" => true,
906        "class" | "obj" | "exc" => {
907            BUILTIN_CLASSES.binary_search(&target).is_ok()
908                || TYPING_ALL
909                    .binary_search(&target.strip_prefix("typing.").unwrap_or(target))
910                    .is_ok()
911        }
912        _ => false,
913    }
914}
915
916/// Replay one document's py registrations from the parse layer's records —
917/// the `note_module` + `note_object` calls `PyModule.run` and
918/// `PyObject.add_target_and_index` made while the directives ran, which
919/// our parse layer records instead (the module scope they read lives in
920/// the parser's ref_context, and a `:no-typesetting:` object registers
921/// itself and then vanishes from the tree).
922///
923/// Duplicate warnings join `out` keyed by the registered node's position
924/// in the doctree — the same document-order merge key
925/// [`crate::env::std_domain::process_doc`] uses for its glossary and
926/// description passes, because in Sphinx all three warning streams are
927/// parse-time and interleave in document order (see the module comment).
928///
929/// `note_module` runs before `note_object` for the whole record stream
930/// where Sphinx alternates per directive; the two registries are disjoint
931/// maps and `note_module` never warns, so the difference is unobservable.
932pub(crate) fn collect_registrations(
933    env: &mut BuildEnvironment,
934    doc: &DocumentSource<'_>,
935    ids: &DocumentIds<'_>,
936    warnings: &mut Vec<(usize, BuildWarning)>,
937) {
938    for record in &doc.registry.py_modules {
939        env.py.note_module(
940            &record.name,
941            PyModuleEntry {
942                docname: doc.docname.to_string(),
943                node_id: record.node_id.clone(),
944                synopsis: record.synopsis.clone(),
945                platform: record.platform.clone(),
946                deprecated: record.deprecated,
947            },
948        );
949    }
950    for record in &doc.registry.py_objects {
951        let Some(other) = env.py.note_object(
952            &record.fullname,
953            PyObjectEntry {
954                docname: doc.docname.to_string(),
955                node_id: record.node_id.clone(),
956                objtype: record.objtype.clone(),
957                aliased: record.aliased,
958            },
959        ) else {
960            continue;
961        };
962        let order = ids
963            .get(&record.node_id)
964            .map(|(order, _)| order)
965            .unwrap_or(usize::MAX);
966        warnings.push((
967            order,
968            // [PY §5]: plain `logger.warning` with no type/subtype — no
969            // `[category]` suffix, and no objtype in the text (unlike the
970            // std domain's `duplicate {objtype} description`).
971            BuildWarning::new(
972                source_path_of(doc, record.source),
973                Some(record.lineno as usize),
974                format!(
975                    "duplicate object description of {}, other instance in {}, \
976                     use :no-index: for one of them",
977                    record.fullname, other
978                ),
979                WarningType::DuplicateLabel,
980            )
981            .with_category(None),
982        ));
983    }
984}
985
986#[cfg(test)]
987mod tests {
988    use super::*;
989    use crate::env::std_domain;
990    use crate::rst::{parse_rst_full, ParseOptions};
991    use std::path::PathBuf;
992
993    fn entry(docname: &str, node_id: &str, objtype: &str, aliased: bool) -> PyObjectEntry {
994        PyObjectEntry {
995            docname: docname.to_string(),
996            node_id: node_id.to_string(),
997            objtype: objtype.to_string(),
998            aliased,
999        }
1000    }
1001
1002    fn module_entry(docname: &str, node_id: &str) -> PyModuleEntry {
1003        PyModuleEntry {
1004            docname: docname.to_string(),
1005            node_id: node_id.to_string(),
1006            synopsis: String::new(),
1007            platform: String::new(),
1008            deprecated: false,
1009        }
1010    }
1011
1012    /// `(fullname, docname, aliased)` of every object, in iteration order.
1013    fn object_rows(data: &PyDomainData) -> Vec<(&str, &str, bool)> {
1014        data.objects
1015            .iter()
1016            .map(|(name, e)| (name.as_str(), e.docname.as_str(), e.aliased))
1017            .collect()
1018    }
1019
1020    fn assert_indices_consistent(data: &PyDomainData) {
1021        assert_eq!(data.objects_index.len(), data.objects.len());
1022        for (name, &index) in &data.objects_index {
1023            assert_eq!(&data.objects[index].0, name, "objects_index[{name}]");
1024        }
1025        assert_eq!(data.modules_index.len(), data.modules.len());
1026        for (name, &index) in &data.modules_index {
1027            assert_eq!(&data.modules[index].0, name, "modules_index[{name}]");
1028        }
1029    }
1030
1031    // ---- find_obj ([PY §3.2]) ------------------------------------------
1032
1033    /// The registration order used across the find_obj tests: entries are
1034    /// noted in the order given, never alphabetized.
1035    fn data_of(entries: &[(&str, &str)]) -> PyDomainData {
1036        let mut data = PyDomainData::default();
1037        for (name, objtype) in entries {
1038            data.note_object(name, entry("index", name, objtype, false));
1039        }
1040        data
1041    }
1042
1043    fn names(matches: &[(String, &PyObjectEntry)]) -> Vec<String> {
1044        matches.iter().map(|(name, _)| name.clone()).collect()
1045    }
1046
1047    /// Exact mode tries `name` → `classname.name` → `modname.name` →
1048    /// `modname.classname.name`, first hit wins, objtype never checked.
1049    #[test]
1050    fn exact_mode_walks_the_candidate_chain_in_spec_order() {
1051        let data = data_of(&[
1052            ("m.C.x", "method"),
1053            ("m.x", "function"),
1054            ("C.x", "method"),
1055            ("x", "function"),
1056        ]);
1057        let find = |modname: Option<&str>, classname: Option<&str>| {
1058            names(&find_obj(&data, modname, classname, "x", Some("func"), 0))
1059        };
1060        assert_eq!(find(Some("m"), Some("C")), vec!["x"], "bare name first");
1061        let partial = data_of(&[("m.C.x", "method"), ("m.x", "function"), ("C.x", "method")]);
1062        assert_eq!(
1063            names(&find_obj(
1064                &partial,
1065                Some("m"),
1066                Some("C"),
1067                "x",
1068                Some("func"),
1069                0
1070            )),
1071            vec!["C.x"],
1072            "then classname.name"
1073        );
1074        let partial = data_of(&[("m.C.x", "method"), ("m.x", "function")]);
1075        assert_eq!(
1076            names(&find_obj(
1077                &partial,
1078                Some("m"),
1079                Some("C"),
1080                "x",
1081                Some("func"),
1082                0
1083            )),
1084            vec!["m.x"],
1085            "then modname.name"
1086        );
1087        let partial = data_of(&[("m.C.x", "method")]);
1088        assert_eq!(
1089            names(&find_obj(
1090                &partial,
1091                Some("m"),
1092                Some("C"),
1093                "x",
1094                Some("func"),
1095                0
1096            )),
1097            vec!["m.C.x"],
1098            "then modname.classname.name"
1099        );
1100        assert!(
1101            find_obj(&partial, None, None, "x", Some("func"), 0).is_empty(),
1102            "no context, no prefix candidates"
1103        );
1104    }
1105
1106    /// Exact mode never checks the objtype: a `func` role happily returns a
1107    /// class entry.
1108    #[test]
1109    fn exact_mode_ignores_the_object_type() {
1110        let data = data_of(&[("thing", "class")]);
1111        assert_eq!(
1112            names(&find_obj(&data, None, None, "thing", Some("func"), 0)),
1113            vec!["thing"]
1114        );
1115    }
1116
1117    /// `type == 'mod'`: "only exact matches allowed for modules" — the
1118    /// prefix chain is cut off entirely (probe: `:py:mod:`sub`` under
1119    /// `.. py:currentmodule:: pkg` does NOT find `pkg.sub`). But the
1120    /// bare-name hit itself is still type-unchecked.
1121    #[test]
1122    fn mod_takes_only_the_bare_name_match() {
1123        let data = data_of(&[("pkg.sub", "module")]);
1124        assert!(find_obj(&data, Some("pkg"), None, "sub", Some("mod"), 0).is_empty());
1125        let shadowed = data_of(&[("sub", "function")]);
1126        assert_eq!(
1127            names(&find_obj(
1128                &shadowed,
1129                Some("pkg"),
1130                None,
1131                "sub",
1132                Some("mod"),
1133                0
1134            )),
1135            vec!["sub"],
1136            "the bare-name arm runs before the mod cutoff and skips no types"
1137        );
1138    }
1139
1140    /// The `()` strip is find_obj's FIRST statement, so it applies in both
1141    /// modes and to every candidate shape.
1142    #[test]
1143    fn trailing_parens_are_stripped_before_any_lookup() {
1144        let data = data_of(&[("m.f", "function")]);
1145        assert_eq!(
1146            names(&find_obj(&data, Some("m"), None, "f()", Some("obj"), 0)),
1147            vec!["m.f"],
1148            "exact mode"
1149        );
1150        assert_eq!(
1151            names(&find_obj(&data, None, None, "f()", Some("obj"), 1)),
1152            vec!["m.f"],
1153            "refspecific mode (the fuzzy pass sees the stripped name)"
1154        );
1155        assert!(
1156            find_obj(&data, Some("m"), None, "()", Some("obj"), 0).is_empty(),
1157            "a name that is nothing but parens strips to empty and matches nothing"
1158        );
1159    }
1160
1161    /// searchmode 1 walks `modname.classname.name` → `modname.name` →
1162    /// `name`, each gated on the role's objtypes.
1163    #[test]
1164    fn refspecific_mode_prefers_the_most_qualified_gated_candidate() {
1165        let data = data_of(&[
1166            ("meth", "function"),
1167            ("m.meth", "function"),
1168            ("m.C.meth", "method"),
1169        ]);
1170        assert_eq!(
1171            names(&find_obj(
1172                &data,
1173                Some("m"),
1174                Some("C"),
1175                "meth",
1176                Some("meth"),
1177                1
1178            )),
1179            vec!["m.C.meth"],
1180            "most qualified first"
1181        );
1182        assert_eq!(
1183            names(&find_obj(
1184                &data,
1185                Some("m"),
1186                Some("C"),
1187                "meth",
1188                Some("func"),
1189                1
1190            )),
1191            vec!["m.meth"],
1192            "the objtype gate skips m.C.meth for :func: and lands on m.meth"
1193        );
1194        assert_eq!(
1195            names(&find_obj(&data, None, None, "meth", Some("func"), 1)),
1196            vec!["meth"],
1197            "no context leaves the bare-name candidate"
1198        );
1199    }
1200
1201    /// The fuzzy suffix scan runs ONLY when every exact candidate failed,
1202    /// and iterates in registration order.
1203    #[test]
1204    fn the_fuzzy_pass_is_gated_and_registration_ordered() {
1205        let data = data_of(&[
1206            ("zeta.same", "function"),
1207            ("alpha.same", "function"),
1208            ("beta.same", "class"),
1209        ]);
1210        assert_eq!(
1211            names(&find_obj(&data, None, None, "same", Some("func"), 1)),
1212            vec!["zeta.same", "alpha.same"],
1213            "registration order, objtype-filtered (beta.same is a class)"
1214        );
1215        let with_exact = data_of(&[("zeta.same", "function"), ("same", "function")]);
1216        assert_eq!(
1217            names(&find_obj(&with_exact, None, None, "same", Some("func"), 1)),
1218            vec!["same"],
1219            "an exact bare-name hit suppresses the fuzzy pass"
1220        );
1221        assert!(
1222            find_obj(&data, None, None, "ame", Some("func"), 1).is_empty(),
1223            "the scan matches '.name', never a bare substring"
1224        );
1225    }
1226
1227    /// `objtypes_for_role` returns `None` for `deco`/`const`, which kills
1228    /// the whole refspecific search — no candidates, no fuzzy (probe:
1229    /// `:py:deco:`.mydeco`` dangles while `:py:deco:`pkg.mydeco``
1230    /// resolves through exact mode).
1231    #[test]
1232    fn roles_without_objtypes_match_nothing_in_refspecific_mode() {
1233        let data = data_of(&[("pkg.mydeco", "function")]);
1234        assert!(find_obj(&data, None, None, "mydeco", Some("deco"), 1).is_empty());
1235        assert_eq!(
1236            names(&find_obj(&data, None, None, "pkg.mydeco", Some("deco"), 0)),
1237            vec!["pkg.mydeco"]
1238        );
1239    }
1240
1241    /// `type=None` (a future `:any:`) searches every objtype.
1242    #[test]
1243    fn a_none_type_searches_all_object_types() {
1244        let data = data_of(&[("m.thing", "attribute")]);
1245        assert_eq!(
1246            names(&find_obj(&data, None, None, "thing", None, 1)),
1247            vec!["m.thing"]
1248        );
1249    }
1250
1251    // ---- resolve_xref ([PY §3.3]) --------------------------------------
1252
1253    /// The type-fallback chains: class→data→attr, attr→meth, meth→_prop.
1254    #[test]
1255    fn resolve_xref_walks_the_type_fallback_chains() {
1256        let alias = data_of(&[("Alias", "data")]);
1257        let (found, warning) = resolve_xref(&alias, None, None, "class", "Alias", 0);
1258        assert_eq!(warning, None);
1259        assert_eq!(
1260            found,
1261            Some(PyXrefTarget {
1262                docname: "index",
1263                node_id: "Alias",
1264                reftitle: "Alias".to_string(),
1265                is_module: false,
1266            }),
1267            "a type alias documented as data resolves through :class:"
1268        );
1269
1270        let attr_alias = data_of(&[("A.x", "attribute")]);
1271        let (found, _) = resolve_xref(&attr_alias, None, Some("A"), "class", "x", 0);
1272        assert!(found.is_some(), "class falls back to attr after data");
1273
1274        let prop = data_of(&[("K.oldm", "method"), ("K.prop", "property")]);
1275        let (found, _) = resolve_xref(&prop, None, Some("K"), "attr", "oldm", 0);
1276        assert_eq!(found.unwrap().node_id, "K.oldm", "attr falls back to meth");
1277        let (found, _) = resolve_xref(&prop, None, Some("K"), "meth", "prop", 0);
1278        assert_eq!(
1279            found.unwrap().node_id,
1280            "K.prop",
1281            "meth falls back to property via the secret _prop role"
1282        );
1283    }
1284
1285    /// Ambiguity: the warning carries the candidates comma-joined in match
1286    /// order and the FIRST match wins — the order-distinguishing case
1287    /// (zeta.same registered before alpha.same).
1288    #[test]
1289    fn ambiguity_warns_with_candidates_in_registration_order_and_takes_the_first() {
1290        let data = data_of(&[("zeta.same", "function"), ("alpha.same", "function")]);
1291        let (found, warning) = resolve_xref(&data, None, None, "func", "same", 1);
1292        assert_eq!(
1293            warning.as_deref(),
1294            Some("more than one target found for cross-reference 'same': zeta.same, alpha.same")
1295        );
1296        assert_eq!(found.unwrap().node_id, "zeta.same");
1297    }
1298
1299    /// Exactly one non-aliased match is preferred silently; all-aliased (or
1300    /// several real) candidates warn.
1301    #[test]
1302    fn a_single_non_aliased_match_wins_silently() {
1303        let mut data = PyDomainData::default();
1304        data.note_object("alpha.f", entry("index", "alpha.f", "function", false));
1305        data.note_object("beta.f", entry("index", "alpha.f", "function", true));
1306        let (found, warning) = resolve_xref(&data, None, None, "func", "f", 1);
1307        assert_eq!(warning, None);
1308        assert_eq!(found.unwrap().reftitle, "alpha.f");
1309    }
1310
1311    /// Module targets build the `_make_module_refnode` reftitle:
1312    /// `{name}[: {synopsis}][ (deprecated)][ ({platform})]` — deprecated
1313    /// BEFORE platform (probe `module_both`:
1314    /// `both: Some synopsis. (deprecated) (Unix, Windows)`).
1315    #[test]
1316    fn a_module_target_carries_the_full_reftitle() {
1317        let mut data = PyDomainData::default();
1318        data.note_object("both", entry("index", "module-both", "module", false));
1319        data.note_module(
1320            "both",
1321            PyModuleEntry {
1322                docname: "index".to_string(),
1323                node_id: "module-both".to_string(),
1324                synopsis: "Some synopsis.".to_string(),
1325                platform: "Unix, Windows".to_string(),
1326                deprecated: true,
1327            },
1328        );
1329        let (found, _) = resolve_xref(&data, None, None, "mod", "both", 0);
1330        assert_eq!(
1331            found,
1332            Some(PyXrefTarget {
1333                docname: "index",
1334                node_id: "module-both",
1335                reftitle: "both: Some synopsis. (deprecated) (Unix, Windows)".to_string(),
1336                is_module: true,
1337            })
1338        );
1339    }
1340
1341    // ---- resolve_any_xref ([PY §3.4]) ----------------------------------
1342
1343    /// `:any:` always searches refspecific with `type=None`: every objtype
1344    /// participates, and the result role is `py:` + the objtype's first
1345    /// role — probe `resolve_any_role` (f → py-func, m → py-mod).
1346    #[test]
1347    fn resolve_any_finds_functions_and_modules_with_their_roles() {
1348        let mut data = PyDomainData::default();
1349        data.note_object("m", entry("index", "module-m", "module", false));
1350        data.note_module("m", module_entry("index", "module-m"));
1351        data.note_object("m.f", entry("index", "m.f", "function", false));
1352
1353        let f = resolve_any_xref(&data, Some("m"), None, "f");
1354        assert_eq!(f.len(), 1);
1355        assert_eq!(f[0].0, "py:func");
1356        assert_eq!(f[0].1.reftitle, "m.f");
1357        assert!(!f[0].1.is_module);
1358
1359        let m = resolve_any_xref(&data, Some("m"), None, "m");
1360        assert_eq!(m.len(), 1);
1361        assert_eq!(m[0].0, "py:mod");
1362        assert_eq!(m[0].1.node_id, "module-m");
1363        assert!(m[0].1.is_module);
1364
1365        // find_obj's `()` strip is inherited: `:any:`f()`` resolves.
1366        let parens = resolve_any_xref(&data, Some("m"), None, "f()");
1367        assert_eq!(parens.len(), 1);
1368        assert_eq!(parens[0].1.reftitle, "m.f");
1369    }
1370
1371    /// Aliased entries are skipped when there is more than one match —
1372    /// and kept when they are the ONLY match.
1373    #[test]
1374    fn resolve_any_skips_aliased_entries_only_among_multiple_matches() {
1375        let mut data = PyDomainData::default();
1376        data.note_object("zeta.same", entry("index", "zeta.same", "function", false));
1377        data.note_object("beta.same", entry("index", "zeta.same", "function", true));
1378        data.note_object(
1379            "alpha.same",
1380            entry("index", "alpha.same", "function", false),
1381        );
1382        let results = resolve_any_xref(&data, None, None, "same");
1383        let names: Vec<&str> = results.iter().map(|(_, t)| t.reftitle.as_str()).collect();
1384        assert_eq!(
1385            names,
1386            vec!["zeta.same", "alpha.same"],
1387            "registration order, alias dropped"
1388        );
1389
1390        let mut lone = PyDomainData::default();
1391        lone.note_object("old.name", entry("index", "new_name", "function", true));
1392        let only = resolve_any_xref(&lone, None, None, "name");
1393        assert_eq!(only.len(), 1, "a single aliased match is kept");
1394        assert_eq!(only[0].1.reftitle, "old.name");
1395    }
1396
1397    /// A module candidate carries the full `_make_module_refnode` reftitle
1398    /// (the ambiguity warning renders it verbatim — probe:
1399    /// ``:py:mod:`syn: The syn module.``).
1400    #[test]
1401    fn resolve_any_module_candidates_carry_the_synopsis_reftitle() {
1402        let mut data = PyDomainData::default();
1403        data.note_object("syn", entry("index", "module-syn", "module", false));
1404        data.note_module(
1405            "syn",
1406            PyModuleEntry {
1407                docname: "index".to_string(),
1408                node_id: "module-syn".to_string(),
1409                synopsis: "The syn module.".to_string(),
1410                platform: String::new(),
1411                deprecated: false,
1412            },
1413        );
1414        let results = resolve_any_xref(&data, None, None, "syn");
1415        assert_eq!(results[0].1.reftitle, "syn: The syn module.");
1416    }
1417
1418    // ---- generate_modindex ([PY §4]) -----------------------------------
1419
1420    /// One probe dump row: `(name, subtype, docname, anchor, extra,
1421    /// qualifier, descr)`.
1422    type ModindexRow<'a> = (&'a str, u8, &'a str, &'a str, &'a str, &'a str, &'a str);
1423
1424    /// The rows of each letter group, in the probe dumps' tuple shape.
1425    fn modindex_rows(modindex: &PyModindex) -> Vec<(&str, Vec<ModindexRow<'_>>)> {
1426        modindex
1427            .groups
1428            .iter()
1429            .map(|group| {
1430                (
1431                    group.letter.as_str(),
1432                    group
1433                        .entries
1434                        .iter()
1435                        .map(|e| {
1436                            (
1437                                e.name.as_str(),
1438                                e.subtype,
1439                                e.docname.as_str(),
1440                                e.anchor.as_str(),
1441                                e.extra.as_str(),
1442                                e.qualifier.as_str(),
1443                                e.descr.as_str(),
1444                            )
1445                        })
1446                        .collect(),
1447                )
1448            })
1449            .collect()
1450    }
1451
1452    fn modindex_module(
1453        docname: &str,
1454        name: &str,
1455        synopsis: &str,
1456        platform: &str,
1457        deprecated: bool,
1458    ) -> PyModuleEntry {
1459        PyModuleEntry {
1460            docname: docname.to_string(),
1461            node_id: format!("module-{name}"),
1462            synopsis: synopsis.to_string(),
1463            platform: platform.to_string(),
1464            deprecated,
1465        }
1466    }
1467
1468    /// The [PY §4] `modindex_shapes` probe, tuple-exact: lower()-sorted
1469    /// walk, parent promotion to subtype 1, the dummy `orphan` parent, and
1470    /// `collapse=False` (5 modules, 2 top-levels: 3 < 2 is false).
1471    #[test]
1472    fn modindex_shapes_reproduces_the_probe_tuples() {
1473        let mut data = PyDomainData::default();
1474        for (name, synopsis, platform, deprecated) in [
1475            ("pkg", "", "", false),
1476            ("pkg.sub", "Sub synopsis.", "", false),
1477            ("pkg.sub2", "", "Windows", false),
1478            ("orphan.child", "", "", false),
1479            ("zzz", "", "", true),
1480        ] {
1481            data.note_module(
1482                name,
1483                modindex_module("index", name, synopsis, platform, deprecated),
1484            );
1485        }
1486        let modindex = generate_modindex(&data, &[]);
1487        assert!(!modindex.collapse);
1488        assert_eq!(
1489            modindex_rows(&modindex),
1490            vec![
1491                (
1492                    "o",
1493                    vec![
1494                        ("orphan", 1, "", "", "", "", ""),
1495                        (
1496                            "orphan.child",
1497                            2,
1498                            "index",
1499                            "module-orphan.child",
1500                            "",
1501                            "",
1502                            ""
1503                        ),
1504                    ]
1505                ),
1506                (
1507                    "p",
1508                    vec![
1509                        ("pkg", 1, "index", "module-pkg", "", "", ""),
1510                        (
1511                            "pkg.sub",
1512                            2,
1513                            "index",
1514                            "module-pkg.sub",
1515                            "",
1516                            "",
1517                            "Sub synopsis."
1518                        ),
1519                        ("pkg.sub2", 2, "index", "module-pkg.sub2", "Windows", "", ""),
1520                    ]
1521                ),
1522                (
1523                    "z",
1524                    vec![("zzz", 0, "index", "module-zzz", "", "Deprecated", "")]
1525                ),
1526            ]
1527        );
1528    }
1529
1530    /// The [PY §4] `modindex_common_prefix` probe: prefix-stripped modules
1531    /// keep their full display name but sort/bucket by the stripped name
1532    /// and count as top-level — `collapse=True` (3 − 3 = 0 < 3).
1533    #[test]
1534    fn modindex_common_prefix_strips_for_bucketing_but_displays_full_names() {
1535        let mut data = PyDomainData::default();
1536        for name in ["pkg.aaa", "pkg.bbb", "other"] {
1537            data.note_module(name, modindex_module("index", name, "", "", false));
1538        }
1539        let modindex = generate_modindex(&data, &["pkg.".to_string()]);
1540        assert!(modindex.collapse);
1541        assert_eq!(
1542            modindex_rows(&modindex),
1543            vec![
1544                (
1545                    "a",
1546                    vec![("pkg.aaa", 0, "index", "module-pkg.aaa", "", "", "")]
1547                ),
1548                (
1549                    "b",
1550                    vec![("pkg.bbb", 0, "index", "module-pkg.bbb", "", "", "")]
1551                ),
1552                ("o", vec![("other", 0, "index", "module-other", "", "", "")]),
1553            ]
1554        );
1555    }
1556
1557    /// A prefix that swallows a whole module name is restored with
1558    /// `stripped` cleared, and the longest prefix wins (stable sort by
1559    /// length, descending) — probe `restore_and_longest`, tuple-exact.
1560    #[test]
1561    fn modindex_prefix_stripping_restores_emptied_names_and_prefers_longer() {
1562        let mut data = PyDomainData::default();
1563        for name in ["pkg", "pkgx", "pkg.deep.mod"] {
1564            data.note_module(name, modindex_module("index", name, "", "", false));
1565        }
1566        let modindex = generate_modindex(&data, &["pkg".to_string(), "pkg.deep.".to_string()]);
1567        assert_eq!(
1568            modindex_rows(&modindex),
1569            vec![
1570                (
1571                    "m",
1572                    vec![(
1573                        "pkg.deep.mod",
1574                        0,
1575                        "index",
1576                        "module-pkg.deep.mod",
1577                        "",
1578                        "",
1579                        ""
1580                    )]
1581                ),
1582                ("p", vec![("pkg", 0, "index", "module-pkg", "", "", "")]),
1583                ("x", vec![("pkgx", 0, "index", "module-pkgx", "", "", "")]),
1584            ],
1585            "pkg.deep.mod strips the longer prefix; pkg empties and restores \
1586             (bucketed under 'p', not dummy-parented); pkgx buckets under \
1587             its stripped 'x'"
1588        );
1589        assert!(modindex.collapse, "3 - 3 = 0 < 3");
1590    }
1591
1592    // ---- builtin_resolver ([PY §3.5]) ----------------------------------
1593
1594    #[test]
1595    fn builtin_resolver_matches_sphinxs_exact_gates() {
1596        // reftype {class, obj} + None.
1597        assert!(builtin_resolver("class", "None"));
1598        assert!(builtin_resolver("obj", "None"));
1599        assert!(
1600            !builtin_resolver("exc", "None"),
1601            "exc is not in the None gate"
1602        );
1603        // reftype {class, obj, exc} + builtins classes (exceptions included).
1604        assert!(builtin_resolver("class", "int"));
1605        assert!(builtin_resolver("obj", "bool"));
1606        assert!(builtin_resolver("exc", "ValueError"));
1607        assert!(builtin_resolver("class", "__loader__"), "getattr quirk");
1608        // typing names, bare or with ONE `typing.` prefix removed.
1609        assert!(builtin_resolver("class", "Sequence"));
1610        assert!(builtin_resolver("class", "typing.Sequence"));
1611        assert!(builtin_resolver("obj", "Optional"));
1612        assert!(
1613            !builtin_resolver("class", "typing.typing.Sequence"),
1614            "removeprefix strips one prefix only"
1615        );
1616        // Everything else warns.
1617        assert!(!builtin_resolver("class", "Missing"));
1618        assert!(!builtin_resolver("func", "int"), "func is never silenced");
1619        assert!(
1620            !builtin_resolver("data", "int"),
1621            "probe: :py:data:`int` warns"
1622        );
1623        assert!(
1624            !builtin_resolver("exc", "len"),
1625            "a builtin function is not a class"
1626        );
1627    }
1628
1629    // ---- note_object matrix ([PY §5], each cell probe-verified) --------
1630
1631    #[test]
1632    fn real_over_real_warns_and_the_last_definition_wins_in_place() {
1633        let mut py = PyDomainData::default();
1634        py.note_object("other", entry("a", "other", "function", false));
1635        assert_eq!(
1636            py.note_object("dup", entry("a", "dup", "function", false)),
1637            None
1638        );
1639        assert_eq!(
1640            py.note_object("dup", entry("b", "id0", "function", false)),
1641            Some("a".to_string()),
1642            "the second real definition warns naming the first's docname"
1643        );
1644        assert_eq!(
1645            object_rows(&py),
1646            vec![("other", "a", false), ("dup", "b", false)],
1647            "the overwrite lands in the original insertion slot"
1648        );
1649        assert_eq!(py.objects[py.objects_index["dup"]].1.node_id, "id0");
1650        assert_indices_consistent(&py);
1651    }
1652
1653    #[test]
1654    fn an_alias_never_replaces_a_real_definition_and_stays_silent() {
1655        let mut py = PyDomainData::default();
1656        py.note_object("name", entry("a", "name", "function", false));
1657        assert_eq!(
1658            py.note_object("name", entry("b", "alias-id", "function", true)),
1659            None
1660        );
1661        assert_eq!(
1662            py.objects[py.objects_index["name"]].1,
1663            entry("a", "name", "function", false),
1664            "the real entry is untouched"
1665        );
1666    }
1667
1668    #[test]
1669    fn a_real_definition_silently_overrides_an_alias_in_place() {
1670        let mut py = PyDomainData::default();
1671        py.note_object("first", entry("a", "first", "function", false));
1672        py.note_object("name", entry("a", "alias-id", "function", true));
1673        py.note_object("last", entry("a", "last", "function", false));
1674        assert_eq!(
1675            py.note_object("name", entry("b", "name", "function", false)),
1676            None,
1677            "\"The original definition found. Override it!\" — no warning"
1678        );
1679        assert_eq!(
1680            object_rows(&py),
1681            vec![
1682                ("first", "a", false),
1683                ("name", "b", false),
1684                ("last", "a", false)
1685            ],
1686            "the override keeps the alias's insertion slot"
1687        );
1688    }
1689
1690    /// The fourth cell, probe-verified against sphinx 9.1.0: two
1691    /// `:canonical: shared.alias` registrations warn (`duplicate object
1692    /// description of shared.alias, other instance in index, use
1693    /// :no-index: for one of them`) and the later alias wins, keeping the
1694    /// original slot — `note_object` falls through to the warn+overwrite
1695    /// `else` whenever the aliased flags are equal.
1696    #[test]
1697    fn an_alias_over_an_alias_warns_and_overwrites_in_place() {
1698        let mut py = PyDomainData::default();
1699        py.note_object("new_a", entry("index", "new_a", "function", false));
1700        py.note_object("shared.alias", entry("index", "new_a", "function", true));
1701        py.note_object("new_b", entry("index", "new_b", "function", false));
1702        assert_eq!(
1703            py.note_object("shared.alias", entry("index", "new_b", "function", true)),
1704            Some("index".to_string())
1705        );
1706        assert_eq!(
1707            object_rows(&py),
1708            vec![
1709                ("new_a", "index", false),
1710                ("shared.alias", "index", true),
1711                ("new_b", "index", false),
1712            ]
1713        );
1714        assert_eq!(
1715            py.objects[py.objects_index["shared.alias"]].1.node_id,
1716            "new_b"
1717        );
1718    }
1719
1720    // ---- ordering, clear_doc, merge, note_module -----------------------
1721
1722    /// The registration-order contract T10's fuzzy pass builds on:
1723    /// iteration yields entries in the order they were first registered,
1724    /// never alphabetized.
1725    #[test]
1726    fn iteration_preserves_registration_order_not_lexicographic_order() {
1727        let mut py = PyDomainData::default();
1728        py.note_object("zeta.same", entry("a", "zeta.same", "function", false));
1729        py.note_object("alpha.same", entry("a", "alpha.same", "function", false));
1730        assert_eq!(
1731            py.objects
1732                .iter()
1733                .map(|(n, _)| n.as_str())
1734                .collect::<Vec<_>>(),
1735            vec!["zeta.same", "alpha.same"]
1736        );
1737        assert_eq!(py.objects_index["zeta.same"], 0);
1738        assert_eq!(py.objects_index["alpha.same"], 1);
1739    }
1740
1741    #[test]
1742    fn clear_doc_preserves_the_relative_order_of_survivors() {
1743        let mut py = PyDomainData::default();
1744        py.note_object("one", entry("a", "one", "function", false));
1745        py.note_object("two", entry("b", "two", "function", false));
1746        py.note_object("three", entry("a", "three", "class", false));
1747        py.note_object("four", entry("b", "four", "function", false));
1748        py.note_module("amod", module_entry("a", "module-amod"));
1749        py.note_module("bmod", module_entry("b", "module-bmod"));
1750
1751        py.clear_doc("a");
1752
1753        assert_eq!(
1754            object_rows(&py),
1755            vec![("two", "b", false), ("four", "b", false)]
1756        );
1757        assert_eq!(
1758            py.modules
1759                .iter()
1760                .map(|(n, _)| n.as_str())
1761                .collect::<Vec<_>>(),
1762            vec!["bmod"]
1763        );
1764        assert_indices_consistent(&py);
1765
1766        py.clear_doc("b");
1767        assert!(py.objects.is_empty() && py.modules.is_empty());
1768        assert!(py.objects_index.is_empty() && py.modules_index.is_empty());
1769    }
1770
1771    #[test]
1772    fn merge_folds_only_the_named_docnames_in_registration_order() {
1773        let mut ours = PyDomainData::default();
1774        ours.note_object("kept", entry("a", "kept", "function", false));
1775        ours.note_object("both", entry("a", "both", "function", false));
1776
1777        let mut theirs = PyDomainData::default();
1778        theirs.note_object("zeta", entry("b", "zeta", "function", false));
1779        theirs.note_object("both", entry("b", "id0", "function", false));
1780        theirs.note_object("skipped", entry("c", "skipped", "function", false));
1781        theirs.note_module("bmod", module_entry("b", "module-bmod"));
1782        theirs.note_module("cmod", module_entry("c", "module-cmod"));
1783
1784        ours.merge(&theirs, &BTreeSet::from(["b".to_string()]));
1785
1786        assert_eq!(
1787            object_rows(&ours),
1788            vec![
1789                ("kept", "a", false),
1790                // Dict assignment: the existing key keeps its slot, the
1791                // value is theirs. No duplicate warning — sphinx's
1792                // merge_domaindata performs none.
1793                ("both", "b", false),
1794                ("zeta", "b", false),
1795            ]
1796        );
1797        assert_eq!(
1798            ours.modules
1799                .iter()
1800                .map(|(n, _)| n.as_str())
1801                .collect::<Vec<_>>(),
1802            vec!["bmod"]
1803        );
1804        assert_indices_consistent(&ours);
1805    }
1806
1807    #[test]
1808    fn note_module_never_warns_and_the_last_entry_wins_in_place() {
1809        let mut py = PyDomainData::default();
1810        py.note_module("mod", module_entry("a", "module-mod"));
1811        py.note_module("other", module_entry("a", "module-other"));
1812        py.note_module(
1813            "mod",
1814            PyModuleEntry {
1815                docname: "b".to_string(),
1816                node_id: "module-0".to_string(),
1817                synopsis: "S".to_string(),
1818                platform: "P".to_string(),
1819                deprecated: true,
1820            },
1821        );
1822        assert_eq!(
1823            py.modules
1824                .iter()
1825                .map(|(n, e)| (n.as_str(), e.docname.as_str()))
1826                .collect::<Vec<_>>(),
1827            vec![("mod", "b"), ("other", "a")]
1828        );
1829        assert!(py.modules[py.modules_index["mod"]].1.deprecated);
1830    }
1831
1832    // ---- the replay through std_domain::process_doc --------------------
1833
1834    fn parse(source: &str, docname: &str) -> crate::rst::ParseOutput {
1835        parse_rst_full(
1836            source,
1837            &ParseOptions {
1838                source_path: format!("<{docname}>"),
1839                sphinx: true,
1840                docname: docname.to_string(),
1841                found_docs: None,
1842                exclude_patterns: Vec::new(),
1843                py: Default::default(),
1844                srcdir: None,
1845                ..Default::default()
1846            },
1847        )
1848    }
1849
1850    /// Fold sources into a fresh environment through the real per-document
1851    /// orchestration ([`std_domain::process_doc`], which replays the py
1852    /// records) and return it with the warnings.
1853    fn read(sources: &[(&str, &str)]) -> (BuildEnvironment, Vec<BuildWarning>) {
1854        let mut env = BuildEnvironment::default();
1855        let mut warnings = Vec::new();
1856        let doc2path = |docname: &str| PathBuf::from(format!("/src/{docname}.rst"));
1857        for (docname, source) in sources {
1858            let parsed = parse(source, docname);
1859            let path = PathBuf::from(format!("/src/{docname}.rst"));
1860            std_domain::process_doc(
1861                &mut env,
1862                &DocumentSource {
1863                    docname,
1864                    doctree: &parsed.doctree,
1865                    registry: &parsed.registry,
1866                    path: &path,
1867                },
1868                &doc2path,
1869                &mut warnings,
1870            );
1871        }
1872        (env, warnings)
1873    }
1874
1875    /// [PY §5] `duplicate_functions` probe: the second definition's id
1876    /// falls back to `id0`, the warning names the document's own docname
1877    /// with the `:no-index:` hint and no category suffix, and the objects
1878    /// table keeps the LAST definition in the FIRST definition's slot.
1879    #[test]
1880    fn a_py_object_defined_twice_in_one_document_warns_with_the_sphinx_bytes() {
1881        let (env, warnings) = read(&[(
1882            "index",
1883            ".. py:function:: dup()\n\n.. py:function:: dup()\n",
1884        )]);
1885        assert_eq!(
1886            warnings.iter().map(|w| w.render()).collect::<Vec<_>>(),
1887            vec![
1888                "<index>:3: WARNING: duplicate object description of dup, \
1889                 other instance in index, use :no-index: for one of them"
1890            ]
1891        );
1892        assert_eq!(
1893            object_rows(&env.py),
1894            vec![("dup", "index", false)],
1895            "last definition wins"
1896        );
1897        assert_eq!(env.py.objects[0].1.node_id, "id0");
1898    }
1899
1900    /// [PY §5] `duplicate_modules` probe: the module duplicate warns via
1901    /// its `note_object` half (line = the directive's own), while
1902    /// `note_module` silently records the second entry — both tables end
1903    /// on `module-0`.
1904    #[test]
1905    fn a_module_defined_twice_warns_once_and_both_tables_keep_the_second() {
1906        let (env, warnings) =
1907            read(&[("index", ".. py:module:: dupmod\n\n.. py:module:: dupmod\n")]);
1908        assert_eq!(
1909            warnings.iter().map(|w| w.render()).collect::<Vec<_>>(),
1910            vec![
1911                "<index>:3: WARNING: duplicate object description of dupmod, \
1912                 other instance in index, use :no-index: for one of them"
1913            ]
1914        );
1915        assert_eq!(
1916            env.py.objects[env.py.objects_index["dupmod"]].1,
1917            entry("index", "module-0", "module", false)
1918        );
1919        assert_eq!(
1920            env.py.modules[env.py.modules_index["dupmod"]].1,
1921            module_entry("index", "module-0")
1922        );
1923    }
1924
1925    /// Cross-document duplicate: the warning fires from the second
1926    /// document, naming the first — byte-checked against a sphinx 9.1.0
1927    /// dummy build of this pair.
1928    #[test]
1929    fn a_py_duplicate_across_documents_names_the_other_docname() {
1930        let (env, warnings) = read(&[
1931            ("a", ".. py:function:: dup()\n"),
1932            ("b", "B\n=\n\n.. py:function:: dup()\n"),
1933        ]);
1934        assert_eq!(
1935            warnings.iter().map(|w| w.render()).collect::<Vec<_>>(),
1936            vec![
1937                "<b>:4: WARNING: duplicate object description of dup, \
1938                 other instance in a, use :no-index: for one of them"
1939            ]
1940        );
1941        assert_eq!(object_rows(&env.py), vec![("dup", "b", false)]);
1942    }
1943
1944    /// §6 `canonical_function` probe: `:canonical:` registers a second
1945    /// entry under the canonical name with `aliased=True` and the same
1946    /// node id.
1947    #[test]
1948    fn canonical_registers_an_aliased_entry_with_the_same_node_id() {
1949        let (env, warnings) = read(&[(
1950            "index",
1951            ".. py:function:: new_name()\n   :canonical: old.name\n",
1952        )]);
1953        assert!(warnings.is_empty(), "{warnings:?}");
1954        assert_eq!(
1955            env.py.objects,
1956            vec![
1957                (
1958                    "new_name".to_string(),
1959                    entry("index", "new_name", "function", false)
1960                ),
1961                (
1962                    "old.name".to_string(),
1963                    entry("index", "new_name", "function", true)
1964                ),
1965            ]
1966        );
1967    }
1968
1969    /// Cross-domain interleaving, probe-verified against sphinx 9.1.0 on
1970    /// this exact document built twice: an envvar duplicate (line 8), a py
1971    /// duplicate (line 15) and a term duplicate (line 18) warn in
1972    /// DOCUMENT order — all three registrations are parse-time in Sphinx,
1973    /// so no domain's stream comes out grouped.
1974    #[test]
1975    fn py_duplicate_warnings_interleave_with_std_s_in_document_order() {
1976        let document = "Probe\n=====\n\n\
1977                        .. envvar:: STDDUP\n\n\
1978                        .. py:function:: pydup()\n\n\
1979                        .. envvar:: STDDUP\n\n\
1980                        .. glossary::\n\n   \
1981                        gterm\n      First.\n\n\
1982                        .. py:function:: pydup()\n\n\
1983                        .. glossary::\n\n   \
1984                        gterm\n      Second.\n";
1985        let (_, warnings) = read(&[("index", document)]);
1986        assert_eq!(
1987            warnings
1988                .iter()
1989                .map(|warning| (warning.line, warning.message.as_str()))
1990                .collect::<Vec<_>>(),
1991            vec![
1992                (
1993                    Some(8),
1994                    "duplicate envvar description of STDDUP, other instance in index"
1995                ),
1996                (
1997                    Some(15),
1998                    "duplicate object description of pydup, other instance in index, \
1999                     use :no-index: for one of them"
2000                ),
2001                (
2002                    Some(18),
2003                    "duplicate term description of gterm, other instance in index"
2004                ),
2005            ],
2006            "{warnings:?}"
2007        );
2008    }
2009
2010    /// The std domain must not see any of this: a py-only document adds
2011    /// nothing to `env.std`, and a std-only document adds nothing to
2012    /// `env.py` — the guard for "no std behavior change" alongside the
2013    /// wiring this task added to `process_doc`.
2014    #[test]
2015    fn py_and_std_registrations_stay_in_their_own_registries() {
2016        let (env, warnings) = read(&[("index", ".. py:function:: func()\n\n.. envvar:: HOME\n")]);
2017        assert!(warnings.is_empty(), "{warnings:?}");
2018        assert_eq!(object_rows(&env.py), vec![("func", "index", false)]);
2019        assert_eq!(
2020            env.std.objects.keys().collect::<Vec<_>>(),
2021            vec![&("envvar".to_string(), "HOME".to_string())]
2022        );
2023        assert!(env
2024            .std
2025            .objects
2026            .keys()
2027            .all(|(objtype, _)| objtype != "function"));
2028    }
2029}