# Copyright 2024 The Bazel Authors. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Sphinx extension for documenting Bazel/Starlark objects."""

import ast
import collections
import enum
import os
import typing
from collections.abc import Collection
from typing import Callable, Iterable, TypeVar

from docutils import nodes as docutils_nodes
from docutils.parsers.rst import directives as docutils_directives
from docutils.parsers.rst import states
from sphinx import addnodes, builders
from sphinx import directives as sphinx_directives
from sphinx import domains, environment, roles
from sphinx.highlighting import lexer_classes
from sphinx.locale import _
from sphinx.util import docfields
from sphinx.util import docutils as sphinx_docutils
from sphinx.util import inspect, logging
from sphinx.util import nodes as sphinx_nodes
from sphinx.util import typing as sphinx_typing
from typing_extensions import TypeAlias, override

_logger = logging.getLogger(__name__)
_LOG_PREFIX = f"[{_logger.name}] "

_INDEX_SUBTYPE_NORMAL = 0
_INDEX_SUBTYPE_ENTRY_WITH_SUB_ENTRIES = 1
_INDEX_SUBTYPE_SUB_ENTRY = 2

_T = TypeVar("_T")

# See https://www.sphinx-doc.org/en/master/extdev/domainapi.html#sphinx.domains.Domain.get_objects
_GetObjectsTuple: TypeAlias = tuple[str, str, str, str, str, int]

# See SphinxRole.run definition; the docs for role classes are pretty sparse.
_RoleRunResult: TypeAlias = tuple[
    list[docutils_nodes.Node], list[docutils_nodes.system_message]
]


def _log_debug(message, *args):
    # NOTE: Non-warning log messages go to stdout and are only
    # visible when -q isn't passed to Sphinx. Note that the sphinx_docs build
    # rule passes -q by default; use --//sphinxdocs:quiet=false to disable it.
    _logger.debug("%s" + message, _LOG_PREFIX, *args)


def _position_iter(values: Collection[_T]) -> tuple[bool, bool, _T]:
    last_i = len(values) - 1
    for i, value in enumerate(values):
        yield i == 0, i == last_i, value


class InvalidValueError(Exception):
    """Generic error for an invalid value instead of ValueError.

    Sphinx treats regular ValueError to mean abort parsing the current
    chunk and continue on as best it can. Their error means a more
    fundamental problem that should cause a failure.
    """


class _ObjectEntry:
    """Metadata about a known object."""

    def __init__(
        self,
        full_id: str,
        display_name: str,
        object_type: str,
        search_priority: int,
        index_entry: domains.IndexEntry,
    ):
        """Creates an instance.

        Args:
            full_id: The fully qualified id of the object. Should be
                globally unique, even between projects.
            display_name: What to display the object as in casual context.
            object_type: The type of object, typically one of the values
                known to the domain.
            search_priority: The search priority, see
                https://www.sphinx-doc.org/en/master/extdev/domainapi.html#sphinx.domains.Domain.get_objects
                for valid values.
            index_entry: Metadata about the object for the domain index.
        """
        self.full_id = full_id
        self.display_name = display_name
        self.object_type = object_type
        self.search_priority = search_priority
        self.index_entry = index_entry

    def to_get_objects_tuple(self) -> _GetObjectsTuple:
        # For the tuple definition
        return (
            self.full_id,
            self.display_name,
            self.object_type,
            self.index_entry.docname,
            self.index_entry.anchor,
            self.search_priority,
        )

    def __repr__(self):
        return f"ObjectEntry({self.full_id=}, {self.object_type=}, {self.display_name=}, {self.index_entry.docname=})"


# A simple helper just to document what the index tuple nodes are.
def _index_node_tuple(
    entry_type: str,
    entry_name: str,
    target: str,
    main: typing.Union[str, None] = None,
    category_key: typing.Union[str, None] = None,
) -> tuple[str, str, str, typing.Union[str, None], typing.Union[str, None]]:
    # For this tuple definition, see:
    # https://www.sphinx-doc.org/en/master/extdev/nodes.html#sphinx.addnodes.index
    # For the definition of entry_type, see:
    # And https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-index
    return (entry_type, entry_name, target, main, category_key)


class _BzlObjectId:
    """Identifies an object defined by a directive.

    This object is returned by `handle_signature()` and passed onto
    `add_target_and_index()`. It contains information to identify the object
    that is being described so that it can be indexed and tracked by the
    domain.
    """

    def __init__(
        self,
        *,
        repo: str,
        label: str,
        namespace: str = None,
        symbol: str = None,
    ):
        """Creates an instance.

        Args:
            repo: repository name, including leading "@".
            bzl_file: label of file containing the object, e.g. //foo:bar.bzl
            namespace: dotted name of the namespace the symbol is within.
            symbol: dotted name, relative to `namespace` of the symbol.
        """
        if not repo:
            raise InvalidValueError("repo cannot be empty")
        if not repo.startswith("@"):
            raise InvalidValueError("repo must start with @")
        if not label:
            raise InvalidValueError("label cannot be empty")
        if not label.startswith("//"):
            raise InvalidValueError("label must start with //")

        if not label.endswith(".bzl") and (symbol or namespace):
            raise InvalidValueError(
                "Symbol and namespace can only be specified for .bzl labels"
            )

        self.repo = repo
        self.label = label
        self.package, self.target_name = self.label.split(":")
        self.namespace = namespace
        self.symbol = symbol  # Relative to namespace
        # doc-relative identifier for this object
        self.doc_id = symbol or self.target_name

        if not self.doc_id:
            raise InvalidValueError("doc_id is empty")

        self.full_id = _full_id_from_parts(repo, label, [namespace, symbol])

    @classmethod
    def from_env(
        cls, env: environment.BuildEnvironment, *, symbol: str = None, label: str = None
    ) -> "_BzlObjectId":
        label = label or env.ref_context["bzl:file"]
        if symbol:
            namespace = ".".join(env.ref_context["bzl:doc_id_stack"])
        else:
            namespace = None

        return cls(
            repo=env.ref_context["bzl:repo"],
            label=label,
            namespace=namespace,
            symbol=symbol,
        )

    def __repr__(self):
        return f"_BzlObjectId({self.full_id=})"


def _full_id_from_env(env, object_ids=None):
    return _full_id_from_parts(
        env.ref_context["bzl:repo"],
        env.ref_context["bzl:file"],
        env.ref_context["bzl:object_id_stack"] + (object_ids or []),
    )


