Skip to content

Handle type parameters and type parameter bounds #25

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Feb 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions sphinx_js/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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")
Expand Down
11 changes: 11 additions & 0 deletions sphinx_js/ir.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -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)
13 changes: 12 additions & 1 deletion sphinx_js/renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
Pathname,
Return,
TopLevel,
TypeParam,
)
from .jsdoc import Analyzer as JsAnalyzer
from .parsers import PathVisitor
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down Expand Up @@ -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=[],
Expand Down Expand Up @@ -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:
Expand Down
43 changes: 26 additions & 17 deletions sphinx_js/typedoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -377,6 +377,7 @@ class ClassOrInterface(NodeBase):
extendedTypes: list["TypeD"] = []
implementedTypes: list["TypeD"] = []
children: Sequence["ClassChild"] = []
typeParameter: list["TypeParameter"] = []

def _related_types(
self,
Expand Down Expand Up @@ -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]
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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 [],
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions tests/test_typedoc_analysis/source/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,17 @@ export interface Lengthwise {
length: number;
}

/**
* @typeParam T - the identity type
*/
export function constrainedIdentity<T extends Lengthwise>(arg: T): T {
return arg;
}

/**
* @typeParam T - The type of the object
* @typeParam K - The type of the key
*/
export function getProperty<T, K extends keyof T>(obj: T, key: K) {
return obj[key];
}
Expand All @@ -94,6 +101,16 @@ export function create<T>(c: { new (): T }): T {
return new c();
}

/**
* @typeParam S - The type we contain
*/
export class ParamClass<S extends number[]> {
constructor() {

}

}

// Utility types (https://www.typescriptlang.org/docs/handbook/utility-types.html)

export let partial: Partial<string>;
Expand Down
49 changes: 42 additions & 7 deletions tests/test_typedoc_analysis/test_typedoc_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 == "<TODO: not implemented>"
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):
Expand Down