pub mod fetch;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde_json::Value as JsonValue;
use crate::inventory::{posix_join, Inventory, InventoryFile, InventoryItem};
use crate::utils::py_repr_str;
pub use fetch::{HttpConfig, InventoryFetcher, TlsCacerts, UreqFetcher, DEFAULT_USER_AGENT};
const INVENTORY_FILENAME: &str = "objects.inv";
pub const CACHE_DIR_NAME: &str = "__intersphinx_cache__";
pub type IntersphinxMapping = BTreeMap<String, (String, Vec<Option<String>>)>;
pub fn validate_mapping(raw: &JsonValue) -> (IntersphinxMapping, Vec<String>) {
let mut mapping = IntersphinxMapping::new();
let mut errors = Vec::new();
let Some(entries) = raw.as_object() else {
return (mapping, errors);
};
let mut seen: BTreeMap<&str, &str> = BTreeMap::new();
for (name, value) in entries {
if name.is_empty() {
errors.push(format!(
"Invalid intersphinx project identifier `{}` in intersphinx_mapping. \
Project identifiers must be non-empty strings.",
py_repr_value(&JsonValue::String(name.clone()))
));
continue;
}
let Some(pair) = value.as_array() else {
errors.push(format!(
"Invalid value `{}` in intersphinx_mapping[{}]. \
Expected a two-element tuple or list.",
py_repr_value(value),
py_repr_str(name)
));
continue;
};
if pair.len() != 2 {
errors.push(format!(
"Invalid value `{}` in intersphinx_mapping[{}]. \
Values must be a (target URI, inventory locations) pair.",
py_repr_value(value),
py_repr_str(name)
));
continue;
}
let (uri, inv) = (&pair[0], &pair[1]);
let uri = match uri.as_str() {
Some(uri) if !uri.is_empty() => uri,
_ => {
errors.push(format!(
"Invalid target URI value `{}` in intersphinx_mapping[{}][0]. \
Target URIs must be unique non-empty strings.",
py_repr_value(uri),
py_repr_str(name)
));
continue;
}
};
if let Some(other) = seen.get(uri) {
errors.push(format!(
"Invalid target URI value `{}` in intersphinx_mapping[{}][0]. \
Target URIs must be unique (other instance in intersphinx_mapping[{}]).",
py_repr_str(uri),
py_repr_str(name),
py_repr_str(other)
));
continue;
}
seen.insert(uri, name);
let locations: Vec<&JsonValue> = match inv.as_array() {
Some(items) => items.iter().collect(),
None => vec![inv],
};
let mut targets = Vec::with_capacity(locations.len());
for location in locations {
match location {
JsonValue::Null => targets.push(None),
JsonValue::String(s) if !s.is_empty() => targets.push(Some(s.clone())),
other => errors.push(format!(
"Invalid inventory location value `{}` in intersphinx_mapping[{}][1]. \
Inventory locations must be non-empty strings or None.",
py_repr_value(other),
py_repr_str(name)
)),
}
}
mapping.insert(name.clone(), (uri.to_string(), targets));
}
(mapping, errors)
}
pub fn mapping_config_error(errors: usize) -> String {
if errors == 1 {
"Invalid `intersphinx_mapping` configuration (1 error).".to_string()
} else {
format!("Invalid `intersphinx_mapping` configuration ({errors} errors).")
}
}
fn py_repr_value(value: &JsonValue) -> String {
match value {
JsonValue::Null => "None".to_string(),
JsonValue::Bool(true) => "True".to_string(),
JsonValue::Bool(false) => "False".to_string(),
JsonValue::Number(n) => n.to_string(),
JsonValue::String(s) => py_repr_str(s),
JsonValue::Array(items) => {
let inner: Vec<String> = items.iter().map(py_repr_value).collect();
format!("[{}]", inner.join(", "))
}
JsonValue::Object(map) => {
let inner: Vec<String> = map
.iter()
.map(|(k, v)| format!("{}: {}", py_repr_str(k), py_repr_value(v)))
.collect();
format!("{{{}}}", inner.join(", "))
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct IntersphinxData {
pub main: Inventory,
pub named: BTreeMap<String, Inventory>,
}
impl IntersphinxData {
pub fn is_empty(&self) -> bool {
self.named.is_empty()
}
pub fn inventory_exists(&self, name: &str) -> bool {
self.named.contains_key(name)
}
}
pub struct LoadRequest<'a> {
pub mapping: &'a IntersphinxMapping,
pub srcdir: &'a Path,
pub cache_dir: Option<PathBuf>,
pub cache_limit: i64,
pub now: i64,
pub http: &'a HttpConfig,
}
#[derive(Debug, Default)]
pub struct LoadOutcome {
pub data: IntersphinxData,
pub warnings: Vec<String>,
pub infos: Vec<String>,
}
pub fn load_mappings(
request: &LoadRequest<'_>,
fetcher: &dyn InventoryFetcher,
) -> anyhow::Result<LoadOutcome> {
let mut outcome = LoadOutcome::default();
if request.mapping.is_empty() {
return Ok(outcome);
}
if request
.mapping
.values()
.any(|(_, locations)| locations.is_empty())
{
anyhow::bail!("An invalid intersphinx_mapping entry was added after normalisation.");
}
let mut cache: Vec<(String, i64, Inventory)> = Vec::new();
for (name, (target_uri, locations)) in request.mapping {
fetch_inventory_group(
request,
fetcher,
name,
target_uri,
locations,
&mut cache,
&mut outcome,
);
}
cache.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
for (name, _expiry, inventory) in cache {
for (objtype, objects) in &inventory.data {
outcome
.data
.main
.data
.entry(objtype.clone())
.or_default()
.extend(objects.iter().map(|(k, v)| (k.clone(), v.clone())));
}
outcome.data.named.insert(name, inventory);
}
Ok(outcome)
}
#[allow(clippy::too_many_arguments)]
fn fetch_inventory_group(
request: &LoadRequest<'_>,
fetcher: &dyn InventoryFetcher,
name: &str,
target_uri: &str,
locations: &[Option<String>],
cache: &mut Vec<(String, i64, Inventory)>,
outcome: &mut LoadOutcome,
) {
let cache_time = if request.cache_limit >= 0 {
request.now - request.cache_limit * 86400
} else {
0
};
let cache_path = request
.cache_dir
.as_ref()
.map(|dir| dir.join(format!("{name}_{INVENTORY_FILENAME}")));
let link_uri = if target_uri.contains("://") {
strip_basic_auth(target_uri)
} else {
target_uri.to_string()
};
let mut failures: Vec<String> = Vec::new();
let mut succeeded = false;
let locations: Vec<Option<String>> = locations.to_vec();
for location in &locations {
let inv_location = match location {
Some(location) => location.clone(),
None => posix_join(target_uri, INVENTORY_FILENAME),
};
let remote = inv_location.contains("://");
if let Some(cache_path) = cache_path.as_ref().filter(|_| remote) {
if let Some(mtime) = file_mtime(cache_path) {
if mtime >= cache_time {
match std::fs::read(cache_path)
.map_err(|e| read_failure(&inv_location, &e))
.and_then(|raw| load_inventory(&raw, &link_uri))
{
Ok(inventory) => {
cache.push((name.to_string(), mtime, inventory));
succeeded = true;
break;
}
Err(message) => failures.push(message),
}
}
}
}
outcome.infos.push(format!(
"loading intersphinx inventory '{name}' from {} ...",
safe_url(&inv_location)
));
let fetched = if remote {
fetcher
.fetch(&inv_location, request.http)
.map_err(|e| fetch_failure(&inv_location, &e))
.inspect(|raw| {
if let Some(cache_path) = cache_path.as_ref() {
write_disk_cache(cache_path, raw);
}
})
} else {
let path = request.srcdir.join(&inv_location);
std::fs::read(&path).map_err(|e| read_failure(&inv_location, &e))
};
match fetched.and_then(|raw| load_inventory(&raw, &link_uri)) {
Ok(inventory) => {
cache.push((name.to_string(), request.now, inventory));
succeeded = true;
break;
}
Err(message) => failures.push(message),
}
}
if failures.is_empty() {
} else if succeeded {
outcome.infos.push(
"encountered some issues with some of the inventories, \
but they had working alternatives:"
.to_string(),
);
outcome.infos.extend(failures);
} else {
outcome.warnings.push(format!(
"failed to reach any of the inventories with the following issues:\n{}",
failures.join("\n")
));
}
}
fn load_inventory(raw: &[u8], target_uri: &str) -> Result<Inventory, String> {
InventoryFile::loads(raw, target_uri).map_err(|err| {
format!(
"unknown or unsupported inventory version: ValueError({})",
py_repr_str(&err.to_string())
)
})
}
fn read_failure(inv_location: &str, err: &std::io::Error) -> String {
let class = match err.kind() {
std::io::ErrorKind::NotFound => "FileNotFoundError",
std::io::ErrorKind::PermissionDenied => "PermissionError",
std::io::ErrorKind::IsADirectory => "IsADirectoryError",
_ => "OSError",
};
format!(
"intersphinx inventory {} not readable due to {class}: {err}",
py_repr_str(inv_location)
)
}
fn fetch_failure(inv_location: &str, err: &anyhow::Error) -> String {
format!(
"intersphinx inventory {} not fetchable due to <class 'ureq.Error'>: {err}",
py_repr_str(inv_location)
)
}
fn write_disk_cache(cache_path: &Path, raw: &[u8]) {
if let Some(parent) = cache_path.parent() {
if std::fs::create_dir_all(parent).is_err() {
return;
}
}
if let Err(e) = std::fs::write(cache_path, raw) {
log::debug!(
"could not write intersphinx disk cache {}: {e}",
cache_path.display()
);
}
}
fn file_mtime(path: &Path) -> Option<i64> {
let meta = std::fs::metadata(path).ok()?;
if !meta.is_file() {
return None;
}
let modified = meta.modified().ok()?;
match modified.duration_since(std::time::UNIX_EPOCH) {
Ok(since) => Some(since.as_secs() as i64),
Err(before) => Some(-(before.duration().as_secs() as i64)),
}
}
pub fn safe_url(url: &str) -> String {
let Some((scheme, rest)) = url.split_once("://") else {
return url.to_string();
};
let (netloc, tail) = match rest.find(['/', '?', '#']) {
Some(end) => (&rest[..end], &rest[end..]),
None => (rest, ""),
};
let Some((userinfo, host)) = netloc.rsplit_once('@') else {
return url.to_string();
};
let user = userinfo.split_once(':').map_or(userinfo, |(user, _)| user);
format!("{scheme}://{user}@{host}{tail}")
}
pub fn strip_basic_auth(url: &str) -> String {
let Some((scheme, rest)) = url.split_once("://") else {
return url.to_string();
};
let (netloc, tail) = match rest.find(['/', '?', '#']) {
Some(end) => (&rest[..end], &rest[end..]),
None => (rest, ""),
};
match netloc.split_once('@') {
Some((_userinfo, host)) => format!("{scheme}://{host}{tail}"),
None => url.to_string(),
}
}
pub fn redirect_target_uri(inv_location: &str, new_inv_location: &str, target_uri: &str) -> String {
if inv_location == new_inv_location {
return target_uri.to_string();
}
let dirname = posix_dirname(inv_location);
if target_uri == inv_location || target_uri == dirname || target_uri == format!("{dirname}/") {
return posix_dirname(new_inv_location);
}
target_uri.to_string()
}
fn posix_dirname(path: &str) -> String {
match path.rfind('/') {
Some(0) => "/".to_string(),
Some(idx) => path[..idx].to_string(),
None => String::new(),
}
}
struct DomainSpec {
name: &'static str,
object_types: &'static [(&'static str, &'static [&'static str])],
roles: &'static [&'static str],
}
const DOMAINS: &[DomainSpec] = &[
DomainSpec {
name: "py",
object_types: &[
("function", &["func", "obj"]),
("data", &["data", "obj"]),
("class", &["class", "exc", "obj"]),
("exception", &["exc", "class", "obj"]),
("method", &["meth", "obj"]),
("classmethod", &["meth", "obj"]),
("staticmethod", &["meth", "obj"]),
("attribute", &["attr", "obj"]),
("property", &["attr", "_prop", "obj"]),
("type", &["type", "class", "obj"]),
("module", &["mod", "obj"]),
],
roles: &[
"attr", "class", "const", "data", "deco", "exc", "func", "meth", "mod", "obj", "type",
],
},
DomainSpec {
name: "std",
object_types: &[
("term", &["term"]),
("token", &["token"]),
("label", &["ref", "keyword"]),
("confval", &["confval"]),
("envvar", &["envvar"]),
("cmdoption", &["option"]),
("doc", &["doc"]),
],
roles: &[
"confval", "doc", "envvar", "keyword", "numref", "option", "ref", "term", "token",
],
},
];
fn domain(name: &str) -> Option<&'static DomainSpec> {
DOMAINS.iter().find(|domain| domain.name == name)
}
impl DomainSpec {
fn objtypes_for_role(&self, role: &str) -> Vec<&'static str> {
self.object_types
.iter()
.filter(|(_, roles)| roles.contains(&role))
.map(|(objtype, _)| *objtype)
.collect()
}
fn has_role(&self, role: &str) -> bool {
self.roles.contains(&role)
}
fn roles_for_objtype(&self, objtype: &str) -> Option<&'static [&'static str]> {
self.object_types
.iter()
.find(|(name, _)| *name == objtype)
.map(|(_, roles)| *roles)
}
}
#[derive(Debug, Clone)]
pub struct XrefQuery<'a> {
pub refdomain: &'a str,
pub reftype: &'a str,
pub reftarget: &'a str,
pub refexplicit: bool,
pub refdoc: &'a str,
pub contnode_text: &'a str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolution {
pub refuri: String,
pub reftitle: String,
pub title: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Diagnostic {
pub message: String,
pub category: Option<String>,
}
impl Diagnostic {
fn external(message: String) -> Self {
Self {
message,
category: Some("intersphinx.external".to_string()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookOutcome {
Resolved(Resolution),
SelfReferential(String),
Missing,
}
#[derive(Debug, Clone, Default)]
pub struct Intersphinx {
pub data: IntersphinxData,
pub disabled_reftypes: BTreeSet<String>,
pub resolve_self: String,
}
impl Intersphinx {
pub fn is_empty(&self) -> bool {
self.data.is_empty()
}
pub fn resolve_detect(
&self,
query: &XrefQuery<'_>,
diagnostics: &mut Vec<Diagnostic>,
) -> HookOutcome {
if let Some(resolution) = self.resolve_any(true, query, diagnostics) {
return HookOutcome::Resolved(resolution);
}
let Some((inv_name, new_target)) = query.reftarget.split_once(':') else {
return HookOutcome::Missing;
};
if !self.resolve_self.is_empty() && self.resolve_self == inv_name {
return HookOutcome::SelfReferential(new_target.to_string());
}
if !self.data.inventory_exists(inv_name) {
return HookOutcome::Missing;
}
let prefixed = XrefQuery {
reftarget: new_target,
..query.clone()
};
match self.resolve_in_inventory(inv_name, &prefixed, diagnostics) {
Some(resolution) => HookOutcome::Resolved(resolution),
None => HookOutcome::Missing,
}
}
pub fn resolve_in_inventory(
&self,
inv_name: &str,
query: &XrefQuery<'_>,
diagnostics: &mut Vec<Diagnostic>,
) -> Option<Resolution> {
let inventory = self.data.named.get(inv_name)?;
self.resolve_reference(Some(inv_name), inventory, false, query, diagnostics)
}
pub fn resolve_any(
&self,
honor_disabled: bool,
query: &XrefQuery<'_>,
diagnostics: &mut Vec<Diagnostic>,
) -> Option<Resolution> {
self.resolve_reference(None, &self.data.main, honor_disabled, query, diagnostics)
}
fn resolve_reference(
&self,
inv_name: Option<&str>,
inventory: &Inventory,
honor_disabled: bool,
query: &XrefQuery<'_>,
diagnostics: &mut Vec<Diagnostic>,
) -> Option<Resolution> {
let honor_disabled = honor_disabled && inv_name.is_none();
if honor_disabled && self.disabled_reftypes.contains("*") {
return None;
}
if query.reftype == "any" {
for spec in DOMAINS {
if honor_disabled && self.disabled_reftypes.contains(&format!("{}:*", spec.name)) {
continue;
}
let objtypes: Vec<&str> = spec
.object_types
.iter()
.map(|(objtype, _)| *objtype)
.collect();
if let Some(resolution) = self.resolve_reference_in_domain(
inv_name,
inventory,
honor_disabled,
spec,
&objtypes,
query,
diagnostics,
) {
return Some(resolution);
}
}
return None;
}
if query.refdomain.is_empty() {
return None;
}
if honor_disabled
&& self
.disabled_reftypes
.contains(&format!("{}:*", query.refdomain))
{
return None;
}
let spec = domain(query.refdomain)?;
let objtypes = spec.objtypes_for_role(query.reftype);
if objtypes.is_empty() {
return None;
}
self.resolve_reference_in_domain(
inv_name,
inventory,
honor_disabled,
spec,
&objtypes,
query,
diagnostics,
)
}
#[allow(clippy::too_many_arguments)]
fn resolve_reference_in_domain(
&self,
inv_name: Option<&str>,
inventory: &Inventory,
honor_disabled: bool,
spec: &DomainSpec,
objtypes: &[&str],
query: &XrefQuery<'_>,
diagnostics: &mut Vec<Diagnostic>,
) -> Option<Resolution> {
let mut obj_types: Vec<String> = Vec::with_capacity(objtypes.len() + 1);
for objtype in objtypes {
if !obj_types.iter().any(|existing| existing == objtype) {
obj_types.push((*objtype).to_string());
}
}
if spec.name == "std" && objtypes.contains(&"cmdoption") {
obj_types.push("option".to_string());
}
if spec.name == "py" && objtypes.contains(&"attribute") {
obj_types.push("method".to_string());
}
let objtypes: Vec<String> = obj_types
.into_iter()
.map(|objtype| format!("{}:{objtype}", spec.name))
.filter(|objtype| !honor_disabled || !self.disabled_reftypes.contains(objtype))
.collect();
self.resolve_by_target(
inv_name,
inventory,
spec.name,
&objtypes,
query.reftarget,
query,
diagnostics,
)
}
#[allow(clippy::too_many_arguments)]
fn resolve_by_target(
&self,
inv_name: Option<&str>,
inventory: &Inventory,
domain_name: &str,
objtypes: &[String],
target: &str,
query: &XrefQuery<'_>,
diagnostics: &mut Vec<Diagnostic>,
) -> Option<Resolution> {
for objtype in objtypes {
let Some(objects) = inventory.data.get(objtype.as_str()) else {
continue;
};
let item = if let Some(item) = objects.get(target) {
item
} else if objtype == "std:label" || objtype == "std:term" {
let lowered = target.to_lowercase();
let matches: Vec<&String> = objects
.keys()
.filter(|key| key.to_lowercase() == lowered)
.collect();
if matches.len() > 1 {
let distinct: std::collections::HashSet<&InventoryItem> =
matches.iter().map(|key| &objects[*key]).collect();
let descriptor = inv_name.unwrap_or("main_inventory");
if distinct.len() == 1 {
log::debug!(
"inventory '{descriptor}': duplicate matches found for {objtype}:{target}"
);
} else {
diagnostics.push(Diagnostic::external(format!(
"inventory '{descriptor}': multiple matches found for {objtype}:{target}"
)));
}
}
match matches.first() {
Some(key) => &objects[*key],
None => continue,
}
} else {
continue;
};
return Some(element_from_result(domain_name, inv_name, item, query));
}
None
}
}
fn element_from_result(
domain_name: &str,
inv_name: Option<&str>,
item: &InventoryItem,
query: &XrefQuery<'_>,
) -> Resolution {
let mut uri = item.uri.clone();
if !uri.contains("://") && !query.refdoc.is_empty() {
let depth = query.refdoc.split('/').count().saturating_sub(1);
if depth > 0 {
uri = format!("{}{uri}", "../".repeat(depth));
}
}
let reftitle = if item.project_version.is_empty() {
format!("(in {})", item.project_name)
} else {
let version = if item
.project_version
.starts_with(|c: char| c.is_ascii_digit())
{
format!("v{}", item.project_version)
} else {
item.project_version.clone()
};
format!("(in {} {version})", item.project_name)
};
let title = if query.refexplicit {
None
} else if item.display_name == "-" || (domain_name == "std" && query.reftype == "keyword") {
match inv_name {
Some(inv_name) => query
.contnode_text
.strip_prefix(&format!("{inv_name}:"))
.map(str::to_string),
None => None,
}
} else {
Some(item.display_name.clone())
};
Resolution {
refuri: uri,
reftitle,
title,
}
}
const DEFAULT_DOMAIN: &str = "py";
pub fn is_external_role(name: &str) -> bool {
name.len() > 9 && (name.starts_with("external:") || name.starts_with("external+"))
}
pub fn inventory_and_name_suffix(name: &str) -> Result<(Option<&str>, &str), String> {
let malformed = || format!("Malformed :external: role name: {name}");
if !name.starts_with("external") || name.len() < 9 {
return Err(malformed());
}
let suffix = &name[9..];
match &name[8..9] {
"+" => {
let (inv_name, suffix) = suffix.split_once(':').unwrap_or((suffix, ""));
Ok((Some(inv_name), suffix))
}
":" => Ok((None, suffix)),
_ => Err(malformed()),
}
}
pub fn domain_and_role(name: &str) -> (Option<&str>, Option<&str>) {
let mut parts = name.split(':');
let first = parts.next().unwrap_or_default();
match (parts.next(), parts.next()) {
(None, _) => (None, Some(first)),
(Some(role), None) => (Some(first), Some(role)),
_ => (None, None),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExternalRole {
Xref {
inventory: Option<String>,
domain: String,
role: String,
},
Failed(Diagnostic),
}
pub fn external_role(name: &str) -> ExternalRole {
let (inventory, suffix) = match inventory_and_name_suffix(name) {
Ok(parsed) => parsed,
Err(message) => return ExternalRole::Failed(Diagnostic::external(message)),
};
let (domain_name, role_name) = domain_and_role(suffix);
let Some(role_name) = role_name else {
return ExternalRole::Failed(Diagnostic::external(format!(
"invalid external cross-reference suffix: {}",
py_repr_str(suffix)
)));
};
let inventory = inventory.map(str::to_string);
if let Some(domain_name) = domain_name {
let Some(spec) = domain(domain_name) else {
return ExternalRole::Failed(Diagnostic::external(format!(
"domain for external cross-reference not found: {}",
py_repr_str(domain_name)
)));
};
if !spec.has_role(role_name) {
let base = format!(
"role for external cross-reference not found in domain {}: {}",
py_repr_str(domain_name),
py_repr_str(role_name)
);
let message = match spec.roles_for_objtype(role_name).filter(|r| !r.is_empty()) {
Some(roles) => format!(
"{base} (perhaps you meant one of: {})",
concat_strings(roles.iter().map(|role| (*role).to_string()))
),
None => base,
};
return ExternalRole::Failed(Diagnostic::external(message));
}
return ExternalRole::Xref {
inventory,
domain: domain_name.to_string(),
role: role_name.to_string(),
};
}
let candidates: Vec<&DomainSpec> = if DEFAULT_DOMAIN == "std" {
vec![domain("std").expect("std is always registered")]
} else {
vec![
domain(DEFAULT_DOMAIN).expect("the default domain is always registered"),
domain("std").expect("std is always registered"),
]
};
for spec in &candidates {
if spec.has_role(role_name) {
return ExternalRole::Xref {
inventory,
domain: spec.name.to_string(),
role: role_name.to_string(),
};
}
}
let domains_str = concat_strings(candidates.iter().map(|spec| spec.name.to_string()));
let base = format!(
"role for external cross-reference not found in domains {domains_str}: {}",
py_repr_str(role_name)
);
let possible: BTreeSet<String> = candidates
.iter()
.filter_map(|spec| {
spec.roles_for_objtype(role_name)
.map(|roles| (spec.name, roles))
})
.flat_map(|(name, roles)| roles.iter().map(move |role| format!("{name}:{role}")))
.collect();
let message = if possible.is_empty() {
base
} else {
format!(
"{base} (perhaps you meant one of: {})",
concat_strings(possible)
)
};
ExternalRole::Failed(Diagnostic::external(message))
}
pub fn external_inventory_missing(isx: &Intersphinx, inventory: &str) -> Option<Diagnostic> {
let self_referential = !isx.resolve_self.is_empty() && isx.resolve_self == inventory;
if self_referential || isx.data.inventory_exists(inventory) {
return None;
}
Some(Diagnostic::external(format!(
"inventory for external cross-reference not found: {}",
py_repr_str(inventory)
)))
}
pub fn external_not_found(query: &XrefQuery<'_>) -> Diagnostic {
Diagnostic {
message: format!(
"external {}:{} reference target not found: {}",
query.refdomain, query.reftype, query.reftarget
),
category: Some(format!("ref.{}", query.reftype)),
}
}
fn concat_strings(strings: impl IntoIterator<Item = String>) -> String {
let sorted: BTreeSet<String> = strings.into_iter().collect();
sorted
.iter()
.map(|s| py_repr_str(s))
.collect::<Vec<_>>()
.join(", ")
}
#[cfg(test)]
mod tests;