1use std::collections::{BTreeMap, BTreeSet};
40
41use crate::doctree::{kinds, AttrValue, Doctree, Node, Span};
42use crate::env::numbers::clean_astext;
43use crate::env::BuildEnvironment;
44use crate::matching;
45use crate::utils::py_repr_str;
46
47pub(crate) const VIRTUAL_DOC_NAMES: [&str; 3] = ["genindex", "modindex", "search"];
58
59#[derive(Debug, Clone, Copy)]
66pub struct ToctreeContent<'a> {
67 pub content: &'a [String],
69 pub docname: &'a str,
71 pub glob: bool,
72 pub reversed: bool,
73 pub source: u16,
76 pub line: u32,
80 pub found_docs: &'a BTreeSet<String>,
82 pub source_suffixes: &'a [&'a str],
85 pub exclude_patterns: &'a [String],
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
94pub enum ToctreeWarningKind {
95 MissingDocument,
97 EmptyGlob,
99 DuplicateEntry,
101 PatternError,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
115pub struct ToctreeWarning {
116 pub source: u16,
124 pub line: u32,
127 pub message: String,
129 pub category: Option<String>,
132 pub kind: ToctreeWarningKind,
133}
134
135#[derive(Debug, Clone, Default, PartialEq, Eq)]
137pub struct ResolvedEntries {
138 pub entries: Vec<(Option<String>, String)>,
141 pub includefiles: Vec<String>,
143 pub warnings: Vec<ToctreeWarning>,
146}
147
148impl ResolvedEntries {
149 pub fn entries_attr(&self) -> AttrValue {
153 AttrValue::List(
154 self.entries
155 .iter()
156 .map(|(title, target)| {
157 let title = match title {
158 Some(t) => py_repr_str(t),
159 None => "None".to_string(),
160 };
161 format!("({title}, {})", py_repr_str(target))
162 })
163 .collect(),
164 )
165 }
166
167 pub fn includefiles_attr(&self) -> AttrValue {
168 AttrValue::List(self.includefiles.clone())
169 }
170}
171
172pub fn resolve_entries(input: &ToctreeContent<'_>) -> ResolvedEntries {
187 let &ToctreeContent {
188 content,
189 docname,
190 glob,
191 reversed,
192 source,
193 line,
194 found_docs,
195 source_suffixes,
196 exclude_patterns,
197 } = input;
198
199 let mut all: BTreeSet<&str> = found_docs.iter().map(String::as_str).collect();
204 all.extend(VIRTUAL_DOC_NAMES);
205 all.remove(docname);
206 let frozen = all.clone();
207
208 let mut out = ResolvedEntries::default();
209 let mut warnings: Vec<ToctreeWarning> = Vec::new();
210 let warn = |sink: &mut Vec<ToctreeWarning>,
213 message: String,
214 category: Option<&str>,
215 kind: ToctreeWarningKind| {
216 sink.push(ToctreeWarning {
217 source,
218 line,
219 message,
220 category: category.map(str::to_string),
221 kind,
222 });
223 };
224
225 for entry in content {
226 if entry.is_empty() {
227 continue;
228 }
229 let explicit = split_explicit_title(entry);
230 let url_match = is_url(entry);
231
232 if glob && has_glob_metachars(entry) && explicit.is_none() && !url_match {
233 let pattern = docname_join(docname, entry);
234 let mut matched: Vec<String> = Vec::new();
236 let mut pattern_error = None;
237 for candidate in all.iter().filter(|d| !VIRTUAL_DOC_NAMES.contains(d)) {
238 match matching::pattern_match(candidate, &pattern) {
239 Ok(true) => matched.push((*candidate).to_string()),
240 Ok(false) => {}
241 Err(e) => {
242 pattern_error.get_or_insert_with(|| e.to_string());
243 }
244 }
245 }
246 if let Some(error) = pattern_error {
247 warn(
248 &mut warnings,
249 format!(
250 "toctree glob pattern {} is not usable: {error}",
251 py_repr_str(entry)
252 ),
253 None,
254 ToctreeWarningKind::PatternError,
255 );
256 } else if matched.is_empty() {
257 warn(
258 &mut warnings,
259 format!(
260 "toctree glob pattern {} didn't match any documents",
261 py_repr_str(entry)
262 ),
263 None,
266 ToctreeWarningKind::EmptyGlob,
267 );
268 }
269 for name in matched {
270 all.remove(name.as_str());
271 out.entries.push((None, name.clone()));
272 out.includefiles.push(name);
273 }
274 continue;
275 }
276
277 let (title, reference) = match explicit {
278 Some((title, target)) => (Some(title.to_string()), target),
279 None => (None, entry.as_str()),
280 };
281 let mut resolved = reference;
282 for suffix in source_suffixes {
283 if let Some(stripped) = resolved.strip_suffix(suffix) {
284 resolved = stripped;
285 break;
286 }
287 }
288 let resolved = docname_join(docname, resolved);
289
290 if url_match || reference == "self" {
291 out.entries.push((title, reference.to_string()));
292 continue;
293 }
294 if !frozen.contains(resolved.as_str()) {
295 let path = format!(
300 "{resolved}{}",
301 source_suffixes.first().copied().unwrap_or_default()
302 );
303 let (message, category) = if matches_any(&path, exclude_patterns) {
304 (
305 "toctree contains reference to excluded document",
306 "toc.excluded",
307 )
308 } else {
309 (
310 "toctree contains reference to nonexisting document",
311 "toc.not_readable",
312 )
313 };
314 warn(
315 &mut warnings,
316 format!("{message} {}", py_repr_str(&resolved)),
317 Some(category),
318 ToctreeWarningKind::MissingDocument,
319 );
320 continue;
321 }
322 if !all.remove(resolved.as_str()) {
325 warn(
326 &mut warnings,
327 format!("duplicated entry found in toctree: {resolved}"),
328 Some("toc.duplicate_entry"),
329 ToctreeWarningKind::DuplicateEntry,
330 );
331 }
332 out.entries.push((title, resolved.clone()));
333 out.includefiles.push(resolved);
334 }
335
336 if reversed {
339 out.entries.reverse();
340 out.includefiles.reverse();
341 }
342 out.warnings = warnings;
343 out
344}
345
346fn matches_any(path: &str, patterns: &[String]) -> bool {
350 patterns
351 .iter()
352 .any(|pattern| matching::pattern_match(path, pattern).unwrap_or(false))
353}
354
355pub fn split_explicit_title(entry: &str) -> Option<(&str, &str)> {
361 let entry = entry.strip_suffix('>')?;
362 let open = entry.find('<')?;
363 if open == 0 {
364 return None;
365 }
366 let title = entry[..open].trim_end();
367 if title.is_empty() {
368 return None;
369 }
370 Some((title, &entry[open + 1..]))
371}
372
373fn is_url(entry: &str) -> bool {
377 entry.match_indices("://").any(|(at, _)| at >= 1)
378}
379
380fn has_glob_metachars(entry: &str) -> bool {
382 entry.contains(['*', '?', '['])
383}
384
385pub fn docname_join(base_docname: &str, docname: &str) -> String {
391 let (base, target) = match docname.strip_prefix('/') {
392 Some(stripped) => ("", stripped),
393 None => (
394 base_docname.rsplit_once('/').map(|(d, _)| d).unwrap_or(""),
395 docname,
396 ),
397 };
398
399 let mut segments: Vec<&str> = Vec::new();
400 for seg in base.split('/').chain(target.split('/')) {
401 match seg {
402 "" | "." => {}
403 ".." => {
404 segments.pop();
405 }
406 s => segments.push(s),
407 }
408 }
409 segments.join("/")
410}
411
412pub fn build_toc(doctree: &Doctree, docname: &str) -> (Node, u32) {
428 let mut num_entries = 0u32;
429 let toc = build_toc_level(&doctree.root.children, docname, &mut num_entries)
430 .unwrap_or_else(|| Node::elem(kinds::BULLET_LIST, Span::ZERO));
431 (toc, num_entries)
432}
433
434fn build_toc_level(nodes: &[Node], docname: &str, num_entries: &mut u32) -> Option<Node> {
435 let mut entries: Vec<Node> = Vec::new();
436
437 for node in nodes {
438 if node.kind == kinds::TEXT {
441 continue;
442 }
443
444 if node.kind == kinds::SECTION {
445 let title_children = node
447 .children
448 .first()
449 .map(filter_title_children)
450 .unwrap_or_default();
451 let anchorname = make_anchor_name(&node.attrs.ids, num_entries);
452
453 let mut reference = Node::elem(kinds::REFERENCE, Span::ZERO);
454 reference.set("anchorname", AttrValue::Str(anchorname));
455 reference.set("internal", AttrValue::Int(1));
456 reference.set("refuri", AttrValue::Str(docname.to_string()));
457 reference.children = title_children;
458
459 let mut para = Node::elem(kinds::COMPACT_PARAGRAPH, Span::ZERO);
460 para.children.push(reference);
461 let mut item = Node::elem(kinds::LIST_ITEM, Span::ZERO);
462 item.children.push(para);
463 if let Some(sub) = build_toc_level(&node.children, docname, num_entries) {
464 item.children.push(sub);
465 }
466 entries.push(item);
467 } else if node.kind == kinds::ONLY {
468 let mut only = Node::elem(kinds::ONLY, Span::ZERO);
471 if let Some(expr) = node.get("expr") {
472 only.set("expr", expr.clone());
473 }
474 if let Some(sub) = build_toc_level(&node.children, docname, num_entries) {
475 only.children = sub.children;
476 entries.push(only);
477 }
478 } else {
479 collect_body_entries(node, docname, num_entries, &mut entries, &[]);
485 }
486 }
487
488 if entries.is_empty() {
489 return None;
490 }
491 let mut list = Node::elem(kinds::BULLET_LIST, Span::ZERO);
492 list.children = entries;
493 Some(list)
494}
495
496fn collect_body_entries(
507 node: &Node,
508 docname: &str,
509 num_entries: &mut u32,
510 entries: &mut Vec<Node>,
511 path: &[usize],
512) {
513 if node.kind == kinds::TEXT {
514 return;
515 }
516 if node.kind == kinds::TOCTREE {
517 entries.push(node.shallow_copy());
518 return;
519 }
520 if node.kind == "desc" {
521 let mut child_path: Option<Vec<usize>> = None;
523 for signature in node.children.iter().filter(|c| c.kind == "desc_signature") {
524 let Some(entry) = object_toc_entry(node, signature, docname, num_entries) else {
525 continue;
526 };
527 let target = resolve_attach_point(entries, path);
528 target.push(entry);
529 let mut deeper = path.to_vec();
530 deeper.push(target.len() - 1);
531 child_path = Some(deeper);
532 }
533 let child_path = child_path.unwrap_or_else(|| path.to_vec());
534 for child in &node.children {
535 collect_body_entries(child, docname, num_entries, entries, &child_path);
536 }
537 return;
538 }
539 for child in &node.children {
540 collect_body_entries(child, docname, num_entries, entries, path);
541 }
542}
543
544fn resolve_attach_point<'a>(entries: &'a mut Vec<Node>, path: &[usize]) -> &'a mut Vec<Node> {
550 let mut current = entries;
551 for &index in path {
552 let item = &mut current[index];
553 if item.children.last().map(|c| c.kind) != Some(kinds::BULLET_LIST) {
554 item.children
555 .push(Node::elem(kinds::BULLET_LIST, Span::ZERO));
556 }
557 current = &mut item
558 .children
559 .last_mut()
560 .expect("a bullet_list was just ensured")
561 .children;
562 }
563 current
564}
565
566fn object_toc_entry(
571 desc: &Node,
572 signature: &Node,
573 docname: &str,
574 num_entries: &mut u32,
575) -> Option<Node> {
576 let toc_name = match signature.get("_toc_name") {
577 Some(AttrValue::Str(name)) if !name.is_empty() => name.clone(),
578 _ => return None,
579 };
580 if matches!(desc.get("no-contents-entry"), Some(AttrValue::Int(1))) {
581 return None;
582 }
583 if signature.attrs.ids.is_empty() {
584 return None;
585 }
586 let anchorname = make_anchor_name(&signature.attrs.ids, num_entries);
587
588 let mut literal = Node::elem(kinds::LITERAL, Span::ZERO);
589 literal.children.push(Node::text_node(toc_name, Span::ZERO));
590 let mut reference = Node::elem(kinds::REFERENCE, Span::ZERO);
591 reference.set("anchorname", AttrValue::Str(anchorname));
592 reference.set("internal", AttrValue::Int(1));
593 reference.set("refuri", AttrValue::Str(docname.to_string()));
594 reference.children.push(literal);
595 let mut para = Node::elem(kinds::COMPACT_PARAGRAPH, Span::ZERO);
596 para.set("skip_section_number", AttrValue::Int(1));
597 para.children.push(reference);
598 let mut item = Node::elem(kinds::LIST_ITEM, Span::ZERO);
599 item.children.push(para);
600 Some(item)
601}
602
603fn make_anchor_name(ids: &[String], num_entries: &mut u32) -> String {
607 let anchor = if *num_entries == 0 {
608 String::new()
609 } else {
610 format!("#{}", ids.first().map(String::as_str).unwrap_or(""))
613 };
614 *num_entries += 1;
615 anchor
616}
617
618fn filter_title_children(title: &Node) -> Vec<Node> {
624 let mut out = Vec::new();
625 filter_into(&title.children, &mut out);
626 out
627}
628
629fn filter_into(children: &[Node], out: &mut Vec<Node>) {
630 for child in children {
631 match child.kind {
632 kinds::FOOTNOTE_REFERENCE | kinds::CITATION_REFERENCE | kinds::IMAGE => {}
636 kinds::REFERENCE | kinds::TARGET | kinds::PROBLEMATIC | kinds::PENDING_XREF => {
639 filter_into(&child.children, out);
640 }
641 kinds::TEXT => out.push(child.clone()),
642 _ => {
643 let mut copy = child.shallow_copy();
644 filter_into(&child.children, &mut copy.children);
645 out.push(copy);
646 }
647 }
648 }
649}
650
651pub fn note_toctree(env: &mut BuildEnvironment, docname: &str, toctree: &Node) {
661 if matches!(toctree.get("glob"), Some(AttrValue::Int(n)) if *n != 0) {
662 env.glob_toctrees.insert(docname.to_string());
663 }
664 if matches!(toctree.get("numbered"), Some(AttrValue::Int(n)) if *n != 0) {
665 env.numbered_toctrees.insert(docname.to_string());
666 }
667
668 let include_files: &[String] = match toctree.get("includefiles") {
669 Some(AttrValue::List(files)) => files,
670 _ => &[],
671 };
672 for include_file in include_files {
673 env.files_to_rebuild
674 .entry(include_file.clone())
675 .or_default()
676 .insert(docname.to_string());
677 }
678 env.toctree_includes
679 .entry(docname.to_string())
680 .or_default()
681 .extend(include_files.iter().cloned());
682}
683
684pub fn toctree_copies(toc: &Node) -> Vec<&Node> {
688 let mut out = Vec::new();
689 fn walk<'a>(node: &'a Node, out: &mut Vec<&'a Node>) {
690 if node.kind == kinds::TOCTREE {
691 out.push(node);
692 }
693 for child in &node.children {
694 walk(child, out);
695 }
696 }
697 walk(toc, &mut out);
698 out
699}
700
701pub type Relation = (Option<String>, Option<String>, Option<String>);
708
709pub fn collect_relations(env: &BuildEnvironment) -> BTreeMap<String, Relation> {
718 let order = traverse_toctree(&env.toctree_includes, &env.root_doc);
719 let mut relations = BTreeMap::new();
720
721 let mut prev: Option<String> = None;
722 for (index, (parent, docname)) in order.iter().enumerate() {
723 let next = order.get(index + 1).map(|(_, doc)| doc.clone());
724 relations.insert(docname.clone(), (parent.clone(), prev.take(), next));
725 prev = Some(docname.clone());
726 }
727 relations
728}
729
730pub fn traverse_toctree(
756 toctree_includes: &BTreeMap<String, Vec<String>>,
757 root: &str,
758) -> Vec<(Option<String>, String)> {
759 let mut out: Vec<(Option<String>, String)> = Vec::new();
760 let mut visited: BTreeSet<String> = BTreeSet::new();
761 let mut stack: Vec<(Option<String>, String)> = vec![(None, root.to_string())];
762
763 while let Some((parent, docname)) = stack.pop() {
764 if parent.as_deref() == Some(docname.as_str()) {
765 continue;
766 }
767 if !visited.insert(docname.clone()) {
768 continue;
769 }
770 if let Some(children) = toctree_includes.get(&docname) {
771 for child in children.iter().rev() {
773 stack.push((Some(docname.clone()), child.clone()));
774 }
775 }
776 out.push((parent, docname));
777 }
778 out
779}
780
781pub fn toctree_ancestors(
796 toctree_includes: &BTreeMap<String, Vec<String>>,
797 docname: &str,
798) -> Vec<String> {
799 let mut parent: BTreeMap<&str, &str> = BTreeMap::new();
800 for (container, children) in toctree_includes {
801 for child in children {
802 parent.insert(child.as_str(), container.as_str());
803 }
804 }
805
806 let mut ancestors: Vec<String> = Vec::new();
807 let mut current = docname;
808 while let Some(next) = parent.get(current) {
809 if ancestors.iter().any(|seen| seen == current) {
810 break;
811 }
812 ancestors.push(current.to_string());
813 current = next;
814 }
815 ancestors
816}
817
818#[derive(Debug, Clone, Copy, PartialEq, Eq)]
821pub enum ConsistencyLevel {
822 Warning,
823 Info,
824}
825
826#[derive(Debug, Clone, PartialEq, Eq)]
829pub struct ConsistencyMessage {
830 pub docname: String,
831 pub message: String,
832 pub category: Option<String>,
835 pub level: ConsistencyLevel,
836}
837
838pub fn check_consistency(
861 env: &BuildEnvironment,
862 is_sphinx_source: &dyn Fn(&str) -> bool,
863) -> Vec<ConsistencyMessage> {
864 let mut messages = Vec::new();
865
866 let included: BTreeSet<&str> = env
867 .included
868 .values()
869 .flatten()
870 .map(String::as_str)
871 .collect();
872
873 for docname in env.all_docs.keys() {
875 if env.files_to_rebuild.contains_key(docname)
879 || *docname == env.root_doc
880 || included.contains(docname.as_str())
881 || env
882 .metadata
883 .get(docname)
884 .is_some_and(|meta| meta.contains_key("orphan"))
885 || !is_sphinx_source(docname)
886 {
887 continue;
888 }
889 messages.push(ConsistencyMessage {
890 docname: docname.clone(),
891 message: "document isn't included in any toctree".to_string(),
892 category: Some("toc.not_included".to_string()),
893 level: ConsistencyLevel::Warning,
894 });
895 }
896
897 let mut toc_parents: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
902 for (container, children) in &env.toctree_includes {
903 for child in children {
904 toc_parents
905 .entry(child.as_str())
906 .or_default()
907 .push(container.as_str());
908 }
909 }
910 for (docname, parents) in toc_parents {
911 if parents.len() <= 1 {
912 continue;
913 }
914 let list = parents
917 .iter()
918 .map(|parent| py_repr_str(parent))
919 .collect::<Vec<_>>()
920 .join(", ");
921 let selected = parents.iter().max().expect("len > 1");
922 messages.push(ConsistencyMessage {
923 docname: docname.to_string(),
924 message: format!(
925 "document is referenced in multiple toctrees: [{list}], \
926 selecting: {selected} <- {docname}"
927 ),
928 category: Some("toc.multiple_toc_parents".to_string()),
929 level: ConsistencyLevel::Info,
930 });
931 }
932
933 messages
934}
935
936pub fn document_title(doctree: &Doctree) -> Node {
948 let mut title = Node::elem(kinds::TITLE, Span::ZERO);
949 match first_section(&doctree.root) {
950 Some(section) => {
951 if let Some(first_child) = section.children.first() {
952 title.children = filter_title_children(first_child);
953 }
954 }
955 None => title
957 .children
958 .push(Node::text_node("<no title>", Span::ZERO)),
959 }
960 title
961}
962
963fn first_section(node: &Node) -> Option<&Node> {
965 for child in &node.children {
966 if child.kind == kinds::SECTION {
967 return Some(child);
968 }
969 if let Some(found) = first_section(child) {
970 return Some(found);
971 }
972 }
973 None
974}
975
976const VIRTUAL_DOCS: [(&str, &str, &str); 3] = [
984 ("genindex", "genindex", "Index"),
985 ("modindex", "py-modindex", "Module Index"),
986 ("search", "search", "Search Page"),
987];
988
989pub(crate) fn toctree_entries(toctree: &Node) -> Vec<(Option<String>, String)> {
993 let Some(AttrValue::List(items)) = toctree.get("entries") else {
994 return Vec::new();
995 };
996 items
997 .iter()
998 .filter_map(|item| parse_entry_repr(item))
999 .collect()
1000}
1001
1002fn parse_entry_repr(item: &str) -> Option<(Option<String>, String)> {
1004 let inner = item.strip_prefix('(')?.strip_suffix(')')?;
1005 let (title, rest) = match inner.strip_prefix("None") {
1006 Some(rest) => (None, rest),
1007 None => {
1008 let (title, rest) = parse_py_str(inner)?;
1009 (Some(title), rest)
1010 }
1011 };
1012 let (target, rest) = parse_py_str(rest.strip_prefix(", ")?)?;
1013 rest.is_empty().then_some((title, target))
1014}
1015
1016fn parse_py_str(s: &str) -> Option<(String, &str)> {
1019 let mut chars = s.char_indices();
1020 let (_, quote) = chars.next()?;
1021 if quote != '\'' && quote != '"' {
1022 return None;
1023 }
1024 let mut out = String::new();
1025 while let Some((at, c)) = chars.next() {
1026 match c {
1027 '\\' => {
1028 let (_, escaped) = chars.next()?;
1029 let hex_digits = match escaped {
1030 'n' => {
1031 out.push('\n');
1032 continue;
1033 }
1034 'r' => {
1035 out.push('\r');
1036 continue;
1037 }
1038 't' => {
1039 out.push('\t');
1040 continue;
1041 }
1042 'x' => 2,
1043 'u' => 4,
1044 'U' => 8,
1045 other => {
1046 out.push(other);
1047 continue;
1048 }
1049 };
1050 let mut code = 0u32;
1051 for _ in 0..hex_digits {
1052 let (_, digit) = chars.next()?;
1053 code = code.checked_mul(16)? + digit.to_digit(16)?;
1054 }
1055 out.push(char::from_u32(code)?);
1056 }
1057 c if c == quote => return Some((out, &s[at + c.len_utf8()..])),
1058 c => out.push(c),
1059 }
1060 }
1061 None
1062}
1063
1064fn attr_flag(node: &Node, key: &'static str) -> bool {
1065 matches!(node.get(key), Some(AttrValue::Int(n)) if *n != 0)
1066}
1067
1068fn toctree_caption(toctree: &Node) -> Option<&String> {
1073 match toctree.get("caption") {
1074 Some(AttrValue::Str(caption)) if !caption.is_empty() && caption != "True" => Some(caption),
1075 _ => None,
1076 }
1077}
1078
1079pub fn resolve_toctree(env: &BuildEnvironment, docname: &str, toctree: &Node) -> Option<Node> {
1098 if attr_flag(toctree, "hidden") {
1099 return None;
1100 }
1101 let maxdepth = match toctree.get("maxdepth") {
1102 Some(AttrValue::Int(depth)) => *depth,
1103 _ => -1,
1104 };
1105 let ancestors = toctree_ancestors(&env.toctree_includes, docname);
1106 let walk = ToctreeWalk {
1107 env,
1108 titles_only: attr_flag(toctree, "titlesonly"),
1109 includehidden: attr_flag(toctree, "includehidden"),
1110 ancestors: &ancestors,
1111 };
1112 let entries = walk.entries_from_toctree(toctree, &[], false);
1113 if entries.is_empty() {
1114 return None;
1115 }
1116
1117 let mut newnode = Node::elem(kinds::COMPACT_PARAGRAPH, Span::ZERO);
1118 if let Some(caption) = toctree_caption(toctree) {
1119 let mut title = Node::elem(kinds::TITLE, Span::ZERO);
1120 title
1121 .children
1122 .push(Node::text_node(caption.clone(), Span::ZERO));
1123 newnode.children.push(title);
1124 }
1125 newnode.children.extend(entries);
1126 newnode.set("toctree", AttrValue::Int(1));
1127
1128 let newnode = toctree_copy(&newnode, 1, maxdepth);
1131 match newnode.children.last() {
1132 Some(last) if last.kind != kinds::TEXT && last.children.is_empty() => None,
1135 _ => Some(newnode),
1136 }
1137}
1138
1139struct ToctreeWalk<'a> {
1142 env: &'a BuildEnvironment,
1143 titles_only: bool,
1144 includehidden: bool,
1145 ancestors: &'a [String],
1146}
1147
1148impl ToctreeWalk<'_> {
1149 fn entries_from_toctree(
1151 &self,
1152 toctreenode: &Node,
1153 parents: &[String],
1154 subtree: bool,
1155 ) -> Vec<Node> {
1156 let mut entries = Vec::new();
1157 for (title, reference) in toctree_entries(toctreenode) {
1158 let Some((mut toc, refdoc)) =
1159 self.toctree_entry(title.as_deref(), &reference, toctreenode, parents)
1160 else {
1161 continue;
1162 };
1163
1164 if self.titles_only {
1167 for top_level in &mut toc.children {
1168 if top_level.children.len() > 1 {
1169 let subtrees = find_toctrees(top_level);
1170 if subtrees.is_empty() {
1171 top_level.children.remove(1);
1172 } else {
1173 top_level.children[1].children = subtrees;
1174 }
1175 }
1176 }
1177 }
1178
1179 let mut sub_parents = Vec::with_capacity(parents.len() + 1);
1180 sub_parents.push(refdoc);
1181 sub_parents.extend_from_slice(parents);
1182 self.expand_sub_toctrees(&mut toc, &sub_parents);
1183
1184 entries.extend(toc.children);
1185 }
1186
1187 if subtree {
1188 entries
1189 } else {
1190 let mut list = Node::elem(kinds::BULLET_LIST, Span::ZERO);
1191 list.children = entries;
1192 vec![list]
1193 }
1194 }
1195
1196 fn toctree_entry(
1201 &self,
1202 title: Option<&str>,
1203 reference: &str,
1204 toctreenode: &Node,
1205 parents: &[String],
1206 ) -> Option<(Node, String)> {
1207 if is_url(reference) {
1208 let text = title.unwrap_or(reference);
1211 return Some((leaf_entry(text, reference), String::new()));
1212 }
1213 if reference == "self" {
1214 let parent = match toctreenode.get("parent") {
1216 Some(AttrValue::Str(parent)) => parent.as_str(),
1217 _ => "",
1218 };
1219 let text = match title {
1220 Some(title) if !title.is_empty() => title.to_string(),
1221 _ => self
1222 .env
1223 .titles
1224 .get(parent)
1225 .map(clean_astext)
1226 .unwrap_or_default(),
1227 };
1228 return Some((leaf_entry(&text, reference), String::new()));
1229 }
1230 if let Some((_, docname, sectionname)) =
1231 VIRTUAL_DOCS.iter().find(|(name, _, _)| *name == reference)
1232 {
1233 let text = match title {
1235 Some(title) if !title.is_empty() => title,
1236 _ => sectionname,
1237 };
1238 return Some((leaf_entry(text, docname), String::new()));
1239 }
1240 if parents.iter().any(|parent| parent == reference) {
1241 return None;
1243 }
1244 let toc = self.env.tocs.get(reference)?;
1247 let tocdepth = self
1248 .env
1249 .metadata
1250 .get(reference)
1251 .and_then(|metadata| metadata.get("tocdepth"))
1252 .and_then(|depth| depth.trim().parse::<i64>().ok())
1253 .unwrap_or(0);
1254 Some((
1255 self.standard_entry(title, reference, tocdepth, toc),
1256 reference.to_string(),
1257 ))
1258 }
1259
1260 fn standard_entry(
1262 &self,
1263 title: Option<&str>,
1264 reference: &str,
1265 tocdepth: i64,
1266 toc: &Node,
1267 ) -> Node {
1268 let mut toc =
1269 if self.ancestors.iter().any(|ancestor| ancestor == reference) && tocdepth <= 0 {
1270 toc.clone()
1271 } else {
1272 toctree_copy(toc, 2, tocdepth)
1273 };
1274 if let Some(title) = title.filter(|title| !title.is_empty()) {
1275 if toc.children.len() == 1 {
1276 replace_entry_title(&mut toc.children[0], reference, title);
1277 }
1278 }
1279 toc
1280 }
1281
1282 fn expand_sub_toctrees(&self, node: &mut Node, parents: &[String]) {
1287 let mut index = 0;
1288 while index < node.children.len() {
1289 if node.children[index].kind != kinds::TOCTREE {
1290 self.expand_sub_toctrees(&mut node.children[index], parents);
1291 index += 1;
1292 continue;
1293 }
1294 if attr_flag(&node.children[index], "hidden") && !self.includehidden {
1295 index += 1;
1296 continue;
1297 }
1298 let sub_toc_node = node.children.remove(index);
1299 let expanded = self.entries_from_toctree(&sub_toc_node, parents, true);
1300 let inserted = expanded.len();
1301 node.children.splice(index..index, expanded);
1302 index += inserted;
1303 }
1304 }
1305}
1306
1307fn leaf_entry(text: &str, refuri: &str) -> Node {
1310 let mut reference = Node::elem(kinds::REFERENCE, Span::ZERO);
1311 reference.set("anchorname", AttrValue::Str(String::new()));
1312 reference.set("refuri", AttrValue::Str(refuri.to_string()));
1313 reference.children.push(Node::text_node(text, Span::ZERO));
1314 let mut para = Node::elem(kinds::COMPACT_PARAGRAPH, Span::ZERO);
1315 para.children.push(reference);
1316 let mut item = Node::elem(kinds::LIST_ITEM, Span::ZERO);
1317 item.children.push(para);
1318 let mut toc = Node::elem(kinds::BULLET_LIST, Span::ZERO);
1319 toc.children.push(item);
1320 toc
1321}
1322
1323fn replace_entry_title(node: &mut Node, reference: &str, title: &str) {
1326 let is_entry_reference = node.kind == kinds::REFERENCE
1327 && matches!(node.get("refuri"), Some(AttrValue::Str(uri)) if uri == reference)
1328 && matches!(node.get("anchorname"), Some(AttrValue::Str(anchor)) if anchor.is_empty());
1329 if is_entry_reference {
1330 node.children = vec![Node::text_node(title, Span::ZERO)];
1331 return;
1332 }
1333 for child in &mut node.children {
1334 replace_entry_title(child, reference, title);
1335 }
1336}
1337
1338fn find_toctrees(node: &Node) -> Vec<Node> {
1341 let mut out = Vec::new();
1342 fn walk(node: &Node, out: &mut Vec<Node>) {
1343 for child in &node.children {
1344 if child.kind == kinds::TOCTREE {
1345 out.push(child.shallow_copy());
1346 }
1347 walk(child, out);
1348 }
1349 }
1350 walk(node, &mut out);
1351 out
1352}
1353
1354fn toctree_copy(node: &Node, depth: i64, maxdepth: i64) -> Node {
1357 let depth = (depth - 1).max(1);
1358 let mut copied = toctree_copy_seq(node, depth, maxdepth, true);
1359 debug_assert_eq!(copied.len(), 1);
1360 copied.pop().unwrap_or_else(|| node.shallow_copy())
1361}
1362
1363fn toctree_copy_seq(node: &Node, depth: i64, maxdepth: i64, initial_call: bool) -> Vec<Node> {
1366 match node.kind {
1367 kinds::COMPACT_PARAGRAPH | kinds::LIST_ITEM => {
1368 let mut copy = node.shallow_copy();
1369 for child in &node.children {
1370 copy.children
1371 .extend(toctree_copy_seq(child, depth, maxdepth, false));
1372 }
1373 vec![copy]
1374 }
1375 kinds::BULLET_LIST => {
1376 let keep = depth <= 1 || depth <= maxdepth || maxdepth <= 0;
1377 if !keep && !initial_call {
1378 return Vec::new();
1379 }
1380 let mut copy = node.shallow_copy();
1381 for child in &node.children {
1382 copy.children
1383 .extend(toctree_copy_seq(child, depth + 1, maxdepth, false));
1384 }
1385 vec![copy]
1386 }
1387 kinds::TOCTREE => vec![node.shallow_copy()],
1389 kinds::ONLY => node
1390 .children
1391 .iter()
1392 .flat_map(|child| toctree_copy_seq(child, depth, maxdepth, false))
1393 .collect(),
1394 _ => vec![node.clone()],
1397 }
1398}
1399
1400#[cfg(test)]
1401mod tests {
1402 use super::*;
1403 use crate::rst;
1404
1405 fn docs(names: &[&str]) -> BTreeSet<String> {
1406 names.iter().map(|s| (*s).to_string()).collect()
1407 }
1408
1409 fn parse(source: &str, docname: &str, found: &BTreeSet<String>) -> Doctree {
1410 rst::parse_rst(
1411 source,
1412 &rst::ParseOptions {
1413 source_path: "<snippet>".to_string(),
1414 sphinx: true,
1415 docname: docname.to_string(),
1416 exclude_patterns: Vec::new(),
1417 py: Default::default(),
1418 srcdir: None,
1419 found_docs: Some(std::sync::Arc::new(found.clone())),
1420 ..Default::default()
1421 },
1422 )
1423 }
1424
1425 #[test]
1426 fn empty_document_yields_empty_bullet_list() {
1427 let doctree = parse("", "index", &docs(&[]));
1428 let (toc, n) = build_toc(&doctree, "index");
1429 assert_eq!(toc.pformat(), "<bullet_list>\n");
1430 assert_eq!(n, 0);
1431 }
1432
1433 #[test]
1434 fn first_entry_has_empty_anchor_and_later_entries_use_ids() {
1435 let doctree = parse("A\n=\n\nSub\n---\n\nText.\n", "a", &docs(&["a"]));
1436 let (toc, n) = build_toc(&doctree, "a");
1437 assert_eq!(n, 2);
1438 assert_eq!(
1439 toc.pformat(),
1440 concat!(
1441 "<bullet_list>\n",
1442 " <list_item>\n",
1443 " <compact_paragraph>\n",
1444 " <reference anchorname=\"\" internal=\"1\" refuri=\"a\">\n",
1445 " A\n",
1446 " <bullet_list>\n",
1447 " <list_item>\n",
1448 " <compact_paragraph>\n",
1449 " <reference anchorname=\"#sub\" internal=\"1\" refuri=\"a\">\n",
1450 " Sub\n",
1451 )
1452 );
1453 }
1454
1455 #[test]
1462 fn object_entries_nest_under_the_nearest_description_that_has_one() {
1463 let doctree = parse(
1464 concat!(
1465 "A\n=\n\n",
1466 ".. confval:: outer\n\n Outer body.\n\n",
1467 " .. confval:: inner\n\n Inner body.\n\n",
1468 " .. confval:: deepest\n\n Deep body.\n\n",
1469 ".. confval:: sibling\n\n",
1470 ".. confval:: multi_a\n multi_b\n\n",
1471 " .. confval:: under_multi\n\n",
1472 ".. describe:: nothing\n\n .. confval:: under_describe\n",
1473 ),
1474 "a",
1475 &docs(&["a"]),
1476 );
1477 let (toc, n) = build_toc(&doctree, "a");
1478 assert_eq!(n, 9);
1479 assert_eq!(
1480 toc.pformat(),
1481 concat!(
1482 "<bullet_list>\n",
1483 " <list_item>\n",
1484 " <compact_paragraph>\n",
1485 " <reference anchorname=\"\" internal=\"1\" refuri=\"a\">\n",
1486 " A\n",
1487 " <bullet_list>\n",
1488 " <list_item>\n",
1489 " <compact_paragraph skip_section_number=\"1\">\n",
1490 " <reference anchorname=\"#confval-outer\" internal=\"1\" refuri=\"a\">\n",
1491 " <literal>\n",
1492 " outer\n",
1493 " <bullet_list>\n",
1494 " <list_item>\n",
1495 " <compact_paragraph skip_section_number=\"1\">\n",
1496 " <reference anchorname=\"#confval-inner\" internal=\"1\" refuri=\"a\">\n",
1497 " <literal>\n",
1498 " inner\n",
1499 " <bullet_list>\n",
1500 " <list_item>\n",
1501 " <compact_paragraph skip_section_number=\"1\">\n",
1502 " <reference anchorname=\"#confval-deepest\" internal=\"1\" refuri=\"a\">\n",
1503 " <literal>\n",
1504 " deepest\n",
1505 " <list_item>\n",
1506 " <compact_paragraph skip_section_number=\"1\">\n",
1507 " <reference anchorname=\"#confval-sibling\" internal=\"1\" refuri=\"a\">\n",
1508 " <literal>\n",
1509 " sibling\n",
1510 " <list_item>\n",
1511 " <compact_paragraph skip_section_number=\"1\">\n",
1512 " <reference anchorname=\"#confval-multi_a\" internal=\"1\" refuri=\"a\">\n",
1513 " <literal>\n",
1514 " multi_a\n",
1515 " <list_item>\n",
1516 " <compact_paragraph skip_section_number=\"1\">\n",
1517 " <reference anchorname=\"#confval-multi_b\" internal=\"1\" refuri=\"a\">\n",
1518 " <literal>\n",
1519 " multi_b\n",
1520 " <bullet_list>\n",
1521 " <list_item>\n",
1522 " <compact_paragraph skip_section_number=\"1\">\n",
1523 " <reference anchorname=\"#confval-under_multi\" internal=\"1\" refuri=\"a\">\n",
1524 " <literal>\n",
1525 " under_multi\n",
1526 " <list_item>\n",
1527 " <compact_paragraph skip_section_number=\"1\">\n",
1528 " <reference anchorname=\"#confval-under_describe\" internal=\"1\" refuri=\"a\">\n",
1529 " <literal>\n",
1530 " under_describe\n",
1531 )
1532 );
1533 }
1534
1535 #[test]
1539 fn object_entries_skip_unnamed_opted_out_and_id_less_signatures() {
1540 let doctree = parse(
1541 concat!(
1542 "A\n=\n\n",
1543 ".. envvar:: HOME\n\n",
1544 ".. confval:: hidden\n :no-contents-entry:\n\n",
1545 ".. confval:: unindexed\n :no-index:\n\n",
1546 ".. confval:: kept\n",
1547 ),
1548 "a",
1549 &docs(&["a"]),
1550 );
1551 let (toc, n) = build_toc(&doctree, "a");
1552 assert_eq!(n, 2);
1554 let out = toc.pformat();
1555 assert!(out.contains("#confval-kept"), "{out}");
1556 assert!(!out.contains("HOME"), "{out}");
1557 assert!(!out.contains("hidden"), "{out}");
1558 assert!(!out.contains("unindexed"), "{out}");
1559 }
1560
1561 #[test]
1562 fn title_inline_markup_survives_but_references_are_unwrapped() {
1563 let doctree = parse(
1564 "A `link <https://x/>`_ and *em* [#f]_\n=====================================\n\n.. [#f] note\n",
1565 "a",
1566 &docs(&["a"]),
1567 );
1568 let (toc, _) = build_toc(&doctree, "a");
1569 assert!(toc.pformat().contains("<emphasis>\n"), "{}", toc.pformat());
1572 assert!(!toc.pformat().contains("<reference anchorname=\"\" internal=\"1\" refuri=\"a\">\n A\n <reference"));
1573 assert!(
1574 !toc.pformat().contains("footnote_reference"),
1575 "{}",
1576 toc.pformat()
1577 );
1578 assert!(toc.pformat().contains("link"), "{}", toc.pformat());
1579 }
1580
1581 #[test]
1587 fn only_directive_wraps_its_entries() {
1588 let doctree = parse(
1589 ".. only:: html\n\n .. toctree::\n\n a\n",
1590 "index",
1591 &docs(&["index", "a"]),
1592 );
1593 let (toc, n) = build_toc(&doctree, "index");
1594 assert_eq!(n, 0, "a copied toctree is not a numbered entry");
1595 assert!(
1596 toc.pformat()
1597 .starts_with("<bullet_list>\n <only expr=\"html\">\n <toctree "),
1598 "{}",
1599 toc.pformat()
1600 );
1601
1602 let mut env = BuildEnvironment::default();
1603 for node in toctree_copies(&toc) {
1604 note_toctree(&mut env, "index", node);
1605 }
1606 assert_eq!(
1607 env.toctree_includes.get("index"),
1608 Some(&vec!["a".to_string()]),
1609 "a toctree inside `only` still contributes to the graph"
1610 );
1611 }
1612
1613 #[test]
1614 fn toctree_node_is_copied_into_the_toc_and_noted() {
1615 let found = docs(&["index", "a", "b"]);
1616 let doctree = parse(
1617 "Index\n=====\n\n.. toctree::\n\n a\n b\n",
1618 "index",
1619 &found,
1620 );
1621 let (toc, n) = build_toc(&doctree, "index");
1622 assert_eq!(n, 1, "the copied toctree is not an entry");
1623
1624 let mut env = BuildEnvironment::default();
1625 for node in toctree_copies(&toc) {
1626 note_toctree(&mut env, "index", node);
1627 }
1628 assert_eq!(
1629 env.toctree_includes.get("index"),
1630 Some(&vec!["a".to_string(), "b".to_string()])
1631 );
1632 assert_eq!(
1633 env.files_to_rebuild.get("a"),
1634 Some(&BTreeSet::from(["index".to_string()]))
1635 );
1636 assert!(env.glob_toctrees.is_empty());
1637 assert!(env.numbered_toctrees.is_empty());
1638 }
1639
1640 #[test]
1641 fn note_toctree_creates_the_includes_key_even_when_empty() {
1642 let mut env = BuildEnvironment::default();
1644 let mut toctree = Node::elem(kinds::TOCTREE, Span::ZERO);
1645 toctree.set("includefiles", AttrValue::List(vec![]));
1646 note_toctree(&mut env, "index", &toctree);
1647 assert_eq!(env.toctree_includes.get("index"), Some(&Vec::new()));
1648 assert!(env.files_to_rebuild.is_empty());
1649 }
1650
1651 #[test]
1652 fn glob_and_numbered_flags_reach_the_environment() {
1653 let mut env = BuildEnvironment::default();
1654 let mut toctree = Node::elem(kinds::TOCTREE, Span::ZERO);
1655 toctree.set("glob", AttrValue::Int(1));
1656 toctree.set("numbered", AttrValue::Int(999));
1657 toctree.set("includefiles", AttrValue::List(vec!["a".into()]));
1658 note_toctree(&mut env, "index", &toctree);
1659 assert!(env.glob_toctrees.contains("index"));
1660 assert!(env.numbered_toctrees.contains("index"));
1661 }
1662
1663 fn content<'a>(
1665 lines: &'a [String],
1666 docname: &'a str,
1667 found: &'a BTreeSet<String>,
1668 ) -> ToctreeContent<'a> {
1669 ToctreeContent {
1670 content: lines,
1671 docname,
1672 glob: false,
1673 reversed: false,
1674 source: 0,
1675 line: 1,
1676 found_docs: found,
1677 source_suffixes: &[".rst"],
1678 exclude_patterns: &[],
1679 }
1680 }
1681
1682 fn lines(entries: &[&str]) -> Vec<String> {
1683 entries.iter().map(|s| (*s).to_string()).collect()
1684 }
1685
1686 #[test]
1687 fn entries_resolve_relative_absolute_and_self_targets() {
1688 let found = docs(&["index", "sub/b", "sub/c", "a"]);
1689 let entries = lines(&[
1690 "c",
1691 "/a",
1692 "self",
1693 "https://example.invalid/x",
1694 "missing",
1695 "sub/b",
1696 ]);
1697 let resolved = resolve_entries(&content(&entries, "sub/b", &found));
1698 assert_eq!(
1699 resolved.entries,
1700 vec![
1701 (None, "sub/c".to_string()),
1702 (None, "a".to_string()),
1703 (None, "self".to_string()),
1704 (None, "https://example.invalid/x".to_string()),
1705 ],
1706 "missing docs drop; `sub/b` is the current document, which is not \
1707 a candidate for its own toctree"
1708 );
1709 assert_eq!(
1710 resolved.includefiles,
1711 vec!["sub/c".to_string(), "a".to_string()]
1712 );
1713 assert_eq!(
1714 resolved
1715 .warnings
1716 .iter()
1717 .map(|w| w.message.as_str())
1718 .collect::<Vec<_>>(),
1719 vec![
1720 "toctree contains reference to nonexisting document 'sub/missing'",
1721 "toctree contains reference to nonexisting document 'sub/sub/b'",
1722 ],
1723 "sphinx reports the *joined* docname, so a document-relative miss \
1724 names the directory it was resolved against"
1725 );
1726 }
1727
1728 #[test]
1734 fn an_entry_naming_its_own_document_is_reported_as_nonexisting() {
1735 let found = docs(&["index", "a"]);
1736 let entries = lines(&["index", "a"]);
1737 let resolved = resolve_entries(&content(&entries, "index", &found));
1738 assert_eq!(resolved.includefiles, vec!["a".to_string()]);
1739 assert_eq!(
1740 resolved
1741 .warnings
1742 .iter()
1743 .map(|w| (w.message.as_str(), w.category.as_deref()))
1744 .collect::<Vec<_>>(),
1745 vec![(
1746 "toctree contains reference to nonexisting document 'index'",
1747 Some("toc.not_readable")
1748 )]
1749 );
1750 }
1751
1752 #[test]
1755 fn diagnostics_are_located_at_the_directive() {
1756 let found = docs(&["index"]);
1757 let entries = lines(&["missing"]);
1758 let resolved = resolve_entries(&ToctreeContent {
1759 line: 12,
1760 ..content(&entries, "index", &found)
1761 });
1762 assert_eq!(resolved.warnings.len(), 1);
1763 assert_eq!(resolved.warnings[0].line, 12);
1764 assert_eq!(
1765 resolved.warnings[0].category.as_deref(),
1766 Some("toc.not_readable")
1767 );
1768 }
1769
1770 #[test]
1775 fn excluded_targets_get_their_own_message() {
1776 let found = docs(&["index"]);
1777 let excluded = vec!["drafts/*".to_string()];
1778 let entries = lines(&["drafts/wip"]);
1779 let resolved = resolve_entries(&ToctreeContent {
1780 exclude_patterns: &excluded,
1781 ..content(&entries, "index", &found)
1782 });
1783 assert_eq!(
1784 resolved.warnings[0].message,
1785 "toctree contains reference to excluded document 'drafts/wip'"
1786 );
1787 assert_eq!(
1788 resolved.warnings[0].category.as_deref(),
1789 Some("toc.excluded")
1790 );
1791 }
1792
1793 #[test]
1794 fn a_document_claimed_twice_warns_but_is_still_listed() {
1795 let found = docs(&["index", "a"]);
1796 let entries = lines(&["a", "a"]);
1797 let resolved = resolve_entries(&content(&entries, "index", &found));
1798 assert_eq!(
1799 resolved.includefiles,
1800 vec!["a".to_string(), "a".to_string()],
1801 "sphinx appends the duplicate either way"
1802 );
1803 assert_eq!(
1804 resolved.warnings[0].message,
1805 "duplicated entry found in toctree: a"
1806 );
1807 assert_eq!(
1808 resolved.warnings[0].category.as_deref(),
1809 Some("toc.duplicate_entry")
1810 );
1811 }
1812
1813 #[test]
1819 fn the_virtual_docnames_are_the_dict_keys_not_its_values() {
1820 let found = docs(&["index"]);
1821 let entries = lines(&["genindex", "modindex", "search"]);
1822 let resolved = resolve_entries(&content(&entries, "index", &found));
1823 assert_eq!(
1824 resolved.includefiles,
1825 vec![
1826 "genindex".to_string(),
1827 "modindex".to_string(),
1828 "search".to_string()
1829 ]
1830 );
1831 assert!(resolved.warnings.is_empty(), "{:?}", resolved.warnings);
1832
1833 let entries = lines(&["py-modindex"]);
1834 let resolved = resolve_entries(&content(&entries, "index", &found));
1835 assert!(resolved.includefiles.is_empty());
1836 assert_eq!(
1837 resolved
1838 .warnings
1839 .iter()
1840 .map(|w| w.message.as_str())
1841 .collect::<Vec<_>>(),
1842 vec!["toctree contains reference to nonexisting document 'py-modindex'"]
1843 );
1844 }
1845
1846 #[test]
1847 fn glob_entries_expand_sorted_and_skip_virtual_docs() {
1848 let found = docs(&["index", "pages/a", "pages/b"]);
1849 let entries = lines(&["pages/*"]);
1850 let resolved = resolve_entries(&ToctreeContent {
1851 glob: true,
1852 ..content(&entries, "index", &found)
1853 });
1854 assert_eq!(
1855 resolved.includefiles,
1856 vec!["pages/a".to_string(), "pages/b".to_string()]
1857 );
1858 assert_eq!(
1859 resolved.entries_attr(),
1860 AttrValue::List(vec![
1861 "(None, 'pages/a')".to_string(),
1862 "(None, 'pages/b')".to_string()
1863 ])
1864 );
1865 assert!(resolved.warnings.is_empty());
1866 }
1867
1868 #[test]
1872 fn a_glob_that_matches_nothing_warns() {
1873 let found = docs(&["index", "pages/a"]);
1874 let entries = lines(&["missing*"]);
1875 let resolved = resolve_entries(&ToctreeContent {
1876 glob: true,
1877 ..content(&entries, "index", &found)
1878 });
1879 assert!(resolved.includefiles.is_empty());
1880 assert_eq!(
1881 resolved.warnings,
1882 vec![ToctreeWarning {
1883 source: 0,
1884 line: 1,
1885 message: "toctree glob pattern 'missing*' didn't match any documents".to_string(),
1886 category: None,
1887 kind: ToctreeWarningKind::EmptyGlob,
1888 }]
1889 );
1890 }
1891
1892 #[test]
1895 fn an_uncompilable_glob_pattern_is_reported_as_such() {
1896 let found = docs(&["index", "a"]);
1897 let entries = lines(&["[z-a]*"]);
1900 let resolved = resolve_entries(&ToctreeContent {
1901 glob: true,
1902 ..content(&entries, "index", &found)
1903 });
1904 assert_eq!(resolved.warnings.len(), 1, "{:?}", resolved.warnings);
1905 assert_eq!(resolved.warnings[0].kind, ToctreeWarningKind::PatternError);
1906 assert!(
1907 resolved.warnings[0]
1908 .message
1909 .starts_with("toctree glob pattern '[z-a]*' is not usable:"),
1910 "{}",
1911 resolved.warnings[0].message
1912 );
1913 }
1914
1915 #[test]
1916 fn explicit_titles_and_suffixes() {
1917 let found = docs(&["index", "other"]);
1918 let entries = lines(&["Linked <other.rst>", "<foo>"]);
1919 let resolved = resolve_entries(&content(&entries, "index", &found));
1920 assert_eq!(
1921 resolved.entries,
1922 vec![(Some("Linked".to_string()), "other".to_string())],
1923 "`<foo>` is a literal (missing) target, not an empty title"
1924 );
1925 assert_eq!(
1926 resolved.entries_attr(),
1927 AttrValue::List(vec!["('Linked', 'other')".to_string()])
1928 );
1929 assert_eq!(
1930 resolved.warnings[0].message,
1931 "toctree contains reference to nonexisting document '<foo>'"
1932 );
1933 }
1934
1935 #[test]
1936 fn reversed_flips_both_lists() {
1937 let found = docs(&["index", "a", "b"]);
1938 let entries = lines(&["a", "b"]);
1939 let resolved = resolve_entries(&ToctreeContent {
1940 reversed: true,
1941 ..content(&entries, "index", &found)
1942 });
1943 assert_eq!(
1944 resolved.includefiles,
1945 vec!["b".to_string(), "a".to_string()]
1946 );
1947 }
1948
1949 #[test]
1953 fn url_detection_matches_the_backtracking_regex() {
1954 assert!(is_url("https://example.invalid/x"));
1955 assert!(is_url("a://b"));
1956 assert!(!is_url("://leading"));
1957 assert!(
1958 is_url("://a://b"),
1959 "the second `://` has a non-empty schema before it"
1960 );
1961 assert!(!is_url("plain/docname"));
1962 }
1963
1964 #[test]
1965 fn virtual_docnames_are_valid_entries() {
1966 let found = docs(&["index"]);
1967 let entries = lines(&["genindex"]);
1968 let resolved = resolve_entries(&content(&entries, "index", &found));
1969 assert_eq!(resolved.includefiles, vec!["genindex".to_string()]);
1970 assert!(resolved.warnings.is_empty());
1971 }
1972
1973 fn graph(root: &str, includes: &[(&str, &[&str])]) -> BuildEnvironment {
1980 let mut env = BuildEnvironment {
1981 root_doc: root.to_string(),
1982 ..Default::default()
1983 };
1984 for (container, children) in includes {
1985 let children: Vec<String> = children.iter().map(|c| (*c).to_string()).collect();
1986 for child in &children {
1987 env.files_to_rebuild
1988 .entry(child.clone())
1989 .or_default()
1990 .insert((*container).to_string());
1991 }
1992 env.toctree_includes
1993 .insert((*container).to_string(), children);
1994 }
1995 for docname in env
1996 .toctree_includes
1997 .keys()
1998 .cloned()
1999 .chain(env.files_to_rebuild.keys().cloned())
2000 .collect::<Vec<_>>()
2001 {
2002 env.all_docs.insert(docname, 0);
2003 }
2004 env.all_docs.insert(root.to_string(), 0);
2005 env
2006 }
2007
2008 fn relation(env: &BuildEnvironment, docname: &str) -> (String, String, String) {
2009 let show = |value: &Option<String>| value.clone().unwrap_or_else(|| "-".to_string());
2010 let (parent, prev, next) = collect_relations(env)[docname].clone();
2011 (show(&parent), show(&prev), show(&next))
2012 }
2013
2014 #[test]
2018 fn relations_chain_the_preorder_walk() {
2019 let env = graph("index", &[("index", &["a", "b"]), ("a", &["a1", "a2"])]);
2020 assert_eq!(
2021 relation(&env, "index"),
2022 ("-".into(), "-".into(), "a".into())
2023 );
2024 assert_eq!(
2025 relation(&env, "a"),
2026 ("index".into(), "index".into(), "a1".into())
2027 );
2028 assert_eq!(relation(&env, "a1"), ("a".into(), "a".into(), "a2".into()));
2029 assert_eq!(relation(&env, "a2"), ("a".into(), "a1".into(), "b".into()));
2030 assert_eq!(
2031 relation(&env, "b"),
2032 ("index".into(), "a2".into(), "-".into())
2033 );
2034 }
2035
2036 #[test]
2040 fn a_document_with_two_parents_keeps_the_first_visit() {
2041 let env = graph(
2042 "index",
2043 &[("index", &["a", "b"]), ("a", &["c"]), ("b", &["c"])],
2044 );
2045 assert_eq!(relation(&env, "c"), ("a".into(), "a".into(), "b".into()));
2046 assert_eq!(
2047 relation(&env, "b"),
2048 ("index".into(), "c".into(), "-".into())
2049 );
2050 }
2051
2052 #[test]
2053 fn a_project_without_toctrees_relates_only_its_root() {
2054 let env = graph("index", &[]);
2055 assert_eq!(
2056 relation(&env, "index"),
2057 ("-".into(), "-".into(), "-".into())
2058 );
2059 assert_eq!(collect_relations(&env).len(), 1);
2060 }
2061
2062 #[test]
2065 fn a_mutual_cycle_terminates_instead_of_recursing_forever() {
2066 let env = graph("index", &[("index", &["a"]), ("a", &["b"]), ("b", &["a"])]);
2067 let relations = collect_relations(&env);
2068 assert_eq!(
2069 relations.keys().collect::<Vec<_>>(),
2070 vec!["a", "b", "index"],
2071 "every document is still reached exactly once"
2072 );
2073 assert_eq!(relation(&env, "b"), ("a".into(), "a".into(), "-".into()));
2074 }
2075
2076 #[test]
2080 fn a_self_parenting_toctree_drops_its_subtree() {
2081 let env = graph("index", &[("index", &["a"]), ("a", &["a"])]);
2082 let relations = collect_relations(&env);
2083 assert_eq!(relations.keys().collect::<Vec<_>>(), vec!["a", "index"]);
2084 }
2085
2086 #[test]
2087 fn ancestors_walk_up_to_the_root_and_stop_on_cycles() {
2088 let includes = graph("index", &[("index", &["a"]), ("a", &["b"])]).toctree_includes;
2089 assert_eq!(toctree_ancestors(&includes, "b"), vec!["b", "a"]);
2090 assert_eq!(
2091 toctree_ancestors(&includes, "index"),
2092 Vec::<String>::new(),
2093 "a document with no toctree parent has no ancestors, not even itself"
2094 );
2095
2096 let cyclic = graph("index", &[("a", &["b"]), ("b", &["a"])]).toctree_includes;
2097 assert_eq!(toctree_ancestors(&cyclic, "a"), vec!["a", "b"]);
2098 }
2099
2100 #[test]
2101 fn documents_no_toctree_reaches_are_reported_as_orphans() {
2102 let mut env = graph("index", &[("index", &["a"])]);
2103 env.all_docs.insert("stray".to_string(), 0);
2104 env.all_docs.insert("textually_included".to_string(), 0);
2105 env.all_docs.insert("marked".to_string(), 0);
2106 env.included.insert(
2107 "a".to_string(),
2108 BTreeSet::from(["textually_included".to_string()]),
2109 );
2110 env.metadata.insert(
2111 "marked".to_string(),
2112 BTreeMap::from([("orphan".to_string(), String::new())]),
2113 );
2114
2115 let messages = check_consistency(&env, &|_| true);
2116 assert_eq!(
2117 messages
2118 .iter()
2119 .map(|m| (m.docname.as_str(), m.level))
2120 .collect::<Vec<_>>(),
2121 vec![("stray", ConsistencyLevel::Warning)],
2122 "the root, toctree'd, textually included and `:orphan:` \
2123 documents are all exempt; {messages:?}"
2124 );
2125 assert_eq!(
2126 messages[0].message,
2127 "document isn't included in any toctree"
2128 );
2129 assert_eq!(messages[0].category.as_deref(), Some("toc.not_included"));
2130 }
2131
2132 #[test]
2135 fn several_toctree_parents_is_informational() {
2136 let env = graph(
2137 "index",
2138 &[("index", &["a", "b"]), ("a", &["c"]), ("b", &["c"])],
2139 );
2140 let messages = check_consistency(&env, &|_| true);
2141 assert_eq!(messages.len(), 1, "{messages:?}");
2142 assert_eq!(messages[0].level, ConsistencyLevel::Info);
2143 assert_eq!(messages[0].docname, "c");
2144 assert_eq!(
2145 messages[0].message,
2146 "document is referenced in multiple toctrees: ['a', 'b'], selecting: b <- c"
2147 );
2148 }
2149
2150 #[test]
2151 fn docname_join_normalizes() {
2152 assert_eq!(docname_join("sub/b", "c"), "sub/c");
2153 assert_eq!(docname_join("sub/b", "/a"), "a");
2154 assert_eq!(docname_join("sub/b", "../a"), "a");
2155 assert_eq!(docname_join("index", "a"), "a");
2156 }
2157
2158 #[test]
2159 fn document_title_filters_like_a_toc_entry() {
2160 let doctree = parse("A *b* c\n=======\n\nText.\n", "a", &docs(&["a"]));
2161 assert_eq!(
2162 document_title(&doctree).pformat(),
2163 "<title>\n A \n <emphasis>\n b\n c\n"
2164 );
2165 }
2166
2167 #[test]
2168 fn document_title_without_a_section_says_no_title() {
2169 let doctree = parse("Just a paragraph.\n", "a", &docs(&["a"]));
2170 assert_eq!(
2171 document_title(&doctree).pformat(),
2172 "<title>\n <no title>\n"
2173 );
2174 }
2175
2176 #[test]
2177 fn toctree_entries_read_back_what_entries_attr_wrote() {
2178 let entries = ResolvedEntries {
2179 entries: vec![
2180 (None, "intro".to_string()),
2181 (Some("Custom <x>".to_string()), "api/it's".to_string()),
2182 (
2183 Some("say \"hi\"".to_string()),
2184 "https://example.org/a?b=c".to_string(),
2185 ),
2186 (Some("tab\there".to_string()), "self".to_string()),
2187 ],
2188 ..Default::default()
2189 };
2190 let mut toctree = Node::elem(kinds::TOCTREE, Span::ZERO);
2191 toctree.set("entries", entries.entries_attr());
2192 assert_eq!(toctree_entries(&toctree), entries.entries);
2193 }
2194
2195 #[test]
2199 fn resolve_toctree_expands_like_sphinx() {
2200 let found = docs(&["index", "child", "leaf"]);
2201 let mut env = BuildEnvironment::default();
2202 let sources = [
2203 ("index", "Root\n====\n\n.. toctree::\n :maxdepth: 2\n :caption: Contents\n\n child\n https://example.org\n Explicit <leaf>\n"),
2204 ("child", "Child\n=====\n\nChild Section\n-------------\n\nChild Sub\n~~~~~~~~~\n\n.. toctree::\n\n leaf\n"),
2205 ("leaf", "Leaf\n====\n\nLeaf Section\n------------\n"),
2206 ];
2207 let mut doctrees = BTreeMap::new();
2208 for (docname, source) in sources {
2209 let doctree = parse(source, docname, &found);
2210 env.titles
2211 .insert(docname.to_string(), document_title(&doctree));
2212 let (toc, _) = build_toc(&doctree, docname);
2213 for toctree in toctree_copies(&toc) {
2214 note_toctree(&mut env, docname, toctree);
2215 }
2216 env.tocs.insert(docname.to_string(), toc);
2217 doctrees.insert(docname, doctree);
2218 }
2219
2220 let toctree = find_toctrees(&doctrees["index"].root)
2221 .into_iter()
2222 .next()
2223 .expect("index has a toctree");
2224 let resolved = resolve_toctree(&env, "index", &toctree).expect("renders");
2226 assert_eq!(
2227 resolved.pformat(),
2228 concat!(
2229 "<compact_paragraph toctree=\"1\">\n",
2230 " <title>\n",
2231 " Contents\n",
2232 " <bullet_list>\n",
2233 " <list_item>\n",
2234 " <compact_paragraph>\n",
2235 " <reference anchorname=\"\" internal=\"1\" refuri=\"child\">\n",
2236 " Child\n",
2237 " <bullet_list>\n",
2238 " <list_item>\n",
2239 " <compact_paragraph>\n",
2240 " <reference anchorname=\"#child-section\" internal=\"1\" refuri=\"child\">\n",
2241 " Child Section\n",
2242 " <list_item>\n",
2243 " <compact_paragraph>\n",
2244 " <reference anchorname=\"\" refuri=\"https://example.org\">\n",
2245 " https://example.org\n",
2246 " <list_item>\n",
2247 " <compact_paragraph>\n",
2248 " <reference anchorname=\"\" internal=\"1\" refuri=\"leaf\">\n",
2249 " Explicit\n",
2250 " <bullet_list>\n",
2251 " <list_item>\n",
2252 " <compact_paragraph>\n",
2253 " <reference anchorname=\"#leaf-section\" internal=\"1\" refuri=\"leaf\">\n",
2254 " Leaf Section\n",
2255 )
2256 );
2257 let mut hidden = toctree.clone();
2264 hidden.set("hidden", AttrValue::Int(1));
2265 assert!(resolve_toctree(&env, "index", &hidden).is_none());
2266
2267 let mut titles_only = toctree.clone();
2269 titles_only.set("titlesonly", AttrValue::Int(1));
2270 let resolved = resolve_toctree(&env, "index", &titles_only).expect("renders");
2271 let text = resolved.astext();
2272 assert!(text.contains("Child") && text.contains("Leaf"));
2273 assert!(!text.contains("Child Section"));
2274 }
2275}