use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::env::std_domain::{source_path_of, DocumentIds, DocumentSource};
use crate::env::BuildEnvironment;
use crate::error::{BuildWarning, WarningType};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PyObjectEntry {
pub docname: String,
pub node_id: String,
pub objtype: String,
pub aliased: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PyModuleEntry {
pub docname: String,
pub node_id: String,
pub synopsis: String,
pub platform: String,
pub deprecated: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct PyDomainData {
pub objects: Vec<(String, PyObjectEntry)>,
pub objects_index: BTreeMap<String, usize>,
pub modules: Vec<(String, PyModuleEntry)>,
pub modules_index: BTreeMap<String, usize>,
}
impl PyDomainData {
pub fn note_object(&mut self, name: &str, entry: PyObjectEntry) -> Option<String> {
if let Some(&index) = self.objects_index.get(name) {
let other = &self.objects[index].1;
if !other.aliased && entry.aliased {
return None;
}
let warn = (other.aliased == entry.aliased).then(|| other.docname.clone());
self.objects[index].1 = entry;
warn
} else {
self.objects_index
.insert(name.to_string(), self.objects.len());
self.objects.push((name.to_string(), entry));
None
}
}
pub fn note_module(&mut self, name: &str, entry: PyModuleEntry) {
if let Some(&index) = self.modules_index.get(name) {
self.modules[index].1 = entry;
} else {
self.modules_index
.insert(name.to_string(), self.modules.len());
self.modules.push((name.to_string(), entry));
}
}
pub fn clear_doc(&mut self, docname: &str) {
self.objects.retain(|(_, entry)| entry.docname != docname);
self.modules.retain(|(_, entry)| entry.docname != docname);
self.rebuild_indices();
}
pub fn merge(&mut self, other: &PyDomainData, docnames: &BTreeSet<String>) {
for (name, entry) in &other.objects {
if !docnames.contains(&entry.docname) {
continue;
}
if let Some(&index) = self.objects_index.get(name) {
self.objects[index].1 = entry.clone();
} else {
self.objects_index.insert(name.clone(), self.objects.len());
self.objects.push((name.clone(), entry.clone()));
}
}
for (name, entry) in &other.modules {
if !docnames.contains(&entry.docname) {
continue;
}
if let Some(&index) = self.modules_index.get(name) {
self.modules[index].1 = entry.clone();
} else {
self.modules_index.insert(name.clone(), self.modules.len());
self.modules.push((name.clone(), entry.clone()));
}
}
}
fn rebuild_indices(&mut self) {
self.objects_index = self
.objects
.iter()
.enumerate()
.map(|(index, (name, _))| (name.clone(), index))
.collect();
self.modules_index = self
.modules
.iter()
.enumerate()
.map(|(index, (name, _))| (name.clone(), index))
.collect();
}
}
const OBJECT_TYPES: &[&str] = &[
"function",
"data",
"class",
"exception",
"method",
"classmethod",
"staticmethod",
"attribute",
"property",
"type",
"module",
];
pub(crate) fn objtypes_for_role(role: &str) -> Option<&'static [&'static str]> {
Some(match role {
"func" => &["function"],
"data" => &["data"],
"class" => &["class", "exception", "type"],
"exc" => &["class", "exception"],
"meth" => &["method", "classmethod", "staticmethod"],
"attr" => &["attribute", "property"],
"_prop" => &["property"],
"type" => &["type"],
"mod" => &["module"],
"obj" => OBJECT_TYPES,
_ => return None,
})
}
pub(crate) fn role_for_objtype(objtype: &str) -> Option<&'static str> {
Some(match objtype {
"function" => "func",
"data" => "data",
"class" => "class",
"exception" => "exc",
"method" | "classmethod" | "staticmethod" => "meth",
"attribute" | "property" => "attr",
"type" => "type",
"module" => "mod",
_ => return None,
})
}
pub fn find_obj<'a>(
data: &'a PyDomainData,
modname: Option<&str>,
classname: Option<&str>,
name: &str,
typ: Option<&str>,
searchmode: u8,
) -> Vec<(String, &'a PyObjectEntry)> {
let name = name.strip_suffix("()").unwrap_or(name);
if name.is_empty() {
return Vec::new();
}
let modname = modname.filter(|m| !m.is_empty());
let classname = classname.filter(|c| !c.is_empty());
let entry_of = |fullname: &str| {
data.objects_index
.get(fullname)
.map(|&index| &data.objects[index].1)
};
let newname: Option<String> = if searchmode == 1 {
let objtypes = match typ {
None => Some(OBJECT_TYPES),
Some(role) => objtypes_for_role(role),
};
let Some(objtypes) = objtypes else {
return Vec::new();
};
let gated = |fullname: &str| {
entry_of(fullname).is_some_and(|entry| objtypes.contains(&entry.objtype.as_str()))
};
let qualified = match (modname, classname) {
(Some(modname), Some(classname)) => {
Some(format!("{modname}.{classname}.{name}")).filter(|fullname| gated(fullname))
}
_ => None,
};
if qualified.is_some() {
qualified
} else if let Some(dotted) = modname
.map(|modname| format!("{modname}.{name}"))
.filter(|dotted| gated(dotted))
{
Some(dotted)
} else if gated(name) {
Some(name.to_string())
} else {
let searchname = format!(".{name}");
return data
.objects
.iter()
.filter(|(oname, entry)| {
oname.ends_with(&searchname) && objtypes.contains(&entry.objtype.as_str())
})
.map(|(oname, entry)| (oname.clone(), entry))
.collect();
}
} else {
if entry_of(name).is_some() {
Some(name.to_string())
} else if typ == Some("mod") {
return Vec::new();
} else {
[
classname.map(|classname| format!("{classname}.{name}")),
modname.map(|modname| format!("{modname}.{name}")),
match (modname, classname) {
(Some(modname), Some(classname)) => {
Some(format!("{modname}.{classname}.{name}"))
}
_ => None,
},
]
.into_iter()
.flatten()
.find(|candidate| entry_of(candidate).is_some())
}
};
newname
.map(|newname| {
let entry = entry_of(&newname).expect("candidate was just found");
vec![(newname, entry)]
})
.unwrap_or_default()
}
#[derive(Debug, PartialEq)]
pub struct PyXrefTarget<'a> {
pub docname: &'a str,
pub node_id: &'a str,
pub reftitle: String,
pub is_module: bool,
}
pub fn resolve_xref<'a>(
data: &'a PyDomainData,
modname: Option<&str>,
classname: Option<&str>,
reftype: &str,
target: &str,
searchmode: u8,
) -> (Option<PyXrefTarget<'a>>, Option<String>) {
let retry = |typ: &str| find_obj(data, modname, classname, target, Some(typ), searchmode);
let mut matches = retry(reftype);
if matches.is_empty() && reftype == "class" {
matches = retry("data");
if matches.is_empty() {
matches = retry("attr");
}
}
if matches.is_empty() && reftype == "attr" {
matches = retry("meth");
}
if matches.is_empty() && reftype == "meth" {
matches = retry("_prop");
}
if matches.is_empty() {
return (None, None);
}
let mut warning = None;
let (name, entry) = if matches.len() > 1 {
let canonicals: Vec<&(String, &PyObjectEntry)> =
matches.iter().filter(|(_, entry)| !entry.aliased).collect();
if canonicals.len() == 1 {
let (name, entry) = canonicals[0];
(name.clone(), *entry)
} else {
warning = Some(format!(
"more than one target found for cross-reference {}: {}",
crate::utils::py_repr_str(target),
matches
.iter()
.map(|(name, _)| name.as_str())
.collect::<Vec<_>>()
.join(", ")
));
let (name, entry) = &matches[0];
(name.clone(), *entry)
}
} else {
let (name, entry) = matches.remove(0);
(name, entry)
};
if entry.objtype == "module" {
(module_xref_target(data, name), warning)
} else {
(
Some(PyXrefTarget {
docname: &entry.docname,
node_id: &entry.node_id,
reftitle: name,
is_module: false,
}),
warning,
)
}
}
fn module_xref_target(data: &PyDomainData, name: String) -> Option<PyXrefTarget<'_>> {
let &index = data.modules_index.get(&name)?;
let module = &data.modules[index].1;
let mut reftitle = name;
if !module.synopsis.is_empty() {
reftitle.push_str(": ");
reftitle.push_str(&module.synopsis);
}
if module.deprecated {
reftitle.push_str(" (deprecated)");
}
if !module.platform.is_empty() {
reftitle.push_str(" (");
reftitle.push_str(&module.platform);
reftitle.push(')');
}
Some(PyXrefTarget {
docname: &module.docname,
node_id: &module.node_id,
reftitle,
is_module: true,
})
}
pub fn resolve_any_xref<'a>(
data: &'a PyDomainData,
modname: Option<&str>,
classname: Option<&str>,
target: &str,
) -> Vec<(String, PyXrefTarget<'a>)> {
let matches = find_obj(data, modname, classname, target, None, 1);
let multiple = matches.len() > 1;
let mut results = Vec::new();
for (name, entry) in matches {
if multiple && entry.aliased {
continue;
}
if entry.objtype == "module" {
if let Some(target) = module_xref_target(data, name) {
results.push(("py:mod".to_string(), target));
}
} else if let Some(role) = role_for_objtype(&entry.objtype) {
results.push((
format!("py:{role}"),
PyXrefTarget {
docname: &entry.docname,
node_id: &entry.node_id,
reftitle: name,
is_module: false,
},
));
}
}
results
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ModindexEntry {
pub name: String,
pub subtype: u8,
pub docname: String,
pub anchor: String,
pub extra: String,
pub qualifier: String,
pub descr: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ModindexGroup {
pub letter: String,
pub entries: Vec<ModindexEntry>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct PyModindex {
pub groups: Vec<ModindexGroup>,
pub collapse: bool,
}
pub fn generate_modindex(data: &PyDomainData, common_prefix: &[String]) -> PyModindex {
let mut ignores: Vec<&str> = common_prefix.iter().map(String::as_str).collect();
ignores.sort_by_key(|prefix| std::cmp::Reverse(prefix.len()));
let mut modules: Vec<(&str, &PyModuleEntry)> = data
.modules
.iter()
.map(|(name, entry)| (name.as_str(), entry))
.collect();
modules.sort_by_key(|(name, _)| name.to_lowercase());
let mut content: BTreeMap<String, Vec<ModindexEntry>> = BTreeMap::new();
let mut prev_modname = String::new();
let mut num_top_levels = 0usize;
for (full_name, module) in &modules {
let mut modname = *full_name;
let mut stripped = "";
for ignore in &ignores {
if let Some(rest) = modname.strip_prefix(ignore) {
modname = rest;
stripped = ignore;
break;
}
}
if modname.is_empty() {
(modname, stripped) = (stripped, "");
}
let Some(first) = modname.chars().next() else {
continue;
};
let entries = content
.entry(first.to_lowercase().collect::<String>())
.or_default();
let package = modname.split('.').next().unwrap_or(modname);
let subtype = if package != modname {
if prev_modname == package {
if let Some(last) = entries.last_mut() {
last.subtype = 1;
}
} else if !prev_modname.starts_with(package) {
entries.push(ModindexEntry {
name: format!("{stripped}{package}"),
subtype: 1,
docname: String::new(),
anchor: String::new(),
extra: String::new(),
qualifier: String::new(),
descr: String::new(),
});
}
2
} else {
num_top_levels += 1;
0
};
entries.push(ModindexEntry {
name: format!("{stripped}{modname}"),
subtype,
docname: module.docname.clone(),
anchor: module.node_id.clone(),
extra: module.platform.clone(),
qualifier: if module.deprecated {
"Deprecated".to_string()
} else {
String::new()
},
descr: module.synopsis.clone(),
});
prev_modname = modname.to_string();
}
let collapse = modules.len() - num_top_levels < num_top_levels;
PyModindex {
groups: content
.into_iter()
.map(|(letter, entries)| ModindexGroup { letter, entries })
.collect(),
collapse,
}
}
pub fn modindex_snapshot(modindex: &PyModindex) -> serde_json::Value {
serde_json::to_value(modindex).unwrap_or(serde_json::Value::Null)
}
const BUILTIN_CLASSES: &[&str] = &[
"ArithmeticError",
"AssertionError",
"AttributeError",
"BaseException",
"BaseExceptionGroup",
"BlockingIOError",
"BrokenPipeError",
"BufferError",
"BytesWarning",
"ChildProcessError",
"ConnectionAbortedError",
"ConnectionError",
"ConnectionRefusedError",
"ConnectionResetError",
"DeprecationWarning",
"EOFError",
"EncodingWarning",
"EnvironmentError",
"Exception",
"ExceptionGroup",
"FileExistsError",
"FileNotFoundError",
"FloatingPointError",
"FutureWarning",
"GeneratorExit",
"IOError",
"ImportError",
"ImportWarning",
"IndentationError",
"IndexError",
"InterruptedError",
"IsADirectoryError",
"KeyError",
"KeyboardInterrupt",
"LookupError",
"MemoryError",
"ModuleNotFoundError",
"NameError",
"NotADirectoryError",
"NotImplementedError",
"OSError",
"OverflowError",
"PendingDeprecationWarning",
"PermissionError",
"ProcessLookupError",
"RecursionError",
"ReferenceError",
"ResourceWarning",
"RuntimeError",
"RuntimeWarning",
"StopAsyncIteration",
"StopIteration",
"SyntaxError",
"SyntaxWarning",
"SystemError",
"SystemExit",
"TabError",
"TimeoutError",
"TypeError",
"UnboundLocalError",
"UnicodeDecodeError",
"UnicodeEncodeError",
"UnicodeError",
"UnicodeTranslateError",
"UnicodeWarning",
"UserWarning",
"ValueError",
"Warning",
"ZeroDivisionError",
"__loader__",
"bool",
"bytearray",
"bytes",
"classmethod",
"complex",
"dict",
"enumerate",
"filter",
"float",
"frozenset",
"int",
"list",
"map",
"memoryview",
"object",
"property",
"range",
"reversed",
"set",
"slice",
"staticmethod",
"str",
"super",
"tuple",
"type",
"zip",
];
const TYPING_ALL: &[&str] = &[
"AbstractSet",
"Annotated",
"Any",
"AnyStr",
"AsyncContextManager",
"AsyncGenerator",
"AsyncIterable",
"AsyncIterator",
"Awaitable",
"BinaryIO",
"ByteString",
"Callable",
"ChainMap",
"ClassVar",
"Collection",
"Concatenate",
"Container",
"ContextManager",
"Coroutine",
"Counter",
"DefaultDict",
"Deque",
"Dict",
"Final",
"ForwardRef",
"FrozenSet",
"Generator",
"Generic",
"Hashable",
"IO",
"ItemsView",
"Iterable",
"Iterator",
"KeysView",
"List",
"Literal",
"LiteralString",
"Mapping",
"MappingView",
"Match",
"MutableMapping",
"MutableSequence",
"MutableSet",
"NamedTuple",
"Never",
"NewType",
"NoReturn",
"NotRequired",
"Optional",
"OrderedDict",
"ParamSpec",
"ParamSpecArgs",
"ParamSpecKwargs",
"Pattern",
"Protocol",
"Required",
"Reversible",
"Self",
"Sequence",
"Set",
"Sized",
"SupportsAbs",
"SupportsBytes",
"SupportsComplex",
"SupportsFloat",
"SupportsIndex",
"SupportsInt",
"SupportsRound",
"TYPE_CHECKING",
"Text",
"TextIO",
"Tuple",
"Type",
"TypeAlias",
"TypeAliasType",
"TypeGuard",
"TypeVar",
"TypeVarTuple",
"TypedDict",
"Union",
"Unpack",
"ValuesView",
"assert_never",
"assert_type",
"cast",
"clear_overloads",
"dataclass_transform",
"final",
"get_args",
"get_origin",
"get_overloads",
"get_type_hints",
"is_typeddict",
"no_type_check",
"no_type_check_decorator",
"overload",
"override",
"reveal_type",
"runtime_checkable",
];
pub fn builtin_resolver(reftype: &str, target: &str) -> bool {
match reftype {
"class" | "obj" if target == "None" => true,
"class" | "obj" | "exc" => {
BUILTIN_CLASSES.binary_search(&target).is_ok()
|| TYPING_ALL
.binary_search(&target.strip_prefix("typing.").unwrap_or(target))
.is_ok()
}
_ => false,
}
}
pub(crate) fn collect_registrations(
env: &mut BuildEnvironment,
doc: &DocumentSource<'_>,
ids: &DocumentIds<'_>,
warnings: &mut Vec<(usize, BuildWarning)>,
) {
for record in &doc.registry.py_modules {
env.py.note_module(
&record.name,
PyModuleEntry {
docname: doc.docname.to_string(),
node_id: record.node_id.clone(),
synopsis: record.synopsis.clone(),
platform: record.platform.clone(),
deprecated: record.deprecated,
},
);
}
for record in &doc.registry.py_objects {
let Some(other) = env.py.note_object(
&record.fullname,
PyObjectEntry {
docname: doc.docname.to_string(),
node_id: record.node_id.clone(),
objtype: record.objtype.clone(),
aliased: record.aliased,
},
) else {
continue;
};
let order = ids
.get(&record.node_id)
.map(|(order, _)| order)
.unwrap_or(usize::MAX);
warnings.push((
order,
BuildWarning::new(
source_path_of(doc, record.source),
Some(record.lineno as usize),
format!(
"duplicate object description of {}, other instance in {}, \
use :no-index: for one of them",
record.fullname, other
),
WarningType::DuplicateLabel,
)
.with_category(None),
));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::env::std_domain;
use crate::rst::{parse_rst_full, ParseOptions};
use std::path::PathBuf;
fn entry(docname: &str, node_id: &str, objtype: &str, aliased: bool) -> PyObjectEntry {
PyObjectEntry {
docname: docname.to_string(),
node_id: node_id.to_string(),
objtype: objtype.to_string(),
aliased,
}
}
fn module_entry(docname: &str, node_id: &str) -> PyModuleEntry {
PyModuleEntry {
docname: docname.to_string(),
node_id: node_id.to_string(),
synopsis: String::new(),
platform: String::new(),
deprecated: false,
}
}
fn object_rows(data: &PyDomainData) -> Vec<(&str, &str, bool)> {
data.objects
.iter()
.map(|(name, e)| (name.as_str(), e.docname.as_str(), e.aliased))
.collect()
}
fn assert_indices_consistent(data: &PyDomainData) {
assert_eq!(data.objects_index.len(), data.objects.len());
for (name, &index) in &data.objects_index {
assert_eq!(&data.objects[index].0, name, "objects_index[{name}]");
}
assert_eq!(data.modules_index.len(), data.modules.len());
for (name, &index) in &data.modules_index {
assert_eq!(&data.modules[index].0, name, "modules_index[{name}]");
}
}
fn data_of(entries: &[(&str, &str)]) -> PyDomainData {
let mut data = PyDomainData::default();
for (name, objtype) in entries {
data.note_object(name, entry("index", name, objtype, false));
}
data
}
fn names(matches: &[(String, &PyObjectEntry)]) -> Vec<String> {
matches.iter().map(|(name, _)| name.clone()).collect()
}
#[test]
fn exact_mode_walks_the_candidate_chain_in_spec_order() {
let data = data_of(&[
("m.C.x", "method"),
("m.x", "function"),
("C.x", "method"),
("x", "function"),
]);
let find = |modname: Option<&str>, classname: Option<&str>| {
names(&find_obj(&data, modname, classname, "x", Some("func"), 0))
};
assert_eq!(find(Some("m"), Some("C")), vec!["x"], "bare name first");
let partial = data_of(&[("m.C.x", "method"), ("m.x", "function"), ("C.x", "method")]);
assert_eq!(
names(&find_obj(
&partial,
Some("m"),
Some("C"),
"x",
Some("func"),
0
)),
vec!["C.x"],
"then classname.name"
);
let partial = data_of(&[("m.C.x", "method"), ("m.x", "function")]);
assert_eq!(
names(&find_obj(
&partial,
Some("m"),
Some("C"),
"x",
Some("func"),
0
)),
vec!["m.x"],
"then modname.name"
);
let partial = data_of(&[("m.C.x", "method")]);
assert_eq!(
names(&find_obj(
&partial,
Some("m"),
Some("C"),
"x",
Some("func"),
0
)),
vec!["m.C.x"],
"then modname.classname.name"
);
assert!(
find_obj(&partial, None, None, "x", Some("func"), 0).is_empty(),
"no context, no prefix candidates"
);
}
#[test]
fn exact_mode_ignores_the_object_type() {
let data = data_of(&[("thing", "class")]);
assert_eq!(
names(&find_obj(&data, None, None, "thing", Some("func"), 0)),
vec!["thing"]
);
}
#[test]
fn mod_takes_only_the_bare_name_match() {
let data = data_of(&[("pkg.sub", "module")]);
assert!(find_obj(&data, Some("pkg"), None, "sub", Some("mod"), 0).is_empty());
let shadowed = data_of(&[("sub", "function")]);
assert_eq!(
names(&find_obj(
&shadowed,
Some("pkg"),
None,
"sub",
Some("mod"),
0
)),
vec!["sub"],
"the bare-name arm runs before the mod cutoff and skips no types"
);
}
#[test]
fn trailing_parens_are_stripped_before_any_lookup() {
let data = data_of(&[("m.f", "function")]);
assert_eq!(
names(&find_obj(&data, Some("m"), None, "f()", Some("obj"), 0)),
vec!["m.f"],
"exact mode"
);
assert_eq!(
names(&find_obj(&data, None, None, "f()", Some("obj"), 1)),
vec!["m.f"],
"refspecific mode (the fuzzy pass sees the stripped name)"
);
assert!(
find_obj(&data, Some("m"), None, "()", Some("obj"), 0).is_empty(),
"a name that is nothing but parens strips to empty and matches nothing"
);
}
#[test]
fn refspecific_mode_prefers_the_most_qualified_gated_candidate() {
let data = data_of(&[
("meth", "function"),
("m.meth", "function"),
("m.C.meth", "method"),
]);
assert_eq!(
names(&find_obj(
&data,
Some("m"),
Some("C"),
"meth",
Some("meth"),
1
)),
vec!["m.C.meth"],
"most qualified first"
);
assert_eq!(
names(&find_obj(
&data,
Some("m"),
Some("C"),
"meth",
Some("func"),
1
)),
vec!["m.meth"],
"the objtype gate skips m.C.meth for :func: and lands on m.meth"
);
assert_eq!(
names(&find_obj(&data, None, None, "meth", Some("func"), 1)),
vec!["meth"],
"no context leaves the bare-name candidate"
);
}
#[test]
fn the_fuzzy_pass_is_gated_and_registration_ordered() {
let data = data_of(&[
("zeta.same", "function"),
("alpha.same", "function"),
("beta.same", "class"),
]);
assert_eq!(
names(&find_obj(&data, None, None, "same", Some("func"), 1)),
vec!["zeta.same", "alpha.same"],
"registration order, objtype-filtered (beta.same is a class)"
);
let with_exact = data_of(&[("zeta.same", "function"), ("same", "function")]);
assert_eq!(
names(&find_obj(&with_exact, None, None, "same", Some("func"), 1)),
vec!["same"],
"an exact bare-name hit suppresses the fuzzy pass"
);
assert!(
find_obj(&data, None, None, "ame", Some("func"), 1).is_empty(),
"the scan matches '.name', never a bare substring"
);
}
#[test]
fn roles_without_objtypes_match_nothing_in_refspecific_mode() {
let data = data_of(&[("pkg.mydeco", "function")]);
assert!(find_obj(&data, None, None, "mydeco", Some("deco"), 1).is_empty());
assert_eq!(
names(&find_obj(&data, None, None, "pkg.mydeco", Some("deco"), 0)),
vec!["pkg.mydeco"]
);
}
#[test]
fn a_none_type_searches_all_object_types() {
let data = data_of(&[("m.thing", "attribute")]);
assert_eq!(
names(&find_obj(&data, None, None, "thing", None, 1)),
vec!["m.thing"]
);
}
#[test]
fn resolve_xref_walks_the_type_fallback_chains() {
let alias = data_of(&[("Alias", "data")]);
let (found, warning) = resolve_xref(&alias, None, None, "class", "Alias", 0);
assert_eq!(warning, None);
assert_eq!(
found,
Some(PyXrefTarget {
docname: "index",
node_id: "Alias",
reftitle: "Alias".to_string(),
is_module: false,
}),
"a type alias documented as data resolves through :class:"
);
let attr_alias = data_of(&[("A.x", "attribute")]);
let (found, _) = resolve_xref(&attr_alias, None, Some("A"), "class", "x", 0);
assert!(found.is_some(), "class falls back to attr after data");
let prop = data_of(&[("K.oldm", "method"), ("K.prop", "property")]);
let (found, _) = resolve_xref(&prop, None, Some("K"), "attr", "oldm", 0);
assert_eq!(found.unwrap().node_id, "K.oldm", "attr falls back to meth");
let (found, _) = resolve_xref(&prop, None, Some("K"), "meth", "prop", 0);
assert_eq!(
found.unwrap().node_id,
"K.prop",
"meth falls back to property via the secret _prop role"
);
}
#[test]
fn ambiguity_warns_with_candidates_in_registration_order_and_takes_the_first() {
let data = data_of(&[("zeta.same", "function"), ("alpha.same", "function")]);
let (found, warning) = resolve_xref(&data, None, None, "func", "same", 1);
assert_eq!(
warning.as_deref(),
Some("more than one target found for cross-reference 'same': zeta.same, alpha.same")
);
assert_eq!(found.unwrap().node_id, "zeta.same");
}
#[test]
fn a_single_non_aliased_match_wins_silently() {
let mut data = PyDomainData::default();
data.note_object("alpha.f", entry("index", "alpha.f", "function", false));
data.note_object("beta.f", entry("index", "alpha.f", "function", true));
let (found, warning) = resolve_xref(&data, None, None, "func", "f", 1);
assert_eq!(warning, None);
assert_eq!(found.unwrap().reftitle, "alpha.f");
}
#[test]
fn a_module_target_carries_the_full_reftitle() {
let mut data = PyDomainData::default();
data.note_object("both", entry("index", "module-both", "module", false));
data.note_module(
"both",
PyModuleEntry {
docname: "index".to_string(),
node_id: "module-both".to_string(),
synopsis: "Some synopsis.".to_string(),
platform: "Unix, Windows".to_string(),
deprecated: true,
},
);
let (found, _) = resolve_xref(&data, None, None, "mod", "both", 0);
assert_eq!(
found,
Some(PyXrefTarget {
docname: "index",
node_id: "module-both",
reftitle: "both: Some synopsis. (deprecated) (Unix, Windows)".to_string(),
is_module: true,
})
);
}
#[test]
fn resolve_any_finds_functions_and_modules_with_their_roles() {
let mut data = PyDomainData::default();
data.note_object("m", entry("index", "module-m", "module", false));
data.note_module("m", module_entry("index", "module-m"));
data.note_object("m.f", entry("index", "m.f", "function", false));
let f = resolve_any_xref(&data, Some("m"), None, "f");
assert_eq!(f.len(), 1);
assert_eq!(f[0].0, "py:func");
assert_eq!(f[0].1.reftitle, "m.f");
assert!(!f[0].1.is_module);
let m = resolve_any_xref(&data, Some("m"), None, "m");
assert_eq!(m.len(), 1);
assert_eq!(m[0].0, "py:mod");
assert_eq!(m[0].1.node_id, "module-m");
assert!(m[0].1.is_module);
let parens = resolve_any_xref(&data, Some("m"), None, "f()");
assert_eq!(parens.len(), 1);
assert_eq!(parens[0].1.reftitle, "m.f");
}
#[test]
fn resolve_any_skips_aliased_entries_only_among_multiple_matches() {
let mut data = PyDomainData::default();
data.note_object("zeta.same", entry("index", "zeta.same", "function", false));
data.note_object("beta.same", entry("index", "zeta.same", "function", true));
data.note_object(
"alpha.same",
entry("index", "alpha.same", "function", false),
);
let results = resolve_any_xref(&data, None, None, "same");
let names: Vec<&str> = results.iter().map(|(_, t)| t.reftitle.as_str()).collect();
assert_eq!(
names,
vec!["zeta.same", "alpha.same"],
"registration order, alias dropped"
);
let mut lone = PyDomainData::default();
lone.note_object("old.name", entry("index", "new_name", "function", true));
let only = resolve_any_xref(&lone, None, None, "name");
assert_eq!(only.len(), 1, "a single aliased match is kept");
assert_eq!(only[0].1.reftitle, "old.name");
}
#[test]
fn resolve_any_module_candidates_carry_the_synopsis_reftitle() {
let mut data = PyDomainData::default();
data.note_object("syn", entry("index", "module-syn", "module", false));
data.note_module(
"syn",
PyModuleEntry {
docname: "index".to_string(),
node_id: "module-syn".to_string(),
synopsis: "The syn module.".to_string(),
platform: String::new(),
deprecated: false,
},
);
let results = resolve_any_xref(&data, None, None, "syn");
assert_eq!(results[0].1.reftitle, "syn: The syn module.");
}
type ModindexRow<'a> = (&'a str, u8, &'a str, &'a str, &'a str, &'a str, &'a str);
fn modindex_rows(modindex: &PyModindex) -> Vec<(&str, Vec<ModindexRow<'_>>)> {
modindex
.groups
.iter()
.map(|group| {
(
group.letter.as_str(),
group
.entries
.iter()
.map(|e| {
(
e.name.as_str(),
e.subtype,
e.docname.as_str(),
e.anchor.as_str(),
e.extra.as_str(),
e.qualifier.as_str(),
e.descr.as_str(),
)
})
.collect(),
)
})
.collect()
}
fn modindex_module(
docname: &str,
name: &str,
synopsis: &str,
platform: &str,
deprecated: bool,
) -> PyModuleEntry {
PyModuleEntry {
docname: docname.to_string(),
node_id: format!("module-{name}"),
synopsis: synopsis.to_string(),
platform: platform.to_string(),
deprecated,
}
}
#[test]
fn modindex_shapes_reproduces_the_probe_tuples() {
let mut data = PyDomainData::default();
for (name, synopsis, platform, deprecated) in [
("pkg", "", "", false),
("pkg.sub", "Sub synopsis.", "", false),
("pkg.sub2", "", "Windows", false),
("orphan.child", "", "", false),
("zzz", "", "", true),
] {
data.note_module(
name,
modindex_module("index", name, synopsis, platform, deprecated),
);
}
let modindex = generate_modindex(&data, &[]);
assert!(!modindex.collapse);
assert_eq!(
modindex_rows(&modindex),
vec![
(
"o",
vec![
("orphan", 1, "", "", "", "", ""),
(
"orphan.child",
2,
"index",
"module-orphan.child",
"",
"",
""
),
]
),
(
"p",
vec![
("pkg", 1, "index", "module-pkg", "", "", ""),
(
"pkg.sub",
2,
"index",
"module-pkg.sub",
"",
"",
"Sub synopsis."
),
("pkg.sub2", 2, "index", "module-pkg.sub2", "Windows", "", ""),
]
),
(
"z",
vec![("zzz", 0, "index", "module-zzz", "", "Deprecated", "")]
),
]
);
}
#[test]
fn modindex_common_prefix_strips_for_bucketing_but_displays_full_names() {
let mut data = PyDomainData::default();
for name in ["pkg.aaa", "pkg.bbb", "other"] {
data.note_module(name, modindex_module("index", name, "", "", false));
}
let modindex = generate_modindex(&data, &["pkg.".to_string()]);
assert!(modindex.collapse);
assert_eq!(
modindex_rows(&modindex),
vec![
(
"a",
vec![("pkg.aaa", 0, "index", "module-pkg.aaa", "", "", "")]
),
(
"b",
vec![("pkg.bbb", 0, "index", "module-pkg.bbb", "", "", "")]
),
("o", vec![("other", 0, "index", "module-other", "", "", "")]),
]
);
}
#[test]
fn modindex_prefix_stripping_restores_emptied_names_and_prefers_longer() {
let mut data = PyDomainData::default();
for name in ["pkg", "pkgx", "pkg.deep.mod"] {
data.note_module(name, modindex_module("index", name, "", "", false));
}
let modindex = generate_modindex(&data, &["pkg".to_string(), "pkg.deep.".to_string()]);
assert_eq!(
modindex_rows(&modindex),
vec![
(
"m",
vec![(
"pkg.deep.mod",
0,
"index",
"module-pkg.deep.mod",
"",
"",
""
)]
),
("p", vec![("pkg", 0, "index", "module-pkg", "", "", "")]),
("x", vec![("pkgx", 0, "index", "module-pkgx", "", "", "")]),
],
"pkg.deep.mod strips the longer prefix; pkg empties and restores \
(bucketed under 'p', not dummy-parented); pkgx buckets under \
its stripped 'x'"
);
assert!(modindex.collapse, "3 - 3 = 0 < 3");
}
#[test]
fn builtin_resolver_matches_sphinxs_exact_gates() {
assert!(builtin_resolver("class", "None"));
assert!(builtin_resolver("obj", "None"));
assert!(
!builtin_resolver("exc", "None"),
"exc is not in the None gate"
);
assert!(builtin_resolver("class", "int"));
assert!(builtin_resolver("obj", "bool"));
assert!(builtin_resolver("exc", "ValueError"));
assert!(builtin_resolver("class", "__loader__"), "getattr quirk");
assert!(builtin_resolver("class", "Sequence"));
assert!(builtin_resolver("class", "typing.Sequence"));
assert!(builtin_resolver("obj", "Optional"));
assert!(
!builtin_resolver("class", "typing.typing.Sequence"),
"removeprefix strips one prefix only"
);
assert!(!builtin_resolver("class", "Missing"));
assert!(!builtin_resolver("func", "int"), "func is never silenced");
assert!(
!builtin_resolver("data", "int"),
"probe: :py:data:`int` warns"
);
assert!(
!builtin_resolver("exc", "len"),
"a builtin function is not a class"
);
}
#[test]
fn real_over_real_warns_and_the_last_definition_wins_in_place() {
let mut py = PyDomainData::default();
py.note_object("other", entry("a", "other", "function", false));
assert_eq!(
py.note_object("dup", entry("a", "dup", "function", false)),
None
);
assert_eq!(
py.note_object("dup", entry("b", "id0", "function", false)),
Some("a".to_string()),
"the second real definition warns naming the first's docname"
);
assert_eq!(
object_rows(&py),
vec![("other", "a", false), ("dup", "b", false)],
"the overwrite lands in the original insertion slot"
);
assert_eq!(py.objects[py.objects_index["dup"]].1.node_id, "id0");
assert_indices_consistent(&py);
}
#[test]
fn an_alias_never_replaces_a_real_definition_and_stays_silent() {
let mut py = PyDomainData::default();
py.note_object("name", entry("a", "name", "function", false));
assert_eq!(
py.note_object("name", entry("b", "alias-id", "function", true)),
None
);
assert_eq!(
py.objects[py.objects_index["name"]].1,
entry("a", "name", "function", false),
"the real entry is untouched"
);
}
#[test]
fn a_real_definition_silently_overrides_an_alias_in_place() {
let mut py = PyDomainData::default();
py.note_object("first", entry("a", "first", "function", false));
py.note_object("name", entry("a", "alias-id", "function", true));
py.note_object("last", entry("a", "last", "function", false));
assert_eq!(
py.note_object("name", entry("b", "name", "function", false)),
None,
"\"The original definition found. Override it!\" โ no warning"
);
assert_eq!(
object_rows(&py),
vec![
("first", "a", false),
("name", "b", false),
("last", "a", false)
],
"the override keeps the alias's insertion slot"
);
}
#[test]
fn an_alias_over_an_alias_warns_and_overwrites_in_place() {
let mut py = PyDomainData::default();
py.note_object("new_a", entry("index", "new_a", "function", false));
py.note_object("shared.alias", entry("index", "new_a", "function", true));
py.note_object("new_b", entry("index", "new_b", "function", false));
assert_eq!(
py.note_object("shared.alias", entry("index", "new_b", "function", true)),
Some("index".to_string())
);
assert_eq!(
object_rows(&py),
vec![
("new_a", "index", false),
("shared.alias", "index", true),
("new_b", "index", false),
]
);
assert_eq!(
py.objects[py.objects_index["shared.alias"]].1.node_id,
"new_b"
);
}
#[test]
fn iteration_preserves_registration_order_not_lexicographic_order() {
let mut py = PyDomainData::default();
py.note_object("zeta.same", entry("a", "zeta.same", "function", false));
py.note_object("alpha.same", entry("a", "alpha.same", "function", false));
assert_eq!(
py.objects
.iter()
.map(|(n, _)| n.as_str())
.collect::<Vec<_>>(),
vec!["zeta.same", "alpha.same"]
);
assert_eq!(py.objects_index["zeta.same"], 0);
assert_eq!(py.objects_index["alpha.same"], 1);
}
#[test]
fn clear_doc_preserves_the_relative_order_of_survivors() {
let mut py = PyDomainData::default();
py.note_object("one", entry("a", "one", "function", false));
py.note_object("two", entry("b", "two", "function", false));
py.note_object("three", entry("a", "three", "class", false));
py.note_object("four", entry("b", "four", "function", false));
py.note_module("amod", module_entry("a", "module-amod"));
py.note_module("bmod", module_entry("b", "module-bmod"));
py.clear_doc("a");
assert_eq!(
object_rows(&py),
vec![("two", "b", false), ("four", "b", false)]
);
assert_eq!(
py.modules
.iter()
.map(|(n, _)| n.as_str())
.collect::<Vec<_>>(),
vec!["bmod"]
);
assert_indices_consistent(&py);
py.clear_doc("b");
assert!(py.objects.is_empty() && py.modules.is_empty());
assert!(py.objects_index.is_empty() && py.modules_index.is_empty());
}
#[test]
fn merge_folds_only_the_named_docnames_in_registration_order() {
let mut ours = PyDomainData::default();
ours.note_object("kept", entry("a", "kept", "function", false));
ours.note_object("both", entry("a", "both", "function", false));
let mut theirs = PyDomainData::default();
theirs.note_object("zeta", entry("b", "zeta", "function", false));
theirs.note_object("both", entry("b", "id0", "function", false));
theirs.note_object("skipped", entry("c", "skipped", "function", false));
theirs.note_module("bmod", module_entry("b", "module-bmod"));
theirs.note_module("cmod", module_entry("c", "module-cmod"));
ours.merge(&theirs, &BTreeSet::from(["b".to_string()]));
assert_eq!(
object_rows(&ours),
vec![
("kept", "a", false),
("both", "b", false),
("zeta", "b", false),
]
);
assert_eq!(
ours.modules
.iter()
.map(|(n, _)| n.as_str())
.collect::<Vec<_>>(),
vec!["bmod"]
);
assert_indices_consistent(&ours);
}
#[test]
fn note_module_never_warns_and_the_last_entry_wins_in_place() {
let mut py = PyDomainData::default();
py.note_module("mod", module_entry("a", "module-mod"));
py.note_module("other", module_entry("a", "module-other"));
py.note_module(
"mod",
PyModuleEntry {
docname: "b".to_string(),
node_id: "module-0".to_string(),
synopsis: "S".to_string(),
platform: "P".to_string(),
deprecated: true,
},
);
assert_eq!(
py.modules
.iter()
.map(|(n, e)| (n.as_str(), e.docname.as_str()))
.collect::<Vec<_>>(),
vec![("mod", "b"), ("other", "a")]
);
assert!(py.modules[py.modules_index["mod"]].1.deprecated);
}
fn parse(source: &str, docname: &str) -> crate::rst::ParseOutput {
parse_rst_full(
source,
&ParseOptions {
source_path: format!("<{docname}>"),
sphinx: true,
docname: docname.to_string(),
found_docs: None,
exclude_patterns: Vec::new(),
py: Default::default(),
srcdir: None,
..Default::default()
},
)
}
fn read(sources: &[(&str, &str)]) -> (BuildEnvironment, Vec<BuildWarning>) {
let mut env = BuildEnvironment::default();
let mut warnings = Vec::new();
let doc2path = |docname: &str| PathBuf::from(format!("/src/{docname}.rst"));
for (docname, source) in sources {
let parsed = parse(source, docname);
let path = PathBuf::from(format!("/src/{docname}.rst"));
std_domain::process_doc(
&mut env,
&DocumentSource {
docname,
doctree: &parsed.doctree,
registry: &parsed.registry,
path: &path,
},
&doc2path,
&mut warnings,
);
}
(env, warnings)
}
#[test]
fn a_py_object_defined_twice_in_one_document_warns_with_the_sphinx_bytes() {
let (env, warnings) = read(&[(
"index",
".. py:function:: dup()\n\n.. py:function:: dup()\n",
)]);
assert_eq!(
warnings.iter().map(|w| w.render()).collect::<Vec<_>>(),
vec![
"<index>:3: WARNING: duplicate object description of dup, \
other instance in index, use :no-index: for one of them"
]
);
assert_eq!(
object_rows(&env.py),
vec![("dup", "index", false)],
"last definition wins"
);
assert_eq!(env.py.objects[0].1.node_id, "id0");
}
#[test]
fn a_module_defined_twice_warns_once_and_both_tables_keep_the_second() {
let (env, warnings) =
read(&[("index", ".. py:module:: dupmod\n\n.. py:module:: dupmod\n")]);
assert_eq!(
warnings.iter().map(|w| w.render()).collect::<Vec<_>>(),
vec![
"<index>:3: WARNING: duplicate object description of dupmod, \
other instance in index, use :no-index: for one of them"
]
);
assert_eq!(
env.py.objects[env.py.objects_index["dupmod"]].1,
entry("index", "module-0", "module", false)
);
assert_eq!(
env.py.modules[env.py.modules_index["dupmod"]].1,
module_entry("index", "module-0")
);
}
#[test]
fn a_py_duplicate_across_documents_names_the_other_docname() {
let (env, warnings) = read(&[
("a", ".. py:function:: dup()\n"),
("b", "B\n=\n\n.. py:function:: dup()\n"),
]);
assert_eq!(
warnings.iter().map(|w| w.render()).collect::<Vec<_>>(),
vec![
"<b>:4: WARNING: duplicate object description of dup, \
other instance in a, use :no-index: for one of them"
]
);
assert_eq!(object_rows(&env.py), vec![("dup", "b", false)]);
}
#[test]
fn canonical_registers_an_aliased_entry_with_the_same_node_id() {
let (env, warnings) = read(&[(
"index",
".. py:function:: new_name()\n :canonical: old.name\n",
)]);
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(
env.py.objects,
vec![
(
"new_name".to_string(),
entry("index", "new_name", "function", false)
),
(
"old.name".to_string(),
entry("index", "new_name", "function", true)
),
]
);
}
#[test]
fn py_duplicate_warnings_interleave_with_std_s_in_document_order() {
let document = "Probe\n=====\n\n\
.. envvar:: STDDUP\n\n\
.. py:function:: pydup()\n\n\
.. envvar:: STDDUP\n\n\
.. glossary::\n\n \
gterm\n First.\n\n\
.. py:function:: pydup()\n\n\
.. glossary::\n\n \
gterm\n Second.\n";
let (_, warnings) = read(&[("index", document)]);
assert_eq!(
warnings
.iter()
.map(|warning| (warning.line, warning.message.as_str()))
.collect::<Vec<_>>(),
vec![
(
Some(8),
"duplicate envvar description of STDDUP, other instance in index"
),
(
Some(15),
"duplicate object description of pydup, other instance in index, \
use :no-index: for one of them"
),
(
Some(18),
"duplicate term description of gterm, other instance in index"
),
],
"{warnings:?}"
);
}
#[test]
fn py_and_std_registrations_stay_in_their_own_registries() {
let (env, warnings) = read(&[("index", ".. py:function:: func()\n\n.. envvar:: HOME\n")]);
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(object_rows(&env.py), vec![("func", "index", false)]);
assert_eq!(
env.std.objects.keys().collect::<Vec<_>>(),
vec![&("envvar".to_string(), "HOME".to_string())]
);
assert!(env
.std
.objects
.keys()
.all(|(objtype, _)| objtype != "function"));
}
}