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;