codeui-python 0.1.0__py3-none-any.whl → 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codeui/__init__.py +1 -1
- codeui/agent/context.py +19 -3
- codeui/analysis/dead_code.py +13 -2
- codeui/analysis/undefined.py +70 -0
- codeui/analysis/unresolved_import.py +34 -4
- codeui/cli/main.py +101 -7
- codeui/core/cache.py +54 -0
- codeui/core/graph.py +233 -3
- codeui/core/override.py +1 -0
- codeui/core/resolver.py +51 -37
- codeui/errors.py +10 -0
- codeui/lang/base.py +5 -2
- codeui/lang/cpp.py +271 -0
- codeui/lang/csharp.py +262 -0
- codeui/lang/dart.py +278 -0
- codeui/lang/generic.py +6 -4
- codeui/lang/go.py +5 -2
- codeui/lang/java.py +320 -0
- codeui/lang/kotlin.py +303 -0
- codeui/lang/php.py +275 -0
- codeui/lang/python.py +49 -7
- codeui/lang/registry.py +22 -2
- codeui/lang/ruby.py +270 -0
- codeui/lang/rust.py +5 -2
- codeui/lang/scala.py +290 -0
- codeui/lang/swift.py +276 -0
- codeui/lang/ts.py +99 -8
- codeui/report/sarif_emitter.py +2 -1
- codeui/server/server.py +156 -85
- codeui/server/static/css/styles.css +1609 -0
- codeui/server/static/index.html +199 -0
- codeui/server/static/js/api.js +485 -0
- codeui/server/static/js/app.js +1044 -0
- codeui/server/static/js/graph_canvas.js +1485 -0
- codeui/server/static/js/minimap.js +383 -0
- codeui/server/static/js/sidebar.js +268 -0
- codeui/server/static/js/state.js +83 -0
- codeui/server/static/js/subgraph_window.js +1208 -0
- codeui/server/static/js/theme.js +332 -0
- codeui/tracer/__init__.py +4 -1
- codeui/tracer/tracer.py +109 -118
- codeui_python-0.1.1.dist-info/METADATA +171 -0
- codeui_python-0.1.1.dist-info/RECORD +71 -0
- {codeui_python-0.1.0.dist-info → codeui_python-0.1.1.dist-info}/licenses/LICENSE +1 -1
- codeui_python-0.1.0.dist-info/METADATA +0 -191
- codeui_python-0.1.0.dist-info/RECORD +0 -52
- {codeui_python-0.1.0.dist-info → codeui_python-0.1.1.dist-info}/WHEEL +0 -0
- {codeui_python-0.1.0.dist-info → codeui_python-0.1.1.dist-info}/entry_points.txt +0 -0
- {codeui_python-0.1.0.dist-info → codeui_python-0.1.1.dist-info}/top_level.txt +0 -0
codeui/lang/registry.py
CHANGED
|
@@ -6,6 +6,15 @@ from codeui.lang.python import PythonLanguageAnalyzer
|
|
|
6
6
|
from codeui.lang.ts import TSLanguageAnalyzer
|
|
7
7
|
from codeui.lang.go import GoLanguageAnalyzer
|
|
8
8
|
from codeui.lang.rust import RustLanguageAnalyzer
|
|
9
|
+
from codeui.lang.java import JavaLanguageAnalyzer
|
|
10
|
+
from codeui.lang.cpp import CppLanguageAnalyzer
|
|
11
|
+
from codeui.lang.csharp import CSharpLanguageAnalyzer
|
|
12
|
+
from codeui.lang.php import PHPLanguageAnalyzer
|
|
13
|
+
from codeui.lang.ruby import RubyLanguageAnalyzer
|
|
14
|
+
from codeui.lang.kotlin import KotlinLanguageAnalyzer
|
|
15
|
+
from codeui.lang.swift import SwiftLanguageAnalyzer
|
|
16
|
+
from codeui.lang.scala import ScalaLanguageAnalyzer
|
|
17
|
+
from codeui.lang.dart import DartLanguageAnalyzer
|
|
9
18
|
from codeui.lang.generic import GenericLanguageAnalyzer
|
|
10
19
|
|
|
11
20
|
class LanguageRegistry:
|
|
@@ -22,10 +31,19 @@ class LanguageRegistry:
|
|
|
22
31
|
TSLanguageAnalyzer(),
|
|
23
32
|
GoLanguageAnalyzer(),
|
|
24
33
|
RustLanguageAnalyzer(),
|
|
34
|
+
JavaLanguageAnalyzer(),
|
|
35
|
+
CppLanguageAnalyzer(),
|
|
36
|
+
CSharpLanguageAnalyzer(),
|
|
37
|
+
PHPLanguageAnalyzer(),
|
|
38
|
+
RubyLanguageAnalyzer(),
|
|
39
|
+
KotlinLanguageAnalyzer(),
|
|
40
|
+
SwiftLanguageAnalyzer(),
|
|
41
|
+
ScalaLanguageAnalyzer(),
|
|
42
|
+
DartLanguageAnalyzer(),
|
|
25
43
|
GenericLanguageAnalyzer(),
|
|
26
44
|
]
|
|
27
45
|
self._ext_map: Dict[str, LanguageAnalyzer] = {}
|
|
28
|
-
for analyzer in self._analyzers:
|
|
46
|
+
for analyzer in reversed(self._analyzers):
|
|
29
47
|
for ext in analyzer.extensions:
|
|
30
48
|
self._ext_map[ext.lower()] = analyzer
|
|
31
49
|
|
|
@@ -37,7 +55,9 @@ class LanguageRegistry:
|
|
|
37
55
|
"""
|
|
38
56
|
self._analyzers.append(analyzer)
|
|
39
57
|
for ext in analyzer.extensions:
|
|
40
|
-
|
|
58
|
+
ext_lower = ext.lower()
|
|
59
|
+
if type(analyzer).__name__ != "GenericLanguageAnalyzer" or ext_lower not in self._ext_map:
|
|
60
|
+
self._ext_map[ext_lower] = analyzer
|
|
41
61
|
|
|
42
62
|
def get_analyzer(self, path: Path) -> LanguageAnalyzer:
|
|
43
63
|
"""Find language analyzer matching file extension or filename.
|
codeui/lang/ruby.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Ruby language analyzer for codeui."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import ClassVar, Iterable, List, Sequence, TYPE_CHECKING
|
|
7
|
+
from codeui.core.ir import Edge, EdgeKind, Location, Symbol, SymbolKind, Visibility
|
|
8
|
+
from codeui.lang.base import ImportRef, LanguageAnalyzer, ParseResult
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from codeui.core.resolver import ResolveContext
|
|
12
|
+
|
|
13
|
+
class RubyLanguageAnalyzer(LanguageAnalyzer):
|
|
14
|
+
"""Ruby language analyzer.
|
|
15
|
+
Example:
|
|
16
|
+
>>> analyzer = RubyLanguageAnalyzer()
|
|
17
|
+
>>> res = analyzer.parse(Path("app.rb"), 'class App < Base\\n def run\\n end\\nend')
|
|
18
|
+
>>> syms = list(analyzer.extract_symbols(res))
|
|
19
|
+
>>> any(s.name == "App" for s in syms)
|
|
20
|
+
True
|
|
21
|
+
"""
|
|
22
|
+
language: ClassVar[str] = "ruby"
|
|
23
|
+
extensions: ClassVar[tuple[str, ...]] = (".rb",)
|
|
24
|
+
has_ast_support: ClassVar[bool] = True
|
|
25
|
+
is_supported: ClassVar[bool] = True
|
|
26
|
+
|
|
27
|
+
def parse(self, path: Path, source: str) -> ParseResult:
|
|
28
|
+
"""Parse Ruby source code into ParseResult.
|
|
29
|
+
Example:
|
|
30
|
+
>>> analyzer = RubyLanguageAnalyzer()
|
|
31
|
+
>>> res = analyzer.parse(Path("main.rb"), "require 'json'")
|
|
32
|
+
>>> res.errors
|
|
33
|
+
[]
|
|
34
|
+
"""
|
|
35
|
+
content_hash = hashlib.sha256(source.encode("utf-8")).hexdigest()
|
|
36
|
+
return ParseResult(
|
|
37
|
+
file_path=str(path),
|
|
38
|
+
source=source,
|
|
39
|
+
ast=source,
|
|
40
|
+
content_hash=content_hash,
|
|
41
|
+
errors=[],
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def extract_symbols(self, parse: ParseResult) -> Iterable[Symbol]:
|
|
45
|
+
"""Extract modules, classes, and methods from Ruby source.
|
|
46
|
+
Example:
|
|
47
|
+
>>> analyzer = RubyLanguageAnalyzer()
|
|
48
|
+
>>> res = analyzer.parse(Path("user.rb"), "class User\\n def name\\n end\\nend")
|
|
49
|
+
>>> syms = list(analyzer.extract_symbols(res))
|
|
50
|
+
>>> len(syms) >= 2
|
|
51
|
+
True
|
|
52
|
+
"""
|
|
53
|
+
source = parse.source
|
|
54
|
+
file_path = parse.file_path
|
|
55
|
+
lines = source.splitlines()
|
|
56
|
+
max_line = len(lines) if lines else 1
|
|
57
|
+
symbols: List[Symbol] = []
|
|
58
|
+
|
|
59
|
+
file_sym = Symbol(
|
|
60
|
+
id=file_path,
|
|
61
|
+
name=Path(file_path).name,
|
|
62
|
+
qualified_name=file_path,
|
|
63
|
+
kind=SymbolKind.FILE,
|
|
64
|
+
language=self.language,
|
|
65
|
+
location=Location(file_path, 1, 0, max_line, 0),
|
|
66
|
+
parent_id=None,
|
|
67
|
+
signature=None,
|
|
68
|
+
visibility=Visibility.PUBLIC,
|
|
69
|
+
modifiers=(),
|
|
70
|
+
content_hash=parse.content_hash,
|
|
71
|
+
)
|
|
72
|
+
symbols.append(file_sym)
|
|
73
|
+
|
|
74
|
+
require_pattern = re.compile(r'require(?:_relative)?\s+["\']([^"\']+)["\']')
|
|
75
|
+
class_pattern = re.compile(r'(?:class|module)\s+([A-Za-z0-9_:]+)(?:\s*<\s*([A-Za-z0-9_:]+))?')
|
|
76
|
+
def_pattern = re.compile(r'def\s+(?:self\.)?([A-Za-z0-9_?!]+)(?:\s*\(([^)]*)\))?')
|
|
77
|
+
|
|
78
|
+
ruby_keywords = {"if", "for", "while", "unless", "until", "case", "when", "then", "begin", "rescue", "ensure", "end", "return", "class", "module", "def"}
|
|
79
|
+
class_ranges: List[tuple[str, str, int, int, str]] = []
|
|
80
|
+
|
|
81
|
+
for idx, line in enumerate(lines, start=1):
|
|
82
|
+
line_str = line.strip()
|
|
83
|
+
if not line_str or line_str.startswith("#"):
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
for match in require_pattern.finditer(line_str):
|
|
87
|
+
req_path = match.group(1)
|
|
88
|
+
sym_id = f"import:{req_path}"
|
|
89
|
+
symbols.append(Symbol(
|
|
90
|
+
id=sym_id,
|
|
91
|
+
name=req_path.split("/")[-1],
|
|
92
|
+
qualified_name=f"import:{req_path}",
|
|
93
|
+
kind=SymbolKind.IMPORT,
|
|
94
|
+
language=self.language,
|
|
95
|
+
location=Location(file_path, idx, line.find(req_path), idx, line.find(req_path) + len(req_path)),
|
|
96
|
+
parent_id=file_path,
|
|
97
|
+
signature=f"require '{req_path}'",
|
|
98
|
+
visibility=Visibility.PUBLIC,
|
|
99
|
+
modifiers=(),
|
|
100
|
+
content_hash=self._extract_body_hash(lines, idx - 1),
|
|
101
|
+
))
|
|
102
|
+
|
|
103
|
+
for match in class_pattern.finditer(line_str):
|
|
104
|
+
cls_name = match.group(1)
|
|
105
|
+
if cls_name in ruby_keywords:
|
|
106
|
+
continue
|
|
107
|
+
base_name = match.group(2) or ""
|
|
108
|
+
sym_id = f"{file_path}::{cls_name}"
|
|
109
|
+
end_line = self._find_ruby_block_end(lines, idx - 1)
|
|
110
|
+
class_ranges.append((cls_name, sym_id, idx, end_line, base_name))
|
|
111
|
+
loc = Location(file_path, idx, line.find(cls_name), end_line, line.find(cls_name) + len(cls_name))
|
|
112
|
+
symbols.append(Symbol(
|
|
113
|
+
id=sym_id,
|
|
114
|
+
name=cls_name,
|
|
115
|
+
qualified_name=cls_name,
|
|
116
|
+
kind=SymbolKind.CLASS,
|
|
117
|
+
language=self.language,
|
|
118
|
+
location=loc,
|
|
119
|
+
parent_id=file_path,
|
|
120
|
+
signature=f"class {cls_name}",
|
|
121
|
+
visibility=Visibility.PUBLIC,
|
|
122
|
+
modifiers=(),
|
|
123
|
+
content_hash=self._extract_body_hash(lines, idx - 1),
|
|
124
|
+
))
|
|
125
|
+
|
|
126
|
+
for match in def_pattern.finditer(line_str):
|
|
127
|
+
m_name = match.group(1)
|
|
128
|
+
params = match.group(2) or ""
|
|
129
|
+
if m_name in ruby_keywords:
|
|
130
|
+
continue
|
|
131
|
+
enclosing = next((c for c in reversed(class_ranges) if c[2] <= idx <= c[3]), None)
|
|
132
|
+
if enclosing:
|
|
133
|
+
cls_name, cls_sym_id, _, _, _ = enclosing
|
|
134
|
+
sym_id = f"{file_path}::{cls_name}::{m_name}"
|
|
135
|
+
qual_name = f"{cls_name}#{m_name}"
|
|
136
|
+
parent_sym_id = cls_sym_id
|
|
137
|
+
sym_kind = SymbolKind.METHOD
|
|
138
|
+
else:
|
|
139
|
+
sym_id = f"{file_path}::{m_name}"
|
|
140
|
+
qual_name = m_name
|
|
141
|
+
parent_sym_id = file_path
|
|
142
|
+
sym_kind = SymbolKind.FUNCTION
|
|
143
|
+
end_line = self._find_ruby_block_end(lines, idx - 1)
|
|
144
|
+
loc = Location(file_path, idx, line.find(m_name), end_line, line.find(m_name) + len(m_name))
|
|
145
|
+
symbols.append(Symbol(
|
|
146
|
+
id=sym_id,
|
|
147
|
+
name=m_name,
|
|
148
|
+
qualified_name=qual_name,
|
|
149
|
+
kind=sym_kind,
|
|
150
|
+
language=self.language,
|
|
151
|
+
location=loc,
|
|
152
|
+
parent_id=parent_sym_id,
|
|
153
|
+
signature=f"def {m_name}({params})",
|
|
154
|
+
visibility=Visibility.PUBLIC,
|
|
155
|
+
modifiers=(),
|
|
156
|
+
content_hash=self._extract_body_hash(lines, idx - 1),
|
|
157
|
+
))
|
|
158
|
+
|
|
159
|
+
return symbols
|
|
160
|
+
|
|
161
|
+
def extract_edges(self, parse: ParseResult, symbols: Sequence[Symbol]) -> Iterable[Edge]:
|
|
162
|
+
"""Extract relationship edges for Ruby AST.
|
|
163
|
+
Example:
|
|
164
|
+
>>> analyzer = RubyLanguageAnalyzer()
|
|
165
|
+
>>> res = analyzer.parse(Path("app.rb"), "require 'helper'")
|
|
166
|
+
>>> syms = list(analyzer.extract_symbols(res))
|
|
167
|
+
>>> edges = list(analyzer.extract_edges(res, syms))
|
|
168
|
+
>>> len(edges) >= 1
|
|
169
|
+
True
|
|
170
|
+
"""
|
|
171
|
+
edges: List[Edge] = []
|
|
172
|
+
file_path = parse.file_path
|
|
173
|
+
sym_map = {s.id: s for s in symbols}
|
|
174
|
+
class_pattern = re.compile(r'class\s+([A-Za-z0-9_:]+)(?:\s*<\s*([A-Za-z0-9_:]+))?')
|
|
175
|
+
call_pattern = re.compile(r'(?<![A-Za-z0-9_$-])\b([A-Za-z_][A-Za-z0-9_]*)\s*(?:\(|\s+[A-Za-z0-9_:"\'])')
|
|
176
|
+
ruby_keywords = {"if", "for", "while", "unless", "until", "case", "when", "then", "begin", "rescue", "ensure", "end", "return", "class", "module", "def", "puts", "print"}
|
|
177
|
+
|
|
178
|
+
for s in symbols:
|
|
179
|
+
if s.parent_id and s.parent_id in sym_map:
|
|
180
|
+
edges.append(Edge(
|
|
181
|
+
source_id=s.parent_id,
|
|
182
|
+
target_id=s.id,
|
|
183
|
+
kind=EdgeKind.CONTAINS,
|
|
184
|
+
weight=1.0,
|
|
185
|
+
confidence=1.0,
|
|
186
|
+
location=s.location,
|
|
187
|
+
))
|
|
188
|
+
if s.kind == SymbolKind.IMPORT:
|
|
189
|
+
target_mod = s.qualified_name.replace("import:", "")
|
|
190
|
+
edges.append(Edge(
|
|
191
|
+
source_id=file_path,
|
|
192
|
+
target_id=f"module::{target_mod}",
|
|
193
|
+
kind=EdgeKind.IMPORTS,
|
|
194
|
+
weight=1.0,
|
|
195
|
+
confidence=1.0,
|
|
196
|
+
location=s.location,
|
|
197
|
+
))
|
|
198
|
+
|
|
199
|
+
lines = parse.source.splitlines()
|
|
200
|
+
for idx, line in enumerate(lines, start=1):
|
|
201
|
+
line_str = line.strip()
|
|
202
|
+
if not line_str or line_str.startswith("#"):
|
|
203
|
+
continue
|
|
204
|
+
for match in class_pattern.finditer(line_str):
|
|
205
|
+
cls_name = match.group(1)
|
|
206
|
+
base_name = match.group(2)
|
|
207
|
+
if base_name and base_name not in ruby_keywords:
|
|
208
|
+
cls_sym_id = f"{file_path}::{cls_name}"
|
|
209
|
+
loc = Location(file_path, idx, line.find(base_name), idx, line.find(base_name) + len(base_name))
|
|
210
|
+
edges.append(Edge(
|
|
211
|
+
source_id=cls_sym_id,
|
|
212
|
+
target_id=f"module::{base_name}",
|
|
213
|
+
kind=EdgeKind.INHERITS,
|
|
214
|
+
weight=1.0,
|
|
215
|
+
confidence=0.9,
|
|
216
|
+
location=loc,
|
|
217
|
+
))
|
|
218
|
+
|
|
219
|
+
func_syms = [s for s in symbols if s.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD)]
|
|
220
|
+
name_to_syms: dict[str, list[Symbol]] = {}
|
|
221
|
+
for s in func_syms:
|
|
222
|
+
name_to_syms.setdefault(s.name, []).append(s)
|
|
223
|
+
|
|
224
|
+
for f in func_syms:
|
|
225
|
+
if not f.location:
|
|
226
|
+
continue
|
|
227
|
+
f_lines = lines[f.location.start_line - 1 : f.location.end_line]
|
|
228
|
+
for f_line in f_lines:
|
|
229
|
+
for match in call_pattern.finditer(f_line):
|
|
230
|
+
callee_name = match.group(1)
|
|
231
|
+
if callee_name in ruby_keywords or callee_name == f.name:
|
|
232
|
+
continue
|
|
233
|
+
if callee_name in name_to_syms:
|
|
234
|
+
target = name_to_syms[callee_name][0]
|
|
235
|
+
edges.append(Edge(
|
|
236
|
+
source_id=f.id,
|
|
237
|
+
target_id=target.id,
|
|
238
|
+
kind=EdgeKind.CALLS,
|
|
239
|
+
weight=1.0,
|
|
240
|
+
confidence=0.85,
|
|
241
|
+
location=f.location,
|
|
242
|
+
))
|
|
243
|
+
|
|
244
|
+
return edges
|
|
245
|
+
|
|
246
|
+
def resolve_import(self, ref: ImportRef, ctx: ResolveContext) -> Iterable[str]:
|
|
247
|
+
"""Resolve Ruby require references to project files.
|
|
248
|
+
Example:
|
|
249
|
+
>>> analyzer = RubyLanguageAnalyzer()
|
|
250
|
+
>>> ctx = ResolveContext(Path("."))
|
|
251
|
+
>>> list(analyzer.resolve_import(ImportRef("app.rb", "helper", None, False), ctx))
|
|
252
|
+
[]
|
|
253
|
+
"""
|
|
254
|
+
clean_path = ref.module_name.replace(".", "/")
|
|
255
|
+
return ctx.resolve_path(ref.file_path, clean_path, self.language)
|
|
256
|
+
|
|
257
|
+
def _find_ruby_block_end(self, lines: List[str], start_idx: int) -> int:
|
|
258
|
+
nesting = 0
|
|
259
|
+
block_starters = ("def ", "class ", "module ", "if ", "unless ", "case ", "while ", "until ", "for ", "begin ", "do ")
|
|
260
|
+
for i in range(start_idx, len(lines)):
|
|
261
|
+
l = lines[i].strip()
|
|
262
|
+
if not l or l.startswith("#"):
|
|
263
|
+
continue
|
|
264
|
+
if any(l.startswith(bs) for bs in block_starters) or l == "do":
|
|
265
|
+
nesting += 1
|
|
266
|
+
if l == "end" or l.startswith("end ") or l.endswith(" end"):
|
|
267
|
+
nesting -= 1
|
|
268
|
+
if nesting <= 0:
|
|
269
|
+
return i + 1
|
|
270
|
+
return min(start_idx + 30, len(lines))
|
codeui/lang/rust.py
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
"""Rust language adapter for codeui."""
|
|
2
|
+
from __future__ import annotations
|
|
2
3
|
import hashlib
|
|
3
4
|
import re
|
|
4
5
|
from pathlib import Path
|
|
5
|
-
from typing import ClassVar, Iterable, List, Sequence
|
|
6
|
+
from typing import ClassVar, Iterable, List, Sequence, TYPE_CHECKING
|
|
6
7
|
from codeui.core.ir import Edge, EdgeKind, Location, Symbol, SymbolKind, Visibility
|
|
7
|
-
from codeui.core.resolver import ResolveContext
|
|
8
8
|
from codeui.lang.base import ImportRef, LanguageAnalyzer, ParseResult
|
|
9
9
|
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from codeui.core.resolver import ResolveContext
|
|
12
|
+
|
|
10
13
|
class RustLanguageAnalyzer(LanguageAnalyzer):
|
|
11
14
|
"""Rust language analyzer.
|
|
12
15
|
Example:
|
codeui/lang/scala.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Scala language adapter for codeui."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import ClassVar, Iterable, List, Sequence, TYPE_CHECKING
|
|
7
|
+
from codeui.core.ir import Edge, EdgeKind, Location, Symbol, SymbolKind, Visibility
|
|
8
|
+
from codeui.lang.base import ImportRef, LanguageAnalyzer, ParseResult
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from codeui.core.resolver import ResolveContext
|
|
12
|
+
|
|
13
|
+
class ScalaLanguageAnalyzer(LanguageAnalyzer):
|
|
14
|
+
"""Scala language adapter extracting traits, objects, case classes, defs, and vals.
|
|
15
|
+
Example:
|
|
16
|
+
>>> analyzer = ScalaLanguageAnalyzer()
|
|
17
|
+
>>> res = analyzer.parse(Path("App.scala"), "case class User(id: Long, name: String)")
|
|
18
|
+
>>> syms = list(analyzer.extract_symbols(res))
|
|
19
|
+
>>> any(s.kind == SymbolKind.CLASS for s in syms)
|
|
20
|
+
True
|
|
21
|
+
"""
|
|
22
|
+
language: ClassVar[str] = "scala"
|
|
23
|
+
extensions: ClassVar[tuple[str, ...]] = (".scala", ".sc")
|
|
24
|
+
|
|
25
|
+
def parse(self, path: Path, source: str) -> ParseResult:
|
|
26
|
+
"""Parse Scala source into AST-emulating representation.
|
|
27
|
+
Example:
|
|
28
|
+
>>> analyzer = ScalaLanguageAnalyzer()
|
|
29
|
+
>>> res = analyzer.parse(Path("Main.scala"), "object Main extends App {}")
|
|
30
|
+
>>> res.errors
|
|
31
|
+
[]
|
|
32
|
+
"""
|
|
33
|
+
content_hash = hashlib.sha256(source.encode("utf-8")).hexdigest()
|
|
34
|
+
return ParseResult(
|
|
35
|
+
file_path=str(path),
|
|
36
|
+
source=source,
|
|
37
|
+
ast=source,
|
|
38
|
+
content_hash=content_hash,
|
|
39
|
+
errors=[],
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def extract_symbols(self, parse: ParseResult) -> Iterable[Symbol]:
|
|
43
|
+
"""Extract Scala packages, classes, traits, objects, defs, and vals.
|
|
44
|
+
Example:
|
|
45
|
+
>>> analyzer = ScalaLanguageAnalyzer()
|
|
46
|
+
>>> res = analyzer.parse(Path("Service.scala"), "trait Service { def run(): Unit }")
|
|
47
|
+
>>> syms = list(analyzer.extract_symbols(res))
|
|
48
|
+
>>> len(syms) >= 2
|
|
49
|
+
True
|
|
50
|
+
"""
|
|
51
|
+
source = parse.source
|
|
52
|
+
file_path = parse.file_path
|
|
53
|
+
lines = source.splitlines()
|
|
54
|
+
max_line = len(lines) if lines else 1
|
|
55
|
+
symbols: List[Symbol] = []
|
|
56
|
+
|
|
57
|
+
file_sym = Symbol(
|
|
58
|
+
id=file_path,
|
|
59
|
+
name=Path(file_path).name,
|
|
60
|
+
qualified_name=file_path,
|
|
61
|
+
kind=SymbolKind.FILE,
|
|
62
|
+
language=self.language,
|
|
63
|
+
location=Location(file_path, 1, 0, max_line, 0),
|
|
64
|
+
parent_id=None,
|
|
65
|
+
signature=None,
|
|
66
|
+
visibility=Visibility.PUBLIC,
|
|
67
|
+
modifiers=(),
|
|
68
|
+
content_hash=parse.content_hash,
|
|
69
|
+
)
|
|
70
|
+
symbols.append(file_sym)
|
|
71
|
+
|
|
72
|
+
package_pattern = re.compile(r'^\s*package\s+([a-zA-Z0-9_.]+)')
|
|
73
|
+
import_pattern = re.compile(r'^\s*import\s+([a-zA-Z0-9_.*{}]+)')
|
|
74
|
+
type_pattern = re.compile(r'^\s*(?:(private|protected)(?:\[[a-zA-Z0-9_]+\])?\s+)?(?:(sealed|abstract|final|case|implicit|lazy)\s+)*(class|trait|object|enum)\s+([a-zA-Z0-9_]+)(?:\[[^\]]+\])?(?:\s*\([^)]*\))?(?:\s+extends\s+([a-zA-Z0-9_.,\s\[\]()]+))?(?:\s+with\s+([a-zA-Z0-9_.,\s\[\]()]+))?')
|
|
75
|
+
def_pattern = re.compile(r'^\s*(?:(private|protected)(?:\[[a-zA-Z0-9_]+\])?\s+)?(?:(override|final|implicit|def)\s+)*def\s+([a-zA-Z0-9_=+<>*/&|~^%-]+)(?:\[[^\]]+\])?\s*(?:\(([^)]*)\))?(?:\s*:\s*([a-zA-Z0-9_?<>,\s\[\]]+))?')
|
|
76
|
+
val_pattern = re.compile(r'^\s*(?:(private|protected)(?:\[[a-zA-Z0-9_]+\])?\s+)?(?:(override|final|implicit|lazy)\s+)*(val|var)\s+([a-zA-Z0-9_]+)(?:\s*:\s*([a-zA-Z0-9_?<>,\s\[\]]+))?')
|
|
77
|
+
|
|
78
|
+
current_package = ""
|
|
79
|
+
current_type: str | None = None
|
|
80
|
+
brace_depth = 0
|
|
81
|
+
type_depth = 0
|
|
82
|
+
|
|
83
|
+
for line_idx, line in enumerate(lines, start=1):
|
|
84
|
+
stripped = line.strip()
|
|
85
|
+
if not stripped or stripped.startswith("//") or stripped.startswith("/*") or stripped.startswith("*"):
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
pkg_match = package_pattern.match(line)
|
|
89
|
+
if pkg_match:
|
|
90
|
+
current_package = pkg_match.group(1)
|
|
91
|
+
symbols.append(Symbol(
|
|
92
|
+
id=f"{file_path}::package::{current_package}",
|
|
93
|
+
name=current_package,
|
|
94
|
+
qualified_name=current_package,
|
|
95
|
+
kind=SymbolKind.NAMESPACE,
|
|
96
|
+
language=self.language,
|
|
97
|
+
location=Location(file_path, line_idx, 0, line_idx, len(line)),
|
|
98
|
+
parent_id=file_path,
|
|
99
|
+
signature=f"package {current_package}",
|
|
100
|
+
visibility=Visibility.PUBLIC,
|
|
101
|
+
modifiers=(),
|
|
102
|
+
content_hash=hashlib.sha256(line.encode("utf-8")).hexdigest()[:16],
|
|
103
|
+
))
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
imp_match = import_pattern.match(line)
|
|
107
|
+
if imp_match:
|
|
108
|
+
imp_name = imp_match.group(1)
|
|
109
|
+
symbols.append(Symbol(
|
|
110
|
+
id=f"{file_path}::import::{imp_name}",
|
|
111
|
+
name=imp_name.split(".")[-1],
|
|
112
|
+
qualified_name=imp_name,
|
|
113
|
+
kind=SymbolKind.IMPORT,
|
|
114
|
+
language=self.language,
|
|
115
|
+
location=Location(file_path, line_idx, 0, line_idx, len(line)),
|
|
116
|
+
parent_id=file_path,
|
|
117
|
+
signature=f"import {imp_name}",
|
|
118
|
+
visibility=Visibility.PUBLIC,
|
|
119
|
+
modifiers=(),
|
|
120
|
+
content_hash=hashlib.sha256(line.encode("utf-8")).hexdigest()[:16],
|
|
121
|
+
))
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
type_match = type_pattern.match(line)
|
|
125
|
+
if type_match:
|
|
126
|
+
vis_str = type_match.group(1) or "public"
|
|
127
|
+
mod_str = type_match.group(2)
|
|
128
|
+
type_kind = type_match.group(3)
|
|
129
|
+
type_name = type_match.group(4)
|
|
130
|
+
vis = Visibility.PRIVATE if vis_str == "private" else (Visibility.PROTECTED if vis_str == "protected" else Visibility.PUBLIC)
|
|
131
|
+
kind = SymbolKind.INTERFACE if type_kind == "trait" else SymbolKind.CLASS
|
|
132
|
+
modifiers = tuple(filter(None, [mod_str, type_kind]))
|
|
133
|
+
qual_prefix = f"{current_package}." if current_package else ""
|
|
134
|
+
sym_id = f"{file_path}::{type_name}"
|
|
135
|
+
current_type = sym_id
|
|
136
|
+
type_depth = brace_depth
|
|
137
|
+
|
|
138
|
+
symbols.append(Symbol(
|
|
139
|
+
id=sym_id,
|
|
140
|
+
name=type_name,
|
|
141
|
+
qualified_name=f"{qual_prefix}{type_name}",
|
|
142
|
+
kind=kind,
|
|
143
|
+
language=self.language,
|
|
144
|
+
location=Location(file_path, line_idx, 0, line_idx, len(line)),
|
|
145
|
+
parent_id=file_path,
|
|
146
|
+
signature=f"{vis_str} {type_kind} {type_name}",
|
|
147
|
+
visibility=vis,
|
|
148
|
+
modifiers=modifiers,
|
|
149
|
+
content_hash=hashlib.sha256(line.encode("utf-8")).hexdigest()[:16],
|
|
150
|
+
))
|
|
151
|
+
|
|
152
|
+
def_match = def_pattern.match(line)
|
|
153
|
+
if def_match:
|
|
154
|
+
vis_str = def_match.group(1) or "public"
|
|
155
|
+
def_mod = def_match.group(2)
|
|
156
|
+
def_name = def_match.group(3)
|
|
157
|
+
def_args = def_match.group(4) or ""
|
|
158
|
+
def_ret = def_match.group(5) or "Unit"
|
|
159
|
+
vis = Visibility.PRIVATE if vis_str == "private" else (Visibility.PROTECTED if vis_str == "protected" else Visibility.PUBLIC)
|
|
160
|
+
parent_id = current_type if current_type and brace_depth > type_depth else file_path
|
|
161
|
+
sym_id = f"{parent_id}::{def_name}"
|
|
162
|
+
symbols.append(Symbol(
|
|
163
|
+
id=sym_id,
|
|
164
|
+
name=def_name,
|
|
165
|
+
qualified_name=f"{current_package}.{def_name}" if current_package else def_name,
|
|
166
|
+
kind=SymbolKind.FUNCTION,
|
|
167
|
+
language=self.language,
|
|
168
|
+
location=Location(file_path, line_idx, 0, line_idx, len(line)),
|
|
169
|
+
parent_id=parent_id,
|
|
170
|
+
signature=f"def {def_name}({def_args}): {def_ret}",
|
|
171
|
+
visibility=vis,
|
|
172
|
+
modifiers=tuple(filter(None, [def_mod])),
|
|
173
|
+
content_hash=hashlib.sha256(line.encode("utf-8")).hexdigest()[:16],
|
|
174
|
+
))
|
|
175
|
+
|
|
176
|
+
val_match = val_pattern.match(line)
|
|
177
|
+
if val_match:
|
|
178
|
+
vis_str = val_match.group(1) or "public"
|
|
179
|
+
val_mod = val_match.group(2)
|
|
180
|
+
val_or_var = val_match.group(3)
|
|
181
|
+
val_name = val_match.group(4)
|
|
182
|
+
val_type = val_match.group(5) or "Any"
|
|
183
|
+
vis = Visibility.PRIVATE if vis_str == "private" else (Visibility.PROTECTED if vis_str == "protected" else Visibility.PUBLIC)
|
|
184
|
+
parent_id = current_type if current_type and brace_depth > type_depth else file_path
|
|
185
|
+
sym_id = f"{parent_id}::{val_name}"
|
|
186
|
+
symbols.append(Symbol(
|
|
187
|
+
id=sym_id,
|
|
188
|
+
name=val_name,
|
|
189
|
+
qualified_name=f"{current_package}.{val_name}" if current_package else val_name,
|
|
190
|
+
kind=SymbolKind.VARIABLE,
|
|
191
|
+
language=self.language,
|
|
192
|
+
location=Location(file_path, line_idx, 0, line_idx, len(line)),
|
|
193
|
+
parent_id=parent_id,
|
|
194
|
+
signature=f"{val_or_var} {val_name}: {val_type}",
|
|
195
|
+
visibility=vis,
|
|
196
|
+
modifiers=tuple(filter(None, [val_mod, val_or_var])),
|
|
197
|
+
content_hash=hashlib.sha256(line.encode("utf-8")).hexdigest()[:16],
|
|
198
|
+
))
|
|
199
|
+
|
|
200
|
+
brace_depth += line.count("{") - line.count("}")
|
|
201
|
+
if current_type and brace_depth <= type_depth:
|
|
202
|
+
current_type = None
|
|
203
|
+
|
|
204
|
+
return sorted(symbols, key=lambda s: (s.location.start_line if s.location else 0, s.name))
|
|
205
|
+
|
|
206
|
+
def extract_edges(self, parse: ParseResult, symbols: Sequence[Symbol]) -> Iterable[Edge]:
|
|
207
|
+
"""Extract containment, inheritance, and import edges for Scala.
|
|
208
|
+
Example:
|
|
209
|
+
>>> analyzer = ScalaLanguageAnalyzer()
|
|
210
|
+
>>> res = analyzer.parse(Path("App.scala"), "class Child extends Parent with TraitA")
|
|
211
|
+
>>> syms = list(analyzer.extract_symbols(res))
|
|
212
|
+
>>> edges = list(analyzer.extract_edges(res, syms))
|
|
213
|
+
>>> len(edges) >= 1
|
|
214
|
+
True
|
|
215
|
+
"""
|
|
216
|
+
edges: List[Edge] = []
|
|
217
|
+
file_path = parse.file_path
|
|
218
|
+
symbol_map = {s.name: s for s in symbols}
|
|
219
|
+
type_inherit_pattern = re.compile(r'^\s*(?:[a-zA-Z0-9_\s<>]+)?(?:class|trait|object)\s+([a-zA-Z0-9_]+)(?:\[[^\]]+\])?(?:\s*\([^)]*\))?(?:\s+extends\s+([a-zA-Z0-9_.,\s\[\]()]+))?(?:\s+with\s+([a-zA-Z0-9_.,\s\[\]()]+))?')
|
|
220
|
+
|
|
221
|
+
for s in symbols:
|
|
222
|
+
if s.parent_id and s.parent_id != s.id:
|
|
223
|
+
edges.append(Edge(
|
|
224
|
+
source_id=s.parent_id,
|
|
225
|
+
target_id=s.id,
|
|
226
|
+
kind=EdgeKind.CONTAINS,
|
|
227
|
+
weight=1.0,
|
|
228
|
+
confidence=1.0,
|
|
229
|
+
location=s.location,
|
|
230
|
+
))
|
|
231
|
+
|
|
232
|
+
for line_idx, line in enumerate(parse.source.splitlines(), start=1):
|
|
233
|
+
type_match = type_inherit_pattern.match(line)
|
|
234
|
+
if type_match:
|
|
235
|
+
type_name = type_match.group(1)
|
|
236
|
+
extends_raw = type_match.group(2)
|
|
237
|
+
with_raw = type_match.group(3)
|
|
238
|
+
type_sym = symbol_map.get(type_name)
|
|
239
|
+
if type_sym:
|
|
240
|
+
super_candidates = []
|
|
241
|
+
if extends_raw:
|
|
242
|
+
super_candidates.append(extends_raw.strip().split("(")[0].split("[")[0].strip())
|
|
243
|
+
if with_raw:
|
|
244
|
+
for w in with_raw.split("with"):
|
|
245
|
+
clean_w = w.strip().split("(")[0].split("[")[0].strip()
|
|
246
|
+
if clean_w:
|
|
247
|
+
super_candidates.append(clean_w)
|
|
248
|
+
for sup in super_candidates:
|
|
249
|
+
if sup and sup not in ("App", "Any", "Serializable", "Product"):
|
|
250
|
+
target_id = symbol_map[sup].id if sup in symbol_map else sup
|
|
251
|
+
edges.append(Edge(
|
|
252
|
+
source_id=type_sym.id,
|
|
253
|
+
target_id=target_id,
|
|
254
|
+
kind=EdgeKind.INHERITS,
|
|
255
|
+
weight=1.0,
|
|
256
|
+
confidence=0.9,
|
|
257
|
+
location=Location(file_path, line_idx, 0, line_idx, len(line)),
|
|
258
|
+
))
|
|
259
|
+
|
|
260
|
+
if line.strip().startswith("import "):
|
|
261
|
+
imp_parts = line.strip().replace("import ", "").rstrip(";").split()
|
|
262
|
+
if imp_parts:
|
|
263
|
+
target_mod = imp_parts[0].rstrip("._")
|
|
264
|
+
edges.append(Edge(
|
|
265
|
+
source_id=file_path,
|
|
266
|
+
target_id=target_mod,
|
|
267
|
+
kind=EdgeKind.IMPORTS,
|
|
268
|
+
weight=1.0,
|
|
269
|
+
confidence=1.0,
|
|
270
|
+
location=Location(file_path, line_idx, 0, line_idx, len(line)),
|
|
271
|
+
))
|
|
272
|
+
|
|
273
|
+
return sorted(edges, key=lambda e: (e.source_id, e.target_id, e.kind.value))
|
|
274
|
+
|
|
275
|
+
def resolve_import(self, ref: ImportRef, ctx: ResolveContext) -> Iterable[str]:
|
|
276
|
+
"""Resolve Scala package or object imports against project files.
|
|
277
|
+
Example:
|
|
278
|
+
>>> analyzer = ScalaLanguageAnalyzer()
|
|
279
|
+
>>> from codeui.core.resolver import ResolveContext
|
|
280
|
+
>>> ctx = ResolveContext(Path("."))
|
|
281
|
+
>>> ref = ImportRef("src/Main.scala", "com.example.Service", None, False)
|
|
282
|
+
>>> list(analyzer.resolve_import(ref, ctx))
|
|
283
|
+
[]
|
|
284
|
+
"""
|
|
285
|
+
mod_name = ref.module_name.rstrip("._")
|
|
286
|
+
candidates = ctx.resolve_path(ref.from_file, mod_name, "scala")
|
|
287
|
+
if not candidates and "." in mod_name:
|
|
288
|
+
leaf = mod_name.split(".")[-1]
|
|
289
|
+
candidates = ctx.resolve_path(ref.from_file, leaf, "scala")
|
|
290
|
+
return candidates
|