summaryrefslogtreecommitdiffstats
path: root/sphinx/environment/adapters/toctree.py
diff options
context:
space:
mode:
Diffstat (limited to 'sphinx/environment/adapters/toctree.py')
-rw-r--r--sphinx/environment/adapters/toctree.py520
1 files changed, 520 insertions, 0 deletions
diff --git a/sphinx/environment/adapters/toctree.py b/sphinx/environment/adapters/toctree.py
new file mode 100644
index 0000000..e50d10b
--- /dev/null
+++ b/sphinx/environment/adapters/toctree.py
@@ -0,0 +1,520 @@
+"""Toctree adapter for sphinx.environment."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, TypeVar
+
+from docutils import nodes
+from docutils.nodes import Element, Node
+
+from sphinx import addnodes
+from sphinx.locale import __
+from sphinx.util import logging, url_re
+from sphinx.util.matching import Matcher
+from sphinx.util.nodes import _only_node_keep_children, clean_astext
+
+if TYPE_CHECKING:
+ from collections.abc import Iterable, Set
+
+ from sphinx.builders import Builder
+ from sphinx.environment import BuildEnvironment
+ from sphinx.util.tags import Tags
+
+
+logger = logging.getLogger(__name__)
+
+
+def note_toctree(env: BuildEnvironment, docname: str, toctreenode: addnodes.toctree) -> None:
+ """Note a TOC tree directive in a document and gather information about
+ file relations from it.
+ """
+ if toctreenode['glob']:
+ env.glob_toctrees.add(docname)
+ if toctreenode.get('numbered'):
+ env.numbered_toctrees.add(docname)
+ include_files = toctreenode['includefiles']
+ for include_file in include_files:
+ # note that if the included file is rebuilt, this one must be
+ # too (since the TOC of the included file could have changed)
+ env.files_to_rebuild.setdefault(include_file, set()).add(docname)
+ env.toctree_includes.setdefault(docname, []).extend(include_files)
+
+
+def document_toc(env: BuildEnvironment, docname: str, tags: Tags) -> Node:
+ """Get the (local) table of contents for a document.
+
+ Note that this is only the sections within the document.
+ For a ToC tree that shows the document's place in the
+ ToC structure, use `get_toctree_for`.
+ """
+
+ tocdepth = env.metadata[docname].get('tocdepth', 0)
+ try:
+ toc = _toctree_copy(env.tocs[docname], 2, tocdepth, False, tags)
+ except KeyError:
+ # the document does not exist any more:
+ # return a dummy node that renders to nothing
+ return nodes.paragraph()
+
+ for node in toc.findall(nodes.reference):
+ node['refuri'] = node['anchorname'] or '#'
+ return toc
+
+
+def global_toctree_for_doc(
+ env: BuildEnvironment,
+ docname: str,
+ builder: Builder,
+ collapse: bool = False,
+ includehidden: bool = True,
+ maxdepth: int = 0,
+ titles_only: bool = False,
+) -> Element | None:
+ """Get the global ToC tree at a given document.
+
+ This gives the global ToC, with all ancestors and their siblings.
+ """
+
+ toctrees: list[Element] = []
+ for toctree_node in env.master_doctree.findall(addnodes.toctree):
+ if toctree := _resolve_toctree(
+ env,
+ docname,
+ builder,
+ toctree_node,
+ prune=True,
+ maxdepth=int(maxdepth),
+ titles_only=titles_only,
+ collapse=collapse,
+ includehidden=includehidden,
+ ):
+ toctrees.append(toctree)
+ if not toctrees:
+ return None
+ result = toctrees[0]
+ for toctree in toctrees[1:]:
+ result.extend(toctree.children)
+ return result
+
+
+def _resolve_toctree(
+ env: BuildEnvironment, docname: str, builder: Builder, toctree: addnodes.toctree, *,
+ prune: bool = True, maxdepth: int = 0, titles_only: bool = False,
+ collapse: bool = False, includehidden: bool = False,
+) -> Element | None:
+ """Resolve a *toctree* node into individual bullet lists with titles
+ as items, returning None (if no containing titles are found) or
+ a new node.
+
+ If *prune* is True, the tree is pruned to *maxdepth*, or if that is 0,
+ to the value of the *maxdepth* option on the *toctree* node.
+ If *titles_only* is True, only toplevel document titles will be in the
+ resulting tree.
+ If *collapse* is True, all branches not containing docname will
+ be collapsed.
+ """
+
+ if toctree.get('hidden', False) and not includehidden:
+ return None
+
+ # For reading the following two helper function, it is useful to keep
+ # in mind the node structure of a toctree (using HTML-like node names
+ # for brevity):
+ #
+ # <ul>
+ # <li>
+ # <p><a></p>
+ # <p><a></p>
+ # ...
+ # <ul>
+ # ...
+ # </ul>
+ # </li>
+ # </ul>
+ #
+ # The transformation is made in two passes in order to avoid
+ # interactions between marking and pruning the tree (see bug #1046).
+
+ toctree_ancestors = _get_toctree_ancestors(env.toctree_includes, docname)
+ included = Matcher(env.config.include_patterns)
+ excluded = Matcher(env.config.exclude_patterns)
+
+ maxdepth = maxdepth or toctree.get('maxdepth', -1)
+ if not titles_only and toctree.get('titlesonly', False):
+ titles_only = True
+ if not includehidden and toctree.get('includehidden', False):
+ includehidden = True
+
+ tocentries = _entries_from_toctree(
+ env,
+ prune,
+ titles_only,
+ collapse,
+ includehidden,
+ builder.tags,
+ toctree_ancestors,
+ included,
+ excluded,
+ toctree,
+ [],
+ )
+ if not tocentries:
+ return None
+
+ newnode = addnodes.compact_paragraph('', '')
+ if caption := toctree.attributes.get('caption'):
+ caption_node = nodes.title(caption, '', *[nodes.Text(caption)])
+ caption_node.line = toctree.line
+ caption_node.source = toctree.source
+ caption_node.rawsource = toctree['rawcaption']
+ if hasattr(toctree, 'uid'):
+ # move uid to caption_node to translate it
+ caption_node.uid = toctree.uid # type: ignore[attr-defined]
+ del toctree.uid
+ newnode.append(caption_node)
+ newnode.extend(tocentries)
+ newnode['toctree'] = True
+
+ # prune the tree to maxdepth, also set toc depth and current classes
+ _toctree_add_classes(newnode, 1, docname)
+ newnode = _toctree_copy(newnode, 1, maxdepth if prune else 0, collapse, builder.tags)
+
+ if isinstance(newnode[-1], nodes.Element) and len(newnode[-1]) == 0: # No titles found
+ return None
+
+ # set the target paths in the toctrees (they are not known at TOC
+ # generation time)
+ for refnode in newnode.findall(nodes.reference):
+ if url_re.match(refnode['refuri']) is None:
+ rel_uri = builder.get_relative_uri(docname, refnode['refuri'])
+ refnode['refuri'] = rel_uri + refnode['anchorname']
+ return newnode
+
+
+def _entries_from_toctree(
+ env: BuildEnvironment,
+ prune: bool,
+ titles_only: bool,
+ collapse: bool,
+ includehidden: bool,
+ tags: Tags,
+ toctree_ancestors: Set[str],
+ included: Matcher,
+ excluded: Matcher,
+ toctreenode: addnodes.toctree,
+ parents: list[str],
+ subtree: bool = False,
+) -> list[Element]:
+ """Return TOC entries for a toctree node."""
+ entries: list[Element] = []
+ for (title, ref) in toctreenode['entries']:
+ try:
+ toc, refdoc = _toctree_entry(
+ title, ref, env, prune, collapse, tags, toctree_ancestors,
+ included, excluded, toctreenode, parents,
+ )
+ except LookupError:
+ continue
+
+ # children of toc are:
+ # - list_item + compact_paragraph + (reference and subtoc)
+ # - only + subtoc
+ # - toctree
+ children: Iterable[nodes.Element] = toc.children # type: ignore[assignment]
+
+ # if titles_only is given, only keep the main title and
+ # sub-toctrees
+ if titles_only:
+ # delete everything but the toplevel title(s)
+ # and toctrees
+ for top_level in children:
+ # nodes with length 1 don't have any children anyway
+ if len(top_level) > 1:
+ if subtrees := list(top_level.findall(addnodes.toctree)):
+ top_level[1][:] = subtrees # type: ignore[index]
+ else:
+ top_level.pop(1)
+ # resolve all sub-toctrees
+ for sub_toc_node in list(toc.findall(addnodes.toctree)):
+ if sub_toc_node.get('hidden', False) and not includehidden:
+ continue
+ for i, entry in enumerate(
+ _entries_from_toctree(
+ env,
+ prune,
+ titles_only,
+ collapse,
+ includehidden,
+ tags,
+ toctree_ancestors,
+ included,
+ excluded,
+ sub_toc_node,
+ [refdoc] + parents,
+ subtree=True,
+ ),
+ start=sub_toc_node.parent.index(sub_toc_node) + 1,
+ ):
+ sub_toc_node.parent.insert(i, entry)
+ sub_toc_node.parent.remove(sub_toc_node)
+
+ entries.extend(children)
+
+ if not subtree:
+ ret = nodes.bullet_list()
+ ret += entries
+ return [ret]
+
+ return entries
+
+
+def _toctree_entry(
+ title: str,
+ ref: str,
+ env: BuildEnvironment,
+ prune: bool,
+ collapse: bool,
+ tags: Tags,
+ toctree_ancestors: Set[str],
+ included: Matcher,
+ excluded: Matcher,
+ toctreenode: addnodes.toctree,
+ parents: list[str],
+) -> tuple[Element, str]:
+ from sphinx.domains.std import StandardDomain
+
+ try:
+ refdoc = ''
+ if url_re.match(ref):
+ toc = _toctree_url_entry(title, ref)
+ elif ref == 'self':
+ toc = _toctree_self_entry(title, toctreenode['parent'], env.titles)
+ elif ref in StandardDomain._virtual_doc_names:
+ toc = _toctree_generated_entry(title, ref)
+ else:
+ if ref in parents:
+ logger.warning(__('circular toctree references '
+ 'detected, ignoring: %s <- %s'),
+ ref, ' <- '.join(parents),
+ location=ref, type='toc', subtype='circular')
+ msg = 'circular reference'
+ raise LookupError(msg)
+
+ toc, refdoc = _toctree_standard_entry(
+ title,
+ ref,
+ env.metadata[ref].get('tocdepth', 0),
+ env.tocs[ref],
+ toctree_ancestors,
+ prune,
+ collapse,
+ tags,
+ )
+
+ if not toc.children:
+ # empty toc means: no titles will show up in the toctree
+ logger.warning(__('toctree contains reference to document %r that '
+ "doesn't have a title: no link will be generated"),
+ ref, location=toctreenode)
+ except KeyError:
+ # this is raised if the included file does not exist
+ ref_path = env.doc2path(ref, False)
+ if excluded(ref_path):
+ message = __('toctree contains reference to excluded document %r')
+ elif not included(ref_path):
+ message = __('toctree contains reference to non-included document %r')
+ else:
+ message = __('toctree contains reference to nonexisting document %r')
+
+ logger.warning(message, ref, location=toctreenode)
+ raise
+ return toc, refdoc
+
+
+def _toctree_url_entry(title: str, ref: str) -> nodes.bullet_list:
+ if title is None:
+ title = ref
+ reference = nodes.reference('', '', internal=False,
+ refuri=ref, anchorname='',
+ *[nodes.Text(title)])
+ para = addnodes.compact_paragraph('', '', reference)
+ item = nodes.list_item('', para)
+ toc = nodes.bullet_list('', item)
+ return toc
+
+
+def _toctree_self_entry(
+ title: str, ref: str, titles: dict[str, nodes.title],
+) -> nodes.bullet_list:
+ # 'self' refers to the document from which this
+ # toctree originates
+ if not title:
+ title = clean_astext(titles[ref])
+ reference = nodes.reference('', '', internal=True,
+ refuri=ref,
+ anchorname='',
+ *[nodes.Text(title)])
+ para = addnodes.compact_paragraph('', '', reference)
+ item = nodes.list_item('', para)
+ # don't show subitems
+ toc = nodes.bullet_list('', item)
+ return toc
+
+
+def _toctree_generated_entry(title: str, ref: str) -> nodes.bullet_list:
+ from sphinx.domains.std import StandardDomain
+
+ docname, sectionname = StandardDomain._virtual_doc_names[ref]
+ if not title:
+ title = sectionname
+ reference = nodes.reference('', title, internal=True,
+ refuri=docname, anchorname='')
+ para = addnodes.compact_paragraph('', '', reference)
+ item = nodes.list_item('', para)
+ # don't show subitems
+ toc = nodes.bullet_list('', item)
+ return toc
+
+
+def _toctree_standard_entry(
+ title: str,
+ ref: str,
+ maxdepth: int,
+ toc: nodes.bullet_list,
+ toctree_ancestors: Set[str],
+ prune: bool,
+ collapse: bool,
+ tags: Tags,
+) -> tuple[nodes.bullet_list, str]:
+ refdoc = ref
+ if ref in toctree_ancestors and (not prune or maxdepth <= 0):
+ toc = toc.deepcopy()
+ else:
+ toc = _toctree_copy(toc, 2, maxdepth, collapse, tags)
+
+ if title and toc.children and len(toc.children) == 1:
+ child = toc.children[0]
+ for refnode in child.findall(nodes.reference):
+ if refnode['refuri'] == ref and not refnode['anchorname']:
+ refnode.children[:] = [nodes.Text(title)]
+ return toc, refdoc
+
+
+def _toctree_add_classes(node: Element, depth: int, docname: str) -> None:
+ """Add 'toctree-l%d' and 'current' classes to the toctree."""
+ for subnode in node.children:
+ if isinstance(subnode, (addnodes.compact_paragraph, nodes.list_item)):
+ # for <p> and <li>, indicate the depth level and recurse
+ subnode['classes'].append(f'toctree-l{depth - 1}')
+ _toctree_add_classes(subnode, depth, docname)
+ elif isinstance(subnode, nodes.bullet_list):
+ # for <ul>, just recurse
+ _toctree_add_classes(subnode, depth + 1, docname)
+ elif isinstance(subnode, nodes.reference):
+ # for <a>, identify which entries point to the current
+ # document and therefore may not be collapsed
+ if subnode['refuri'] == docname:
+ if not subnode['anchorname']:
+ # give the whole branch a 'current' class
+ # (useful for styling it differently)
+ branchnode: Element = subnode
+ while branchnode:
+ branchnode['classes'].append('current')
+ branchnode = branchnode.parent
+ # mark the list_item as "on current page"
+ if subnode.parent.parent.get('iscurrent'):
+ # but only if it's not already done
+ return
+ while subnode:
+ subnode['iscurrent'] = True
+ subnode = subnode.parent
+
+
+ET = TypeVar('ET', bound=Element)
+
+
+def _toctree_copy(node: ET, depth: int, maxdepth: int, collapse: bool, tags: Tags) -> ET:
+ """Utility: Cut and deep-copy a TOC at a specified depth."""
+ keep_bullet_list_sub_nodes = (depth <= 1
+ or ((depth <= maxdepth or maxdepth <= 0)
+ and (not collapse or 'iscurrent' in node)))
+
+ copy = node.copy()
+ for subnode in node.children:
+ if isinstance(subnode, (addnodes.compact_paragraph, nodes.list_item)):
+ # for <p> and <li>, just recurse
+ copy.append(_toctree_copy(subnode, depth, maxdepth, collapse, tags))
+ elif isinstance(subnode, nodes.bullet_list):
+ # for <ul>, copy if the entry is top-level
+ # or, copy if the depth is within bounds and;
+ # collapsing is disabled or the sub-entry's parent is 'current'.
+ # The boolean is constant so is calculated outwith the loop.
+ if keep_bullet_list_sub_nodes:
+ copy.append(_toctree_copy(subnode, depth + 1, maxdepth, collapse, tags))
+ elif isinstance(subnode, addnodes.toctree):
+ # copy sub toctree nodes for later processing
+ copy.append(subnode.copy())
+ elif isinstance(subnode, addnodes.only):
+ # only keep children if the only node matches the tags
+ if _only_node_keep_children(subnode, tags):
+ for child in subnode.children:
+ copy.append(_toctree_copy(
+ child, depth, maxdepth, collapse, tags, # type: ignore[type-var]
+ ))
+ elif isinstance(subnode, (nodes.reference, nodes.title)):
+ # deep copy references and captions
+ sub_node_copy = subnode.copy()
+ sub_node_copy.children = [child.deepcopy() for child in subnode.children]
+ for child in sub_node_copy.children:
+ child.parent = sub_node_copy
+ copy.append(sub_node_copy)
+ else:
+ msg = f'Unexpected node type {subnode.__class__.__name__!r}!'
+ raise ValueError(msg)
+ return copy
+
+
+def _get_toctree_ancestors(
+ toctree_includes: dict[str, list[str]], docname: str,
+) -> Set[str]:
+ parent: dict[str, str] = {}
+ for p, children in toctree_includes.items():
+ parent |= dict.fromkeys(children, p)
+ ancestors: list[str] = []
+ d = docname
+ while d in parent and d not in ancestors:
+ ancestors.append(d)
+ d = parent[d]
+ # use dict keys for ordered set operations
+ return dict.fromkeys(ancestors).keys()
+
+
+class TocTree:
+ def __init__(self, env: BuildEnvironment) -> None:
+ self.env = env
+
+ def note(self, docname: str, toctreenode: addnodes.toctree) -> None:
+ note_toctree(self.env, docname, toctreenode)
+
+ def resolve(self, docname: str, builder: Builder, toctree: addnodes.toctree,
+ prune: bool = True, maxdepth: int = 0, titles_only: bool = False,
+ collapse: bool = False, includehidden: bool = False) -> Element | None:
+ return _resolve_toctree(
+ self.env, docname, builder, toctree,
+ prune=prune,
+ maxdepth=maxdepth,
+ titles_only=titles_only,
+ collapse=collapse,
+ includehidden=includehidden,
+ )
+
+ def get_toctree_ancestors(self, docname: str) -> list[str]:
+ return [*_get_toctree_ancestors(self.env.toctree_includes, docname)]
+
+ def get_toc_for(self, docname: str, builder: Builder) -> Node:
+ return document_toc(self.env, docname, self.env.app.builder.tags)
+
+ def get_toctree_for(
+ self, docname: str, builder: Builder, collapse: bool, **kwargs: Any,
+ ) -> Element | None:
+ return global_toctree_for_doc(self.env, docname, builder, collapse=collapse, **kwargs)