xref: /aosp_15_r20/external/bazelbuild-rules_python/sphinxdocs/src/sphinx_bzl/bzl.py (revision 60517a1edbc8ecf509223e9af94a7adec7d736b8)
1# Copyright 2024 The Bazel Authors. All rights reserved.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#     http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14"""Sphinx extension for documenting Bazel/Starlark objects."""
15
16import ast
17import collections
18import enum
19import os
20import typing
21from collections.abc import Collection
22from typing import Callable, Iterable, TypeVar
23
24from docutils import nodes as docutils_nodes
25from docutils.parsers.rst import directives as docutils_directives
26from docutils.parsers.rst import states
27from sphinx import addnodes, builders
28from sphinx import directives as sphinx_directives
29from sphinx import domains, environment, roles
30from sphinx.highlighting import lexer_classes
31from sphinx.locale import _
32from sphinx.util import docfields
33from sphinx.util import docutils as sphinx_docutils
34from sphinx.util import inspect, logging
35from sphinx.util import nodes as sphinx_nodes
36from sphinx.util import typing as sphinx_typing
37from typing_extensions import TypeAlias, override
38
39_logger = logging.getLogger(__name__)
40_LOG_PREFIX = f"[{_logger.name}] "
41
42_INDEX_SUBTYPE_NORMAL = 0
43_INDEX_SUBTYPE_ENTRY_WITH_SUB_ENTRIES = 1
44_INDEX_SUBTYPE_SUB_ENTRY = 2
45
46_T = TypeVar("_T")
47
48# See https://www.sphinx-doc.org/en/master/extdev/domainapi.html#sphinx.domains.Domain.get_objects
49_GetObjectsTuple: TypeAlias = tuple[str, str, str, str, str, int]
50
51# See SphinxRole.run definition; the docs for role classes are pretty sparse.
52_RoleRunResult: TypeAlias = tuple[
53    list[docutils_nodes.Node], list[docutils_nodes.system_message]
54]
55
56
57def _log_debug(message, *args):
58    # NOTE: Non-warning log messages go to stdout and are only
59    # visible when -q isn't passed to Sphinx. Note that the sphinx_docs build
60    # rule passes -q by default; use --//sphinxdocs:quiet=false to disable it.
61    _logger.debug("%s" + message, _LOG_PREFIX, *args)
62
63
64def _position_iter(values: Collection[_T]) -> tuple[bool, bool, _T]:
65    last_i = len(values) - 1
66    for i, value in enumerate(values):
67        yield i == 0, i == last_i, value
68
69
70class InvalidValueError(Exception):
71    """Generic error for an invalid value instead of ValueError.
72
73    Sphinx treats regular ValueError to mean abort parsing the current
74    chunk and continue on as best it can. Their error means a more
75    fundamental problem that should cause a failure.
76    """
77
78
79class _ObjectEntry:
80    """Metadata about a known object."""
81
82    def __init__(
83        self,
84        full_id: str,
85        display_name: str,
86        object_type: str,
87        search_priority: int,
88        index_entry: domains.IndexEntry,
89    ):
90        """Creates an instance.
91
92        Args:
93            full_id: The fully qualified id of the object. Should be
94                globally unique, even between projects.
95            display_name: What to display the object as in casual context.
96            object_type: The type of object, typically one of the values
97                known to the domain.
98            search_priority: The search priority, see
99                https://www.sphinx-doc.org/en/master/extdev/domainapi.html#sphinx.domains.Domain.get_objects
100                for valid values.
101            index_entry: Metadata about the object for the domain index.
102        """
103        self.full_id = full_id
104        self.display_name = display_name
105        self.object_type = object_type
106        self.search_priority = search_priority
107        self.index_entry = index_entry
108
109    def to_get_objects_tuple(self) -> _GetObjectsTuple:
110        # For the tuple definition
111        return (
112            self.full_id,
113            self.display_name,
114            self.object_type,
115            self.index_entry.docname,
116            self.index_entry.anchor,
117            self.search_priority,
118        )
119
120    def __repr__(self):
121        return f"ObjectEntry({self.full_id=}, {self.object_type=}, {self.display_name=}, {self.index_entry.docname=})"
122
123
124# A simple helper just to document what the index tuple nodes are.
125def _index_node_tuple(
126    entry_type: str,
127    entry_name: str,
128    target: str,
129    main: typing.Union[str, None] = None,
130    category_key: typing.Union[str, None] = None,
131) -> tuple[str, str, str, typing.Union[str, None], typing.Union[str, None]]:
132    # For this tuple definition, see:
133    # https://www.sphinx-doc.org/en/master/extdev/nodes.html#sphinx.addnodes.index
134    # For the definition of entry_type, see:
135    # And https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-index
136    return (entry_type, entry_name, target, main, category_key)
137
138
139class _BzlObjectId:
140    """Identifies an object defined by a directive.
141
142    This object is returned by `handle_signature()` and passed onto
143    `add_target_and_index()`. It contains information to identify the object
144    that is being described so that it can be indexed and tracked by the
145    domain.
146    """
147
148    def __init__(
149        self,
150        *,
151        repo: str,
152        label: str,
153        namespace: str = None,
154        symbol: str = None,
155    ):
156        """Creates an instance.
157
158        Args:
159            repo: repository name, including leading "@".
160            bzl_file: label of file containing the object, e.g. //foo:bar.bzl
161            namespace: dotted name of the namespace the symbol is within.
162            symbol: dotted name, relative to `namespace` of the symbol.
163        """
164        if not repo:
165            raise InvalidValueError("repo cannot be empty")
166        if not repo.startswith("@"):
167            raise InvalidValueError("repo must start with @")
168        if not label:
169            raise InvalidValueError("label cannot be empty")
170        if not label.startswith("//"):
171            raise InvalidValueError("label must start with //")
172
173        if not label.endswith(".bzl") and (symbol or namespace):
174            raise InvalidValueError(
175                "Symbol and namespace can only be specified for .bzl labels"
176            )
177
178        self.repo = repo
179        self.label = label
180        self.package, self.target_name = self.label.split(":")
181        self.namespace = namespace
182        self.symbol = symbol  # Relative to namespace
183        # doc-relative identifier for this object
184        self.doc_id = symbol or self.target_name
185
186        if not self.doc_id:
187            raise InvalidValueError("doc_id is empty")
188
189        self.full_id = _full_id_from_parts(repo, label, [namespace, symbol])
190
191    @classmethod
192    def from_env(
193        cls, env: environment.BuildEnvironment, *, symbol: str = None, label: str = None
194    ) -> "_BzlObjectId":
195        label = label or env.ref_context["bzl:file"]
196        if symbol:
197            namespace = ".".join(env.ref_context["bzl:doc_id_stack"])
198        else:
199            namespace = None
200
201        return cls(
202            repo=env.ref_context["bzl:repo"],
203            label=label,
204            namespace=namespace,
205            symbol=symbol,
206        )
207
208    def __repr__(self):
209        return f"_BzlObjectId({self.full_id=})"
210
211
212def _full_id_from_env(env, object_ids=None):
213    return _full_id_from_parts(
214        env.ref_context["bzl:repo"],
215        env.ref_context["bzl:file"],
216        env.ref_context["bzl:object_id_stack"] + (object_ids or []),
217    )
218
219
220def _full_id_from_parts(repo, bzl_file, symbol_names=None):
221    parts = [repo, bzl_file]
222
223    symbol_names = symbol_names or []
224    symbol_names = list(filter(None, symbol_names))  # Filter out empty values
225    if symbol_names:
226        parts.append("%")
227        parts.append(".".join(symbol_names))
228
229    full_id = "".join(parts)
230    return full_id
231
232
233def _parse_full_id(full_id):
234    repo, slashes, label = full_id.partition("//")
235    label = slashes + label
236    label, _, symbol = label.partition("%")
237    return (repo, label, symbol)
238
239
240class _TypeExprParser(ast.NodeVisitor):
241    """Parsers a string description of types to doc nodes."""
242
243    def __init__(self, make_xref: Callable[[str], docutils_nodes.Node]):
244        self.root_node = addnodes.desc_inline("bzl", classes=["type-expr"])
245        self.make_xref = make_xref
246        self._doc_node_stack = [self.root_node]
247
248    @classmethod
249    def xrefs_from_type_expr(
250        cls,
251        type_expr_str: str,
252        make_xref: Callable[[str], docutils_nodes.Node],
253    ) -> docutils_nodes.Node:
254        module = ast.parse(type_expr_str)
255        visitor = cls(make_xref)
256        visitor.visit(module.body[0])
257        return visitor.root_node
258
259    def _append(self, node: docutils_nodes.Node):
260        self._doc_node_stack[-1] += node
261
262    def _append_and_push(self, node: docutils_nodes.Node):
263        self._append(node)
264        self._doc_node_stack.append(node)
265
266    def visit_Attribute(self, node: ast.Attribute):
267        current = node
268        parts = []
269        while current:
270            if isinstance(current, ast.Attribute):
271                parts.append(current.attr)
272                current = current.value
273            elif isinstance(current, ast.Name):
274                parts.append(current.id)
275                break
276            else:
277                raise InvalidValueError(f"Unexpected Attribute.value node: {current}")
278        dotted_name = ".".join(reversed(parts))
279        self._append(self.make_xref(dotted_name))
280
281    def visit_Constant(self, node: ast.Constant):
282        if node.value is None:
283            self._append(self.make_xref("None"))
284        elif isinstance(node.value, str):
285            self._append(self.make_xref(node.value))
286        else:
287            raise InvalidValueError(
288                f"Unexpected Constant node value: ({type(node.value)}) {node.value=}"
289            )
290
291    def visit_Name(self, node: ast.Name):
292        xref_node = self.make_xref(node.id)
293        self._append(xref_node)
294
295    def visit_BinOp(self, node: ast.BinOp):
296        self.visit(node.left)
297        self._append(addnodes.desc_sig_space())
298        if isinstance(node.op, ast.BitOr):
299            self._append(addnodes.desc_sig_punctuation("", "|"))
300        else:
301            raise InvalidValueError(f"Unexpected BinOp: {node}")
302        self._append(addnodes.desc_sig_space())
303        self.visit(node.right)
304
305    def visit_Expr(self, node: ast.Expr):
306        self.visit(node.value)
307
308    def visit_Subscript(self, node: ast.Subscript):
309        self.visit(node.value)
310        self._append_and_push(addnodes.desc_type_parameter_list())
311        self.visit(node.slice)
312        self._doc_node_stack.pop()
313
314    def visit_Tuple(self, node: ast.Tuple):
315        for element in node.elts:
316            self._append_and_push(addnodes.desc_type_parameter())
317            self.visit(element)
318            self._doc_node_stack.pop()
319
320    def visit_List(self, node: ast.List):
321        self._append_and_push(addnodes.desc_type_parameter_list())
322        for element in node.elts:
323            self._append_and_push(addnodes.desc_type_parameter())
324            self.visit(element)
325            self._doc_node_stack.pop()
326
327    @override
328    def generic_visit(self, node):
329        raise InvalidValueError(f"Unexpected ast node: {type(node)} {node}")
330
331
332class _BzlXrefField(docfields.Field):
333    """Abstract base class to create cross references for fields."""
334
335    @override
336    def make_xrefs(
337        self,
338        rolename: str,
339        domain: str,
340        target: str,
341        innernode: type[sphinx_typing.TextlikeNode] = addnodes.literal_emphasis,
342        contnode: typing.Union[docutils_nodes.Node, None] = None,
343        env: typing.Union[environment.BuildEnvironment, None] = None,
344        inliner: typing.Union[states.Inliner, None] = None,
345        location: typing.Union[docutils_nodes.Element, None] = None,
346    ) -> list[docutils_nodes.Node]:
347        if rolename in ("arg", "attr"):
348            return self._make_xrefs_for_arg_attr(
349                rolename, domain, target, innernode, contnode, env, inliner, location
350            )
351        else:
352            return super().make_xrefs(
353                rolename, domain, target, innernode, contnode, env, inliner, location
354            )
355
356    def _make_xrefs_for_arg_attr(
357        self,
358        rolename: str,
359        domain: str,
360        arg_name: str,
361        innernode: type[sphinx_typing.TextlikeNode] = addnodes.literal_emphasis,
362        contnode: typing.Union[docutils_nodes.Node, None] = None,
363        env: typing.Union[environment.BuildEnvironment, None] = None,
364        inliner: typing.Union[states.Inliner, None] = None,
365        location: typing.Union[docutils_nodes.Element, None] = None,
366    ) -> list[docutils_nodes.Node]:
367        bzl_file = env.ref_context["bzl:file"]
368        anchor_prefix = ".".join(env.ref_context["bzl:doc_id_stack"])
369        if not anchor_prefix:
370            raise InvalidValueError(
371                f"doc_id_stack empty when processing arg {arg_name}"
372            )
373        index_description = f"{arg_name} ({self.name} in {bzl_file}%{anchor_prefix})"
374        anchor_id = f"{anchor_prefix}.{arg_name}"
375        full_id = _full_id_from_env(env, [arg_name])
376
377        env.get_domain(domain).add_object(
378            _ObjectEntry(
379                full_id=full_id,
380                display_name=arg_name,
381                object_type=self.name,
382                search_priority=1,
383                index_entry=domains.IndexEntry(
384                    name=arg_name,
385                    subtype=_INDEX_SUBTYPE_NORMAL,
386                    docname=env.docname,
387                    anchor=anchor_id,
388                    extra="",
389                    qualifier="",
390                    descr=index_description,
391                ),
392            ),
393            # This allows referencing an arg as e.g `funcname.argname`
394            alt_names=[anchor_id],
395        )
396
397        # Two changes to how arg xrefs are created:
398        # 2. Use the full id instead of base name. This makes it unambiguous
399        #    as to what it's referencing.
400        pending_xref = super().make_xref(
401            # The full_id is used as the target so its unambiguious.
402            rolename,
403            domain,
404            f"{arg_name} <{full_id}>",
405            innernode,
406            contnode,
407            env,
408            inliner,
409            location,
410        )
411
412        wrapper = docutils_nodes.inline(ids=[anchor_id])
413
414        index_node = addnodes.index(
415            entries=[
416                _index_node_tuple(
417                    "single", f"{self.name}; {index_description}", anchor_id
418                ),
419                _index_node_tuple("single", index_description, anchor_id),
420            ]
421        )
422        wrapper += index_node
423        wrapper += pending_xref
424        return [wrapper]
425
426
427class _BzlField(_BzlXrefField, docfields.Field):
428    """A non-repeated field with xref support."""
429
430
431class _BzlGroupedField(_BzlXrefField, docfields.GroupedField):
432    """A repeated fieled grouped as a list with xref support."""
433
434
435class _BzlCsvField(_BzlXrefField):
436    """Field with a CSV list of values."""
437
438    def __init__(self, *args, body_domain: str = "", **kwargs):
439        super().__init__(*args, **kwargs)
440        self._body_domain = body_domain
441
442    def make_field(
443        self,
444        types: dict[str, list[docutils_nodes.Node]],
445        domain: str,
446        item: tuple,
447        env: environment.BuildEnvironment = None,
448        inliner: typing.Union[states.Inliner, None] = None,
449        location: typing.Union[docutils_nodes.Element, None] = None,
450    ) -> docutils_nodes.field:
451        field_text = item[1][0].astext()
452        parts = [p.strip() for p in field_text.split(",")]
453        field_body = docutils_nodes.field_body()
454        for _, is_last, part in _position_iter(parts):
455            node = self.make_xref(
456                self.bodyrolename,
457                self._body_domain or domain,
458                part,
459                env=env,
460                inliner=inliner,
461                location=location,
462            )
463            field_body += node
464            if not is_last:
465                field_body += docutils_nodes.Text(", ")
466
467        field_name = docutils_nodes.field_name("", self.label)
468        return docutils_nodes.field("", field_name, field_body)
469
470
471class _BzlCurrentFile(sphinx_docutils.SphinxDirective):
472    """Sets what bzl file following directives are defined in.
473
474    The directive's argument is an absolute Bazel label, e.g. `//foo:bar.bzl`
475    or `@repo//foo:bar.bzl`. The repository portion is optional; if specified,
476    it will override the `bzl_default_repository_name` configuration setting.
477
478    Example MyST usage
479
480    ```
481    :::{bzl:currentfile} //my:file.bzl
482    :::
483    ```
484    """
485
486    has_content = False
487    required_arguments = 1
488    final_argument_whitespace = False
489
490    @override
491    def run(self) -> list[docutils_nodes.Node]:
492        label = self.arguments[0].strip()
493        repo, slashes, file_label = label.partition("//")
494        file_label = slashes + file_label
495        if not repo:
496            repo = self.env.config.bzl_default_repository_name
497        self.env.ref_context["bzl:repo"] = repo
498        self.env.ref_context["bzl:file"] = file_label
499        self.env.ref_context["bzl:object_id_stack"] = []
500        self.env.ref_context["bzl:doc_id_stack"] = []
501        return []
502
503
504class _BzlAttrInfo(sphinx_docutils.SphinxDirective):
505    has_content = False
506    required_arguments = 1
507    optional_arguments = 0
508    option_spec = {
509        "executable": docutils_directives.flag,
510        "mandatory": docutils_directives.flag,
511    }
512
513    def run(self):
514        content_node = docutils_nodes.paragraph("", "")
515        content_node += docutils_nodes.paragraph(
516            "", "mandatory" if "mandatory" in self.options else "optional"
517        )
518        if "executable" in self.options:
519            content_node += docutils_nodes.paragraph("", "Must be an executable")
520
521        return [content_node]
522
523
524class _BzlObject(sphinx_directives.ObjectDescription[_BzlObjectId]):
525    """Base class for describing a Bazel/Starlark object.
526
527    This directive takes a single argument: a string name with optional
528    function signature.
529
530    * The name can be a dotted name, e.g. `a.b.foo`
531    * The signature is in Python signature syntax, e.g. `foo(a=x) -> R`
532    * The signature supports default values.
533    * Arg type annotations are not supported; use `{bzl:type}` instead as
534      part of arg/attr documentation.
535
536    Example signatures:
537      * `foo`
538      * `foo(arg1, arg2)`
539      * `foo(arg1, arg2=default) -> returntype`
540    """
541
542    option_spec = sphinx_directives.ObjectDescription.option_spec | {
543        "origin-key": docutils_directives.unchanged,
544    }
545
546    @override
547    def before_content(self) -> None:
548        symbol_name = self.names[-1].symbol
549        if symbol_name:
550            self.env.ref_context["bzl:object_id_stack"].append(symbol_name)
551            self.env.ref_context["bzl:doc_id_stack"].append(symbol_name)
552
553    @override
554    def transform_content(self, content_node: addnodes.desc_content) -> None:
555        def first_child_with_class_name(
556            root, class_name
557        ) -> typing.Union[None, docutils_nodes.Element]:
558            matches = root.findall(
559                lambda node: isinstance(node, docutils_nodes.Element)
560                and class_name in node["classes"]
561            )
562            found = next(matches, None)
563            return found
564
565        def match_arg_field_name(node):
566            # fmt: off
567            return (
568                isinstance(node, docutils_nodes.field_name)
569                and node.astext().startswith(("arg ", "attr "))
570            )
571            # fmt: on
572
573        # Move the spans for the arg type and default value to be first.
574        arg_name_fields = list(content_node.findall(match_arg_field_name))
575        for arg_name_field in arg_name_fields:
576            arg_body_field = arg_name_field.next_node(descend=False, siblings=True)
577            # arg_type_node = first_child_with_class_name(arg_body_field, "arg-type-span")
578            arg_type_node = first_child_with_class_name(arg_body_field, "type-expr")
579            arg_default_node = first_child_with_class_name(
580                arg_body_field, "default-value-span"
581            )
582
583            # Inserting into the body field itself causes the elements
584            # to be grouped into the paragraph node containing the arg
585            # name (as opposed to the paragraph node containing the
586            # doc text)
587
588            if arg_default_node:
589                arg_default_node.parent.remove(arg_default_node)
590                arg_body_field.insert(0, arg_default_node)
591
592            if arg_type_node:
593                arg_type_node.parent.remove(arg_type_node)
594                decorated_arg_type_node = docutils_nodes.inline(
595                    "",
596                    "",
597                    docutils_nodes.Text("("),
598                    arg_type_node,
599                    docutils_nodes.Text(") "),
600                    classes=["arg-type-span"],
601                )
602                # arg_body_field.insert(0, arg_type_node)
603                arg_body_field.insert(0, decorated_arg_type_node)
604
605    @override
606    def after_content(self) -> None:
607        if self.names[-1].symbol:
608            self.env.ref_context["bzl:object_id_stack"].pop()
609            self.env.ref_context["bzl:doc_id_stack"].pop()
610
611    # docs on how to build signatures:
612    # https://www.sphinx-doc.org/en/master/extdev/nodes.html#sphinx.addnodes.desc_signature
613    @override
614    def handle_signature(
615        self, sig_text: str, sig_node: addnodes.desc_signature
616    ) -> _BzlObjectId:
617        self._signature_add_object_type(sig_node)
618
619        relative_name, lparen, params_text = sig_text.partition("(")
620        if lparen:
621            params_text = lparen + params_text
622
623        relative_name = relative_name.strip()
624
625        name_prefix, _, base_symbol_name = relative_name.rpartition(".")
626        if name_prefix:
627            # Respect whatever the signature wanted
628            display_prefix = name_prefix
629        else:
630            # Otherwise, show the outermost name. This makes ctrl+f finding
631            # for a symbol a bit easier.
632            display_prefix = ".".join(self.env.ref_context["bzl:doc_id_stack"])
633            _, _, display_prefix = display_prefix.rpartition(".")
634
635        if display_prefix:
636            display_prefix = display_prefix + "."
637            sig_node += addnodes.desc_addname(display_prefix, display_prefix)
638        sig_node += addnodes.desc_name(base_symbol_name, base_symbol_name)
639
640        if type_expr := self.options.get("type"):
641
642            def make_xref(name, title=None):
643                content_node = addnodes.desc_type(name, name)
644                return addnodes.pending_xref(
645                    "",
646                    content_node,
647                    refdomain="bzl",
648                    reftype="type",
649                    reftarget=name,
650                )
651
652            attr_annotation_node = addnodes.desc_annotation(
653                type_expr,
654                "",
655                addnodes.desc_sig_punctuation("", ":"),
656                addnodes.desc_sig_space(),
657                _TypeExprParser.xrefs_from_type_expr(type_expr, make_xref),
658            )
659            sig_node += attr_annotation_node
660
661        if params_text:
662            try:
663                signature = inspect.signature_from_str(params_text)
664            except SyntaxError:
665                # Stardoc doesn't provide accurate info, so the reconstructed
666                # signature might not be valid syntax. Rather than fail, just
667                # provide a plain-text description of the approximate signature.
668                # See https://github.com/bazelbuild/stardoc/issues/225
669                sig_node += addnodes.desc_parameterlist(
670                    # Offset by 1 to remove the surrounding parentheses
671                    params_text[1:-1],
672                    params_text[1:-1],
673                )
674            else:
675                last_kind = None
676                paramlist_node = addnodes.desc_parameterlist()
677                for param in signature.parameters.values():
678                    if param.kind == param.KEYWORD_ONLY and last_kind in (
679                        param.POSITIONAL_OR_KEYWORD,
680                        param.POSITIONAL_ONLY,
681                        None,
682                    ):
683                        # Add separator for keyword only parameter: *
684                        paramlist_node += addnodes.desc_parameter(
685                            "", "", addnodes.desc_sig_operator("", "*")
686                        )
687
688                    last_kind = param.kind
689                    node = addnodes.desc_parameter()
690                    if param.kind == param.VAR_POSITIONAL:
691                        node += addnodes.desc_sig_operator("", "*")
692                    elif param.kind == param.VAR_KEYWORD:
693                        node += addnodes.desc_sig_operator("", "**")
694
695                    node += addnodes.desc_sig_name(rawsource="", text=param.name)
696                    if param.default is not param.empty:
697                        node += addnodes.desc_sig_operator("", "=")
698                        node += docutils_nodes.inline(
699                            "",
700                            param.default,
701                            classes=["default_value"],
702                            support_smartquotes=False,
703                        )
704                    paramlist_node += node
705                sig_node += paramlist_node
706
707                if signature.return_annotation is not signature.empty:
708                    sig_node += addnodes.desc_returns("", signature.return_annotation)
709
710        obj_id = _BzlObjectId.from_env(self.env, symbol=relative_name)
711
712        sig_node["bzl:object_id"] = obj_id.full_id
713        return obj_id
714
715    def _signature_add_object_type(self, sig_node: addnodes.desc_signature):
716        if sig_object_type := self._get_signature_object_type():
717            sig_node += addnodes.desc_annotation("", self._get_signature_object_type())
718            sig_node += addnodes.desc_sig_space()
719
720    @override
721    def add_target_and_index(
722        self, obj_desc: _BzlObjectId, sig: str, sig_node: addnodes.desc_signature
723    ) -> None:
724        super().add_target_and_index(obj_desc, sig, sig_node)
725        if obj_desc.symbol:
726            display_name = obj_desc.symbol
727            location = obj_desc.label
728            if obj_desc.namespace:
729                location += f"%{obj_desc.namespace}"
730        else:
731            display_name = obj_desc.target_name
732            location = obj_desc.package
733
734        anchor_prefix = ".".join(self.env.ref_context["bzl:doc_id_stack"])
735        if anchor_prefix:
736            anchor_id = f"{anchor_prefix}.{obj_desc.doc_id}"
737        else:
738            anchor_id = obj_desc.doc_id
739
740        sig_node["ids"].append(anchor_id)
741
742        object_type_display = self._get_object_type_display_name()
743        index_description = f"{display_name} ({object_type_display} in {location})"
744        self.indexnode["entries"].extend(
745            _index_node_tuple("single", f"{index_type}; {index_description}", anchor_id)
746            for index_type in [object_type_display] + self._get_additional_index_types()
747        )
748        self.indexnode["entries"].append(
749            _index_node_tuple("single", index_description, anchor_id),
750        )
751
752        object_entry = _ObjectEntry(
753            full_id=obj_desc.full_id,
754            display_name=display_name,
755            object_type=self.objtype,
756            search_priority=1,
757            index_entry=domains.IndexEntry(
758                name=display_name,
759                subtype=_INDEX_SUBTYPE_NORMAL,
760                docname=self.env.docname,
761                anchor=anchor_id,
762                extra="",
763                qualifier="",
764                descr=index_description,
765            ),
766        )
767
768        alt_names = []
769        if origin_key := self.options.get("origin-key"):
770            alt_names.append(
771                origin_key
772                # Options require \@ for leading @, but don't
773                # remove the escaping slash, so we have to do it manually
774                .lstrip("\\")
775            )
776        extra_alt_names = self._get_alt_names(object_entry)
777        alt_names.extend(extra_alt_names)
778
779        self.env.get_domain(self.domain).add_object(object_entry, alt_names=alt_names)
780
781    def _get_additional_index_types(self):
782        return []
783
784    @override
785    def _object_hierarchy_parts(
786        self, sig_node: addnodes.desc_signature
787    ) -> tuple[str, ...]:
788        return _parse_full_id(sig_node["bzl:object_id"])
789
790    @override
791    def _toc_entry_name(self, sig_node: addnodes.desc_signature) -> str:
792        return sig_node["_toc_parts"][-1]
793
794    def _get_object_type_display_name(self) -> str:
795        return self.env.get_domain(self.domain).object_types[self.objtype].lname
796
797    def _get_signature_object_type(self) -> str:
798        return self._get_object_type_display_name()
799
800    def _get_alt_names(self, object_entry):
801        alt_names = []
802        full_id = object_entry.full_id
803        label, _, symbol = full_id.partition("%")
804        if symbol:
805            # Allow referring to the file-relative fully qualified symbol name
806            alt_names.append(symbol)
807            if "." in symbol:
808                # Allow referring to the last component of the symbol
809                alt_names.append(symbol.split(".")[-1])
810        else:
811            # Otherwise, it's a target. Allow referring to just the target name
812            _, _, target_name = label.partition(":")
813            alt_names.append(target_name)
814
815        return alt_names
816
817
818class _BzlCallable(_BzlObject):
819    """Abstract base class for objects that are callable."""
820
821
822class _BzlProvider(_BzlObject):
823    """Documents a provider type.
824
825    Example MyST usage
826
827    ```
828    ::::{bzl:provider} MyInfo
829
830    Docs about MyInfo
831
832    :::{bzl:provider-field} some_field
833    :type: depset[str]
834    :::
835    ::::
836    ```
837    """
838
839
840class _BzlProviderField(_BzlObject):
841    """Documents a field of a provider.
842
843    Fields can optionally have a type specified using the `:type:` option.
844
845    The type can be any type expression understood by the `{bzl:type}` role.
846
847    ```
848    :::{bzl:provider-field} foo
849    :type: str
850    :::
851    ```
852    """
853
854    option_spec = _BzlObject.option_spec.copy()
855    option_spec.update(
856        {
857            "type": docutils_directives.unchanged,
858        }
859    )
860
861    @override
862    def _get_signature_object_type(self) -> str:
863        return ""
864
865    @override
866    def _get_alt_names(self, object_entry):
867        alt_names = super()._get_alt_names(object_entry)
868        _, _, symbol = object_entry.full_id.partition("%")
869        # Allow refering to `mod_ext_name.tag_name`, even if the extension
870        # is nested within another object
871        alt_names.append(".".join(symbol.split(".")[-2:]))
872        return alt_names
873
874
875class _BzlRepositoryRule(_BzlCallable):
876    """Documents a repository rule.
877
878    Doc fields:
879    * attr: Documents attributes of the rule. Takes a single arg, the
880      attribute name. Can be repeated. The special roles `{default-value}`
881      and `{arg-type}` can be used to indicate the default value and
882      type of attribute, respectively.
883    * environment-variables: a CSV list of environment variable names.
884      They will be cross referenced with matching environment variables.
885
886    Example MyST usage
887
888    ```
889    :::{bzl:repo-rule} myrule(foo)
890
891    :attr foo: {default-value}`"foo"` {arg-type}`attr.string` foo doc string
892
893    :environment-variables: FOO, BAR
894    :::
895    ```
896    """
897
898    doc_field_types = [
899        _BzlGroupedField(
900            "attr",
901            label=_("Attributes"),
902            names=["attr"],
903            rolename="attr",
904            can_collapse=False,
905        ),
906        _BzlCsvField(
907            "environment-variables",
908            label=_("Environment Variables"),
909            names=["environment-variables"],
910            body_domain="std",
911            bodyrolename="envvar",
912            has_arg=False,
913        ),
914    ]
915
916    @override
917    def _get_signature_object_type(self) -> str:
918        return "repo rule"
919
920
921class _BzlRule(_BzlCallable):
922    """Documents a rule.
923
924    Doc fields:
925    * attr: Documents attributes of the rule. Takes a single arg, the
926      attribute name. Can be repeated. The special roles `{default-value}`
927      and `{arg-type}` can be used to indicate the default value and
928      type of attribute, respectively.
929    * provides: A type expression of the provider types the rule provides.
930      To indicate different groupings, use `|` and `[]`. For example,
931      `FooInfo | [BarInfo, BazInfo]` means it provides either `FooInfo`
932      or both of `BarInfo` and `BazInfo`.
933
934    Example MyST usage
935
936    ```
937    :::{bzl:repo-rule} myrule(foo)
938
939    :attr foo: {default-value}`"foo"` {arg-type}`attr.string` foo doc string
940
941    :provides: FooInfo | BarInfo
942    :::
943    ```
944    """
945
946    doc_field_types = [
947        _BzlGroupedField(
948            "attr",
949            label=_("Attributes"),
950            names=["attr"],
951            rolename="attr",
952            can_collapse=False,
953        ),
954        _BzlField(
955            "provides",
956            label="Provides",
957            has_arg=False,
958            names=["provides"],
959            bodyrolename="type",
960        ),
961    ]
962
963
964class _BzlAspect(_BzlObject):
965    """Documents an aspect.
966
967    Doc fields:
968    * attr: Documents attributes of the aspect. Takes a single arg, the
969      attribute name. Can be repeated. The special roles `{default-value}`
970      and `{arg-type}` can be used to indicate the default value and
971      type of attribute, respectively.
972    * aspect-attributes: A CSV list of attribute names the aspect
973      propagates along.
974
975    Example MyST usage
976
977    ```
978    :::{bzl:repo-rule} myaspect
979
980    :attr foo: {default-value}`"foo"` {arg-type}`attr.string` foo doc string
981
982    :aspect-attributes: srcs, deps
983    :::
984    ```
985    """
986
987    doc_field_types = [
988        _BzlGroupedField(
989            "attr",
990            label=_("Attributes"),
991            names=["attr"],
992            rolename="attr",
993            can_collapse=False,
994        ),
995        _BzlCsvField(
996            "aspect-attributes",
997            label=_("Aspect Attributes"),
998            names=["aspect-attributes"],
999            has_arg=False,
1000        ),
1001    ]
1002
1003
1004class _BzlFunction(_BzlCallable):
1005    """Documents a general purpose function.
1006
1007    Doc fields:
1008    * arg: Documents the arguments of the function. Takes a single arg, the
1009      arg name. Can be repeated. The special roles `{default-value}`
1010      and `{arg-type}` can be used to indicate the default value and
1011      type of attribute, respectively.
1012    * returns: Documents what the function returns. The special role
1013      `{return-type}` can be used to indicate the return type of the function.
1014
1015    Example MyST usage
1016
1017    ```
1018    :::{bzl:function} myfunc(a, b=None) -> bool
1019
1020    :arg a: {arg-type}`str` some arg doc
1021    :arg b: {arg-type}`int | None` {default-value}`42` more arg doc
1022    :returns: {return-type}`bool` doc about return value.
1023    :::
1024    ```
1025    """
1026
1027    doc_field_types = [
1028        _BzlGroupedField(
1029            "arg",
1030            label=_("Args"),
1031            names=["arg"],
1032            rolename="arg",
1033            can_collapse=False,
1034        ),
1035        docfields.Field(
1036            "returns",
1037            label=_("Returns"),
1038            has_arg=False,
1039            names=["returns"],
1040        ),
1041    ]
1042
1043    @override
1044    def _get_signature_object_type(self) -> str:
1045        return ""
1046
1047
1048class _BzlModuleExtension(_BzlObject):
1049    """Documents a module_extension.
1050
1051    Doc fields:
1052    * os-dependent: Documents if the module extension depends on the host
1053      architecture.
1054    * arch-dependent: Documents if the module extension depends on the host
1055      architecture.
1056    * environment-variables: a CSV list of environment variable names.
1057      They will be cross referenced with matching environment variables.
1058
1059    Tag classes are documented using the bzl:tag-class directives within
1060    this directive.
1061
1062    Example MyST usage:
1063
1064    ```
1065    ::::{bzl:module-extension} myext
1066
1067    :os-dependent: True
1068    :arch-dependent: False
1069
1070    :::{bzl:tag-class} mytag(myattr)
1071
1072    :attr myattr:
1073      {arg-type}`attr.string_list`
1074      doc for attribute
1075    :::
1076    ::::
1077    ```
1078    """
1079
1080    doc_field_types = [
1081        _BzlField(
1082            "os-dependent",
1083            label="OS Dependent",
1084            has_arg=False,
1085            names=["os-dependent"],
1086        ),
1087        _BzlField(
1088            "arch-dependent",
1089            label="Arch Dependent",
1090            has_arg=False,
1091            names=["arch-dependent"],
1092        ),
1093        _BzlCsvField(
1094            "environment-variables",
1095            label=_("Environment Variables"),
1096            names=["environment-variables"],
1097            body_domain="std",
1098            bodyrolename="envvar",
1099            has_arg=False,
1100        ),
1101    ]
1102
1103    @override
1104    def _get_signature_object_type(self) -> str:
1105        return "module ext"
1106
1107
1108class _BzlTagClass(_BzlCallable):
1109    """Documents a tag class for a module extension.
1110
1111    Doc fields:
1112    * attr: Documents attributes of the tag class. Takes a single arg, the
1113      attribute name. Can be repeated. The special roles `{default-value}`
1114      and `{arg-type}` can be used to indicate the default value and
1115      type of attribute, respectively.
1116
1117    Example MyST usage, note that this directive should be nested with
1118    a `bzl:module-extension` directive.
1119
1120    ```
1121    :::{bzl:tag-class} mytag(myattr)
1122
1123    :attr myattr:
1124      {arg-type}`attr.string_list`
1125      doc for attribute
1126    :::
1127    ```
1128    """
1129
1130    doc_field_types = [
1131        _BzlGroupedField(
1132            "arg",
1133            label=_("Attributes"),
1134            names=["attr"],
1135            rolename="arg",
1136            can_collapse=False,
1137        ),
1138    ]
1139
1140    @override
1141    def _get_signature_object_type(self) -> str:
1142        return ""
1143
1144    @override
1145    def _get_alt_names(self, object_entry):
1146        alt_names = super()._get_alt_names(object_entry)
1147        _, _, symbol = object_entry.full_id.partition("%")
1148        # Allow refering to `ProviderName.field`, even if the provider
1149        # is nested within another object
1150        alt_names.append(".".join(symbol.split(".")[-2:]))
1151        return alt_names
1152
1153
1154class _TargetType(enum.Enum):
1155    TARGET = "target"
1156    FLAG = "flag"
1157
1158
1159class _BzlTarget(_BzlObject):
1160    """Documents an arbitrary target."""
1161
1162    _TARGET_TYPE = _TargetType.TARGET
1163
1164    def handle_signature(self, sig_text, sig_node):
1165        self._signature_add_object_type(sig_node)
1166        if ":" in sig_text:
1167            package, target_name = sig_text.split(":", 1)
1168        else:
1169            target_name = sig_text
1170            package = self.env.ref_context["bzl:file"]
1171            package = package[: package.find(":BUILD")]
1172
1173        package = package + ":"
1174        if self._TARGET_TYPE == _TargetType.FLAG:
1175            sig_node += addnodes.desc_addname("--", "--")
1176        sig_node += addnodes.desc_addname(package, package)
1177        sig_node += addnodes.desc_name(target_name, target_name)
1178
1179        obj_id = _BzlObjectId.from_env(self.env, label=package + target_name)
1180        sig_node["bzl:object_id"] = obj_id.full_id
1181        return obj_id
1182
1183    @override
1184    def _get_signature_object_type(self) -> str:
1185        # We purposely return empty here because having "target" in front
1186        # of every label isn't very helpful
1187        return ""
1188
1189
1190# TODO: Integrate with the option directive, since flags are options, afterall.
1191# https://www.sphinx-doc.org/en/master/usage/domains/standard.html#directive-option
1192class _BzlFlag(_BzlTarget):
1193    """Documents a flag"""
1194
1195    _TARGET_TYPE = _TargetType.FLAG
1196
1197    @override
1198    def _get_signature_object_type(self) -> str:
1199        return "flag"
1200
1201    def _get_additional_index_types(self):
1202        return ["target"]
1203
1204
1205class _DefaultValueRole(sphinx_docutils.SphinxRole):
1206    """Documents the default value for an arg or attribute.
1207
1208    This is a special role used within `:arg:` and `:attr:` doc fields to
1209    indicate the default value. The rendering process looks for this role
1210    and reformats and moves its content for better display.
1211
1212    Styling can be customized by matching the `.default_value` class.
1213    """
1214
1215    def run(self) -> _RoleRunResult:
1216        node = docutils_nodes.emphasis(
1217            "",
1218            "(default ",
1219            docutils_nodes.inline("", self.text, classes=["sig", "default_value"]),
1220            docutils_nodes.Text(") "),
1221            classes=["default-value-span"],
1222        )
1223        return ([node], [])
1224
1225
1226class _TypeRole(sphinx_docutils.SphinxRole):
1227    """Documents a type (or type expression) with crossreferencing.
1228
1229    This is an inline role used to create cross references to other types.
1230
1231    The content is interpreted as a reference to a type or an expression
1232    of types. The syntax uses Python-style sytax with `|` and `[]`, e.g.
1233    `foo.MyType | str | list[str] | dict[str, int]`. Each symbolic name
1234    will be turned into a cross reference; see the domain's documentation
1235    for how to reference objects.
1236
1237    Example MyST usage:
1238
1239    ```
1240    This function accepts {bzl:type}`str | list[str]` for usernames
1241    ```
1242    """
1243
1244    def __init__(self):
1245        super().__init__()
1246        self._xref = roles.XRefRole()
1247
1248    def run(self) -> _RoleRunResult:
1249        outer_messages = []
1250
1251        def make_xref(name):
1252            nodes, msgs = self._xref(
1253                "bzl:type",
1254                name,
1255                name,
1256                self.lineno,
1257                self.inliner,
1258                self.options,
1259                self.content,
1260            )
1261            outer_messages.extend(msgs)
1262            if len(nodes) == 1:
1263                return nodes[0]
1264            else:
1265                return docutils_nodes.inline("", "", nodes)
1266
1267        root = _TypeExprParser.xrefs_from_type_expr(self.text, make_xref)
1268        return ([root], outer_messages)
1269
1270
1271class _ReturnTypeRole(_TypeRole):
1272    """Documents the return type for function.
1273
1274    This is a special role used within `:returns:` doc fields to
1275    indicate the return type of the function. The rendering process looks for
1276    this role and reformats and moves its content for better display.
1277
1278    Example MyST Usage
1279
1280    ```
1281    :::{bzl:function} foo()
1282
1283    :returns: {return-type}`list[str]`
1284    :::
1285    ```
1286    """
1287
1288    def run(self) -> _RoleRunResult:
1289        nodes, messages = super().run()
1290        nodes.append(docutils_nodes.Text(" -- "))
1291        return nodes, messages
1292
1293
1294class _RequiredProvidersRole(_TypeRole):
1295    """Documents the providers an attribute requires.
1296
1297    This is a special role used within `:arg:` or `:attr:` doc fields to
1298    indicate the types of providers that are required. The rendering process
1299    looks for this role and reformats its content for better display, but its
1300    position is left as-is; typically it would be its own paragraph near the
1301    end of the doc.
1302
1303    The syntax is a pipe (`|`) delimited list of types or groups of types,
1304    where groups are indicated using `[...]`. e.g, to express that FooInfo OR
1305    (both of BarInfo and BazInfo) are supported, write `FooInfo | [BarInfo,
1306    BazInfo]`
1307
1308    Example MyST Usage
1309
1310    ```
1311    :::{bzl:rule} foo(bar)
1312
1313    :attr bar: My attribute doc
1314
1315      {required-providers}`CcInfo | [PyInfo, JavaInfo]`
1316    :::
1317    ```
1318    """
1319
1320    def run(self) -> _RoleRunResult:
1321        xref_nodes, messages = super().run()
1322        nodes = [
1323            docutils_nodes.emphasis("", "Required providers: "),
1324        ] + xref_nodes
1325        return nodes, messages
1326
1327
1328class _BzlIndex(domains.Index):
1329    """An index of a bzl file's objects.
1330
1331    NOTE: This generates the entries for the *domain specific* index
1332    (bzl-index.html), not the general index (genindex.html). To affect
1333    the general index, index nodes and directives must be used (grep
1334    for `self.indexnode`).
1335    """
1336
1337    name = "index"
1338    localname = "Bazel/Starlark Object Index"
1339    shortname = "Bzl"
1340
1341    def generate(
1342        self, docnames: Iterable[str] = None
1343    ) -> tuple[list[tuple[str, list[domains.IndexEntry]]], bool]:
1344        content = collections.defaultdict(list)
1345
1346        # sort the list of objects in alphabetical order
1347        objects = self.domain.data["objects"].values()
1348        objects = sorted(objects, key=lambda obj: obj.index_entry.name)
1349
1350        # Group by first letter
1351        for entry in objects:
1352            index_entry = entry.index_entry
1353            content[index_entry.name[0].lower()].append(index_entry)
1354
1355        # convert the dict to the sorted list of tuples expected
1356        content = sorted(content.items())
1357
1358        return content, True
1359
1360
1361class _BzlDomain(domains.Domain):
1362    """Domain for Bazel/Starlark objects.
1363
1364    Directives
1365
1366    There are directives for defining Bazel objects and their functionality.
1367    See the respective directive classes for details.
1368
1369    Public Crossreferencing Roles
1370
1371    These are roles that can be used in docs to create cross references.
1372
1373    Objects are fully identified using dotted notation converted from the Bazel
1374    label and symbol name within a `.bzl` file. The `@`, `/` and `:` characters
1375    are converted to dots (with runs removed), and `.bzl` is removed from file
1376    names. The dotted path of a symbol in the bzl file is appended. For example,
1377    the `paths.join` function in `@bazel_skylib//lib:paths.bzl` would be
1378    identified as `bazel_skylib.lib.paths.paths.join`.
1379
1380    Shorter identifiers can be used. Within a project, the repo name portion
1381    can be omitted. Within a file, file-relative names can be used.
1382
1383    * obj: Used to reference a single object without concern for its type.
1384      This roles searches all object types for a name that matches the given
1385      value. Example usage in MyST:
1386      ```
1387      {bzl:obj}`repo.pkg.file.my_function`
1388      ```
1389
1390    * type: Transforms a type expression into cross references for objects
1391      with object type "type". For example, it parses `int | list[str]` into
1392      three links for each component part.
1393
1394    Public Typography Roles
1395
1396    These are roles used for special purposes to aid documentation.
1397
1398    * default-value: The default value for an argument or attribute. Only valid
1399      to use within arg or attribute documentation. See `_DefaultValueRole` for
1400      details.
1401    * required-providers: The providers an attribute requires. Only
1402      valud to use within an attribute documentation. See
1403      `_RequiredProvidersRole` for details.
1404    * return-type: The type of value a function returns. Only valid
1405      within a function's return doc field. See `_ReturnTypeRole` for details.
1406
1407    Object Types
1408
1409    These are the types of objects that this domain keeps in its index.
1410
1411    * arg: An argument to a function or macro.
1412    * aspect: A Bazel `aspect`.
1413    * attribute: An input to a rule (regular, repository, aspect, or module
1414      extension).
1415    * method: A function bound to an instance of a struct acting as a type.
1416    * module-extension: A Bazel `module_extension`.
1417    * provider: A Bazel `provider`.
1418    * provider-field: A field of a provider.
1419    * repo-rule: A Bazel `repository_rule`.
1420    * rule: A regular Bazel `rule`.
1421    * tag-class: A Bazel `tag_class` of a `module_extension`.
1422    * target: A Bazel target.
1423    * type: A builtin Bazel type or user-defined structural type. User defined
1424      structual types are typically instances `struct` created using a function
1425      that acts as a constructor with implicit state bound using closures.
1426    """
1427
1428    name = "bzl"
1429    label = "Bzl"
1430
1431    # NOTE: Most every object type has "obj" as one of the roles because
1432    # an object type's role determine what reftypes (cross referencing) can
1433    # refer to it. By having "obj" for all of them, it allows writing
1434    # :bzl:obj`foo` to restrict object searching to the bzl domain. Under the
1435    # hood, this domain translates requests for the :any: role as lookups for
1436    # :obj:.
1437    # NOTE: We also use these object types for categorizing things in the
1438    # generated index page.
1439    object_types = {
1440        "arg": domains.ObjType("arg", "arg", "obj"),  # macro/function arg
1441        "aspect": domains.ObjType("aspect", "aspect", "obj"),
1442        "attr": domains.ObjType("attr", "attr", "obj"),  # rule attribute
1443        "function": domains.ObjType("function", "func", "obj"),
1444        "method": domains.ObjType("method", "method", "obj"),
1445        "module-extension": domains.ObjType(
1446            "module extension", "module_extension", "obj"
1447        ),
1448        # Providers are close enough to types that we include "type". This
1449        # also makes :type: Foo work in directive options.
1450        "provider": domains.ObjType("provider", "provider", "type", "obj"),
1451        "provider-field": domains.ObjType("provider field", "field", "obj"),
1452        "repo-rule": domains.ObjType("repository rule", "repo_rule", "obj"),
1453        "rule": domains.ObjType("rule", "rule", "obj"),
1454        "tag-class": domains.ObjType("tag class", "tag_class", "obj"),
1455        "target": domains.ObjType("target", "target", "obj"),  # target in a build file
1456        # Flags are also targets, so include "target" for xref'ing
1457        "flag": domains.ObjType("flag", "flag", "target", "obj"),
1458        # types are objects that have a constructor and methods/attrs
1459        "type": domains.ObjType("type", "type", "obj"),
1460    }
1461
1462    # This controls:
1463    # * What is recognized when parsing, e.g. ":bzl:ref:`foo`" requires
1464    # "ref" to be in the role dict below.
1465    roles = {
1466        "arg": roles.XRefRole(),
1467        "attr": roles.XRefRole(),
1468        "default-value": _DefaultValueRole(),
1469        "flag": roles.XRefRole(),
1470        "obj": roles.XRefRole(),
1471        "required-providers": _RequiredProvidersRole(),
1472        "return-type": _ReturnTypeRole(),
1473        "rule": roles.XRefRole(),
1474        "target": roles.XRefRole(),
1475        "type": _TypeRole(),
1476    }
1477    # NOTE: Directives that have a corresponding object type should use
1478    # the same key for both directive and object type. Some directives
1479    # look up their corresponding object type.
1480    directives = {
1481        "aspect": _BzlAspect,
1482        "currentfile": _BzlCurrentFile,
1483        "function": _BzlFunction,
1484        "module-extension": _BzlModuleExtension,
1485        "provider": _BzlProvider,
1486        "provider-field": _BzlProviderField,
1487        "repo-rule": _BzlRepositoryRule,
1488        "rule": _BzlRule,
1489        "tag-class": _BzlTagClass,
1490        "target": _BzlTarget,
1491        "flag": _BzlFlag,
1492        "attr-info": _BzlAttrInfo,
1493    }
1494    indices = {
1495        _BzlIndex,
1496    }
1497
1498    # NOTE: When adding additional data keys, make sure to update
1499    # merge_domaindata
1500    initial_data = {
1501        # All objects; keyed by full id
1502        # dict[str, _ObjectEntry]
1503        "objects": {},
1504        #  dict[str, dict[str, _ObjectEntry]]
1505        "objects_by_type": {},
1506        # Objects within each doc
1507        # dict[str, dict[str, _ObjectEntry]]
1508        "doc_names": {},
1509        # Objects by a shorter or alternative name
1510        # dict[str, dict[str id, _ObjectEntry]]
1511        "alt_names": {},
1512    }
1513
1514    @override
1515    def get_full_qualified_name(
1516        self, node: docutils_nodes.Element
1517    ) -> typing.Union[str, None]:
1518        bzl_file = node.get("bzl:file")
1519        symbol_name = node.get("bzl:symbol")
1520        ref_target = node.get("reftarget")
1521        return ".".join(filter(None, [bzl_file, symbol_name, ref_target]))
1522
1523    @override
1524    def get_objects(self) -> Iterable[_GetObjectsTuple]:
1525        for entry in self.data["objects"].values():
1526            yield entry.to_get_objects_tuple()
1527
1528    @override
1529    def resolve_any_xref(
1530        self,
1531        env: environment.BuildEnvironment,
1532        fromdocname: str,
1533        builder: builders.Builder,
1534        target: str,
1535        node: addnodes.pending_xref,
1536        contnode: docutils_nodes.Element,
1537    ) -> list[tuple[str, docutils_nodes.Element]]:
1538        del env, node  # Unused
1539        entry = self._find_entry_for_xref(fromdocname, "obj", target)
1540        if not entry:
1541            return []
1542        to_docname = entry.index_entry.docname
1543        to_anchor = entry.index_entry.anchor
1544        ref_node = sphinx_nodes.make_refnode(
1545            builder, fromdocname, to_docname, to_anchor, contnode, title=to_anchor
1546        )
1547
1548        matches = [(f"bzl:{entry.object_type}", ref_node)]
1549        return matches
1550
1551    @override
1552    def resolve_xref(
1553        self,
1554        env: environment.BuildEnvironment,
1555        fromdocname: str,
1556        builder: builders.Builder,
1557        typ: str,
1558        target: str,
1559        node: addnodes.pending_xref,
1560        contnode: docutils_nodes.Element,
1561    ) -> typing.Union[docutils_nodes.Element, None]:
1562        _log_debug(
1563            "resolve_xref: fromdocname=%s, typ=%s, target=%s", fromdocname, typ, target
1564        )
1565        del env, node  # Unused
1566        entry = self._find_entry_for_xref(fromdocname, typ, target)
1567        if not entry:
1568            return None
1569
1570        to_docname = entry.index_entry.docname
1571        to_anchor = entry.index_entry.anchor
1572        return sphinx_nodes.make_refnode(
1573            builder, fromdocname, to_docname, to_anchor, contnode, title=to_anchor
1574        )
1575
1576    def _find_entry_for_xref(
1577        self, fromdocname: str, object_type: str, target: str
1578    ) -> typing.Union[_ObjectEntry, None]:
1579        if target.startswith("--"):
1580            target = target.strip("-")
1581            object_type = "flag"
1582
1583        # Allow using parentheses, e.g. `foo()` or `foo(x=...)`
1584        target, _, _ = target.partition("(")
1585
1586        # Elide the value part of --foo=bar flags
1587        # Note that the flag value could contain `=`
1588        if "=" in target:
1589            target = target[: target.find("=")]
1590
1591        if target in self.data["doc_names"].get(fromdocname, {}):
1592            entry = self.data["doc_names"][fromdocname][target]
1593            # Prevent a local doc name masking a global alt name when its of
1594            # a different type. e.g. when the macro `foo` refers to the
1595            # rule `foo` in another doc.
1596            if object_type in self.object_types[entry.object_type].roles:
1597                return entry
1598
1599        if object_type == "obj":
1600            search_space = self.data["objects"]
1601        else:
1602            search_space = self.data["objects_by_type"].get(object_type, {})
1603        if target in search_space:
1604            return search_space[target]
1605
1606        _log_debug("find_entry: alt_names=%s", sorted(self.data["alt_names"].keys()))
1607        if target in self.data["alt_names"]:
1608            # Give preference to shorter object ids. This is a work around
1609            # to allow e.g. `FooInfo` to refer to the FooInfo type rather than
1610            # the `FooInfo` constructor.
1611            entries = sorted(
1612                self.data["alt_names"][target].items(), key=lambda item: len(item[0])
1613            )
1614            for _, entry in entries:
1615                if object_type in self.object_types[entry.object_type].roles:
1616                    return entry
1617
1618        return None
1619
1620    def add_object(self, entry: _ObjectEntry, alt_names=None) -> None:
1621        _log_debug(
1622            "add_object: full_id=%s, object_type=%s, alt_names=%s",
1623            entry.full_id,
1624            entry.object_type,
1625            alt_names,
1626        )
1627        if entry.full_id in self.data["objects"]:
1628            existing = self.data["objects"][entry.full_id]
1629            raise Exception(
1630                f"Object {entry.full_id} already registered: "
1631                + f"existing={existing}, incoming={entry}"
1632            )
1633        self.data["objects"][entry.full_id] = entry
1634        self.data["objects_by_type"].setdefault(entry.object_type, {})
1635        self.data["objects_by_type"][entry.object_type][entry.full_id] = entry
1636
1637        repo, label, symbol = _parse_full_id(entry.full_id)
1638        if symbol:
1639            base_name = symbol.split(".")[-1]
1640        else:
1641            base_name = label.split(":")[-1]
1642
1643        if alt_names is not None:
1644            alt_names = list(alt_names)
1645        # Add the repo-less version as an alias
1646        alt_names.append(label + (f"%{symbol}" if symbol else ""))
1647
1648        for alt_name in sorted(set(alt_names)):
1649            self.data["alt_names"].setdefault(alt_name, {})
1650            self.data["alt_names"][alt_name][entry.full_id] = entry
1651
1652        docname = entry.index_entry.docname
1653        self.data["doc_names"].setdefault(docname, {})
1654        self.data["doc_names"][docname][base_name] = entry
1655
1656    def merge_domaindata(
1657        self, docnames: list[str], otherdata: dict[str, typing.Any]
1658    ) -> None:
1659        # Merge in simple dict[key, value] data
1660        for top_key in ("objects",):
1661            self.data[top_key].update(otherdata.get(top_key, {}))
1662
1663        # Merge in two-level dict[top_key, dict[sub_key, value]] data
1664        for top_key in ("objects_by_type", "doc_names", "alt_names"):
1665            existing_top_map = self.data[top_key]
1666            for sub_key, sub_values in otherdata.get(top_key, {}).items():
1667                if sub_key not in existing_top_map:
1668                    existing_top_map[sub_key] = sub_values
1669                else:
1670                    existing_top_map[sub_key].update(sub_values)
1671
1672
1673def _on_missing_reference(app, env: environment.BuildEnvironment, node, contnode):
1674    if node["refdomain"] != "bzl":
1675        return None
1676    if node["reftype"] != "type":
1677        return None
1678
1679    # There's no Bazel docs for None, so prevent missing xrefs warning
1680    if node["reftarget"] == "None":
1681        return contnode
1682    return None
1683
1684
1685def setup(app):
1686    app.add_domain(_BzlDomain)
1687
1688    app.add_config_value(
1689        "bzl_default_repository_name",
1690        default=os.environ.get("SPHINX_BZL_DEFAULT_REPOSITORY_NAME", "@_main"),
1691        rebuild="env",
1692        types=[str],
1693    )
1694    app.connect("missing-reference", _on_missing_reference)
1695
1696    # Pygments says it supports starlark, but it doesn't seem to actually
1697    # recognize `starlark` as a name. So just manually map it to python.
1698    app.add_lexer("starlark", lexer_classes["python"])
1699    app.add_lexer("bzl", lexer_classes["python"])
1700
1701    return {
1702        "version": "1.0.0",
1703        "parallel_read_safe": True,
1704        "parallel_write_safe": True,
1705    }
1706