def _full_id_from_parts(repo, bzl_file, symbol_names=None):
    parts = [repo, bzl_file]

    symbol_names = symbol_names or []
    symbol_names = list(filter(None, symbol_names))  # Filter out empty values
    if symbol_names:
        parts.append("%")
        parts.append(".".join(symbol_names))

    full_id = "".join(parts)
    return full_id


def _parse_full_id(full_id):
    repo, slashes, label = full_id.partition("//")
    label = slashes + label
    label, _, symbol = label.partition("%")
    return (repo, label, symbol)


class _TypeExprParser(ast.NodeVisitor):
    """Parsers a string description of types to doc nodes."""

    def __init__(self, make_xref: Callable[[str], docutils_nodes.Node]):
        self.root_node = addnodes.desc_inline("bzl", classes=["type-expr"])
        self.make_xref = make_xref
        self._doc_node_stack = [self.root_node]

    @classmethod
    def xrefs_from_type_expr(
        cls,
        type_expr_str: str,
        make_xref: Callable[[str], docutils_nodes.Node],
    ) -> docutils_nodes.Node:
        module = ast.parse(type_expr_str)
        visitor = cls(make_xref)
        visitor.visit(module.body[0])
        return visitor.root_node

    def _append(self, node: docutils_nodes.Node):
        self._doc_node_stack[-1] += node

    def _append_and_push(self, node: docutils_nodes.Node):
        self._append(node)
        self._doc_node_stack.append(node)

    def visit_Attribute(self, node: ast.Attribute):
        current = node
        parts = []
        while current:
            if isinstance(current, ast.Attribute):
                parts.append(current.attr)
                current = current.value
            elif isinstance(current, ast.Name):
                parts.append(current.id)
                break
            else:
                raise InvalidValueError(f"Unexpected Attribute.value node: {current}")
        dotted_name = ".".join(reversed(parts))
        self._append(self.make_xref(dotted_name))

    def visit_Constant(self, node: ast.Constant):
        if node.value is None:
            self._append(self.make_xref("None"))
        elif isinstance(node.value, str):
            self._append(self.make_xref(node.value))
        else:
            raise InvalidValueError(
                f"Unexpected Constant node value: ({type(node.value)}) {node.value=}"
            )

    def visit_Name(self, node: ast.Name):
        xref_node = self.make_xref(node.id)
        self._append(xref_node)

    def visit_BinOp(self, node: ast.BinOp):
        self.visit(node.left)
        self._append(addnodes.desc_sig_space())
        if isinstance(node.op, ast.BitOr):
            self._append(addnodes.desc_sig_punctuation("", "|"))
        else:
            raise InvalidValueError(f"Unexpected BinOp: {node}")
        self._append(addnodes.desc_sig_space())
        self.visit(node.right)

    def visit_Expr(self, node: ast.Expr):
        self.visit(node.value)

    def visit_Subscript(self, node: ast.Subscript):
        self.visit(node.value)
        self._append_and_push(addnodes.desc_type_parameter_list())
        self.visit(node.slice)
        self._doc_node_stack.pop()

    def visit_Tuple(self, node: ast.Tuple):
        for element in node.elts:
            self._append_and_push(addnodes.desc_type_parameter())
            self.visit(element)
            self._doc_node_stack.pop()

    def visit_List(self, node: ast.List):
        self._append_and_push(addnodes.desc_type_parameter_list())
        for element in node.elts:
            self._append_and_push(addnodes.desc_type_parameter())
            self.visit(element)
            self._doc_node_stack.pop()

    @override
    def generic_visit(self, node):
        raise InvalidValueError(f"Unexpected ast node: {type(node)} {node}")


class _BzlXrefField(docfields.Field):
    """Abstract base class to create cross references for fields."""

    @override
    def make_xrefs(
        self,
        rolename: str,
        domain: str,
        target: str,
        innernode: type[sphinx_typing.TextlikeNode] = addnodes.literal_emphasis,
        contnode: typing.Union[docutils_nodes.Node, None] = None,
        env: typing.Union[environment.BuildEnvironment, None] = None,
        inliner: typing.Union[states.Inliner, None] = None,
        location: typing.Union[docutils_nodes.Element, None] = None,
    ) -> list[docutils_nodes.Node]:
        if rolename in ("arg", "attr"):
            return self._make_xrefs_for_arg_attr(
                rolename, domain, target, innernode, contnode, env, inliner, location
            )
        else:
            return super().make_xrefs(
                rolename, domain, target, innernode, contnode, env, inliner, location
            )

    def _make_xrefs_for_arg_attr(
        self,
        rolename: str,
        domain: str,
        arg_name: str,
        innernode: type[sphinx_typing.TextlikeNode] = addnodes.literal_emphasis,
        contnode: typing.Union[docutils_nodes.Node, None] = None,
        env: typing.Union[environment.BuildEnvironment, None] = None,
        inliner: typing.Union[states.Inliner, None] = None,
        location: typing.Union[docutils_nodes.Element, None] = None,
    ) -> list[docutils_nodes.Node]:
        bzl_file = env.ref_context["bzl:file"]
        anchor_prefix = ".".join(env.ref_context["bzl:doc_id_stack"])
        if not anchor_prefix:
            raise InvalidValueError(
                f"doc_id_stack empty when processing arg {arg_name}"
            )
        index_description = f"{arg_name} ({self.name} in {bzl_file}%{anchor_prefix})"
        anchor_id = f"{anchor_prefix}.{arg_name}"
        full_id = _full_id_from_env(env, [arg_name])

        env.get_domain(domain).add_object(
            _ObjectEntry(
                full_id=full_id,
                display_name=arg_name,
                object_type=self.name,
                search_priority=1,
                index_entry=domains.IndexEntry(
                    name=arg_name,
                    subtype=_INDEX_SUBTYPE_NORMAL,
                    docname=env.docname,
                    anchor=anchor_id,
                    extra="",
                    qualifier="",
                    descr=index_description,
                ),
            ),
            # This allows referencing an arg as e.g `funcname.argname`
            alt_names=[anchor_id],
        )

        # Two changes to how arg xrefs are created:
        # 2. Use the full id instead of base name. This makes it unambiguous
        #    as to what it's referencing.
        pending_xref = super().make_xref(
            # The full_id is used as the target so its unambiguious.
            rolename,
            domain,
            f"{arg_name} <{full_id}>",
            innernode,
            contnode,
            env,
            inliner,
            location,
        )

        wrapper = docutils_nodes.inline(ids=[anchor_id])

        index_node = addnodes.index(
            entries=[
                _index_node_tuple(
                    "single", f"{self.name}; {index_description}", anchor_id
                ),
                _index_node_tuple("single", index_description, anchor_id),
            ]
        )
        wrapper += index_node
        wrapper += pending_xref
        return [wrapper]


