diff --git a/sphinx_js/__init__.py b/sphinx_js/__init__.py index 5859d9a3..0f705cb8 100644 --- a/sphinx_js/__init__.py +++ b/sphinx_js/__init__.py @@ -12,6 +12,7 @@ auto_attribute_directive_bound_to_app, auto_class_directive_bound_to_app, auto_function_directive_bound_to_app, + auto_module_directive_bound_to_app, sphinx_js_type_role, ) from .jsdoc import Analyzer as JsAnalyzer @@ -159,6 +160,9 @@ def setup(app: Sphinx) -> None: app.add_directive_to_domain( "js", "autoattribute", auto_attribute_directive_bound_to_app(app) ) + app.add_directive_to_domain( + "js", "automodule", auto_module_directive_bound_to_app(app) + ) # TODO: We could add a js:module with app.add_directive_to_domain(). diff --git a/sphinx_js/directives.py b/sphinx_js/directives.py index c2daa4f0..316c1202 100644 --- a/sphinx_js/directives.py +++ b/sphinx_js/directives.py @@ -26,6 +26,7 @@ AutoAttributeRenderer, AutoClassRenderer, AutoFunctionRenderer, + AutoModuleRenderer, JsRenderer, ) @@ -174,3 +175,15 @@ def get_display_prefix( ] ) return result + + +def auto_module_directive_bound_to_app(app: Sphinx) -> type[Directive]: + class AutoModuleDirective(JsDirectiveWithChildren): + """TODO: words here""" + + required_arguments = 1 + + def run(self) -> list[Node]: + return self._run(AutoModuleRenderer, app) + + return AutoModuleDirective diff --git a/sphinx_js/ir.py b/sphinx_js/ir.py index 2181f9df..62c9e303 100644 --- a/sphinx_js/ir.py +++ b/sphinx_js/ir.py @@ -175,6 +175,16 @@ class Return: description: Description +@define +class Module: + filename: str + path: Pathname + line: int + attributes: list["TopLevel"] = Factory(list) + functions: list["Function"] = Factory(list) + classes: list["Class"] = Factory(list) + + @define(slots=False) class TopLevel: """A language object with an independent existence diff --git a/sphinx_js/renderers.py b/sphinx_js/renderers.py index c70ffe63..46b11ce1 100644 --- a/sphinx_js/renderers.py +++ b/sphinx_js/renderers.py @@ -1,5 +1,5 @@ import textwrap -from collections.abc import Callable, Iterable, Iterator +from collections.abc import Callable, Iterable, Iterator, Sequence from functools import partial from re import sub from typing import Any, Literal @@ -24,6 +24,7 @@ Exc, Function, Interface, + Module, Param, Pathname, Return, @@ -49,7 +50,7 @@ def sort_attributes_first_then_by_path(obj: TopLevel) -> Any: idx = 0 case Function(_): idx = 1 - case Class(_): + case Class(_) | Interface(_): idx = 2 return idx, obj.path.segments @@ -253,19 +254,20 @@ def rst_nodes(self) -> list[Node]: return doc.children def rst_for(self, obj: TopLevel) -> str: - renderer: type + renderer_class: type match obj: case Attribute(_): - renderer = AutoAttributeRenderer + renderer_class = AutoAttributeRenderer case Function(_): - renderer = AutoFunctionRenderer + renderer_class = AutoFunctionRenderer case Class(_): - renderer = AutoClassRenderer + renderer_class = AutoClassRenderer case _: raise RuntimeError("This shouldn't happen...") - return renderer(self._directive, self._app, arguments=["dummy"]).rst( - [obj.name], obj, use_short_name=False + renderer = renderer_class( + self._directive, self._app, arguments=["dummy"], options={"members": ["*"]} ) + return renderer.rst([obj.name], obj, use_short_name=False) def rst( 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: ) +class AutoModuleRenderer(JsRenderer): + def get_object(self) -> Module: # type:ignore[override] + analyzer: Analyzer = self._app._sphinxjs_analyzer # type:ignore[attr-defined] + assert isinstance(analyzer, TsAnalyzer) + return analyzer._modules_by_path.get(self._partial_path) + + def dependencies(self) -> set[str]: + return set() + + def rst_for_group(self, objects: Iterable[TopLevel]) -> list[str]: + return [self.rst_for(obj) for obj in objects] + + def rst( # type:ignore[override] + self, + partial_path: list[str], + obj: Module, + use_short_name: bool = False, + ) -> str: + rst: list[Sequence[str]] = [] + rst.append([f".. js:module:: {''.join(partial_path)}"]) + rst.append(self.rst_for_group(obj.attributes)) + rst.append(self.rst_for_group(obj.functions)) + rst.append(self.rst_for_group(obj.classes)) + return "\n\n".join(["\n\n".join(r) for r in rst]) + + def unwrapped(text: str) -> str: """Return the text with line wrapping removed.""" return sub(r"[ \t]*[\r\n]+[ \t]*", " ", text) diff --git a/sphinx_js/typedoc.py b/sphinx_js/typedoc.py index 360d7d9f..085bd629 100644 --- a/sphinx_js/typedoc.py +++ b/sphinx_js/typedoc.py @@ -10,6 +10,7 @@ from functools import cache from inspect import isclass from json import load +from operator import attrgetter from pathlib import Path from tempfile import NamedTemporaryFile from typing import Annotated, Any, Literal, TypedDict @@ -103,6 +104,16 @@ def parse(json: dict[str, Any]) -> "Project": PostConvertType = typing.Callable[["Converter", "Node | Signature", ir.TopLevel], None] +def _parse_filepath(path: str, base_dir: str) -> list[str]: + p = Path(path).resolve().relative_to(base_dir) + if p.name: + p = p.with_suffix("") + entries = ["."] + list(p.parts) + for i in range(len(entries) - 1): + entries[i] += "/" + return entries + + class Converter: base_dir: str index: dict[int, "IndexType"] @@ -134,15 +145,6 @@ def populate_index(self, root: "Project") -> "Converter": self._populate_index_inner(root, parent=None, idmap=root.symbolIdMap) return self - def _parse_filepath(self, path: str) -> list[str]: - p = Path(path).resolve().relative_to(self.base_dir) - if p.name: - p = p.with_suffix("") - entries = ["."] + list(p.parts) - for i in range(len(entries) - 1): - entries[i] += "/" - return entries - def _populate_index_inner( self, node: "IndexType", @@ -156,7 +158,9 @@ def _populate_index_inner( parent_kind = parent.kindString if parent else "" parent_segments = parent.path if parent else [] if str(node.id) in idmap: - filepath = self._parse_filepath(idmap[str(node.id)].sourceFileName) + filepath = _parse_filepath( + idmap[str(node.id)].sourceFileName, self.base_dir + ) if filepath: node.filepath = filepath self.compute_path(node, parent_kind, parent_segments, filepath) @@ -208,9 +212,12 @@ def compute_path( node.path = segments - def convert_all_nodes(self, root: "Project") -> list[ir.TopLevel]: + def convert_all_nodes( + self, root: "Project" + ) -> tuple[list[ir.TopLevel], list[ir.TopLevel]]: todo: list[Node | Signature] = list(root.children) done = [] + top_level = [] while todo: node = todo.pop() if node.sources and node.sources[0].fileName[0] == "/": @@ -221,13 +228,17 @@ def convert_all_nodes(self, root: "Project") -> list[ir.TopLevel]: if converted: self._post_convert(self, node, converted) done.append(converted) - return done + if converted and getattr(node, "top_level", False): + top_level.append(converted) + return done, top_level class Analyzer: + modules: dict[str, ir.Module] + def __init__( self, - json: "Project", + project: "Project", base_dir: str, *, should_destructure_arg: ShouldDestructureArgType | None = None, @@ -243,12 +254,21 @@ def __init__( base_dir, should_destructure_arg=should_destructure_arg, post_convert=post_convert, - ).populate_index(json) - ir_objects = converter.convert_all_nodes(json) + ).populate_index(project) + for child in project.children: + child.top_level = True + if isinstance(child, Module): + for c in child.children: + c.top_level = True + + ir_objects, top_level = converter.convert_all_nodes(project) self._base_dir = base_dir self._objects_by_path: SuffixTree[ir.TopLevel] = SuffixTree() self._objects_by_path.add_many((obj.path.segments, obj) for obj in ir_objects) + modules = self._create_modules(top_level) + self._modules_by_path: SuffixTree[ir.Module] = SuffixTree() + self._modules_by_path.add_many((obj.path.segments, obj) for obj in modules) @classmethod def from_disk( @@ -272,19 +292,41 @@ def get_object( """Return the IR object with the given path suffix. :arg as_type: Ignored - - We can't scan through the raw TypeDoc output at runtime like the JSDoc - analyzer does, because it's just a linear list of files, each - containing a nested tree of nodes. They're not indexed at all. And - since we need to index by suffix, we need to traverse all the way down, - eagerly. Also, we will keep the flattening, because we need it to - resolve the IDs of references. (Some of the references are potentially - important in the future: that's how TypeDoc points to superclass - definitions of methods inherited by subclasses.) - """ return self._objects_by_path.get(path_suffix) + def _create_modules(self, ir_objects: list[ir.TopLevel]) -> Iterable[ir.Module]: + """Search through the doclets generated by JsDoc and categorize them by + summary section. Skip docs labeled as "@private". + """ + modules = {} + for obj in ir_objects: + assert obj.deppath + path = obj.deppath.split("/") + for i in range(len(path) - 1): + path[i] += "/" + if obj.deppath not in modules: + modules[obj.deppath] = ir.Module( + filename=obj.deppath, path=ir.Pathname(path), line=1 + ) + mod = modules[obj.deppath] + if "attribute" in obj.modifier_tags: + mod.attributes.append(obj) + continue + match obj: + case ir.Attribute(_): + mod.attributes.append(obj) + case ir.Function(_): + mod.functions.append(obj) + case ir.Class(_): + mod.classes.append(obj) + + for mod in modules.values(): + mod.attributes = sorted(mod.attributes, key=attrgetter("name")) + mod.functions = sorted(mod.functions, key=attrgetter("name")) + mod.classes = sorted(mod.classes, key=attrgetter("name")) + return modules.values() + class Source(BaseModel): fileName: str @@ -410,6 +452,7 @@ class TopLevelProperties(Base): name: str kindString: str comment_: Comment = Field(default_factory=Comment, alias="comment") + top_level: bool = False @property def comment(self) -> Comment: @@ -976,11 +1019,12 @@ def inner(param: Param) -> Iterator[str | ir.TypeXRef]: def to_ir( self, converter: Converter ) -> tuple[ir.Function | None, Sequence["Node"]]: - if self.name.startswith("["): + SYMBOL_PREFIX = "[Symbol\u2024" + if self.name.startswith("[") and not self.name.startswith(SYMBOL_PREFIX): # a symbol. # \u2024 looks like a period but is not a period. # This isn't ideal, but otherwise the coloring is weird. - self.name = "[Symbol\u2024" + self.name[1:] + self.name = SYMBOL_PREFIX + self.name[1:] self._fix_type_suffix() params = self._destructure_params(converter) # Would be nice if we could statically determine that the function was diff --git a/tests/test_build_ts/source/docs/automodule.rst b/tests/test_build_ts/source/docs/automodule.rst new file mode 100644 index 00000000..17043c0d --- /dev/null +++ b/tests/test_build_ts/source/docs/automodule.rst @@ -0,0 +1 @@ +.. js:automodule:: module diff --git a/tests/test_build_ts/source/module.ts b/tests/test_build_ts/source/module.ts new file mode 100644 index 00000000..5880227c --- /dev/null +++ b/tests/test_build_ts/source/module.ts @@ -0,0 +1,34 @@ +/** + * The thing. + */ +export const a = 7; + +/** + * Crimps the bundle + */ +export function f() {} + +export function z(a: number, b: typeof q): number { + return a; +} + +export class A { + f() {} + + [Symbol.iterator]() {} + + g(a: number): number { + return a + 1; + } +} + +export class Z { + constructor(a: number, b: number) {} + + z() {} +} + +/** + * Another thing. + */ +export const q = { a: "z29", b: 76 }; diff --git a/tests/test_build_ts/test_build_ts.py b/tests/test_build_ts/test_build_ts.py index d94be374..fa731e61 100644 --- a/tests/test_build_ts/test_build_ts.py +++ b/tests/test_build_ts/test_build_ts.py @@ -246,6 +246,67 @@ class Extension() ), ) + def test_automodule(self): + self._file_contents_eq( + "automodule", + dedent( + """\ + module.a + + type: number + + The thing. + + module.q + + type: { a: string; b: number; } + + Another thing. + + module.f() + + Crimps the bundle + + module.z(a, b) + + Arguments: + * **a** (number) -- + + * **b** ({ a: string; b: number; }) -- + + Returns: + number + + class module.A() + + *exported from* "module" + + A.[Symbol․iterator]() + + A.f() + + A.g(a) + + Arguments: + * **a** (number) -- + + Returns: + number + + class module.Z(a, b) + + *exported from* "module" + + Arguments: + * **a** (number) -- + + * **b** (number) -- + + Z.z() + """ + ), + ) + class TestHtmlBuilder(SphinxBuildTestCase): """Tests which require an HTML build of our Sphinx tree, for checking