Skip to main content

sphinx_ultra/intersphinx/
mod.rs

1//! `sphinx.ext.intersphinx` — cross-project references resolved through
2//! other projects' `objects.inv` inventories.
3//!
4//! Ported from Sphinx 9.1.0's `sphinx/ext/intersphinx/` (`_load.py`,
5//! `_resolve.py`, `_shared.py`); every message text, ordering rule and
6//! fallback below is cited to the file:line it mirrors, collected in
7//! `docs/superpowers/plans/2026-08-31-m2-wave4-research-spec-inventory-intersphinx.md`
8//! §3-§4.
9//!
10//! The three phases, in the order a build runs them:
11//!
12//! 1. **Validation** ([`validate_mapping`]) normalises `intersphinx_mapping`
13//!    at configuration load. Any failure is a `ConfigError` in Sphinx, which
14//!    aborts the build — here it aborts config loading, which the CLI turns
15//!    into exit code 2.
16//! 2. **Loading** ([`load_mappings`]) reads each project's inventory: local
17//!    files always, remote ones through an [`InventoryFetcher`] with an
18//!    on-disk cache. The merged "main" inventory plus the per-name ones are
19//!    the [`IntersphinxData`].
20//! 3. **Resolution** ([`Intersphinx::resolve_detect`] and friends) runs from
21//!    the reference resolver, standing exactly where Sphinx's
22//!    `missing-reference` event does: after the local domain has failed and
23//!    before the dangling-reference warning.
24
25pub mod fetch;
26
27use std::collections::{BTreeMap, BTreeSet};
28use std::path::{Path, PathBuf};
29
30use serde_json::Value as JsonValue;
31
32use crate::inventory::{posix_join, Inventory, InventoryFile, InventoryItem};
33use crate::utils::py_repr_str;
34
35pub use fetch::{HttpConfig, InventoryFetcher, TlsCacerts, UreqFetcher, DEFAULT_USER_AGENT};
36
37/// The file name intersphinx appends to a target URI when a mapping gives no
38/// explicit inventory location (`INVENTORY_FILENAME`, `_load.py:262-267`).
39const INVENTORY_FILENAME: &str = "objects.inv";
40
41/// The on-disk cache directory, relative to the doctree/cache directory
42/// (`app.doctreedir / '__intersphinx_cache__'`, `_load.py:186-190`). Sphinx
43/// documents that "the location of this cache directory must not be relied
44/// upon externally"; ours rides the fingerprint-wiped cache dir, so a
45/// configuration change clears it along with everything else.
46pub const CACHE_DIR_NAME: &str = "__intersphinx_cache__";
47
48/// The normalised `intersphinx_mapping`: project name -> (target URI,
49/// inventory locations). Sphinx stores `{name: (name, (uri, locations))}`
50/// with the name duplicated inside the value (`_load.py:129`); the key is
51/// the same string, so this drops the duplicate.
52///
53/// A location of `None` means "`objects.inv` under the target URI".
54pub type IntersphinxMapping = BTreeMap<String, (String, Vec<Option<String>>)>;
55
56// ---------------------------------------------------------------------------
57// 1. Mapping validation (`validate_intersphinx_mapping`, `_load.py:38-136`)
58// ---------------------------------------------------------------------------
59
60/// Validate and normalise a raw `intersphinx_mapping` value as parsed from
61/// `conf.py`, returning the entries that survived and the error messages for
62/// the ones that did not.
63///
64/// The messages are Sphinx's own, verbatim (`_load.py:59-125`, checks 1-6 in
65/// the research spec §3). Sphinx logs each with `LOGGER.error` and then
66/// raises `ConfigError` if any fired; [`mapping_config_error`] is that final
67/// message.
68///
69/// Two knowing divergences, both forced by going through JSON rather than
70/// live Python objects:
71///
72/// * Entries are visited in *key order*, not `conf.py` order, because the
73///   parsed dict is a sorted map. This only shows in check 5, which names
74///   the other entry that claimed a duplicate target URI.
75/// * `%r` of a sequence renders with list brackets, since a `conf.py` tuple
76///   and list both arrive as a JSON array. Checks 2, 3 and 6 can therefore
77///   print `['a', 'b', 'c']` where Sphinx prints `('a', 'b', 'c')`.
78///
79/// Check 1 is also narrower here than in Sphinx: a *non-string* key cannot
80/// reach this function, because the conf.py literal parser rejects the whole
81/// dict (with its own dropped-value warning) before it gets here. The empty
82/// string is the only identifier this check can still fire on.
83pub fn validate_mapping(raw: &JsonValue) -> (IntersphinxMapping, Vec<String>) {
84    let mut mapping = IntersphinxMapping::new();
85    let mut errors = Vec::new();
86    let Some(entries) = raw.as_object() else {
87        // Sphinx's config machinery guarantees a dict here; anything else
88        // would be an AttributeError inside `validate_intersphinx_mapping`.
89        // Reporting nothing is the honest answer: we have no message of
90        // Sphinx's to reuse, and inventing one would be worse than treating
91        // the setting as absent.
92        return (mapping, errors);
93    };
94
95    // uri -> the name that claimed it first (`seen`, `_load.py:53`).
96    let mut seen: BTreeMap<&str, &str> = BTreeMap::new();
97
98    for (name, value) in entries {
99        if name.is_empty() {
100            errors.push(format!(
101                "Invalid intersphinx project identifier `{}` in intersphinx_mapping. \
102                 Project identifiers must be non-empty strings.",
103                py_repr_value(&JsonValue::String(name.clone()))
104            ));
105            continue;
106        }
107
108        let Some(pair) = value.as_array() else {
109            errors.push(format!(
110                "Invalid value `{}` in intersphinx_mapping[{}]. \
111                 Expected a two-element tuple or list.",
112                py_repr_value(value),
113                py_repr_str(name)
114            ));
115            continue;
116        };
117        if pair.len() != 2 {
118            errors.push(format!(
119                "Invalid value `{}` in intersphinx_mapping[{}]. \
120                 Values must be a (target URI, inventory locations) pair.",
121                py_repr_value(value),
122                py_repr_str(name)
123            ));
124            continue;
125        }
126        let (uri, inv) = (&pair[0], &pair[1]);
127
128        let uri = match uri.as_str() {
129            Some(uri) if !uri.is_empty() => uri,
130            _ => {
131                errors.push(format!(
132                    "Invalid target URI value `{}` in intersphinx_mapping[{}][0]. \
133                     Target URIs must be unique non-empty strings.",
134                    py_repr_value(uri),
135                    py_repr_str(name)
136                ));
137                continue;
138            }
139        };
140        if let Some(other) = seen.get(uri) {
141            errors.push(format!(
142                "Invalid target URI value `{}` in intersphinx_mapping[{}][0]. \
143                 Target URIs must be unique (other instance in intersphinx_mapping[{}]).",
144                py_repr_str(uri),
145                py_repr_str(name),
146                py_repr_str(other)
147            ));
148            continue;
149        }
150        seen.insert(uri, name);
151
152        // `if not isinstance(inv, (tuple, list)): inv = (inv,)`.
153        let locations: Vec<&JsonValue> = match inv.as_array() {
154            Some(items) => items.iter().collect(),
155            None => vec![inv],
156        };
157        let mut targets = Vec::with_capacity(locations.len());
158        for location in locations {
159            match location {
160                JsonValue::Null => targets.push(None),
161                JsonValue::String(s) if !s.is_empty() => targets.push(Some(s.clone())),
162                other => errors.push(format!(
163                    "Invalid inventory location value `{}` in intersphinx_mapping[{}][1]. \
164                     Inventory locations must be non-empty strings or None.",
165                    py_repr_value(other),
166                    py_repr_str(name)
167                )),
168            }
169        }
170
171        // Sphinx's `continue` here only leaves the *inner* loop, so the
172        // entry is re-added with whatever targets did validate even though
173        // it was just deleted (`_load.py:113-129`). Faithfully reproduced —
174        // it is unobservable anyway, since any error aborts the build.
175        mapping.insert(name.clone(), (uri.to_string(), targets));
176    }
177
178    (mapping, errors)
179}
180
181/// The `ConfigError` Sphinx raises once validation has logged its errors
182/// (`_load.py:131-136`).
183pub fn mapping_config_error(errors: usize) -> String {
184    if errors == 1 {
185        "Invalid `intersphinx_mapping` configuration (1 error).".to_string()
186    } else {
187        format!("Invalid `intersphinx_mapping` configuration ({errors} errors).")
188    }
189}
190
191/// Python's `repr()` for the JSON shapes a `conf.py` literal can produce.
192/// Sequences render as lists — see [`validate_mapping`]'s doc comment for
193/// why a tuple cannot be told apart here.
194fn py_repr_value(value: &JsonValue) -> String {
195    match value {
196        JsonValue::Null => "None".to_string(),
197        JsonValue::Bool(true) => "True".to_string(),
198        JsonValue::Bool(false) => "False".to_string(),
199        JsonValue::Number(n) => n.to_string(),
200        JsonValue::String(s) => py_repr_str(s),
201        JsonValue::Array(items) => {
202            let inner: Vec<String> = items.iter().map(py_repr_value).collect();
203            format!("[{}]", inner.join(", "))
204        }
205        JsonValue::Object(map) => {
206            let inner: Vec<String> = map
207                .iter()
208                .map(|(k, v)| format!("{}: {}", py_repr_str(k), py_repr_value(v)))
209                .collect();
210            format!("{{{}}}", inner.join(", "))
211        }
212    }
213}
214
215// ---------------------------------------------------------------------------
216// 2. Loading (`load_mappings` / `_fetch_inventory_group`, `_load.py:139-335`)
217// ---------------------------------------------------------------------------
218
219/// The loaded inventories: the merged "main" one every un-named lookup goes
220/// through, and the per-project ones an `inv:target` or `:external+inv:`
221/// reference names (`_shared.py:114-149`, `InventoryAdapter`).
222#[derive(Debug, Clone, Default, PartialEq)]
223pub struct IntersphinxData {
224    pub main: Inventory,
225    pub named: BTreeMap<String, Inventory>,
226}
227
228impl IntersphinxData {
229    pub fn is_empty(&self) -> bool {
230        self.named.is_empty()
231    }
232
233    /// `inventory_exists(env, inv_name)` (`_resolve.py:255-256`).
234    pub fn inventory_exists(&self, name: &str) -> bool {
235        self.named.contains_key(name)
236    }
237}
238
239/// Everything [`load_mappings`] needs from the build.
240pub struct LoadRequest<'a> {
241    pub mapping: &'a IntersphinxMapping,
242    /// Local inventory locations resolve against the *source* directory
243    /// (`srcdir / inv_location`, `_load.py:424-427`).
244    pub srcdir: &'a Path,
245    /// `<cache_dir>/__intersphinx_cache__`, or `None` to disable the disk
246    /// cache entirely (Sphinx passes `None` from `fetch_inventory`).
247    pub cache_dir: Option<PathBuf>,
248    /// `intersphinx_cache_limit`, in days. Negative means never expire
249    /// (`_load.py:250-257`).
250    pub cache_limit: i64,
251    /// `int(time.time())`, injectable so cache-expiry behaviour is testable.
252    pub now: i64,
253    pub http: &'a HttpConfig,
254}
255
256/// What loading produced, plus the diagnostics it wants reported.
257#[derive(Debug, Default)]
258pub struct LoadOutcome {
259    pub data: IntersphinxData,
260    /// `LOGGER.warning` messages — the all-locations-failed report
261    /// (`_load.py:330-334`). Logged without a `type`, so they render with no
262    /// `[category]` suffix.
263    pub warnings: Vec<String>,
264    /// `LOGGER.info` messages, in the order Sphinx emits them.
265    pub infos: Vec<String>,
266}
267
268/// Read every configured inventory (`load_mappings`, `_load.py:139-208`).
269///
270/// **Cache design.** Sphinx keeps `env.intersphinx_cache` — a pickled
271/// `{uri: (name, expiry, inventory)}` — on the environment, so a warm
272/// incremental build can skip both the download *and* the parse. This port
273/// keeps only the on-disk half: `__intersphinx_cache__/{name}_objects.inv`
274/// holds the raw bytes, and the file's mtime is the expiry basis, exactly as
275/// Sphinx's disk short-circuit uses it (`_load.py:274-287`). A warm rebuild
276/// therefore still skips the download and only re-parses, which costs
277/// milliseconds — while keeping `BuildEnvironment` free of a
278/// non-`serde`-shaped field that would have to version-lock with `env.bin`.
279/// The in-memory map below is per-call, and exists so the merge order and
280/// the cache-hit checks stay byte-faithful to Sphinx's.
281///
282/// Two things Sphinx's memory cache does that this one cannot:
283///
284/// 1. **Pruning.** Sphinx drops a cached entry whose project changed target
285///    URI (`_load.py:164-173`), where a disk file keyed by project name alone
286///    would otherwise serve bytes fetched from somewhere else. Covered a
287///    layer up here: changing a target URI changes `intersphinx_mapping`,
288///    changes the configuration fingerprint, and wipes the whole cache
289///    directory this file lives in.
290/// 2. **Graceful degradation, which is genuinely lost.** In Sphinx the
291///    previous inventory stays in `env.intersphinx_cache` across builds, so
292///    when an *expired* remote's refresh fails the old data is still there
293///    and references still resolve — an offline or flaky-network build keeps
294///    working. Here a failed refresh leaves nothing behind: the project
295///    contributes no inventory, and every reference into it becomes a
296///    dangling warning (and fails `-W`). The stale bytes are still on disk;
297///    falling back to them on fetch failure is the obvious remedy and is
298///    deliberately **not** implemented here — it is a behaviour change from
299///    Sphinx, not a port of it, and belongs to T13/post-wave with its own
300///    decision about how stale is too stale.
301pub fn load_mappings(
302    request: &LoadRequest<'_>,
303    fetcher: &dyn InventoryFetcher,
304) -> anyhow::Result<LoadOutcome> {
305    let mut outcome = LoadOutcome::default();
306    if request.mapping.is_empty() {
307        return Ok(outcome);
308    }
309
310    // `_IntersphinxProject`'s invariants (`_shared.py:60-83`), checked before
311    // any fetching starts, exactly as `load_mappings` builds every project up
312    // front: a violation is a `ConfigError` that aborts the build
313    // (`_load.py:150-160`). Validation guarantees the name and target URI are
314    // non-empty and every location is `None`-or-non-empty, so an empty
315    // location *tuple* — `('https://x/', ())` — is the one invariant that can
316    // still fail here.
317    if request
318        .mapping
319        .values()
320        .any(|(_, locations)| locations.is_empty())
321    {
322        anyhow::bail!("An invalid intersphinx_mapping entry was added after normalisation.");
323    }
324
325    // uri -> (name, expiry, inventory) — Sphinx's `intersphinx_cache`.
326    let mut cache: Vec<(String, i64, Inventory)> = Vec::new();
327
328    for (name, (target_uri, locations)) in request.mapping {
329        fetch_inventory_group(
330            request,
331            fetcher,
332            name,
333            target_uri,
334            locations,
335            &mut cache,
336            &mut outcome,
337        );
338    }
339
340    // "Duplicate values in different inventories will shadow each other" —
341    // sorted by `(name, expiry)` so the winner is at least deterministic
342    // (`_load.py:196-208`). Later entries shadow earlier ones.
343    cache.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
344    for (name, _expiry, inventory) in cache {
345        for (objtype, objects) in &inventory.data {
346            outcome
347                .data
348                .main
349                .data
350                .entry(objtype.clone())
351                .or_default()
352                .extend(objects.iter().map(|(k, v)| (k.clone(), v.clone())));
353        }
354        outcome.data.named.insert(name, inventory);
355    }
356
357    Ok(outcome)
358}
359
360#[allow(clippy::too_many_arguments)]
361fn fetch_inventory_group(
362    request: &LoadRequest<'_>,
363    fetcher: &dyn InventoryFetcher,
364    name: &str,
365    target_uri: &str,
366    locations: &[Option<String>],
367    cache: &mut Vec<(String, i64, Inventory)>,
368    outcome: &mut LoadOutcome,
369) {
370    // A positive limit expires the cache `limit` days back; a negative one
371    // never expires it (`_load.py:250-257`).
372    let cache_time = if request.cache_limit >= 0 {
373        request.now - request.cache_limit * 86400
374    } else {
375        0
376    };
377
378    let cache_path = request
379        .cache_dir
380        .as_ref()
381        .map(|dir| dir.join(format!("{name}_{INVENTORY_FILENAME}")));
382
383    // The URI every resolved link is built from. Basic-auth credentials are
384    // stripped from it before it is ever joined onto an inventory entry
385    // (`_fetch_inventory_data`, `_load.py:361-364`) — otherwise the password
386    // in `https://user:pw@example.org/` would be published in the `href` of
387    // every cross-project reference. The inventory *location* keeps its
388    // credentials: that one has to authenticate.
389    //
390    // (Sphinx skips this on its disk-cache path, which loads with the
391    // unstripped `project.target_uri` (`_load.py:279`); this port strips on
392    // both paths, because reproducing a credential leak for bug-parity is
393    // not parity worth having.)
394    let link_uri = if target_uri.contains("://") {
395        strip_basic_auth(target_uri)
396    } else {
397        target_uri.to_string()
398    };
399
400    let mut failures: Vec<String> = Vec::new();
401    // Whether any location ultimately produced an inventory. Sphinx decides
402    // between its two failure reports with `len(failures) < len(locations)`,
403    // which assumes each location contributes at most one failure — but the
404    // disk-cache short-circuit here can fail *and* the same location then
405    // succeed, and that arithmetic would report a successful load as "failed
406    // to reach any of the inventories". Tracking the outcome directly says
407    // the same thing in every case Sphinx's count was right about, and the
408    // truth in the one it was not.
409    let mut succeeded = false;
410    let locations: Vec<Option<String>> = locations.to_vec();
411
412    for location in &locations {
413        let inv_location = match location {
414            Some(location) => location.clone(),
415            // Built from the *unstripped* target URI: this one is fetched,
416            // so it is the one that still needs the credentials
417            // (`_load.py:262-267` runs before the strip).
418            None => posix_join(target_uri, INVENTORY_FILENAME),
419        };
420        let remote = inv_location.contains("://");
421
422        // Disk-cache short-circuit: remote locations only, and only while
423        // the saved copy is younger than the expiry (`_load.py:274-287`).
424        if let Some(cache_path) = cache_path.as_ref().filter(|_| remote) {
425            if let Some(mtime) = file_mtime(cache_path) {
426                if mtime >= cache_time {
427                    match std::fs::read(cache_path)
428                        .map_err(|e| read_failure(&inv_location, &e))
429                        .and_then(|raw| load_inventory(&raw, &link_uri))
430                    {
431                        Ok(inventory) => {
432                            cache.push((name.to_string(), mtime, inventory));
433                            succeeded = true;
434                            break;
435                        }
436                        Err(message) => failures.push(message),
437                    }
438                }
439            }
440        }
441
442        // Local files are always re-read; remote ones only when the cache
443        // has expired (`_load.py:289-295`).
444        outcome.infos.push(format!(
445            "loading intersphinx inventory '{name}' from {} ...",
446            safe_url(&inv_location)
447        ));
448        let fetched = if remote {
449            fetcher
450                .fetch(&inv_location, request.http)
451                .map_err(|e| fetch_failure(&inv_location, &e))
452                .inspect(|raw| {
453                    if let Some(cache_path) = cache_path.as_ref() {
454                        write_disk_cache(cache_path, raw);
455                    }
456                })
457        } else {
458            let path = request.srcdir.join(&inv_location);
459            std::fs::read(&path).map_err(|e| read_failure(&inv_location, &e))
460        };
461
462        match fetched.and_then(|raw| load_inventory(&raw, &link_uri)) {
463            Ok(inventory) => {
464                cache.push((name.to_string(), request.now, inventory));
465                succeeded = true;
466                break;
467            }
468            Err(message) => failures.push(message),
469        }
470    }
471
472    // Sphinx reports whatever failed even when a later location worked
473    // (`_load.py:319-334`): all-failed is one warning, some-failed with a
474    // working alternative is a set of infos.
475    if failures.is_empty() {
476    } else if succeeded {
477        outcome.infos.push(
478            "encountered some issues with some of the inventories, \
479             but they had working alternatives:"
480                .to_string(),
481        );
482        outcome.infos.extend(failures);
483    } else {
484        outcome.warnings.push(format!(
485            "failed to reach any of the inventories with the following issues:\n{}",
486            failures.join("\n")
487        ));
488    }
489}
490
491/// `_load_inventory` (`_load.py:377-385`): **any** parse failure is
492/// re-labelled as an unsupported-version error, wrapping the original
493/// `ValueError`'s repr — even when the original was a header error.
494fn load_inventory(raw: &[u8], target_uri: &str) -> Result<Inventory, String> {
495    InventoryFile::loads(raw, target_uri).map_err(|err| {
496        format!(
497            "unknown or unsupported inventory version: ValueError({})",
498            py_repr_str(&err.to_string())
499        )
500    })
501}
502
503/// `_fetch_inventory_file`'s error rewrite (`_load.py:428-435`).
504///
505/// The `%s: %s` halves are Python's exception class *name* and `str(err)`,
506/// which have no exact Rust equivalent; the class name is mapped from the
507/// I/O error kind and the message is Rust's. The first `%r` — the inventory
508/// location, which is what makes the message useful — is exact.
509fn read_failure(inv_location: &str, err: &std::io::Error) -> String {
510    let class = match err.kind() {
511        std::io::ErrorKind::NotFound => "FileNotFoundError",
512        std::io::ErrorKind::PermissionDenied => "PermissionError",
513        std::io::ErrorKind::IsADirectory => "IsADirectoryError",
514        _ => "OSError",
515    };
516    format!(
517        "intersphinx inventory {} not readable due to {class}: {err}",
518        py_repr_str(inv_location)
519    )
520}
521
522/// `_fetch_inventory_url`'s error rewrite (`_load.py:401-408`). Sphinx
523/// interpolates `err.__class__` — the class *repr*, `<class 'x.Y'>`, not its
524/// name — which has no Rust counterpart; ours names the transport instead.
525fn fetch_failure(inv_location: &str, err: &anyhow::Error) -> String {
526    format!(
527        "intersphinx inventory {} not fetchable due to <class 'ureq.Error'>: {err}",
528        py_repr_str(inv_location)
529    )
530}
531
532fn write_disk_cache(cache_path: &Path, raw: &[u8]) {
533    if let Some(parent) = cache_path.parent() {
534        if std::fs::create_dir_all(parent).is_err() {
535            return;
536        }
537    }
538    // A cache that cannot be written is not a build failure; Sphinx would
539    // raise here, but losing the *cache* must never lose the *build*.
540    if let Err(e) = std::fs::write(cache_path, raw) {
541        log::debug!(
542            "could not write intersphinx disk cache {}: {e}",
543            cache_path.display()
544        );
545    }
546}
547
548/// The file's mtime in whole seconds since the epoch, or `None` if it is not
549/// a readable regular file (`cache_path.is_file()` + `stat().st_mtime`).
550fn file_mtime(path: &Path) -> Option<i64> {
551    let meta = std::fs::metadata(path).ok()?;
552    if !meta.is_file() {
553        return None;
554    }
555    let modified = meta.modified().ok()?;
556    match modified.duration_since(std::time::UNIX_EPOCH) {
557        Ok(since) => Some(since.as_secs() as i64),
558        Err(before) => Some(-(before.duration().as_secs() as i64)),
559    }
560}
561
562/// `_get_safe_url` (`_load.py:439-461`): the password is dropped from a
563/// `user:password@host` URL before it reaches a log line.
564pub fn safe_url(url: &str) -> String {
565    let Some((scheme, rest)) = url.split_once("://") else {
566        return url.to_string();
567    };
568    let (netloc, tail) = match rest.find(['/', '?', '#']) {
569        Some(end) => (&rest[..end], &rest[end..]),
570        None => (rest, ""),
571    };
572    let Some((userinfo, host)) = netloc.rsplit_once('@') else {
573        return url.to_string();
574    };
575    let user = userinfo.split_once(':').map_or(userinfo, |(user, _)| user);
576    format!("{scheme}://{user}@{host}{tail}")
577}
578
579/// `_strip_basic_auth` (`_load.py:464-482`): `user[:pass]@hostname` in the
580/// netloc becomes `hostname`.
581///
582/// Note the split semantics Sphinx uses — `frags[1].split('@')[1]`, the
583/// *second* field, not the last — so a netloc containing more than one `@`
584/// keeps everything after the first one, `@`s included. Reproduced rather
585/// than improved on: this decides what every published link says.
586pub fn strip_basic_auth(url: &str) -> String {
587    let Some((scheme, rest)) = url.split_once("://") else {
588        return url.to_string();
589    };
590    let (netloc, tail) = match rest.find(['/', '?', '#']) {
591        Some(end) => (&rest[..end], &rest[end..]),
592        None => (rest, ""),
593    };
594    match netloc.split_once('@') {
595        Some((_userinfo, host)) => format!("{scheme}://{host}{tail}"),
596        None => url.to_string(),
597    }
598}
599
600/// The redirect rule (`_load.py:410-419`), kept pure: after a fetch that
601/// ended somewhere other than where it started, the *target* URI is rewritten
602/// only when it was pointing at the inventory's own directory.
603///
604/// Not reachable in production — [`InventoryFetcher`] returns bytes, not the
605/// final URL — but the rule is pinned here so wiring a redirect-aware
606/// fetcher later is a change of plumbing, not of policy.
607pub fn redirect_target_uri(inv_location: &str, new_inv_location: &str, target_uri: &str) -> String {
608    if inv_location == new_inv_location {
609        return target_uri.to_string();
610    }
611    let dirname = posix_dirname(inv_location);
612    if target_uri == inv_location || target_uri == dirname || target_uri == format!("{dirname}/") {
613        return posix_dirname(new_inv_location);
614    }
615    target_uri.to_string()
616}
617
618/// `os.path.dirname` on a `/`-separated string.
619fn posix_dirname(path: &str) -> String {
620    match path.rfind('/') {
621        // `os.path.dirname('/x')` is `'/'`, not `''`.
622        Some(0) => "/".to_string(),
623        Some(idx) => path[..idx].to_string(),
624        None => String::new(),
625    }
626}
627
628// ---------------------------------------------------------------------------
629// 3. Domain tables
630// ---------------------------------------------------------------------------
631
632/// One domain's cross-reference surface, as intersphinx reads it off a
633/// `Domain`: `object_types` (objtype -> the roles that can name it) and
634/// `roles`.
635struct DomainSpec {
636    name: &'static str,
637    /// In declaration order — `objtypes_for_role` preserves it
638    /// (`domains/__init__.py:130-135` builds `_role2type` by iterating
639    /// `object_types`), and so does the `any` role's objtype sweep.
640    object_types: &'static [(&'static str, &'static [&'static str])],
641    roles: &'static [&'static str],
642}
643
644/// The domains this build knows, in `domains.sorted()` order (alphabetical
645/// by name, `domains/_domains_container.py:284-286`).
646///
647/// Only `py` and `std` are modelled: those are the domains sphinx-ultra
648/// itself populates, and they are the ones whose objects appear in the
649/// inventories real projects publish. A reference into any other domain
650/// (`c`, `cpp`, `js`, `rst`, `math`) is reported as unregistered, which is
651/// what `_resolve_reference` does for a domain that is not installed.
652const DOMAINS: &[DomainSpec] = &[
653    DomainSpec {
654        name: "py",
655        // `domains/python/__init__.py:725-737`.
656        object_types: &[
657            ("function", &["func", "obj"]),
658            ("data", &["data", "obj"]),
659            ("class", &["class", "exc", "obj"]),
660            ("exception", &["exc", "class", "obj"]),
661            ("method", &["meth", "obj"]),
662            ("classmethod", &["meth", "obj"]),
663            ("staticmethod", &["meth", "obj"]),
664            ("attribute", &["attr", "obj"]),
665            ("property", &["attr", "_prop", "obj"]),
666            ("type", &["type", "class", "obj"]),
667            ("module", &["mod", "obj"]),
668        ],
669        // `domains/python/__init__.py:755-767`.
670        roles: &[
671            "attr", "class", "const", "data", "deco", "exc", "func", "meth", "mod", "obj", "type",
672        ],
673    },
674    DomainSpec {
675        name: "std",
676        // `domains/std/__init__.py:729-737`.
677        object_types: &[
678            ("term", &["term"]),
679            ("token", &["token"]),
680            ("label", &["ref", "keyword"]),
681            ("confval", &["confval"]),
682            ("envvar", &["envvar"]),
683            ("cmdoption", &["option"]),
684            ("doc", &["doc"]),
685        ],
686        // `domains/std/__init__.py:748-766`.
687        roles: &[
688            "confval", "doc", "envvar", "keyword", "numref", "option", "ref", "term", "token",
689        ],
690    },
691];
692
693fn domain(name: &str) -> Option<&'static DomainSpec> {
694    DOMAINS.iter().find(|domain| domain.name == name)
695}
696
697impl DomainSpec {
698    /// `domain.objtypes_for_role(role)` — every objtype the role can name,
699    /// in `object_types` declaration order.
700    fn objtypes_for_role(&self, role: &str) -> Vec<&'static str> {
701        self.object_types
702            .iter()
703            .filter(|(_, roles)| roles.contains(&role))
704            .map(|(objtype, _)| *objtype)
705            .collect()
706    }
707
708    fn has_role(&self, role: &str) -> bool {
709        self.roles.contains(&role)
710    }
711
712    /// The roles that name `objtype`, for the "perhaps you meant one of"
713    /// hint (`_resolve.py:415-424`).
714    fn roles_for_objtype(&self, objtype: &str) -> Option<&'static [&'static str]> {
715        self.object_types
716            .iter()
717            .find(|(name, _)| *name == objtype)
718            .map(|(_, roles)| *roles)
719    }
720}
721
722// ---------------------------------------------------------------------------
723// 4. Resolution (`_resolve.py:36-347`)
724// ---------------------------------------------------------------------------
725
726/// The `pending_xref` attributes resolution reads.
727#[derive(Debug, Clone)]
728pub struct XrefQuery<'a> {
729    pub refdomain: &'a str,
730    pub reftype: &'a str,
731    pub reftarget: &'a str,
732    pub refexplicit: bool,
733    /// The document the reference was written in, which a document-relative
734    /// inventory URI is adjusted against (`_resolve.py:43-46`).
735    pub refdoc: &'a str,
736    /// `contnode.astext()`.
737    pub contnode_text: &'a str,
738}
739
740/// A reference into another project.
741#[derive(Debug, Clone, PartialEq, Eq)]
742pub struct Resolution {
743    pub refuri: String,
744    /// The hover title, `(in Project vX)` (`_resolve.py:47-55`).
745    pub reftitle: String,
746    /// `None` keeps the content node as parsed; `Some` replaces its text
747    /// (`_resolve.py:57-77`).
748    pub title: Option<String>,
749}
750
751/// A diagnostic resolution wants logged, with the `type.subtype` category
752/// Sphinx gives it.
753#[derive(Debug, Clone, PartialEq, Eq)]
754pub struct Diagnostic {
755    pub message: String,
756    pub category: Option<String>,
757}
758
759impl Diagnostic {
760    /// `type='intersphinx', subtype='external'` — every message the
761    /// inventory lookups and the `:external:` role raise.
762    fn external(message: String) -> Self {
763        Self {
764            message,
765            category: Some("intersphinx.external".to_string()),
766        }
767    }
768}
769
770/// What the missing-reference hook decided.
771#[derive(Debug, Clone, PartialEq, Eq)]
772pub enum HookOutcome {
773    /// Resolved into another project.
774    Resolved(Resolution),
775    /// The target named `intersphinx_resolve_self`: the reference points at
776    /// *this* project, and the caller must retry the local domain with the
777    /// carried target (`_resolve.py:326-333` +
778    /// `post_transforms/__init__.py:140-154`).
779    SelfReferential(String),
780    /// Nothing matched; the caller proceeds to its dangling warning.
781    Missing,
782}
783
784/// The loaded inventories plus the two configuration values resolution
785/// consults.
786#[derive(Debug, Clone, Default)]
787pub struct Intersphinx {
788    pub data: IntersphinxData,
789    /// `intersphinx_disabled_reftypes`, default `['std:doc']`.
790    pub disabled_reftypes: BTreeSet<String>,
791    /// `intersphinx_resolve_self`, default `''` (disabled).
792    pub resolve_self: String,
793}
794
795impl Intersphinx {
796    pub fn is_empty(&self) -> bool {
797        self.data.is_empty()
798    }
799
800    /// `resolve_reference_detect_inventory` (`_resolve.py:305-340`) — what
801    /// the `missing-reference` event calls.
802    ///
803    /// Tries the merged inventory with the target as written, then splits
804    /// the target on its first `:` into `inv_name:target` and retries inside
805    /// that named inventory. The prefixed form deliberately bypasses
806    /// `intersphinx_disabled_reftypes`.
807    pub fn resolve_detect(
808        &self,
809        query: &XrefQuery<'_>,
810        diagnostics: &mut Vec<Diagnostic>,
811    ) -> HookOutcome {
812        if let Some(resolution) = self.resolve_any(true, query, diagnostics) {
813            return HookOutcome::Resolved(resolution);
814        }
815        let Some((inv_name, new_target)) = query.reftarget.split_once(':') else {
816            return HookOutcome::Missing;
817        };
818        if !self.resolve_self.is_empty() && self.resolve_self == inv_name {
819            return HookOutcome::SelfReferential(new_target.to_string());
820        }
821        if !self.data.inventory_exists(inv_name) {
822            return HookOutcome::Missing;
823        }
824        // The target is rewritten for the lookup and restored afterwards,
825        // which is why the dangling warning still names the written target.
826        let prefixed = XrefQuery {
827            reftarget: new_target,
828            ..query.clone()
829        };
830        match self.resolve_in_inventory(inv_name, &prefixed, diagnostics) {
831            Some(resolution) => HookOutcome::Resolved(resolution),
832            None => HookOutcome::Missing,
833        }
834    }
835
836    /// `resolve_reference_in_inventory` (`_resolve.py:258-277`): an explicit
837    /// inventory never honours the disabled reftypes.
838    pub fn resolve_in_inventory(
839        &self,
840        inv_name: &str,
841        query: &XrefQuery<'_>,
842        diagnostics: &mut Vec<Diagnostic>,
843    ) -> Option<Resolution> {
844        let inventory = self.data.named.get(inv_name)?;
845        self.resolve_reference(Some(inv_name), inventory, false, query, diagnostics)
846    }
847
848    /// `resolve_reference_any_inventory` (`_resolve.py:280-302`).
849    pub fn resolve_any(
850        &self,
851        honor_disabled: bool,
852        query: &XrefQuery<'_>,
853        diagnostics: &mut Vec<Diagnostic>,
854    ) -> Option<Resolution> {
855        self.resolve_reference(None, &self.data.main, honor_disabled, query, diagnostics)
856    }
857
858    /// `_resolve_reference` (`_resolve.py:186-253`).
859    fn resolve_reference(
860        &self,
861        inv_name: Option<&str>,
862        inventory: &Inventory,
863        honor_disabled: bool,
864        query: &XrefQuery<'_>,
865        diagnostics: &mut Vec<Diagnostic>,
866    ) -> Option<Resolution> {
867        // "disabling should only be done if no inventory is given".
868        let honor_disabled = honor_disabled && inv_name.is_none();
869        if honor_disabled && self.disabled_reftypes.contains("*") {
870            return None;
871        }
872
873        if query.reftype == "any" {
874            for spec in DOMAINS {
875                if honor_disabled && self.disabled_reftypes.contains(&format!("{}:*", spec.name)) {
876                    continue;
877                }
878                let objtypes: Vec<&str> = spec
879                    .object_types
880                    .iter()
881                    .map(|(objtype, _)| *objtype)
882                    .collect();
883                if let Some(resolution) = self.resolve_reference_in_domain(
884                    inv_name,
885                    inventory,
886                    honor_disabled,
887                    spec,
888                    &objtypes,
889                    query,
890                    diagnostics,
891                ) {
892                    return Some(resolution);
893                }
894            }
895            return None;
896        }
897
898        if query.refdomain.is_empty() {
899            // Only objects in domains are in the inventory.
900            return None;
901        }
902        if honor_disabled
903            && self
904                .disabled_reftypes
905                .contains(&format!("{}:*", query.refdomain))
906        {
907            return None;
908        }
909        // Sphinx raises `ExtensionError('Domain %r is not registered')` for
910        // an unknown domain. Ours cannot: a reference into a domain this
911        // build does not implement is an everyday occurrence here, not a
912        // configuration bug, so it simply does not resolve.
913        let spec = domain(query.refdomain)?;
914        let objtypes = spec.objtypes_for_role(query.reftype);
915        if objtypes.is_empty() {
916            return None;
917        }
918        self.resolve_reference_in_domain(
919            inv_name,
920            inventory,
921            honor_disabled,
922            spec,
923            &objtypes,
924            query,
925            diagnostics,
926        )
927    }
928
929    /// `_resolve_reference_in_domain` (`_resolve.py:136-191`), including the
930    /// two backwards-compatibility objtype shims.
931    #[allow(clippy::too_many_arguments)]
932    fn resolve_reference_in_domain(
933        &self,
934        inv_name: Option<&str>,
935        inventory: &Inventory,
936        honor_disabled: bool,
937        spec: &DomainSpec,
938        objtypes: &[&str],
939        query: &XrefQuery<'_>,
940        diagnostics: &mut Vec<Diagnostic>,
941    ) -> Option<Resolution> {
942        // An insertion-ordered set: `dict.fromkeys(objtypes)` with the two
943        // compatibility additions appended.
944        let mut obj_types: Vec<String> = Vec::with_capacity(objtypes.len() + 1);
945        for objtype in objtypes {
946            if !obj_types.iter().any(|existing| existing == objtype) {
947                obj_types.push((*objtype).to_string());
948            }
949        }
950        // "cmdoptions were stored as std:option until Sphinx 1.6".
951        if spec.name == "std" && objtypes.contains(&"cmdoption") {
952            obj_types.push("option".to_string());
953        }
954        // "properties are stored as py:method since Sphinx 2.1".
955        if spec.name == "py" && objtypes.contains(&"attribute") {
956            obj_types.push("method".to_string());
957        }
958
959        let objtypes: Vec<String> = obj_types
960            .into_iter()
961            .map(|objtype| format!("{}:{objtype}", spec.name))
962            // The individually disabled entries go last, once the list is
963            // complete and prefixed.
964            .filter(|objtype| !honor_disabled || !self.disabled_reftypes.contains(objtype))
965            .collect();
966
967        // `domain.get_full_qualified_name(node)` — the module/class-scoped
968        // retry — is not modelled: the std domain returns None for it, and
969        // the py domain needs the `py:module`/`py:class` scope this build
970        // does not carry through resolution yet.
971        self.resolve_by_target(
972            inv_name,
973            inventory,
974            spec.name,
975            &objtypes,
976            query.reftarget,
977            query,
978            diagnostics,
979        )
980    }
981
982    /// `_resolve_reference_in_domain_by_target` (`_resolve.py:80-133`).
983    #[allow(clippy::too_many_arguments)]
984    fn resolve_by_target(
985        &self,
986        inv_name: Option<&str>,
987        inventory: &Inventory,
988        domain_name: &str,
989        objtypes: &[String],
990        target: &str,
991        query: &XrefQuery<'_>,
992        diagnostics: &mut Vec<Diagnostic>,
993    ) -> Option<Resolution> {
994        for objtype in objtypes {
995            let Some(objects) = inventory.data.get(objtype.as_str()) else {
996                continue;
997            };
998            let item = if let Some(item) = objects.get(target) {
999                item
1000            } else if objtype == "std:label" || objtype == "std:term" {
1001                // Case-insensitive fallback, for these two objtypes only
1002                // (sphinx-doc/sphinx#9291 and #12008).
1003                let lowered = target.to_lowercase();
1004                let matches: Vec<&String> = objects
1005                    .keys()
1006                    .filter(|key| key.to_lowercase() == lowered)
1007                    .collect();
1008                if matches.len() > 1 {
1009                    let distinct: std::collections::HashSet<&InventoryItem> =
1010                        matches.iter().map(|key| &objects[*key]).collect();
1011                    let descriptor = inv_name.unwrap_or("main_inventory");
1012                    if distinct.len() == 1 {
1013                        log::debug!(
1014                            "inventory '{descriptor}': duplicate matches found for {objtype}:{target}"
1015                        );
1016                    } else {
1017                        diagnostics.push(Diagnostic::external(format!(
1018                            "inventory '{descriptor}': multiple matches found for {objtype}:{target}"
1019                        )));
1020                    }
1021                }
1022                match matches.first() {
1023                    Some(key) => &objects[*key],
1024                    None => continue,
1025                }
1026            } else {
1027                // A case-insensitive match for any other objtype is
1028                // deliberately *not* used.
1029                continue;
1030            };
1031            return Some(element_from_result(domain_name, inv_name, item, query));
1032        }
1033        None
1034    }
1035}
1036
1037/// `_create_element_from_result` (`_resolve.py:36-77`) — the URI adjustment
1038/// and the three display rules.
1039fn element_from_result(
1040    domain_name: &str,
1041    inv_name: Option<&str>,
1042    item: &InventoryItem,
1043    query: &XrefQuery<'_>,
1044) -> Resolution {
1045    let mut uri = item.uri.clone();
1046    if !uri.contains("://") && !query.refdoc.is_empty() {
1047        // `(_relative_path(Path(), Path(refdoc).parent) / uri).as_posix()`:
1048        // one `..` per directory the referencing document sits in.
1049        let depth = query.refdoc.split('/').count().saturating_sub(1);
1050        if depth > 0 {
1051            uri = format!("{}{uri}", "../".repeat(depth));
1052        }
1053    }
1054
1055    let reftitle = if item.project_version.is_empty() {
1056        format!("(in {})", item.project_name)
1057    } else {
1058        // A version starting with a digit gets a `v` prefix; anything else
1059        // is printed as written.
1060        let version = if item
1061            .project_version
1062            .starts_with(|c: char| c.is_ascii_digit())
1063        {
1064            format!("v{}", item.project_version)
1065        } else {
1066            item.project_version.clone()
1067        };
1068        format!("(in {} {version})", item.project_name)
1069    };
1070
1071    let title = if query.refexplicit {
1072        // An explicit title wins outright.
1073        None
1074    } else if item.display_name == "-" || (domain_name == "std" && query.reftype == "keyword") {
1075        // Keep the written title, minus any `inv:` prefix it still carries
1076        // from an `inv:target` reference.
1077        match inv_name {
1078            Some(inv_name) => query
1079                .contnode_text
1080                .strip_prefix(&format!("{inv_name}:"))
1081                .map(str::to_string),
1082            None => None,
1083        }
1084    } else {
1085        Some(item.display_name.clone())
1086    };
1087
1088    Resolution {
1089        refuri: uri,
1090        reftitle,
1091        title,
1092    }
1093}
1094
1095// ---------------------------------------------------------------------------
1096// 5. The `:external:` role (`_resolve.py:350-533`)
1097// ---------------------------------------------------------------------------
1098
1099/// Sphinx's `primary_domain` default, which is what
1100/// `env.current_document.default_domain` holds for a document with no
1101/// `.. default-domain::` (`sphinx/config.py`, `primary_domain = 'py'`).
1102const DEFAULT_DOMAIN: &str = "py";
1103
1104/// Whether a role name is one `IntersphinxDispatcher` claims
1105/// (`_resolve.py:358-366`). The name is the one the author *wrote*: the
1106/// inventory name inside it is case-sensitive.
1107pub fn is_external_role(name: &str) -> bool {
1108    name.len() > 9 && (name.starts_with("external:") || name.starts_with("external+"))
1109}
1110
1111/// `get_inventory_and_name_suffix` (`_resolve.py:486-506`): split
1112/// `external[+inv]:suffix` into its inventory name and `domain:name` suffix.
1113///
1114/// The `Err` case is Sphinx's `ValueError`, which
1115/// [`is_external_role`]-gated dispatch makes unreachable — index 8 of such a
1116/// name is always `+` or `:`. It is implemented and pinned anyway so the
1117/// invariant is checked rather than assumed.
1118pub fn inventory_and_name_suffix(name: &str) -> Result<(Option<&str>, &str), String> {
1119    let malformed = || format!("Malformed :external: role name: {name}");
1120    if !name.starts_with("external") || name.len() < 9 {
1121        return Err(malformed());
1122    }
1123    let suffix = &name[9..];
1124    match &name[8..9] {
1125        "+" => {
1126            let (inv_name, suffix) = suffix.split_once(':').unwrap_or((suffix, ""));
1127            Ok((Some(inv_name), suffix))
1128        }
1129        ":" => Ok((None, suffix)),
1130        _ => Err(malformed()),
1131    }
1132}
1133
1134/// `_get_domain_role` (`_resolve.py:508-521`): no colon is a bare role name,
1135/// one colon splits domain from role, two or more is unusable.
1136pub fn domain_and_role(name: &str) -> (Option<&str>, Option<&str>) {
1137    let mut parts = name.split(':');
1138    let first = parts.next().unwrap_or_default();
1139    match (parts.next(), parts.next()) {
1140        (None, _) => (None, Some(first)),
1141        (Some(role), None) => (Some(first), Some(role)),
1142        _ => (None, None),
1143    }
1144}
1145
1146/// What the parse layer should build for an `:external:...:` role.
1147#[derive(Debug, Clone, PartialEq, Eq)]
1148pub enum ExternalRole {
1149    /// Emit a `pending_xref` for `domain:role`, stamped with `inventory`.
1150    Xref {
1151        inventory: Option<String>,
1152        domain: String,
1153        role: String,
1154    },
1155    /// Emit nothing, and report this once the build can locate it. Sphinx's
1156    /// role returns `([], [])` on every one of these
1157    /// (`_resolve.py:386-463`).
1158    Failed(Diagnostic),
1159}
1160
1161/// The role-name half of `IntersphinxRole.run` (`_resolve.py:378-463`),
1162/// minus the inventory-existence check — that one needs the loaded
1163/// inventories, and is applied at resolution time by
1164/// [`external_inventory_missing`] so its warning still wins the race Sphinx
1165/// gives it (it is checked first).
1166pub fn external_role(name: &str) -> ExternalRole {
1167    let (inventory, suffix) = match inventory_and_name_suffix(name) {
1168        Ok(parsed) => parsed,
1169        Err(message) => return ExternalRole::Failed(Diagnostic::external(message)),
1170    };
1171
1172    let (domain_name, role_name) = domain_and_role(suffix);
1173    let Some(role_name) = role_name else {
1174        return ExternalRole::Failed(Diagnostic::external(format!(
1175            "invalid external cross-reference suffix: {}",
1176            py_repr_str(suffix)
1177        )));
1178    };
1179
1180    let inventory = inventory.map(str::to_string);
1181    if let Some(domain_name) = domain_name {
1182        // An explicit domain is the only one checked.
1183        let Some(spec) = domain(domain_name) else {
1184            return ExternalRole::Failed(Diagnostic::external(format!(
1185                "domain for external cross-reference not found: {}",
1186                py_repr_str(domain_name)
1187            )));
1188        };
1189        if !spec.has_role(role_name) {
1190            let base = format!(
1191                "role for external cross-reference not found in domain {}: {}",
1192                py_repr_str(domain_name),
1193                py_repr_str(role_name)
1194            );
1195            let message = match spec.roles_for_objtype(role_name).filter(|r| !r.is_empty()) {
1196                Some(roles) => format!(
1197                    "{base} (perhaps you meant one of: {})",
1198                    concat_strings(roles.iter().map(|role| (*role).to_string()))
1199                ),
1200                None => base,
1201            };
1202            return ExternalRole::Failed(Diagnostic::external(message));
1203        }
1204        return ExternalRole::Xref {
1205            inventory,
1206            domain: domain_name.to_string(),
1207            role: role_name.to_string(),
1208        };
1209    }
1210
1211    // No domain given: try the default domain, then std.
1212    let candidates: Vec<&DomainSpec> = if DEFAULT_DOMAIN == "std" {
1213        vec![domain("std").expect("std is always registered")]
1214    } else {
1215        vec![
1216            domain(DEFAULT_DOMAIN).expect("the default domain is always registered"),
1217            domain("std").expect("std is always registered"),
1218        ]
1219    };
1220    for spec in &candidates {
1221        if spec.has_role(role_name) {
1222            return ExternalRole::Xref {
1223                inventory,
1224                domain: spec.name.to_string(),
1225                role: role_name.to_string(),
1226            };
1227        }
1228    }
1229
1230    let domains_str = concat_strings(candidates.iter().map(|spec| spec.name.to_string()));
1231    let base = format!(
1232        "role for external cross-reference not found in domains {domains_str}: {}",
1233        py_repr_str(role_name)
1234    );
1235    let possible: BTreeSet<String> = candidates
1236        .iter()
1237        .filter_map(|spec| {
1238            spec.roles_for_objtype(role_name)
1239                .map(|roles| (spec.name, roles))
1240        })
1241        .flat_map(|(name, roles)| roles.iter().map(move |role| format!("{name}:{role}")))
1242        .collect();
1243    let message = if possible.is_empty() {
1244        base
1245    } else {
1246        format!(
1247            "{base} (perhaps you meant one of: {})",
1248            concat_strings(possible)
1249        )
1250    };
1251    ExternalRole::Failed(Diagnostic::external(message))
1252}
1253
1254/// The inventory-existence check `IntersphinxRole.run` makes first
1255/// (`_resolve.py:385-390`), deferred to resolution time because that is
1256/// where this port knows what got loaded.
1257///
1258/// Returns the diagnostic when the named inventory is unknown and the
1259/// reference is not self-referential.
1260pub fn external_inventory_missing(isx: &Intersphinx, inventory: &str) -> Option<Diagnostic> {
1261    let self_referential = !isx.resolve_self.is_empty() && isx.resolve_self == inventory;
1262    if self_referential || isx.data.inventory_exists(inventory) {
1263        return None;
1264    }
1265    Some(Diagnostic::external(format!(
1266        "inventory for external cross-reference not found: {}",
1267        py_repr_str(inventory)
1268    )))
1269}
1270
1271/// The failure `IntersphinxRoleResolver` reports for a stamped node nothing
1272/// matched (`_resolve.py:557-565`), logged with `type='ref'` and
1273/// `subtype=reftype`.
1274pub fn external_not_found(query: &XrefQuery<'_>) -> Diagnostic {
1275    Diagnostic {
1276        message: format!(
1277            "external {}:{} reference target not found: {}",
1278            query.refdomain, query.reftype, query.reftarget
1279        ),
1280        category: Some(format!("ref.{}", query.reftype)),
1281    }
1282}
1283
1284/// `_concat_strings` (`_resolve.py:532-533`): sorted, `repr`'d, `', '`-joined.
1285fn concat_strings(strings: impl IntoIterator<Item = String>) -> String {
1286    let sorted: BTreeSet<String> = strings.into_iter().collect();
1287    sorted
1288        .iter()
1289        .map(|s| py_repr_str(s))
1290        .collect::<Vec<_>>()
1291        .join(", ")
1292}
1293
1294#[cfg(test)]
1295mod tests;