class _BzlField(_BzlXrefField, docfields.Field):
    """A non-repeated field with xref support."""


class _BzlGroupedField(_BzlXrefField, docfields.GroupedField):
    """A repeated fieled grouped as a list with xref support."""


class _BzlCsvField(_BzlXrefField):
    """Field with a CSV list of values."""

    def __init__(self, *args, body_domain: str = "", **kwargs):
        super().__init__(*args, **kwargs)
        self._body_domain = body_domain

    def make_field(
        self,
        types: dict[str, list[docutils_nodes.Node]],
        domain: str,
        item: tuple,
        env: environment.BuildEnvironment = None,
        inliner: typing.Union[states.Inliner, None] = None,
        location: typing.Union[docutils_nodes.Element, None] = None,
    ) -> docutils_nodes.field:
        field_text = item[1][0].astext()
        parts = [p.strip() for p in field_text.split(",")]
        field_body = docutils_nodes.field_body()
        for _, is_last, part in _position_iter(parts):
            node = self.make_xref(
                self.bodyrolename,
                self._body_domain or domain,
                part,
                env=env,
                inliner=inliner,
                location=location,
            )
            field_body += node
            if not is_last:
                field_body += docutils_nodes.Text(", ")

        field_name = docutils_nodes.field_name("", self.label)
        return docutils_nodes.field("", field_name, field_body)


class _BzlCurrentFile(sphinx_docutils.SphinxDirective):
    """Sets what bzl file following directives are defined in.

    The directive's argument is an absolute Bazel label, e.g. `//foo:bar.bzl`
    or `@repo//foo:bar.bzl`. The repository portion is optional; if specified,
    it will override the `bzl_default_repository_name` configuration setting.

    Example MyST usage

    ```
    :::{bzl:currentfile} //my:file.bzl
    :::
    ```
    """

    has_content = False
    required_arguments = 1
    final_argument_whitespace = False

    @override
    def run(self) -> list[docutils_nodes.Node]:
        label = self.arguments[0].strip()
        repo, slashes, file_label = label.partition("//")
        file_label = slashes + file_label
        if not repo:
            repo = self.env.config.bzl_default_repository_name
        self.env.ref_context["bzl:repo"] = repo
        self.env.ref_context["bzl:file"] = file_label
        self.env.ref_context["bzl:object_id_stack"] = []
        self.env.ref_context["bzl:doc_id_stack"] = []
        return []


class _BzlAttrInfo(sphinx_docutils.SphinxDirective):
    has_content = False
    required_arguments = 1
    optional_arguments = 0
    option_spec = {
        "executable": docutils_directives.flag,
        "mandatory": docutils_directives.flag,
    }

    def run(self):
        content_node = docutils_nodes.paragraph("", "")
        content_node += docutils_nodes.paragraph(
            "", "mandatory" if "mandatory" in self.options else "optional"
        )
        if "executable" in self.options:
            content_node += docutils_nodes.paragraph("", "Must be an executable")

        return [content_node]


