Skip to content

Commit 72fa619

Browse files
committed
wip: typedef directive
1 parent a3cdab5 commit 72fa619

File tree

8 files changed

+305
-17
lines changed

8 files changed

+305
-17
lines changed

sphinxdocs/docs/sphinx-bzl.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,3 +255,28 @@ My docs
255255
Documents a flag. It has the same format as `{bzl:target}`
256256
:::
257257

258+
259+
:::{rst:directive} .. bzl:typedef:: typename
260+
261+
Documents a user-defined structural "type". These are typically generated
262+
by following [User-defined types] to create a struct with a `TYPEDEF` field.
263+
264+
```
265+
::::{bzl:typedef} Square
266+
267+
:::{bzl:field} width
268+
:type: int
269+
:::
270+
271+
:::{bzl:function} new(size)
272+
:::
273+
274+
:::{bzl:function} area()
275+
:::
276+
::::
277+
```
278+
279+
Note that, due to a Sphinx and/or MyST bug, the number of colons for the
280+
outer typedef directive must be greater than the inner directives. Otherwise,
281+
only the first nested directive is parsed as part of the type def.
282+
:::

sphinxdocs/docs/starlark-docgen.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,86 @@ bzl_library(
7373
deps = ...
7474
)
7575
```
76+
77+
## User-defined types
78+
79+
While Starlark doesn't have user-defined types as a first-class concept, it's
80+
still possible to create such objects using `struct` and lambdas. For the
81+
purposes of documentation, they can be documented by creating a module-level
82+
`struct` with matching fields *and* also a field named `TYPEDEF`. When the
83+
`sphinx_stardoc` rule sees a struct with a `TYPEDEF` field, it generates doc
84+
using the {rst:directive}`bzl:typedef` directive and puts all the struct's fields
85+
within the typedef. The net result is the rendered docs look similar to how
86+
a class would be documented in other programming languages.
87+
88+
For example, a `Square` object with a `area()` method would look like:
89+
90+
```
91+
92+
def _Square_typedef():
93+
"""A square with fixed size.
94+
95+
:::{field} width
96+
:type: int
97+
:::
98+
"""
99+
100+
def _Square_new(width):
101+
"""Creates a Square.
102+
103+
Args:
104+
width: {type}`int` width of square
105+
106+
Returns:
107+
{type}`Square`
108+
"""
109+
self = struct(
110+
area = lambda *a, **k: _Square_area(self, *a, **k),
111+
width = width
112+
)
113+
return self
114+
115+
def _Square_area(self, ):
116+
"""Tells the area of the square."""
117+
return self.width * self.width
118+
119+
Square = struct(
120+
TYPEDEF = _Square_typedef,
121+
new = _Square_new,
122+
area = _Square_area,
123+
)
124+
```
125+
126+
This will then genereate markdown that looks like:
127+
128+
```
129+
::::{bzl:typedef} Square
130+
A square with fixed size
131+
132+
:::{bzl:field} width
133+
:type: int
134+
:::
135+
:::{bzl:function} new()
136+
...
137+
:::
138+
:::{bzl:function} area()
139+
...
140+
:::
141+
::::
142+
```
143+
144+
Which renders as:
145+
146+
::::{bzl:typedef} Square
147+
A square with fixed size
148+
149+
:::{bzl:field} width
150+
:type: int
151+
:::
152+
:::{bzl:function} new()
153+
...
154+
:::
155+
:::{bzl:function} area()
156+
...
157+
:::
158+
::::

sphinxdocs/private/proto_to_markdown.py

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,15 @@ def __init__(
9696
self._module = module
9797
self._out_stream = out_stream
9898
self._public_load_path = public_load_path
99+
self._typedef_stack = []
100+
101+
def _get_colons(self):
102+
# There's a weird behavior where increasing colon indents doesn't
103+
# parse as nested objects correctly, so we have to reduce the
104+
# number of colons based on the indent level
105+
indent = 10 - len(self._typedef_stack)
106+
assert indent >= 0
107+
return ":::" + ":" * indent
99108

100109
def render(self):
101110
self._render_module(self._module)
@@ -115,11 +124,10 @@ def _render_module(self, module: stardoc_output_pb2.ModuleInfo):
115124
"\n\n",
116125
)
117126

118-
# Sort the objects by name
119127
objects = itertools.chain(
120128
((r.rule_name, r, self._render_rule) for r in module.rule_info),
121129
((p.provider_name, p, self._render_provider) for p in module.provider_info),
122-
((f.function_name, f, self._render_func) for f in module.func_info),
130+
((f.function_name, f, self._process_func_info) for f in module.func_info),
123131
((a.aspect_name, a, self._render_aspect) for a in module.aspect_info),
124132
(
125133
(m.extension_name, m, self._render_module_extension)
@@ -130,13 +138,31 @@ def _render_module(self, module: stardoc_output_pb2.ModuleInfo):
130138
for r in module.repository_rule_info
131139
),
132140
)
141+
# Sort by name, ignoring case. The `.TYPEDEF` string is removed so
142+
# that the .TYPEDEF entries come before what is in the typedef.
143+
objects = sorted(objects, key=lambda v: v[0].removesuffix(".TYPEDEF").lower())
133144

134-
objects = sorted(objects, key=lambda v: v[0].lower())
135-
136-
for _, obj, func in objects:
137-
func(obj)
145+
for name, obj, func in objects:
146+
self._process_object(name, obj, func)
138147
self._write("\n")
139148

149+
# Close any typedefs
150+
while self._typedef_stack:
151+
self._typedef_stack.pop()
152+
self._render_typedef_end()
153+
154+
def _process_object(self, name, obj, renderer):
155+
# The trailing doc is added to prevent matching a common prefix
156+
typedef_group = name.removesuffix(".TYPEDEF") + "."
157+
while self._typedef_stack and not typedef_group.startswith(
158+
self._typedef_stack[-1]
159+
):
160+
self._typedef_stack.pop()
161+
self._render_typedef_end()
162+
renderer(obj)
163+
if name.endswith(".TYPEDEF"):
164+
self._typedef_stack.append(typedef_group)
165+
140166
def _render_aspect(self, aspect: stardoc_output_pb2.AspectInfo):
141167
_sort_attributes_inplace(aspect.attribute)
142168
self._write("::::::{bzl:aspect} ", aspect.aspect_name, "\n\n")
@@ -242,12 +268,32 @@ def _rule_attr_type_string(self, attr: stardoc_output_pb2.AttributeInfo) -> str:
242268
# Rather than error, give some somewhat understandable value.
243269
return _AttributeType.Name(attr.type)
244270

271+
def _process_func_info(self, func):
272+
if func.function_name.endswith(".TYPEDEF"):
273+
self._render_typedef_start(func)
274+
else:
275+
self._render_func(func)
276+
277+
def _render_typedef_start(self, func):
278+
self._write(
279+
self._get_colons(),
280+
"{bzl:typedef} ",
281+
func.function_name.removesuffix(".TYPEDEF"),
282+
"\n",
283+
)
284+
if func.doc_string:
285+
self._write(func.doc_string.strip(), "\n")
286+
287+
def _render_typedef_end(self):
288+
self._write(self._get_colons(), "\n\n")
289+
245290
def _render_func(self, func: stardoc_output_pb2.StarlarkFunctionInfo):
246-
self._write("::::::{bzl:function} ")
291+
self._write(self._get_colons(), "{bzl:function} ")
247292

248293
parameters = self._render_func_signature(func)
249294

250-
self._write(func.doc_string.strip(), "\n\n")
295+
if doc_string := func.doc_string.strip():
296+
self._write(doc_string, "\n\n")
251297

252298
if parameters:
253299
for param in parameters:
@@ -268,10 +314,13 @@ def _render_func(self, func: stardoc_output_pb2.StarlarkFunctionInfo):
268314
self._write(":::::{deprecated}: unknown\n")
269315
self._write(" ", _indent_block_text(func.deprecated.doc_string), "\n")
270316
self._write(":::::\n")
271-
self._write("::::::\n")
317+
self._write(self._get_colons(), "\n")
272318

273319
def _render_func_signature(self, func):
274-
self._write(f"{func.function_name}(")
320+
func_name = func.function_name
321+
if self._typedef_stack:
322+
func_name = func.function_name.removeprefix(self._typedef_stack[-1])
323+
self._write(f"{func_name}(")
275324
# TODO: Have an "is method" directive in the docstring to decide if
276325
# the self parameter should be removed.
277326
parameters = [param for param in func.parameter if param.name != "self"]

sphinxdocs/src/sphinx_bzl/bzl.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,7 @@ def _make_xrefs_for_arg_attr(
424424
return [wrapper]
425425

426426

427-
class _BzlField(_BzlXrefField, docfields.Field):
427+
class _BzlDocField(_BzlXrefField, docfields.Field):
428428
"""A non-repeated field with xref support."""
429429

430430

@@ -623,6 +623,7 @@ def handle_signature(
623623
relative_name = relative_name.strip()
624624

625625
name_prefix, _, base_symbol_name = relative_name.rpartition(".")
626+
626627
if name_prefix:
627628
# Respect whatever the signature wanted
628629
display_prefix = name_prefix
@@ -819,6 +820,26 @@ class _BzlCallable(_BzlObject):
819820
"""Abstract base class for objects that are callable."""
820821

821822

823+
class _BzlTypedef(_BzlObject):
824+
"""Documents a typedef.
825+
826+
A typedef describes objects with well known attributes.
827+
828+
::::{bzl:typedef} Square
829+
830+
:::{bzl:field} width
831+
:type: int
832+
:::
833+
834+
:::{bzl:function} new(size)
835+
:::
836+
837+
:::{bzl:function} area()
838+
:::
839+
::::
840+
"""
841+
842+
822843
class _BzlProvider(_BzlObject):
823844
"""Documents a provider type.
824845
@@ -837,7 +858,7 @@ class _BzlProvider(_BzlObject):
837858
"""
838859

839860

840-
class _BzlProviderField(_BzlObject):
861+
class _BzlField(_BzlObject):
841862
"""Documents a field of a provider.
842863
843864
Fields can optionally have a type specified using the `:type:` option.
@@ -872,6 +893,10 @@ def _get_alt_names(self, object_entry):
872893
return alt_names
873894

874895

896+
class _BzlProviderField(_BzlField):
897+
pass
898+
899+
875900
class _BzlRepositoryRule(_BzlCallable):
876901
"""Documents a repository rule.
877902
@@ -951,7 +976,7 @@ class _BzlRule(_BzlCallable):
951976
rolename="attr",
952977
can_collapse=False,
953978
),
954-
_BzlField(
979+
_BzlDocField(
955980
"provides",
956981
label="Provides",
957982
has_arg=False,
@@ -1078,13 +1103,13 @@ class _BzlModuleExtension(_BzlObject):
10781103
"""
10791104

10801105
doc_field_types = [
1081-
_BzlField(
1106+
_BzlDocField(
10821107
"os-dependent",
10831108
label="OS Dependent",
10841109
has_arg=False,
10851110
names=["os-dependent"],
10861111
),
1087-
_BzlField(
1112+
_BzlDocField(
10881113
"arch-dependent",
10891114
label="Arch Dependent",
10901115
has_arg=False,
@@ -1448,7 +1473,8 @@ class _BzlDomain(domains.Domain):
14481473
# Providers are close enough to types that we include "type". This
14491474
# also makes :type: Foo work in directive options.
14501475
"provider": domains.ObjType("provider", "provider", "type", "obj"),
1451-
"provider-field": domains.ObjType("provider field", "field", "obj"),
1476+
"provider-field": domains.ObjType("provider field", "provider-field", "obj"),
1477+
"field": domains.ObjType("field", "field", "obj"),
14521478
"repo-rule": domains.ObjType("repository rule", "repo_rule", "obj"),
14531479
"rule": domains.ObjType("rule", "rule", "obj"),
14541480
"tag-class": domains.ObjType("tag class", "tag_class", "obj"),
@@ -1457,6 +1483,7 @@ class _BzlDomain(domains.Domain):
14571483
"flag": domains.ObjType("flag", "flag", "target", "obj"),
14581484
# types are objects that have a constructor and methods/attrs
14591485
"type": domains.ObjType("type", "type", "obj"),
1486+
"typedef": domains.ObjType("typedef", "typedef", "type", "obj"),
14601487
}
14611488

14621489
# This controls:
@@ -1483,7 +1510,9 @@ class _BzlDomain(domains.Domain):
14831510
"function": _BzlFunction,
14841511
"module-extension": _BzlModuleExtension,
14851512
"provider": _BzlProvider,
1513+
"typedef": _BzlTypedef,
14861514
"provider-field": _BzlProviderField,
1515+
"field": _BzlField,
14871516
"repo-rule": _BzlRepositoryRule,
14881517
"rule": _BzlRule,
14891518
"tag-class": _BzlTagClass,

sphinxdocs/tests/proto_to_markdown/proto_to_markdown_test.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,22 @@ def test_render_signature(self):
193193
self.assertIn('{default-value}`"@repo//pkg:file.bzl"`', actual)
194194
self.assertIn("{default-value}`'<function foo from //bar:baz.bzl>'", actual)
195195

