• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 _BzlDocField(_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
627        if name_prefix:
628            # Respect whatever the signature wanted
629            display_prefix = name_prefix
630        else:
631            # Otherwise, show the outermost name. This makes ctrl+f finding
632            # for a symbol a bit easier.
633            display_prefix = ".".join(self.env.ref_context["bzl:doc_id_stack"])
634            _, _, display_prefix = display_prefix.rpartition(".")
635
636        if display_prefix:
637            display_prefix = display_prefix + "."
638            sig_node += addnodes.desc_addname(display_prefix, display_prefix)
639        sig_node += addnodes.desc_name(base_symbol_name, base_symbol_name)
640
641        if type_expr := self.options.get("type"):
642
643            def make_xref(name, title=None):
644                content_node = addnodes.desc_type(name, name)
645                return addnodes.pending_xref(
646                    "",
647                    content_node,
648                    refdomain="bzl",
649                    reftype="type",
650                    reftarget=name,
651                )
652
653            attr_annotation_node = addnodes.desc_annotation(
654                type_expr,
655                "",
656                addnodes.desc_sig_punctuation("", ":"),
657                addnodes.desc_sig_space(),
658                _TypeExprParser.xrefs_from_type_expr(type_expr, make_xref),
659            )
660            sig_node += attr_annotation_node
661
662        if params_text:
663            try:
664                signature = inspect.signature_from_str(params_text)
665            except SyntaxError:
666                # Stardoc doesn't provide accurate info, so the reconstructed
667                # signature might not be valid syntax. Rather than fail, just
668                # provide a plain-text description of the approximate signature.
669                # See https://github.com/bazelbuild/stardoc/issues/225
670                sig_node += addnodes.desc_parameterlist(
671                    # Offset by 1 to remove the surrounding parentheses
672                    params_text[1:-1],
673                    params_text[1:-1],
674                )
675            else:
676                last_kind = None
677                paramlist_node = addnodes.desc_parameterlist()
678                for param in signature.parameters.values():
679                    if param.kind == param.KEYWORD_ONLY and last_kind in (
680                        param.POSITIONAL_OR_KEYWORD,
681                        param.POSITIONAL_ONLY,
682                        None,
683                    ):
684                        # Add separator for keyword only parameter: *
685                        paramlist_node += addnodes.desc_parameter(
686                            "", "", addnodes.desc_sig_operator("", "*")
687                        )
688
689                    last_kind = param.kind
690                    node = addnodes.desc_parameter()
691                    if param.kind == param.VAR_POSITIONAL:
692                        node += addnodes.desc_sig_operator("", "*")
693                    elif param.kind == param.VAR_KEYWORD:
694                        node += addnodes.desc_sig_operator("", "**")
695
696                    node += addnodes.desc_sig_name(rawsource="", text=param.name)
697                    if param.default is not param.empty:
698                        node += addnodes.desc_sig_operator("", "=")
699                        node += docutils_nodes.inline(
700                            "",
701                            param.default,
702                            classes=["default_value"],
703                            support_smartquotes=False,
704                        )
705                    paramlist_node += node
706                sig_node += paramlist_node
707
708                if signature.return_annotation is not signature.empty:
709                    sig_node += addnodes.desc_returns("", signature.return_annotation)
710
711        obj_id = _BzlObjectId.from_env(self.env, symbol=relative_name)
712
713        sig_node["bzl:object_id"] = obj_id.full_id
714        return obj_id
715
716    def _signature_add_object_type(self, sig_node: addnodes.desc_signature):
717        if sig_object_type := self._get_signature_object_type():
718            sig_node += addnodes.desc_annotation("", self._get_signature_object_type())
719            sig_node += addnodes.desc_sig_space()
720
721    @override
722    def add_target_and_index(
723        self, obj_desc: _BzlObjectId, sig: str, sig_node: addnodes.desc_signature
724    ) -> None:
725        super().add_target_and_index(obj_desc, sig, sig_node)
726        if obj_desc.symbol:
727            display_name = obj_desc.symbol
728            location = obj_desc.label
729            if obj_desc.namespace:
730                location += f"%{obj_desc.namespace}"
731        else:
732            display_name = obj_desc.target_name
733            location = obj_desc.package
734
735        anchor_prefix = ".".join(self.env.ref_context["bzl:doc_id_stack"])
736        if anchor_prefix:
737            anchor_id = f"{anchor_prefix}.{obj_desc.doc_id}"
738        else:
739            anchor_id = obj_desc.doc_id
740
741        sig_node["ids"].append(anchor_id)
742
743        object_type_display = self._get_object_type_display_name()
744        index_description = f"{display_name} ({object_type_display} in {location})"
745        self.indexnode["entries"].extend(
746            _index_node_tuple("single", f"{index_type}; {index_description}", anchor_id)
747            for index_type in [object_type_display] + self._get_additional_index_types()
748        )
749        self.indexnode["entries"].append(
750            _index_node_tuple("single", index_description, anchor_id),
751        )
752
753        object_entry = _ObjectEntry(
754            full_id=obj_desc.full_id,
755            display_name=display_name,
756            object_type=self.objtype,
757            search_priority=1,
758            index_entry=domains.IndexEntry(
759                name=display_name,
760                subtype=_INDEX_SUBTYPE_NORMAL,
761                docname=self.env.docname,
762                anchor=anchor_id,
763                extra="",
764                qualifier="",
765                descr=index_description,
766            ),
767        )
768
769        alt_names = []
770        if origin_key := self.options.get("origin-key"):
771            alt_names.append(
772                origin_key
773                # Options require \@ for leading @, but don't
774                # remove the escaping slash, so we have to do it manually
775                .lstrip("\\")
776            )
777        extra_alt_names = self._get_alt_names(object_entry)
778        alt_names.extend(extra_alt_names)
779
780        self.env.get_domain(self.domain).add_object(object_entry, alt_names=alt_names)
781
782    def _get_additional_index_types(self):
783        return []
784
785    @override
786    def _object_hierarchy_parts(
787        self, sig_node: addnodes.desc_signature
788    ) -> tuple[str, ...]:
789        return _parse_full_id(sig_node["bzl:object_id"])
790
791    @override
792    def _toc_entry_name(self, sig_node: addnodes.desc_signature) -> str:
793        return sig_node["_toc_parts"][-1]
794
795    def _get_object_type_display_name(self) -> str:
796        return self.env.get_domain(self.domain).object_types[self.objtype].lname
797
798    def _get_signature_object_type(self) -> str:
799        return self._get_object_type_display_name()
800
801    def _get_alt_names(self, object_entry):
802        alt_names = []
803        full_id = object_entry.full_id
804        label, _, symbol = full_id.partition("%")
805        if symbol:
806            # Allow referring to the file-relative fully qualified symbol name
807            alt_names.append(symbol)
808            if "." in symbol:
809                # Allow referring to the last component of the symbol
810                alt_names.append(symbol.split(".")[-1])
811        else:
812            # Otherwise, it's a target. Allow referring to just the target name
813            _, _, target_name = label.partition(":")
814            alt_names.append(target_name)
815
816        return alt_names
817
818
819class _BzlCallable(_BzlObject):
820    """Abstract base class for objects that are callable."""
821
822
823class _BzlTypedef(_BzlObject):
824    """Documents a typedef.
825
826    A typedef describes objects with well known attributes.
827
828    `````
829    ::::{bzl:typedef} Square
830
831    :::{bzl:field} width
832    :type: int
833    :::
834
835    :::{bzl:function} new(size)
836    :::
837
838    :::{bzl:function} area()
839    :::
840    ::::
841    `````
842    """
843
844
845class _BzlProvider(_BzlObject):
846    """Documents a provider type.
847
848    Example MyST usage
849
850    ```
851    ::::{bzl:provider} MyInfo
852
853    Docs about MyInfo
854
855    :::{bzl:provider-field} some_field
856    :type: depset[str]
857    :::
858    ::::
859    ```
860    """
861
862
863class _BzlField(_BzlObject):
864    """Documents a field of a provider.
865
866    Fields can optionally have a type specified using the `:type:` option.
867
868    The type can be any type expression understood by the `{bzl:type}` role.
869
870    ```
871    :::{bzl:provider-field} foo
872    :type: str
873    :::
874    ```
875    """
876
877    option_spec = _BzlObject.option_spec.copy()
878    option_spec.update(
879        {
880            "type": docutils_directives.unchanged,
881        }
882    )
883
884    @override
885    def _get_signature_object_type(self) -> str:
886        return ""
887
888    @override
889    def _get_alt_names(self, object_entry):
890        alt_names = super()._get_alt_names(object_entry)
891        _, _, symbol = object_entry.full_id.partition("%")
892        # Allow refering to `mod_ext_name.tag_name`, even if the extension
893        # is nested within another object
894        alt_names.append(".".join(symbol.split(".")[-2:]))
895        return alt_names
896
897
898class _BzlProviderField(_BzlField):
899    pass
900
901
902class _BzlRepositoryRule(_BzlCallable):
903    """Documents a repository rule.
904
905    Doc fields:
906    * attr: Documents attributes of the rule. Takes a single arg, the
907      attribute name. Can be repeated. The special roles `{default-value}`
908      and `{arg-type}` can be used to indicate the default value and
909      type of attribute, respectively.
910    * environment-variables: a CSV list of environment variable names.
911      They will be cross referenced with matching environment variables.
912
913    Example MyST usage
914
915    ```
916    :::{bzl:repo-rule} myrule(foo)
917
918    :attr foo: {default-value}`"foo"` {arg-type}`attr.string` foo doc string
919
920    :environment-variables: FOO, BAR
921    :::
922    ```
923    """
924
925    doc_field_types = [
926        _BzlGroupedField(
927            "attr",
928            label=_("Attributes"),
929            names=["attr"],
930            rolename="attr",
931            can_collapse=False,
932        ),
933        _BzlCsvField(
934            "environment-variables",
935            label=_("Environment Variables"),
936            names=["environment-variables"],
937            body_domain="std",
938            bodyrolename="envvar",
939            has_arg=False,
940        ),
941    ]
942
943    @override
944    def _get_signature_object_type(self) -> str:
945        return "repo rule"
946
947
948class _BzlRule(_BzlCallable):
949    """Documents a rule.
950
951    Doc fields:
952    * attr: Documents attributes of the rule. Takes a single arg, the
953      attribute name. Can be repeated. The special roles `{default-value}`
954      and `{arg-type}` can be used to indicate the default value and
955      type of attribute, respectively.
956    * provides: A type expression of the provider types the rule provides.
957      To indicate different groupings, use `|` and `[]`. For example,
958      `FooInfo | [BarInfo, BazInfo]` means it provides either `FooInfo`
959      or both of `BarInfo` and `BazInfo`.
960
961    Example MyST usage
962
963    ```
964    :::{bzl:repo-rule} myrule(foo)
965
966    :attr foo: {default-value}`"foo"` {arg-type}`attr.string` foo doc string
967
968    :provides: FooInfo | BarInfo
969    :::
970    ```
971    """
972
973    doc_field_types = [
974        _BzlGroupedField(
975            "attr",
976            label=_("Attributes"),
977            names=["attr"],
978            rolename="attr",
979            can_collapse=False,
980        ),
981        _BzlDocField(
982            "provides",
983            label="Provides",
984            has_arg=False,
985            names=["provides"],
986            bodyrolename="type",
987        ),
988    ]
989
990
991class _BzlAspect(_BzlObject):
992    """Documents an aspect.
993
994    Doc fields:
995    * attr: Documents attributes of the aspect. Takes a single arg, the
996      attribute name. Can be repeated. The special roles `{default-value}`
997      and `{arg-type}` can be used to indicate the default value and
998      type of attribute, respectively.
999    * aspect-attributes: A CSV list of attribute names the aspect
1000      propagates along.
1001
1002    Example MyST usage
1003
1004    ```
1005    :::{bzl:repo-rule} myaspect
1006
1007    :attr foo: {default-value}`"foo"` {arg-type}`attr.string` foo doc string
1008
1009    :aspect-attributes: srcs, deps
1010    :::
1011    ```
1012    """
1013
1014    doc_field_types = [
1015        _BzlGroupedField(
1016            "attr",
1017            label=_("Attributes"),
1018            names=["attr"],
1019            rolename="attr",
1020            can_collapse=False,
1021        ),
1022        _BzlCsvField(
1023            "aspect-attributes",
1024            label=_("Aspect Attributes"),
1025            names=["aspect-attributes"],
1026            has_arg=False,
1027        ),
1028    ]
1029
1030
1031class _BzlFunction(_BzlCallable):
1032    """Documents a general purpose function.
1033
1034    Doc fields:
1035    * arg: Documents the arguments of the function. Takes a single arg, the
1036      arg name. Can be repeated. The special roles `{default-value}`
1037      and `{arg-type}` can be used to indicate the default value and
1038      type of attribute, respectively.
1039    * returns: Documents what the function returns. The special role
1040      `{return-type}` can be used to indicate the return type of the function.
1041
1042    Example MyST usage
1043
1044    ```
1045    :::{bzl:function} myfunc(a, b=None) -> bool
1046
1047    :arg a: {arg-type}`str` some arg doc
1048    :arg b: {arg-type}`int | None` {default-value}`42` more arg doc
1049    :returns: {return-type}`bool` doc about return value.
1050    :::
1051    ```
1052    """
1053
1054    doc_field_types = [
1055        _BzlGroupedField(
1056            "arg",
1057            label=_("Args"),
1058            names=["arg"],
1059            rolename="arg",
1060            can_collapse=False,
1061        ),
1062        docfields.Field(
1063            "returns",
1064            label=_("Returns"),
1065            has_arg=False,
1066            names=["returns"],
1067        ),
1068    ]
1069
1070    @override
1071    def _get_signature_object_type(self) -> str:
1072        return ""
1073
1074
1075class _BzlModuleExtension(_BzlObject):
1076    """Documents a module_extension.
1077
1078    Doc fields:
1079    * os-dependent: Documents if the module extension depends on the host
1080      architecture.
1081    * arch-dependent: Documents if the module extension depends on the host
1082      architecture.
1083    * environment-variables: a CSV list of environment variable names.
1084      They will be cross referenced with matching environment variables.
1085
1086    Tag classes are documented using the bzl:tag-class directives within
1087    this directive.
1088
1089    Example MyST usage:
1090
1091    ```
1092    ::::{bzl:module-extension} myext
1093
1094    :os-dependent: True
1095    :arch-dependent: False
1096
1097    :::{bzl:tag-class} mytag(myattr)
1098
1099    :attr myattr:
1100      {arg-type}`attr.string_list`
1101      doc for attribute
1102    :::
1103    ::::
1104    ```
1105    """
1106
1107    doc_field_types = [
1108        _BzlDocField(
1109            "os-dependent",
1110            label="OS Dependent",
1111            has_arg=False,
1112            names=["os-dependent"],
1113        ),
1114        _BzlDocField(
1115            "arch-dependent",
1116            label="Arch Dependent",
1117            has_arg=False,
1118            names=["arch-dependent"],
1119        ),
1120        _BzlCsvField(
1121            "environment-variables",
1122            label=_("Environment Variables"),
1123            names=["environment-variables"],
1124            body_domain="std",
1125            bodyrolename="envvar",
1126            has_arg=False,
1127        ),
1128    ]
1129
1130    @override
1131    def _get_signature_object_type(self) -> str:
1132        return "module ext"
1133
1134
1135class _BzlTagClass(_BzlCallable):
1136    """Documents a tag class for a module extension.
1137
1138    Doc fields:
1139    * attr: Documents attributes of the tag class. Takes a single arg, the
1140      attribute name. Can be repeated. The special roles `{default-value}`
1141      and `{arg-type}` can be used to indicate the default value and
1142      type of attribute, respectively.
1143
1144    Example MyST usage, note that this directive should be nested with
1145    a `bzl:module-extension` directive.
1146
1147    ```
1148    :::{bzl:tag-class} mytag(myattr)
1149
1150    :attr myattr:
1151      {arg-type}`attr.string_list`
1152      doc for attribute
1153    :::
1154    ```
1155    """
1156
1157    doc_field_types = [
1158        _BzlGroupedField(
1159            "arg",
1160            label=_("Attributes"),
1161            names=["attr"],
1162            rolename="arg",
1163            can_collapse=False,
1164        ),
1165    ]
1166
1167    @override
1168    def _get_signature_object_type(self) -> str:
1169        return ""
1170
1171    @override
1172    def _get_alt_names(self, object_entry):
1173        alt_names = super()._get_alt_names(object_entry)
1174        _, _, symbol = object_entry.full_id.partition("%")
1175        # Allow refering to `ProviderName.field`, even if the provider
1176        # is nested within another object
1177        alt_names.append(".".join(symbol.split(".")[-2:]))
1178        return alt_names
1179
1180
1181class _TargetType(enum.Enum):
1182    TARGET = "target"
1183    FLAG = "flag"
1184
1185
1186class _BzlTarget(_BzlObject):
1187    """Documents an arbitrary target."""
1188
1189    _TARGET_TYPE = _TargetType.TARGET
1190
1191    def handle_signature(self, sig_text, sig_node):
1192        self._signature_add_object_type(sig_node)
1193        if ":" in sig_text:
1194            package, target_name = sig_text.split(":", 1)
1195        else:
1196            target_name = sig_text
1197            package = self.env.ref_context["bzl:file"]
1198            package = package[: package.find(":BUILD")]
1199
1200        package = package + ":"
1201        if self._TARGET_TYPE == _TargetType.FLAG:
1202            sig_node += addnodes.desc_addname("--", "--")
1203        sig_node += addnodes.desc_addname(package, package)
1204        sig_node += addnodes.desc_name(target_name, target_name)
1205
1206        obj_id = _BzlObjectId.from_env(self.env, label=package + target_name)
1207        sig_node["bzl:object_id"] = obj_id.full_id
1208        return obj_id
1209
1210    @override
1211    def _get_signature_object_type(self) -> str:
1212        # We purposely return empty here because having "target" in front
1213        # of every label isn't very helpful
1214        return ""
1215
1216
1217# TODO: Integrate with the option directive, since flags are options, afterall.
1218# https://www.sphinx-doc.org/en/master/usage/domains/standard.html#directive-option
1219class _BzlFlag(_BzlTarget):
1220    """Documents a flag"""
1221
1222    _TARGET_TYPE = _TargetType.FLAG
1223
1224    @override
1225    def _get_signature_object_type(self) -> str:
1226        return "flag"
1227
1228    def _get_additional_index_types(self):
1229        return ["target"]
1230
1231
1232class _DefaultValueRole(sphinx_docutils.SphinxRole):
1233    """Documents the default value for an arg or attribute.
1234
1235    This is a special role used within `:arg:` and `:attr:` doc fields to
1236    indicate the default value. The rendering process looks for this role
1237    and reformats and moves its content for better display.
1238
1239    Styling can be customized by matching the `.default_value` class.
1240    """
1241
1242    def run(self) -> _RoleRunResult:
1243        node = docutils_nodes.emphasis(
1244            "",
1245            "(default ",
1246            docutils_nodes.inline("", self.text, classes=["sig", "default_value"]),
1247            docutils_nodes.Text(") "),
1248            classes=["default-value-span"],
1249        )
1250        return ([node], [])
1251
1252
1253class _TypeRole(sphinx_docutils.SphinxRole):
1254    """Documents a type (or type expression) with crossreferencing.
1255
1256    This is an inline role used to create cross references to other types.
1257
1258    The content is interpreted as a reference to a type or an expression
1259    of types. The syntax uses Python-style sytax with `|` and `[]`, e.g.
1260    `foo.MyType | str | list[str] | dict[str, int]`. Each symbolic name
1261    will be turned into a cross reference; see the domain's documentation
1262    for how to reference objects.
1263
1264    Example MyST usage:
1265
1266    ```
1267    This function accepts {bzl:type}`str | list[str]` for usernames
1268    ```
1269    """
1270
1271    def __init__(self):
1272        super().__init__()
1273        self._xref = roles.XRefRole()
1274
1275    def run(self) -> _RoleRunResult:
1276        outer_messages = []
1277
1278        def make_xref(name):
1279            nodes, msgs = self._xref(
1280                "bzl:type",
1281                name,
1282                name,
1283                self.lineno,
1284                self.inliner,
1285                self.options,
1286                self.content,
1287            )
1288            outer_messages.extend(msgs)
1289            if len(nodes) == 1:
1290                return nodes[0]
1291            else:
1292                return docutils_nodes.inline("", "", nodes)
1293
1294        root = _TypeExprParser.xrefs_from_type_expr(self.text, make_xref)
1295        return ([root], outer_messages)
1296
1297
1298class _ReturnTypeRole(_TypeRole):
1299    """Documents the return type for function.
1300
1301    This is a special role used within `:returns:` doc fields to
1302    indicate the return type of the function. The rendering process looks for
1303    this role and reformats and moves its content for better display.
1304
1305    Example MyST Usage
1306
1307    ```
1308    :::{bzl:function} foo()
1309
1310    :returns: {return-type}`list[str]`
1311    :::
1312    ```
1313    """
1314
1315    def run(self) -> _RoleRunResult:
1316        nodes, messages = super().run()
1317        nodes.append(docutils_nodes.Text(" -- "))
1318        return nodes, messages
1319
1320
1321class _RequiredProvidersRole(_TypeRole):
1322    """Documents the providers an attribute requires.
1323
1324    This is a special role used within `:arg:` or `:attr:` doc fields to
1325    indicate the types of providers that are required. The rendering process
1326    looks for this role and reformats its content for better display, but its
1327    position is left as-is; typically it would be its own paragraph near the
1328    end of the doc.
1329
1330    The syntax is a pipe (`|`) delimited list of types or groups of types,
1331    where groups are indicated using `[...]`. e.g, to express that FooInfo OR
1332    (both of BarInfo and BazInfo) are supported, write `FooInfo | [BarInfo,
1333    BazInfo]`
1334
1335    Example MyST Usage
1336
1337    ```
1338    :::{bzl:rule} foo(bar)
1339
1340    :attr bar: My attribute doc
1341
1342      {required-providers}`CcInfo | [PyInfo, JavaInfo]`
1343    :::
1344    ```
1345    """
1346
1347    def run(self) -> _RoleRunResult:
1348        xref_nodes, messages = super().run()
1349        nodes = [
1350            docutils_nodes.emphasis("", "Required providers: "),
1351        ] + xref_nodes
1352        return nodes, messages
1353
1354
1355class _BzlIndex(domains.Index):
1356    """An index of a bzl file's objects.
1357
1358    NOTE: This generates the entries for the *domain specific* index
1359    (bzl-index.html), not the general index (genindex.html). To affect
1360    the general index, index nodes and directives must be used (grep
1361    for `self.indexnode`).
1362    """
1363
1364    name = "index"
1365    localname = "Bazel/Starlark Object Index"
1366    shortname = "Bzl"
1367
1368    def generate(
1369        self, docnames: Iterable[str] = None
1370    ) -> tuple[list[tuple[str, list[domains.IndexEntry]]], bool]:
1371        content = collections.defaultdict(list)
1372
1373        # sort the list of objects in alphabetical order
1374        objects = self.domain.data["objects"].values()
1375        objects = sorted(objects, key=lambda obj: obj.index_entry.name)
1376
1377        # Group by first letter
1378        for entry in objects:
1379            index_entry = entry.index_entry
1380            content[index_entry.name[0].lower()].append(index_entry)
1381
1382        # convert the dict to the sorted list of tuples expected
1383        content = sorted(content.items())
1384
1385        return content, True
1386
1387
1388class _BzlDomain(domains.Domain):
1389    """Domain for Bazel/Starlark objects.
1390
1391    Directives
1392
1393    There are directives for defining Bazel objects and their functionality.
1394    See the respective directive classes for details.
1395
1396    Public Crossreferencing Roles
1397
1398    These are roles that can be used in docs to create cross references.
1399
1400    Objects are fully identified using dotted notation converted from the Bazel
1401    label and symbol name within a `.bzl` file. The `@`, `/` and `:` characters
1402    are converted to dots (with runs removed), and `.bzl` is removed from file
1403    names. The dotted path of a symbol in the bzl file is appended. For example,
1404    the `paths.join` function in `@bazel_skylib//lib:paths.bzl` would be
1405    identified as `bazel_skylib.lib.paths.paths.join`.
1406
1407    Shorter identifiers can be used. Within a project, the repo name portion
1408    can be omitted. Within a file, file-relative names can be used.
1409
1410    * obj: Used to reference a single object without concern for its type.
1411      This roles searches all object types for a name that matches the given
1412      value. Example usage in MyST:
1413      ```
1414      {bzl:obj}`repo.pkg.file.my_function`
1415      ```
1416
1417    * type: Transforms a type expression into cross references for objects
1418      with object type "type". For example, it parses `int | list[str]` into
1419      three links for each component part.
1420
1421    Public Typography Roles
1422
1423    These are roles used for special purposes to aid documentation.
1424
1425    * default-value: The default value for an argument or attribute. Only valid
1426      to use within arg or attribute documentation. See `_DefaultValueRole` for
1427      details.
1428    * required-providers: The providers an attribute requires. Only
1429      valud to use within an attribute documentation. See
1430      `_RequiredProvidersRole` for details.
1431    * return-type: The type of value a function returns. Only valid
1432      within a function's return doc field. See `_ReturnTypeRole` for details.
1433
1434    Object Types
1435
1436    These are the types of objects that this domain keeps in its index.
1437
1438    * arg: An argument to a function or macro.
1439    * aspect: A Bazel `aspect`.
1440    * attribute: An input to a rule (regular, repository, aspect, or module
1441      extension).
1442    * method: A function bound to an instance of a struct acting as a type.
1443    * module-extension: A Bazel `module_extension`.
1444    * provider: A Bazel `provider`.
1445    * provider-field: A field of a provider.
1446    * repo-rule: A Bazel `repository_rule`.
1447    * rule: A regular Bazel `rule`.
1448    * tag-class: A Bazel `tag_class` of a `module_extension`.
1449    * target: A Bazel target.
1450    * type: A builtin Bazel type or user-defined structural type. User defined
1451      structual types are typically instances `struct` created using a function
1452      that acts as a constructor with implicit state bound using closures.
1453    """
1454
1455    name = "bzl"
1456    label = "Bzl"
1457
1458    # NOTE: Most every object type has "obj" as one of the roles because
1459    # an object type's role determine what reftypes (cross referencing) can
1460    # refer to it. By having "obj" for all of them, it allows writing
1461    # :bzl:obj`foo` to restrict object searching to the bzl domain. Under the
1462    # hood, this domain translates requests for the :any: role as lookups for
1463    # :obj:.
1464    # NOTE: We also use these object types for categorizing things in the
1465    # generated index page.
1466    object_types = {
1467        "arg": domains.ObjType("arg", "arg", "obj"),  # macro/function arg
1468        "aspect": domains.ObjType("aspect", "aspect", "obj"),
1469        "attr": domains.ObjType("attr", "attr", "obj"),  # rule attribute
1470        "function": domains.ObjType("function", "func", "obj"),
1471        "method": domains.ObjType("method", "method", "obj"),
1472        "module-extension": domains.ObjType(
1473            "module extension", "module_extension", "obj"
1474        ),
1475        # Providers are close enough to types that we include "type". This
1476        # also makes :type: Foo work in directive options.
1477        "provider": domains.ObjType("provider", "provider", "type", "obj"),
1478        "provider-field": domains.ObjType("provider field", "provider-field", "obj"),
1479        "field": domains.ObjType("field", "field", "obj"),
1480        "repo-rule": domains.ObjType("repository rule", "repo_rule", "obj"),
1481        "rule": domains.ObjType("rule", "rule", "obj"),
1482        "tag-class": domains.ObjType("tag class", "tag_class", "obj"),
1483        "target": domains.ObjType("target", "target", "obj"),  # target in a build file
1484        # Flags are also targets, so include "target" for xref'ing
1485        "flag": domains.ObjType("flag", "flag", "target", "obj"),
1486        # types are objects that have a constructor and methods/attrs
1487        "type": domains.ObjType("type", "type", "obj"),
1488        "typedef": domains.ObjType("typedef", "typedef", "type", "obj"),
1489    }
1490
1491    # This controls:
1492    # * What is recognized when parsing, e.g. ":bzl:ref:`foo`" requires
1493    # "ref" to be in the role dict below.
1494    roles = {
1495        "arg": roles.XRefRole(),
1496        "attr": roles.XRefRole(),
1497        "default-value": _DefaultValueRole(),
1498        "flag": roles.XRefRole(),
1499        "obj": roles.XRefRole(),
1500        "required-providers": _RequiredProvidersRole(),
1501        "return-type": _ReturnTypeRole(),
1502        "rule": roles.XRefRole(),
1503        "target": roles.XRefRole(),
1504        "type": _TypeRole(),
1505    }
1506    # NOTE: Directives that have a corresponding object type should use
1507    # the same key for both directive and object type. Some directives
1508    # look up their corresponding object type.
1509    directives = {
1510        "aspect": _BzlAspect,
1511        "currentfile": _BzlCurrentFile,
1512        "function": _BzlFunction,
1513        "module-extension": _BzlModuleExtension,
1514        "provider": _BzlProvider,
1515        "typedef": _BzlTypedef,
1516        "provider-field": _BzlProviderField,
1517        "field": _BzlField,
1518        "repo-rule": _BzlRepositoryRule,
1519        "rule": _BzlRule,
1520        "tag-class": _BzlTagClass,
1521        "target": _BzlTarget,
1522        "flag": _BzlFlag,
1523        "attr-info": _BzlAttrInfo,
1524    }
1525    indices = {
1526        _BzlIndex,
1527    }
1528
1529    # NOTE: When adding additional data keys, make sure to update
1530    # merge_domaindata
1531    initial_data = {
1532        # All objects; keyed by full id
1533        # dict[str, _ObjectEntry]
1534        "objects": {},
1535        #  dict[str, dict[str, _ObjectEntry]]
1536        "objects_by_type": {},
1537        # Objects within each doc
1538        # dict[str, dict[str, _ObjectEntry]]
1539        "doc_names": {},
1540        # Objects by a shorter or alternative name
1541        # dict[str, dict[str id, _ObjectEntry]]
1542        "alt_names": {},
1543    }
1544
1545    @override
1546    def get_full_qualified_name(
1547        self, node: docutils_nodes.Element
1548    ) -> typing.Union[str, None]:
1549        bzl_file = node.get("bzl:file")
1550        symbol_name = node.get("bzl:symbol")
1551        ref_target = node.get("reftarget")
1552        return ".".join(filter(None, [bzl_file, symbol_name, ref_target]))
1553
1554    @override
1555    def get_objects(self) -> Iterable[_GetObjectsTuple]:
1556        for entry in self.data["objects"].values():
1557            yield entry.to_get_objects_tuple()
1558
1559    @override
1560    def resolve_any_xref(
1561        self,
1562        env: environment.BuildEnvironment,
1563        fromdocname: str,
1564        builder: builders.Builder,
1565        target: str,
1566        node: addnodes.pending_xref,
1567        contnode: docutils_nodes.Element,
1568    ) -> list[tuple[str, docutils_nodes.Element]]:
1569        del env, node  # Unused
1570        entry = self._find_entry_for_xref(fromdocname, "obj", target)
1571        if not entry:
1572            return []
1573        to_docname = entry.index_entry.docname
1574        to_anchor = entry.index_entry.anchor
1575        ref_node = sphinx_nodes.make_refnode(
1576            builder, fromdocname, to_docname, to_anchor, contnode, title=to_anchor
1577        )
1578
1579        matches = [(f"bzl:{entry.object_type}", ref_node)]
1580        return matches
1581
1582    @override
1583    def resolve_xref(
1584        self,
1585        env: environment.BuildEnvironment,
1586        fromdocname: str,
1587        builder: builders.Builder,
1588        typ: str,
1589        target: str,
1590        node: addnodes.pending_xref,
1591        contnode: docutils_nodes.Element,
1592    ) -> typing.Union[docutils_nodes.Element, None]:
1593        _log_debug(
1594            "resolve_xref: fromdocname=%s, typ=%s, target=%s", fromdocname, typ, target
1595        )
1596        del env, node  # Unused
1597        entry = self._find_entry_for_xref(fromdocname, typ, target)
1598        if not entry:
1599            return None
1600
1601        to_docname = entry.index_entry.docname
1602        to_anchor = entry.index_entry.anchor
1603        return sphinx_nodes.make_refnode(
1604            builder, fromdocname, to_docname, to_anchor, contnode, title=to_anchor
1605        )
1606
1607    def _find_entry_for_xref(
1608        self, fromdocname: str, object_type: str, target: str
1609    ) -> typing.Union[_ObjectEntry, None]:
1610        if target.startswith("--"):
1611            target = target.strip("-")
1612            object_type = "flag"
1613
1614        # Allow using parentheses, e.g. `foo()` or `foo(x=...)`
1615        target, _, _ = target.partition("(")
1616
1617        # Elide the value part of --foo=bar flags
1618        # Note that the flag value could contain `=`
1619        if "=" in target:
1620            target = target[: target.find("=")]
1621
1622        if target in self.data["doc_names"].get(fromdocname, {}):
1623            entry = self.data["doc_names"][fromdocname][target]
1624            # Prevent a local doc name masking a global alt name when its of
1625            # a different type. e.g. when the macro `foo` refers to the
1626            # rule `foo` in another doc.
1627            if object_type in self.object_types[entry.object_type].roles:
1628                return entry
1629
1630        if object_type == "obj":
1631            search_space = self.data["objects"]
1632        else:
1633            search_space = self.data["objects_by_type"].get(object_type, {})
1634        if target in search_space:
1635            return search_space[target]
1636
1637        _log_debug("find_entry: alt_names=%s", sorted(self.data["alt_names"].keys()))
1638        if target in self.data["alt_names"]:
1639            # Give preference to shorter object ids. This is a work around
1640            # to allow e.g. `FooInfo` to refer to the FooInfo type rather than
1641            # the `FooInfo` constructor.
1642            entries = sorted(
1643                self.data["alt_names"][target].items(), key=lambda item: len(item[0])
1644            )
1645            for _, entry in entries:
1646                if object_type in self.object_types[entry.object_type].roles:
1647                    return entry
1648
1649        return None
1650
1651    def add_object(self, entry: _ObjectEntry, alt_names=None) -> None:
1652        _log_debug(
1653            "add_object: full_id=%s, object_type=%s, alt_names=%s",
1654            entry.full_id,
1655            entry.object_type,
1656            alt_names,
1657        )
1658        if entry.full_id in self.data["objects"]:
1659            existing = self.data["objects"][entry.full_id]
1660            raise Exception(
1661                f"Object {entry.full_id} already registered: "
1662                + f"existing={existing}, incoming={entry}"
1663            )
1664        self.data["objects"][entry.full_id] = entry
1665        self.data["objects_by_type"].setdefault(entry.object_type, {})
1666        self.data["objects_by_type"][entry.object_type][entry.full_id] = entry
1667
1668        repo, label, symbol = _parse_full_id(entry.full_id)
1669        if symbol:
1670            base_name = symbol.split(".")[-1]
1671        else:
1672            base_name = label.split(":")[-1]
1673
1674        if alt_names is not None:
1675            alt_names = list(alt_names)
1676        # Add the repo-less version as an alias
1677        alt_names.append(label + (f"%{symbol}" if symbol else ""))
1678
1679        for alt_name in sorted(set(alt_names)):
1680            self.data["alt_names"].setdefault(alt_name, {})
1681            self.data["alt_names"][alt_name][entry.full_id] = entry
1682
1683        docname = entry.index_entry.docname
1684        self.data["doc_names"].setdefault(docname, {})
1685        self.data["doc_names"][docname][base_name] = entry
1686
1687    def merge_domaindata(
1688        self, docnames: list[str], otherdata: dict[str, typing.Any]
1689    ) -> None:
1690        # Merge in simple dict[key, value] data
1691        for top_key in ("objects",):
1692            self.data[top_key].update(otherdata.get(top_key, {}))
1693
1694        # Merge in two-level dict[top_key, dict[sub_key, value]] data
1695        for top_key in ("objects_by_type", "doc_names", "alt_names"):
1696            existing_top_map = self.data[top_key]
1697            for sub_key, sub_values in otherdata.get(top_key, {}).items():
1698                if sub_key not in existing_top_map:
1699                    existing_top_map[sub_key] = sub_values
1700                else:
1701                    existing_top_map[sub_key].update(sub_values)
1702
1703
1704def _on_missing_reference(app, env: environment.BuildEnvironment, node, contnode):
1705    if node["refdomain"] != "bzl":
1706        return None
1707    if node["reftype"] != "type":
1708        return None
1709
1710    # There's no Bazel docs for None, so prevent missing xrefs warning
1711    if node["reftarget"] == "None":
1712        return contnode
1713    return None
1714
1715
1716def setup(app):
1717    app.add_domain(_BzlDomain)
1718
1719    app.add_config_value(
1720        "bzl_default_repository_name",
1721        default=os.environ.get("SPHINX_BZL_DEFAULT_REPOSITORY_NAME", "@_main"),
1722        rebuild="env",
1723        types=[str],
1724    )
1725    app.connect("missing-reference", _on_missing_reference)
1726
1727    # Pygments says it supports starlark, but it doesn't seem to actually
1728    # recognize `starlark` as a name. So just manually map it to python.
1729    app.add_lexer("starlark", lexer_classes["python"])
1730    app.add_lexer("bzl", lexer_classes["python"])
1731
1732    return {
1733        "version": "1.0.0",
1734        "parallel_read_safe": True,
1735        "parallel_write_safe": True,
1736    }
1737