class _BzlObject(sphinx_directives.ObjectDescription[_BzlObjectId]):
    """Base class for describing a Bazel/Starlark object.

    This directive takes a single argument: a string name with optional
    function signature.

    * The name can be a dotted name, e.g. `a.b.foo`
    * The signature is in Python signature syntax, e.g. `foo(a=x) -> R`
    * The signature supports default values.
    * Arg type annotations are not supported; use `{bzl:type}` instead as
      part of arg/attr documentation.

    Example signatures:
      * `foo`
      * `foo(arg1, arg2)`
      * `foo(arg1, arg2=default) -> returntype`
    """

    option_spec = sphinx_directives.ObjectDescription.option_spec | {
        "origin-key": docutils_directives.unchanged,
    }

    @override
    def before_content(self) -> None:
        symbol_name = self.names[-1].symbol
        if symbol_name:
            self.env.ref_context["bzl:object_id_stack"].append(symbol_name)
            self.env.ref_context["bzl:doc_id_stack"].append(symbol_name)

    @override
    def transform_content(self, content_node: addnodes.desc_content) -> None:
        def first_child_with_class_name(
            root, class_name
        ) -> typing.Union[None, docutils_nodes.Element]:
            matches = root.findall(
                lambda node: isinstance(node, docutils_nodes.Element)
                and class_name in node["classes"]
            )
            found = next(matches, None)
            return found

        def match_arg_field_name(node):
            # fmt: off
            return (
                isinstance(node, docutils_nodes.field_name)
                and node.astext().startswith(("arg ", "attr "))
            )
            # fmt: on

        # Move the spans for the arg type and default value to be first.
        arg_name_fields = list(content_node.findall(match_arg_field_name))
        for arg_name_field in arg_name_fields:
            arg_body_field = arg_name_field.next_node(descend=False, siblings=True)
            # arg_type_node = first_child_with_class_name(arg_body_field, "arg-type-span")
            arg_type_node = first_child_with_class_name(arg_body_field, "type-expr")
            arg_default_node = first_child_with_class_name(
                arg_body_field, "default-value-span"
            )

            # Inserting into the body field itself causes the elements
            # to be grouped into the paragraph node containing the arg
            # name (as opposed to the paragraph node containing the
            # doc text)

            if arg_default_node:
                arg_default_node.parent.remove(arg_default_node)
                arg_body_field.insert(0, arg_default_node)

            if arg_type_node:
                arg_type_node.parent.remove(arg_type_node)
                decorated_arg_type_node = docutils_nodes.inline(
                    "",
                    "",
                    docutils_nodes.Text("("),
                    arg_type_node,
                    docutils_nodes.Text(") "),
                    classes=["arg-type-span"],
                )
                # arg_body_field.insert(0, arg_type_node)
                arg_body_field.insert(0, decorated_arg_type_node)

    @override
    def after_content(self) -> None:
        if self.names[-1].symbol:
            self.env.ref_context["bzl:object_id_stack"].pop()
            self.env.ref_context["bzl:doc_id_stack"].pop()

    # docs on how to build signatures:
    # https://www.sphinx-doc.org/en/master/extdev/nodes.html#sphinx.addnodes.desc_signature
    @override
    def handle_signature(
        self, sig_text: str, sig_node: addnodes.desc_signature
    ) -> _BzlObjectId:
        self._signature_add_object_type(sig_node)

        relative_name, lparen, params_text = sig_text.partition("(")
        if lparen:
            params_text = lparen + params_text

        relative_name = relative_name.strip()

        name_prefix, _, base_symbol_name = relative_name.rpartition(".")
        if name_prefix:
            # Respect whatever the signature wanted
            display_prefix = name_prefix
        else:
            # Otherwise, show the outermost name. This makes ctrl+f finding
            # for a symbol a bit easier.
            display_prefix = ".".join(self.env.ref_context["bzl:doc_id_stack"])
            _, _, display_prefix = display_prefix.rpartition(".")

        if display_prefix:
            display_prefix = display_prefix + "."
            sig_node += addnodes.desc_addname(display_prefix, display_prefix)
        sig_node += addnodes.desc_name(base_symbol_name, base_symbol_name)

        if type_expr := self.options.get("type"):

            def make_xref(name, title=None):
                content_node = addnodes.desc_type(name, name)
                return addnodes.pending_xref(
                    "",
                    content_node,
                    refdomain="bzl",
                    reftype="type",
                    reftarget=name,
                )

            attr_annotation_node = addnodes.desc_annotation(
                type_expr,
                "",
                addnodes.desc_sig_punctuation("", ":"),
                addnodes.desc_sig_space(),
                _TypeExprParser.xrefs_from_type_expr(type_expr, make_xref),
            )
            sig_node += attr_annotation_node

        if params_text:
            try:
                signature = inspect.signature_from_str(params_text)
            except SyntaxError:
                # Stardoc doesn't provide accurate info, so the reconstructed
                # signature might not be valid syntax. Rather than fail, just
                # provide a plain-text description of the approximate signature.
                # See https://github.com/bazelbuild/stardoc/issues/225
                sig_node += addnodes.desc_parameterlist(
                    # Offset by 1 to remove the surrounding parentheses
                    params_text[1:-1],
                    params_text[1:-1],
                )
            else:
                last_kind = None
                paramlist_node = addnodes.desc_parameterlist()
                for param in signature.parameters.values():
                    if param.kind == param.KEYWORD_ONLY and last_kind in (
                        param.POSITIONAL_OR_KEYWORD,
                        param.POSITIONAL_ONLY,
                        None,
                    ):
                        # Add separator for keyword only parameter: *
                        paramlist_node += addnodes.desc_parameter(
                            "", "", addnodes.desc_sig_operator("", "*")
                        )

                    last_kind = param.kind
                    node = addnodes.desc_parameter()
                    if param.kind == param.VAR_POSITIONAL:
                        node += addnodes.desc_sig_operator("", "*")
                    elif param.kind == param.VAR_KEYWORD:
                        node += addnodes.desc_sig_operator("", "**")

                    node += addnodes.desc_sig_name(rawsource="", text=param.name)
                    if param.default is not param.empty:
                        node += addnodes.desc_sig_operator("", "=")
                        node += docutils_nodes.inline(
                            "",
                            param.default,
                            classes=["default_value"],
                            support_smartquotes=False,
                        )
                    paramlist_node += node
                sig_node += paramlist_node

                if signature.return_annotation is not signature.empty:
                    sig_node += addnodes.desc_returns("", signature.return_annotation)

        obj_id = _BzlObjectId.from_env(self.env, symbol=relative_name)

        sig_node["bzl:object_id"] = obj_id.full_id
        return obj_id

    def _signature_add_object_type(self, sig_node: addnodes.desc_signature):
        if sig_object_type := self._get_signature_object_type():
            sig_node += addnodes.desc_annotation("", self._get_signature_object_type())
            sig_node += addnodes.desc_sig_space()

    @override
    def add_target_and_index(
        self, obj_desc: _BzlObjectId, sig: str, sig_node: addnodes.desc_signature
    ) -> None:
        super().add_target_and_index(obj_desc, sig, sig_node)
        if obj_desc.symbol:
            display_name = obj_desc.symbol
            location = obj_desc.label
            if obj_desc.namespace:
                location += f"%{obj_desc.namespace}"
        else:
            display_name = obj_desc.target_name
            location = obj_desc.package

        anchor_prefix = ".".join(self.env.ref_context["bzl:doc_id_stack"])
        if anchor_prefix:
            anchor_id = f"{anchor_prefix}.{obj_desc.doc_id}"
        else:
            anchor_id = obj_desc.doc_id

        sig_node["ids"].append(anchor_id)

        object_type_display = self._get_object_type_display_name()
        index_description = f"{display_name} ({object_type_display} in {location})"
        self.indexnode["entries"].extend(
            _index_node_tuple("single", f"{index_type}; {index_description}", anchor_id)
            for index_type in [object_type_display] + self._get_additional_index_types()
        )
        self.indexnode["entries"].append(
            _index_node_tuple("single", index_description, anchor_id),
        )

        object_entry = _ObjectEntry(
            full_id=obj_desc.full_id,
            display_name=display_name,
            object_type=self.objtype,
            search_priority=1,
            index_entry=domains.IndexEntry(
                name=display_name,
                subtype=_INDEX_SUBTYPE_NORMAL,
                docname=self.env.docname,
                anchor=anchor_id,
                extra="",
                qualifier="",
                descr=index_description,
            ),
        )

        alt_names = []
        if origin_key := self.options.get("origin-key"):
            alt_names.append(
                origin_key
                # Options require \@ for leading @, but don't
                # remove the escaping slash, so we have to do it manually
                .lstrip("\\")
            )
        extra_alt_names = self._get_alt_names(object_entry)
        alt_names.extend(extra_alt_names)

        self.env.get_domain(self.domain).add_object(object_entry, alt_names=alt_names)

    def _get_additional_index_types(self):
        return []

    @override
    def _object_hierarchy_parts(
        self, sig_node: addnodes.desc_signature
    ) -> tuple[str, ...]:
        return _parse_full_id(sig_node["bzl:object_id"])

    @override
    def _toc_entry_name(self, sig_node: addnodes.desc_signature) -> str:
        return sig_node["_toc_parts"][-1]

    def _get_object_type_display_name(self) -> str:
        return self.env.get_domain(self.domain).object_types[self.objtype].lname

    def _get_signature_object_type(self) -> str:
        return self._get_object_type_display_name()

    def _get_alt_names(self, object_entry):
        alt_names = []
        full_id = object_entry.full_id
        label, _, symbol = full_id.partition("%")
        if symbol:
            # Allow referring to the file-relative fully qualified symbol name
            alt_names.append(symbol)
            if "." in symbol:
                # Allow referring to the last component of the symbol
                alt_names.append(symbol.split(".")[-1])
        else:
            # Otherwise, it's a target. Allow referring to just the target name
            _, _, target_name = label.partition(":")
            alt_names.append(target_name)

        return alt_names


