use std::collections::{BTreeMap, BTreeSet};
use crate::doctree::{kinds, AttrValue, Doctree, Node, Span};
use crate::env::BuildEnvironment;
use crate::matching;
use crate::utils::py_repr_str;
pub(crate) const VIRTUAL_DOC_NAMES: [&str; 3] = ["genindex", "modindex", "search"];
#[derive(Debug, Clone, Copy)]
pub struct ToctreeContent<'a> {
pub content: &'a [String],
pub docname: &'a str,
pub glob: bool,
pub reversed: bool,
pub source: u16,
pub line: u32,
pub found_docs: &'a BTreeSet<String>,
pub source_suffixes: &'a [&'a str],
pub exclude_patterns: &'a [String],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ToctreeWarningKind {
MissingDocument,
EmptyGlob,
DuplicateEntry,
PatternError,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ToctreeWarning {
pub source: u16,
pub line: u32,
pub message: String,
pub category: Option<String>,
pub kind: ToctreeWarningKind,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ResolvedEntries {
pub entries: Vec<(Option<String>, String)>,
pub includefiles: Vec<String>,
pub warnings: Vec<ToctreeWarning>,
}
impl ResolvedEntries {
pub fn entries_attr(&self) -> AttrValue {
AttrValue::List(
self.entries
.iter()
.map(|(title, target)| {
let title = match title {
Some(t) => py_repr_str(t),
None => "None".to_string(),
};
format!("({title}, {})", py_repr_str(target))
})
.collect(),
)
}
pub fn includefiles_attr(&self) -> AttrValue {
AttrValue::List(self.includefiles.clone())
}
}
pub fn resolve_entries(input: &ToctreeContent<'_>) -> ResolvedEntries {
let &ToctreeContent {
content,
docname,
glob,
reversed,
source,
line,
found_docs,
source_suffixes,
exclude_patterns,
} = input;
let mut all: BTreeSet<&str> = found_docs.iter().map(String::as_str).collect();
all.extend(VIRTUAL_DOC_NAMES);
all.remove(docname);
let frozen = all.clone();
let mut out = ResolvedEntries::default();
let mut warnings: Vec<ToctreeWarning> = Vec::new();
let warn = |sink: &mut Vec<ToctreeWarning>,
message: String,
category: Option<&str>,
kind: ToctreeWarningKind| {
sink.push(ToctreeWarning {
source,
line,
message,
category: category.map(str::to_string),
kind,
});
};
for entry in content {
if entry.is_empty() {
continue;
}
let explicit = split_explicit_title(entry);
let url_match = is_url(entry);
if glob && has_glob_metachars(entry) && explicit.is_none() && !url_match {
let pattern = docname_join(docname, entry);
let mut matched: Vec<String> = Vec::new();
let mut pattern_error = None;
for candidate in all.iter().filter(|d| !VIRTUAL_DOC_NAMES.contains(d)) {
match matching::pattern_match(candidate, &pattern) {
Ok(true) => matched.push((*candidate).to_string()),
Ok(false) => {}
Err(e) => {
pattern_error.get_or_insert_with(|| e.to_string());
}
}
}
if let Some(error) = pattern_error {
warn(
&mut warnings,
format!(
"toctree glob pattern {} is not usable: {error}",
py_repr_str(entry)
),
None,
ToctreeWarningKind::PatternError,
);
} else if matched.is_empty() {
warn(
&mut warnings,
format!(
"toctree glob pattern {} didn't match any documents",
py_repr_str(entry)
),
None,
ToctreeWarningKind::EmptyGlob,
);
}
for name in matched {
all.remove(name.as_str());
out.entries.push((None, name.clone()));
out.includefiles.push(name);
}
continue;
}
let (title, reference) = match explicit {
Some((title, target)) => (Some(title.to_string()), target),
None => (None, entry.as_str()),
};
let mut resolved = reference;
for suffix in source_suffixes {
if let Some(stripped) = resolved.strip_suffix(suffix) {
resolved = stripped;
break;
}
}
let resolved = docname_join(docname, resolved);
if url_match || reference == "self" {
out.entries.push((title, reference.to_string()));
continue;
}
if !frozen.contains(resolved.as_str()) {
let path = format!(
"{resolved}{}",
source_suffixes.first().copied().unwrap_or_default()
);
let (message, category) = if matches_any(&path, exclude_patterns) {
(
"toctree contains reference to excluded document",
"toc.excluded",
)
} else {
(
"toctree contains reference to nonexisting document",
"toc.not_readable",
)
};
warn(
&mut warnings,
format!("{message} {}", py_repr_str(&resolved)),
Some(category),
ToctreeWarningKind::MissingDocument,
);
continue;
}
if !all.remove(resolved.as_str()) {
warn(
&mut warnings,
format!("duplicated entry found in toctree: {resolved}"),
Some("toc.duplicate_entry"),
ToctreeWarningKind::DuplicateEntry,
);
}
out.entries.push((title, resolved.clone()));
out.includefiles.push(resolved);
}
if reversed {
out.entries.reverse();
out.includefiles.reverse();
}
out.warnings = warnings;
out
}
fn matches_any(path: &str, patterns: &[String]) -> bool {
patterns
.iter()
.any(|pattern| matching::pattern_match(path, pattern).unwrap_or(false))
}
pub fn split_explicit_title(entry: &str) -> Option<(&str, &str)> {
let entry = entry.strip_suffix('>')?;
let open = entry.find('<')?;
if open == 0 {
return None;
}
let title = entry[..open].trim_end();
if title.is_empty() {
return None;
}
Some((title, &entry[open + 1..]))
}
fn is_url(entry: &str) -> bool {
entry.match_indices("://").any(|(at, _)| at >= 1)
}
fn has_glob_metachars(entry: &str) -> bool {
entry.contains(['*', '?', '['])
}
pub fn docname_join(base_docname: &str, docname: &str) -> String {
let (base, target) = match docname.strip_prefix('/') {
Some(stripped) => ("", stripped),
None => (
base_docname.rsplit_once('/').map(|(d, _)| d).unwrap_or(""),
docname,
),
};
let mut segments: Vec<&str> = Vec::new();
for seg in base.split('/').chain(target.split('/')) {
match seg {
"" | "." => {}
".." => {
segments.pop();
}
s => segments.push(s),
}
}
segments.join("/")
}
pub fn build_toc(doctree: &Doctree, docname: &str) -> (Node, u32) {
let mut num_entries = 0u32;
let toc = build_toc_level(&doctree.root.children, docname, &mut num_entries)
.unwrap_or_else(|| Node::elem(kinds::BULLET_LIST, Span::ZERO));
(toc, num_entries)
}
fn build_toc_level(nodes: &[Node], docname: &str, num_entries: &mut u32) -> Option<Node> {
let mut entries: Vec<Node> = Vec::new();
for node in nodes {
if node.kind == kinds::TEXT {
continue;
}
if node.kind == kinds::SECTION {
let title_children = node
.children
.first()
.map(filter_title_children)
.unwrap_or_default();
let anchorname = make_anchor_name(&node.attrs.ids, num_entries);
let mut reference = Node::elem(kinds::REFERENCE, Span::ZERO);
reference.set("anchorname", AttrValue::Str(anchorname));
reference.set("internal", AttrValue::Int(1));
reference.set("refuri", AttrValue::Str(docname.to_string()));
reference.children = title_children;
let mut para = Node::elem(kinds::COMPACT_PARAGRAPH, Span::ZERO);
para.children.push(reference);
let mut item = Node::elem(kinds::LIST_ITEM, Span::ZERO);
item.children.push(para);
if let Some(sub) = build_toc_level(&node.children, docname, num_entries) {
item.children.push(sub);
}
entries.push(item);
} else if node.kind == kinds::ONLY {
let mut only = Node::elem(kinds::ONLY, Span::ZERO);
if let Some(expr) = node.get("expr") {
only.set("expr", expr.clone());
}
if let Some(sub) = build_toc_level(&node.children, docname, num_entries) {
only.children = sub.children;
entries.push(only);
}
} else {
collect_body_entries(node, docname, num_entries, &mut entries, &[]);
}
}
if entries.is_empty() {
return None;
}
let mut list = Node::elem(kinds::BULLET_LIST, Span::ZERO);
list.children = entries;
Some(list)
}
fn collect_body_entries(
node: &Node,
docname: &str,
num_entries: &mut u32,
entries: &mut Vec<Node>,
path: &[usize],
) {
if node.kind == kinds::TEXT {
return;
}
if node.kind == kinds::TOCTREE {
entries.push(node.shallow_copy());
return;
}
if node.kind == "desc" {
let mut child_path: Option<Vec<usize>> = None;
for signature in node.children.iter().filter(|c| c.kind == "desc_signature") {
let Some(entry) = object_toc_entry(node, signature, docname, num_entries) else {
continue;
};
let target = resolve_attach_point(entries, path);
target.push(entry);
let mut deeper = path.to_vec();
deeper.push(target.len() - 1);
child_path = Some(deeper);
}
let child_path = child_path.unwrap_or_else(|| path.to_vec());
for child in &node.children {
collect_body_entries(child, docname, num_entries, entries, &child_path);
}
return;
}
for child in &node.children {
collect_body_entries(child, docname, num_entries, entries, path);
}
}
fn resolve_attach_point<'a>(entries: &'a mut Vec<Node>, path: &[usize]) -> &'a mut Vec<Node> {
let mut current = entries;
for &index in path {
let item = &mut current[index];
if item.children.last().map(|c| c.kind) != Some(kinds::BULLET_LIST) {
item.children
.push(Node::elem(kinds::BULLET_LIST, Span::ZERO));
}
current = &mut item
.children
.last_mut()
.expect("a bullet_list was just ensured")
.children;
}
current
}
fn object_toc_entry(
desc: &Node,
signature: &Node,
docname: &str,
num_entries: &mut u32,
) -> Option<Node> {
let toc_name = match signature.get("_toc_name") {
Some(AttrValue::Str(name)) if !name.is_empty() => name.clone(),
_ => return None,
};
if matches!(desc.get("no-contents-entry"), Some(AttrValue::Int(1))) {
return None;
}
if signature.attrs.ids.is_empty() {
return None;
}
let anchorname = make_anchor_name(&signature.attrs.ids, num_entries);
let mut literal = Node::elem(kinds::LITERAL, Span::ZERO);
literal.children.push(Node::text_node(toc_name, Span::ZERO));
let mut reference = Node::elem(kinds::REFERENCE, Span::ZERO);
reference.set("anchorname", AttrValue::Str(anchorname));
reference.set("internal", AttrValue::Int(1));
reference.set("refuri", AttrValue::Str(docname.to_string()));
reference.children.push(literal);
let mut para = Node::elem(kinds::COMPACT_PARAGRAPH, Span::ZERO);
para.set("skip_section_number", AttrValue::Int(1));
para.children.push(reference);
let mut item = Node::elem(kinds::LIST_ITEM, Span::ZERO);
item.children.push(para);
Some(item)
}
fn make_anchor_name(ids: &[String], num_entries: &mut u32) -> String {
let anchor = if *num_entries == 0 {
String::new()
} else {
format!("#{}", ids.first().map(String::as_str).unwrap_or(""))
};
*num_entries += 1;
anchor
}
fn filter_title_children(title: &Node) -> Vec<Node> {
let mut out = Vec::new();
filter_into(&title.children, &mut out);
out
}
fn filter_into(children: &[Node], out: &mut Vec<Node>) {
for child in children {
match child.kind {
kinds::FOOTNOTE_REFERENCE | kinds::CITATION_REFERENCE | kinds::IMAGE => {}
kinds::REFERENCE | kinds::TARGET | kinds::PROBLEMATIC | kinds::PENDING_XREF => {
filter_into(&child.children, out);
}
kinds::TEXT => out.push(child.clone()),
_ => {
let mut copy = child.shallow_copy();
filter_into(&child.children, &mut copy.children);
out.push(copy);
}
}
}
}
pub fn note_toctree(env: &mut BuildEnvironment, docname: &str, toctree: &Node) {
if matches!(toctree.get("glob"), Some(AttrValue::Int(n)) if *n != 0) {
env.glob_toctrees.insert(docname.to_string());
}
if matches!(toctree.get("numbered"), Some(AttrValue::Int(n)) if *n != 0) {
env.numbered_toctrees.insert(docname.to_string());
}
let include_files: &[String] = match toctree.get("includefiles") {
Some(AttrValue::List(files)) => files,
_ => &[],
};
for include_file in include_files {
env.files_to_rebuild
.entry(include_file.clone())
.or_default()
.insert(docname.to_string());
}
env.toctree_includes
.entry(docname.to_string())
.or_default()
.extend(include_files.iter().cloned());
}
pub fn toctree_copies(toc: &Node) -> Vec<&Node> {
let mut out = Vec::new();
fn walk<'a>(node: &'a Node, out: &mut Vec<&'a Node>) {
if node.kind == kinds::TOCTREE {
out.push(node);
}
for child in &node.children {
walk(child, out);
}
}
walk(toc, &mut out);
out
}
pub type Relation = (Option<String>, Option<String>, Option<String>);
pub fn collect_relations(env: &BuildEnvironment) -> BTreeMap<String, Relation> {
let order = traverse_toctree(&env.toctree_includes, &env.root_doc);
let mut relations = BTreeMap::new();
let mut prev: Option<String> = None;
for (index, (parent, docname)) in order.iter().enumerate() {
let next = order.get(index + 1).map(|(_, doc)| doc.clone());
relations.insert(docname.clone(), (parent.clone(), prev.take(), next));
prev = Some(docname.clone());
}
relations
}
pub fn traverse_toctree(
toctree_includes: &BTreeMap<String, Vec<String>>,
root: &str,
) -> Vec<(Option<String>, String)> {
let mut out: Vec<(Option<String>, String)> = Vec::new();
let mut visited: BTreeSet<String> = BTreeSet::new();
let mut stack: Vec<(Option<String>, String)> = vec![(None, root.to_string())];
while let Some((parent, docname)) = stack.pop() {
if parent.as_deref() == Some(docname.as_str()) {
continue;
}
if !visited.insert(docname.clone()) {
continue;
}
if let Some(children) = toctree_includes.get(&docname) {
for child in children.iter().rev() {
stack.push((Some(docname.clone()), child.clone()));
}
}
out.push((parent, docname));
}
out
}
pub fn toctree_ancestors(
toctree_includes: &BTreeMap<String, Vec<String>>,
docname: &str,
) -> Vec<String> {
let mut parent: BTreeMap<&str, &str> = BTreeMap::new();
for (container, children) in toctree_includes {
for child in children {
parent.insert(child.as_str(), container.as_str());
}
}
let mut ancestors: Vec<String> = Vec::new();
let mut current = docname;
while let Some(next) = parent.get(current) {
if ancestors.iter().any(|seen| seen == current) {
break;
}
ancestors.push(current.to_string());
current = next;
}
ancestors
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConsistencyLevel {
Warning,
Info,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConsistencyMessage {
pub docname: String,
pub message: String,
pub category: Option<String>,
pub level: ConsistencyLevel,
}
pub fn check_consistency(
env: &BuildEnvironment,
is_sphinx_source: &dyn Fn(&str) -> bool,
) -> Vec<ConsistencyMessage> {
let mut messages = Vec::new();
let included: BTreeSet<&str> = env
.included
.values()
.flatten()
.map(String::as_str)
.collect();
for docname in env.all_docs.keys() {
if env.files_to_rebuild.contains_key(docname)
|| *docname == env.root_doc
|| included.contains(docname.as_str())
|| env
.metadata
.get(docname)
.is_some_and(|meta| meta.contains_key("orphan"))
|| !is_sphinx_source(docname)
{
continue;
}
messages.push(ConsistencyMessage {
docname: docname.clone(),
message: "document isn't included in any toctree".to_string(),
category: Some("toc.not_included".to_string()),
level: ConsistencyLevel::Warning,
});
}
let mut toc_parents: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for (container, children) in &env.toctree_includes {
for child in children {
toc_parents
.entry(child.as_str())
.or_default()
.push(container.as_str());
}
}
for (docname, parents) in toc_parents {
if parents.len() <= 1 {
continue;
}
let list = parents
.iter()
.map(|parent| py_repr_str(parent))
.collect::<Vec<_>>()
.join(", ");
let selected = parents.iter().max().expect("len > 1");
messages.push(ConsistencyMessage {
docname: docname.to_string(),
message: format!(
"document is referenced in multiple toctrees: [{list}], \
selecting: {selected} <- {docname}"
),
category: Some("toc.multiple_toc_parents".to_string()),
level: ConsistencyLevel::Info,
});
}
messages
}
pub fn document_title(doctree: &Doctree) -> Node {
let mut title = Node::elem(kinds::TITLE, Span::ZERO);
match first_section(&doctree.root) {
Some(section) => {
if let Some(first_child) = section.children.first() {
title.children = filter_title_children(first_child);
}
}
None => title
.children
.push(Node::text_node("<no title>", Span::ZERO)),
}
title
}
fn first_section(node: &Node) -> Option<&Node> {
for child in &node.children {
if child.kind == kinds::SECTION {
return Some(child);
}
if let Some(found) = first_section(child) {
return Some(found);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rst;
fn docs(names: &[&str]) -> BTreeSet<String> {
names.iter().map(|s| (*s).to_string()).collect()
}
fn parse(source: &str, docname: &str, found: &BTreeSet<String>) -> Doctree {
rst::parse_rst(
source,
&rst::ParseOptions {
source_path: "<snippet>".to_string(),
sphinx: true,
docname: docname.to_string(),
exclude_patterns: Vec::new(),
py: Default::default(),
srcdir: None,
found_docs: Some(std::sync::Arc::new(found.clone())),
..Default::default()
},
)
}
#[test]
fn empty_document_yields_empty_bullet_list() {
let doctree = parse("", "index", &docs(&[]));
let (toc, n) = build_toc(&doctree, "index");
assert_eq!(toc.pformat(), "<bullet_list>\n");
assert_eq!(n, 0);
}
#[test]
fn first_entry_has_empty_anchor_and_later_entries_use_ids() {
let doctree = parse("A\n=\n\nSub\n---\n\nText.\n", "a", &docs(&["a"]));
let (toc, n) = build_toc(&doctree, "a");
assert_eq!(n, 2);
assert_eq!(
toc.pformat(),
concat!(
"<bullet_list>\n",
" <list_item>\n",
" <compact_paragraph>\n",
" <reference anchorname=\"\" internal=\"1\" refuri=\"a\">\n",
" A\n",
" <bullet_list>\n",
" <list_item>\n",
" <compact_paragraph>\n",
" <reference anchorname=\"#sub\" internal=\"1\" refuri=\"a\">\n",
" Sub\n",
)
);
}
#[test]
fn object_entries_nest_under_the_nearest_description_that_has_one() {
let doctree = parse(
concat!(
"A\n=\n\n",
".. confval:: outer\n\n Outer body.\n\n",
" .. confval:: inner\n\n Inner body.\n\n",
" .. confval:: deepest\n\n Deep body.\n\n",
".. confval:: sibling\n\n",
".. confval:: multi_a\n multi_b\n\n",
" .. confval:: under_multi\n\n",
".. describe:: nothing\n\n .. confval:: under_describe\n",
),
"a",
&docs(&["a"]),
);
let (toc, n) = build_toc(&doctree, "a");
assert_eq!(n, 9);
assert_eq!(
toc.pformat(),
concat!(
"<bullet_list>\n",
" <list_item>\n",
" <compact_paragraph>\n",
" <reference anchorname=\"\" internal=\"1\" refuri=\"a\">\n",
" A\n",
" <bullet_list>\n",
" <list_item>\n",
" <compact_paragraph skip_section_number=\"1\">\n",
" <reference anchorname=\"#confval-outer\" internal=\"1\" refuri=\"a\">\n",
" <literal>\n",
" outer\n",
" <bullet_list>\n",
" <list_item>\n",
" <compact_paragraph skip_section_number=\"1\">\n",
" <reference anchorname=\"#confval-inner\" internal=\"1\" refuri=\"a\">\n",
" <literal>\n",
" inner\n",
" <bullet_list>\n",
" <list_item>\n",
" <compact_paragraph skip_section_number=\"1\">\n",
" <reference anchorname=\"#confval-deepest\" internal=\"1\" refuri=\"a\">\n",
" <literal>\n",
" deepest\n",
" <list_item>\n",
" <compact_paragraph skip_section_number=\"1\">\n",
" <reference anchorname=\"#confval-sibling\" internal=\"1\" refuri=\"a\">\n",
" <literal>\n",
" sibling\n",
" <list_item>\n",
" <compact_paragraph skip_section_number=\"1\">\n",
" <reference anchorname=\"#confval-multi_a\" internal=\"1\" refuri=\"a\">\n",
" <literal>\n",
" multi_a\n",
" <list_item>\n",
" <compact_paragraph skip_section_number=\"1\">\n",
" <reference anchorname=\"#confval-multi_b\" internal=\"1\" refuri=\"a\">\n",
" <literal>\n",
" multi_b\n",
" <bullet_list>\n",
" <list_item>\n",
" <compact_paragraph skip_section_number=\"1\">\n",
" <reference anchorname=\"#confval-under_multi\" internal=\"1\" refuri=\"a\">\n",
" <literal>\n",
" under_multi\n",
" <list_item>\n",
" <compact_paragraph skip_section_number=\"1\">\n",
" <reference anchorname=\"#confval-under_describe\" internal=\"1\" refuri=\"a\">\n",
" <literal>\n",
" under_describe\n",
)
);
}
#[test]
fn object_entries_skip_unnamed_opted_out_and_id_less_signatures() {
let doctree = parse(
concat!(
"A\n=\n\n",
".. envvar:: HOME\n\n",
".. confval:: hidden\n :no-contents-entry:\n\n",
".. confval:: unindexed\n :no-index:\n\n",
".. confval:: kept\n",
),
"a",
&docs(&["a"]),
);
let (toc, n) = build_toc(&doctree, "a");
assert_eq!(n, 2);
let out = toc.pformat();
assert!(out.contains("#confval-kept"), "{out}");
assert!(!out.contains("HOME"), "{out}");
assert!(!out.contains("hidden"), "{out}");
assert!(!out.contains("unindexed"), "{out}");
}
#[test]
fn title_inline_markup_survives_but_references_are_unwrapped() {
let doctree = parse(
"A `link <https://x/>`_ and *em* [#f]_\n=====================================\n\n.. [#f] note\n",
"a",
&docs(&["a"]),
);
let (toc, _) = build_toc(&doctree, "a");
assert!(toc.pformat().contains("<emphasis>\n"), "{}", toc.pformat());
assert!(!toc.pformat().contains("<reference anchorname=\"\" internal=\"1\" refuri=\"a\">\n A\n <reference"));
assert!(
!toc.pformat().contains("footnote_reference"),
"{}",
toc.pformat()
);
assert!(toc.pformat().contains("link"), "{}", toc.pformat());
}
#[test]
fn only_directive_wraps_its_entries() {
let doctree = parse(
".. only:: html\n\n .. toctree::\n\n a\n",
"index",
&docs(&["index", "a"]),
);
let (toc, n) = build_toc(&doctree, "index");
assert_eq!(n, 0, "a copied toctree is not a numbered entry");
assert!(
toc.pformat()
.starts_with("<bullet_list>\n <only expr=\"html\">\n <toctree "),
"{}",
toc.pformat()
);
let mut env = BuildEnvironment::default();
for node in toctree_copies(&toc) {
note_toctree(&mut env, "index", node);
}
assert_eq!(
env.toctree_includes.get("index"),
Some(&vec!["a".to_string()]),
"a toctree inside `only` still contributes to the graph"
);
}
#[test]
fn toctree_node_is_copied_into_the_toc_and_noted() {
let found = docs(&["index", "a", "b"]);
let doctree = parse(
"Index\n=====\n\n.. toctree::\n\n a\n b\n",
"index",
&found,
);
let (toc, n) = build_toc(&doctree, "index");
assert_eq!(n, 1, "the copied toctree is not an entry");
let mut env = BuildEnvironment::default();
for node in toctree_copies(&toc) {
note_toctree(&mut env, "index", node);
}
assert_eq!(
env.toctree_includes.get("index"),
Some(&vec!["a".to_string(), "b".to_string()])
);
assert_eq!(
env.files_to_rebuild.get("a"),
Some(&BTreeSet::from(["index".to_string()]))
);
assert!(env.glob_toctrees.is_empty());
assert!(env.numbered_toctrees.is_empty());
}
#[test]
fn note_toctree_creates_the_includes_key_even_when_empty() {
let mut env = BuildEnvironment::default();
let mut toctree = Node::elem(kinds::TOCTREE, Span::ZERO);
toctree.set("includefiles", AttrValue::List(vec![]));
note_toctree(&mut env, "index", &toctree);
assert_eq!(env.toctree_includes.get("index"), Some(&Vec::new()));
assert!(env.files_to_rebuild.is_empty());
}
#[test]
fn glob_and_numbered_flags_reach_the_environment() {
let mut env = BuildEnvironment::default();
let mut toctree = Node::elem(kinds::TOCTREE, Span::ZERO);
toctree.set("glob", AttrValue::Int(1));
toctree.set("numbered", AttrValue::Int(999));
toctree.set("includefiles", AttrValue::List(vec!["a".into()]));
note_toctree(&mut env, "index", &toctree);
assert!(env.glob_toctrees.contains("index"));
assert!(env.numbered_toctrees.contains("index"));
}
fn content<'a>(
lines: &'a [String],
docname: &'a str,
found: &'a BTreeSet<String>,
) -> ToctreeContent<'a> {
ToctreeContent {
content: lines,
docname,
glob: false,
reversed: false,
source: 0,
line: 1,
found_docs: found,
source_suffixes: &[".rst"],
exclude_patterns: &[],
}
}
fn lines(entries: &[&str]) -> Vec<String> {
entries.iter().map(|s| (*s).to_string()).collect()
}
#[test]
fn entries_resolve_relative_absolute_and_self_targets() {
let found = docs(&["index", "sub/b", "sub/c", "a"]);
let entries = lines(&[
"c",
"/a",
"self",
"https://example.invalid/x",
"missing",
"sub/b",
]);
let resolved = resolve_entries(&content(&entries, "sub/b", &found));
assert_eq!(
resolved.entries,
vec![
(None, "sub/c".to_string()),
(None, "a".to_string()),
(None, "self".to_string()),
(None, "https://example.invalid/x".to_string()),
],
"missing docs drop; `sub/b` is the current document, which is not \
a candidate for its own toctree"
);
assert_eq!(
resolved.includefiles,
vec!["sub/c".to_string(), "a".to_string()]
);
assert_eq!(
resolved
.warnings
.iter()
.map(|w| w.message.as_str())
.collect::<Vec<_>>(),
vec![
"toctree contains reference to nonexisting document 'sub/missing'",
"toctree contains reference to nonexisting document 'sub/sub/b'",
],
"sphinx reports the *joined* docname, so a document-relative miss \
names the directory it was resolved against"
);
}
#[test]
fn an_entry_naming_its_own_document_is_reported_as_nonexisting() {
let found = docs(&["index", "a"]);
let entries = lines(&["index", "a"]);
let resolved = resolve_entries(&content(&entries, "index", &found));
assert_eq!(resolved.includefiles, vec!["a".to_string()]);
assert_eq!(
resolved
.warnings
.iter()
.map(|w| (w.message.as_str(), w.category.as_deref()))
.collect::<Vec<_>>(),
vec![(
"toctree contains reference to nonexisting document 'index'",
Some("toc.not_readable")
)]
);
}
#[test]
fn diagnostics_are_located_at_the_directive() {
let found = docs(&["index"]);
let entries = lines(&["missing"]);
let resolved = resolve_entries(&ToctreeContent {
line: 12,
..content(&entries, "index", &found)
});
assert_eq!(resolved.warnings.len(), 1);
assert_eq!(resolved.warnings[0].line, 12);
assert_eq!(
resolved.warnings[0].category.as_deref(),
Some("toc.not_readable")
);
}
#[test]
fn excluded_targets_get_their_own_message() {
let found = docs(&["index"]);
let excluded = vec!["drafts/*".to_string()];
let entries = lines(&["drafts/wip"]);
let resolved = resolve_entries(&ToctreeContent {
exclude_patterns: &excluded,
..content(&entries, "index", &found)
});
assert_eq!(
resolved.warnings[0].message,
"toctree contains reference to excluded document 'drafts/wip'"
);
assert_eq!(
resolved.warnings[0].category.as_deref(),
Some("toc.excluded")
);
}
#[test]
fn a_document_claimed_twice_warns_but_is_still_listed() {
let found = docs(&["index", "a"]);
let entries = lines(&["a", "a"]);
let resolved = resolve_entries(&content(&entries, "index", &found));
assert_eq!(
resolved.includefiles,
vec!["a".to_string(), "a".to_string()],
"sphinx appends the duplicate either way"
);
assert_eq!(
resolved.warnings[0].message,
"duplicated entry found in toctree: a"
);
assert_eq!(
resolved.warnings[0].category.as_deref(),
Some("toc.duplicate_entry")
);
}
#[test]
fn the_virtual_docnames_are_the_dict_keys_not_its_values() {
let found = docs(&["index"]);
let entries = lines(&["genindex", "modindex", "search"]);
let resolved = resolve_entries(&content(&entries, "index", &found));
assert_eq!(
resolved.includefiles,
vec![
"genindex".to_string(),
"modindex".to_string(),
"search".to_string()
]
);
assert!(resolved.warnings.is_empty(), "{:?}", resolved.warnings);
let entries = lines(&["py-modindex"]);
let resolved = resolve_entries(&content(&entries, "index", &found));
assert!(resolved.includefiles.is_empty());
assert_eq!(
resolved
.warnings
.iter()
.map(|w| w.message.as_str())
.collect::<Vec<_>>(),
vec!["toctree contains reference to nonexisting document 'py-modindex'"]
);
}
#[test]
fn glob_entries_expand_sorted_and_skip_virtual_docs() {
let found = docs(&["index", "pages/a", "pages/b"]);
let entries = lines(&["pages/*"]);
let resolved = resolve_entries(&ToctreeContent {
glob: true,
..content(&entries, "index", &found)
});
assert_eq!(
resolved.includefiles,
vec!["pages/a".to_string(), "pages/b".to_string()]
);
assert_eq!(
resolved.entries_attr(),
AttrValue::List(vec![
"(None, 'pages/a')".to_string(),
"(None, 'pages/b')".to_string()
])
);
assert!(resolved.warnings.is_empty());
}
#[test]
fn a_glob_that_matches_nothing_warns() {
let found = docs(&["index", "pages/a"]);
let entries = lines(&["missing*"]);
let resolved = resolve_entries(&ToctreeContent {
glob: true,
..content(&entries, "index", &found)
});
assert!(resolved.includefiles.is_empty());
assert_eq!(
resolved.warnings,
vec![ToctreeWarning {
source: 0,
line: 1,
message: "toctree glob pattern 'missing*' didn't match any documents".to_string(),
category: None,
kind: ToctreeWarningKind::EmptyGlob,
}]
);
}
#[test]
fn an_uncompilable_glob_pattern_is_reported_as_such() {
let found = docs(&["index", "a"]);
let entries = lines(&["[z-a]*"]);
let resolved = resolve_entries(&ToctreeContent {
glob: true,
..content(&entries, "index", &found)
});
assert_eq!(resolved.warnings.len(), 1, "{:?}", resolved.warnings);
assert_eq!(resolved.warnings[0].kind, ToctreeWarningKind::PatternError);
assert!(
resolved.warnings[0]
.message
.starts_with("toctree glob pattern '[z-a]*' is not usable:"),
"{}",
resolved.warnings[0].message
);
}
#[test]
fn explicit_titles_and_suffixes() {
let found = docs(&["index", "other"]);
let entries = lines(&["Linked <other.rst>", "<foo>"]);
let resolved = resolve_entries(&content(&entries, "index", &found));
assert_eq!(
resolved.entries,
vec![(Some("Linked".to_string()), "other".to_string())],
"`<foo>` is a literal (missing) target, not an empty title"
);
assert_eq!(
resolved.entries_attr(),
AttrValue::List(vec!["('Linked', 'other')".to_string()])
);
assert_eq!(
resolved.warnings[0].message,
"toctree contains reference to nonexisting document '<foo>'"
);
}
#[test]
fn reversed_flips_both_lists() {
let found = docs(&["index", "a", "b"]);
let entries = lines(&["a", "b"]);
let resolved = resolve_entries(&ToctreeContent {
reversed: true,
..content(&entries, "index", &found)
});
assert_eq!(
resolved.includefiles,
vec!["b".to_string(), "a".to_string()]
);
}
#[test]
fn url_detection_matches_the_backtracking_regex() {
assert!(is_url("https://example.invalid/x"));
assert!(is_url("a://b"));
assert!(!is_url("://leading"));
assert!(
is_url("://a://b"),
"the second `://` has a non-empty schema before it"
);
assert!(!is_url("plain/docname"));
}
#[test]
fn virtual_docnames_are_valid_entries() {
let found = docs(&["index"]);
let entries = lines(&["genindex"]);
let resolved = resolve_entries(&content(&entries, "index", &found));
assert_eq!(resolved.includefiles, vec!["genindex".to_string()]);
assert!(resolved.warnings.is_empty());
}
fn graph(root: &str, includes: &[(&str, &[&str])]) -> BuildEnvironment {
let mut env = BuildEnvironment {
root_doc: root.to_string(),
..Default::default()
};
for (container, children) in includes {
let children: Vec<String> = children.iter().map(|c| (*c).to_string()).collect();
for child in &children {
env.files_to_rebuild
.entry(child.clone())
.or_default()
.insert((*container).to_string());
}
env.toctree_includes
.insert((*container).to_string(), children);
}
for docname in env
.toctree_includes
.keys()
.cloned()
.chain(env.files_to_rebuild.keys().cloned())
.collect::<Vec<_>>()
{
env.all_docs.insert(docname, 0);
}
env.all_docs.insert(root.to_string(), 0);
env
}
fn relation(env: &BuildEnvironment, docname: &str) -> (String, String, String) {
let show = |value: &Option<String>| value.clone().unwrap_or_else(|| "-".to_string());
let (parent, prev, next) = collect_relations(env)[docname].clone();
(show(&parent), show(&prev), show(&next))
}
#[test]
fn relations_chain_the_preorder_walk() {
let env = graph("index", &[("index", &["a", "b"]), ("a", &["a1", "a2"])]);
assert_eq!(
relation(&env, "index"),
("-".into(), "-".into(), "a".into())
);
assert_eq!(
relation(&env, "a"),
("index".into(), "index".into(), "a1".into())
);
assert_eq!(relation(&env, "a1"), ("a".into(), "a".into(), "a2".into()));
assert_eq!(relation(&env, "a2"), ("a".into(), "a1".into(), "b".into()));
assert_eq!(
relation(&env, "b"),
("index".into(), "a2".into(), "-".into())
);
}
#[test]
fn a_document_with_two_parents_keeps_the_first_visit() {
let env = graph(
"index",
&[("index", &["a", "b"]), ("a", &["c"]), ("b", &["c"])],
);
assert_eq!(relation(&env, "c"), ("a".into(), "a".into(), "b".into()));
assert_eq!(
relation(&env, "b"),
("index".into(), "c".into(), "-".into())
);
}
#[test]
fn a_project_without_toctrees_relates_only_its_root() {
let env = graph("index", &[]);
assert_eq!(
relation(&env, "index"),
("-".into(), "-".into(), "-".into())
);
assert_eq!(collect_relations(&env).len(), 1);
}
#[test]
fn a_mutual_cycle_terminates_instead_of_recursing_forever() {
let env = graph("index", &[("index", &["a"]), ("a", &["b"]), ("b", &["a"])]);
let relations = collect_relations(&env);
assert_eq!(
relations.keys().collect::<Vec<_>>(),
vec!["a", "b", "index"],
"every document is still reached exactly once"
);
assert_eq!(relation(&env, "b"), ("a".into(), "a".into(), "-".into()));
}
#[test]
fn a_self_parenting_toctree_drops_its_subtree() {
let env = graph("index", &[("index", &["a"]), ("a", &["a"])]);
let relations = collect_relations(&env);
assert_eq!(relations.keys().collect::<Vec<_>>(), vec!["a", "index"]);
}
#[test]
fn ancestors_walk_up_to_the_root_and_stop_on_cycles() {
let includes = graph("index", &[("index", &["a"]), ("a", &["b"])]).toctree_includes;
assert_eq!(toctree_ancestors(&includes, "b"), vec!["b", "a"]);
assert_eq!(
toctree_ancestors(&includes, "index"),
Vec::<String>::new(),
"a document with no toctree parent has no ancestors, not even itself"
);
let cyclic = graph("index", &[("a", &["b"]), ("b", &["a"])]).toctree_includes;
assert_eq!(toctree_ancestors(&cyclic, "a"), vec!["a", "b"]);
}
#[test]
fn documents_no_toctree_reaches_are_reported_as_orphans() {
let mut env = graph("index", &[("index", &["a"])]);
env.all_docs.insert("stray".to_string(), 0);
env.all_docs.insert("textually_included".to_string(), 0);
env.all_docs.insert("marked".to_string(), 0);
env.included.insert(
"a".to_string(),
BTreeSet::from(["textually_included".to_string()]),
);
env.metadata.insert(
"marked".to_string(),
BTreeMap::from([("orphan".to_string(), String::new())]),
);
let messages = check_consistency(&env, &|_| true);
assert_eq!(
messages
.iter()
.map(|m| (m.docname.as_str(), m.level))
.collect::<Vec<_>>(),
vec![("stray", ConsistencyLevel::Warning)],
"the root, toctree'd, textually included and `:orphan:` \
documents are all exempt; {messages:?}"
);
assert_eq!(
messages[0].message,
"document isn't included in any toctree"
);
assert_eq!(messages[0].category.as_deref(), Some("toc.not_included"));
}
#[test]
fn several_toctree_parents_is_informational() {
let env = graph(
"index",
&[("index", &["a", "b"]), ("a", &["c"]), ("b", &["c"])],
);
let messages = check_consistency(&env, &|_| true);
assert_eq!(messages.len(), 1, "{messages:?}");
assert_eq!(messages[0].level, ConsistencyLevel::Info);
assert_eq!(messages[0].docname, "c");
assert_eq!(
messages[0].message,
"document is referenced in multiple toctrees: ['a', 'b'], selecting: b <- c"
);
}
#[test]
fn docname_join_normalizes() {
assert_eq!(docname_join("sub/b", "c"), "sub/c");
assert_eq!(docname_join("sub/b", "/a"), "a");
assert_eq!(docname_join("sub/b", "../a"), "a");
assert_eq!(docname_join("index", "a"), "a");
}
#[test]
fn document_title_filters_like_a_toc_entry() {
let doctree = parse("A *b* c\n=======\n\nText.\n", "a", &docs(&["a"]));
assert_eq!(
document_title(&doctree).pformat(),
"<title>\n A \n <emphasis>\n b\n c\n"
);
}
#[test]
fn document_title_without_a_section_says_no_title() {
let doctree = parse("Just a paragraph.\n", "a", &docs(&["a"]));
assert_eq!(
document_title(&doctree).pformat(),
"<title>\n <no title>\n"
);
}
}