Skip to content

Commit ecf7473

Browse files
authored
Merge branch 'staging' into backend/add-import-support-python
2 parents 1b1ef5c + 7daba03 commit ecf7473

File tree

7 files changed

+502
-3
lines changed

7 files changed

+502
-3
lines changed

api/analyzers/javascript/__init__.py

Whitespace-only changes.
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""JavaScript analyzer using tree-sitter for code entity extraction."""
2+
3+
from pathlib import Path
4+
from typing import Optional
5+
6+
from multilspy import SyncLanguageServer
7+
from ...entities.entity import Entity
8+
from ...entities.file import File
9+
from ..analyzer import AbstractAnalyzer
10+
11+
import tree_sitter_javascript as tsjs
12+
from tree_sitter import Language, Node
13+
14+
import logging
15+
logger = logging.getLogger('code_graph')
16+
17+
18+
class JavaScriptAnalyzer(AbstractAnalyzer):
19+
"""Analyzer for JavaScript source files using tree-sitter.
20+
21+
Extracts functions, classes, and methods from JavaScript code.
22+
Resolves class inheritance (extends) and function/method call references.
23+
"""
24+
25+
def __init__(self) -> None:
26+
"""Initialize the JavaScript analyzer with the tree-sitter JS grammar."""
27+
super().__init__(Language(tsjs.language()))
28+
29+
def add_dependencies(self, path: Path, files: list[Path]) -> None:
30+
"""Detect and register JavaScript project dependencies.
31+
32+
Currently a no-op; npm dependency resolution is not yet implemented.
33+
"""
34+
pass
35+
36+
def get_entity_label(self, node: Node) -> str:
37+
"""Return the graph label for a given AST node type.
38+
39+
Args:
40+
node: A tree-sitter AST node representing a JavaScript entity.
41+
42+
Returns:
43+
One of 'Function', 'Class', or 'Method'.
44+
45+
Raises:
46+
ValueError: If the node type is not a recognised entity.
47+
"""
48+
if node.type == 'function_declaration':
49+
return "Function"
50+
elif node.type == 'class_declaration':
51+
return "Class"
52+
elif node.type == 'method_definition':
53+
return "Method"
54+
raise ValueError(f"Unknown entity type: {node.type}")
55+
56+
def get_entity_name(self, node: Node) -> str:
57+
"""Extract the declared name from a JavaScript entity node.
58+
59+
Args:
60+
node: A tree-sitter AST node for a function, class, or method.
61+
62+
Returns:
63+
The entity name, or an empty string if no name node is found.
64+
65+
Raises:
66+
ValueError: If the node type is not a recognised entity.
67+
"""
68+
if node.type in ['function_declaration', 'class_declaration', 'method_definition']:
69+
name_node = node.child_by_field_name('name')
70+
if name_node is None:
71+
return ''
72+
return name_node.text.decode('utf-8')
73+
raise ValueError(f"Unknown entity type: {node.type}")
74+
75+
def get_entity_docstring(self, node: Node) -> Optional[str]:
76+
"""Extract a leading comment as a docstring for the entity.
77+
78+
Looks for a comment node immediately preceding the entity in the AST.
79+
80+
Args:
81+
node: A tree-sitter AST node for a function, class, or method.
82+
83+
Returns:
84+
The comment text, or None if no leading comment exists.
85+
86+
Raises:
87+
ValueError: If the node type is not a recognised entity.
88+
"""
89+
if node.type in ['function_declaration', 'class_declaration', 'method_definition']:
90+
if node.prev_sibling and node.prev_sibling.type == 'comment':
91+
return node.prev_sibling.text.decode('utf-8')
92+
return None
93+
raise ValueError(f"Unknown entity type: {node.type}")
94+
95+
def get_entity_types(self) -> list[str]:
96+
"""Return the tree-sitter node types recognised as JavaScript entities."""
97+
return ['function_declaration', 'class_declaration', 'method_definition']
98+
99+
def add_symbols(self, entity: Entity) -> None:
100+
"""Extract symbols (references) from a JavaScript entity.
101+
102+
For classes: extracts base-class identifiers from ``extends`` clauses.
103+
For functions/methods: extracts call-expression references.
104+
105+
Note:
106+
JavaScript parameters are untyped, so they are not captured as
107+
symbols — unlike typed languages (Java, Python) where parameter
108+
type annotations are meaningful for resolution.
109+
"""
110+
if entity.node.type == 'class_declaration':
111+
for child in entity.node.children:
112+
if child.type == 'class_heritage':
113+
for heritage_child in child.children:
114+
if heritage_child.type == 'identifier':
115+
entity.add_symbol("base_class", heritage_child)
116+
elif entity.node.type in ['function_declaration', 'method_definition']:
117+
captures = self._captures("(call_expression) @reference.call", entity.node)
118+
if 'reference.call' in captures:
119+
for caller in captures['reference.call']:
120+
entity.add_symbol("call", caller)
121+
122+
def is_dependency(self, file_path: str) -> bool:
123+
"""Check whether a file path belongs to an external dependency.
124+
125+
Uses path-segment matching so that directories merely containing
126+
'node_modules' in their name (e.g. ``node_modules_utils``) are not
127+
treated as dependencies.
128+
"""
129+
return "node_modules" in Path(file_path).parts
130+
131+
def resolve_path(self, file_path: str, path: Path) -> str:
132+
"""Resolve an import path relative to the project root."""
133+
return file_path
134+
135+
def resolve_type(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, node: Node) -> list[Entity]:
136+
"""Resolve a type reference to its class declaration entity."""
137+
res = []
138+
for file, resolved_node in self.resolve(files, lsp, file_path, path, node):
139+
type_dec = self.find_parent(resolved_node, ['class_declaration'])
140+
if type_dec in file.entities:
141+
res.append(file.entities[type_dec])
142+
return res
143+
144+
def resolve_method(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, node: Node) -> list[Entity]:
145+
"""Resolve a call expression to the target function or method entity."""
146+
res = []
147+
if node.type == 'call_expression':
148+
func_node = node.child_by_field_name('function')
149+
if func_node and func_node.type == 'member_expression':
150+
func_node = func_node.child_by_field_name('property')
151+
if func_node:
152+
node = func_node
153+
for file, resolved_node in self.resolve(files, lsp, file_path, path, node):
154+
method_dec = self.find_parent(resolved_node, ['function_declaration', 'method_definition', 'class_declaration'])
155+
if method_dec and method_dec.type == 'class_declaration':
156+
continue
157+
if method_dec in file.entities:
158+
res.append(file.entities[method_dec])
159+
return res
160+
161+
def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, key: str, symbol: Node) -> list[Entity]:
162+
"""Dispatch symbol resolution based on the symbol category.
163+
164+
Routes ``base_class`` symbols to type resolution and ``call`` symbols
165+
to method resolution.
166+
"""
167+
if key == "base_class":
168+
return self.resolve_type(files, lsp, file_path, path, symbol)
169+
elif key == "call":
170+
return self.resolve_method(files, lsp, file_path, path, symbol)
171+
else:
172+
raise ValueError(f"Unknown key {key}")

api/analyzers/source_analyzer.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from .java.analyzer import JavaAnalyzer
1212
from .python.analyzer import PythonAnalyzer
1313
from .csharp.analyzer import CSharpAnalyzer
14+
from .javascript.analyzer import JavaScriptAnalyzer
1415

1516
from multilspy import SyncLanguageServer
1617
from multilspy.multilspy_config import MultilspyConfig
@@ -26,7 +27,8 @@
2627
# '.h': CAnalyzer(),
2728
'.py': PythonAnalyzer(),
2829
'.java': JavaAnalyzer(),
29-
'.cs': CSharpAnalyzer()}
30+
'.cs': CSharpAnalyzer(),
31+
'.js': JavaScriptAnalyzer()}
3032

3133
class NullLanguageServer:
3234
def start_server(self):
@@ -147,9 +149,15 @@ def second_pass(self, graph: Graph, files: list[Path], path: Path) -> None:
147149
lsps[".cs"] = SyncLanguageServer.create(config, logger, str(path))
148150
else:
149151
lsps[".cs"] = NullLanguageServer()
150-
with lsps[".java"].start_server(), lsps[".py"].start_server(), lsps[".cs"].start_server():
152+
lsps[".js"] = NullLanguageServer()
153+
with lsps[".java"].start_server(), lsps[".py"].start_server(), lsps[".cs"].start_server(), lsps[".js"].start_server():
151154
files_len = len(self.files)
152155
for i, file_path in enumerate(files):
156+
if file_path not in self.files:
157+
continue
158+
# Skip symbol resolution when no real LSP is available
159+
if isinstance(lsps.get(file_path.suffix), NullLanguageServer):
160+
continue
153161
file = self.files[file_path]
154162
logging.info(f'Processing file ({i + 1}/{files_len}): {file_path}')
155163

@@ -187,7 +195,7 @@ def analyze_files(self, files: list[Path], path: Path, graph: Graph) -> None:
187195

188196
def analyze_sources(self, path: Path, ignore: list[str], graph: Graph) -> None:
189197
path = path.resolve()
190-
files = list(path.rglob("*.java")) + list(path.rglob("*.py")) + list(path.rglob("*.cs"))
198+
files = list(path.rglob("*.java")) + list(path.rglob("*.py")) + list(path.rglob("*.cs")) + [f for f in path.rglob("*.js") if "node_modules" not in f.parts]
191199
# First pass analysis of the source code
192200
self.first_pass(path, files, ignore, graph)
193201

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ dependencies = [
1313
"tree-sitter-c>=0.24.1,<0.25.0",
1414
"tree-sitter-python>=0.25.0,<0.26.0",
1515
"tree-sitter-java>=0.23.5,<0.24.0",
16+
"tree-sitter-javascript>=0.23.0,<0.24.0",
1617
"tree-sitter-c-sharp>=0.23.1,<0.24.0",
1718
"fastapi>=0.115.0,<1.0.0",
1819
"uvicorn[standard]>=0.34.0,<1.0.0",
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Base class for shapes
3+
*/
4+
class Shape {
5+
constructor(name) {
6+
this.name = name;
7+
}
8+
9+
area() {
10+
return 0;
11+
}
12+
}
13+
14+
class Circle extends Shape {
15+
constructor(radius) {
16+
super(radius);
17+
this.radius = radius;
18+
}
19+
20+
area() {
21+
return Math.PI * this.radius * this.radius;
22+
}
23+
}
24+
25+
function calculateTotal(shapes) {
26+
let total = 0;
27+
for (const shape of shapes) {
28+
total += shape.area();
29+
}
30+
return total;
31+
}

0 commit comments

Comments
 (0)