class _BzlCallable(_BzlObject):
    """Abstract base class for objects that are callable."""


class _BzlProvider(_BzlObject):
    """Documents a provider type.

    Example MyST usage

    ```
    ::::{bzl:provider} MyInfo

    Docs about MyInfo

    :::{bzl:provider-field} some_field
    :type: depset[str]
    :::
    ::::
    ```
    """


class _BzlProviderField(_BzlObject):
    """Documents a field of a provider.

    Fields can optionally have a type specified using the `:type:` option.

    The type can be any type expression understood by the `{bzl:type}` role.

    ```
    :::{bzl:provider-field} foo
    :type: str
    :::
    ```
    """

    option_spec = _BzlObject.option_spec.copy()
    option_spec.update(
        {
            "type": docutils_directives.unchanged,
        }
    )

    @override
    def _get_signature_object_type(self) -> str:
        return ""

    @override
    def _get_alt_names(self, object_entry):
        alt_names = super()._get_alt_names(object_entry)
        _, _, symbol = object_entry.full_id.partition("%")
        # Allow refering to `mod_ext_name.tag_name`, even if the extension
        # is nested within another object
        alt_names.append(".".join(symbol.split(".")[-2:]))
        return alt_names


class _BzlRepositoryRule(_BzlCallable):
    """Documents a repository rule.

    Doc fields:
    * attr: Documents attributes of the rule. Takes a single arg, the
      attribute name. Can be repeated. The special roles `{default-value}`
      and `{arg-type}` can be used to indicate the default value and
      type of attribute, respectively.
    * environment-variables: a CSV list of environment variable names.
      They will be cross referenced with matching environment variables.

    Example MyST usage

    ```
    :::{bzl:repo-rule} myrule(foo)

    :attr foo: {default-value}`"foo"` {arg-type}`attr.string` foo doc string

    :environment-variables: FOO, BAR
    :::
    ```
    """

    doc_field_types = [
        _BzlGroupedField(
            "attr",
            label=_("Attributes"),
            names=["attr"],
            rolename="attr",
            can_collapse=False,
        ),
        _BzlCsvField(
            "environment-variables",
            label=_("Environment Variables"),
            names=["environment-variables"],
            body_domain="std",
            bodyrolename="envvar",
            has_arg=False,
        ),
    ]

    @override
    def _get_signature_object_type(self) -> str:
        return "repo rule"


class _BzlRule(_BzlCallable):
    """Documents a rule.

    Doc fields:
    * attr: Documents attributes of the rule. Takes a single arg, the
      attribute name. Can be repeated. The special roles `{default-value}`
      and `{arg-type}` can be used to indicate the default value and
      type of attribute, respectively.
    * provides: A type expression of the provider types the rule provides.
      To indicate different groupings, use `|` and `[]`. For example,
      `FooInfo | [BarInfo, BazInfo]` means it provides either `FooInfo`
      or both of `BarInfo` and `BazInfo`.

    Example MyST usage

    ```
    :::{bzl:repo-rule} myrule(foo)

    :attr foo: {default-value}`"foo"` {arg-type}`attr.string` foo doc string

    :provides: FooInfo | BarInfo
    :::
    ```
    """

    doc_field_types = [
        _BzlGroupedField(
            "attr",
            label=_("Attributes"),
            names=["attr"],
            rolename="attr",
            can_collapse=False,
        ),
        _BzlField(
            "provides",
            label="Provides",
            has_arg=False,
            names=["provides"],
            bodyrolename="type",
        ),
    ]


class _BzlAspect(_BzlObject):
    """Documents an aspect.

    Doc fields:
    * attr: Documents attributes of the aspect. Takes a single arg, the
      attribute name. Can be repeated. The special roles `{default-value}`
      and `{arg-type}` can be used to indicate the default value and
      type of attribute, respectively.
    * aspect-attributes: A CSV list of attribute names the aspect
      propagates along.

    Example MyST usage

    ```
    :::{bzl:repo-rule} myaspect

    :attr foo: {default-value}`"foo"` {arg-type}`attr.string` foo doc string

    :aspect-attributes: srcs, deps
    :::
    ```
    """

    doc_field_types = [
        _BzlGroupedField(
            "attr",
            label=_("Attributes"),
            names=["attr"],
            rolename="attr",
            can_collapse=False,
        ),
        _BzlCsvField(
            "aspect-attributes",
            label=_("Aspect Attributes"),
            names=["aspect-attributes"],
            has_arg=False,
        ),
    ]


class _BzlFunction(_BzlCallable):
    """Documents a general purpose function.

    Doc fields:
    * arg: Documents the arguments of the function. Takes a single arg, the
      arg name. Can be repeated. The special roles `{default-value}`
      and `{arg-type}` can be used to indicate the default value and
      type of attribute, respectively.
    * returns: Documents what the function returns. The special role
      `{return-type}` can be used to indicate the return type of the function.

    Example MyST usage

    ```
    :::{bzl:function} myfunc(a, b=None) -> bool

    :arg a: {arg-type}`str` some arg doc
    :arg b: {arg-type}`int | None` {default-value}`42` more arg doc
    :returns: {return-type}`bool` doc about return value.
    :::
    ```
    """

    doc_field_types = [
        _BzlGroupedField(
            "arg",
            label=_("Args"),
            names=["arg"],
            rolename="arg",
            can_collapse=False,
        ),
        docfields.Field(
            "returns",
            label=_("Returns"),
            has_arg=False,
            names=["returns"],
        ),
    ]

    @override
    def _get_signature_object_type(self) -> str:
        return ""


class _BzlModuleExtension(_BzlObject):
    """Documents a module_extension.

    Doc fields:
    * os-dependent: Documents if the module extension depends on the host
      architecture.
    * arch-dependent: Documents if the module extension depends on the host
      architecture.
    * environment-variables: a CSV list of environment variable names.
      They will be cross referenced with matching environment variables.

    Tag classes are documented using the bzl:tag-class directives within
    this directive.

    Example MyST usage:

    ```
    ::::{bzl:module-extension} myext

    :os-dependent: True
    :arch-dependent: False

    :::{bzl:tag-class} mytag(myattr)

    :attr myattr:
      {arg-type}`attr.string_list`
      doc for attribute
    :::
    ::::
    ```
    """

    doc_field_types = [
        _BzlField(
            "os-dependent",
            label="OS Dependent",
            has_arg=False,
            names=["os-dependent"],
        ),
        _BzlField(
            "arch-dependent",
            label="Arch Dependent",
            has_arg=False,
            names=["arch-dependent"],
        ),
        _BzlCsvField(
            "environment-variables",
            label=_("Environment Variables"),
            names=["environment-variables"],
            body_domain="std",
            bodyrolename="envvar",
            has_arg=False,
        ),
    ]

    @override
    def _get_signature_object_type(self) -> str:
        return "module ext"


