Skip to main content

sphinx_ultra/env/
resolve.rs

1//! Cross-reference resolution: the `std` half of Sphinx's
2//! `ReferencesResolver` post-transform
3//! (`transforms/post_transforms/__init__.py:60-160`) plus
4//! `StandardDomain.resolve_xref` (`domains/std/__init__.py:1034-1293`) and
5//! the dangling-reference warnings both of them can raise
6//! [ENV §4, §8 #4-#13].
7//!
8//! Sphinx resolves references while *writing* each document, over a fresh
9//! copy of its doctree; this port does the same at the end of the resolve
10//! phase, once numbering has run (`:numref:` reads `env.toc_fignumbers`).
11
12use std::borrow::Cow;
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15
16use crate::doctree::{kinds, AttrValue, Doctree, Node};
17use crate::env::numbers::clean_astext;
18use crate::env::std_domain::{DocumentIds, PropagatedIds};
19use crate::env::toctree::docname_join;
20use crate::env::BuildEnvironment;
21use crate::error::{BuildWarning, WarningType};
22use crate::intersphinx::{self, Diagnostic, HookOutcome, Intersphinx, XrefQuery};
23use crate::utils::py_repr_str;
24
25/// One `pending_xref` to resolve — the attributes Sphinx's resolvers read
26/// off the node.
27#[derive(Clone, Copy)]
28pub struct XrefRequest<'a> {
29    /// The document being resolved (Sphinx's `fromdocname`).
30    pub fromdoc: &'a str,
31    /// `pending_xref['refdoc']`: the document the reference was *written*
32    /// in, which is what a relative `:doc:` target resolves against. Equal
33    /// to `fromdoc` unless the node was copied in from an include.
34    pub refdoc: &'a str,
35    pub reftype: &'a str,
36    pub reftarget: &'a str,
37    pub refexplicit: bool,
38    /// `pending_xref['std:program']`: the `.. program::` in scope where the
39    /// `:option:` reference was written.
40    pub program: Option<&'a str>,
41    /// `contnode.astext()` — the text the parse layer put in the reference.
42    pub contnode_text: &'a str,
43}
44
45/// What resolution did with a reference. Sphinx expresses these three as
46/// "returned a node" / "returned the contnode" / "returned None": the
47/// middle case still counts as *resolved* to the caller, which is why a
48/// `:numref:` that gives up never also raises a dangling-reference warning.
49#[derive(Debug, PartialEq)]
50pub enum XrefOutcome {
51    /// A reference node replaces the `pending_xref`.
52    Resolved(ResolvedXref),
53    /// The content node stays in place. `warning` is the diagnostic the
54    /// resolver logged on its way out (numref's #10-#13), which carries no
55    /// `type`/`subtype` and so renders with no `[category]` suffix.
56    Kept { warning: Option<String> },
57    /// Nothing found: the caller decides whether this warrants a
58    /// dangling-reference warning.
59    Missing,
60}
61
62/// The reference node a successful resolution builds.
63#[derive(Debug, PartialEq)]
64pub struct ResolvedXref {
65    /// `reference`, or `number_reference` for a resolved `:numref:`.
66    pub kind: &'static str,
67    pub refid: Option<String>,
68    pub refuri: Option<String>,
69    /// `number_reference['title']`: the *format*, not the rendered text.
70    pub title: Option<String>,
71    /// `reference['reftitle']`: the hover title `make_refnode` stamps for
72    /// py targets (the matched fullname, or the module title). std's
73    /// resolvers never pass one.
74    pub reftitle: Option<String>,
75    pub inner: Inner,
76}
77
78/// The reference's child node(s).
79#[derive(Debug, PartialEq)]
80pub enum Inner {
81    /// Sphinx's `contnode`: whatever the parse layer produced, reused
82    /// verbatim (`make_refnode(..., contnode)`).
83    Contnode,
84    /// A fresh `inline` node (`build_reference_node`, and the `:doc:`
85    /// caption).
86    Inline { text: String, classes: Vec<String> },
87    /// Existing nodes moved under the reference: the
88    /// `pending_xref_condition(condition='resolved')` children a resolved
89    /// py xref adopts (`PythonDomain.resolve_xref`, `:986-992`).
90    Children(Vec<Node>),
91}
92
93/// Everything resolution reads: the environment, the numbering
94/// configuration, the other documents' doctrees, and the builder's URI
95/// policy.
96pub struct Resolver<'a> {
97    pub env: &'a BuildEnvironment,
98    pub numfig: bool,
99    pub numfig_format: &'a BTreeMap<String, String>,
100    /// A document's doctree, for `:numref:`'s target-node lookup
101    /// (`env.get_doctree(docname).ids`).
102    pub doctree: &'a dyn Fn(&str) -> Option<Cow<'a, Doctree>>,
103    /// `builder.get_relative_uri(from, to)`.
104    pub relative_uri: &'a dyn Fn(&str, &str) -> String,
105    /// The loaded cross-project inventories. An [`Intersphinx::default`]
106    /// (no mapping configured) makes every hook below inert, which is what
107    /// keeps a project without `intersphinx_mapping` byte-identical to what
108    /// it produced before this existed.
109    pub intersphinx: &'a Intersphinx,
110}
111
112impl Resolver<'_> {
113    /// `StandardDomain.resolve_xref` (`:1034-1059`) — the role → resolver
114    /// dispatch table.
115    pub fn resolve_xref(&self, req: &XrefRequest<'_>) -> XrefOutcome {
116        match req.reftype {
117            "ref" => self.resolve_ref(req),
118            "numref" => self.resolve_numref(req),
119            "keyword" => self.resolve_keyword(req),
120            "doc" => self.resolve_doc(req),
121            "option" => self.resolve_option(req),
122            "term" => self.resolve_term(req),
123            _ => self.resolve_obj(req),
124        }
125    }
126
127    /// `_resolve_ref_xref` (`:1061-1085`).
128    fn resolve_ref(&self, req: &XrefRequest<'_>) -> XrefOutcome {
129        let (docname, labelid, sectname) = if req.refexplicit {
130            // A reference to an anonymous label uses the supplied caption.
131            match self.env.std.anonlabels.get(req.reftarget) {
132                Some((docname, labelid)) => (
133                    docname.clone(),
134                    labelid.clone(),
135                    req.contnode_text.to_string(),
136                ),
137                None => return XrefOutcome::Missing,
138            }
139        } else {
140            match self.env.std.labels.get(req.reftarget) {
141                Some((docname, labelid, sectname)) => {
142                    (docname.clone(), labelid.clone(), sectname.clone())
143                }
144                None => return XrefOutcome::Missing,
145            }
146        };
147        if docname.is_empty() {
148            return XrefOutcome::Missing;
149        }
150        XrefOutcome::Resolved(self.build_reference_node(
151            LabelTarget {
152                fromdoc: req.fromdoc,
153                docname: &docname,
154                labelid: &labelid,
155            },
156            &sectname,
157            "ref",
158            kinds::REFERENCE,
159            None,
160        ))
161    }
162
163    /// `_resolve_numref_xref` (`:1087-1170`) — the whole algorithm,
164    /// warnings [ENV §8 #10-#13] included.
165    fn resolve_numref(&self, req: &XrefRequest<'_>) -> XrefOutcome {
166        // `labels` first; an anonymous-only label resolves with no figname.
167        let (docname, labelid, figname) = match self.env.std.labels.get(req.reftarget) {
168            Some((docname, labelid, figname)) => {
169                (docname.clone(), labelid.clone(), Some(figname.clone()))
170            }
171            None => match self.env.std.anonlabels.get(req.reftarget) {
172                Some((docname, labelid)) => (docname.clone(), labelid.clone(), None),
173                None => return XrefOutcome::Missing,
174            },
175        };
176        if docname.is_empty() {
177            return XrefOutcome::Missing;
178        }
179
180        // `env.get_doctree(docname).ids.get(labelid)`: the numbered node
181        // itself, which decides the figtype and owns the number's key.
182        let Some((figtype, target_ids)) = (self.doctree)(&docname).and_then(|doctree| {
183            let ids = DocumentIds::of(&doctree);
184            let node = ids.node(&labelid)?;
185            Some((
186                enumerable_node_type(node).map(str::to_string),
187                // `target_node['ids']`, which for a `.. _label:` written
188                // above the node holds the propagated id — the same key
189                // `assign_figure_numbers` filed the number under.
190                PropagatedIds::of(&doctree).effective_ids(node),
191            ))
192        }) else {
193            return XrefOutcome::Missing;
194        };
195        let Some(figtype) = figtype else {
196            return XrefOutcome::Missing;
197        };
198
199        if figtype != "section" && !self.numfig {
200            return XrefOutcome::Kept {
201                warning: Some("numfig is disabled. :numref: is ignored.".to_string()),
202            };
203        }
204
205        let fignumber = match self.fignumber(&figtype, &docname, &target_ids) {
206            Ok(Some(fignumber)) => fignumber,
207            // `get_fignumber` returning None: the contnode stays, silently.
208            Ok(None) => return XrefOutcome::Kept { warning: None },
209            Err(NoNumber) => {
210                return XrefOutcome::Kept {
211                    warning: Some(format!(
212                        "Failed to create a cross reference. Any number is not assigned: {labelid}"
213                    )),
214                }
215            }
216        };
217
218        let title = if req.refexplicit {
219            req.contnode_text.to_string()
220        } else {
221            self.numfig_format
222                .get(&figtype)
223                .cloned()
224                .unwrap_or_default()
225        };
226        if figname.is_none() && title.contains("{name}") {
227            return XrefOutcome::Kept {
228                warning: Some(format!("the link has no caption: {title}")),
229            };
230        }
231        let fignum: Vec<String> = fignumber.iter().map(u32::to_string).collect();
232        let fignum = fignum.join(".");
233        let newtitle = if title.contains("{name}") || title.contains("number") {
234            // New style (`Fig.{number}`). Sphinx passes `name` to `format`
235            // only `if figname:` — a *truthiness* test, so an empty caption
236            // is formatted without it, and a `{name}` in the title then
237            // raises the KeyError below (the `figname is None` guard above
238            // is the only None-ness test in this algorithm).
239            let named = figname.as_deref().filter(|figname| !figname.is_empty());
240            match format_new_style(&title, named, &fignum) {
241                Ok(newtitle) => newtitle,
242                Err(KeyError(key)) => {
243                    return XrefOutcome::Kept {
244                        warning: Some(format!(
245                            "invalid numfig_format: {title} (KeyError({}))",
246                            py_repr_str(&key)
247                        )),
248                    }
249                }
250            }
251        } else {
252            // Old style (`Fig.%s`).
253            match format_old_style(&title, &fignum) {
254                Ok(newtitle) => newtitle,
255                Err(TypeError) => {
256                    return XrefOutcome::Kept {
257                        warning: Some(format!("invalid numfig_format: {title}")),
258                    }
259                }
260            }
261        };
262
263        XrefOutcome::Resolved(self.build_reference_node(
264            LabelTarget {
265                fromdoc: req.fromdoc,
266                docname: &docname,
267                labelid: &labelid,
268            },
269            &newtitle,
270            "numref",
271            "number_reference",
272            Some(title),
273        ))
274    }
275
276    /// `StandardDomain.get_fignumber` (`:1395-1422`). `Err(NoNumber)` is
277    /// Sphinx's `ValueError`.
278    fn fignumber(
279        &self,
280        figtype: &str,
281        docname: &str,
282        target_ids: &[String],
283    ) -> Result<Option<Vec<u32>>, NoNumber> {
284        if figtype == "section" {
285            // (`builder.name == 'latex'` returns `()` — no latex builder here.)
286            let secnumbers = self.env.toc_secnumbers.get(docname).ok_or(NoNumber)?;
287            let anchorname = format!("#{}", target_ids.first().ok_or(NoNumber)?);
288            return Ok(secnumbers
289                .get(&anchorname)
290                .or_else(|| secnumbers.get(""))
291                .cloned());
292        }
293        // `target_node['ids'][0]` raises IndexError when there is none,
294        // which the caller turns into the same ValueError.
295        let figure_id = target_ids.first().ok_or(NoNumber)?;
296        self.env
297            .toc_fignumbers
298            .get(docname)
299            .and_then(|per_type| per_type.get(figtype))
300            .and_then(|per_id| per_id.get(figure_id))
301            .cloned()
302            .map(Some)
303            .ok_or(NoNumber)
304    }
305
306    /// `_resolve_keyword_xref` (`:1172-1186`): named labels only, and the
307    /// content node is kept as-is.
308    fn resolve_keyword(&self, req: &XrefRequest<'_>) -> XrefOutcome {
309        match self.env.std.labels.get(req.reftarget) {
310            Some((docname, labelid, _)) if !docname.is_empty() => {
311                XrefOutcome::Resolved(self.make_refnode(req.fromdoc, docname, Some(labelid)))
312            }
313            _ => XrefOutcome::Missing,
314        }
315    }
316
317    /// `_resolve_doc_xref` (`:1188-1210`).
318    fn resolve_doc(&self, req: &XrefRequest<'_>) -> XrefOutcome {
319        let docname = docname_join(req.refdoc, req.reftarget);
320        if !self.env.all_docs.contains_key(&docname) {
321            return XrefOutcome::Missing;
322        }
323        let caption = if req.refexplicit {
324            req.contnode_text.to_string()
325        } else {
326            self.env
327                .titles
328                .get(&docname)
329                .map(clean_astext)
330                .unwrap_or_default()
331        };
332        let mut node = self.make_refnode(req.fromdoc, &docname, None);
333        node.inner = Inner::Inline {
334            text: caption,
335            classes: vec!["doc".to_string()],
336        };
337        XrefOutcome::Resolved(node)
338    }
339
340    /// `_resolve_option_xref` (`:1212-1249`): the exact key first, then the
341    /// option-value fallback, then folding leading words into the program
342    /// name.
343    fn resolve_option(&self, req: &XrefRequest<'_>) -> XrefOutcome {
344        let program = req.program.map(str::to_string);
345        let target = req.reftarget.trim();
346
347        let mut found = self.progoption(program.as_deref(), target);
348        if found.is_none() {
349            // `:option:`-foo=bar`` / `-foo[=bar]` / `-foo bar`.
350            for needle in ["=", "[=", " "] {
351                if let Some((stem, _)) = target.split_once(needle) {
352                    found = self.progoption(program.as_deref(), stem);
353                    if found.is_some() {
354                        break;
355                    }
356                }
357            }
358        }
359        if found.is_none() {
360            // `:option:`git add --patch`` -> program `git-add`, option
361            // `--patch`; one word is folded in per round.
362            let mut commands: Vec<&str> = Vec::new();
363            let mut rest = target;
364            while let Some((subcommand, tail)) = split_once_whitespace(rest) {
365                commands.push(subcommand);
366                rest = tail;
367                let progname = commands.join("-");
368                found = self.progoption(Some(&progname), rest);
369                if found.is_some() {
370                    break;
371                }
372            }
373        }
374        match found {
375            Some((docname, labelid)) => {
376                XrefOutcome::Resolved(self.make_refnode(req.fromdoc, &docname, Some(&labelid)))
377            }
378            None => XrefOutcome::Missing,
379        }
380    }
381
382    fn progoption(&self, program: Option<&str>, name: &str) -> Option<(String, String)> {
383        self.env
384            .std
385            .progoptions
386            .get(&(program.map(str::to_string), name.to_string()))
387            .filter(|(docname, _)| !docname.is_empty())
388            .cloned()
389    }
390
391    /// `_resolve_term_xref` (`:1251-1272`): the exact object first, then a
392    /// case-insensitive fallback through `terms`.
393    fn resolve_term(&self, req: &XrefRequest<'_>) -> XrefOutcome {
394        if let XrefOutcome::Resolved(node) = self.resolve_obj(req) {
395            return XrefOutcome::Resolved(node);
396        }
397        match self.env.std.terms.get(&req.reftarget.to_lowercase()) {
398            Some((docname, labelid)) => {
399                XrefOutcome::Resolved(self.make_refnode(req.fromdoc, docname, Some(labelid)))
400            }
401            None => XrefOutcome::Missing,
402        }
403    }
404
405    /// `_resolve_obj_xref` (`:1274-1293`): the first object type this role
406    /// can name that has an entry wins.
407    fn resolve_obj(&self, req: &XrefRequest<'_>) -> XrefOutcome {
408        for objtype in objtypes_for_role(req.reftype) {
409            let key = (objtype.to_string(), req.reftarget.to_string());
410            if let Some((docname, labelid)) = self.env.std.objects.get(&key) {
411                if docname.is_empty() {
412                    break;
413                }
414                return XrefOutcome::Resolved(self.make_refnode(
415                    req.fromdoc,
416                    docname,
417                    Some(labelid),
418                ));
419            }
420        }
421        XrefOutcome::Missing
422    }
423
424    /// `sphinx.util.nodes.make_refnode`, which keeps the content node.
425    fn make_refnode(&self, fromdoc: &str, docname: &str, targetid: Option<&str>) -> ResolvedXref {
426        let mut node = ResolvedXref {
427            kind: kinds::REFERENCE,
428            refid: None,
429            refuri: None,
430            title: None,
431            reftitle: None,
432            inner: Inner::Contnode,
433        };
434        match targetid {
435            Some(targetid) if fromdoc == docname => node.refid = Some(targetid.to_string()),
436            Some(targetid) => {
437                node.refuri = Some(format!(
438                    "{}#{targetid}",
439                    (self.relative_uri)(fromdoc, docname)
440                ));
441            }
442            None => node.refuri = Some((self.relative_uri)(fromdoc, docname)),
443        }
444        node
445    }
446
447    /// The reference node for a resolved py target: [`Self::make_refnode`]
448    /// semantics (Sphinx routes both `_make_module_refnode` and the object
449    /// branch through `sphinx.util.nodes.make_refnode`) plus the
450    /// `reftitle` and, for non-module targets, the
451    /// `pending_xref_condition(condition='resolved')` children when the
452    /// node carries them (`PythonDomain.resolve_xref`, `:983-994`).
453    fn py_refnode(
454        &self,
455        fromdoc: &str,
456        target: crate::env::py_domain::PyXrefTarget<'_>,
457        resolved_children: Option<Vec<Node>>,
458    ) -> ResolvedXref {
459        // `make_refnode`'s targetid test is truthiness, not presence.
460        let targetid = Some(target.node_id).filter(|id| !id.is_empty());
461        let mut node = self.make_refnode(fromdoc, target.docname, targetid);
462        node.reftitle = Some(target.reftitle);
463        if !target.is_module {
464            if let Some(children) = resolved_children {
465                node.inner = Inner::Children(children);
466            }
467        }
468        node
469    }
470
471    /// The candidate walk behind `:any:` —
472    /// `ReferencesResolver._resolve_pending_any_xref`
473    /// (`post_transforms/__init__.py:180-233`) minus the winner-picking and
474    /// warning, which [`resolve_any_ref`] owns. Order is load-bearing (the
475    /// FIRST candidate wins): `:doc:` resolution first (role `'doc'`, no
476    /// `std:` prefix), then `StandardDomain.resolve_any_xref`
477    /// (`std/__init__.py`: `'ref'` with the LOWERCASED target, `'option'`
478    /// with the target as written, then the `objects` walk over
479    /// [`STD_OBJECT_TYPE_ROLES`]), then — `domains.sorted()` is
480    /// alphabetical and only `py` has a resolver here —
481    /// `PythonDomain.resolve_any_xref`.
482    fn resolve_any(
483        &self,
484        req: &XrefRequest<'_>,
485        py_module: Option<&str>,
486        py_class: Option<&str>,
487        resolved_children: Option<&Vec<Node>>,
488    ) -> Vec<AnyCandidate> {
489        let mut results: Vec<AnyCandidate> = Vec::new();
490        let mut push = |role: String, node: ResolvedXref| {
491            // `_stringify`: `node.get('reftitle', node.astext())`.
492            let label = node.reftitle.clone().unwrap_or_else(|| match &node.inner {
493                Inner::Inline { text, .. } => text.clone(),
494                Inner::Contnode => req.contnode_text.to_string(),
495                Inner::Children(children) => children.iter().map(Node::astext).collect(),
496            });
497            results.push(AnyCandidate { role, node, label });
498        };
499
500        // "first, try resolving as :doc:".
501        if let XrefOutcome::Resolved(node) = self.resolve_doc(&XrefRequest {
502            reftype: "doc",
503            ..*req
504        }) {
505            push("doc".to_string(), node);
506        }
507
508        // "next, do the standard domain (makes this a priority)":
509        // StandardDomain.resolve_any_xref. ":ref: lowercases its target
510        // automatically", so the any walk hands it the lowercased form;
511        // "do not try 'keyword'".
512        let ltarget = req.reftarget.to_lowercase();
513        if let XrefOutcome::Resolved(node) = self.resolve_ref(&XrefRequest {
514            reftype: "ref",
515            reftarget: &ltarget,
516            ..*req
517        }) {
518            push("std:ref".to_string(), node);
519        }
520        if let XrefOutcome::Resolved(node) = self.resolve_option(&XrefRequest {
521            reftype: "option",
522            ..*req
523        }) {
524            push("std:option".to_string(), node);
525        }
526        for (objtype, role) in STD_OBJECT_TYPE_ROLES {
527            let name = if *objtype == "term" {
528                // Terms alone are looked up lowercased — which only hits
529                // entries whose as-written form IS lowercase, since the
530                // objects key keeps the term's case (probe: `:any:`Aterm``
531                // and `:any:`aterm`` both dangle against a glossary term
532                // `Aterm`).
533                ltarget.clone()
534            } else {
535                req.reftarget.to_string()
536            };
537            let key = ((*objtype).to_string(), name);
538            if let Some((docname, labelid)) = self.env.std.objects.get(&key) {
539                push(
540                    format!("std:{role}"),
541                    self.make_refnode(req.fromdoc, docname, Some(labelid)),
542                );
543            }
544        }
545
546        // PythonDomain.resolve_any_xref, non-module entries adopting the
547        // `resolved`-condition children exactly like resolve_xref's path.
548        for (role, target) in crate::env::py_domain::resolve_any_xref(
549            &self.env.py,
550            py_module,
551            py_class,
552            req.reftarget,
553        ) {
554            let node = self.py_refnode(req.fromdoc, target, resolved_children.cloned());
555            push(role, node);
556        }
557        results
558    }
559
560    /// `StandardDomain.build_reference_node` (`:1002-1032`), which replaces
561    /// the content node with a fresh `inline` carrying the section name.
562    fn build_reference_node(
563        &self,
564        target: LabelTarget<'_>,
565        sectname: &str,
566        rolename: &str,
567        kind: &'static str,
568        title: Option<String>,
569    ) -> ResolvedXref {
570        let LabelTarget {
571            fromdoc,
572            docname,
573            labelid,
574        } = target;
575        let mut node = ResolvedXref {
576            kind,
577            refid: None,
578            refuri: None,
579            title,
580            reftitle: None,
581            inner: Inner::Inline {
582                text: sectname.to_string(),
583                classes: vec!["std".to_string(), format!("std-{rolename}")],
584            },
585        };
586        // Note this arm does *not* require a non-empty labelid, unlike
587        // `make_refnode`.
588        if docname == fromdoc {
589            node.refid = Some(labelid.to_string());
590        } else {
591            let mut refuri = (self.relative_uri)(fromdoc, docname);
592            if !labelid.is_empty() {
593                refuri.push('#');
594                refuri.push_str(labelid);
595            }
596            node.refuri = Some(refuri);
597        }
598        node
599    }
600}
601
602/// `StandardDomain.object_types` in declaration order (dict order is the
603/// `resolve_any_xref` walk order), paired with each ObjType's first role
604/// (`Domain.role_for_objtype`): term/token/label/confval/envvar/cmdoption/
605/// doc → term/token/ref/confval/envvar/option/doc. Labels and documents
606/// never live in `objects` (they have their own registries), so those two
607/// keys are dead weight carried for fidelity.
608const STD_OBJECT_TYPE_ROLES: &[(&str, &str)] = &[
609    ("term", "term"),
610    ("token", "token"),
611    ("label", "ref"),
612    ("confval", "confval"),
613    ("envvar", "envvar"),
614    ("cmdoption", "option"),
615    ("doc", "doc"),
616];
617
618/// One `:any:` candidate: the role string Sphinx's resolvers hand back
619/// (`'doc'`, `'std:ref'`, `'py:func'`, ...), the node it built, and the
620/// text half of the ambiguity warning's ``:role:`label``` form.
621struct AnyCandidate {
622    role: String,
623    node: ResolvedXref,
624    label: String,
625}
626
627/// The label a reference resolved to, as `build_reference_node` takes it.
628struct LabelTarget<'a> {
629    fromdoc: &'a str,
630    docname: &'a str,
631    labelid: &'a str,
632}
633
634/// Sphinx's `ValueError` out of `get_fignumber`.
635#[derive(Debug)]
636struct NoNumber;
637
638/// Python's `KeyError` out of `str.format`, carrying the missing field.
639#[derive(Debug)]
640struct KeyError(String);
641
642/// Python's `TypeError` out of `%`-formatting.
643#[derive(Debug)]
644struct TypeError;
645
646/// `title.format(name=..., number=...)` for the fields numfig formats can
647/// name. Any other `{field}` is Python's `KeyError`.
648fn format_new_style(title: &str, figname: Option<&str>, fignum: &str) -> Result<String, KeyError> {
649    let mut out = String::with_capacity(title.len());
650    let mut rest = title;
651    while let Some(open) = rest.find('{') {
652        out.push_str(&rest[..open]);
653        let after = &rest[open + 1..];
654        let Some(close) = after.find('}') else {
655            // An unbalanced `{` is a ValueError in Python; Sphinx does not
656            // catch it. Ours keeps the text as written rather than crashing
657            // the build.
658            out.push_str(&rest[open..]);
659            return Ok(out);
660        };
661        let field = &after[..close];
662        match field {
663            // `title.format(number=fignum)` is called *without* `name` when
664            // there is no figname, so `{name}` is a KeyError then.
665            "name" => match figname {
666                Some(figname) => out.push_str(figname),
667                None => return Err(KeyError("name".to_string())),
668            },
669            "number" => out.push_str(fignum),
670            other => return Err(KeyError(other.to_string())),
671        }
672        rest = &after[close + 1..];
673    }
674    out.push_str(rest);
675    Ok(out)
676}
677
678/// `title % fignum` for a single string argument: exactly one `%s`
679/// conversion, or Python raises `TypeError` — too few ("not enough
680/// arguments") and too many ("not all arguments converted") both land on
681/// the same warning. Any other conversion is reported as an invalid format
682/// too; `%r` would in fact work in Python, but no `numfig_format` uses it
683/// and guessing at the rest of `%`-formatting would be worse than saying
684/// the format is unusable.
685fn format_old_style(title: &str, fignum: &str) -> Result<String, TypeError> {
686    let mut out = String::with_capacity(title.len());
687    let mut rest = title;
688    let mut conversions = 0usize;
689    while let Some(percent) = rest.find('%') {
690        out.push_str(&rest[..percent]);
691        let mut chars = rest[percent + 1..].chars();
692        match chars.next() {
693            Some('%') => out.push('%'),
694            Some('s') => {
695                conversions += 1;
696                out.push_str(fignum);
697            }
698            // `%d` with a string argument, or a trailing bare `%`, is a
699            // TypeError/ValueError; either way Sphinx logs #13.
700            _ => return Err(TypeError),
701        }
702        rest = &rest[percent + 2..];
703    }
704    if conversions != 1 {
705        // "not all arguments converted during string formatting".
706        return Err(TypeError);
707    }
708    out.push_str(rest);
709    Ok(out)
710}
711
712/// `ws_re.split(target, maxsplit=1)`: the first whitespace run splits the
713/// leading word off. `ws_re` is `\s+`, Python's `str.isspace` — so a
714/// `\x1f` (which `OptionXRefRole` keeps in the reftarget) splits a
715/// subcommand off exactly as a space would ([`crate::utils::py_isspace`]).
716fn split_once_whitespace(target: &str) -> Option<(&str, &str)> {
717    let start = target.find(crate::utils::py_isspace)?;
718    let end = target[start..]
719        .find(|c: char| !crate::utils::py_isspace(c))
720        .map(|offset| start + offset)
721        .unwrap_or(target.len());
722    Some((&target[..start], &target[end..]))
723}
724
725/// `StandardDomain.objtypes_for_role` over `object_types` (`:729-737`).
726fn objtypes_for_role(role: &str) -> &'static [&'static str] {
727    match role {
728        "term" => &["term"],
729        "token" => &["token"],
730        "ref" | "keyword" => &["label"],
731        "confval" => &["confval"],
732        "envvar" => &["envvar"],
733        "option" => &["cmdoption"],
734        "doc" => &["doc"],
735        _ => &[],
736    }
737}
738
739/// `StandardDomain.get_enumerable_node_type` (`:1380-1393`) — note this is
740/// the std domain's own table, so a `math_block` is not enumerable here
741/// even though the math domain numbers it.
742fn enumerable_node_type(node: &Node) -> Option<&'static str> {
743    match node.kind {
744        kinds::SECTION => Some("section"),
745        "figure" => Some("figure"),
746        kinds::TABLE => Some("table"),
747        "container" => Some("code-block"),
748        _ => None,
749    }
750}
751
752// ---------------------------------------------------------------------------
753// The document walk
754// ---------------------------------------------------------------------------
755
756/// Nitpick configuration, as `warn_missing_reference` consults it
757/// (`post_transforms/__init__.py:255-282`).
758pub struct NitpickConfig<'a> {
759    pub nitpicky: bool,
760    pub ignore: &'a [(String, String)],
761    pub ignore_regex: &'a [(String, String)],
762}
763
764/// What resolving one document produced.
765#[derive(Default)]
766pub struct DocumentResolution {
767    pub warnings: Vec<BuildWarning>,
768    /// References into a domain this build has no implementation for —
769    /// every `refdomain` outside `{"", "std", "py"}` (`c:`, `cpp:`, `js:`,
770    /// ...) — counted rather than warned about.
771    pub unresolvable_domain_refs: usize,
772}
773
774/// Resolve every `pending_xref` in one document, rewriting the tree the way
775/// `ReferencesResolver.run` does: the node is replaced by the reference
776/// that resolution built, or by its own content node when it failed.
777pub fn resolve_document(
778    resolver: &Resolver<'_>,
779    nitpick: &NitpickConfig<'_>,
780    docname: &str,
781    doctree: &mut Doctree,
782    path: &Path,
783) -> DocumentResolution {
784    let mut out = DocumentResolution::default();
785    // The walk mutates `root` while warnings read the source table for
786    // each node's `(source, line)`; the table is tiny, so a clone is the
787    // simplest split.
788    let sources = doctree.sources.clone();
789    resolve_children(
790        resolver,
791        nitpick,
792        docname,
793        &mut doctree.root,
794        &sources,
795        path,
796        None,
797        &mut out,
798    );
799    propagate_desc_domain(&mut doctree.root);
800    out
801}
802
803/// The `(source, line)` a warning about a node reports — docutils'
804/// `get_source_line`, which `sphinx.util.logging.get_node_location` runs
805/// for every `logger.warning(..., location=node)`: the node's OWN
806/// `(source, line)` when it has one, else the nearest ancestor's, else
807/// nothing (the warning then prints with no location prefix at all).
808///
809/// Threaded down the resolution walk as the nearest stamped ancestor's
810/// location, so an unstamped `pending_xref` — the doc-field xrefs
811/// `DocFieldTransformer` synthesizes carry line 0 by design, see
812/// `DocFieldEnv` in src/rst/block.rs — locates where sphinx locates it.
813type Location = Option<(u16, u32)>;
814
815/// Whether docutils' walk would stop at this node. Our parser stamps a span
816/// on every node it builds; docutils stamps most containers too (sections,
817/// paragraphs, list items, admonitions, ...) but NOT the `document` root
818/// (its source lives in the attribute dict, not on `node.source`), nor
819/// `desc`/`desc_content` (`ObjectDescription.run` builds both bare and
820/// calls `set_source_info` on the signature only), so those three are
821/// skipped regardless of the span they carry. A zero line is "unstamped"
822/// for any kind.
823fn contributes_location(node: &Node) -> bool {
824    node.span.line != 0 && !matches!(node.kind, kinds::DOCUMENT | "desc" | "desc_content")
825}
826
827/// `PropagateDescDomain` (`post_transforms/__init__.py:382-390`, priority
828/// 200): "Add the domain name of the parent node as a class in each
829/// desc_signature node." Only descriptions that named a domain get one, so
830/// `describe`/`object` (`domain=""`) are left alone.
831fn propagate_desc_domain(node: &mut Node) {
832    if node.kind == "desc" {
833        if let Some(AttrValue::Str(domain)) = node.get("domain") {
834            if !domain.is_empty() {
835                let domain = domain.clone();
836                for child in &mut node.children {
837                    if child.kind == "desc_signature" {
838                        child.attrs.classes.push(domain.clone());
839                    }
840                }
841            }
842        }
843    }
844    for child in &mut node.children {
845        propagate_desc_domain(child);
846    }
847}
848
849#[allow(clippy::too_many_arguments)]
850fn resolve_children(
851    resolver: &Resolver<'_>,
852    nitpick: &NitpickConfig<'_>,
853    docname: &str,
854    node: &mut Node,
855    sources: &[String],
856    path: &Path,
857    inherited: Location,
858    out: &mut DocumentResolution,
859) {
860    let location = if contributes_location(node) {
861        Some((node.span.source, node.span.line))
862    } else {
863        inherited
864    };
865    for child in &mut node.children {
866        resolve_children(
867            resolver, nitpick, docname, child, sources, path, location, out,
868        );
869    }
870    if !node
871        .children
872        .iter()
873        .any(|child| child.kind == kinds::PENDING_XREF)
874    {
875        return;
876    }
877    let children = std::mem::take(&mut node.children);
878    for child in children {
879        if child.kind != kinds::PENDING_XREF {
880            node.children.push(child);
881            continue;
882        }
883        node.children.extend(resolve_one(
884            resolver, nitpick, docname, child, sources, path, location, out,
885        ));
886    }
887}
888
889/// `ReferencesResolver._resolve_pending_xref` for a single node.
890#[allow(clippy::too_many_arguments)]
891fn resolve_one(
892    resolver: &Resolver<'_>,
893    nitpick: &NitpickConfig<'_>,
894    docname: &str,
895    node: Node,
896    sources: &[String],
897    doc_path: &Path,
898    inherited: Location,
899    out: &mut DocumentResolution,
900) -> Vec<Node> {
901    let span = node.span;
902    // Warnings locate the way `get_source_line` does: at the node's own
903    // `(source, line)` when it is stamped, else at the nearest stamped
904    // ancestor's (an unstamped node under an unstamped tree — a doc-field
905    // xref in a description directly under the document — has no location
906    // at all, and sphinx prints the bare `WARNING:`). Never the enclosing
907    // document's path for a node that came from an included file.
908    let location = if span.line != 0 {
909        Some((span.source, span.line))
910    } else {
911        inherited
912    };
913    let (source_path, line): (PathBuf, Option<usize>) = match location {
914        Some((source, line)) => (
915            sources
916                .get(source as usize)
917                .map(PathBuf::from)
918                .unwrap_or_else(|| doc_path.to_path_buf()),
919            Some(line as usize),
920        ),
921        None => (PathBuf::new(), None),
922    };
923    let path = source_path.as_path();
924    let refdomain = attr_str(&node, "refdomain").unwrap_or_default().to_string();
925    let reftype = attr_str(&node, "reftype").unwrap_or_default().to_string();
926    let reftarget = attr_str(&node, "reftarget").unwrap_or_default().to_string();
927    let refdoc = attr_str(&node, "refdoc").unwrap_or(docname).to_string();
928    let refexplicit = matches!(node.get("refexplicit"), Some(AttrValue::Int(1)));
929    let refwarn = matches!(node.get("refwarn"), Some(AttrValue::Int(1)));
930    // `OptionXRefRole.process_link` stamps this on every `:option:`, using
931    // Python None outside a `.. program::` scope — which pformat renders as
932    // the "True" sentinel (see `std_domain::is_none_sentinel`).
933    let program = attr_str(&node, "std:program")
934        .filter(|program| !crate::env::std_domain::is_none_sentinel(program))
935        .map(str::to_string);
936    // `node['intersphinx']`: the stamp `:external:` leaves, which sends the
937    // node through `IntersphinxRoleResolver` instead of ordinary resolution.
938    let external = matches!(node.get("intersphinx"), Some(AttrValue::Int(1)));
939    let inventory = attr_str(&node, "inventory").map(str::to_string);
940    let role_error = attr_str(&node, "intersphinx_role_error").map(str::to_string);
941    // `PyXRefRole.process_link` context stamps (Python `None` renders as
942    // the "True" sentinel, and an empty ref_context value is falsy in
943    // every place Sphinx reads these).
944    let py_module = attr_str(&node, "py:module")
945        .filter(|value| !crate::env::std_domain::is_none_sentinel(value) && !value.is_empty())
946        .map(str::to_string);
947    let py_class = attr_str(&node, "py:class")
948        .filter(|value| !crate::env::std_domain::is_none_sentinel(value) && !value.is_empty())
949        .map(str::to_string);
950    // `searchmode = 1 if node.hasattr('refspecific') else 0` (`:942`) — a
951    // PRESENCE test: annotation xrefs carry `refspecific="0"` and still
952    // search in refspecific mode (probe: a bare `Cls` annotation resolves
953    // `pkg.Cls` through the fuzzy pass).
954    let searchmode: u8 = u8::from(node.get("refspecific").is_some());
955    let children = XrefChildren::split(node.children);
956    let contnode = children.contnode();
957    let contnode_text = contnode.as_ref().map(Node::astext).unwrap_or_default();
958
959    let query = XrefQuery {
960        refdomain: &refdomain,
961        reftype: &reftype,
962        reftarget: &reftarget,
963        refexplicit,
964        refdoc: &refdoc,
965        contnode_text: &contnode_text,
966    };
967
968    // `:external:` first, exactly like the post-transform that runs one
969    // priority ahead of the reference resolver — except when the inventory
970    // it names is this project, which Sphinx's role never stamps at all.
971    let self_referential = inventory.as_deref().is_some_and(|inventory| {
972        !resolver.intersphinx.resolve_self.is_empty()
973            && resolver.intersphinx.resolve_self == inventory
974    });
975    if external && !self_referential {
976        return resolve_external(
977            resolver,
978            &query,
979            inventory.as_deref(),
980            role_error.as_deref(),
981            contnode,
982            span,
983            line,
984            path,
985            out,
986        );
987    }
988
989    // `:any:` is the one role with no domain (`refdomain=""` routes
990    // `_resolve_pending_xref_in_domain` to the "really hardwired reference
991    // types" branch, `post_transforms/__init__.py:216-222`).
992    if refdomain.is_empty() && reftype == "any" {
993        return resolve_any_ref(
994            resolver,
995            nitpick,
996            docname,
997            &query,
998            PyRefContext {
999                module: py_module.as_deref(),
1000                class: py_class.as_deref(),
1001                searchmode: 1,
1002                refwarn,
1003            },
1004            program.as_deref(),
1005            children,
1006            span,
1007            line,
1008            path,
1009            out,
1010        );
1011    }
1012
1013    // Domains this build has no resolver for (`c:`, `cpp:`, `js:`, ...)
1014    // are left alone: warning about them would report every such reference
1015    // in every project as broken. The count feeds the build's one-line
1016    // notice. Intersphinx still gets a look first — a reference into
1017    // another project's inventory is exactly what it is for.
1018    if !matches!(refdomain.as_str(), "" | "std" | "py") {
1019        let mut diagnostics = Vec::new();
1020        let outcome = resolver
1021            .intersphinx
1022            .resolve_detect(&query, &mut diagnostics);
1023        report(out, diagnostics, line, path);
1024        if let HookOutcome::Resolved(resolution) = outcome {
1025            return vec![intersphinx_node(resolution, contnode, span)];
1026        }
1027        out.unresolvable_domain_refs += 1;
1028        return children.fallback(out, line, path);
1029    }
1030    if refdomain == "py" {
1031        return resolve_py(
1032            resolver,
1033            nitpick,
1034            docname,
1035            &query,
1036            PyRefContext {
1037                module: py_module.as_deref(),
1038                class: py_class.as_deref(),
1039                searchmode,
1040                refwarn,
1041            },
1042            children,
1043            span,
1044            line,
1045            path,
1046            out,
1047        );
1048    }
1049    // An M1 heuristic kept deliberately: a `:doc:` target that is a URL is
1050    // somebody linking out, not a broken document reference. Sphinx has no
1051    // such carve-out and warns; ours stays silent (pinned by the CLI e2e
1052    // suite).
1053    if reftype == "doc" && is_url(&reftarget) {
1054        return contnode.into_iter().collect();
1055    }
1056
1057    let req = XrefRequest {
1058        fromdoc: docname,
1059        refdoc: &refdoc,
1060        reftype: &reftype,
1061        reftarget: &reftarget,
1062        refexplicit,
1063        program: program.as_deref(),
1064        contnode_text: &contnode_text,
1065    };
1066    let outcome = resolver.resolve_xref(&req);
1067
1068    match outcome {
1069        XrefOutcome::Resolved(resolved) => {
1070            vec![reference_node(resolved, contnode, span)]
1071        }
1072        XrefOutcome::Kept { warning } => {
1073            if let Some(message) = warning {
1074                out.warnings.push(
1075                    BuildWarning::new(
1076                        path.to_path_buf(),
1077                        line,
1078                        message,
1079                        WarningType::BrokenCrossReference,
1080                    )
1081                    .with_category(None),
1082                );
1083            }
1084            contnode.into_iter().collect()
1085        }
1086        XrefOutcome::Missing => {
1087            // The `missing-reference` event, which is where intersphinx
1088            // hooks in: after the domain, before the warning.
1089            let mut diagnostics = Vec::new();
1090            let outcome = resolver
1091                .intersphinx
1092                .resolve_detect(&query, &mut diagnostics);
1093            report(out, diagnostics, line, path);
1094            match outcome {
1095                HookOutcome::Resolved(resolution) => {
1096                    return vec![intersphinx_node(resolution, contnode, span)];
1097                }
1098                // The target named this project: retry the local domain
1099                // with the prefix stripped. The warning below still reports
1100                // the target as written, because Sphinx never rewrote it.
1101                HookOutcome::SelfReferential(stripped) => {
1102                    let retry = resolver.resolve_xref(&XrefRequest {
1103                        reftarget: &stripped,
1104                        ..req
1105                    });
1106                    if let XrefOutcome::Resolved(resolved) = retry {
1107                        return vec![reference_node(resolved, contnode, span)];
1108                    }
1109                }
1110                HookOutcome::Missing => {}
1111            }
1112            if let Some(message) = missing_reference_warning(
1113                resolver.env,
1114                nitpick,
1115                &refdomain,
1116                &reftype,
1117                &reftarget,
1118                refwarn,
1119            ) {
1120                out.warnings.push(
1121                    BuildWarning::new(
1122                        path.to_path_buf(),
1123                        line,
1124                        message,
1125                        WarningType::BrokenCrossReference,
1126                    )
1127                    // `logger.warning(..., type='ref', subtype=typ)`.
1128                    .with_category(Some(format!("ref.{reftype}"))),
1129                );
1130            }
1131            children.fallback(out, line, path)
1132        }
1133    }
1134}
1135
1136/// The py-role context [`resolve_py`] reads off the `pending_xref`.
1137struct PyRefContext<'a> {
1138    /// `node['py:module']` / `node['py:class']`, None-sentinel and
1139    /// empty-string (Python falsy) both read as absent.
1140    module: Option<&'a str>,
1141    class: Option<&'a str>,
1142    searchmode: u8,
1143    refwarn: bool,
1144}
1145
1146/// `PythonDomain.resolve_xref` wired into the resolver's event order
1147/// (`ReferencesResolver._resolve_pending_xref`): the domain first, then the
1148/// `missing-reference` event — intersphinx at its default priority 500,
1149/// [`crate::env::py_domain::builtin_resolver`] at 900 — then the
1150/// self-referential retry, then the nitpicky warning. Probe-pinned
1151/// consequence of the priorities: a builtin name a loaded inventory carries
1152/// resolves EXTERNALLY; one it doesn't carry is silenced.
1153#[allow(clippy::too_many_arguments)]
1154fn resolve_py(
1155    resolver: &Resolver<'_>,
1156    nitpick: &NitpickConfig<'_>,
1157    docname: &str,
1158    query: &XrefQuery<'_>,
1159    ctx: PyRefContext<'_>,
1160    children: XrefChildren,
1161    span: crate::doctree::Span,
1162    line: Option<usize>,
1163    path: &Path,
1164    out: &mut DocumentResolution,
1165) -> Vec<Node> {
1166    use crate::env::py_domain;
1167
1168    let reftype = query.reftype;
1169    let contnode = children.contnode();
1170
1171    // The domain's own resolution. The ambiguity warning fires even when
1172    // the reference then resolves (to the first match).
1173    let resolve = |target: &str, out: &mut DocumentResolution| {
1174        let (found, ambiguity) = py_domain::resolve_xref(
1175            &resolver.env.py,
1176            ctx.module,
1177            ctx.class,
1178            reftype,
1179            target,
1180            ctx.searchmode,
1181        );
1182        if let Some(message) = ambiguity {
1183            out.warnings.push(
1184                BuildWarning::new(
1185                    path.to_path_buf(),
1186                    line,
1187                    message,
1188                    WarningType::BrokenCrossReference,
1189                )
1190                // `type='ref', subtype='python'` (`:977-978`).
1191                .with_category(Some("ref.python".to_string())),
1192            );
1193        }
1194        found
1195    };
1196    if let Some(target) = resolve(query.reftarget, out) {
1197        let resolved = resolver.py_refnode(docname, target, children.resolved.clone());
1198        return vec![reference_node(resolved, contnode, span)];
1199    }
1200
1201    // The `missing-reference` event: intersphinx first (priority 500)...
1202    let mut diagnostics = Vec::new();
1203    let outcome = resolver.intersphinx.resolve_detect(query, &mut diagnostics);
1204    report(out, diagnostics, line, path);
1205    match outcome {
1206        HookOutcome::Resolved(resolution) => {
1207            return vec![intersphinx_node(resolution, contnode, span)];
1208        }
1209        HookOutcome::SelfReferential(stripped) => {
1210            // ...then builtin_resolver (900), which reads the reftarget
1211            // intersphinx just rewrote on the node...
1212            if py_domain::builtin_resolver(reftype, &stripped) {
1213                return children.contnode().into_iter().collect();
1214            }
1215            // ...and only then the domain retry with the stripped target.
1216            // The warning below still reports the target as written.
1217            if let Some(target) = resolve(&stripped, out) {
1218                let resolved = resolver.py_refnode(docname, target, children.resolved.clone());
1219                return vec![reference_node(resolved, contnode, span)];
1220            }
1221        }
1222        HookOutcome::Missing => {
1223            if py_domain::builtin_resolver(reftype, query.reftarget) {
1224                // "Do not emit nitpicky warnings for built-in types": the
1225                // event returns the contnode, so no `*`-condition fallback
1226                // either (probe: an unqualified-names annotation keeps the
1227                // SHORT name when builtin-silenced).
1228                return children.contnode().into_iter().collect();
1229            }
1230        }
1231    }
1232
1233    if let Some(message) = missing_reference_warning(
1234        resolver.env,
1235        nitpick,
1236        "py",
1237        reftype,
1238        query.reftarget,
1239        ctx.refwarn,
1240    ) {
1241        out.warnings.push(
1242            BuildWarning::new(
1243                path.to_path_buf(),
1244                line,
1245                message,
1246                WarningType::BrokenCrossReference,
1247            )
1248            // `logger.warning(..., type='ref', subtype=typ)`.
1249            .with_category(Some(format!("ref.{reftype}"))),
1250        );
1251    }
1252    children.fallback(out, line, path)
1253}
1254
1255/// `ReferencesResolver._resolve_pending_any_xref` wired into the event
1256/// order: the candidate walk ([`Resolver::resolve_any`]), the ambiguity
1257/// warning (`[ref.any]`, fired even though the first candidate still
1258/// wins), the winner's class extension, then — on no candidates — the
1259/// `missing-reference` event (intersphinx; `builtin_resolver` never fires
1260/// for `any`, its reftype gate is `{class, obj, exc}`), the
1261/// self-referential retry, and the dangling warning (`:any:` is
1262/// `warn_dangling=True`).
1263#[allow(clippy::too_many_arguments)]
1264fn resolve_any_ref(
1265    resolver: &Resolver<'_>,
1266    nitpick: &NitpickConfig<'_>,
1267    docname: &str,
1268    query: &XrefQuery<'_>,
1269    ctx: PyRefContext<'_>,
1270    program: Option<&str>,
1271    children: XrefChildren,
1272    span: crate::doctree::Span,
1273    line: Option<usize>,
1274    path: &Path,
1275    out: &mut DocumentResolution,
1276) -> Vec<Node> {
1277    let contnode = children.contnode();
1278    let contnode_text = contnode.as_ref().map(Node::astext).unwrap_or_default();
1279    let req = XrefRequest {
1280        fromdoc: docname,
1281        refdoc: query.refdoc,
1282        reftype: "any",
1283        reftarget: query.reftarget,
1284        refexplicit: query.refexplicit,
1285        program,
1286        contnode_text: &contnode_text,
1287    };
1288
1289    // One resolution attempt over a target (the self-referential retry runs
1290    // the same code over the stripped spelling, ambiguity warning included).
1291    let attempt = |target: &str, out: &mut DocumentResolution| -> Option<Node> {
1292        let mut results = resolver.resolve_any(
1293            &XrefRequest {
1294                reftarget: target,
1295                ..req
1296            },
1297            ctx.module,
1298            ctx.class,
1299            children.resolved.as_ref(),
1300        );
1301        if results.is_empty() {
1302            return None;
1303        }
1304        if results.len() > 1 {
1305            let candidates = results
1306                .iter()
1307                .map(|candidate| format!(":{}:`{}`", candidate.role, candidate.label))
1308                .collect::<Vec<_>>()
1309                .join(" or ");
1310            out.warnings.push(
1311                BuildWarning::new(
1312                    path.to_path_buf(),
1313                    line,
1314                    format!(
1315                        "more than one target found for 'any' cross-reference {}: \
1316                         could be {candidates}",
1317                        py_repr_str(target)
1318                    ),
1319                    WarningType::BrokenCrossReference,
1320                )
1321                // `type='ref', subtype='any'` (`:227-233`).
1322                .with_category(Some("ref.any".to_string())),
1323            );
1324        }
1325        let AnyCandidate { role, node, .. } = results.remove(0);
1326        let mut built = reference_node(node, children.contnode(), span);
1327        // 'Override "any" class with the actual role type' (`:236-247`):
1328        // the winner's first child — when it is an element that has classes
1329        // — gains `[domain, role.replace(':', '-')]`. Note `'doc'` has no
1330        // colon, so both halves are `doc` (probe: `classes="doc doc doc"`),
1331        // and a `std:ref` winner's fresh inline doubles up to
1332        // `std std-ref std std-ref`.
1333        if let Some(first) = built.children.first_mut() {
1334            if first.kind != kinds::TEXT && !first.attrs.classes.is_empty() {
1335                let domain_half = role.split(':').next().unwrap_or_default().to_string();
1336                first.attrs.classes.push(domain_half);
1337                first.attrs.classes.push(role.replace(':', "-"));
1338            }
1339        }
1340        Some(built)
1341    };
1342
1343    if let Some(node) = attempt(query.reftarget, out) {
1344        return vec![node];
1345    }
1346
1347    // The `missing-reference` event: intersphinx's handler resolves `any`
1348    // by sweeping every domain's objtypes.
1349    let mut diagnostics = Vec::new();
1350    let outcome = resolver.intersphinx.resolve_detect(query, &mut diagnostics);
1351    report(out, diagnostics, line, path);
1352    match outcome {
1353        HookOutcome::Resolved(resolution) => {
1354            return vec![intersphinx_node(resolution, contnode, span)];
1355        }
1356        HookOutcome::SelfReferential(stripped) => {
1357            if let Some(node) = attempt(&stripped, out) {
1358                return vec![node];
1359            }
1360        }
1361        HookOutcome::Missing => {}
1362    }
1363
1364    if let Some(message) = missing_reference_warning(
1365        resolver.env,
1366        nitpick,
1367        "",
1368        "any",
1369        query.reftarget,
1370        ctx.refwarn,
1371    ) {
1372        out.warnings.push(
1373            BuildWarning::new(
1374                path.to_path_buf(),
1375                line,
1376                message,
1377                WarningType::BrokenCrossReference,
1378            )
1379            // `logger.warning(..., type='ref', subtype=typ)`.
1380            .with_category(Some("ref.any".to_string())),
1381        );
1382    }
1383    children.fallback(out, line, path)
1384}
1385
1386/// `IntersphinxRoleResolver.run` (`ext/intersphinx/_resolve.py:543-565`),
1387/// plus the two checks Sphinx's `:external:` role makes at parse time and
1388/// this port defers to here (see
1389/// [`crate::rst::inline`]'s `emit_external_xref`): the inventory-existence
1390/// test comes first, then the role-name failure.
1391#[allow(clippy::too_many_arguments)]
1392fn resolve_external(
1393    resolver: &Resolver<'_>,
1394    query: &XrefQuery<'_>,
1395    inventory: Option<&str>,
1396    role_error: Option<&str>,
1397    contnode: Option<Node>,
1398    span: crate::doctree::Span,
1399    line: Option<usize>,
1400    path: &Path,
1401    out: &mut DocumentResolution,
1402) -> Vec<Node> {
1403    if let Some(inventory) = inventory {
1404        if let Some(diagnostic) =
1405            intersphinx::external_inventory_missing(resolver.intersphinx, inventory)
1406        {
1407            report(out, vec![diagnostic], line, path);
1408            // Sphinx's role returns `([], [])`: no reference, and no
1409            // content either.
1410            return Vec::new();
1411        }
1412    }
1413    if let Some(message) = role_error {
1414        report(
1415            out,
1416            vec![Diagnostic {
1417                message: message.to_string(),
1418                category: Some("intersphinx.external".to_string()),
1419            }],
1420            line,
1421            path,
1422        );
1423        return Vec::new();
1424    }
1425
1426    let mut diagnostics = Vec::new();
1427    let resolution = match inventory {
1428        Some(inventory) => {
1429            resolver
1430                .intersphinx
1431                .resolve_in_inventory(inventory, query, &mut diagnostics)
1432        }
1433        // `resolve_reference_any_inventory(env, False, ...)`: an
1434        // `:external:` reference never honours the disabled reftypes.
1435        None => resolver
1436            .intersphinx
1437            .resolve_any(false, query, &mut diagnostics),
1438    };
1439    report(out, diagnostics, line, path);
1440
1441    match resolution {
1442        Some(resolution) => vec![intersphinx_node(resolution, contnode, span)],
1443        None => {
1444            report(
1445                out,
1446                vec![intersphinx::external_not_found(query)],
1447                line,
1448                path,
1449            );
1450            contnode.into_iter().collect()
1451        }
1452    }
1453}
1454
1455/// A `pending_xref`'s children, split the way `ReferencesResolver.run`
1456/// reads them (`post_transforms/__init__.py:66-92`): the content node comes
1457/// from the first non-empty `pending_xref_condition` matching `'resolved'`
1458/// then `'*'` (docutils truthiness — a childless condition node is falsy
1459/// and skipped), else from the node's own first child.
1460struct XrefChildren {
1461    contnode: Option<Node>,
1462    /// All children of the first non-empty `condition="resolved"` node —
1463    /// what a resolved py xref adopts in place of the contnode.
1464    resolved: Option<Vec<Node>>,
1465    /// All children of the first non-empty `condition="*"` node — what
1466    /// replaces the `pending_xref` when resolution fails.
1467    star: Option<Vec<Node>>,
1468    /// `isinstance(node[0], pending_xref_condition)`, which gates the
1469    /// failure fallback.
1470    first_is_condition: bool,
1471}
1472
1473impl XrefChildren {
1474    /// SIMPLIFICATION, deliberate: `find` takes the first NON-EMPTY node
1475    /// with the wanted condition, where sphinx takes the first node with
1476    /// that condition and *then* tests its truthiness — so on a
1477    /// `[resolved(empty), resolved(full)]` sequence sphinx falls through
1478    /// to `'*'` and this returns the second `resolved`. The two agree
1479    /// wherever the nodes come from `type_to_xref`, which emits at most
1480    /// one condition of each kind and never an empty one (task 10), and
1481    /// nothing else in this crate builds `pending_xref_condition` nodes.
1482    /// Kept as-is because the faithful form needs a two-pass search for a
1483    /// shape the parser cannot produce.
1484    fn split(children: Vec<Node>) -> Self {
1485        let first_is_condition = children
1486            .first()
1487            .is_some_and(|child| child.kind == "pending_xref_condition");
1488        let find = |condition: &str| -> Option<Vec<Node>> {
1489            children
1490                .iter()
1491                .find(|child| {
1492                    child.kind == "pending_xref_condition"
1493                        && !child.children.is_empty()
1494                        && matches!(child.get("condition"),
1495                                    Some(AttrValue::Str(value)) if value == condition)
1496                })
1497                .map(|child| child.children.clone())
1498        };
1499        let resolved = find("resolved");
1500        let star = find("*");
1501        let contnode = resolved
1502            .as_ref()
1503            .or(star.as_ref())
1504            .map(|content| content[0].clone())
1505            // `contnode = node[0].deepcopy()` — which is the (childless)
1506            // condition node itself when conditions exist but are empty.
1507            .or_else(|| children.into_iter().next());
1508        XrefChildren {
1509            contnode,
1510            resolved,
1511            star,
1512            first_is_condition,
1513        }
1514    }
1515
1516    /// Sphinx's `contnode` (a deepcopy — every use hands out a fresh clone).
1517    fn contnode(&self) -> Option<Node> {
1518        self.contnode.clone()
1519    }
1520
1521    /// The nodes that replace a `pending_xref` whose resolution FAILED —
1522    /// returned None, as opposed to a Kept/builtin-silenced outcome, which
1523    /// keeps the plain contnode: the `'*'` condition's children when the
1524    /// node leads with a condition, else the contnode (`run()`, `:76-90`).
1525    fn fallback(self, out: &mut DocumentResolution, line: Option<usize>, path: &Path) -> Vec<Node> {
1526        if self.first_is_condition {
1527            if let Some(star) = self.star {
1528                return star;
1529            }
1530            out.warnings.push(
1531                BuildWarning::new(
1532                    path.to_path_buf(),
1533                    line,
1534                    "Could not determine the fallback text for the cross-reference. \
1535                     Might be a bug."
1536                        .to_string(),
1537                    WarningType::BrokenCrossReference,
1538                )
1539                // Plain `logger.warning(msg, location=node)` — no category.
1540                .with_category(None),
1541            );
1542        }
1543        self.contnode.into_iter().collect()
1544    }
1545}
1546
1547/// Turn intersphinx diagnostics into build warnings at the reference's line.
1548fn report(
1549    out: &mut DocumentResolution,
1550    diagnostics: Vec<Diagnostic>,
1551    line: Option<usize>,
1552    path: &Path,
1553) {
1554    for diagnostic in diagnostics {
1555        out.warnings.push(
1556            BuildWarning::new(
1557                path.to_path_buf(),
1558                line,
1559                diagnostic.message,
1560                WarningType::BrokenCrossReference,
1561            )
1562            .with_category(diagnostic.category),
1563        );
1564    }
1565}
1566
1567/// `_create_element_from_result`'s node (`_resolve.py:71-77`): an *external*
1568/// reference carrying the inventory's hover title, whose child is either the
1569/// content node as parsed or a fresh one of the same kind holding the
1570/// inventory's display name.
1571fn intersphinx_node(
1572    resolution: crate::intersphinx::Resolution,
1573    contnode: Option<Node>,
1574    span: crate::doctree::Span,
1575) -> Node {
1576    let mut node = Node::elem(kinds::REFERENCE, span);
1577    node.set("internal", AttrValue::Int(0));
1578    node.set("refuri", AttrValue::Str(resolution.refuri));
1579    node.set("reftitle", AttrValue::Str(resolution.reftitle));
1580    match resolution.title {
1581        // `contnode.__class__(title, title)` — the same node kind, with the
1582        // new text and none of the original's classes.
1583        Some(title) => {
1584            let kind = contnode.as_ref().map_or(kinds::LITERAL, |node| node.kind);
1585            let mut inner = Node::elem(kind, span);
1586            inner.children.push(Node::text_node(title, span));
1587            node.children.push(inner);
1588        }
1589        None => node.children.extend(contnode),
1590    }
1591    node
1592}
1593
1594/// `ReferencesResolver.warn_missing_reference` (`:255-298`) plus the std
1595/// domain's `warn-missing-reference` handler (`std/__init__.py:1444-1461`).
1596/// `None` means "resolution failed silently", which is the default for
1597/// roles that are not `warn_dangling` outside nitpicky mode.
1598///
1599/// `refdomain` is `"py"`, `"std"` or `""` (a domainless std role). Sphinx's
1600/// nitpick-ignore matching tries the bare `(typ, target)` form ON TOP of
1601/// `(domain:typ, target)` only "for 'std' types" — `not domain or
1602/// domain.name == 'std'` — so a `('func', 'x')` entry does NOT silence a
1603/// missing `:py:func:`x`` (probe-verified; `('py:func', 'x')` does).
1604fn missing_reference_warning(
1605    env: &BuildEnvironment,
1606    nitpick: &NitpickConfig<'_>,
1607    refdomain: &str,
1608    typ: &str,
1609    target: &str,
1610    refwarn: bool,
1611) -> Option<String> {
1612    let py = refdomain == "py";
1613    let mut warn = refwarn;
1614    if nitpick.nitpicky {
1615        warn = true;
1616        // `dtype = f'{domain.name}:{typ}' if domain else typ` — a
1617        // domainless node (`:any:`) has NO domain-qualified spelling.
1618        let dtype = if py {
1619            format!("py:{typ}")
1620        } else if refdomain.is_empty() {
1621            typ.to_string()
1622        } else {
1623            format!("std:{typ}")
1624        };
1625        let bare = !py;
1626        let ignored =
1627            nitpick.ignore.iter().any(|(ityp, itarget)| {
1628                (ityp == &dtype || (bare && ityp == typ)) && itarget == target
1629            }) || nitpick.ignore_regex.iter().any(|(ityp, itarget)| {
1630                (full_match(ityp, &dtype) || (bare && full_match(ityp, typ)))
1631                    && full_match(itarget, target)
1632            });
1633        if ignored {
1634            warn = false;
1635        }
1636    }
1637    if !warn {
1638        return None;
1639    }
1640
1641    // The generic branch for a non-std domain
1642    // (`post_transforms/__init__.py:290-295`) — the py domain defines no
1643    // `dangling_warnings` and no `warn-missing-reference` handler, so every
1644    // missing py ref takes this exact shape.
1645    if py {
1646        return Some(format!("py:{typ} reference target not found: {target}"));
1647    }
1648
1649    // `:ref:` goes through the std domain's event handler, which
1650    // distinguishes "no such label" from "label with no title".
1651    if typ == "ref" {
1652        return Some(if env.std.anonlabels.contains_key(target) {
1653            format!(
1654                "Failed to create a cross reference. A title or caption not found: {}",
1655                py_repr_str(target)
1656            )
1657        } else {
1658            format!("undefined label: {}", py_repr_str(target))
1659        });
1660    }
1661    // `domain.dangling_warnings` (`std/__init__.py:790-796`).
1662    let message = match typ {
1663        "term" => Some(format!("term not in glossary: {}", py_repr_str(target))),
1664        "numref" => Some(format!("undefined label: {}", py_repr_str(target))),
1665        "keyword" => Some(format!("unknown keyword: {}", py_repr_str(target))),
1666        "doc" => Some(format!("unknown document: {}", py_repr_str(target))),
1667        "option" => Some(format!("unknown option: {}", py_repr_str(target))),
1668        _ => None,
1669    };
1670    Some(message.unwrap_or_else(|| {
1671        // The generic fallback. Sphinx's other branch — `%s:%s reference
1672        // target not found` — is for non-std domains, which return before
1673        // reaching this function.
1674        format!("{} reference target not found: {target}", py_repr_str(typ))
1675    }))
1676}
1677
1678/// Python `re.fullmatch`.
1679fn full_match(pattern: &str, text: &str) -> bool {
1680    regex::Regex::new(&format!("^(?:{pattern})$"))
1681        .map(|re| re.is_match(text))
1682        .unwrap_or(false)
1683}
1684
1685fn is_url(target: &str) -> bool {
1686    target.starts_with("http://") || target.starts_with("https://") || target.starts_with("file://")
1687}
1688
1689fn attr_str<'a>(node: &'a Node, key: &'static str) -> Option<&'a str> {
1690    match node.get(key) {
1691        Some(AttrValue::Str(value)) => Some(value.as_str()),
1692        _ => None,
1693    }
1694}
1695
1696/// Materialize a [`ResolvedXref`] as the doctree node it describes.
1697fn reference_node(
1698    resolved: ResolvedXref,
1699    contnode: Option<Node>,
1700    span: crate::doctree::Span,
1701) -> Node {
1702    let mut node = Node::elem(resolved.kind, span);
1703    node.set("internal", AttrValue::Int(1));
1704    if let Some(refid) = resolved.refid {
1705        node.set("refid", AttrValue::Str(refid));
1706    }
1707    if let Some(refuri) = resolved.refuri {
1708        node.set("refuri", AttrValue::Str(refuri));
1709    }
1710    if let Some(title) = resolved.title {
1711        node.set("title", AttrValue::Str(title));
1712    }
1713    if let Some(reftitle) = resolved.reftitle {
1714        node.set("reftitle", AttrValue::Str(reftitle));
1715    }
1716    match resolved.inner {
1717        Inner::Contnode => node.children.extend(contnode),
1718        Inner::Inline { text, classes } => {
1719            let mut inner = Node::elem("inline", span);
1720            inner.attrs.classes = classes;
1721            inner.children.push(Node::text_node(text, span));
1722            node.children.push(inner);
1723        }
1724        Inner::Children(children) => node.children.extend(children),
1725    }
1726    node
1727}
1728
1729#[cfg(test)]
1730mod intersphinx_tests;
1731
1732#[cfg(test)]
1733mod tests {
1734    use super::*;
1735
1736    /// `ws_re.split(target, maxsplit=1)` — `\s+` is Python's `str.isspace`,
1737    /// so the `\x1f` an `OptionXRefRole` keeps in its reftarget folds a
1738    /// subcommand off exactly as a space does: `:option:`git\x1fadd -x``
1739    /// reaches `(git-add, -x)` under sphinx 9.1.0 (env oracle project
1740    /// `names_round_d`, panel fix round D).
1741    #[test]
1742    fn subcommand_folding_splits_on_python_whitespace() {
1743        assert_eq!(
1744            split_once_whitespace("git\x1fadd -x"),
1745            Some(("git", "add -x"))
1746        );
1747        assert_eq!(split_once_whitespace("add -x"), Some(("add", "-x")));
1748        assert_eq!(
1749            split_once_whitespace("git \x1f\t add"),
1750            Some(("git", "add"))
1751        );
1752        assert_eq!(split_once_whitespace("-x"), None);
1753    }
1754
1755    /// No `intersphinx_mapping`: every hook is a no-op, which is the state
1756    /// every one of these tests (and every environment-oracle project) is
1757    /// in.
1758    static INERT: Intersphinx = Intersphinx {
1759        data: crate::intersphinx::IntersphinxData {
1760            main: crate::inventory::Inventory {
1761                data: BTreeMap::new(),
1762            },
1763            named: BTreeMap::new(),
1764        },
1765        disabled_reftypes: std::collections::BTreeSet::new(),
1766        resolve_self: String::new(),
1767    };
1768
1769    fn env_with_label() -> BuildEnvironment {
1770        let mut env = BuildEnvironment::default();
1771        env.std.labels.insert(
1772            "the-label".to_string(),
1773            (
1774                "a".to_string(),
1775                "the-label".to_string(),
1776                "The Section".to_string(),
1777            ),
1778        );
1779        env.std.anonlabels.insert(
1780            "the-label".to_string(),
1781            ("a".to_string(), "the-label".to_string()),
1782        );
1783        env.all_docs.insert("a".to_string(), 0);
1784        env.all_docs.insert("b".to_string(), 0);
1785        env
1786    }
1787
1788    fn resolver<'a>(
1789        env: &'a BuildEnvironment,
1790        numfig_format: &'a BTreeMap<String, String>,
1791    ) -> Resolver<'a> {
1792        Resolver {
1793            env,
1794            numfig: true,
1795            numfig_format,
1796            doctree: &|_| None,
1797            relative_uri: &|_, _| String::new(),
1798            intersphinx: &INERT,
1799        }
1800    }
1801
1802    fn request<'a>(fromdoc: &'a str, reftype: &'a str, reftarget: &'a str) -> XrefRequest<'a> {
1803        XrefRequest {
1804            fromdoc,
1805            refdoc: fromdoc,
1806            reftype,
1807            reftarget,
1808            refexplicit: false,
1809            program: None,
1810            contnode_text: reftarget,
1811        }
1812    }
1813
1814    /// The `.. program::` in scope where an `:option:` was *written* is the
1815    /// first key `_resolve_option_xref` tries — which is what
1816    /// `pending_xref['std:program']` carries, and why reading that attribute
1817    /// has to strip docutils' `None` rendering first (a literal `"True"`
1818    /// program name would miss every registration).
1819    #[test]
1820    fn an_option_resolves_against_the_program_in_scope_where_it_was_written() {
1821        let mut env = BuildEnvironment::default();
1822        env.std
1823            .add_program_option(Some("myprog"), "--verbose", "a", "cmdoption-myprog-verbose");
1824        env.all_docs.insert("a".to_string(), 0);
1825        let formats = BTreeMap::new();
1826        let resolver = resolver(&env, &formats);
1827
1828        let mut scoped = request("a", "option", "--verbose");
1829        scoped.program = Some("myprog");
1830        assert_eq!(
1831            resolver.resolve_xref(&scoped),
1832            XrefOutcome::Resolved(ResolvedXref {
1833                kind: kinds::REFERENCE,
1834                refid: Some("cmdoption-myprog-verbose".to_string()),
1835                refuri: None,
1836                title: None,
1837                reftitle: None,
1838                inner: Inner::Contnode,
1839            })
1840        );
1841
1842        // Same target with no program in scope: only the word-folding
1843        // fallback could save it, and `--verbose` has no leading command.
1844        assert_eq!(
1845            resolver.resolve_xref(&request("a", "option", "--verbose")),
1846            XrefOutcome::Missing
1847        );
1848    }
1849
1850    #[test]
1851    fn a_same_document_ref_uses_refid_and_a_cross_document_one_uses_refuri() {
1852        let env = env_with_label();
1853        let formats = BTreeMap::new();
1854        let resolver = resolver(&env, &formats);
1855
1856        let same = resolver.resolve_xref(&request("a", "ref", "the-label"));
1857        assert_eq!(
1858            same,
1859            XrefOutcome::Resolved(ResolvedXref {
1860                kind: kinds::REFERENCE,
1861                refid: Some("the-label".to_string()),
1862                refuri: None,
1863                title: None,
1864                reftitle: None,
1865                inner: Inner::Inline {
1866                    text: "The Section".to_string(),
1867                    classes: vec!["std".to_string(), "std-ref".to_string()],
1868                },
1869            })
1870        );
1871
1872        let cross = resolver.resolve_xref(&request("b", "ref", "the-label"));
1873        let XrefOutcome::Resolved(cross) = cross else {
1874            panic!("expected a resolved reference, got {cross:?}")
1875        };
1876        assert_eq!(cross.refuri.as_deref(), Some("#the-label"));
1877        assert_eq!(cross.refid, None);
1878    }
1879
1880    #[test]
1881    fn an_explicit_ref_titles_itself_from_the_anonymous_label() {
1882        let env = env_with_label();
1883        let formats = BTreeMap::new();
1884        let resolver = resolver(&env, &formats);
1885        let mut req = request("a", "ref", "the-label");
1886        req.refexplicit = true;
1887        req.contnode_text = "My Own Words";
1888
1889        let XrefOutcome::Resolved(resolved) = resolver.resolve_xref(&req) else {
1890            panic!("expected a resolved reference")
1891        };
1892        assert_eq!(
1893            resolved.inner,
1894            Inner::Inline {
1895                text: "My Own Words".to_string(),
1896                classes: vec!["std".to_string(), "std-ref".to_string()],
1897            }
1898        );
1899    }
1900
1901    #[test]
1902    fn a_doc_reference_joins_the_target_against_the_referencing_document() {
1903        let mut env = BuildEnvironment::default();
1904        env.all_docs.insert("sub/c".to_string(), 0);
1905        let mut title = Node::elem(kinds::TITLE, crate::doctree::Span::ZERO);
1906        title
1907            .children
1908            .push(Node::text_node("Sub C", crate::doctree::Span::ZERO));
1909        env.titles.insert("sub/c".to_string(), title);
1910        let formats = BTreeMap::new();
1911        let resolver = resolver(&env, &formats);
1912
1913        let relative = resolver.resolve_xref(&request("sub/b", "doc", "c"));
1914        assert_eq!(
1915            relative,
1916            XrefOutcome::Resolved(ResolvedXref {
1917                kind: kinds::REFERENCE,
1918                refid: None,
1919                refuri: Some(String::new()),
1920                title: None,
1921                reftitle: None,
1922                inner: Inner::Inline {
1923                    text: "Sub C".to_string(),
1924                    classes: vec!["doc".to_string()],
1925                },
1926            }),
1927            "the caption comes from the target's title, not the written target"
1928        );
1929        assert_eq!(
1930            resolver.resolve_xref(&request("sub/b", "doc", "/sub/c")),
1931            relative,
1932            "an absolute target names the same document"
1933        );
1934        assert_eq!(
1935            resolver.resolve_xref(&request("sub/b", "doc", "nope")),
1936            XrefOutcome::Missing
1937        );
1938    }
1939
1940    #[test]
1941    fn option_resolution_folds_leading_words_into_the_program_name() {
1942        let mut env = BuildEnvironment::default();
1943        env.std
1944            .add_program_option(Some("myprog"), "--verbose", "a", "cmdoption-myprog-verbose");
1945        env.std
1946            .add_program_option(None, "--global", "a", "cmdoption-global");
1947        let formats = BTreeMap::new();
1948        let resolver = resolver(&env, &formats);
1949
1950        let XrefOutcome::Resolved(scoped) =
1951            resolver.resolve_xref(&request("b", "option", "myprog --verbose"))
1952        else {
1953            panic!("`myprog --verbose` must resolve through the program fallback")
1954        };
1955        assert_eq!(scoped.refuri.as_deref(), Some("#cmdoption-myprog-verbose"));
1956
1957        let XrefOutcome::Resolved(global) =
1958            resolver.resolve_xref(&request("b", "option", "--global"))
1959        else {
1960            panic!("an unscoped option resolves under the `None` program")
1961        };
1962        assert_eq!(global.refuri.as_deref(), Some("#cmdoption-global"));
1963
1964        assert_eq!(
1965            resolver.resolve_xref(&request("b", "option", "--missing")),
1966            XrefOutcome::Missing
1967        );
1968    }
1969
1970    #[test]
1971    fn option_resolution_strips_an_option_value() {
1972        let mut env = BuildEnvironment::default();
1973        env.std
1974            .add_program_option(None, "-foo", "a", "cmdoption-foo");
1975        let formats = BTreeMap::new();
1976        let resolver = resolver(&env, &formats);
1977        for target in ["-foo=bar", "-foo[=bar]"] {
1978            let outcome = resolver.resolve_xref(&request("b", "option", target));
1979            assert!(
1980                matches!(outcome, XrefOutcome::Resolved(_)),
1981                "{target} must fall back to the option stem, got {outcome:?}"
1982            );
1983        }
1984    }
1985
1986    #[test]
1987    fn term_resolution_falls_back_to_a_case_insensitive_match() {
1988        let mut env = BuildEnvironment::default();
1989        env.std.note_term("environment", "a", "term-environment");
1990        let formats = BTreeMap::new();
1991        let resolver = resolver(&env, &formats);
1992
1993        let exact = resolver.resolve_xref(&request("b", "term", "environment"));
1994        let other_case = resolver.resolve_xref(&request("b", "term", "Environment"));
1995        assert!(matches!(exact, XrefOutcome::Resolved(_)));
1996        assert_eq!(exact, other_case);
1997        assert_eq!(
1998            resolver.resolve_xref(&request("b", "term", "nonexistent term")),
1999            XrefOutcome::Missing
2000        );
2001    }
2002
2003    #[test]
2004    fn numfig_off_keeps_the_content_node_and_says_so_once() {
2005        let mut env = BuildEnvironment::default();
2006        env.std.labels.insert(
2007            "fig-a".to_string(),
2008            ("a".to_string(), "fig-a".to_string(), "A Figure".to_string()),
2009        );
2010        let mut figure = Node::elem("figure", crate::doctree::Span::ZERO);
2011        figure.attrs.ids.push("fig-a".to_string());
2012        let mut root = Node::elem(kinds::DOCUMENT, crate::doctree::Span::ZERO);
2013        root.children.push(figure);
2014        let doctree = Doctree {
2015            root,
2016            sources: vec!["<test>".to_string()],
2017        };
2018        let formats = BTreeMap::new();
2019        let resolver = Resolver {
2020            env: &env,
2021            numfig: false,
2022            numfig_format: &formats,
2023            doctree: &|_| Some(Cow::Borrowed(&doctree)),
2024            relative_uri: &|_, _| String::new(),
2025            intersphinx: &INERT,
2026        };
2027
2028        assert_eq!(
2029            resolver.resolve_xref(&request("b", "numref", "fig-a")),
2030            XrefOutcome::Kept {
2031                warning: Some("numfig is disabled. :numref: is ignored.".to_string())
2032            }
2033        );
2034    }
2035
2036    /// The two ways a `{name}` can have nothing to fill it: no label entry
2037    /// at all (`figname is None` — "the link has no caption"), and a label
2038    /// whose section name is empty, which Sphinx's truthiness test sends
2039    /// down the `format(number=...)` path and straight into a `KeyError`.
2040    #[test]
2041    fn a_nameless_numref_target_reports_the_format_it_could_not_fill() {
2042        let mut env = BuildEnvironment::default();
2043        env.std.labels.insert(
2044            "captionless".to_string(),
2045            ("a".to_string(), "captionless".to_string(), String::new()),
2046        );
2047        env.std
2048            .anonlabels
2049            .insert("anon".to_string(), ("a".to_string(), "anon".to_string()));
2050        env.toc_fignumbers.insert(
2051            "a".to_string(),
2052            BTreeMap::from([(
2053                "figure".to_string(),
2054                BTreeMap::from([
2055                    ("captionless".to_string(), vec![1]),
2056                    ("anon".to_string(), vec![2]),
2057                ]),
2058            )]),
2059        );
2060        let mut root = Node::elem(kinds::DOCUMENT, crate::doctree::Span::ZERO);
2061        for id in ["captionless", "anon"] {
2062            let mut figure = Node::elem("figure", crate::doctree::Span::ZERO);
2063            figure.attrs.ids.push(id.to_string());
2064            root.children.push(figure);
2065        }
2066        let doctree = Doctree {
2067            root,
2068            sources: vec!["<test>".to_string()],
2069        };
2070        let formats = BTreeMap::from([("figure".to_string(), "Fig. {name} {number}".to_string())]);
2071        let resolver = Resolver {
2072            env: &env,
2073            numfig: true,
2074            numfig_format: &formats,
2075            doctree: &|_| Some(Cow::Borrowed(&doctree)),
2076            relative_uri: &|_, _| String::new(),
2077            intersphinx: &INERT,
2078        };
2079
2080        assert_eq!(
2081            resolver.resolve_xref(&request("b", "numref", "anon")),
2082            XrefOutcome::Kept {
2083                warning: Some("the link has no caption: Fig. {name} {number}".to_string())
2084            },
2085            "an anonymous-only label has no caption to name"
2086        );
2087        assert_eq!(
2088            resolver.resolve_xref(&request("b", "numref", "captionless")),
2089            XrefOutcome::Kept {
2090                warning: Some(
2091                    "invalid numfig_format: Fig. {name} {number} (KeyError('name'))".to_string()
2092                )
2093            },
2094            "an empty caption is falsy, so `name` is never passed to format()"
2095        );
2096    }
2097
2098    /// A label on a real figure that numbering never reached — an orphaned
2099    /// document's, say — is `get_fignumber`'s `ValueError`.
2100    #[test]
2101    fn a_numref_target_with_no_number_names_the_label_it_could_not_number() {
2102        let mut env = BuildEnvironment::default();
2103        env.std.labels.insert(
2104            "fig-a".to_string(),
2105            ("a".to_string(), "fig-a".to_string(), "A Figure".to_string()),
2106        );
2107        let mut figure = Node::elem("figure", crate::doctree::Span::ZERO);
2108        figure.attrs.ids.push("fig-a".to_string());
2109        let mut root = Node::elem(kinds::DOCUMENT, crate::doctree::Span::ZERO);
2110        root.children.push(figure);
2111        let doctree = Doctree {
2112            root,
2113            sources: vec!["<test>".to_string()],
2114        };
2115        let formats = BTreeMap::from([("figure".to_string(), "Fig. %s".to_string())]);
2116        let resolver = Resolver {
2117            env: &env,
2118            numfig: true,
2119            numfig_format: &formats,
2120            doctree: &|_| Some(Cow::Borrowed(&doctree)),
2121            relative_uri: &|_, _| String::new(),
2122            intersphinx: &INERT,
2123        };
2124
2125        assert_eq!(
2126            resolver.resolve_xref(&request("b", "numref", "fig-a")),
2127            XrefOutcome::Kept {
2128                warning: Some(
2129                    "Failed to create a cross reference. Any number is not assigned: fig-a"
2130                        .to_string()
2131                )
2132            }
2133        );
2134    }
2135
2136    #[test]
2137    fn numref_renders_both_format_styles_and_reports_broken_ones() {
2138        assert_eq!(format_old_style("Fig. %s", "1.2").unwrap(), "Fig. 1.2");
2139        assert!(
2140            format_old_style("Fig.", "1").is_err(),
2141            "no conversion: TypeError"
2142        );
2143        assert!(
2144            format_old_style("%s %s", "1").is_err(),
2145            "two conversions for one argument: TypeError"
2146        );
2147        assert_eq!(
2148            format_new_style("Custom {name} number {number}", Some("Cap"), "1").unwrap(),
2149            "Custom Cap number 1"
2150        );
2151        assert_eq!(
2152            format_new_style("Table {number}", None, "3").unwrap(),
2153            "Table 3"
2154        );
2155        let err = format_new_style("{nope}", Some("Cap"), "1").err().unwrap();
2156        assert_eq!(err.0, "nope");
2157    }
2158
2159    #[test]
2160    fn dangling_warnings_use_the_exact_sphinx_texts() {
2161        let env = env_with_label();
2162        let nitpick = NitpickConfig {
2163            nitpicky: false,
2164            ignore: &[],
2165            ignore_regex: &[],
2166        };
2167        let warn = |typ: &str, target: &str| {
2168            missing_reference_warning(&env, &nitpick, "std", typ, target, true)
2169        };
2170        assert_eq!(
2171            warn("doc", "missing-doc").unwrap(),
2172            "unknown document: 'missing-doc'"
2173        );
2174        assert_eq!(
2175            warn("term", "nonexistent term").unwrap(),
2176            "term not in glossary: 'nonexistent term'"
2177        );
2178        assert_eq!(warn("option", "--x").unwrap(), "unknown option: '--x'");
2179        assert_eq!(warn("keyword", "k").unwrap(), "unknown keyword: 'k'");
2180        assert_eq!(warn("numref", "fig").unwrap(), "undefined label: 'fig'");
2181        assert_eq!(warn("ref", "nope").unwrap(), "undefined label: 'nope'");
2182        assert_eq!(
2183            warn("ref", "the-label").unwrap(),
2184            "Failed to create a cross reference. A title or caption not found: 'the-label'",
2185            "a label that exists but has no title takes the other branch"
2186        );
2187        assert_eq!(
2188            warn("envvar", "PATH").unwrap(),
2189            "'envvar' reference target not found: PATH",
2190            "a role with no dangling_warnings entry takes the generic form"
2191        );
2192    }
2193
2194    #[test]
2195    fn a_role_that_is_not_warn_dangling_only_warns_under_nitpicky() {
2196        let env = env_with_label();
2197        let quiet = NitpickConfig {
2198            nitpicky: false,
2199            ignore: &[],
2200            ignore_regex: &[],
2201        };
2202        assert_eq!(
2203            missing_reference_warning(&env, &quiet, "std", "envvar", "PATH", false),
2204            None
2205        );
2206        let nitpicky = NitpickConfig {
2207            nitpicky: true,
2208            ignore: &[],
2209            ignore_regex: &[],
2210        };
2211        assert!(
2212            missing_reference_warning(&env, &nitpicky, "std", "envvar", "PATH", false).is_some()
2213        );
2214    }
2215
2216    // ---- the :any: candidate walk (`_resolve_pending_any_xref`) ----------
2217
2218    /// Candidate labels for the walk over one request.
2219    fn any_roles(resolver: &Resolver<'_>, req: &XrefRequest<'_>) -> Vec<(String, String)> {
2220        resolver
2221            .resolve_any(req, None, None, None)
2222            .into_iter()
2223            .map(|candidate| (candidate.role, candidate.label))
2224            .collect()
2225    }
2226
2227    /// The std half's candidate sets, in walk order: `:doc:` first (role
2228    /// `'doc'`, unprefixed), then `'ref'` over the LOWERCASED target, then
2229    /// `'option'`, then the objects table in `object_types` order.
2230    #[test]
2231    fn any_walks_doc_then_ref_then_option_then_the_objects_table() {
2232        let mut env = BuildEnvironment::default();
2233        env.all_docs.insert("same".to_string(), 0);
2234        let mut title = Node::elem(kinds::TITLE, crate::doctree::Span::ZERO);
2235        title
2236            .children
2237            .push(Node::text_node("Doc Title", crate::doctree::Span::ZERO));
2238        env.titles.insert("same".to_string(), title);
2239        env.std.labels.insert(
2240            "same".to_string(),
2241            ("a".to_string(), "same".to_string(), "Sect".to_string()),
2242        );
2243        env.std.note_object("envvar", "same", "a", "envvar-same");
2244        env.py.note_object(
2245            "m.same",
2246            crate::env::py_domain::PyObjectEntry {
2247                docname: "a".to_string(),
2248                node_id: "m.same".to_string(),
2249                objtype: "function".to_string(),
2250                aliased: false,
2251            },
2252        );
2253        let formats = BTreeMap::new();
2254        let resolver = resolver(&env, &formats);
2255
2256        let req = request("a", "any", "same");
2257        assert_eq!(
2258            any_roles(&resolver, &req),
2259            vec![
2260                ("doc".to_string(), "Doc Title".to_string()),
2261                ("std:ref".to_string(), "Sect".to_string()),
2262                // make_refnode keeps the contnode, so the label is its text.
2263                ("std:envvar".to_string(), "same".to_string()),
2264                // py candidates label with the make_refnode reftitle.
2265                ("py:func".to_string(), "m.same".to_string()),
2266            ]
2267        );
2268    }
2269
2270    /// Only the `'ref'` arm lowercases; the objects walk lowercases the
2271    /// TERM key alone — so a glossary term registered with an uppercase
2272    /// letter is unreachable through `:any:` under either spelling
2273    /// (probe: `:any:`Aterm`` and `:any:`aterm`` both dangle).
2274    #[test]
2275    fn any_lowercases_the_ref_arm_and_the_term_key_only() {
2276        let mut env = BuildEnvironment::default();
2277        env.std.labels.insert(
2278            "mixed".to_string(),
2279            ("a".to_string(), "mixed".to_string(), "Sect".to_string()),
2280        );
2281        env.std.note_term("Aterm", "a", "term-Aterm");
2282        env.std.note_term("bterm", "a", "term-bterm");
2283        let formats = BTreeMap::new();
2284        let resolver = resolver(&env, &formats);
2285
2286        assert_eq!(
2287            any_roles(&resolver, &request("a", "any", "MIXED"))
2288                .iter()
2289                .map(|(role, _)| role.as_str())
2290                .collect::<Vec<_>>(),
2291            vec!["std:ref"],
2292            "the ref arm sees the lowercased target"
2293        );
2294        assert!(
2295            any_roles(&resolver, &request("a", "any", "Aterm")).is_empty(),
2296            "objects holds ('term', 'Aterm') but the walk asks for ('term', 'aterm')"
2297        );
2298        assert!(
2299            any_roles(&resolver, &request("a", "any", "aterm")).is_empty(),
2300            "and 'aterm' was never registered"
2301        );
2302        assert_eq!(
2303            any_roles(&resolver, &request("a", "any", "BTERM"))
2304                .iter()
2305                .map(|(role, _)| role.as_str())
2306                .collect::<Vec<_>>(),
2307            vec!["std:term"],
2308            "a lowercase-registered term is reachable under any case"
2309        );
2310    }
2311
2312    /// A module candidate's label is the full `_make_module_refnode`
2313    /// reftitle — the ambiguity warning renders it verbatim.
2314    #[test]
2315    fn any_module_candidates_label_with_the_synopsis_reftitle() {
2316        let mut env = BuildEnvironment::default();
2317        env.py.note_object(
2318            "syn",
2319            crate::env::py_domain::PyObjectEntry {
2320                docname: "a".to_string(),
2321                node_id: "module-syn".to_string(),
2322                objtype: "module".to_string(),
2323                aliased: false,
2324            },
2325        );
2326        env.py.note_module(
2327            "syn",
2328            crate::env::py_domain::PyModuleEntry {
2329                docname: "a".to_string(),
2330                node_id: "module-syn".to_string(),
2331                synopsis: "The syn module.".to_string(),
2332                platform: String::new(),
2333                deprecated: false,
2334            },
2335        );
2336        let formats = BTreeMap::new();
2337        let resolver = resolver(&env, &formats);
2338        assert_eq!(
2339            any_roles(&resolver, &request("a", "any", "syn")),
2340            vec![("py:mod".to_string(), "syn: The syn module.".to_string())]
2341        );
2342    }
2343
2344    #[test]
2345    fn a_missing_any_reference_warns_with_the_domainless_spelling() {
2346        let env = BuildEnvironment::default();
2347        let quiet = NitpickConfig {
2348            nitpicky: false,
2349            ignore: &[],
2350            ignore_regex: &[],
2351        };
2352        assert_eq!(
2353            missing_reference_warning(&env, &quiet, "", "any", "missing_thing", true).unwrap(),
2354            "'any' reference target not found: missing_thing"
2355        );
2356        // Nitpick-ignore matches the BARE ('any', target) pair — there is
2357        // no domain-qualified spelling for a domainless node.
2358        let ignore = vec![("any".to_string(), "missing_thing".to_string())];
2359        let nitpicky = NitpickConfig {
2360            nitpicky: true,
2361            ignore: &ignore,
2362            ignore_regex: &[],
2363        };
2364        assert_eq!(
2365            missing_reference_warning(&env, &nitpicky, "", "any", "missing_thing", true),
2366            None
2367        );
2368    }
2369
2370    #[test]
2371    fn nitpick_ignore_filters_by_exact_pair_and_by_regex() {
2372        let env = env_with_label();
2373        let exact = vec![("std:doc".to_string(), "missing".to_string())];
2374        let config = NitpickConfig {
2375            nitpicky: true,
2376            ignore: &exact,
2377            ignore_regex: &[],
2378        };
2379        assert_eq!(
2380            missing_reference_warning(&env, &config, "std", "doc", "missing", true),
2381            None
2382        );
2383        assert!(missing_reference_warning(&env, &config, "std", "doc", "other", true).is_some());
2384
2385        // The domainless form is accepted for std types too.
2386        let domainless = vec![("doc".to_string(), "missing".to_string())];
2387        let config = NitpickConfig {
2388            nitpicky: true,
2389            ignore: &domainless,
2390            ignore_regex: &[],
2391        };
2392        assert_eq!(
2393            missing_reference_warning(&env, &config, "std", "doc", "missing", true),
2394            None
2395        );
2396
2397        let regex = vec![("std:.*".to_string(), "miss.*".to_string())];
2398        let config = NitpickConfig {
2399            nitpicky: true,
2400            ignore: &[],
2401            ignore_regex: &regex,
2402        };
2403        assert_eq!(
2404            missing_reference_warning(&env, &config, "std", "doc", "missing", true),
2405            None
2406        );
2407        assert!(
2408            missing_reference_warning(&env, &config, "std", "doc", "hit", true).is_some(),
2409            "the regexes must both full-match, not merely find"
2410        );
2411    }
2412}