diff --git a/sphinx_js/directives.py b/sphinx_js/directives.py index 9e3e558..b93fcd4 100644 --- a/sphinx_js/directives.py +++ b/sphinx_js/directives.py @@ -11,7 +11,7 @@ from collections.abc import Iterable from functools import cache from os.path import join, relpath -from typing import Any +from typing import Any, cast from docutils import nodes from docutils.nodes import Node @@ -20,6 +20,7 @@ from docutils.parsers.rst.directives import flag from docutils.utils import new_document from sphinx import addnodes +from sphinx.addnodes import desc_signature from sphinx.application import Sphinx from sphinx.domains import ObjType, javascript from sphinx.domains.javascript import ( @@ -31,6 +32,7 @@ ) from sphinx.locale import _ from sphinx.util.docfields import GroupedField, TypedField +from sphinx.writers.html5 import HTML5Translator from .renderers import ( AutoAttributeRenderer, @@ -257,7 +259,76 @@ def run(self) -> list[Node]: return AutoAttributeDirective +class desc_js_type_parameter_list(nodes.Part, nodes.Inline, nodes.FixedTextElement): + """Node for a javascript type parameter list. + + Unlike normal parameter lists, we use angle braces <> as the braces. Based + on sphinx.addnodes.desc_type_parameter_list + """ + + child_text_separator = ", " + + def astext(self) -> str: + return f"<{nodes.FixedTextElement.astext(self)}>" + + +def visit_desc_js_type_parameter_list( + self: HTML5Translator, node: nodes.Element +) -> None: + """Define the html/text rendering for desc_js_type_parameter_list. Based on + sphinx.writers.html5.visit_desc_type_parameter_list + """ + self._visit_sig_parameter_list(node, addnodes.desc_parameter, "<", ">") + + +def depart_desc_js_type_parameter_list( + self: HTML5Translator, node: nodes.Element +) -> None: + """Define the html/text rendering for desc_js_type_parameter_list. Based on + sphinx.writers.html5.depart_desc_type_parameter_list + """ + self._depart_sig_parameter_list(node) + + +def add_param_list_to_signode(signode: desc_signature, params: str) -> None: + paramlist = desc_js_type_parameter_list() + for arg in params.split(","): + paramlist += addnodes.desc_parameter("", "", addnodes.desc_sig_name(arg, arg)) + signode += paramlist + + +def handle_typeparams_for_signature( + self: JSObject, sig: str, signode: desc_signature, *, keep_callsig: bool +) -> tuple[str, str]: + """Generic function to handle type params in the sig line for interfaces, + classes, and functions. + + For interfaces and classes we don't prefer the look with parentheses so we + also remove them (by setting keep_callsig to False). + """ + typeparams = None + if "<" in sig and ">" in sig: + base, _, rest = sig.partition("<") + typeparams, _, params = rest.partition(">") + sig = base + params + res = JSCallable.handle_signature(cast(JSCallable, self), sig, signode) + sig = sig.strip() + lastchild = None + # Check for call signature, if present take it off + if signode.children[-1].astext().endswith(")"): + lastchild = signode.children[-1] + signode.remove(lastchild) + if typeparams: + add_param_list_to_signode(signode, typeparams) + # if we took off a call signature and we want to keep it put it back. + if keep_callsig and lastchild: + signode += lastchild + return res + + class JSFunction(JSCallable): + """Variant of JSCallable that can take static/async prefixes""" + option_spec = { **JSCallable.option_spec, "static": flag, @@ -278,9 +349,15 @@ def get_display_prefix( ) return result + def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str]: + return handle_typeparams_for_signature(self, sig, signode, keep_callsig=True) + class JSInterface(JSCallable): - """Like a callable but with a different prefix.""" + """An interface directive. + + Based on sphinx.domains.javascript.JSConstructor. + """ allow_nesting = True @@ -290,9 +367,18 @@ def get_display_prefix(self) -> list[Node]: addnodes.desc_sig_space(), ] + def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str]: + return handle_typeparams_for_signature(self, sig, signode, keep_callsig=False) + + +class JSClass(JSConstructor): + def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str]: + return handle_typeparams_for_signature(self, sig, signode, keep_callsig=True) + @cache def patch_JsObject_get_index_text() -> None: + """Add our additional object types to the index""" orig_get_index_text = JSObject.get_index_text def patched_get_index_text( @@ -308,8 +394,6 @@ def patched_get_index_text( 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]: @@ -349,7 +433,13 @@ def add_directives(app: Sphinx) -> None: app.add_directive_to_domain( "js", "autosummary", auto_summary_directive_bound_to_app(app) ) + app.add_directive_to_domain("js", "class", JSClass) app.add_role_to_domain("js", "class", JSXRefRole()) JavaScriptDomain.object_types["interface"] = ObjType(_("interface"), "interface") app.add_directive_to_domain("js", "interface", JSInterface) app.add_role_to_domain("js", "interface", JSXRefRole()) + app.add_node( + desc_js_type_parameter_list, + html=(visit_desc_js_type_parameter_list, depart_desc_js_type_parameter_list), + text=(visit_desc_js_type_parameter_list, depart_desc_js_type_parameter_list), + ) diff --git a/sphinx_js/renderers.py b/sphinx_js/renderers.py index f722dad..03e3642 100644 --- a/sphinx_js/renderers.py +++ b/sphinx_js/renderers.py @@ -374,6 +374,11 @@ def rst( result = "\n".join(lines) + "\n" return result + def _type_params(self, obj: Function | Class | Interface) -> str: + if not obj.type_params: + return "" + return "<{}>".format(", ".join(tp.name for tp in obj.type_params)) + def _formal_params(self, obj: Function) -> str: """Return the JS function params, looking first to any explicit params written into the directive and falling back to those in comments or JS @@ -543,6 +548,7 @@ def _template_vars(self, name: str, obj: Function) -> dict[str, Any]: # type: i deprecated = render_description(deprecated) return dict( name=name, + type_params=self._type_params(obj), params=self._formal_params(obj), fields=self._fields(obj), description=render_description(obj.description), @@ -595,6 +601,7 @@ def _template_vars(self, name: str, obj: Class | Interface) -> dict[str, Any]: return dict( name=name, params=self._formal_params(constructor), + type_params=self._type_params(obj), fields=self._fields(constructor), examples=[render_description(ex) for ex in constructor.examples], deprecated=constructor.deprecated, diff --git a/sphinx_js/templates/attribute.rst b/sphinx_js/templates/attribute.rst index 065c88d..e9763e9 100644 --- a/sphinx_js/templates/attribute.rst +++ b/sphinx_js/templates/attribute.rst @@ -14,6 +14,10 @@ {{ description|indent(3) }} {%- endif %} + {% if is_type_alias -%} + {{ common.fields(fields) | indent(3) }} + {%- endif %} + {{ common.examples(examples)|indent(3) }} {{ content|indent(3) }} diff --git a/sphinx_js/templates/class.rst b/sphinx_js/templates/class.rst index ea3ef49..f1165de 100644 --- a/sphinx_js/templates/class.rst +++ b/sphinx_js/templates/class.rst @@ -1,9 +1,9 @@ {% import 'common.rst' as common %} {% if is_interface -%} -.. js:interface:: {{ name }}{{ params }} +.. js:interface:: {{ name }}{{ type_params }}{{ params }} {%- else -%} -.. js:class:: {{ name }}{{ params }} +.. js:class:: {{ name }}{{ type_params }}{{ params }} {%- endif %} {{ common.deprecated(deprecated)|indent(3) }} @@ -36,9 +36,7 @@ {{ constructor_comment|indent(3) }} {%- endif %} - {% for heads, tail in fields -%} - :{{ heads|join(' ') }}: {{ tail }} - {% endfor %} + {{ common.fields(fields) | indent(3) }} {{ common.examples(examples)|indent(3) }} diff --git a/sphinx_js/templates/common.rst b/sphinx_js/templates/common.rst index 3408649..e1c47b5 100644 --- a/sphinx_js/templates/common.rst +++ b/sphinx_js/templates/common.rst @@ -31,3 +31,9 @@ *exported from* :js:mod:`{{ pathname.dotted() }}` {%- endif %} {% endmacro %} + +{% macro fields(items) %} +{% for heads, tail in items -%} + :{{ heads|join(' ') }}: {{ tail }} +{% endfor %} +{% endmacro %} diff --git a/sphinx_js/templates/function.rst b/sphinx_js/templates/function.rst index 456a35f..2930a9d 100644 --- a/sphinx_js/templates/function.rst +++ b/sphinx_js/templates/function.rst @@ -1,6 +1,6 @@ {% import 'common.rst' as common %} -.. js:function:: {{ name }}{{ '?' if is_optional else '' }}{{ params }} +.. js:function:: {{ name }}{{ '?' if is_optional else '' }}{{ type_params }}{{ params }} {% if is_static -%} :static: {% endif %} @@ -14,9 +14,7 @@ {{ description|indent(3) }} {%- endif %} - {% for heads, tail in fields -%} - :{{ heads|join(' ') }}: {{ tail }} - {% endfor %} + {{ common.fields(fields) | indent(3) }} {{ common.examples(examples)|indent(3) }} diff --git a/tests/test_build_ts/source/docs/conf.py b/tests/test_build_ts/source/docs/conf.py index a1d8d74..62b954c 100644 --- a/tests/test_build_ts/source/docs/conf.py +++ b/tests/test_build_ts/source/docs/conf.py @@ -11,10 +11,10 @@ js_language = "typescript" from sphinx.util import rst -from sphinx_js.ir import TypeXRefInternal +from sphinx_js.ir import TypeXRef, TypeXRefInternal -def ts_type_xref_formatter(config, xref, kind): +def ts_type_xref_formatter(config, xref: TypeXRef, kind: str) -> str: if isinstance(xref, TypeXRefInternal): name = rst.escape(xref.name) return f":js:{kind}:`{name}`" diff --git a/tests/test_build_ts/source/module.ts b/tests/test_build_ts/source/module.ts index 71256ac..b8344df 100644 --- a/tests/test_build_ts/source/module.ts +++ b/tests/test_build_ts/source/module.ts @@ -25,6 +25,11 @@ export class A { } } +/** + * An instance of class A + */ +export let aInstance: A; + /** * @typeParam T Description of T */ @@ -35,6 +40,8 @@ export class Z { z() {} } +export let zInstance: Z; + /** * Another thing. */ @@ -44,3 +51,22 @@ export const q = { a: "z29", b: 76 }; * Documentation for the interface I */ export interface I {} + +/** + * An instance of the interface + */ +export let interfaceInstance: I = {}; + +/** + * A function with a type parameter! + * + * We'll refer to ourselves: :js:func:`functionWithTypeParam` + * + * @typeParam T The type parameter + * @typeParam S Another type param + * @param z A Z of T + * @returns The x field of z + */ +export function functionWithTypeParam(z: Z): T { + return z.x; +} diff --git a/tests/test_build_ts/test_build_ts.py b/tests/test_build_ts/test_build_ts.py index 8865151..264c227 100644 --- a/tests/test_build_ts/test_build_ts.py +++ b/tests/test_build_ts/test_build_ts.py @@ -93,7 +93,7 @@ def test_optional_members(self): question marks sticking out of them.""" self._file_contents_eq( "autoclass_interface_optionals", - "interface OptionalThings()\n" + "interface OptionalThings\n" "\n" ' *exported from* "class"\n' "\n" @@ -249,79 +249,110 @@ def test_automodule(self): "automodule", dedent( """\ - module.a +module.a - type: 7 + type: 7 - The thing. + The thing. - module.q +module.aInstance - type: { a: string; b: number; } + type: "A" - Another thing. + An instance of class A - async module.f() +module.interfaceInstance - Clutches the bundle + type: "I" - Returns: - Promise + An instance of the interface - module.z(a, b) +module.q - Arguments: - * **a** (number) + type: { a: string; b: number; } - * **b** ({ a: string; b: number; }) + Another thing. - Returns: - number +module.zInstance - class module.A() + type: "Z"<"A"> - This is a summary. This is more info. +async module.f() - *exported from* "module" + Clutches the bundle - A.[Symbol․iterator]() + Returns: + Promise - async A.f() +module.functionWithTypeParam(z) - Returns: - Promise + A function with a type parameter! - A.g(a) + We'll refer to ourselves: "functionWithTypeParam()" - Arguments: - * **a** (number) + Type parameters: + **T** -- The type parameter (extends "A") - Returns: - number + Arguments: + * **z** ("Z") -- A Z of T - class module.Z(a, b) + Returns: + T -- The x field of z - *exported from* "module" +module.z(a, b) - Type parameters: - **T** -- Description of T (extends "A") + Arguments: + * **a** (number) - Arguments: - * **a** (number) + * **b** ({ a: string; b: number; }) + + Returns: + number + +class module.A() + + This is a summary. This is more info. + + *exported from* "module" + + A.[Symbol․iterator]() + + async A.f() + + Returns: + Promise + + A.g(a) + + Arguments: + * **a** (number) + + Returns: + number + +class module.Z(a, b) + + *exported from* "module" + + Type parameters: + **T** -- Description of T (extends "A") + + Arguments: + * **a** (number) - * **b** (number) + * **b** (number) - Z.x + Z.x - type: T + type: T - Z.z() + Z.z() - interface module.I() +interface module.I - Documentation for the interface I + Documentation for the interface I - *exported from* "module" + *exported from* "module" """ ), ) @@ -403,7 +434,7 @@ def test_autosummary(self): soup = BeautifulSoup(self._file_contents("autosummary"), "html.parser") attrs = soup.find(class_="attributes") rows = list(attrs.find_all("tr")) - assert len(rows) == 2 + assert len(rows) == 5 href = rows[0].find("a") assert href.get_text() == "a" @@ -411,13 +442,13 @@ def test_autosummary(self): assert rows[0].find(class_="summary").get_text() == "The thing." href = rows[1].find("a") - assert href.get_text() == "q" - assert href["href"] == "automodule.html#module.q" - assert rows[1].find(class_="summary").get_text() == "Another thing." + assert href.get_text() == "aInstance" + assert href["href"] == "automodule.html#module.aInstance" + assert rows[1].find(class_="summary").get_text() == "An instance of class A" funcs = soup.find(class_="functions") rows = list(funcs.find_all("tr")) - assert len(rows) == 2 + assert len(rows) == 3 row0 = list(rows[0].children) NBSP = "\xa0" assert row0[0].get_text() == f"async{NBSP}f()" @@ -426,7 +457,7 @@ def test_autosummary(self): assert href["href"] == "automodule.html#module.f" assert rows[0].find(class_="summary").get_text() == "Clutches the bundle" - row1 = list(rows[1].children) + row1 = list(rows[2].children) assert row1[0].get_text() == "z(a, b)" href = row1[0].find("a") assert href.get_text() == "z" diff --git a/tests/test_renderers.py b/tests/test_renderers.py index 821fd2d..6c16fb6 100644 --- a/tests/test_renderers.py +++ b/tests/test_renderers.py @@ -278,14 +278,14 @@ def test_func_render_type_params(function_render): assert function_render( params=[Param("a", type="T"), Param("b", type="S")], type_params=[ - TypeParam("T", "", "a type param"), + TypeParam("T", "number", "a type param"), TypeParam("S", "", "second type param"), ], ) == dedent( """\ - .. js:function:: blah(a, b) + .. js:function:: blah(a, b) - :typeparam T: a type param + :typeparam T: a type param (extends **number**) :typeparam S: second type param :param a: :param b: diff --git a/tests/testing.py b/tests/testing.py index 5ad6499..2bc6dd1 100644 --- a/tests/testing.py +++ b/tests/testing.py @@ -100,9 +100,6 @@ def setup_class(cls): """Run the TS analyzer over the TypeDoc output.""" super().setup_class() - def should_destructure(sig, p): - return p.name == "destructureThisPlease" - cls.analyzer = TsAnalyzer(cls.json, cls.extra_data, cls._source_dir)