From a5f1bffe69ed932141201dbfb820f3bd9d4ab960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Su=C3=A1rez?= Date: Thu, 12 Mar 2026 11:27:40 -0600 Subject: [PATCH] feat: add TypeScript and TSX analyzer --- api/analyzers/source_analyzer.py | 17 +++- api/analyzers/typescript/__init__.py | 0 api/analyzers/typescript/analyzer.py | 119 +++++++++++++++++++++++++++ pyproject.toml | 1 + 4 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 api/analyzers/typescript/__init__.py create mode 100644 api/analyzers/typescript/analyzer.py diff --git a/api/analyzers/source_analyzer.py b/api/analyzers/source_analyzer.py index 4186f358..c18a92a5 100644 --- a/api/analyzers/source_analyzer.py +++ b/api/analyzers/source_analyzer.py @@ -11,6 +11,7 @@ from .java.analyzer import JavaAnalyzer from .python.analyzer import PythonAnalyzer from .csharp.analyzer import CSharpAnalyzer +from .typescript.analyzer import TypeScriptAnalyzer from multilspy import SyncLanguageServer from multilspy.multilspy_config import MultilspyConfig @@ -26,7 +27,9 @@ # '.h': CAnalyzer(), '.py': PythonAnalyzer(), '.java': JavaAnalyzer(), - '.cs': CSharpAnalyzer()} + '.cs': CSharpAnalyzer(), + '.ts': TypeScriptAnalyzer(), + '.tsx': TypeScriptAnalyzer()} class NullLanguageServer: def start_server(self): @@ -143,7 +146,15 @@ def second_pass(self, graph: Graph, files: list[Path], path: Path) -> None: lsps[".cs"] = SyncLanguageServer.create(config, logger, str(path)) else: lsps[".cs"] = NullLanguageServer() - with lsps[".java"].start_server(), lsps[".py"].start_server(), lsps[".cs"].start_server(): + if any(path.rglob('*.ts')) or any(path.rglob('*.tsx')): + config = MultilspyConfig.from_dict({"code_language": "typescript"}) + ts_lsp = SyncLanguageServer.create(config, logger, str(path)) + lsps[".ts"] = ts_lsp + lsps[".tsx"] = ts_lsp + else: + lsps[".ts"] = NullLanguageServer() + lsps[".tsx"] = NullLanguageServer() + with lsps[".java"].start_server(), lsps[".py"].start_server(), lsps[".cs"].start_server(), lsps[".ts"].start_server(): files_len = len(self.files) for i, file_path in enumerate(files): file = self.files[file_path] @@ -174,7 +185,7 @@ def analyze_files(self, files: list[Path], path: Path, graph: Graph) -> None: def analyze_sources(self, path: Path, ignore: list[str], graph: Graph) -> None: path = path.resolve() - files = list(path.rglob("*.java")) + list(path.rglob("*.py")) + list(path.rglob("*.cs")) + files = list(path.rglob("*.java")) + list(path.rglob("*.py")) + list(path.rglob("*.cs")) + list(path.rglob("*.ts")) + list(path.rglob("*.tsx")) # First pass analysis of the source code self.first_pass(path, files, ignore, graph) diff --git a/api/analyzers/typescript/__init__.py b/api/analyzers/typescript/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/analyzers/typescript/analyzer.py b/api/analyzers/typescript/analyzer.py new file mode 100644 index 00000000..10578b8c --- /dev/null +++ b/api/analyzers/typescript/analyzer.py @@ -0,0 +1,119 @@ +import subprocess +from pathlib import Path + +from multilspy import SyncLanguageServer +from ...entities import * +from typing import Optional +from ..analyzer import AbstractAnalyzer + +import tree_sitter_typescript as tstypescript +from tree_sitter import Language, Node + +import logging +logger = logging.getLogger('code_graph') + +class TypeScriptAnalyzer(AbstractAnalyzer): + def __init__(self) -> None: + super().__init__(Language(tstypescript.language_typescript())) + + def add_dependencies(self, path: Path, files: list[Path]): + if Path(f"{path}/node_modules").is_dir(): + return + if Path(f"{path}/package.json").is_file(): + subprocess.run(["npm", "install"], cwd=str(path)) + + def get_entity_label(self, node: Node) -> str: + if node.type == 'class_declaration': + return "Class" + elif node.type == 'interface_declaration': + return "Interface" + elif node.type == 'function_declaration': + return "Function" + elif node.type == 'method_definition': + return "Method" + raise ValueError(f"Unknown entity type: {node.type}") + + def get_entity_name(self, node: Node) -> str: + if node.type in ['class_declaration', 'interface_declaration', 'function_declaration', 'method_definition']: + name_node = node.child_by_field_name('name') + if name_node: + return name_node.text.decode('utf-8') + return '' + raise ValueError(f"Unknown entity type: {node.type}") + + def get_entity_docstring(self, node: Node) -> Optional[str]: + if node.type in ['class_declaration', 'interface_declaration', 'function_declaration', 'method_definition']: + sibling = node.prev_sibling + if sibling and sibling.type == 'comment': + return sibling.text.decode('utf-8') + return None + raise ValueError(f"Unknown entity type: {node.type}") + + def get_entity_types(self) -> list[str]: + return ['class_declaration', 'interface_declaration', 'function_declaration', 'method_definition'] + + def add_symbols(self, entity: Entity) -> None: + if entity.node.type == 'class_declaration': + heritage = self._captures("(class_heritage (extends_clause value: (_) @base_class))", entity.node) + if 'base_class' in heritage: + for base in heritage['base_class']: + entity.add_symbol("base_class", base) + implements = self._captures("(class_heritage (implements_clause (type_list (_) @interface)))", entity.node) + if 'interface' in implements: + for iface in implements['interface']: + entity.add_symbol("implement_interface", iface) + elif entity.node.type == 'interface_declaration': + extends = self._captures("(interface_declaration (extends_clause (type_list (_) @type)))?", entity.node) + if 'type' in extends: + for t in extends['type']: + entity.add_symbol("extend_interface", t) + elif entity.node.type in ['function_declaration', 'method_definition']: + captures = self._captures("(call_expression) @reference.call", entity.node) + if 'reference.call' in captures: + for caller in captures['reference.call']: + entity.add_symbol("call", caller) + params = self._captures("(formal_parameters (required_parameter type: (_) @parameter))", entity.node) + if 'parameter' in params: + for param in params['parameter']: + entity.add_symbol("parameters", param) + return_type = entity.node.child_by_field_name('return_type') + if return_type: + entity.add_symbol("return_type", return_type) + + def is_dependency(self, file_path: str) -> bool: + return "node_modules" in file_path + + def resolve_path(self, file_path: str, path: Path) -> str: + return file_path + + def resolve_type(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, node: Node) -> list[Entity]: + res = [] + for file, resolved_node in self.resolve(files, lsp, file_path, path, node): + type_dec = self.find_parent(resolved_node, ['class_declaration', 'interface_declaration']) + if type_dec in file.entities: + res.append(file.entities[type_dec]) + return res + + def resolve_method(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, node: Node) -> list[Entity]: + res = [] + if node.type == 'call_expression': + func_node = node.child_by_field_name('function') + if func_node and func_node.type == 'member_expression': + func_node = func_node.child_by_field_name('property') + if func_node: + node = func_node + for file, resolved_node in self.resolve(files, lsp, file_path, path, node): + method_dec = self.find_parent(resolved_node, ['function_declaration', 'method_definition', 'class_declaration', 'interface_declaration']) + if method_dec and method_dec.type in ['class_declaration', 'interface_declaration']: + continue + if method_dec in file.entities: + res.append(file.entities[method_dec]) + return res + + def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, key: str, symbol: Node) -> list[Entity]: + if key in ["implement_interface", "base_class", "extend_interface", "parameters", "return_type"]: + return self.resolve_type(files, lsp, file_path, path, symbol) + elif key in ["call"]: + return self.resolve_method(files, lsp, file_path, path, symbol) + else: + raise ValueError(f"Unknown key {key}") diff --git a/pyproject.toml b/pyproject.toml index 07e1db5a..82f61cf9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "tree-sitter-python>=0.25.0,<0.26.0", "tree-sitter-java>=0.23.5,<0.24.0", "tree-sitter-c-sharp>=0.23.1,<0.24.0", + "tree-sitter-typescript>=0.23.2,<0.24.0", "flask>=3.1.0,<4.0.0", "python-dotenv>=1.0.1,<2.0.0", "multilspy @ git+https://github.com/AviAvni/multilspy.git@python-init-params",