diff --git a/sphinx_js/analyzer_utils.py b/sphinx_js/analyzer_utils.py index 4b46f2fc..b6c302cd 100644 --- a/sphinx_js/analyzer_utils.py +++ b/sphinx_js/analyzer_utils.py @@ -107,6 +107,8 @@ def dotted_path(segments: list[str]) -> str: into this and construct a full path based on that. """ + if not segments: + return "" segments_without_separators = [ s[:-1] for s in segments[:-1] if s not in ["./", "../"] ] diff --git a/sphinx_js/typedoc.py b/sphinx_js/typedoc.py index 75771093..ea59ce78 100644 --- a/sphinx_js/typedoc.py +++ b/sphinx_js/typedoc.py @@ -10,7 +10,6 @@ from functools import cache, partial from inspect import isclass from json import load -from os.path import basename, relpath, sep, splitext from pathlib import Path from tempfile import NamedTemporaryFile from typing import Annotated, Any, Literal, TypedDict @@ -20,7 +19,7 @@ from sphinx.errors import SphinxError from . import ir -from .analyzer_utils import Command, is_explicitly_rooted, search_node_modules +from .analyzer_utils import Command, search_node_modules from .suffix_tree import SuffixTree __all__ = ["Analyzer"] @@ -109,12 +108,28 @@ def populate_index(self, root: "IndexType") -> "Converter": self._populate_index_inner(root, parent=None, containing_module=[]) return self + def _url_to_filepath(self, url: str) -> list[str]: + if not url: + return [] + # url looks like "https://github.com/project/repo/blob//path/to/file.ts#lineno + entries = url.split("/") + blob_idx = entries.index("blob") + # have to skip blob and hash too + entries = entries[blob_idx + 2 :] + entries[-1] = entries[-1].rsplit(".")[0] + a = Path("/".join(entries)).resolve().relative_to(Path(self.base_dir).resolve()) + entries = ["."] + entries.extend(a.parts) + for i in range(len(entries) - 1): + entries[i] += "/" + return entries + def _populate_index_inner( self, node: "IndexType", parent: "IndexType | None", containing_module: list[str], - filename: str = "", + filepath: list[str] | None = None, ) -> None: if node.id is not None: # 0 is okay; it's the root node. self.index[node.id] = node @@ -122,11 +137,12 @@ def _populate_index_inner( parent_kind = parent.kindString if parent else "" parent_segments = parent.path if parent else [] if node.sources: - filename = node.sources[0].fileName - node.filename = filename - self.compute_path(node, parent_kind, parent_segments, filename) + filepath = self._url_to_filepath(node.sources[0].url) + if filepath: + node.filepath = filepath + self.compute_path(node, parent_kind, parent_segments, filepath) - if node.kindString in ["External module", "Module"]: + if node.kindString == "Module": containing_module = node.path if parent and isinstance(node, Signature): @@ -162,7 +178,7 @@ def _populate_index_inner( child, parent=node, containing_module=containing_module, - filename=filename, + filepath=filepath, ) def compute_path( @@ -170,7 +186,7 @@ def compute_path( node: "IndexType", parent_kind: str, parent_segments: list[str], - filename: str, + filepath: list[str] | None, ) -> None: """Compute the full, unambiguous list of path segments that points to an entity described by a TypeDoc JSON node. @@ -189,16 +205,8 @@ def compute_path( if not node.flags.isStatic and parent_kind == "Class": delimiter = "#" - if ( - parent_kind == "Project" - and node.kindString - not in [ - "Module", - "External module", - ] - and not parent_segments - ): - parent_segments = make_filepath_segments(filename) + filepath2 = filepath or [] + parent_segments = parent_segments or filepath2 segs = node._path_segments(self.base_dir) @@ -275,6 +283,8 @@ def get_object( class Source(BaseModel): fileName: str line: int + character: int = 0 + url: str = "" class DescriptionItem(BaseModel): @@ -342,9 +352,8 @@ class Base(BaseModel): path: list[str] = [] id: int | None kindString: str = "" - originalName: str | None sources: list[Source] = [] - filename: str = "" + filepath: list[str] = [] flags: Flags = Field(default_factory=Flags) def member_properties(self) -> MemberProperties: @@ -397,8 +406,8 @@ def _top_level_properties(self) -> TopLevelPropertiesDict: return dict( name=self.short_name(), path=ir.Pathname(self.path), - filename=basename(self.filename), - deppath=self.filename, + filename="", + deppath="".join(self.filepath), description=self.comment.get_description(), line=self.sources[0].line if self.sources else None, # These properties aren't supported by TypeDoc: @@ -406,7 +415,7 @@ def _top_level_properties(self) -> TopLevelPropertiesDict: examples=self.comment.get_tag_list("example"), see_alsos=[], properties=[], - exported_from=ir.Pathname(make_filepath_segments(self.filename)), + exported_from=ir.Pathname(self.filepath), ) def to_ir( @@ -610,30 +619,14 @@ def to_ir( return result, self.children -def make_filepath_segments(path: str) -> list[str]: - if not is_explicitly_rooted(path): - path = f".{sep}{path}" - segs = path.split(sep) - filename = splitext(segs[-1])[0] - segments = [s + "/" for s in segs[:-1]] + [filename] - return segments - - -class ExternalModule(NodeBase): - kindString: Literal["External module", "Module"] - originalName: str = "" +class Module(NodeBase): + kindString: Literal["Module"] def short_name(self) -> str: return self.name[1:-1] # strip quotes def _path_segments(self, base_dir: str) -> list[str]: - # 'name' contains folder names if multiple folders are passed into - # TypeDoc. It's also got excess quotes. So we ignore it and take - # 'originalName', which has a nice, absolute path. - if not self.originalName: - return [] - rel = relpath(self.originalName, base_dir) - return make_filepath_segments(rel) + return [] class TypeLiteral(NodeBase): @@ -689,14 +682,7 @@ class OtherNode(NodeBase): Node = Annotated[ - Accessor - | Callable - | Class - | ExternalModule - | Interface - | Member - | OtherNode - | TypeLiteral, + Accessor | Callable | Class | Module | Interface | Member | OtherNode | TypeLiteral, Field(discriminator="kindString"), ] diff --git a/tests/test_typedoc_analysis/test_typedoc_analysis.py b/tests/test_typedoc_analysis/test_typedoc_analysis.py index 14e61ac5..8609977e 100644 --- a/tests/test_typedoc_analysis/test_typedoc_analysis.py +++ b/tests/test_typedoc_analysis/test_typedoc_analysis.py @@ -47,72 +47,45 @@ def test_top_level_function(self): # function with no params or return value: json = parse( loads( - r"""{ - "id": 0, - "name": "misterRoot", - "children": [ - { - "id": 1, - "name": "\"longnames\"", - "kindString": "External module", - "originalName": "/a/b/c/tests/test_typedoc_analysis/source/longnames.ts", - "children": [ + r""" { - "id": 2, - "name": "foo", - "kindString": "Function", - "signatures": [ - { - "id": 3, - "name": "foo", - "kindString": "Call signature", - "comment": { - "shortText": "Foo function." - }, - "type": { - "type": "intrinsic", - "name": "void" - } - } - ], - "sources": [ - { - "fileName": "longnames.ts", - "line": 4, - "character": 12 - } - ] + "id": 0, + "name": "misterRoot", + "children": [ + { + "id": 1, + "name": "longnames", + "kindString": "Module", + "children": [ + { + "id": 2, + "name": "foo", + "kindString": "Function", + "signatures": [ + { + "id": 3, + "name": "foo", + "kindString": "Call signature", + "comment": {"shortText": "Foo function."}, + "type": {"type": "intrinsic", "name": "void"} + } + ] + } + ], + "sources": [ + { + "fileName": "longnames.ts", + "line": 1, + "url": "blob/commithash/tests/test_typedoc_analysis/source/longnames.ts" + } + ] + } + ] } - ], - "groups": [ - { - "title": "Functions", - "children": [ - 2 - ] - } - ], - "sources": [ - { - "fileName": "longnames.ts", - "line": 1, - "character": 0 - } - ] - } - ], - "groups": [ - { - "title": "External modules", - "children": [ - 1 - ] - } - ] - }""" + """ ) ) - index = Converter("/a/b/c/tests/").populate_index(json).index + index = Converter("./tests/").populate_index(json).index # Things get indexed by ID: function = index[2] assert function.name == "foo" @@ -290,7 +263,6 @@ def test_class1(self): # have the filling of them factored out. assert subclass.name == "EmptySubclass" assert subclass.path == Pathname(["./", "nodes.", "EmptySubclass"]) - assert subclass.filename == "nodes.ts" assert subclass.description == [DescriptionText("An empty subclass")] assert subclass.deprecated is False assert subclass.examples == []