diff --git a/sphinx_js/__init__.py b/sphinx_js/__init__.py index 4150405c..259f9fa9 100644 --- a/sphinx_js/__init__.py +++ b/sphinx_js/__init__.py @@ -119,8 +119,29 @@ def get_index_text(self: Any, objectname: str, name_obj: Any) -> Any: JSObject.get_index_text = get_index_text # type:ignore[assignment] +@cache +def add_type_param_field_to_directives() -> None: + from sphinx.domains.javascript import ( # type: ignore[attr-defined] + GroupedField, + JSCallable, + JSConstructor, + ) + + typeparam_field = GroupedField( + "typeparam", + label="Type parameters", + rolename="func", + names=("typeparam",), + can_collapse=True, + ) + + JSCallable.doc_field_types.insert(0, typeparam_field) + JSConstructor.doc_field_types.insert(0, typeparam_field) + + fix_js_make_xref() fix_staticfunction_objtype() +add_type_param_field_to_directives() def setup(app: Sphinx) -> None: @@ -139,6 +160,7 @@ def setup(app: Sphinx) -> None: app.add_directive_to_domain( "js", "autoattribute", auto_attribute_directive_bound_to_app(app) ) + # TODO: We could add a js:module with app.add_directive_to_domain(). app.add_config_value("js_language", default="javascript", rebuild="env") diff --git a/sphinx_js/ir.py b/sphinx_js/ir.py index 6725468b..11f0a1b8 100644 --- a/sphinx_js/ir.py +++ b/sphinx_js/ir.py @@ -95,6 +95,13 @@ class or interface""" is_private: bool +@dataclass +class TypeParam: + name: str + extends: str | None + description: ReStructuredText = ReStructuredText("") + + @dataclass class Param: """A parameter of either a function or (in the case of TS, which has @@ -210,6 +217,7 @@ class Function(TopLevel, _Member): params: list[Param] exceptions: list[Exc] returns: list[Return] + type_params: list[TypeParam] = field(default_factory=list) @dataclass @@ -230,6 +238,8 @@ class _MembersAndSupers: class Interface(TopLevel, _MembersAndSupers): """An interface, a la TypeScript""" + type_params: list[TypeParam] = field(default_factory=list) + @dataclass class Class(TopLevel, _MembersAndSupers): @@ -244,4 +254,5 @@ class Class(TopLevel, _MembersAndSupers): # itself. These are supported and extracted by jsdoc, but they end up in an # `undocumented: True` doclet and so are presently filtered out. But we do # have the space to include them someday. + type_params: list[TypeParam] = field(default_factory=list) params: list[Param] = field(default_factory=list) diff --git a/sphinx_js/renderers.py b/sphinx_js/renderers.py index c5a0b142..b41c9030 100644 --- a/sphinx_js/renderers.py +++ b/sphinx_js/renderers.py @@ -23,6 +23,7 @@ Pathname, Return, TopLevel, + TypeParam, ) from .jsdoc import Analyzer as JsAnalyzer from .parsers import PathVisitor @@ -204,7 +205,7 @@ def _formal_params(self, obj: Function | Class) -> str: ) used_names.add(name) - return "(%s)" % ", ".join(formals) + return "({})".format(", ".join(formals)) def _fields(self, obj: TopLevel) -> Iterator[tuple[list[str], str]]: """Return an iterable of "info fields" to be included in the directive, @@ -216,6 +217,7 @@ def _fields(self, obj: TopLevel) -> Iterator[tuple[list[str], str]]: """ FIELD_TYPES: list[tuple[str, Callable[[Any], tuple[list[str], str] | None]]] = [ + ("type_params", _type_param_formatter), ("params", _param_formatter), ("params", _param_type_formatter), ("properties", _param_formatter), @@ -285,6 +287,7 @@ def _template_vars(self, name: str, obj: Class | Interface) -> dict[str, Any]: is_optional=False, is_static=False, is_private=False, + type_params=obj.type_params, params=[], exceptions=[], returns=[], @@ -428,6 +431,14 @@ def _return_formatter(return_: Return) -> tuple[list[str], str]: return ["returns"], tail +def _type_param_formatter(tparam: TypeParam) -> tuple[list[str], str] | None: + v = tparam.name + if tparam.extends: + v += f" extends {tparam.extends}" + heads = ["typeparam", v] + return heads, tparam.description + + def _param_formatter(param: Param) -> tuple[list[str], str] | None: """Derive heads and tail from ``@param`` blocks.""" if not param.type and not param.description: diff --git a/sphinx_js/typedoc.py b/sphinx_js/typedoc.py index 14c87700..2f2d2e57 100644 --- a/sphinx_js/typedoc.py +++ b/sphinx_js/typedoc.py @@ -7,7 +7,7 @@ from json import load from os.path import basename, join, normpath, relpath, sep, splitext from tempfile import NamedTemporaryFile -from typing import Annotated, Any, Literal, Optional, TypedDict +from typing import Annotated, Any, Literal, TypedDict from pydantic import BaseModel, Field, ValidationError from sphinx.application import Sphinx @@ -377,6 +377,7 @@ class ClassOrInterface(NodeBase): extendedTypes: list["TypeD"] = [] implementedTypes: list["TypeD"] = [] children: Sequence["ClassChild"] = [] + typeParameter: list["TypeParameter"] = [] def _related_types( self, @@ -431,6 +432,9 @@ def _constructor_and_members( for child in self.children: if child.kindString == "Constructor": # This really, really should happen exactly once per class. + # Type parameter cannot appear on constructor declaration so copy + # it down from the class. + child.signatures[0].typeParameter = self.typeParameter constructor = child.to_ir(converter)[0] continue result = child.to_ir(converter)[0] @@ -450,6 +454,7 @@ def to_ir(self, converter: Converter) -> tuple[ir.Class | None, Sequence["Node"] supers=self._related_types(converter, kind="extendedTypes"), is_abstract=self.flags.isAbstract, interfaces=self._related_types(converter, kind="implementedTypes"), + type_params=[x.to_ir(converter) for x in self.typeParameter], **self._top_level_properties(), ) return result, self.children @@ -463,6 +468,7 @@ def to_ir(self, converter: Converter) -> tuple[ir.Interface, Sequence["Node"]]: result = ir.Interface( members=members, supers=self._related_types(converter, kind="extendedTypes"), + type_params=[x.to_ir(converter) for x in self.typeParameter], **self._top_level_properties(), ) return result, self.children @@ -530,6 +536,20 @@ def make_description(comment: Comment) -> str: return ret.strip() +class TypeParameter(BaseModel): + name: str + type: "OptionalTypeD" + comment: Comment = Field(default_factory=Comment) + + def to_ir(self, converter: Converter) -> ir.TypeParam: + extends = None + if self.type: + extends = self.type.render_name(converter) + return ir.TypeParam( + self.name, extends, description=make_description(self.comment) + ) + + class Param(Base): kindString: Literal["Parameter"] = "Parameter" comment: Comment = Field(default_factory=Comment) @@ -563,9 +583,10 @@ class Signature(TopLevelProperties): ] name: str + typeParameter: list[TypeParameter] = [] parameters: list["Param"] = [] sources: list[Source] = [] - type: "TypeD" + type: "TypeD" # This is the return type! inheritedFrom: Any = None parent_member_properties: MemberProperties = {} # type: ignore[typeddict-item] @@ -607,6 +628,7 @@ def to_ir( exceptions=[], # Though perhaps technically true, it looks weird to the user # (and in the template) if constructors have a return value: + type_params=[x.to_ir(converter) for x in self.typeParameter], returns=self.return_type(converter) if self.kindString != "Constructor signature" else [], @@ -662,19 +684,6 @@ def _render_name_root(self, converter: Converter) -> str: return self.operator + " " + self.target.render_name(converter) -class ParameterType(TypeBase): - type: Literal["typeParameter"] - name: str - constraint: Optional["TypeD"] - - def _render_name_root(self, converter: Converter) -> str: - name = self.name - if self.constraint is not None: - name += " extends " + self.constraint.render_name(converter) - # e.g. K += extends + keyof T - return name - - class ReferenceType(TypeBase): type: Literal["reference", "intrinsic"] name: str @@ -733,13 +742,13 @@ def _render_name_root(self, converter: Converter) -> str: | LiteralType | OtherType | OperatorType - | ParameterType | ReferenceType | ReflectionType | TupleType ) -TypeD = Annotated[Type, Field(discriminator="TypeD")] +TypeD = Annotated[Type, Field(discriminator="type")] +OptionalTypeD = Annotated[Type | None, Field(discriminator="type")] IndexType = Node | Project | Signature | Param diff --git a/tests/test_typedoc_analysis/source/types.ts b/tests/test_typedoc_analysis/source/types.ts index f4782f0c..a6e3dd61 100644 --- a/tests/test_typedoc_analysis/source/types.ts +++ b/tests/test_typedoc_analysis/source/types.ts @@ -82,10 +82,17 @@ export interface Lengthwise { length: number; } +/** + * @typeParam T - the identity type + */ export function constrainedIdentity(arg: T): T { return arg; } +/** + * @typeParam T - The type of the object + * @typeParam K - The type of the key + */ export function getProperty(obj: T, key: K) { return obj[key]; } @@ -94,6 +101,16 @@ export function create(c: { new (): T }): T { return new c(); } +/** + * @typeParam S - The type we contain + */ +export class ParamClass { + constructor() { + + } + +} + // Utility types (https://www.typescriptlang.org/docs/handbook/utility-types.html) export let partial: Partial; diff --git a/tests/test_typedoc_analysis/test_typedoc_analysis.py b/tests/test_typedoc_analysis/test_typedoc_analysis.py index 82f7749d..a4a6b0df 100644 --- a/tests/test_typedoc_analysis/test_typedoc_analysis.py +++ b/tests/test_typedoc_analysis/test_typedoc_analysis.py @@ -3,7 +3,8 @@ import pytest -from sphinx_js.ir import Attribute, Class, Function, Param, Pathname, Return +from sphinx_js.ir import Attribute, Class, Function, Param, Pathname, Return, TypeParam +from sphinx_js.renderers import AutoClassRenderer, AutoFunctionRenderer from sphinx_js.typedoc import Comment, Converter, parse from tests.testing import NO_MATCH, TypeDocAnalyzerTestCase, TypeDocTestCase, dict_where @@ -458,18 +459,52 @@ def test_generic_member(self): assert obj.type == "T" assert obj.params[0].type == "T" - @pytest.mark.xfail(reason="Needs update and fix") def test_constrained_by_interface(self): """Make sure ``extends SomeInterface`` constraints are rendered.""" obj = self.analyzer.get_object(["constrainedIdentity"]) - assert obj.params[0].type == "T extends Lengthwise" - assert obj.returns[0].type == "T extends Lengthwise" + assert obj.params[0].type == "T" + assert obj.returns[0].type == "T" + assert obj.type_params[0] == TypeParam( + name="T", extends="Lengthwise", description="the identity type" + ) - @pytest.mark.xfail(reason="Needs update and fix") def test_constrained_by_key(self): """Make sure ``extends keyof SomeObject`` constraints are rendered.""" - obj = self.analyzer.get_object(["getProperty"]) - assert obj.params[1].type == "K extends keyof T" + obj: Function = self.analyzer.get_object(["getProperty"]) + assert obj.params[0].name == "obj" + assert obj.params[0].type == "T" + assert obj.params[1].type == "K" + # TODO? + # assert obj.returns[0].type == "" + assert obj.type_params[0] == TypeParam( + name="T", extends=None, description="The type of the object" + ) + assert obj.type_params[1] == TypeParam( + name="K", extends="string|number|symbol", description="The type of the key" + ) + + # TODO: this part maybe belongs in a unit test for the renderer or something + a = AutoFunctionRenderer.__new__(AutoFunctionRenderer) + a._explicit_formal_params = None + a._content = [] + rst = a.rst([obj.name], obj) + assert ":typeparam T: The type of the object" in rst + assert ( + ":typeparam K extends string\\|number\\|symbol: The type of the key" in rst + ) + + def test_class_constrained(self): + # TODO: this may belong somewhere else + obj: Class = self.analyzer.get_object(["ParamClass"]) + assert obj.type_params[0] == TypeParam( + name="S", extends="number[]", description="The type we contain" + ) + a = AutoClassRenderer.__new__(AutoClassRenderer) + a._explicit_formal_params = None + a._content = [] + a._options = {} + rst = a.rst([obj.name], obj) + assert ":typeparam S extends number\\[\\]: The type we contain" in rst @pytest.mark.xfail(reason="reflection not implemented yet") def test_constrained_by_constructor(self):