Skip to content

Commit e844a53

Browse files
authored
Merge pull request #592 from FalkorDB/backend/add-kotlin-support
Add Kotlin language support
2 parents 7daba03 + e5eda92 commit e844a53

File tree

9 files changed

+362
-29
lines changed

9 files changed

+362
-29
lines changed

api/analyzers/analyzer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def resolve(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: P
5656
try:
5757
locations = lsp.request_definition(str(file_path), node.start_point.row, node.start_point.column)
5858
return [(files[Path(self.resolve_path(location['absolutePath'], path))], files[Path(self.resolve_path(location['absolutePath'], path))].tree.root_node.descendant_for_point_range(Point(location['range']['start']['line'], location['range']['start']['character']), Point(location['range']['end']['line'], location['range']['end']['character']))) for location in locations if location and Path(self.resolve_path(location['absolutePath'], path)) in files]
59-
except Exception as e:
59+
except Exception:
6060
return []
6161

6262
@abstractmethod
@@ -135,7 +135,7 @@ def add_symbols(self, entity: Entity) -> None:
135135
@abstractmethod
136136
def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, key: str, symbol: Node) -> list[Entity]:
137137
"""
138-
Resolve a symbol to an entity.
138+
Resolve a symbol to entities.
139139
140140
Args:
141141
lsp (SyncLanguageServer): The language server.

api/analyzers/kotlin/__init__.py

Whitespace-only changes.

api/analyzers/kotlin/analyzer.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
from pathlib import Path
2+
from ...entities.entity import Entity
3+
from ...entities.file import File
4+
from typing import Optional
5+
from ..analyzer import AbstractAnalyzer
6+
7+
from multilspy import SyncLanguageServer
8+
9+
import tree_sitter_kotlin as tskotlin
10+
from tree_sitter import Language, Node
11+
12+
import logging
13+
logger = logging.getLogger('code_graph')
14+
15+
class KotlinAnalyzer(AbstractAnalyzer):
16+
def __init__(self) -> None:
17+
super().__init__(Language(tskotlin.language()))
18+
19+
def add_dependencies(self, path: Path, files: list[Path]):
20+
# For now, we skip dependency resolution for Kotlin
21+
# In the future, this could parse build.gradle or pom.xml for Kotlin projects
22+
pass
23+
24+
def get_entity_label(self, node: Node) -> str:
25+
if node.type == 'class_declaration':
26+
# Check if it's an interface by looking for interface keyword
27+
for child in node.children:
28+
if child.type == 'interface':
29+
return "Interface"
30+
return "Class"
31+
elif node.type == 'object_declaration':
32+
return "Object"
33+
elif node.type == 'function_declaration':
34+
# Check if this is a method (inside a class) or a top-level function
35+
parent = node.parent
36+
if parent and parent.type == 'class_body':
37+
return "Method"
38+
return "Function"
39+
raise ValueError(f"Unknown entity type: {node.type}")
40+
41+
def get_entity_name(self, node: Node) -> str:
42+
if node.type in ['class_declaration', 'object_declaration', 'function_declaration']:
43+
for child in node.children:
44+
if child.type == 'identifier':
45+
return child.text.decode('utf-8')
46+
raise ValueError(f"Cannot extract name from entity type: {node.type}")
47+
48+
def get_entity_docstring(self, node: Node) -> Optional[str]:
49+
if node.type in ['class_declaration', 'object_declaration', 'function_declaration']:
50+
# Check for KDoc comment (/** ... */) before the node
51+
if node.prev_sibling and node.prev_sibling.type == "multiline_comment":
52+
comment_text = node.prev_sibling.text.decode('utf-8')
53+
# Only return if it's a KDoc comment (starts with /**)
54+
if comment_text.startswith('/**'):
55+
return comment_text
56+
return None
57+
raise ValueError(f"Unknown entity type: {node.type}")
58+
59+
def get_entity_types(self) -> list[str]:
60+
return ['class_declaration', 'object_declaration', 'function_declaration']
61+
62+
def _get_delegation_types(self, entity: Entity) -> list[tuple]:
63+
"""Extract type identifiers from delegation specifiers in order.
64+
65+
Returns list of (node, is_constructor_invocation) tuples.
66+
constructor_invocation indicates a superclass; plain user_type indicates an interface.
67+
"""
68+
types = []
69+
for child in entity.node.children:
70+
if child.type == 'delegation_specifiers':
71+
for spec in child.children:
72+
if spec.type == 'delegation_specifier':
73+
for sub in spec.children:
74+
if sub.type == 'constructor_invocation':
75+
for s in sub.children:
76+
if s.type == 'user_type':
77+
for id_node in s.children:
78+
if id_node.type == 'identifier':
79+
types.append((id_node, True))
80+
elif sub.type == 'user_type':
81+
for id_node in sub.children:
82+
if id_node.type == 'identifier':
83+
types.append((id_node, False))
84+
return types
85+
86+
def add_symbols(self, entity: Entity) -> None:
87+
if entity.node.type == 'class_declaration':
88+
types = self._get_delegation_types(entity)
89+
for node, is_class in types:
90+
if is_class:
91+
entity.add_symbol("base_class", node)
92+
else:
93+
entity.add_symbol("implement_interface", node)
94+
95+
elif entity.node.type == 'object_declaration':
96+
types = self._get_delegation_types(entity)
97+
for node, _ in types:
98+
entity.add_symbol("implement_interface", node)
99+
100+
elif entity.node.type == 'function_declaration':
101+
# Find function calls
102+
captures = self._captures("(call_expression) @reference.call", entity.node)
103+
if 'reference.call' in captures:
104+
for caller in captures['reference.call']:
105+
entity.add_symbol("call", caller)
106+
107+
# Find parameters with types
108+
captures = self._captures("(parameter (user_type (identifier) @parameter))", entity.node)
109+
if 'parameter' in captures:
110+
for parameter in captures['parameter']:
111+
entity.add_symbol("parameters", parameter)
112+
113+
# Find return type
114+
captures = self._captures("(function_declaration (user_type (identifier) @return_type))", entity.node)
115+
if 'return_type' in captures:
116+
for return_type in captures['return_type']:
117+
entity.add_symbol("return_type", return_type)
118+
119+
def is_dependency(self, file_path: str) -> bool:
120+
# Check if file is in a dependency directory (e.g., build, .gradle cache)
121+
return "build/" in file_path or ".gradle/" in file_path or "/cache/" in file_path
122+
123+
def resolve_path(self, file_path: str, path: Path) -> str:
124+
# For Kotlin, just return the file path as-is for now
125+
return file_path
126+
127+
def resolve_type(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, node: Node) -> list[Entity]:
128+
res = []
129+
for file, resolved_node in self.resolve(files, lsp, file_path, path, node):
130+
type_dec = self.find_parent(resolved_node, ['class_declaration', 'object_declaration'])
131+
if type_dec in file.entities:
132+
res.append(file.entities[type_dec])
133+
return res
134+
135+
def resolve_method(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, node: Node) -> list[Entity]:
136+
res = []
137+
# For call expressions, we need to extract the function name
138+
if node.type == 'call_expression':
139+
# Find the identifier being called
140+
for child in node.children:
141+
if child.type in ['identifier', 'navigation_expression']:
142+
for file, resolved_node in self.resolve(files, lsp, file_path, path, child):
143+
method_dec = self.find_parent(resolved_node, ['function_declaration', 'class_declaration', 'object_declaration'])
144+
if method_dec and method_dec.type in ['class_declaration', 'object_declaration']:
145+
continue
146+
if method_dec in file.entities:
147+
res.append(file.entities[method_dec])
148+
break
149+
return res
150+
151+
def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, key: str, symbol: Node) -> list[Entity]:
152+
if key in ["implement_interface", "base_class", "parameters", "return_type"]:
153+
return self.resolve_type(files, lsp, file_path, path, symbol)
154+
elif key in ["call"]:
155+
return self.resolve_method(files, lsp, file_path, path, symbol)
156+
else:
157+
raise ValueError(f"Unknown key {key}")

api/analyzers/source_analyzer.py

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
from ..graph import Graph
99
from .analyzer import AbstractAnalyzer
1010
# from .c.analyzer import CAnalyzer
11-
from .java.analyzer import JavaAnalyzer
12-
from .python.analyzer import PythonAnalyzer
1311
from .csharp.analyzer import CSharpAnalyzer
12+
from .java.analyzer import JavaAnalyzer
1413
from .javascript.analyzer import JavaScriptAnalyzer
14+
from .kotlin.analyzer import KotlinAnalyzer
15+
from .python.analyzer import PythonAnalyzer
1516

1617
from multilspy import SyncLanguageServer
1718
from multilspy.multilspy_config import MultilspyConfig
@@ -28,7 +29,9 @@
2829
'.py': PythonAnalyzer(),
2930
'.java': JavaAnalyzer(),
3031
'.cs': CSharpAnalyzer(),
31-
'.js': JavaScriptAnalyzer()}
32+
'.js': JavaScriptAnalyzer(),
33+
'.kt': KotlinAnalyzer(),
34+
'.kts': KotlinAnalyzer()}
3235

3336
class NullLanguageServer:
3437
def start_server(self):
@@ -145,8 +148,11 @@ def second_pass(self, graph: Graph, files: list[Path], path: Path) -> None:
145148
lsps[".cs"] = SyncLanguageServer.create(config, logger, str(path))
146149
else:
147150
lsps[".cs"] = NullLanguageServer()
151+
# For now, use NullLanguageServer for Kotlin as kotlin-language-server setup is not yet integrated
152+
lsps[".kt"] = NullLanguageServer()
153+
lsps[".kts"] = NullLanguageServer()
148154
lsps[".js"] = NullLanguageServer()
149-
with lsps[".java"].start_server(), lsps[".py"].start_server(), lsps[".cs"].start_server(), lsps[".js"].start_server():
155+
with lsps[".java"].start_server(), lsps[".py"].start_server(), lsps[".cs"].start_server(), lsps[".js"].start_server(), lsps[".kt"].start_server(), lsps[".kts"].start_server():
150156
files_len = len(self.files)
151157
for i, file_path in enumerate(files):
152158
if file_path not in self.files:
@@ -158,31 +164,28 @@ def second_pass(self, graph: Graph, files: list[Path], path: Path) -> None:
158164
logging.info(f'Processing file ({i + 1}/{files_len}): {file_path}')
159165
for _, entity in file.entities.items():
160166
entity.resolved_symbol(lambda key, symbol, fp=file_path: analyzers[fp.suffix].resolve_symbol(self.files, lsps[fp.suffix], fp, path, key, symbol))
161-
for key, symbols in entity.symbols.items():
162-
for symbol in symbols:
163-
if len(symbol.resolved_symbol) == 0:
164-
continue
165-
resolved_symbol = next(iter(symbol.resolved_symbol))
167+
for key, resolved_set in entity.resolved_symbols.items():
168+
for resolved in resolved_set:
166169
if key == "base_class":
167-
graph.connect_entities("EXTENDS", entity.id, resolved_symbol.id)
170+
graph.connect_entities("EXTENDS", entity.id, resolved.id)
168171
elif key == "implement_interface":
169-
graph.connect_entities("IMPLEMENTS", entity.id, resolved_symbol.id)
172+
graph.connect_entities("IMPLEMENTS", entity.id, resolved.id)
170173
elif key == "extend_interface":
171-
graph.connect_entities("EXTENDS", entity.id, resolved_symbol.id)
174+
graph.connect_entities("EXTENDS", entity.id, resolved.id)
172175
elif key == "call":
173-
graph.connect_entities("CALLS", entity.id, resolved_symbol.id, {"line": symbol.symbol.start_point.row, "text": symbol.symbol.text.decode("utf-8")})
176+
graph.connect_entities("CALLS", entity.id, resolved.id)
174177
elif key == "return_type":
175-
graph.connect_entities("RETURNS", entity.id, resolved_symbol.id)
178+
graph.connect_entities("RETURNS", entity.id, resolved.id)
176179
elif key == "parameters":
177-
graph.connect_entities("PARAMETERS", entity.id, resolved_symbol.id)
180+
graph.connect_entities("PARAMETERS", entity.id, resolved.id)
178181

179182
def analyze_files(self, files: list[Path], path: Path, graph: Graph) -> None:
180183
self.first_pass(path, files, [], graph)
181184
self.second_pass(graph, files, path)
182185

183186
def analyze_sources(self, path: Path, ignore: list[str], graph: Graph) -> None:
184187
path = path.resolve()
185-
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]
188+
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] + list(path.rglob("*.kt")) + list(path.rglob("*.kts"))
186189
# First pass analysis of the source code
187190
self.first_pass(path, files, ignore, graph)
188191

api/entities/entity.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,31 @@
11
from typing import Callable, Self
22
from tree_sitter import Node
33

4-
class Symbol:
5-
def __init__(self, symbol: Node):
6-
self.symbol = symbol
7-
self.resolved_symbol = set()
8-
9-
def add_resolve_symbol(self, resolved_symbol):
10-
self.resolved_symbol.add(resolved_symbol)
114

125
class Entity:
136
def __init__(self, node: Node):
147
self.node = node
15-
self.symbols: dict[str, list[Symbol]] = {}
8+
self.symbols: dict[str, list[Node]] = {}
9+
self.resolved_symbols: dict[str, set[Self]] = {}
1610
self.children: dict[Node, Self] = {}
1711

1812
def add_symbol(self, key: str, symbol: Node):
1913
if key not in self.symbols:
2014
self.symbols[key] = []
21-
self.symbols[key].append(Symbol(symbol))
15+
self.symbols[key].append(symbol)
16+
17+
def add_resolved_symbol(self, key: str, symbol: Self):
18+
if key not in self.resolved_symbols:
19+
self.resolved_symbols[key] = set()
20+
self.resolved_symbols[key].add(symbol)
2221

2322
def add_child(self, child: Self):
2423
child.parent = self
2524
self.children[child.node] = child
2625

2726
def resolved_symbol(self, f: Callable[[str, Node], list[Self]]):
2827
for key, symbols in self.symbols.items():
28+
self.resolved_symbols[key] = set()
2929
for symbol in symbols:
30-
for resolved_symbol in f(key, symbol.symbol):
31-
symbol.add_resolve_symbol(resolved_symbol)
30+
for resolved_symbol in f(key, symbol):
31+
self.resolved_symbols[key].add(resolved_symbol)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ dependencies = [
1414
"tree-sitter-python>=0.25.0,<0.26.0",
1515
"tree-sitter-java>=0.23.5,<0.24.0",
1616
"tree-sitter-javascript>=0.23.0,<0.24.0",
17+
"tree-sitter-kotlin>=1.1.0,<2.0.0",
1718
"tree-sitter-c-sharp>=0.23.1,<0.24.0",
1819
"fastapi>=0.115.0,<1.0.0",
1920
"uvicorn[standard]>=0.34.0,<1.0.0",
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* A base interface for logging
3+
*/
4+
interface Logger {
5+
fun log(message: String)
6+
}
7+
8+
/**
9+
* Base class for shapes
10+
*/
11+
open class Shape(val name: String) {
12+
open fun area(): Double = 0.0
13+
}
14+
15+
class Circle(val radius: Double) : Shape("circle"), Logger {
16+
override fun area(): Double {
17+
return Math.PI * radius * radius
18+
}
19+
20+
override fun log(message: String) {
21+
println(message)
22+
}
23+
}
24+
25+
fun calculateTotal(shapes: List<Shape>): Double {
26+
var total = 0.0
27+
for (shape in shapes) {
28+
total += shape.area()
29+
}
30+
return total
31+
}
32+
33+
object AppConfig : Logger {
34+
val version = "1.0"
35+
36+
override fun log(message: String) {
37+
println("[$version] $message")
38+
}
39+
}

0 commit comments

Comments
 (0)