summaryrefslogtreecommitdiffstats
path: root/sphinx/environment/adapters/toctree.py
blob: e50d10b778363587ceb3bb583a2b063634006f6c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
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)