Skip to content

Commit 6be8e05

Browse files
committed
Implement js:automodule directive
1 parent 32bb950 commit 6be8e05

File tree

8 files changed

+241
-35
lines changed

8 files changed

+241
-35
lines changed

sphinx_js/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
auto_attribute_directive_bound_to_app,
1313
auto_class_directive_bound_to_app,
1414
auto_function_directive_bound_to_app,
15+
auto_module_directive_bound_to_app,
1516
sphinx_js_type_role,
1617
)
1718
from .jsdoc import Analyzer as JsAnalyzer
@@ -159,6 +160,9 @@ def setup(app: Sphinx) -> None:
159160
app.add_directive_to_domain(
160161
"js", "autoattribute", auto_attribute_directive_bound_to_app(app)
161162
)
163+
app.add_directive_to_domain(
164+
"js", "automodule", auto_module_directive_bound_to_app(app)
165+
)
162166

163167
# TODO: We could add a js:module with app.add_directive_to_domain().
164168

sphinx_js/directives.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
AutoAttributeRenderer,
2727
AutoClassRenderer,
2828
AutoFunctionRenderer,
29+
AutoModuleRenderer,
2930
JsRenderer,
3031
)
3132

@@ -174,3 +175,15 @@ def get_display_prefix(
174175
]
175176
)
176177
return result
178+
179+
180+
def auto_module_directive_bound_to_app(app: Sphinx) -> type[Directive]:
181+
class AutoModuleDirective(JsDirectiveWithChildren):
182+
"""TODO: words here"""
183+
184+
required_arguments = 1
185+
186+
def run(self) -> list[Node]:
187+
return self._run(AutoModuleRenderer, app)
188+
189+
return AutoModuleDirective

sphinx_js/ir.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,16 @@ class Return:
175175
description: Description
176176

177177

