diff --git a/sphinx/builders/html/__init__.py b/sphinx/builders/html/__init__.py index 832b9a91a3d..273124eb32e 100644 --- a/sphinx/builders/html/__init__.py +++ b/sphinx/builders/html/__init__.py @@ -32,7 +32,7 @@ from sphinx.environment import BuildEnvironment from sphinx.environment.adapters.asset import ImageAdapter from sphinx.environment.adapters.indexentries import IndexEntries -from sphinx.environment.adapters.toctree import TocTree +from sphinx.environment.adapters.toctree import document_toc, global_toctree_for_doc from sphinx.errors import ConfigError, ThemeError from sphinx.highlighting import PygmentsBridge from sphinx.locale import _, __ @@ -638,7 +638,7 @@ def get_doc_context(self, docname: str, body: str, metatags: str) -> dict[str, A meta = self.env.metadata.get(docname) # local TOC and global TOC tree - self_toc = TocTree(self.env).get_toc_for(docname, self) + self_toc = document_toc(self.env, docname, self.tags) toc = self.render_partial(self_toc)['fragment'] return { @@ -969,8 +969,8 @@ def _get_local_toctree(self, docname: str, collapse: bool = True, **kwargs: Any) kwargs['includehidden'] = False if kwargs.get('maxdepth') == '': kwargs.pop('maxdepth') - return self.render_partial(TocTree(self.env).get_toctree_for( - docname, self, collapse, **kwargs))['fragment'] + toctree = global_toctree_for_doc(self.env, docname, self, collapse=collapse, **kwargs) + return self.render_partial(toctree)['fragment'] def get_outfilename(self, pagename: str) -> str: return path.join(self.outdir, os_path(pagename) + self.out_suffix) diff --git a/sphinx/builders/singlehtml.py b/sphinx/builders/singlehtml.py index 70fe61a8cd7..45c0350cbff 100644 --- a/sphinx/builders/singlehtml.py +++ b/sphinx/builders/singlehtml.py @@ -10,7 +10,7 @@ from sphinx.application import Sphinx from sphinx.builders.html import StandaloneHTMLBuilder -from sphinx.environment.adapters.toctree import TocTree +from sphinx.environment.adapters.toctree import global_toctree_for_doc from sphinx.locale import __ from sphinx.util import logging from sphinx.util.console import darkgreen # type: ignore @@ -61,9 +61,13 @@ def fix_refuris(self, tree: Node) -> None: refnode['refuri'] = fname + refuri[hashindex:] def _get_local_toctree(self, docname: str, collapse: bool = True, **kwargs: Any) -> str: - if 'includehidden' not in kwargs: + if kwargs.get('includehidden', 'false').lower() == 'false': kwargs['includehidden'] = False - toctree = TocTree(self.env).get_toctree_for(docname, self, collapse, **kwargs) + elif kwargs['includehidden'].lower() == 'true': + kwargs['includehidden'] = True + if kwargs.get('maxdepth') == '': + kwargs.pop('maxdepth') + toctree = global_toctree_for_doc(self.env, docname, self, collapse=collapse, **kwargs) if toctree is not None: self.fix_refuris(toctree) return self.render_partial(toctree)['fragment'] @@ -118,7 +122,7 @@ def assemble_toc_fignumbers(self) -> dict[str, dict[str, dict[str, tuple[int, .. def get_doc_context(self, docname: str, body: str, metatags: str) -> dict[str, Any]: # no relation links... - toctree = TocTree(self.env).get_toctree_for(self.config.root_doc, self, False) + toctree = global_toctree_for_doc(self.env, self.config.root_doc, self, collapse=False) # if there is no toctree, toc is None if toctree: self.fix_refuris(toctree) diff --git a/sphinx/directives/other.py b/sphinx/directives/other.py index 7f9930e51ce..dec88b1d628 100644 --- a/sphinx/directives/other.py +++ b/sphinx/directives/other.py @@ -12,6 +12,7 @@ from sphinx import addnodes from sphinx.domains.changeset import VersionChange # noqa: F401 # for compatibility +from sphinx.domains.std import StandardDomain from sphinx.locale import _, __ from sphinx.util import docname_join, logging, url_re from sphinx.util.docutils import SphinxDirective @@ -79,71 +80,81 @@ def run(self) -> list[Node]: return ret def parse_content(self, toctree: addnodes.toctree) -> list[Node]: - generated_docnames = frozenset(self.env.domains['std']._virtual_doc_names) + generated_docnames = frozenset(StandardDomain._virtual_doc_names) suffixes = self.config.source_suffix + current_docname = self.env.docname + glob = toctree['glob'] # glob target documents all_docnames = self.env.found_docs.copy() | generated_docnames - all_docnames.remove(self.env.docname) # remove current document + all_docnames.remove(current_docname) # remove current document + frozen_all_docnames = frozenset(all_docnames) ret: list[Node] = [] excluded = Matcher(self.config.exclude_patterns) for entry in self.content: if not entry: continue + # look for explicit titles ("Some Title ") explicit = explicit_title_re.match(entry) - if (toctree['glob'] and glob_re.match(entry) and - not explicit and not url_re.match(entry)): - patname = docname_join(self.env.docname, entry) - docnames = sorted(patfilter(all_docnames, patname)) - for docname in docnames: + url_match = url_re.match(entry) is not None + if glob and glob_re.match(entry) and not explicit and not url_match: + pat_name = docname_join(current_docname, entry) + doc_names = sorted(patfilter(all_docnames, pat_name)) + for docname in doc_names: if docname in generated_docnames: # don't include generated documents in globs continue all_docnames.remove(docname) # don't include it again toctree['entries'].append((None, docname)) toctree['includefiles'].append(docname) - if not docnames: + if not doc_names: logger.warning(__("toctree glob pattern %r didn't match any documents"), entry, location=toctree) + continue + + if explicit: + ref = explicit.group(2) + title = explicit.group(1) + docname = ref else: - if explicit: - ref = explicit.group(2) - title = explicit.group(1) - docname = ref - else: - ref = docname = entry - title = None - # remove suffixes (backwards compatibility) - for suffix in suffixes: - if docname.endswith(suffix): - docname = docname[:-len(suffix)] - break - # absolutize filenames - docname = docname_join(self.env.docname, docname) - if url_re.match(ref) or ref == 'self': - toctree['entries'].append((title, ref)) - elif docname not in self.env.found_docs | generated_docnames: - if excluded(self.env.doc2path(docname, False)): - message = __('toctree contains reference to excluded document %r') - subtype = 'excluded' - else: - message = __('toctree contains reference to nonexisting document %r') - subtype = 'not_readable' - - logger.warning(message, docname, type='toc', subtype=subtype, - location=toctree) - self.env.note_reread() + ref = docname = entry + title = None + + # remove suffixes (backwards compatibility) + for suffix in suffixes: + if docname.endswith(suffix): + docname = docname.removesuffix(suffix) + break + + # absolutise filenames + docname = docname_join(current_docname, docname) + if url_match or ref == 'self': + toctree['entries'].append((title, ref)) + continue + + if docname not in frozen_all_docnames: + if excluded(self.env.doc2path(docname, False)): + message = __('toctree contains reference to excluded document %r') + subtype = 'excluded' else: - if docname in all_docnames: - all_docnames.remove(docname) - else: - logger.warning(__('duplicated entry found in toctree: %s'), docname, - location=toctree) + message = __('toctree contains reference to nonexisting document %r') + subtype = 'not_readable' - toctree['entries'].append((title, docname)) - toctree['includefiles'].append(docname) + logger.warning(message, docname, type='toc', subtype=subtype, + location=toctree) + self.env.note_reread() + continue + + if docname in all_docnames: + all_docnames.remove(docname) + else: + logger.warning(__('duplicated entry found in toctree: %s'), docname, + location=toctree) + + toctree['entries'].append((title, docname)) + toctree['includefiles'].append(docname) # entries contains all entries (self references, external links etc.) if 'reversed' in self.options: diff --git a/sphinx/environment/__init__.py b/sphinx/environment/__init__.py index 116840e5e9e..d3a9b7c1e47 100644 --- a/sphinx/environment/__init__.py +++ b/sphinx/environment/__init__.py @@ -17,7 +17,7 @@ from sphinx import addnodes from sphinx.config import Config from sphinx.domains import Domain -from sphinx.environment.adapters.toctree import TocTree +from sphinx.environment.adapters.toctree import _resolve_toctree from sphinx.errors import BuildEnvironmentError, DocumentError, ExtensionError, SphinxError from sphinx.events import EventManager from sphinx.locale import __ @@ -58,7 +58,7 @@ # This is increased every time an environment attribute is added # or changed to properly invalidate pickle files. -ENV_VERSION = 58 +ENV_VERSION = 59 # config status CONFIG_UNSET = -1 @@ -630,9 +630,9 @@ def get_and_resolve_doctree( # now, resolve all toctree nodes for toctreenode in doctree.findall(addnodes.toctree): - result = TocTree(self).resolve(docname, builder, toctreenode, - prune=prune_toctrees, - includehidden=includehidden) + result = _resolve_toctree(self, docname, builder, toctreenode, + prune=prune_toctrees, + includehidden=includehidden) if result is None: toctreenode.parent.replace(toctreenode, []) else: @@ -654,9 +654,8 @@ def resolve_toctree(self, docname: str, builder: Builder, toctree: addnodes.toct If *collapse* is True, all branches not containing docname will be collapsed. """ - return TocTree(self).resolve(docname, builder, toctree, prune, - maxdepth, titles_only, collapse, - includehidden) + return _resolve_toctree(self, docname, builder, toctree, prune, + maxdepth, titles_only, collapse, includehidden) def resolve_references(self, doctree: nodes.document, fromdocname: str, builder: Builder) -> None: @@ -680,38 +679,21 @@ def apply_post_transforms(self, doctree: nodes.document, docname: str) -> None: self.events.emit('doctree-resolved', doctree, docname) def collect_relations(self) -> dict[str, list[str | None]]: - traversed = set() - - def traverse_toctree( - parent: str | None, docname: str, - ) -> Iterator[tuple[str | None, str]]: - if parent == docname: - logger.warning(__('self referenced toctree found. Ignored.'), - location=docname, type='toc', - subtype='circular') - return - - # traverse toctree by pre-order - yield parent, docname - traversed.add(docname) - - for child in (self.toctree_includes.get(docname) or []): - for subparent, subdocname in traverse_toctree(docname, child): - if subdocname not in traversed: - yield subparent, subdocname - traversed.add(subdocname) + traversed: set[str] = set() relations = {} - docnames = traverse_toctree(None, self.config.root_doc) - prevdoc = None + docnames = _traverse_toctree( + traversed, None, self.config.root_doc, self.toctree_includes, + ) + prev_doc = None parent, docname = next(docnames) - for nextparent, nextdoc in docnames: - relations[docname] = [parent, prevdoc, nextdoc] - prevdoc = docname - docname = nextdoc - parent = nextparent + for next_parent, next_doc in docnames: + relations[docname] = [parent, prev_doc, next_doc] + prev_doc = docname + docname = next_doc + parent = next_parent - relations[docname] = [parent, prevdoc, None] + relations[docname] = [parent, prev_doc, None] return relations @@ -750,3 +732,28 @@ def _last_modified_time(filename: str | os.PathLike[str]) -> int: # upside-down floor division to get the ceiling return -(os.stat(filename).st_mtime_ns // -1_000) + + +def _traverse_toctree( + traversed: set[str], + parent: str | None, + docname: str, + toctree_includes: dict[str, list[str]], +) -> Iterator[tuple[str | None, str]]: + if parent == docname: + logger.warning(__('self referenced toctree found. Ignored.'), + location=docname, type='toc', + subtype='circular') + return + + # traverse toctree by pre-order + yield parent, docname + traversed.add(docname) + + for child in toctree_includes.get(docname, ()): + for sub_parent, sub_docname in _traverse_toctree( + traversed, docname, child, toctree_includes, + ): + if sub_docname not in traversed: + yield sub_parent, sub_docname + traversed.add(sub_docname) diff --git a/sphinx/environment/adapters/toctree.py b/sphinx/environment/adapters/toctree.py index edc085005ff..ef6361a186a 100644 --- a/sphinx/environment/adapters/toctree.py +++ b/sphinx/environment/adapters/toctree.py @@ -2,8 +2,7 @@ from __future__ import annotations -from collections.abc import Iterable -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, TypeVar from docutils import nodes from docutils.nodes import Element, Node @@ -12,330 +11,501 @@ from sphinx.locale import __ from sphinx.util import logging, url_re from sphinx.util.matching import Matcher -from sphinx.util.nodes import clean_astext, process_only_nodes +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, + maxdepth: int = 0, + titles_only: bool = False, + collapse: bool = False, + includehidden: bool = True, +) -> 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): + # + # + # + # 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 + 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') + raise LookupError('circular reference') + + 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

and

  • , 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