class _BzlTagClass(_BzlCallable):
    """Documents a tag class for a module extension.

    Doc fields:
    * attr: Documents attributes of the tag class. Takes a single arg, the
      attribute name. Can be repeated. The special roles `{default-value}`
      and `{arg-type}` can be used to indicate the default value and
      type of attribute, respectively.

    Example MyST usage, note that this directive should be nested with
    a `bzl:module-extension` directive.

    ```
    :::{bzl:tag-class} mytag(myattr)

    :attr myattr:
      {arg-type}`attr.string_list`
      doc for attribute
    :::
    ```
    """

    doc_field_types = [
        _BzlGroupedField(
            "arg",
            label=_("Attributes"),
            names=["attr"],
            rolename="arg",
            can_collapse=False,
        ),
    ]

    @override
    def _get_signature_object_type(self) -> str:
        return ""

    @override
    def _get_alt_names(self, object_entry):
        alt_names = super()._get_alt_names(object_entry)
        _, _, symbol = object_entry.full_id.partition("%")
        # Allow refering to `ProviderName.field`, even if the provider
        # is nested within another object
        alt_names.append(".".join(symbol.split(".")[-2:]))
        return alt_names


class _TargetType(enum.Enum):
    TARGET = "target"
    FLAG = "flag"


class _BzlTarget(_BzlObject):
    """Documents an arbitrary target."""

    _TARGET_TYPE = _TargetType.TARGET

    def handle_signature(self, sig_text, sig_node):
        self._signature_add_object_type(sig_node)
        if ":" in sig_text:
            package, target_name = sig_text.split(":", 1)
        else:
            target_name = sig_text
            package = self.env.ref_context["bzl:file"]
            package = package[: package.find(":BUILD")]

        package = package + ":"
        if self._TARGET_TYPE == _TargetType.FLAG:
            sig_node += addnodes.desc_addname("--", "--")
        sig_node += addnodes.desc_addname(package, package)
        sig_node += addnodes.desc_name(target_name, target_name)

        obj_id = _BzlObjectId.from_env(self.env, label=package + target_name)
        sig_node["bzl:object_id"] = obj_id.full_id
        return obj_id

    @override
    def _get_signature_object_type(self) -> str:
        # We purposely return empty here because having "target" in front
        # of every label isn't very helpful
        return ""


# TODO: Integrate with the option directive, since flags are options, afterall.
# https://www.sphinx-doc.org/en/master/usage/domains/standard.html#directive-option
class _BzlFlag(_BzlTarget):
    """Documents a flag"""

    _TARGET_TYPE = _TargetType.FLAG

    @override
    def _get_signature_object_type(self) -> str:
        return "flag"

    def _get_additional_index_types(self):
        return ["target"]


class _DefaultValueRole(sphinx_docutils.SphinxRole):
    """Documents the default value for an arg or attribute.

    This is a special role used within `:arg:` and `:attr:` doc fields to
    indicate the default value. The rendering process looks for this role
    and reformats and moves its content for better display.

    Styling can be customized by matching the `.default_value` class.
    """

    def run(self) -> _RoleRunResult:
        node = docutils_nodes.emphasis(
            "",
            "(default ",
            docutils_nodes.inline("", self.text, classes=["sig", "default_value"]),
            docutils_nodes.Text(") "),
            classes=["default-value-span"],
        )
        return ([node], [])


class _TypeRole(sphinx_docutils.SphinxRole):
    """Documents a type (or type expression) with crossreferencing.

    This is an inline role used to create cross references to other types.

    The content is interpreted as a reference to a type or an expression
    of types. The syntax uses Python-style sytax with `|` and `[]`, e.g.
    `foo.MyType | str | list[str] | dict[str, int]`. Each symbolic name
    will be turned into a cross reference; see the domain's documentation
    for how to reference objects.

    Example MyST usage:

    ```
    This function accepts {bzl:type}`str | list[str]` for usernames
    ```
    """

    def __init__(self):
        super().__init__()
        self._xref = roles.XRefRole()

    def run(self) -> _RoleRunResult:
        outer_messages = []

        def make_xref(name):
            nodes, msgs = self._xref(
                "bzl:type",
                name,
                name,
                self.lineno,
                self.inliner,
                self.options,
                self.content,
            )
            outer_messages.extend(msgs)
            if len(nodes) == 1:
                return nodes[0]
            else:
                return docutils_nodes.inline("", "", nodes)

        root = _TypeExprParser.xrefs_from_type_expr(self.text, make_xref)
        return ([root], outer_messages)


class _ReturnTypeRole(_TypeRole):
    """Documents the return type for function.

    This is a special role used within `:returns:` doc fields to
    indicate the return type of the function. The rendering process looks for
    this role and reformats and moves its content for better display.

    Example MyST Usage

    ```
    :::{bzl:function} foo()

    :returns: {return-type}`list[str]`
    :::
    ```
    """

    def run(self) -> _RoleRunResult:
        nodes, messages = super().run()
        nodes.append(docutils_nodes.Text(" -- "))
        return nodes, messages


class _RequiredProvidersRole(_TypeRole):
    """Documents the providers an attribute requires.

    This is a special role used within `:arg:` or `:attr:` doc fields to
    indicate the types of providers that are required. The rendering process
    looks for this role and reformats its content for better display, but its
    position is left as-is; typically it would be its own paragraph near the
    end of the doc.

    The syntax is a pipe (`|`) delimited list of types or groups of types,
    where groups are indicated using `[...]`. e.g, to express that FooInfo OR
    (both of BarInfo and BazInfo) are supported, write `FooInfo | [BarInfo,
    BazInfo]`

    Example MyST Usage

    ```
    :::{bzl:rule} foo(bar)

    :attr bar: My attribute doc

      {required-providers}`CcInfo | [PyInfo, JavaInfo]`
    :::
    ```
    """

    def run(self) -> _RoleRunResult:
        xref_nodes, messages = super().run()
        nodes = [
            docutils_nodes.emphasis("", "Required providers: "),
        ] + xref_nodes
        return nodes, messages


