| """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, | |
| ) | |
| 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: 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")) | |
| 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: 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("", "") | |
| 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 | |
| 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, 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, 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, | |
| ), | |
| ) | |
| 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("\\") | |
| .lstrip("@") | |
| .replace("//", "/") | |
| .replace(".bzl%", ".") | |
| .replace("/", ".") | |
| .replace(":", ".") | |
| ) | |
| alt_names.extend(self._get_alt_names(object_entry)) | |
| 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 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 "" | |
| # 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"), | |
| "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" | |
| ), | |
| # 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(), | |
| "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 or alternative 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]]: | |
| 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, | |
| ) -> 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 a variety of formats to the dotted format used internally. | |
| # --@foo//:bar flags | |
| # --@foo//:bar=value labels | |
| # //foo:bar.bzl labels | |
| target = ( | |
| target.lstrip("@/:-") | |
| .replace("//", "/") | |
| .replace(".bzl%", ".") | |
| .replace("/", ".") | |
| .replace(":", ".") | |
| ) | |
| # 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, {}): | |
| 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"]: | |
| 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 | |
| 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, | |
| } |