use std::borrow::Cow;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::doctree::{kinds, AttrValue, Doctree, Node};
use crate::env::numbers::clean_astext;
use crate::env::std_domain::{DocumentIds, PropagatedIds};
use crate::env::toctree::docname_join;
use crate::env::BuildEnvironment;
use crate::error::{BuildWarning, WarningType};
use crate::intersphinx::{self, Diagnostic, HookOutcome, Intersphinx, XrefQuery};
use crate::utils::py_repr_str;
#[derive(Clone, Copy)]
pub struct XrefRequest<'a> {
pub fromdoc: &'a str,
pub refdoc: &'a str,
pub reftype: &'a str,
pub reftarget: &'a str,
pub refexplicit: bool,
pub program: Option<&'a str>,
pub contnode_text: &'a str,
}
#[derive(Debug, PartialEq)]
pub enum XrefOutcome {
Resolved(ResolvedXref),
Kept { warning: Option<String> },
Missing,
}
#[derive(Debug, PartialEq)]
pub struct ResolvedXref {
pub kind: &'static str,
pub refid: Option<String>,
pub refuri: Option<String>,
pub title: Option<String>,
pub reftitle: Option<String>,
pub inner: Inner,
}
#[derive(Debug, PartialEq)]
pub enum Inner {
Contnode,
Inline { text: String, classes: Vec<String> },
Children(Vec<Node>),
}
pub struct Resolver<'a> {
pub env: &'a BuildEnvironment,
pub numfig: bool,
pub numfig_format: &'a BTreeMap<String, String>,
pub doctree: &'a dyn Fn(&str) -> Option<Cow<'a, Doctree>>,
pub relative_uri: &'a dyn Fn(&str, &str) -> String,
pub intersphinx: &'a Intersphinx,
}
impl Resolver<'_> {
pub fn resolve_xref(&self, req: &XrefRequest<'_>) -> XrefOutcome {
match req.reftype {
"ref" => self.resolve_ref(req),
"numref" => self.resolve_numref(req),
"keyword" => self.resolve_keyword(req),
"doc" => self.resolve_doc(req),
"option" => self.resolve_option(req),
"term" => self.resolve_term(req),
_ => self.resolve_obj(req),
}
}
fn resolve_ref(&self, req: &XrefRequest<'_>) -> XrefOutcome {
let (docname, labelid, sectname) = if req.refexplicit {
match self.env.std.anonlabels.get(req.reftarget) {
Some((docname, labelid)) => (
docname.clone(),
labelid.clone(),
req.contnode_text.to_string(),
),
None => return XrefOutcome::Missing,
}
} else {
match self.env.std.labels.get(req.reftarget) {
Some((docname, labelid, sectname)) => {
(docname.clone(), labelid.clone(), sectname.clone())
}
None => return XrefOutcome::Missing,
}
};
if docname.is_empty() {
return XrefOutcome::Missing;
}
XrefOutcome::Resolved(self.build_reference_node(
LabelTarget {
fromdoc: req.fromdoc,
docname: &docname,
labelid: &labelid,
},
§name,
"ref",
kinds::REFERENCE,
None,
))
}
fn resolve_numref(&self, req: &XrefRequest<'_>) -> XrefOutcome {
let (docname, labelid, figname) = match self.env.std.labels.get(req.reftarget) {
Some((docname, labelid, figname)) => {
(docname.clone(), labelid.clone(), Some(figname.clone()))
}
None => match self.env.std.anonlabels.get(req.reftarget) {
Some((docname, labelid)) => (docname.clone(), labelid.clone(), None),
None => return XrefOutcome::Missing,
},
};
if docname.is_empty() {
return XrefOutcome::Missing;
}
let Some((figtype, target_ids)) = (self.doctree)(&docname).and_then(|doctree| {
let ids = DocumentIds::of(&doctree);
let node = ids.node(&labelid)?;
Some((
enumerable_node_type(node).map(str::to_string),
PropagatedIds::of(&doctree).effective_ids(node),
))
}) else {
return XrefOutcome::Missing;
};
let Some(figtype) = figtype else {
return XrefOutcome::Missing;
};
if figtype != "section" && !self.numfig {
return XrefOutcome::Kept {
warning: Some("numfig is disabled. :numref: is ignored.".to_string()),
};
}
let fignumber = match self.fignumber(&figtype, &docname, &target_ids) {
Ok(Some(fignumber)) => fignumber,
Ok(None) => return XrefOutcome::Kept { warning: None },
Err(NoNumber) => {
return XrefOutcome::Kept {
warning: Some(format!(
"Failed to create a cross reference. Any number is not assigned: {labelid}"
)),
}
}
};
let title = if req.refexplicit {
req.contnode_text.to_string()
} else {
self.numfig_format
.get(&figtype)
.cloned()
.unwrap_or_default()
};
if figname.is_none() && title.contains("{name}") {
return XrefOutcome::Kept {
warning: Some(format!("the link has no caption: {title}")),
};
}
let fignum: Vec<String> = fignumber.iter().map(u32::to_string).collect();
let fignum = fignum.join(".");
let newtitle = if title.contains("{name}") || title.contains("number") {
let named = figname.as_deref().filter(|figname| !figname.is_empty());
match format_new_style(&title, named, &fignum) {
Ok(newtitle) => newtitle,
Err(KeyError(key)) => {
return XrefOutcome::Kept {
warning: Some(format!(
"invalid numfig_format: {title} (KeyError({}))",
py_repr_str(&key)
)),
}
}
}
} else {
match format_old_style(&title, &fignum) {
Ok(newtitle) => newtitle,
Err(TypeError) => {
return XrefOutcome::Kept {
warning: Some(format!("invalid numfig_format: {title}")),
}
}
}
};
XrefOutcome::Resolved(self.build_reference_node(
LabelTarget {
fromdoc: req.fromdoc,
docname: &docname,
labelid: &labelid,
},
&newtitle,
"numref",
"number_reference",
Some(title),
))
}
fn fignumber(
&self,
figtype: &str,
docname: &str,
target_ids: &[String],
) -> Result<Option<Vec<u32>>, NoNumber> {
if figtype == "section" {
let secnumbers = self.env.toc_secnumbers.get(docname).ok_or(NoNumber)?;
let anchorname = format!("#{}", target_ids.first().ok_or(NoNumber)?);
return Ok(secnumbers
.get(&anchorname)
.or_else(|| secnumbers.get(""))
.cloned());
}
let figure_id = target_ids.first().ok_or(NoNumber)?;
self.env
.toc_fignumbers
.get(docname)
.and_then(|per_type| per_type.get(figtype))
.and_then(|per_id| per_id.get(figure_id))
.cloned()
.map(Some)
.ok_or(NoNumber)
}
fn resolve_keyword(&self, req: &XrefRequest<'_>) -> XrefOutcome {
match self.env.std.labels.get(req.reftarget) {
Some((docname, labelid, _)) if !docname.is_empty() => {
XrefOutcome::Resolved(self.make_refnode(req.fromdoc, docname, Some(labelid)))
}
_ => XrefOutcome::Missing,
}
}
fn resolve_doc(&self, req: &XrefRequest<'_>) -> XrefOutcome {
let docname = docname_join(req.refdoc, req.reftarget);
if !self.env.all_docs.contains_key(&docname) {
return XrefOutcome::Missing;
}
let caption = if req.refexplicit {
req.contnode_text.to_string()
} else {
self.env
.titles
.get(&docname)
.map(clean_astext)
.unwrap_or_default()
};
let mut node = self.make_refnode(req.fromdoc, &docname, None);
node.inner = Inner::Inline {
text: caption,
classes: vec!["doc".to_string()],
};
XrefOutcome::Resolved(node)
}
fn resolve_option(&self, req: &XrefRequest<'_>) -> XrefOutcome {
let program = req.program.map(str::to_string);
let target = req.reftarget.trim();
let mut found = self.progoption(program.as_deref(), target);
if found.is_none() {
for needle in ["=", "[=", " "] {
if let Some((stem, _)) = target.split_once(needle) {
found = self.progoption(program.as_deref(), stem);
if found.is_some() {
break;
}
}
}
}
if found.is_none() {
let mut commands: Vec<&str> = Vec::new();
let mut rest = target;
while let Some((subcommand, tail)) = split_once_whitespace(rest) {
commands.push(subcommand);
rest = tail;
let progname = commands.join("-");
found = self.progoption(Some(&progname), rest);
if found.is_some() {
break;
}
}
}
match found {
Some((docname, labelid)) => {
XrefOutcome::Resolved(self.make_refnode(req.fromdoc, &docname, Some(&labelid)))
}
None => XrefOutcome::Missing,
}
}
fn progoption(&self, program: Option<&str>, name: &str) -> Option<(String, String)> {
self.env
.std
.progoptions
.get(&(program.map(str::to_string), name.to_string()))
.filter(|(docname, _)| !docname.is_empty())
.cloned()
}
fn resolve_term(&self, req: &XrefRequest<'_>) -> XrefOutcome {
if let XrefOutcome::Resolved(node) = self.resolve_obj(req) {
return XrefOutcome::Resolved(node);
}
match self.env.std.terms.get(&req.reftarget.to_lowercase()) {
Some((docname, labelid)) => {
XrefOutcome::Resolved(self.make_refnode(req.fromdoc, docname, Some(labelid)))
}
None => XrefOutcome::Missing,
}
}
fn resolve_obj(&self, req: &XrefRequest<'_>) -> XrefOutcome {
for objtype in objtypes_for_role(req.reftype) {
let key = (objtype.to_string(), req.reftarget.to_string());
if let Some((docname, labelid)) = self.env.std.objects.get(&key) {
if docname.is_empty() {
break;
}
return XrefOutcome::Resolved(self.make_refnode(
req.fromdoc,
docname,
Some(labelid),
));
}
}
XrefOutcome::Missing
}
fn make_refnode(&self, fromdoc: &str, docname: &str, targetid: Option<&str>) -> ResolvedXref {
let mut node = ResolvedXref {
kind: kinds::REFERENCE,
refid: None,
refuri: None,
title: None,
reftitle: None,
inner: Inner::Contnode,
};
match targetid {
Some(targetid) if fromdoc == docname => node.refid = Some(targetid.to_string()),
Some(targetid) => {
node.refuri = Some(format!(
"{}#{targetid}",
(self.relative_uri)(fromdoc, docname)
));
}
None => node.refuri = Some((self.relative_uri)(fromdoc, docname)),
}
node
}
fn py_refnode(
&self,
fromdoc: &str,
target: crate::env::py_domain::PyXrefTarget<'_>,
resolved_children: Option<Vec<Node>>,
) -> ResolvedXref {
let targetid = Some(target.node_id).filter(|id| !id.is_empty());
let mut node = self.make_refnode(fromdoc, target.docname, targetid);
node.reftitle = Some(target.reftitle);
if !target.is_module {
if let Some(children) = resolved_children {
node.inner = Inner::Children(children);
}
}
node
}
fn resolve_any(
&self,
req: &XrefRequest<'_>,
py_module: Option<&str>,
py_class: Option<&str>,
resolved_children: Option<&Vec<Node>>,
) -> Vec<AnyCandidate> {
let mut results: Vec<AnyCandidate> = Vec::new();
let mut push = |role: String, node: ResolvedXref| {
let label = node.reftitle.clone().unwrap_or_else(|| match &node.inner {
Inner::Inline { text, .. } => text.clone(),
Inner::Contnode => req.contnode_text.to_string(),
Inner::Children(children) => children.iter().map(Node::astext).collect(),
});
results.push(AnyCandidate { role, node, label });
};
if let XrefOutcome::Resolved(node) = self.resolve_doc(&XrefRequest {
reftype: "doc",
..*req
}) {
push("doc".to_string(), node);
}
let ltarget = req.reftarget.to_lowercase();
if let XrefOutcome::Resolved(node) = self.resolve_ref(&XrefRequest {
reftype: "ref",
reftarget: <arget,
..*req
}) {
push("std:ref".to_string(), node);
}
if let XrefOutcome::Resolved(node) = self.resolve_option(&XrefRequest {
reftype: "option",
..*req
}) {
push("std:option".to_string(), node);
}
for (objtype, role) in STD_OBJECT_TYPE_ROLES {
let name = if *objtype == "term" {
ltarget.clone()
} else {
req.reftarget.to_string()
};
let key = ((*objtype).to_string(), name);
if let Some((docname, labelid)) = self.env.std.objects.get(&key) {
push(
format!("std:{role}"),
self.make_refnode(req.fromdoc, docname, Some(labelid)),
);
}
}
for (role, target) in crate::env::py_domain::resolve_any_xref(
&self.env.py,
py_module,
py_class,
req.reftarget,
) {
let node = self.py_refnode(req.fromdoc, target, resolved_children.cloned());
push(role, node);
}
results
}
fn build_reference_node(
&self,
target: LabelTarget<'_>,
sectname: &str,
rolename: &str,
kind: &'static str,
title: Option<String>,
) -> ResolvedXref {
let LabelTarget {
fromdoc,
docname,
labelid,
} = target;
let mut node = ResolvedXref {
kind,
refid: None,
refuri: None,
title,
reftitle: None,
inner: Inner::Inline {
text: sectname.to_string(),
classes: vec!["std".to_string(), format!("std-{rolename}")],
},
};
if docname == fromdoc {
node.refid = Some(labelid.to_string());
} else {
let mut refuri = (self.relative_uri)(fromdoc, docname);
if !labelid.is_empty() {
refuri.push('#');
refuri.push_str(labelid);
}
node.refuri = Some(refuri);
}
node
}
}
const STD_OBJECT_TYPE_ROLES: &[(&str, &str)] = &[
("term", "term"),
("token", "token"),
("label", "ref"),
("confval", "confval"),
("envvar", "envvar"),
("cmdoption", "option"),
("doc", "doc"),
];
struct AnyCandidate {
role: String,
node: ResolvedXref,
label: String,
}
struct LabelTarget<'a> {
fromdoc: &'a str,
docname: &'a str,
labelid: &'a str,
}
#[derive(Debug)]
struct NoNumber;
#[derive(Debug)]
struct KeyError(String);
#[derive(Debug)]
struct TypeError;
fn format_new_style(title: &str, figname: Option<&str>, fignum: &str) -> Result<String, KeyError> {
let mut out = String::with_capacity(title.len());
let mut rest = title;
while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
let after = &rest[open + 1..];
let Some(close) = after.find('}') else {
out.push_str(&rest[open..]);
return Ok(out);
};
let field = &after[..close];
match field {
"name" => match figname {
Some(figname) => out.push_str(figname),
None => return Err(KeyError("name".to_string())),
},
"number" => out.push_str(fignum),
other => return Err(KeyError(other.to_string())),
}
rest = &after[close + 1..];
}
out.push_str(rest);
Ok(out)
}
fn format_old_style(title: &str, fignum: &str) -> Result<String, TypeError> {
let mut out = String::with_capacity(title.len());
let mut rest = title;
let mut conversions = 0usize;
while let Some(percent) = rest.find('%') {
out.push_str(&rest[..percent]);
let mut chars = rest[percent + 1..].chars();
match chars.next() {
Some('%') => out.push('%'),
Some('s') => {
conversions += 1;
out.push_str(fignum);
}
_ => return Err(TypeError),
}
rest = &rest[percent + 2..];
}
if conversions != 1 {
return Err(TypeError);
}
out.push_str(rest);
Ok(out)
}
fn split_once_whitespace(target: &str) -> Option<(&str, &str)> {
let start = target.find(crate::utils::py_isspace)?;
let end = target[start..]
.find(|c: char| !crate::utils::py_isspace(c))
.map(|offset| start + offset)
.unwrap_or(target.len());
Some((&target[..start], &target[end..]))
}
fn objtypes_for_role(role: &str) -> &'static [&'static str] {
match role {
"term" => &["term"],
"token" => &["token"],
"ref" | "keyword" => &["label"],
"confval" => &["confval"],
"envvar" => &["envvar"],
"option" => &["cmdoption"],
"doc" => &["doc"],
_ => &[],
}
}
fn enumerable_node_type(node: &Node) -> Option<&'static str> {
match node.kind {
kinds::SECTION => Some("section"),
"figure" => Some("figure"),
kinds::TABLE => Some("table"),
"container" => Some("code-block"),
_ => None,
}
}
pub struct NitpickConfig<'a> {
pub nitpicky: bool,
pub ignore: &'a [(String, String)],
pub ignore_regex: &'a [(String, String)],
}
#[derive(Default)]
pub struct DocumentResolution {
pub warnings: Vec<BuildWarning>,
pub unresolvable_domain_refs: usize,
}
pub fn resolve_document(
resolver: &Resolver<'_>,
nitpick: &NitpickConfig<'_>,
docname: &str,
doctree: &mut Doctree,
path: &Path,
) -> DocumentResolution {
let mut out = DocumentResolution::default();
let sources = doctree.sources.clone();
resolve_children(
resolver,
nitpick,
docname,
&mut doctree.root,
&sources,
path,
None,
&mut out,
);
propagate_desc_domain(&mut doctree.root);
out
}
type Location = Option<(u16, u32)>;
fn contributes_location(node: &Node) -> bool {
node.span.line != 0 && !matches!(node.kind, kinds::DOCUMENT | "desc" | "desc_content")
}
fn propagate_desc_domain(node: &mut Node) {
if node.kind == "desc" {
if let Some(AttrValue::Str(domain)) = node.get("domain") {
if !domain.is_empty() {
let domain = domain.clone();
for child in &mut node.children {
if child.kind == "desc_signature" {
child.attrs.classes.push(domain.clone());
}
}
}
}
}
for child in &mut node.children {
propagate_desc_domain(child);
}
}
#[allow(clippy::too_many_arguments)]
fn resolve_children(
resolver: &Resolver<'_>,
nitpick: &NitpickConfig<'_>,
docname: &str,
node: &mut Node,
sources: &[String],
path: &Path,
inherited: Location,
out: &mut DocumentResolution,
) {
let location = if contributes_location(node) {
Some((node.span.source, node.span.line))
} else {
inherited
};
for child in &mut node.children {
resolve_children(
resolver, nitpick, docname, child, sources, path, location, out,
);
}
if !node
.children
.iter()
.any(|child| child.kind == kinds::PENDING_XREF)
{
return;
}
let children = std::mem::take(&mut node.children);
for child in children {
if child.kind != kinds::PENDING_XREF {
node.children.push(child);
continue;
}
node.children.extend(resolve_one(
resolver, nitpick, docname, child, sources, path, location, out,
));
}
}
#[allow(clippy::too_many_arguments)]
fn resolve_one(
resolver: &Resolver<'_>,
nitpick: &NitpickConfig<'_>,
docname: &str,
node: Node,
sources: &[String],
doc_path: &Path,
inherited: Location,
out: &mut DocumentResolution,
) -> Vec<Node> {
let span = node.span;
let location = if span.line != 0 {
Some((span.source, span.line))
} else {
inherited
};
let (source_path, line): (PathBuf, Option<usize>) = match location {
Some((source, line)) => (
sources
.get(source as usize)
.map(PathBuf::from)
.unwrap_or_else(|| doc_path.to_path_buf()),
Some(line as usize),
),
None => (PathBuf::new(), None),
};
let path = source_path.as_path();
let refdomain = attr_str(&node, "refdomain").unwrap_or_default().to_string();
let reftype = attr_str(&node, "reftype").unwrap_or_default().to_string();
let reftarget = attr_str(&node, "reftarget").unwrap_or_default().to_string();
let refdoc = attr_str(&node, "refdoc").unwrap_or(docname).to_string();
let refexplicit = matches!(node.get("refexplicit"), Some(AttrValue::Int(1)));
let refwarn = matches!(node.get("refwarn"), Some(AttrValue::Int(1)));
let program = attr_str(&node, "std:program")
.filter(|program| !crate::env::std_domain::is_none_sentinel(program))
.map(str::to_string);
let external = matches!(node.get("intersphinx"), Some(AttrValue::Int(1)));
let inventory = attr_str(&node, "inventory").map(str::to_string);
let role_error = attr_str(&node, "intersphinx_role_error").map(str::to_string);
let py_module = attr_str(&node, "py:module")
.filter(|value| !crate::env::std_domain::is_none_sentinel(value) && !value.is_empty())
.map(str::to_string);
let py_class = attr_str(&node, "py:class")
.filter(|value| !crate::env::std_domain::is_none_sentinel(value) && !value.is_empty())
.map(str::to_string);
let searchmode: u8 = u8::from(node.get("refspecific").is_some());
let children = XrefChildren::split(node.children);
let contnode = children.contnode();
let contnode_text = contnode.as_ref().map(Node::astext).unwrap_or_default();
let query = XrefQuery {
refdomain: &refdomain,
reftype: &reftype,
reftarget: &reftarget,
refexplicit,
refdoc: &refdoc,
contnode_text: &contnode_text,
};
let self_referential = inventory.as_deref().is_some_and(|inventory| {
!resolver.intersphinx.resolve_self.is_empty()
&& resolver.intersphinx.resolve_self == inventory
});
if external && !self_referential {
return resolve_external(
resolver,
&query,
inventory.as_deref(),
role_error.as_deref(),
contnode,
span,
line,
path,
out,
);
}
if refdomain.is_empty() && reftype == "any" {
return resolve_any_ref(
resolver,
nitpick,
docname,
&query,
PyRefContext {
module: py_module.as_deref(),
class: py_class.as_deref(),
searchmode: 1,
refwarn,
},
program.as_deref(),
children,
span,
line,
path,
out,
);
}
if !matches!(refdomain.as_str(), "" | "std" | "py") {
let mut diagnostics = Vec::new();
let outcome = resolver
.intersphinx
.resolve_detect(&query, &mut diagnostics);
report(out, diagnostics, line, path);
if let HookOutcome::Resolved(resolution) = outcome {
return vec![intersphinx_node(resolution, contnode, span)];
}
out.unresolvable_domain_refs += 1;
return children.fallback(out, line, path);
}
if refdomain == "py" {
return resolve_py(
resolver,
nitpick,
docname,
&query,
PyRefContext {
module: py_module.as_deref(),
class: py_class.as_deref(),
searchmode,
refwarn,
},
children,
span,
line,
path,
out,
);
}
if reftype == "doc" && is_url(&reftarget) {
return contnode.into_iter().collect();
}
let req = XrefRequest {
fromdoc: docname,
refdoc: &refdoc,
reftype: &reftype,
reftarget: &reftarget,
refexplicit,
program: program.as_deref(),
contnode_text: &contnode_text,
};
let outcome = resolver.resolve_xref(&req);
match outcome {
XrefOutcome::Resolved(resolved) => {
vec![reference_node(resolved, contnode, span)]
}
XrefOutcome::Kept { warning } => {
if let Some(message) = warning {
out.warnings.push(
BuildWarning::new(
path.to_path_buf(),
line,
message,
WarningType::BrokenCrossReference,
)
.with_category(None),
);
}
contnode.into_iter().collect()
}
XrefOutcome::Missing => {
let mut diagnostics = Vec::new();
let outcome = resolver
.intersphinx
.resolve_detect(&query, &mut diagnostics);
report(out, diagnostics, line, path);
match outcome {
HookOutcome::Resolved(resolution) => {
return vec![intersphinx_node(resolution, contnode, span)];
}
HookOutcome::SelfReferential(stripped) => {
let retry = resolver.resolve_xref(&XrefRequest {
reftarget: &stripped,
..req
});
if let XrefOutcome::Resolved(resolved) = retry {
return vec![reference_node(resolved, contnode, span)];
}
}
HookOutcome::Missing => {}
}
if let Some(message) = missing_reference_warning(
resolver.env,
nitpick,
&refdomain,
&reftype,
&reftarget,
refwarn,
) {
out.warnings.push(
BuildWarning::new(
path.to_path_buf(),
line,
message,
WarningType::BrokenCrossReference,
)
.with_category(Some(format!("ref.{reftype}"))),
);
}
children.fallback(out, line, path)
}
}
}
struct PyRefContext<'a> {
module: Option<&'a str>,
class: Option<&'a str>,
searchmode: u8,
refwarn: bool,
}
#[allow(clippy::too_many_arguments)]
fn resolve_py(
resolver: &Resolver<'_>,
nitpick: &NitpickConfig<'_>,
docname: &str,
query: &XrefQuery<'_>,
ctx: PyRefContext<'_>,
children: XrefChildren,
span: crate::doctree::Span,
line: Option<usize>,
path: &Path,
out: &mut DocumentResolution,
) -> Vec<Node> {
use crate::env::py_domain;
let reftype = query.reftype;
let contnode = children.contnode();
let resolve = |target: &str, out: &mut DocumentResolution| {
let (found, ambiguity) = py_domain::resolve_xref(
&resolver.env.py,
ctx.module,
ctx.class,
reftype,
target,
ctx.searchmode,
);
if let Some(message) = ambiguity {
out.warnings.push(
BuildWarning::new(
path.to_path_buf(),
line,
message,
WarningType::BrokenCrossReference,
)
.with_category(Some("ref.python".to_string())),
);
}
found
};
if let Some(target) = resolve(query.reftarget, out) {
let resolved = resolver.py_refnode(docname, target, children.resolved.clone());
return vec![reference_node(resolved, contnode, span)];
}
let mut diagnostics = Vec::new();
let outcome = resolver.intersphinx.resolve_detect(query, &mut diagnostics);
report(out, diagnostics, line, path);
match outcome {
HookOutcome::Resolved(resolution) => {
return vec![intersphinx_node(resolution, contnode, span)];
}
HookOutcome::SelfReferential(stripped) => {
if py_domain::builtin_resolver(reftype, &stripped) {
return children.contnode().into_iter().collect();
}
if let Some(target) = resolve(&stripped, out) {
let resolved = resolver.py_refnode(docname, target, children.resolved.clone());
return vec![reference_node(resolved, contnode, span)];
}
}
HookOutcome::Missing => {
if py_domain::builtin_resolver(reftype, query.reftarget) {
return children.contnode().into_iter().collect();
}
}
}
if let Some(message) = missing_reference_warning(
resolver.env,
nitpick,
"py",
reftype,
query.reftarget,
ctx.refwarn,
) {
out.warnings.push(
BuildWarning::new(
path.to_path_buf(),
line,
message,
WarningType::BrokenCrossReference,
)
.with_category(Some(format!("ref.{reftype}"))),
);
}
children.fallback(out, line, path)
}
#[allow(clippy::too_many_arguments)]
fn resolve_any_ref(
resolver: &Resolver<'_>,
nitpick: &NitpickConfig<'_>,
docname: &str,
query: &XrefQuery<'_>,
ctx: PyRefContext<'_>,
program: Option<&str>,
children: XrefChildren,
span: crate::doctree::Span,
line: Option<usize>,
path: &Path,
out: &mut DocumentResolution,
) -> Vec<Node> {
let contnode = children.contnode();
let contnode_text = contnode.as_ref().map(Node::astext).unwrap_or_default();
let req = XrefRequest {
fromdoc: docname,
refdoc: query.refdoc,
reftype: "any",
reftarget: query.reftarget,
refexplicit: query.refexplicit,
program,
contnode_text: &contnode_text,
};
let attempt = |target: &str, out: &mut DocumentResolution| -> Option<Node> {
let mut results = resolver.resolve_any(
&XrefRequest {
reftarget: target,
..req
},
ctx.module,
ctx.class,
children.resolved.as_ref(),
);
if results.is_empty() {
return None;
}
if results.len() > 1 {
let candidates = results
.iter()
.map(|candidate| format!(":{}:`{}`", candidate.role, candidate.label))
.collect::<Vec<_>>()
.join(" or ");
out.warnings.push(
BuildWarning::new(
path.to_path_buf(),
line,
format!(
"more than one target found for 'any' cross-reference {}: \
could be {candidates}",
py_repr_str(target)
),
WarningType::BrokenCrossReference,
)
.with_category(Some("ref.any".to_string())),
);
}
let AnyCandidate { role, node, .. } = results.remove(0);
let mut built = reference_node(node, children.contnode(), span);
if let Some(first) = built.children.first_mut() {
if first.kind != kinds::TEXT && !first.attrs.classes.is_empty() {
let domain_half = role.split(':').next().unwrap_or_default().to_string();
first.attrs.classes.push(domain_half);
first.attrs.classes.push(role.replace(':', "-"));
}
}
Some(built)
};
if let Some(node) = attempt(query.reftarget, out) {
return vec![node];
}
let mut diagnostics = Vec::new();
let outcome = resolver.intersphinx.resolve_detect(query, &mut diagnostics);
report(out, diagnostics, line, path);
match outcome {
HookOutcome::Resolved(resolution) => {
return vec![intersphinx_node(resolution, contnode, span)];
}
HookOutcome::SelfReferential(stripped) => {
if let Some(node) = attempt(&stripped, out) {
return vec![node];
}
}
HookOutcome::Missing => {}
}
if let Some(message) = missing_reference_warning(
resolver.env,
nitpick,
"",
"any",
query.reftarget,
ctx.refwarn,
) {
out.warnings.push(
BuildWarning::new(
path.to_path_buf(),
line,
message,
WarningType::BrokenCrossReference,
)
.with_category(Some("ref.any".to_string())),
);
}
children.fallback(out, line, path)
}
#[allow(clippy::too_many_arguments)]
fn resolve_external(
resolver: &Resolver<'_>,
query: &XrefQuery<'_>,
inventory: Option<&str>,
role_error: Option<&str>,
contnode: Option<Node>,
span: crate::doctree::Span,
line: Option<usize>,
path: &Path,
out: &mut DocumentResolution,
) -> Vec<Node> {
if let Some(inventory) = inventory {
if let Some(diagnostic) =
intersphinx::external_inventory_missing(resolver.intersphinx, inventory)
{
report(out, vec![diagnostic], line, path);
return Vec::new();
}
}
if let Some(message) = role_error {
report(
out,
vec![Diagnostic {
message: message.to_string(),
category: Some("intersphinx.external".to_string()),
}],
line,
path,
);
return Vec::new();
}
let mut diagnostics = Vec::new();
let resolution = match inventory {
Some(inventory) => {
resolver
.intersphinx
.resolve_in_inventory(inventory, query, &mut diagnostics)
}
None => resolver
.intersphinx
.resolve_any(false, query, &mut diagnostics),
};
report(out, diagnostics, line, path);
match resolution {
Some(resolution) => vec![intersphinx_node(resolution, contnode, span)],
None => {
report(
out,
vec![intersphinx::external_not_found(query)],
line,
path,
);
contnode.into_iter().collect()
}
}
}
struct XrefChildren {
contnode: Option<Node>,
resolved: Option<Vec<Node>>,
star: Option<Vec<Node>>,
first_is_condition: bool,
}
impl XrefChildren {
fn split(children: Vec<Node>) -> Self {
let first_is_condition = children
.first()
.is_some_and(|child| child.kind == "pending_xref_condition");
let find = |condition: &str| -> Option<Vec<Node>> {
children
.iter()
.find(|child| {
child.kind == "pending_xref_condition"
&& !child.children.is_empty()
&& matches!(child.get("condition"),
Some(AttrValue::Str(value)) if value == condition)
})
.map(|child| child.children.clone())
};
let resolved = find("resolved");
let star = find("*");
let contnode = resolved
.as_ref()
.or(star.as_ref())
.map(|content| content[0].clone())
.or_else(|| children.into_iter().next());
XrefChildren {
contnode,
resolved,
star,
first_is_condition,
}
}
fn contnode(&self) -> Option<Node> {
self.contnode.clone()
}
fn fallback(self, out: &mut DocumentResolution, line: Option<usize>, path: &Path) -> Vec<Node> {
if self.first_is_condition {
if let Some(star) = self.star {
return star;
}
out.warnings.push(
BuildWarning::new(
path.to_path_buf(),
line,
"Could not determine the fallback text for the cross-reference. \
Might be a bug."
.to_string(),
WarningType::BrokenCrossReference,
)
.with_category(None),
);
}
self.contnode.into_iter().collect()
}
}
fn report(
out: &mut DocumentResolution,
diagnostics: Vec<Diagnostic>,
line: Option<usize>,
path: &Path,
) {
for diagnostic in diagnostics {
out.warnings.push(
BuildWarning::new(
path.to_path_buf(),
line,
diagnostic.message,
WarningType::BrokenCrossReference,
)
.with_category(diagnostic.category),
);
}
}
fn intersphinx_node(
resolution: crate::intersphinx::Resolution,
contnode: Option<Node>,
span: crate::doctree::Span,
) -> Node {
let mut node = Node::elem(kinds::REFERENCE, span);
node.set("internal", AttrValue::Int(0));
node.set("refuri", AttrValue::Str(resolution.refuri));
node.set("reftitle", AttrValue::Str(resolution.reftitle));
match resolution.title {
Some(title) => {
let kind = contnode.as_ref().map_or(kinds::LITERAL, |node| node.kind);
let mut inner = Node::elem(kind, span);
inner.children.push(Node::text_node(title, span));
node.children.push(inner);
}
None => node.children.extend(contnode),
}
node
}
fn missing_reference_warning(
env: &BuildEnvironment,
nitpick: &NitpickConfig<'_>,
refdomain: &str,
typ: &str,
target: &str,
refwarn: bool,
) -> Option<String> {
let py = refdomain == "py";
let mut warn = refwarn;
if nitpick.nitpicky {
warn = true;
let dtype = if py {
format!("py:{typ}")
} else if refdomain.is_empty() {
typ.to_string()
} else {
format!("std:{typ}")
};
let bare = !py;
let ignored =
nitpick.ignore.iter().any(|(ityp, itarget)| {
(ityp == &dtype || (bare && ityp == typ)) && itarget == target
}) || nitpick.ignore_regex.iter().any(|(ityp, itarget)| {
(full_match(ityp, &dtype) || (bare && full_match(ityp, typ)))
&& full_match(itarget, target)
});
if ignored {
warn = false;
}
}
if !warn {
return None;
}
if py {
return Some(format!("py:{typ} reference target not found: {target}"));
}
if typ == "ref" {
return Some(if env.std.anonlabels.contains_key(target) {
format!(
"Failed to create a cross reference. A title or caption not found: {}",
py_repr_str(target)
)
} else {
format!("undefined label: {}", py_repr_str(target))
});
}
let message = match typ {
"term" => Some(format!("term not in glossary: {}", py_repr_str(target))),
"numref" => Some(format!("undefined label: {}", py_repr_str(target))),
"keyword" => Some(format!("unknown keyword: {}", py_repr_str(target))),
"doc" => Some(format!("unknown document: {}", py_repr_str(target))),
"option" => Some(format!("unknown option: {}", py_repr_str(target))),
_ => None,
};
Some(message.unwrap_or_else(|| {
format!("{} reference target not found: {target}", py_repr_str(typ))
}))
}
fn full_match(pattern: &str, text: &str) -> bool {
regex::Regex::new(&format!("^(?:{pattern})$"))
.map(|re| re.is_match(text))
.unwrap_or(false)
}
fn is_url(target: &str) -> bool {
target.starts_with("http://") || target.starts_with("https://") || target.starts_with("file://")
}
fn attr_str<'a>(node: &'a Node, key: &'static str) -> Option<&'a str> {
match node.get(key) {
Some(AttrValue::Str(value)) => Some(value.as_str()),
_ => None,
}
}
fn reference_node(
resolved: ResolvedXref,
contnode: Option<Node>,
span: crate::doctree::Span,
) -> Node {
let mut node = Node::elem(resolved.kind, span);
node.set("internal", AttrValue::Int(1));
if let Some(refid) = resolved.refid {
node.set("refid", AttrValue::Str(refid));
}
if let Some(refuri) = resolved.refuri {
node.set("refuri", AttrValue::Str(refuri));
}
if let Some(title) = resolved.title {
node.set("title", AttrValue::Str(title));
}
if let Some(reftitle) = resolved.reftitle {
node.set("reftitle", AttrValue::Str(reftitle));
}
match resolved.inner {
Inner::Contnode => node.children.extend(contnode),
Inner::Inline { text, classes } => {
let mut inner = Node::elem("inline", span);
inner.attrs.classes = classes;
inner.children.push(Node::text_node(text, span));
node.children.push(inner);
}
Inner::Children(children) => node.children.extend(children),
}
node
}
#[cfg(test)]
mod intersphinx_tests;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn subcommand_folding_splits_on_python_whitespace() {
assert_eq!(
split_once_whitespace("git\x1fadd -x"),
Some(("git", "add -x"))
);
assert_eq!(split_once_whitespace("add -x"), Some(("add", "-x")));
assert_eq!(
split_once_whitespace("git \x1f\t add"),
Some(("git", "add"))
);
assert_eq!(split_once_whitespace("-x"), None);
}
static INERT: Intersphinx = Intersphinx {
data: crate::intersphinx::IntersphinxData {
main: crate::inventory::Inventory {
data: BTreeMap::new(),
},
named: BTreeMap::new(),
},
disabled_reftypes: std::collections::BTreeSet::new(),
resolve_self: String::new(),
};
fn env_with_label() -> BuildEnvironment {
let mut env = BuildEnvironment::default();
env.std.labels.insert(
"the-label".to_string(),
(
"a".to_string(),
"the-label".to_string(),
"The Section".to_string(),
),
);
env.std.anonlabels.insert(
"the-label".to_string(),
("a".to_string(), "the-label".to_string()),
);
env.all_docs.insert("a".to_string(), 0);
env.all_docs.insert("b".to_string(), 0);
env
}
fn resolver<'a>(
env: &'a BuildEnvironment,
numfig_format: &'a BTreeMap<String, String>,
) -> Resolver<'a> {
Resolver {
env,
numfig: true,
numfig_format,
doctree: &|_| None,
relative_uri: &|_, _| String::new(),
intersphinx: &INERT,
}
}
fn request<'a>(fromdoc: &'a str, reftype: &'a str, reftarget: &'a str) -> XrefRequest<'a> {
XrefRequest {
fromdoc,
refdoc: fromdoc,
reftype,
reftarget,
refexplicit: false,
program: None,
contnode_text: reftarget,
}
}
#[test]
fn an_option_resolves_against_the_program_in_scope_where_it_was_written() {
let mut env = BuildEnvironment::default();
env.std
.add_program_option(Some("myprog"), "--verbose", "a", "cmdoption-myprog-verbose");
env.all_docs.insert("a".to_string(), 0);
let formats = BTreeMap::new();
let resolver = resolver(&env, &formats);
let mut scoped = request("a", "option", "--verbose");
scoped.program = Some("myprog");
assert_eq!(
resolver.resolve_xref(&scoped),
XrefOutcome::Resolved(ResolvedXref {
kind: kinds::REFERENCE,
refid: Some("cmdoption-myprog-verbose".to_string()),
refuri: None,
title: None,
reftitle: None,
inner: Inner::Contnode,
})
);
assert_eq!(
resolver.resolve_xref(&request("a", "option", "--verbose")),
XrefOutcome::Missing
);
}
#[test]
fn a_same_document_ref_uses_refid_and_a_cross_document_one_uses_refuri() {
let env = env_with_label();
let formats = BTreeMap::new();
let resolver = resolver(&env, &formats);
let same = resolver.resolve_xref(&request("a", "ref", "the-label"));
assert_eq!(
same,
XrefOutcome::Resolved(ResolvedXref {
kind: kinds::REFERENCE,
refid: Some("the-label".to_string()),
refuri: None,
title: None,
reftitle: None,
inner: Inner::Inline {
text: "The Section".to_string(),
classes: vec!["std".to_string(), "std-ref".to_string()],
},
})
);
let cross = resolver.resolve_xref(&request("b", "ref", "the-label"));
let XrefOutcome::Resolved(cross) = cross else {
panic!("expected a resolved reference, got {cross:?}")
};
assert_eq!(cross.refuri.as_deref(), Some("#the-label"));
assert_eq!(cross.refid, None);
}
#[test]
fn an_explicit_ref_titles_itself_from_the_anonymous_label() {
let env = env_with_label();
let formats = BTreeMap::new();
let resolver = resolver(&env, &formats);
let mut req = request("a", "ref", "the-label");
req.refexplicit = true;
req.contnode_text = "My Own Words";
let XrefOutcome::Resolved(resolved) = resolver.resolve_xref(&req) else {
panic!("expected a resolved reference")
};
assert_eq!(
resolved.inner,
Inner::Inline {
text: "My Own Words".to_string(),
classes: vec!["std".to_string(), "std-ref".to_string()],
}
);
}
#[test]
fn a_doc_reference_joins_the_target_against_the_referencing_document() {
let mut env = BuildEnvironment::default();
env.all_docs.insert("sub/c".to_string(), 0);
let mut title = Node::elem(kinds::TITLE, crate::doctree::Span::ZERO);
title
.children
.push(Node::text_node("Sub C", crate::doctree::Span::ZERO));
env.titles.insert("sub/c".to_string(), title);
let formats = BTreeMap::new();
let resolver = resolver(&env, &formats);
let relative = resolver.resolve_xref(&request("sub/b", "doc", "c"));
assert_eq!(
relative,
XrefOutcome::Resolved(ResolvedXref {
kind: kinds::REFERENCE,
refid: None,
refuri: Some(String::new()),
title: None,
reftitle: None,
inner: Inner::Inline {
text: "Sub C".to_string(),
classes: vec!["doc".to_string()],
},
}),
"the caption comes from the target's title, not the written target"
);
assert_eq!(
resolver.resolve_xref(&request("sub/b", "doc", "/sub/c")),
relative,
"an absolute target names the same document"
);
assert_eq!(
resolver.resolve_xref(&request("sub/b", "doc", "nope")),
XrefOutcome::Missing
);
}
#[test]
fn option_resolution_folds_leading_words_into_the_program_name() {
let mut env = BuildEnvironment::default();
env.std
.add_program_option(Some("myprog"), "--verbose", "a", "cmdoption-myprog-verbose");
env.std
.add_program_option(None, "--global", "a", "cmdoption-global");
let formats = BTreeMap::new();
let resolver = resolver(&env, &formats);
let XrefOutcome::Resolved(scoped) =
resolver.resolve_xref(&request("b", "option", "myprog --verbose"))
else {
panic!("`myprog --verbose` must resolve through the program fallback")
};
assert_eq!(scoped.refuri.as_deref(), Some("#cmdoption-myprog-verbose"));
let XrefOutcome::Resolved(global) =
resolver.resolve_xref(&request("b", "option", "--global"))
else {
panic!("an unscoped option resolves under the `None` program")
};
assert_eq!(global.refuri.as_deref(), Some("#cmdoption-global"));
assert_eq!(
resolver.resolve_xref(&request("b", "option", "--missing")),
XrefOutcome::Missing
);
}
#[test]
fn option_resolution_strips_an_option_value() {
let mut env = BuildEnvironment::default();
env.std
.add_program_option(None, "-foo", "a", "cmdoption-foo");
let formats = BTreeMap::new();
let resolver = resolver(&env, &formats);
for target in ["-foo=bar", "-foo[=bar]"] {
let outcome = resolver.resolve_xref(&request("b", "option", target));
assert!(
matches!(outcome, XrefOutcome::Resolved(_)),
"{target} must fall back to the option stem, got {outcome:?}"
);
}
}
#[test]
fn term_resolution_falls_back_to_a_case_insensitive_match() {
let mut env = BuildEnvironment::default();
env.std.note_term("environment", "a", "term-environment");
let formats = BTreeMap::new();
let resolver = resolver(&env, &formats);
let exact = resolver.resolve_xref(&request("b", "term", "environment"));
let other_case = resolver.resolve_xref(&request("b", "term", "Environment"));
assert!(matches!(exact, XrefOutcome::Resolved(_)));
assert_eq!(exact, other_case);
assert_eq!(
resolver.resolve_xref(&request("b", "term", "nonexistent term")),
XrefOutcome::Missing
);
}
#[test]
fn numfig_off_keeps_the_content_node_and_says_so_once() {
let mut env = BuildEnvironment::default();
env.std.labels.insert(
"fig-a".to_string(),
("a".to_string(), "fig-a".to_string(), "A Figure".to_string()),
);
let mut figure = Node::elem("figure", crate::doctree::Span::ZERO);
figure.attrs.ids.push("fig-a".to_string());
let mut root = Node::elem(kinds::DOCUMENT, crate::doctree::Span::ZERO);
root.children.push(figure);
let doctree = Doctree {
root,
sources: vec!["<test>".to_string()],
};
let formats = BTreeMap::new();
let resolver = Resolver {
env: &env,
numfig: false,
numfig_format: &formats,
doctree: &|_| Some(Cow::Borrowed(&doctree)),
relative_uri: &|_, _| String::new(),
intersphinx: &INERT,
};
assert_eq!(
resolver.resolve_xref(&request("b", "numref", "fig-a")),
XrefOutcome::Kept {
warning: Some("numfig is disabled. :numref: is ignored.".to_string())
}
);
}
#[test]
fn a_nameless_numref_target_reports_the_format_it_could_not_fill() {
let mut env = BuildEnvironment::default();
env.std.labels.insert(
"captionless".to_string(),
("a".to_string(), "captionless".to_string(), String::new()),
);
env.std
.anonlabels
.insert("anon".to_string(), ("a".to_string(), "anon".to_string()));
env.toc_fignumbers.insert(
"a".to_string(),
BTreeMap::from([(
"figure".to_string(),
BTreeMap::from([
("captionless".to_string(), vec![1]),
("anon".to_string(), vec![2]),
]),
)]),
);
let mut root = Node::elem(kinds::DOCUMENT, crate::doctree::Span::ZERO);
for id in ["captionless", "anon"] {
let mut figure = Node::elem("figure", crate::doctree::Span::ZERO);
figure.attrs.ids.push(id.to_string());
root.children.push(figure);
}
let doctree = Doctree {
root,
sources: vec!["<test>".to_string()],
};
let formats = BTreeMap::from([("figure".to_string(), "Fig. {name} {number}".to_string())]);
let resolver = Resolver {
env: &env,
numfig: true,
numfig_format: &formats,
doctree: &|_| Some(Cow::Borrowed(&doctree)),
relative_uri: &|_, _| String::new(),
intersphinx: &INERT,
};
assert_eq!(
resolver.resolve_xref(&request("b", "numref", "anon")),
XrefOutcome::Kept {
warning: Some("the link has no caption: Fig. {name} {number}".to_string())
},
"an anonymous-only label has no caption to name"
);
assert_eq!(
resolver.resolve_xref(&request("b", "numref", "captionless")),
XrefOutcome::Kept {
warning: Some(
"invalid numfig_format: Fig. {name} {number} (KeyError('name'))".to_string()
)
},
"an empty caption is falsy, so `name` is never passed to format()"
);
}
#[test]
fn a_numref_target_with_no_number_names_the_label_it_could_not_number() {
let mut env = BuildEnvironment::default();
env.std.labels.insert(
"fig-a".to_string(),
("a".to_string(), "fig-a".to_string(), "A Figure".to_string()),
);
let mut figure = Node::elem("figure", crate::doctree::Span::ZERO);
figure.attrs.ids.push("fig-a".to_string());
let mut root = Node::elem(kinds::DOCUMENT, crate::doctree::Span::ZERO);
root.children.push(figure);
let doctree = Doctree {
root,
sources: vec!["<test>".to_string()],
};
let formats = BTreeMap::from([("figure".to_string(), "Fig. %s".to_string())]);
let resolver = Resolver {
env: &env,
numfig: true,
numfig_format: &formats,
doctree: &|_| Some(Cow::Borrowed(&doctree)),
relative_uri: &|_, _| String::new(),
intersphinx: &INERT,
};
assert_eq!(
resolver.resolve_xref(&request("b", "numref", "fig-a")),
XrefOutcome::Kept {
warning: Some(
"Failed to create a cross reference. Any number is not assigned: fig-a"
.to_string()
)
}
);
}
#[test]
fn numref_renders_both_format_styles_and_reports_broken_ones() {
assert_eq!(format_old_style("Fig. %s", "1.2").unwrap(), "Fig. 1.2");
assert!(
format_old_style("Fig.", "1").is_err(),
"no conversion: TypeError"
);
assert!(
format_old_style("%s %s", "1").is_err(),
"two conversions for one argument: TypeError"
);
assert_eq!(
format_new_style("Custom {name} number {number}", Some("Cap"), "1").unwrap(),
"Custom Cap number 1"
);
assert_eq!(
format_new_style("Table {number}", None, "3").unwrap(),
"Table 3"
);
let err = format_new_style("{nope}", Some("Cap"), "1").err().unwrap();
assert_eq!(err.0, "nope");
}
#[test]
fn dangling_warnings_use_the_exact_sphinx_texts() {
let env = env_with_label();
let nitpick = NitpickConfig {
nitpicky: false,
ignore: &[],
ignore_regex: &[],
};
let warn = |typ: &str, target: &str| {
missing_reference_warning(&env, &nitpick, "std", typ, target, true)
};
assert_eq!(
warn("doc", "missing-doc").unwrap(),
"unknown document: 'missing-doc'"
);
assert_eq!(
warn("term", "nonexistent term").unwrap(),
"term not in glossary: 'nonexistent term'"
);
assert_eq!(warn("option", "--x").unwrap(), "unknown option: '--x'");
assert_eq!(warn("keyword", "k").unwrap(), "unknown keyword: 'k'");
assert_eq!(warn("numref", "fig").unwrap(), "undefined label: 'fig'");
assert_eq!(warn("ref", "nope").unwrap(), "undefined label: 'nope'");
assert_eq!(
warn("ref", "the-label").unwrap(),
"Failed to create a cross reference. A title or caption not found: 'the-label'",
"a label that exists but has no title takes the other branch"
);
assert_eq!(
warn("envvar", "PATH").unwrap(),
"'envvar' reference target not found: PATH",
"a role with no dangling_warnings entry takes the generic form"
);
}
#[test]
fn a_role_that_is_not_warn_dangling_only_warns_under_nitpicky() {
let env = env_with_label();
let quiet = NitpickConfig {
nitpicky: false,
ignore: &[],
ignore_regex: &[],
};
assert_eq!(
missing_reference_warning(&env, &quiet, "std", "envvar", "PATH", false),
None
);
let nitpicky = NitpickConfig {
nitpicky: true,
ignore: &[],
ignore_regex: &[],
};
assert!(
missing_reference_warning(&env, &nitpicky, "std", "envvar", "PATH", false).is_some()
);
}
fn any_roles(resolver: &Resolver<'_>, req: &XrefRequest<'_>) -> Vec<(String, String)> {
resolver
.resolve_any(req, None, None, None)
.into_iter()
.map(|candidate| (candidate.role, candidate.label))
.collect()
}
#[test]
fn any_walks_doc_then_ref_then_option_then_the_objects_table() {
let mut env = BuildEnvironment::default();
env.all_docs.insert("same".to_string(), 0);
let mut title = Node::elem(kinds::TITLE, crate::doctree::Span::ZERO);
title
.children
.push(Node::text_node("Doc Title", crate::doctree::Span::ZERO));
env.titles.insert("same".to_string(), title);
env.std.labels.insert(
"same".to_string(),
("a".to_string(), "same".to_string(), "Sect".to_string()),
);
env.std.note_object("envvar", "same", "a", "envvar-same");
env.py.note_object(
"m.same",
crate::env::py_domain::PyObjectEntry {
docname: "a".to_string(),
node_id: "m.same".to_string(),
objtype: "function".to_string(),
aliased: false,
},
);
let formats = BTreeMap::new();
let resolver = resolver(&env, &formats);
let req = request("a", "any", "same");
assert_eq!(
any_roles(&resolver, &req),
vec![
("doc".to_string(), "Doc Title".to_string()),
("std:ref".to_string(), "Sect".to_string()),
("std:envvar".to_string(), "same".to_string()),
("py:func".to_string(), "m.same".to_string()),
]
);
}
#[test]
fn any_lowercases_the_ref_arm_and_the_term_key_only() {
let mut env = BuildEnvironment::default();
env.std.labels.insert(
"mixed".to_string(),
("a".to_string(), "mixed".to_string(), "Sect".to_string()),
);
env.std.note_term("Aterm", "a", "term-Aterm");
env.std.note_term("bterm", "a", "term-bterm");
let formats = BTreeMap::new();
let resolver = resolver(&env, &formats);
assert_eq!(
any_roles(&resolver, &request("a", "any", "MIXED"))
.iter()
.map(|(role, _)| role.as_str())
.collect::<Vec<_>>(),
vec!["std:ref"],
"the ref arm sees the lowercased target"
);
assert!(
any_roles(&resolver, &request("a", "any", "Aterm")).is_empty(),
"objects holds ('term', 'Aterm') but the walk asks for ('term', 'aterm')"
);
assert!(
any_roles(&resolver, &request("a", "any", "aterm")).is_empty(),
"and 'aterm' was never registered"
);
assert_eq!(
any_roles(&resolver, &request("a", "any", "BTERM"))
.iter()
.map(|(role, _)| role.as_str())
.collect::<Vec<_>>(),
vec!["std:term"],
"a lowercase-registered term is reachable under any case"
);
}
#[test]
fn any_module_candidates_label_with_the_synopsis_reftitle() {
let mut env = BuildEnvironment::default();
env.py.note_object(
"syn",
crate::env::py_domain::PyObjectEntry {
docname: "a".to_string(),
node_id: "module-syn".to_string(),
objtype: "module".to_string(),
aliased: false,
},
);
env.py.note_module(
"syn",
crate::env::py_domain::PyModuleEntry {
docname: "a".to_string(),
node_id: "module-syn".to_string(),
synopsis: "The syn module.".to_string(),
platform: String::new(),
deprecated: false,
},
);
let formats = BTreeMap::new();
let resolver = resolver(&env, &formats);
assert_eq!(
any_roles(&resolver, &request("a", "any", "syn")),
vec![("py:mod".to_string(), "syn: The syn module.".to_string())]
);
}
#[test]
fn a_missing_any_reference_warns_with_the_domainless_spelling() {
let env = BuildEnvironment::default();
let quiet = NitpickConfig {
nitpicky: false,
ignore: &[],
ignore_regex: &[],
};
assert_eq!(
missing_reference_warning(&env, &quiet, "", "any", "missing_thing", true).unwrap(),
"'any' reference target not found: missing_thing"
);
let ignore = vec![("any".to_string(), "missing_thing".to_string())];
let nitpicky = NitpickConfig {
nitpicky: true,
ignore: &ignore,
ignore_regex: &[],
};
assert_eq!(
missing_reference_warning(&env, &nitpicky, "", "any", "missing_thing", true),
None
);
}
#[test]
fn nitpick_ignore_filters_by_exact_pair_and_by_regex() {
let env = env_with_label();
let exact = vec![("std:doc".to_string(), "missing".to_string())];
let config = NitpickConfig {
nitpicky: true,
ignore: &exact,
ignore_regex: &[],
};
assert_eq!(
missing_reference_warning(&env, &config, "std", "doc", "missing", true),
None
);
assert!(missing_reference_warning(&env, &config, "std", "doc", "other", true).is_some());
let domainless = vec![("doc".to_string(), "missing".to_string())];
let config = NitpickConfig {
nitpicky: true,
ignore: &domainless,
ignore_regex: &[],
};
assert_eq!(
missing_reference_warning(&env, &config, "std", "doc", "missing", true),
None
);
let regex = vec![("std:.*".to_string(), "miss.*".to_string())];
let config = NitpickConfig {
nitpicky: true,
ignore: &[],
ignore_regex: ®ex,
};
assert_eq!(
missing_reference_warning(&env, &config, "std", "doc", "missing", true),
None
);
assert!(
missing_reference_warning(&env, &config, "std", "doc", "hit", true).is_some(),
"the regexes must both full-match, not merely find"
);
}
}