178+
@define
179+
class Module:
180+
filename: str
181+
path: Pathname
182+
line: int
183+
attributes: list["TopLevel"] = Factory(list)
184+
functions: list["Function"] = Factory(list)
185+
classes: list["Class"] = Factory(list)
186+
187+
178188
@define(slots=False)
179189
class TopLevel:
180190
"""A language object with an independent existence

sphinx_js/renderers.py

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import textwrap
2-
from collections.abc import Callable, Iterable, Iterator
2+
from collections.abc import Callable, Iterable, Iterator, Sequence
33
from functools import partial
44
from re import sub
55
from typing import Any, Literal
@@ -24,6 +24,7 @@
2424
Exc,
2525
Function,
2626
Interface,
27+
Module,
2728
Param,
2829
Pathname,
2930
Return,
@@ -49,7 +50,7 @@ def sort_attributes_first_then_by_path(obj: TopLevel) -> Any:
4950
idx = 0
5051
case Function(_):
5152
idx = 1
52-
case Class(_):
53+
case Class(_) | Interface(_):
5354
idx = 2
5455

5556
return idx, obj.path.segments
@@ -253,19 +254,20 @@ def rst_nodes(self) -> list[Node]:
253254
return doc.children
254255

255256
def rst_for(self, obj: TopLevel) -> str:
256-
renderer: type
257+
renderer_class: type
257258
match obj:
258259
case Attribute(_):
259-
renderer = AutoAttributeRenderer
260+
renderer_class = AutoAttributeRenderer
260261
case Function(_):
261-
renderer = AutoFunctionRenderer
262+
renderer_class = AutoFunctionRenderer
262263
case Class(_):
263-
renderer = AutoClassRenderer
264+
renderer_class = AutoClassRenderer
264265
case _:
265266
raise RuntimeError("This shouldn't happen...")
266-
return renderer(self._directive, self._app, arguments=["dummy"]).rst(
267-
[obj.name], obj, use_short_name=False
267+
renderer = renderer_class(
268+
self._directive, self._app, arguments=["dummy"], options={"members": ["*"]}
268269
)
270+
return renderer.rst([obj.name], obj, use_short_name=False)
269271

270272
def rst(
271273
self, partial_path: list[str], obj: TopLevel, use_short_name: bool = False
@@ -610,6 +612,32 @@ def _template_vars(self, name: str, obj: Attribute) -> dict[str, Any]: # type:
610612
)
611613

612614

615+
class AutoModuleRenderer(JsRenderer):
616+
def get_object(self) -> Module: # type:ignore[override]
617+
analyzer: Analyzer = self._app._sphinxjs_analyzer # type:ignore[attr-defined]
618+
assert isinstance(analyzer, TsAnalyzer)
619+
return analyzer._modules_by_path.get(self._partial_path)
620+
621+
def dependencies(self) -> set[str]:
622+
return set()
623+
624+
def rst_for_group(self, objects: Iterable[TopLevel]) -> list[str]:
625+
return [self.rst_for(obj) for obj in objects]
626+
627+
def rst( # type:ignore[override]
628+
self,
629+
partial_path: list[str],
630+
obj: Module,
631+
use_short_name: bool = False,
632+
) -> str:
633+
rst: list[Sequence[str]] = []
634+
rst.append([f".. js:module:: {''.join(partial_path)}"])
635+
rst.append(self.rst_for_group(obj.attributes))
636+
rst.append(self.rst_for_group(obj.functions))
637+
rst.append(self.rst_for_group(obj.classes))
638+
return "\n\n".join(["\n\n".join(r) for r in rst])
639+
640+
613641
def unwrapped(text: str) -> str:
614642
"""Return the text with line wrapping removed."""
615643
return sub(r"[ \t]*[\r\n]+[ \t]*", " ", text)

sphinx_js/typedoc.py

Lines changed: 71 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from functools import cache
1111
from inspect import isclass
1212
from json import load
13+
from operator import attrgetter
1314
from pathlib import Path
1415
from tempfile import NamedTemporaryFile
1516
from typing import Annotated, Any, Literal, TypedDict
@@ -103,6 +104,16 @@ def parse(json: dict[str, Any]) -> "Project":
103104
PostConvertType = typing.Callable[["Converter", "Node | Signature", ir.TopLevel], None]
104105

105106

107+
def _parse_filepath(path: str, base_dir: str) -> list[str]:
108+
p = Path(path).resolve().relative_to(base_dir)
109+
if p.name:
110+
p = p.with_suffix("")
111+
entries = ["."] + list(p.parts)
112+
for i in range(len(entries) - 1):
113+
entries[i] += "/"
114+
return entries
115+
116+
106117
class Converter:
107118
base_dir: str
108119
index: dict[int, "IndexType"]
@@ -134,15 +145,6 @@ def populate_index(self, root: "Project") -> "Converter":
134145
self._populate_index_inner(root, parent=None, idmap=root.symbolIdMap)
135146
return self
136147

137-
def _parse_filepath(self, path: str) -> list[str]:
138-
p = Path(path).resolve().relative_to(self.base_dir)
139-
if p.name:
140-
p = p.with_suffix("")
141-
entries = ["."] + list(p.parts)
142-
for i in range(len(entries) - 1):
143-
entries[i] += "/"
144-
return entries
145-
146148
def _populate_index_inner(
147149
self,
148150
node: "IndexType",
@@ -156,7 +158,9 @@ def _populate_index_inner(
156158
parent_kind = parent.kindString if parent else ""
157159
parent_segments = parent.path if parent else []
158160
if str(node.id) in idmap:
159-
filepath = self._parse_filepath(idmap[str(node.id)].sourceFileName)
161+
filepath = _parse_filepath(
162+
idmap[str(node.id)].sourceFileName, self.base_dir
163+
)
160164
if filepath:
161165
node.filepath = filepath
162166
self.compute_path(node, parent_kind, parent_segments, filepath)
@@ -208,9 +212,12 @@ def compute_path(
208212

209213
node.path = segments
210214

211-
def convert_all_nodes(self, root: "Project") -> list[ir.TopLevel]:
215+
def convert_all_nodes(
216+
self, root: "Project"
217+
) -> tuple[list[ir.TopLevel], list[ir.TopLevel]]:
212218
todo: list[Node | Signature] = list(root.children)
213219
done = []
220+
top_level = []
214221
while todo:
215222
node = todo.pop()
216223
if node.sources and node.sources[0].fileName[0] == "/":
@@ -221,13 +228,17 @@ def convert_all_nodes(self, root: "Project") -> list[ir.TopLevel]:
221228
if converted:
222229
self._post_convert(self, node, converted)
223230
done.append(converted)
224-
return done
231+
if converted and getattr(node, "top_level", False):
232+
top_level.append(converted)
233+
return done, top_level
225234

226235

227236
class Analyzer:
237+
modules: dict[str, ir.Module]
238+
228239
def __init__(
229240
self,
230-
json: "Project",
241+
project: "Project",
231242
base_dir: str,
232243
*,
233244
should_destructure_arg: ShouldDestructureArgType | None = None,
@@ -243,12 +254,21 @@ def __init__(
243254
base_dir,
244255
should_destructure_arg=should_destructure_arg,
245256
post_convert=post_convert,
246-
).populate_index(json)
247-
ir_objects = converter.convert_all_nodes(json)
257+
).populate_index(project)
258+
for child in project.children:
259+
child.top_level = True
260+
if isinstance(child, Module):
261+
for c in child.children:
262+
c.top_level = True
263+
264+
ir_objects, top_level = converter.convert_all_nodes(project)
248265

249266
self._base_dir = base_dir
250267
self._objects_by_path: SuffixTree[ir.TopLevel] = SuffixTree()
251268
self._objects_by_path.add_many((obj.path.segments, obj) for obj in ir_objects)
269+
modules = self._create_modules(top_level)
270+
self._modules_by_path: SuffixTree[ir.Module] = SuffixTree()
271+
self._modules_by_path.add_many((obj.path.segments, obj) for obj in modules)
252272

253273
@classmethod
254274
def from_disk(
@@ -272,19 +292,41 @@ def get_object(
272292
"""Return the IR object with the given path suffix.
273293
274294
:arg as_type: Ignored
275-
276-
We can't scan through the raw TypeDoc output at runtime like the JSDoc
277-
analyzer does, because it's just a linear list of files, each
278-
containing a nested tree of nodes. They're not indexed at all. And
279-
since we need to index by suffix, we need to traverse all the way down,
280-
eagerly. Also, we will keep the flattening, because we need it to
281-
resolve the IDs of references. (Some of the references are potentially
282-
important in the future: that's how TypeDoc points to superclass
283-
definitions of methods inherited by subclasses.)
284-
285295
"""
286296
return self._objects_by_path.get(path_suffix)
287297

298+
def _create_modules(self, ir_objects: list[ir.TopLevel]) -> Iterable[ir.Module]:
299+
"""Search through the doclets generated by JsDoc and categorize them by
300+
summary section. Skip docs labeled as "@private".
301+
"""
302+
modules = {}
303+
for obj in ir_objects:
304+
assert obj.deppath
305+
path = obj.deppath.split("/")
306+
for i in range(len(path) - 1):
307+
path[i] += "/"
308+
if obj.deppath not in modules:
309+
modules[obj.deppath] = ir.Module(
310+
filename=obj.deppath, path=ir.Pathname(path), line=1
311+
)
312+
mod = modules[obj.deppath]
313+
if "attribute" in obj.modifier_tags:
314+
mod.attributes.append(obj)
315+
continue
316+
match obj:
317+
case ir.Attribute(_):
318+
mod.attributes.append(obj)
319+
case ir.Function(_):
320+
mod.functions.append(obj)
321+
case ir.Class(_):
322+
mod.classes.append(obj)
323+
324+
for mod in modules.values():
325+
mod.attributes = sorted(mod.attributes, key=attrgetter("name"))
326+
mod.functions = sorted(mod.functions, key=attrgetter("name"))
327+
mod.classes = sorted(mod.classes, key=attrgetter("name"))
328+
return modules.values()
329+
288330

289331
class Source(BaseModel):
290332
fileName: str
@@ -410,6 +452,7 @@ class TopLevelProperties(Base):
410452
name: str
411453
kindString: str
412454
comment_: Comment = Field(default_factory=Comment, alias="comment")
455+
top_level: bool = False
413456

414457
@property
415458
def comment(self) -> Comment:
@@ -976,11 +1019,12 @@ def inner(param: Param) -> Iterator[str | ir.TypeXRef]:
9761019
def to_ir(
9771020
self, converter: Converter
9781021
) -> tuple[ir.Function | None, Sequence["Node"]]:
979-
if self.name.startswith("["):
1022+
SYMBOL_PREFIX = "[Symbol\u2024"
1023+
if self.name.startswith("[") and not self.name.startswith(SYMBOL_PREFIX):
9801024
# a symbol.
9811025
# \u2024 looks like a period but is not a period.
9821026
# This isn't ideal, but otherwise the coloring is weird.
983-
self.name = "[Symbol\u2024" + self.name[1:]
1027+
self.name = SYMBOL_PREFIX + self.name[1:]
9841028
self._fix_type_suffix()
9851029
params = self._destructure_params(converter)
9861030
# Would be nice if we could statically determine that the function was
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.. js:automodule:: module

tests/test_build_ts/source/module.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* The thing.
3+
*/
4+
export const a = 7;
5+
6+
/**
7+
* Crimps the bundle
8+
*/
9+
export function f() {
10+
11+
}
12+
13+
export function z(a: number, b: typeof q): number {
14+
return a;
15+
}
16+
17+
export class A {
18+
f() {
19+
20+
}
21+
22+
[Symbol.iterator]() {
23+
24+
}
25+
26+
g(a: number) : number {
27+
return a + 1;
28+
}
29+
}
30+
31+
export class Z {
32+
constructor(a: number, b: number) {
33+
34+
}
35+
36+
z() {}
37+
}
38+
39+
/**
40+
* Another thing.
41+
*/
42+
export const q = {a: "z29", b: 76};
43+
44+
45+

0 commit comments

Comments
 (0)