docs: implement Starlark domain plugin for Sphinx (#1909)

This implements a Sphinx plugin to support Starlark as a first-class
domain in Sphinx.
A Starlark domain allows writing object descriptions directly using
Sphinx's object
markup language, which allows better integration with cross references,
understanding
and distinguishing types, rendering information about things, and
referencing types
from other projects.

Note that this doesn't affect the docs today because the
proto_to_markdown tool is
still generating regular markdown; updating that to generate Sphinx
domain markdown
will be done separately.

Summary of features:
* Types and arguments can be documented using Python syntax, e.g.
`list[str]` This includes
unions, generics, and custom types. Each component of the type
expression is linked and
cross referenced appropriately. Each object can have its approprate
pieces documented and
defined (e.g. a rule can have attributes, attributes can have their
defaults etc
  documented, etc)
* An index of Starlark objects is generated automatically. This makes it
easy, for example,
  to find the rules that have a particular attribute.
* The following objects can be documented: functions, methods, rules,
repository rules,
providers, aspects, bzlmod extensions, tag classes, targets, flags, and
  attributes/fields of the aforementioned objects.
* Arbitary docs can cross reference to Starlark types. e.g., a manually
written "Getting
Started" doc can write `{bzl:obj}PyInfo.some_field` and it will
automatically link to
  the appropriate API docs.
* Resolution of cross-references is much smarter and customizable.
Instead of relying
Markdown's link resolution rules, the Sphinx's crossreference resolution
hooks are
used. This allows more concise references (e.g., just a rule's base
name), distinguishing
a particular object type (e.g. a function vs rule), or referring to an
absolute object.
diff --git a/docs/sphinx/BUILD.bazel b/docs/sphinx/BUILD.bazel
index 8912f2c..e3b9ad3 100644
--- a/docs/sphinx/BUILD.bazel
+++ b/docs/sphinx/BUILD.bazel
@@ -70,6 +70,7 @@
 sphinx_inventory(
     name = "bazel_inventory",
     src = "bazel_inventory.txt",
+    visibility = ["//:__subpackages__"],
 )
 
 sphinx_stardocs(
diff --git a/docs/sphinx/bazel_inventory.txt b/docs/sphinx/bazel_inventory.txt
index 869e66a..b099f42 100644
--- a/docs/sphinx/bazel_inventory.txt
+++ b/docs/sphinx/bazel_inventory.txt
@@ -2,16 +2,23 @@
 # Project: Bazel
 # Version: 7.0.0
 # The remainder of this file is compressed using zlib
-Action bzl:obj 1 rules/lib/Action -
-File bzl:obj 1 rules/lib/File -
-Label bzl:obj 1 rules/lib/Label -
-Target bzl:obj 1 rules/lib/builtins/Target -
-bool bzl:obj 1 rules/lib/bool -
-depset bzl:obj 1 rules/lib/depset -
-dict bzl:obj 1 rules/lib/dict -
+Action bzl:type 1 rules/lib/Action -
+File bzl:type 1 rules/lib/File -
+Label bzl:type 1 rules/lib/Label -
+Target bzl:type 1 rules/lib/builtins/Target -
+bool bzl:type 1 rules/lib/bool -
+int bzl:type 1 rules/lib/int -
+depset bzl:type 1 rules/lib/depset -
+dict bzl:type 1 rules/lib/dict -
 label bzl:doc 1 concepts/labels -
-list bzl:obj: 1 rules/lib/list -
+attr.bool bzl:type 1 rules/lib/toplevel/attr#bool -
+attr.int bzl:type 1 rules/lib/toplevel/attr#int -
+attr.label bzl:type 1 rules/lib/toplevel/attr#label -
+attr.label_list bzl:type 1 rules/lib/toplevel/attr#label_list -
+attr.string bzl:type 1 rules/lib/toplevel/attr#string -
+attr.string_list bzl:type 1 rules/lib/toplevel/attr#string_list -
+list bzl:type 1 rules/lib/list -
 python bzl:doc 1 reference/be/python -
-str bzl:obj 1 rules/lib/string -
-struct bzl:obj 1 rules/lib/builtins/struct -
+str bzl:type 1 rules/lib/string -
+struct bzl:type 1 rules/lib/builtins/struct -
 target-name bzl:doc 1 concepts/labels#target-names -
diff --git a/docs/sphinx/pyproject.toml b/docs/sphinx/pyproject.toml
index d36c9f2..03279c5 100644
--- a/docs/sphinx/pyproject.toml
+++ b/docs/sphinx/pyproject.toml
@@ -10,4 +10,5 @@
     "sphinx_rtd_theme",
     "readthedocs-sphinx-ext",
     "absl-py",
+    "typing-extensions"
 ]
diff --git a/docs/sphinx/requirements.txt b/docs/sphinx/requirements.txt
index ed74550..a3c7c9a 100644
--- a/docs/sphinx/requirements.txt
+++ b/docs/sphinx/requirements.txt
@@ -335,6 +335,10 @@
     --hash=sha256:0c64ff898339e1fac29abd2bf5f11078f3ec413cfe9c046d3120d7ca65530b54 \
     --hash=sha256:9b36e503703ff04f20e9675771df105e58aa029cfcbc23b8ed716019b7416ae1
     # via sphinx
+typing-extensions==4.9.0 \
+    --hash=sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783 \
+    --hash=sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd
+    # via rules-python-docs (docs/sphinx/pyproject.toml)
 urllib3==2.1.0 \
     --hash=sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3 \
     --hash=sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54
diff --git a/sphinxdocs/BUILD.bazel b/sphinxdocs/BUILD.bazel
index cd1a1fb..6cb69ba 100644
--- a/sphinxdocs/BUILD.bazel
+++ b/sphinxdocs/BUILD.bazel
@@ -13,6 +13,7 @@
 # limitations under the License.
 
 load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
+load("@bazel_skylib//rules:common_settings.bzl", "bool_flag")
 load("//sphinxdocs/private:sphinx.bzl", "repeated_string_list_flag")
 
 package(
@@ -31,6 +32,15 @@
     build_setting_default = [],
 )
 
+# Whether to add the `-q` arg to Sphinx invocations, which determines if
+# stdout has any output or not (logging INFO messages and progress messages).
+# If true, add `-q`. If false, don't add `-q`. This is mostly useful for
+# debugging invocations or developing extensions.
+bool_flag(
+    name = "quiet",
+    build_setting_default = True,
+)
+
 bzl_library(
     name = "sphinx_bzl",
     srcs = ["sphinx.bzl"],
diff --git a/sphinxdocs/private/sphinx.bzl b/sphinxdocs/private/sphinx.bzl
index 4226391..a5ac831 100644
--- a/sphinxdocs/private/sphinx.bzl
+++ b/sphinxdocs/private/sphinx.bzl
@@ -15,6 +15,7 @@
 """Implementation of sphinx rules."""
 
 load("@bazel_skylib//lib:paths.bzl", "paths")
+load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo")
 load("//python:py_binary.bzl", "py_binary")
 load("//python/private:util.bzl", "add_tag", "copy_propagating_kwargs")  # buildifier: disable=bzl-visibility
 
@@ -119,6 +120,7 @@
         output_group = "html",
         **common_kwargs
     )
+
     py_binary(
         name = name + ".serve",
         srcs = [_SPHINX_SERVE_MAIN_SRC],
@@ -187,6 +189,7 @@
         ),
         "_extra_defines_flag": attr.label(default = "//sphinxdocs:extra_defines"),
         "_extra_env_flag": attr.label(default = "//sphinxdocs:extra_env"),
+        "_quiet_flag": attr.label(default = "//sphinxdocs:quiet"),
     },
 )
 