class _BzlIndex(domains.Index):
    """An index of a bzl file's objects.

    NOTE: This generates the entries for the *domain specific* index
    (bzl-index.html), not the general index (genindex.html). To affect
    the general index, index nodes and directives must be used (grep
    for `self.indexnode`).
    """

    name = "index"
    localname = "Bazel/Starlark Object Index"
    shortname = "Bzl"

    def generate(
        self, docnames: Iterable[str] = None
    ) -> tuple[list[tuple[str, list[domains.IndexEntry]]], bool]:
        content = collections.defaultdict(list)

        # sort the list of objects in alphabetical order
        objects = self.domain.data["objects"].values()
        objects = sorted(objects, key=lambda obj: obj.index_entry.name)

        # Group by first letter
        for entry in objects:
            index_entry = entry.index_entry
            content[index_entry.name[0].lower()].append(index_entry)

        # convert the dict to the sorted list of tuples expected
        content = sorted(content.items())

        return content, True


class _BzlDomain(domains.Domain):
    """Domain for Bazel/Starlark objects.

    Directives

    There are directives for defining Bazel objects and their functionality.
    See the respective directive classes for details.

    Public Crossreferencing Roles

    These are roles that can be used in docs to create cross references.

    Objects are fully identified using dotted notation converted from the Bazel
    label and symbol name within a `.bzl` file. The `@`, `/` and `:` characters
    are converted to dots (with runs removed), and `.bzl` is removed from file
    names. The dotted path of a symbol in the bzl file is appended. For example,
    the `paths.join` function in `@bazel_skylib//lib:paths.bzl` would be
    identified as `bazel_skylib.lib.paths.paths.join`.

    Shorter identifiers can be used. Within a project, the repo name portion
    can be omitted. Within a file, file-relative names can be used.

    * obj: Used to reference a single object without concern for its type.
      This roles searches all object types for a name that matches the given
      value. Example usage in MyST:
      ```
      {bzl:obj}`repo.pkg.file.my_function`
      ```

    * type: Transforms a type expression into cross references for objects
      with object type "type". For example, it parses `int | list[str]` into
      three links for each component part.

    Public Typography Roles

    These are roles used for special purposes to aid documentation.

    * default-value: The default value for an argument or attribute. Only valid
      to use within arg or attribute documentation. See `_DefaultValueRole` for
      details.
    * required-providers: The providers an attribute requires. Only
      valud to use within an attribute documentation. See
      `_RequiredProvidersRole` for details.
    * return-type: The type of value a function returns. Only valid
      within a function's return doc field. See `_ReturnTypeRole` for details.

    Object Types

    These are the types of objects that this domain keeps in its index.

    * arg: An argument to a function or macro.
    * aspect: A Bazel `aspect`.
    * attribute: An input to a rule (regular, repository, aspect, or module
      extension).
    * method: A function bound to an instance of a struct acting as a type.
    * module-extension: A Bazel `module_extension`.
    * provider: A Bazel `provider`.
    * provider-field: A field of a provider.
    * repo-rule: A Bazel `repository_rule`.
    * rule: A regular Bazel `rule`.
    * tag-class: A Bazel `tag_class` of a `module_extension`.
    * target: A Bazel target.
    * type: A builtin Bazel type or user-defined structural type. User defined
      structual types are typically instances `struct` created using a function
      that acts as a constructor with implicit state bound using closures.
    """

    name = "bzl"
    label = "Bzl"

    # NOTE: Most every object type has "obj" as one of the roles because
    # an object type's role determine what reftypes (cross referencing) can
    # refer to it. By having "obj" for all of them, it allows writing
    # :bzl:obj`foo` to restrict object searching to the bzl domain. Under the
    # hood, this domain translates requests for the :any: role as lookups for
    # :obj:.
    # NOTE: We also use these object types for categorizing things in the
    # generated index page.
    object_types = {
        "arg": domains.ObjType("arg", "arg", "obj"),  # macro/function arg
        "aspect": domains.ObjType("aspect", "aspect", "obj"),
        "attr": domains.ObjType("attr", "attr", "obj"),  # rule attribute
        "function": domains.ObjType("function", "func", "obj"),
        "method": domains.ObjType("method", "method", "obj"),
        "module-extension": domains.ObjType(
            "module extension", "module_extension", "obj"
        ),
        # Providers are close enough to types that we include "type". This
        # also makes :type: Foo work in directive options.
        "provider": domains.ObjType("provider", "provider", "type", "obj"),
        "provider-field": domains.ObjType("provider field", "field", "obj"),
        "repo-rule": domains.ObjType("repository rule", "repo_rule", "obj"),
        "rule": domains.ObjType("rule", "rule", "obj"),
        "tag-class": domains.ObjType("tag class", "tag_class", "obj"),
        "target": domains.ObjType("target", "target", "obj"),  # target in a build file
        # Flags are also targets, so include "target" for xref'ing
        "flag": domains.ObjType("flag", "flag", "target", "obj"),
        # types are objects that have a constructor and methods/attrs
        "type": domains.ObjType("type", "type", "obj"),
    }

    # This controls:
    # * What is recognized when parsing, e.g. ":bzl:ref:`foo`" requires
    # "ref" to be in the role dict below.
    roles = {
        "arg": roles.XRefRole(),
        "attr": roles.XRefRole(),
        "default-value": _DefaultValueRole(),
        "flag": roles.XRefRole(),
        "obj": roles.XRefRole(),
        "required-providers": _RequiredProvidersRole(),
        "return-type": _ReturnTypeRole(),
        "rule": roles.XRefRole(),
        "target": roles.XRefRole(),
        "type": _TypeRole(),
    }
    # NOTE: Directives that have a corresponding object type should use
    # the same key for both directive and object type. Some directives
    # look up their corresponding object type.
    directives = {
        "aspect": _BzlAspect,
        "currentfile": _BzlCurrentFile,
        "function": _BzlFunction,
        "module-extension": _BzlModuleExtension,
        "provider": _BzlProvider,
        "provider-field": _BzlProviderField,
        "repo-rule": _BzlRepositoryRule,
        "rule": _BzlRule,
        "tag-class": _BzlTagClass,
        "target": _BzlTarget,
        "flag": _BzlFlag,
        "attr-info": _BzlAttrInfo,
    }
    indices = {
        _BzlIndex,
    }

    # NOTE: When adding additional data keys, make sure to update
    # merge_domaindata
    initial_data = {
        # All objects; keyed by full id
        # dict[str, _ObjectEntry]
        "objects": {},
        #  dict[str, dict[str, _ObjectEntry]]
        "objects_by_type": {},
        # Objects within each doc
        # dict[str, dict[str, _ObjectEntry]]
        "doc_names": {},
        # Objects by a shorter or alternative name
        # dict[str, dict[str id, _ObjectEntry]]
        "alt_names": {},
    }

    @override
    def get_full_qualified_name(
        self, node: docutils_nodes.Element
    ) -> typing.Union[str, None]:
        bzl_file = node.get("bzl:file")
        symbol_name = node.get("bzl:symbol")
        ref_target = node.get("reftarget")
        return ".".join(filter(None, [bzl_file, symbol_name, ref_target]))

    @override
    def get_objects(self) -> Iterable[_GetObjectsTuple]:
        for entry in self.data["objects"].values():
            yield entry.to_get_objects_tuple()

    @override
    def resolve_any_xref(
        self,
        env: environment.BuildEnvironment,
        fromdocname: str,
        builder: builders.Builder,
        target: str,
        node: addnodes.pending_xref,
        contnode: docutils_nodes.Element,
    ) -> list[tuple[str, docutils_nodes.Element]]:
        del env, node  # Unused
        entry = self._find_entry_for_xref(fromdocname, "obj", target)
        if not entry:
            return []
        to_docname = entry.index_entry.docname
        to_anchor = entry.index_entry.anchor
        ref_node = sphinx_nodes.make_refnode(
            builder, fromdocname, to_docname, to_anchor, contnode, title=to_anchor
        )

        matches = [(f"bzl:{entry.object_type}", ref_node)]
        return matches

    @override
    def resolve_xref(
        self,
        env: environment.BuildEnvironment,
        fromdocname: str,
        builder: builders.Builder,
        typ: str,
        target: str,
        node: addnodes.pending_xref,
        contnode: docutils_nodes.Element,
    ) -> typing.Union[docutils_nodes.Element, None]:
        _log_debug(
            "resolve_xref: fromdocname=%s, typ=%s, target=%s", fromdocname, typ, target
        )
        del env, node  # Unused
        entry = self._find_entry_for_xref(fromdocname, typ, target)
        if not entry:
            return None

        to_docname = entry.index_entry.docname
        to_anchor = entry.index_entry.anchor
        return sphinx_nodes.make_refnode(
            builder, fromdocname, to_docname, to_anchor, contnode, title=to_anchor
        )

    def _find_entry_for_xref(
        self, fromdocname: str, object_type: str, target: str
    ) -> typing.Union[_ObjectEntry, None]:
        if target.startswith("--"):
            target = target.strip("-")
            object_type = "flag"

        # Allow using parentheses, e.g. `foo()` or `foo(x=...)`
        target, _, _ = target.partition("(")

        # Elide the value part of --foo=bar flags
        # Note that the flag value could contain `=`
        if "=" in target:
            target = target[: target.find("=")]

        if target in self.data["doc_names"].get(fromdocname, {}):
            entry = self.data["doc_names"][fromdocname][target]
            # Prevent a local doc name masking a global alt name when its of
            # a different type. e.g. when the macro `foo` refers to the
            # rule `foo` in another doc.
            if object_type in self.object_types[entry.object_type].roles:
                return entry

        if object_type == "obj":
            search_space = self.data["objects"]
        else:
            search_space = self.data["objects_by_type"].get(object_type, {})
        if target in search_space:
            return search_space[target]

        _log_debug("find_entry: alt_names=%s", sorted(self.data["alt_names"].keys()))
        if target in self.data["alt_names"]:
            # Give preference to shorter object ids. This is a work around
            # to allow e.g. `FooInfo` to refer to the FooInfo type rather than
            # the `FooInfo` constructor.
            entries = sorted(
                self.data["alt_names"][target].items(), key=lambda item: len(item[0])
            )
            for _, entry in entries:
                if object_type in self.object_types[entry.object_type].roles:
                    return entry

        return None

    def add_object(self, entry: _ObjectEntry, alt_names=None) -> None:
        _log_debug(
            "add_object: full_id=%s, object_type=%s, alt_names=%s",
            entry.full_id,
            entry.object_type,
            alt_names,
        )
        if entry.full_id in self.data["objects"]:
            existing = self.data["objects"][entry.full_id]
            raise Exception(
                f"Object {entry.full_id} already registered: "
                + f"existing={existing}, incoming={entry}"
            )
        self.data["objects"][entry.full_id] = entry
        self.data["objects_by_type"].setdefault(entry.object_type, {})
        self.data["objects_by_type"][entry.object_type][entry.full_id] = entry

        repo, label, symbol = _parse_full_id(entry.full_id)
        if symbol:
            base_name = symbol.split(".")[-1]
        else:
            base_name = label.split(":")[-1]

        if alt_names is not None:
            alt_names = list(alt_names)
        # Add the repo-less version as an alias
        alt_names.append(label + (f"%{symbol}" if symbol else ""))

        for alt_name in sorted(set(alt_names)):
            self.data["alt_names"].setdefault(alt_name, {})
            self.data["alt_names"][alt_name][entry.full_id] = entry

        docname = entry.index_entry.docname
        self.data["doc_names"].setdefault(docname, {})
        self.data["doc_names"][docname][base_name] = entry

    def merge_domaindata(
        self, docnames: list[str], otherdata: dict[str, typing.Any]
    ) -> None:
        # Merge in simple dict[key, value] data
        for top_key in ("objects",):
            self.data[top_key].update(otherdata.get(top_key, {}))

        # Merge in two-level dict[top_key, dict[sub_key, value]] data
        for top_key in ("objects_by_type", "doc_names", "alt_names"):
            existing_top_map = self.data[top_key]
            for sub_key, sub_values in otherdata.get(top_key, {}).items():
                if sub_key not in existing_top_map:
                    existing_top_map[sub_key] = sub_values
                else:
                    existing_top_map[sub_key].update(sub_values)


def _on_missing_reference(app, env: environment.BuildEnvironment, node, contnode):
    if node["refdomain"] != "bzl":
        return None
    if node["reftype"] != "type":
        return None

    # There's no Bazel docs for None, so prevent missing xrefs warning
    if node["reftarget"] == "None":
        return contnode
    return None


def setup(app):
    app.add_domain(_BzlDomain)

    app.add_config_value(
        "bzl_default_repository_name",
        default=os.environ.get("SPHINX_BZL_DEFAULT_REPOSITORY_NAME", "@_main"),
        rebuild="env",
        types=[str],
    )
    app.connect("missing-reference", _on_missing_reference)

    # Pygments says it supports starlark, but it doesn't seem to actually
    # recognize `starlark` as a name. So just manually map it to python.
    app.add_lexer("starlark", lexer_classes["python"])
    app.add_lexer("bzl", lexer_classes["python"])

    return {
        "version": "1.0.0",
        "parallel_read_safe": True,
        "parallel_write_safe": True,
    }