196+
def test_render_typedefs(self):
197+
proto_text = """
198+
file: "@repo//pkg:foo.bzl"
199+
func_info: { function_name: "Zeta.TYPEDEF" }
200+
func_info: { function_name: "Carl.TYPEDEF" }
201+
func_info: { function_name: "Carl.ns.Alpha.TYPEDEF" }
202+
func_info: { function_name: "Beta.TYPEDEF" }
203+
func_info: { function_name: "Beta.Sub.TYPEDEF" }
204+
"""
205+
actual = self._render(proto_text)
206+
self.assertIn("\n:::::::::::::{bzl:typedef} Beta\n", actual)
207+
self.assertIn("\n::::::::::::{bzl:typedef} Beta.Sub\n", actual)
208+
self.assertIn("\n:::::::::::::{bzl:typedef} Carl\n", actual)
209+
self.assertIn("\n::::::::::::{bzl:typedef} Carl.ns.Alpha\n", actual)
210+
self.assertIn("\n:::::::::::::{bzl:typedef} Zeta\n", actual)
211+
196212

197213
if __name__ == "__main__":
198214
absltest.main()

sphinxdocs/tests/sphinx_stardoc/BUILD.bazel

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ sphinx_docs(
4242

4343
sphinx_stardocs(
4444
name = "simple_bzl_docs",
45-
srcs = [":bzl_rule_bzl"],
45+
srcs = [
46+
":bzl_rule_bzl",
47+
":bzl_typedef_bzl",
48+
],
4649
target_compatible_with = _TARGET_COMPATIBLE_WITH,
4750
)
4851

@@ -76,6 +79,11 @@ bzl_library(
7679
deps = [":func_and_providers_bzl"],
7780
)
7881

82+
bzl_library(
83+
name = "bzl_typedef_bzl",
84+
srcs = ["bzl_typedef.bzl"],
85+
)
86+
7987
sphinx_build_binary(
8088
name = "sphinx-build",
8189
tags = ["manual"], # Only needed as part of sphinx doc building

0 commit comments

Comments
 (0)