Skip to content

Commit

Permalink
Implement js:automodule directive (#108)
Browse files Browse the repository at this point in the history
  • Loading branch information
hoodmane authored Oct 1, 2023
1 parent 32bb950 commit 8709725
Show file tree
Hide file tree
Showing 8 changed files with 230 additions and 35 deletions.
4 changes: 4 additions & 0 deletions sphinx_js/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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().

Expand Down
13 changes: 13 additions & 0 deletions sphinx_js/directives.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
AutoAttributeRenderer,
AutoClassRenderer,
AutoFunctionRenderer,
AutoModuleRenderer,
JsRenderer,
)

Expand Down Expand Up @@ -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
10 changes: 10 additions & 0 deletions sphinx_js/ir.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 36 additions & 8 deletions sphinx_js/renderers.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -24,6 +24,7 @@
Exc,
Function,
Interface,
Module,
Param,
Pathname,
Return,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
98 changes: 71 additions & 27 deletions sphinx_js/typedoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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",
Expand All @@ -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)
Expand Down Expand Up @@ -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] == "/":
Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/test_build_ts/source/docs/automodule.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.. js:automodule:: module
34 changes: 34 additions & 0 deletions tests/test_build_ts/source/module.ts
Original file line number Diff line number Diff line change
@@ -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 };
Loading

0 comments on commit 8709725

Please sign in to comment.