@@ -245,7 +248,9 @@
     args = ctx.actions.args()
     args.add("-T")  # Full tracebacks on error
     args.add("-b", format)
-    args.add("-q")  # Suppress stdout informational text
+
+    if ctx.attr._quiet_flag[BuildSettingInfo].value:
+        args.add("-q")  # Suppress stdout informational text
     args.add("-j", "auto")  # Build in parallel, if possible
     args.add("-E")  # Don't try to use cache files. Bazel can't make use of them.
     args.add("-a")  # Write all files; don't try to detect "changed" files
diff --git a/sphinxdocs/src/sphinx_stardoc/BUILD.bazel b/sphinxdocs/src/sphinx_stardoc/BUILD.bazel
new file mode 100644
index 0000000..b788b09
--- /dev/null
+++ b/sphinxdocs/src/sphinx_stardoc/BUILD.bazel
@@ -0,0 +1,14 @@
+load("//python:py_library.bzl", "py_library")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+)
+
+# NOTE: This provides the library on its own, not its dependencies.
+py_library(
+    name = "sphinx_stardoc",
+    srcs = glob(["*.py"]),
+    imports = [".."],
+    # Allow depending on it in sphinx_binary targets
+    visibility = ["//visibility:public"],
+)
diff --git a/sphinxdocs/src/sphinx_stardoc/__init__.py b/sphinxdocs/src/sphinx_stardoc/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/sphinxdocs/src/sphinx_stardoc/__init__.py
diff --git a/sphinxdocs/src/sphinx_stardoc/stardoc.py b/sphinxdocs/src/sphinx_stardoc/stardoc.py
new file mode 100644
index 0000000..283fb67
--- /dev/null
+++ b/sphinxdocs/src/sphinx_stardoc/stardoc.py
@@ -0,0 +1,1568 @@
+"""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 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: typing.TypeAlias = tuple[str, str, str, str, str, int]
+
+# See SphinxRole.run definition; the docs for role classes are pretty sparse.
+_RoleRunResult: typing.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
+
+
+# TODO: Remove this. Use @repo//pkg:file.bzl%symbol to identify things instead
+# of dots. This more directly reflects the bzl concept and avoids issues with
+# e.g. repos, directories, or files containing dots themselves.
+def _label_to_dotted_name(label: str) -> str:
+    """Convert an absolute label to a dotted name.
+
+    Args:
+        label: Absolute label with optional repo prefix, e.g. `@a//b:c.bzl`
+            or `//b:c.bzl`
+
+    Returns:
+        Label converted to a dotted notation for easier writing of object
+        references.
+    """
+    if label.endswith(".bzl"):
+        label = label[: -len(".bzl")]
+    elif ":BUILD" in label:
+        label = label[: label.find(":BUILD")]
+    else:
+        raise InvalidValueError(
+            f"Malformed label: Label must end with .bzl or :BUILD*, got {label}"
+        )
+
+    # Make a //foo:bar.bzl convert to foo.bar, not .foo.bar
+    if label.startswith("//"):
+        label = label.lstrip("/")
+    return label.replace("@", "").replace("//", "/").replace(":", "/").replace("/", ".")
+
+
+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,
+        )
+
+
+# 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: str | None = None,
+    category_key: str | None = None,
+) -> tuple[str, str, str, str | None, 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:
+    def __init__(
+        self,
+        *,
+        repo: str,
+        bzl_file: str = None,
+        namespace: str = None,
+        symbol: str = None,
+        target: 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 bzl_file:
+            raise InvalidValueError("bzl_file cannot be empty")
+        if not symbol:
+            raise InvalidvalueError("symbol cannot be empty")
+
+        self.repo = repo
+        self.bzl_file = bzl_file
+        self.namespace = namespace
+        self.symbol = symbol  # Relative to namespace
+
+        clean_repo = repo.replace("@", "")
+        package = _label_to_dotted_name(bzl_file)
+        self.full_id = ".".join(filter(None, [clean_repo, package, namespace, symbol]))
+
+    @classmethod
+    def from_env(
+        cls, env: environment.BuildEnvironment, symbol: str = None, target: str = None
+    ) -> "_BzlObjectId":
+        if target:
+            symbol = target.lstrip("/:").replace(":", ".")
+        return cls(
+            repo=env.ref_context["bzl:repo"],
+            bzl_file=env.ref_context["bzl:file"],
+            namespace=".".join(env.ref_context["bzl:doc_id_stack"]),
+            symbol=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"))
+        else:
+            raise InvalidValueError(f"Unexpected Constant 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: docutils_nodes.Node | None = None,
+        env: environment.BuildEnvironment | None = None,
+        inliner: states.Inliner | None = None,
+        location: 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: docutils_nodes.Node | None = None,
+        env: environment.BuildEnvironment | None = None,
+        inliner: states.Inliner | None = None,
+        location: 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 = ".".join(env.ref_context["bzl:object_id_stack"] + [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: states.Inliner | None = None,
+        location: 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"] = [
+            _label_to_dotted_name(repo + file_label)
+        ]
+        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("", "")
+        if "mandatory" in self.options:
+            content_node += docutils_nodes.paragraph("", "mandatory (must be non-None)")
+        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`
+    """
+
+    @override
+    def before_content(self) -> None:
+        symbol_name = self.names[-1].symbol
+        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) -> "None | 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:
+        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):
+                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:
+            signature = inspect.signature_from_str(params_text)
+
+            paramlist_node = addnodes.desc_parameterlist()
+            for param in signature.parameters.values():
+                node = addnodes.desc_parameter()
+                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, 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)
+        symbol_name = obj_desc.symbol
+        display_name = sig_node.get("bzl:index_display_name", symbol_name)
+
+        anchor_prefix = ".".join(self.env.ref_context["bzl:doc_id_stack"])
+        if anchor_prefix:
+            anchor_id = f"{anchor_prefix}.{symbol_name}"
+            file_location = "%" + anchor_prefix
+        else:
+            anchor_id = symbol_name
+            file_location = ""
+
+        sig_node["ids"].append(anchor_id)
+
+        object_type_display = self._get_object_type_display_name()
+        index_description = (
+            f"{display_name} ({object_type_display} in "
+            f"{obj_desc.bzl_file}{file_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=symbol_name,
+                subtype=_INDEX_SUBTYPE_NORMAL,
+                docname=self.env.docname,
+                anchor=anchor_id,
+                extra="",
+                qualifier="",
+                descr=index_description,
+            ),
+        )
+
+        self.env.get_domain(self.domain).add_object(
+            object_entry, alt_names=self._get_alt_names(object_entry)
+        )
+
+    def _get_additional_index_types(self):
+        return []
+
+    @override
+    def _object_hierarchy_parts(
+        self, sig_node: addnodes.desc_signature
+    ) -> tuple[str, ...]:
+        return tuple(sig_node["bzl:object_id"].split("."))
+
+    @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):
+        return [object_entry.full_id.split(".")[-1]]
+
+
+class _BzlCallable(_BzlObject):
+    """Abstract base class for objects that are callable."""
+
+    @override
+    def _get_alt_names(self, object_entry):
+        return [object_entry.full_id.split(".")[-1]]
+
+
+class _BzlProvider(_BzlObject):
+    """Documents a provider type.
+
+    Example MyST usage
+
+    ```
+    ::::{bzl:provider} MyInfo
+
+    Docs about MyInfo
+
+    :::{bzl:provider-field} some_field
+    :type: depset[str]
+    :::
+    ::::
+    ```
+    """
+
+    @override
+    def _get_alt_names(self, object_entry):
+        return [object_entry.full_id.split(".")[-1]]
+
+
+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):
+        return [".".join(object_entry.full_id.split(".")[-2:])]
+
+
+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 ""
+
+
+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, target=sig_text)
+        sig_node["bzl:object_id"] = obj_id.full_id
+        sig_node["bzl:index_display_name"] = f"{package}{target_name}"
+        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 ""
+
+
+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 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"),
+        "attribute": domains.ObjType("attribute", "attribute", "obj"),  # rule attribute
+        "function": domains.ObjType("function", "func", "obj"),
+        "method": domains.ObjType("method", "method", "obj"),
+        "module-extension": domains.ObjType(
+            "module extension", "module_extension", "obj"
+        ),
+        "provider": domains.ObjType("provider", "provider", "obj"),
+        "provider-field": domains.ObjType(
+            "provider field", "field", "obj"
+        ),  # provider field
+        "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
+        "flag": domains.ObjType(
+            "flag", "flag", "target", "obj"
+        ),  # flag-target in a build file
+        # 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(),
+        "obj": roles.XRefRole(),
+        "required-providers": _RequiredProvidersRole(),
+        "return-type": _ReturnTypeRole(),
+        "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 name
+        # dict[str, _ObjectEntry]
+        "alt_names": {},
+    }
+
+    @override
+    def get_full_qualified_name(self, node: docutils_nodes.Element) -> 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]]:
+        ref_node = self.resolve_xref(
+            env, fromdocname, builder, "obj", target, node, contnode
+        )
+        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,
+    ) -> 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
+    ) -> _ObjectEntry | None:
+        # Normalize labels to dotted notation
+        target = (
+            target.lstrip("@/:")
+            .replace("//", "/")
+            .replace(".bzl%", ".")
+            .replace("/", ".")
+            .replace(":", ".")
+        )
+        if target in self.data["doc_names"].get(fromdocname, {}):
+            return self.data["doc_names"][fromdocname][target]
+
+        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"]:
+            return self.data["alt_names"][target]
+
+        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"]:
+            raise Exception(f"Object {entry.full_id} already registered")
+        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
+
+        base_name = entry.full_id.split(".")[-1]
+
+        without_repo = entry.full_id.split(".", 1)[1]
+
+        if alt_names is not None:
+            alt_names = list(alt_names)
+        alt_names.append(without_repo)
+
+        for alt_name in alt_names:
+            if alt_name in self.data["alt_names"]:
+                existing = self.data["alt_names"][alt_name]
+                # This situation usually occurs for the constructor function
+                # of a provider, but could occur for e.g. an exported struct
+                # with an attribute the same name as the struct. For lack
+                # of a better option, take the shorter entry, on the assumption
+                # it refers to some container of the longer entry.
+                if len(entry.full_id) < len(existing.full_id):
+                    self.data["alt_names"][alt_name] = entry
+            else:
+                self.data["alt_names"][alt_name] = 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", "alt_names"):
+            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"):
+            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,
+    }
diff --git a/sphinxdocs/tests/sphinx_stardoc/BUILD.bazel b/sphinxdocs/tests/sphinx_stardoc/BUILD.bazel
new file mode 100644
index 0000000..5cf5736
--- /dev/null
+++ b/sphinxdocs/tests/sphinx_stardoc/BUILD.bazel
@@ -0,0 +1,37 @@
+load("//sphinxdocs:sphinx.bzl", "sphinx_build_binary", "sphinx_docs")
+
+sphinx_docs(
+    name = "docs",
+    srcs = glob(
+        include = [
+            "*.md",
+        ],
+    ),
+    config = "conf.py",
+    formats = [
+        "html",
+    ],
+    renamed_srcs = {
+        "//docs/sphinx:bazel_inventory": "bazel_inventory.inv",
+    },
+    sphinx = ":sphinx-build",
+    strip_prefix = package_name() + "/",
+    # We only develop the docs using Linux/Mac, and there are deps that
+    # don't work for Windows, so just skip Windows.
+    target_compatible_with = select({
+        "@platforms//os:linux": [],
+        "@platforms//os:macos": [],
+        "//conditions:default": ["@platforms//:incompatible"],
+    }),
+)
+
+sphinx_build_binary(
+    name = "sphinx-build",
+    tags = ["manual"],  # Only needed as part of sphinx doc building
+    deps = [
+        "//sphinxdocs/src/sphinx_stardoc",
+        "@dev_pip//myst_parser",
+        "@dev_pip//sphinx",
+        "@dev_pip//typing_extensions",  # Needed by sphinx_stardoc
+    ],
+)
diff --git a/sphinxdocs/tests/sphinx_stardoc/aspect.md b/sphinxdocs/tests/sphinx_stardoc/aspect.md
new file mode 100644
index 0000000..3c49903
--- /dev/null
+++ b/sphinxdocs/tests/sphinx_stardoc/aspect.md
@@ -0,0 +1,22 @@
+:::{default-domain} bzl
+:::
+
+:::{bzl:currentfile} //lang:aspect.bzl
+:::
+
+
+# Aspect
+
+:::{bzl:aspect} myaspect
+
+:attr aa1:
+  {bzl:default-value}`True`
+  {type}`bool`
+  aa1 doc
+:attr aa2:
+  {type}`str`
+  aa2 doc
+
+:aspect-attributes: edge1, edge2, deps, ra1
+:::
+
diff --git a/sphinxdocs/tests/sphinx_stardoc/conf.py b/sphinxdocs/tests/sphinx_stardoc/conf.py
new file mode 100644
index 0000000..b1dae97
--- /dev/null
+++ b/sphinxdocs/tests/sphinx_stardoc/conf.py
@@ -0,0 +1,33 @@
+# Configuration file for the Sphinx documentation builder.
+#
+# For the full list of built-in configuration values, see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+
+# -- Project info
+
+project = "Sphinx Stardoc Test"
+
+extensions = [
+    "sphinx_stardoc.stardoc",
+    "myst_parser",
+    "sphinx.ext.intersphinx",
+]
+
+myst_enable_extensions = [
+    "fieldlist",
+    "attrs_block",
+    "attrs_inline",
+    "colon_fence",
+    "deflist",
+    "substitution",
+]
+
+# --- Stardoc configuration
+
+bzl_default_repository_name = "@testrepo"
+
+# --- Intersphinx configuration
+
+intersphinx_mapping = {
+    "bazel": ("https://bazel.build/", "bazel_inventory.inv"),
+}
diff --git a/sphinxdocs/tests/sphinx_stardoc/envvars.md b/sphinxdocs/tests/sphinx_stardoc/envvars.md
new file mode 100644
index 0000000..d6bcc1b
--- /dev/null
+++ b/sphinxdocs/tests/sphinx_stardoc/envvars.md
@@ -0,0 +1,9 @@
+# Environment Variables
+
+These are just defined so the repo rules have a xref target.
+
+.. envvar:: FOO
+   The foo environment variable
+
+.. envvar:: BAR
+   The bar environment variable
diff --git a/sphinxdocs/tests/sphinx_stardoc/function.md b/sphinxdocs/tests/sphinx_stardoc/function.md
new file mode 100644
index 0000000..b8cbd37
--- /dev/null
+++ b/sphinxdocs/tests/sphinx_stardoc/function.md
@@ -0,0 +1,41 @@
+:::{default-domain} bzl
+:::
+
+:::{bzl:currentfile} //lang:function.bzl
+:::
+
+
+# Function
+
+Module documentation
+
+:::{bzl:function} myfunc(foo, bar=False, baz=[]) -> FooObj
+
+This is a bazel function.
+
+:arg arg1:
+  {default-value}`99`
+  {type}`bool | int`
+  arg1 doc
+
+:arg arg2:
+  {default-value}`True`
+  {type}`dict[str, str]` my arg2 doc
+
+  and a second paragraph of text here
+:arg arg3:
+  {default-value}`"arg3default"`
+  {type}`list[int]`
+  my arg3 doc
+:arg arg4:
+  my arg4 doc
+
+:returns:
+  {bzl:return-type}`list | int`
+  description
+
+:::
+
+:::{bzl:function} mylongfunc(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9)
+
+:::
diff --git a/sphinxdocs/tests/sphinx_stardoc/glossary.md b/sphinxdocs/tests/sphinx_stardoc/glossary.md
new file mode 100644
index 0000000..b3c0721
--- /dev/null
+++ b/sphinxdocs/tests/sphinx_stardoc/glossary.md
@@ -0,0 +1,8 @@
+# Glossary
+
+:::{glossary}
+
+customterm
+: A custom term definition
+
+:::
diff --git a/sphinxdocs/tests/sphinx_stardoc/index.md b/sphinxdocs/tests/sphinx_stardoc/index.md
new file mode 100644
index 0000000..4f70482
--- /dev/null
+++ b/sphinxdocs/tests/sphinx_stardoc/index.md
@@ -0,0 +1,26 @@
+# Sphinx Stardoc Test
+
+This is a set of documents to test the sphinx_stardoc extension.
+
+To build and view these docs, run:
+
+```
+bazel run //sphinxdocs/tests/sphinx_stardoc:docs.serve
+```
+
+This will build the docs and start an HTTP server where they can be viewed.
+
+To aid the edit/debug cycle, `ibazel` can be used to automatically rebuild
+the HTML:
+
+```
+ibazel build //sphinxdocs/tests/sphinx_stardoc:docs
+```
+
+:::{toctree}
+:hidden:
+:glob:
+
+*
+genindex
+:::
diff --git a/sphinxdocs/tests/sphinx_stardoc/module_extension.md b/sphinxdocs/tests/sphinx_stardoc/module_extension.md
new file mode 100644
index 0000000..0335386
--- /dev/null
+++ b/sphinxdocs/tests/sphinx_stardoc/module_extension.md
@@ -0,0 +1,20 @@
+:::{default-domain} bzl
+:::
+
+:::{bzl:currentfile} //lang:extension.bzl
+:::
+
+
+# Module extension
+::::{bzl:module-extension} myext
+
+:::{bzl:tag-class} mytag(ta1, ta2)
+
+:attr ta1:
+  {type}`attr.string_list`
+  ta1 doc
+:attr ta2:
+  {type}`attr.label_list`
+  ta2 doc
+:::
+::::
diff --git a/sphinxdocs/tests/sphinx_stardoc/provider.md b/sphinxdocs/tests/sphinx_stardoc/provider.md
new file mode 100644
index 0000000..dac16f0
--- /dev/null
+++ b/sphinxdocs/tests/sphinx_stardoc/provider.md
@@ -0,0 +1,34 @@
+:::{default-domain} bzl
+:::
+
+:::{bzl:currentfile} //lang:provider.bzl
+:::
+
+
+# Provider
+
+below is a provider
+
+::::{bzl:provider} LangInfo
+
+my provider doc
+
+:::{bzl:function} LangInfo(mi1, mi2=None)
+
+:arg ami1:
+  {type}`depset[str]`
+  mi1 doc
+:arg ami2: ami2 doc
+  {type}`None | depset[File]`
+:::
+
+:::{bzl:provider-field} mi1
+:type: depset[str]
+
+The doc for mi1
+:::
+
+:::{bzl:provider-field} mi2
+:type: str
+:::
+::::
diff --git a/sphinxdocs/tests/sphinx_stardoc/repo_rule.md b/sphinxdocs/tests/sphinx_stardoc/repo_rule.md
new file mode 100644
index 0000000..0a909d6
--- /dev/null
+++ b/sphinxdocs/tests/sphinx_stardoc/repo_rule.md
@@ -0,0 +1,19 @@
+:::{default-domain} bzl
+:::
+
+:::{bzl:currentfile} //lang:repo_rule.bzl
+:::
+
+
+# Repo rule
+
+below is a repository rule
+
+:::{bzl:repo-rule} myreporule(rra1, rra2)
+
+:attr rra1: rra1 doc
+:attr rra2: rra2 doc
+
+:envvars: FOO, BAR
+
+:::
diff --git a/sphinxdocs/tests/sphinx_stardoc/rule.md b/sphinxdocs/tests/sphinx_stardoc/rule.md
new file mode 100644
index 0000000..a6f3a56
--- /dev/null
+++ b/sphinxdocs/tests/sphinx_stardoc/rule.md
@@ -0,0 +1,34 @@
+:::{default-domain} bzl
+:::
+
+:::{bzl:currentfile} //lang:rule.bzl
+:::
+
+
+# Rule
+
+Here is some module documentation
+
+Next, we're going to document some rules.
+
+::::{bzl:rule} my_rule(ra1, ra2=3)
+
+:attr ra1:
+  {bzl:default-value}`//foo:bar`
+  {type}`attr.label`
+  Docs for attribute ra1.
+
+  :::{bzl:attr-info} Info
+  :executable: true
+  :mandatory: true
+  :::
+
+  {required-providers}`LangInfo | [OtherLangInfo, AnotherLangInfo]`
+
+:attr ra2:
+  {type}`attr.label`
+  Docs for attribute ra2
+
+:provides: LangInfo
+
+::::
diff --git a/sphinxdocs/tests/sphinx_stardoc/target.md b/sphinxdocs/tests/sphinx_stardoc/target.md
new file mode 100644
index 0000000..447a5ac
--- /dev/null
+++ b/sphinxdocs/tests/sphinx_stardoc/target.md
@@ -0,0 +1,23 @@
+:::{default-domain} bzl
+:::
+
+:::{bzl:currentfile} //lang:BUILD.bazel
+:::
+
+# Target
+
+Here is some package documentation
+
+:::{bzl:target} relativetarget
+
+Some doc about relativetarget
+
+:::
+
+:::{bzl:target} //absolute:abstarget
+
+:::
+
+:::{bzl:flag} myflag
+
+:::
diff --git a/sphinxdocs/tests/sphinx_stardoc/xrefs.md b/sphinxdocs/tests/sphinx_stardoc/xrefs.md
new file mode 100644
index 0000000..f0ea038
--- /dev/null
+++ b/sphinxdocs/tests/sphinx_stardoc/xrefs.md
@@ -0,0 +1,50 @@
+:::{default-domain} bzl
+:::
+
+# Xrefs
+
+Various tests of cross referencing support
+
+## Short name
+
+* function: {obj}`myfunc`
+* function arg: {obj}`myfunc.arg1`
+* rule: {obj}`my_rule`
+* rule attr: {obj}`my_rule.ra1`
+* provider: {obj}`LangInfo`
+
+## Fully qualified label without repo
+
+* function: {obj}`//lang:function.bzl%myfunc`
+* function arg: {obj}`//lang:function.bzl%myfunc.arg1`
+* rule: {obj}`//lang:rule.bzl%my_rule`
+* function: {obj}`//lang:rule.bzl%my_rule.ra1`
+* provider: {obj}`//lang:provider.bzl%LangInfo`
+* aspect: {obj}`//lang:aspect.bzl%myaspect`
+* target: {obj}`//lang:relativetarget`
+
+## Fully qualified label with repo
+
+* function: {obj}`@testrepo//lang:function.bzl%myfunc`
+* function arg: {obj}`@testrepo//lang:function.bzl%myfunc.arg1`
+* rule: {obj}`@testrepo//lang:rule.bzl%my_rule`
+* function: {obj}`@testrepo//lang:rule.bzl%my_rule.ra1`
+* provider: {obj}`@testrepo//lang:provider.bzl%LangInfo`
+* aspect: {obj}`@testrepo//lang:aspect.bzl%myaspect`
+* target: {obj}`@testrepo//lang:relativetarget`
+
+## Fully qualified dotted name with repo
+
+* function: {obj}`testrepo.lang.function.myfunc`
+* function arg: {obj}`testrepo.lang.function.myfunc.arg1`
+* rule: {obj}`testrepo.lang.rule.my_rule`
+* function: {obj}`testrepo.lang.rule.my_rule.ra1`
+* provider: {obj}`testrepo.lang.provider.LangInfo`
+
+## Fully qualified dotted name without repo
+
+* function: {obj}`lang.function.myfunc`
+* function arg: {obj}`lang.function.myfunc.arg1`
+* rule: {obj}`lang.rule.my_rule`
+* rule attr: {obj}`lang.rule.my_rule.ra1`
+* provider: {obj}`lang.provider.LangInfo`