1use super::{add_contains_edge, add_node_if_missing, make_file_node};
2use crate::error::Result;
3use crate::extract::{make_id, ImportNode, LanguageExtractor};
4use crate::types::{Edge, ExtractionFragment, Node};
5use std::collections::HashMap;
6use std::path::Path;
7
8pub struct TsKotlinExtractor;
9
10fn kt_node(id: String, label: String, file_type: &str, path: &Path) -> Node {
11 Node {
12 id,
13 label,
14 file_type: file_type.to_string(),
15 source_file: path.to_string_lossy().to_string(),
16 source_location: None,
17 community: None,
18 rationale: None,
19 docstring: None,
20 metadata: HashMap::new(),
21 }
22}
23
24fn parse_delegation_specs(specs_str: &str) -> Vec<(String, bool)> {
27 let mut result = Vec::new();
28 let mut depth = 0i32;
29 let mut angle = 0i32;
30 let mut current = String::new();
31
32 for ch in specs_str.chars() {
33 match ch {
34 '<' => {
35 angle += 1;
36 current.push(ch);
37 }
38 '>' => {
39 angle -= 1;
40 if angle < 0 {
41 angle = 0;
42 }
43 current.push(ch);
44 }
45 '(' if angle == 0 => {
46 depth += 1;
47 current.push(ch);
48 }
49 ')' if angle == 0 => {
50 depth -= 1;
51 if depth < 0 {
52 depth = 0;
53 }
54 current.push(ch);
55 }
56 ',' if depth == 0 && angle == 0 => {
57 let spec = current.trim().to_string();
58 if !spec.is_empty() {
59 let is_class = spec.contains('(');
60 let name = spec
61 .split('<')
62 .next()
63 .unwrap_or(&spec)
64 .split('(')
65 .next()
66 .unwrap_or(&spec)
67 .trim()
68 .to_string();
69 if !name.is_empty() {
70 result.push((name, is_class));
71 }
72 }
73 current = String::new();
74 }
75 _ => {
76 current.push(ch);
77 }
78 }
79 }
80 if !current.trim().is_empty() {
81 let spec = current.trim().to_string();
82 let is_class = spec.contains('(');
83 let name = spec
84 .split('<')
85 .next()
86 .unwrap_or(&spec)
87 .split('(')
88 .next()
89 .unwrap_or(&spec)
90 .trim()
91 .to_string();
92 if !name.is_empty() {
93 result.push((name, is_class));
94 }
95 }
96 result
97}
98
99fn parse_kotlin_type(type_str: &str) -> (String, Vec<String>) {
102 let trimmed = type_str.trim().trim_end_matches('?');
103 if let Some(lt) = trimmed.find('<') {
104 let base = trimmed[..lt].trim().to_string();
105 let args_str = trimmed[lt + 1..].trim_end_matches('>');
106 let args: Vec<String> = args_str
107 .split(',')
108 .map(|s| s.trim().trim_end_matches('?').to_string())
109 .filter(|s| !s.is_empty())
110 .collect();
111 (base, args)
112 } else {
113 (trimmed.to_string(), vec![])
114 }
115}
116
117impl TsKotlinExtractor {
118 pub fn extract(source: &[u8], path: &Path) -> Result<ExtractionFragment> {
119 let (file_id, _, file_node) = make_file_node(path);
120 let mut fragment = ExtractionFragment {
121 nodes: vec![file_node],
122 edges: vec![],
123 };
124
125 let content = std::str::from_utf8(source).unwrap_or("");
126 let stem = file_id.clone();
127
128 let mut current_class: Option<String> = None;
129
130 for line in content.lines() {
131 let trimmed = line.trim();
132
133 if let Some(rest) = trimmed.strip_prefix("import ") {
135 let full = rest.split_whitespace().next().unwrap_or("").trim();
136 let leaf = full.split('.').next_back().unwrap_or(full).to_string();
137 if !leaf.is_empty() {
138 let id = make_id(&[&leaf]);
139 add_node_if_missing(&mut fragment, kt_node(id.clone(), leaf, "module", path));
140 fragment.edges.push(Edge {
141 source: file_id.clone(),
142 target: id,
143 relation: "imports".to_string(),
144 confidence: "EXTRACTED".to_string(),
145 source_file: Some(path.to_string_lossy().to_string()),
146 weight: 1.0,
147 context: Some("import".to_string()),
148 });
149 }
150 continue;
151 }
152
153 let is_class_line = {
155 let mut kw = None;
156 for prefix in &[
157 "class ",
158 "data class ",
159 "open class ",
160 "abstract class ",
161 "sealed class ",
162 "interface ",
163 "object ",
164 "enum class ",
165 ] {
166 if let Some(rest) = trimmed.strip_prefix(prefix) {
167 kw = Some((*prefix, rest));
168 break;
169 }
170 }
171 kw
172 };
173
174 if let Some((kw, rest)) = is_class_line {
175 let class_name = rest
177 .split([':', '(', '<', '{'])
178 .next()
179 .unwrap_or("")
180 .trim()
181 .to_string();
182
183 let class_file_type = match kw {
184 "interface " => "interface",
185 "enum class " => "enum",
186 _ => "class",
187 };
188
189 if !class_name.is_empty() {
190 let class_id = make_id(&[&stem, &class_name]);
191 add_node_if_missing(
192 &mut fragment,
193 kt_node(class_id.clone(), class_name.clone(), class_file_type, path),
194 );
195 add_contains_edge(&mut fragment, &file_id, class_id.clone(), path);
196 current_class = Some(class_id.clone());
197
198 if let Some(colon_pos) = trimmed.find(':') {
200 let after_colon = &trimmed[colon_pos + 1..];
201 let specs_str = after_colon.split('{').next().unwrap_or(after_colon).trim();
203
204 for (base_name, is_class_base) in parse_delegation_specs(specs_str) {
205 let base_id = make_id(&[&stem, &base_name]);
206 add_node_if_missing(
207 &mut fragment,
208 kt_node(base_id.clone(), base_name, "code", path),
209 );
210 let relation = if is_class_base {
211 "inherits"
212 } else {
213 "implements"
214 };
215 fragment.edges.push(Edge {
216 source: class_id.clone(),
217 target: base_id,
218 relation: relation.to_string(),
219 confidence: "EXTRACTED".to_string(),
220 source_file: Some(path.to_string_lossy().to_string()),
221 weight: 1.0,
222 context: None,
223 });
224 }
225 }
226 }
227 continue;
228 }
229
230 if let Some(rest) = trimmed.strip_prefix("fun ").or_else(|| {
232 let stripped = trimmed
234 .trim_start_matches("override ")
235 .trim_start_matches("private ")
236 .trim_start_matches("public ")
237 .trim_start_matches("protected ")
238 .trim_start_matches("internal ")
239 .trim_start_matches("suspend ")
240 .trim_start_matches("inline ")
241 .trim_start_matches("abstract ");
242 if stripped.starts_with("fun ") {
243 stripped.strip_prefix("fun ")
244 } else {
245 None
246 }
247 }) {
248 let func_name = rest
250 .split(['(', '<', ':', ' '])
251 .next()
252 .unwrap_or("")
253 .trim()
254 .to_string();
255
256 if func_name.is_empty() {
257 continue;
258 }
259
260 let func_id = make_id(&[&stem, &func_name, "()"]);
261 let label = format!(".{}()", func_name);
262 let fn_file_type = if current_class.is_some() {
263 "method"
264 } else {
265 "function"
266 };
267 add_node_if_missing(
268 &mut fragment,
269 kt_node(func_id.clone(), label, fn_file_type, path),
270 );
271
272 let owner = current_class.as_deref().unwrap_or(&file_id);
273 fragment.edges.push(Edge {
274 source: owner.to_string(),
275 target: func_id.clone(),
276 relation: "method".to_string(),
277 confidence: "EXTRACTED".to_string(),
278 source_file: Some(path.to_string_lossy().to_string()),
279 weight: 1.0,
280 context: None,
281 });
282 fragment.edges.push(Edge {
283 source: file_id.clone(),
284 target: func_id.clone(),
285 relation: "contains".to_string(),
286 confidence: "EXTRACTED".to_string(),
287 source_file: Some(path.to_string_lossy().to_string()),
288 weight: 1.0,
289 context: None,
290 });
291
292 if let Some(paren_end) = rest.rfind(')') {
294 let after_paren = rest[paren_end + 1..].trim();
295 if let Some(ret_str) = after_paren.strip_prefix(':') {
296 let ret_type_str = ret_str
297 .split('{')
298 .next()
299 .unwrap_or(ret_str)
300 .split('=')
301 .next()
302 .unwrap_or(ret_str)
303 .trim();
304 let (base_type, generic_args) = parse_kotlin_type(ret_type_str);
305 if !base_type.is_empty() && base_type != "Unit" {
306 let id = make_id(&[&base_type]);
307 add_node_if_missing(
308 &mut fragment,
309 kt_node(id.clone(), base_type, "code", path),
310 );
311 fragment.edges.push(Edge {
312 source: func_id.clone(),
313 target: id,
314 relation: "references".to_string(),
315 confidence: "EXTRACTED".to_string(),
316 source_file: Some(path.to_string_lossy().to_string()),
317 weight: 1.0,
318 context: Some("return_type".to_string()),
319 });
320 }
321 for arg in generic_args {
322 if !arg.is_empty() {
323 let id = make_id(&[&arg]);
324 add_node_if_missing(
325 &mut fragment,
326 kt_node(id.clone(), arg, "code", path),
327 );
328 fragment.edges.push(Edge {
329 source: func_id.clone(),
330 target: id,
331 relation: "references".to_string(),
332 confidence: "EXTRACTED".to_string(),
333 source_file: Some(path.to_string_lossy().to_string()),
334 weight: 1.0,
335 context: Some("generic_arg".to_string()),
336 });
337 }
338 }
339 }
340 }
341
342 if let Some(paren_start) = rest.find('(') {
344 let paren_end = rest.rfind(')').unwrap_or(rest.len());
345 if paren_end > paren_start {
346 let params_str = &rest[paren_start + 1..paren_end];
347 for param in params_str.split(',') {
348 let param = param.trim();
349 if let Some(colon_pos) = param.find(':') {
350 let type_str = param[colon_pos + 1..].trim();
351 let (base_type, _generic_args) = parse_kotlin_type(type_str);
352 if !base_type.is_empty()
353 && !matches!(
354 base_type.as_str(),
355 "String"
356 | "Int"
357 | "Boolean"
358 | "Long"
359 | "Double"
360 | "Float"
361 | "Any"
362 )
363 {
364 let id = make_id(&[&base_type]);
365 add_node_if_missing(
366 &mut fragment,
367 kt_node(id.clone(), base_type.clone(), "code", path),
368 );
369 fragment.edges.push(Edge {
370 source: func_id.clone(),
371 target: id,
372 relation: "references".to_string(),
373 confidence: "EXTRACTED".to_string(),
374 source_file: Some(path.to_string_lossy().to_string()),
375 weight: 1.0,
376 context: Some("parameter_type".to_string()),
377 });
378 }
379 }
380 }
381 }
382 }
383 continue;
384 }
385
386 if let Some(rest) = trimmed
388 .strip_prefix("var ")
389 .or_else(|| trimmed.strip_prefix("val "))
390 {
391 if let Some(owner) = ¤t_class {
392 if let Some(colon_pos) = rest.find(':') {
393 let type_str = rest[colon_pos + 1..].split('=').next().unwrap_or("").trim();
394 let (base_type, _) = parse_kotlin_type(type_str);
395 if !base_type.is_empty()
396 && !matches!(
397 base_type.as_str(),
398 "String" | "Int" | "Boolean" | "Long" | "Double" | "Float"
399 )
400 {
401 let id = make_id(&[&base_type]);
402 add_node_if_missing(
403 &mut fragment,
404 kt_node(id.clone(), base_type, "code", path),
405 );
406 fragment.edges.push(Edge {
407 source: owner.clone(),
408 target: id,
409 relation: "references".to_string(),
410 confidence: "EXTRACTED".to_string(),
411 source_file: Some(path.to_string_lossy().to_string()),
412 weight: 1.0,
413 context: Some("field".to_string()),
414 });
415 }
416 }
417 }
418 continue;
419 }
420
421 if trimmed == "}" {
423 }
426 }
427
428 Ok(fragment)
429 }
430}
431
432impl LanguageExtractor for TsKotlinExtractor {
433 fn file_extensions(&self) -> Vec<&'static str> {
434 vec!["kt", "kts"]
435 }
436 fn extract(&self, source: &[u8], path: &Path) -> Result<ExtractionFragment> {
437 Self::extract(source, path)
438 }
439 fn resolve_imports(&self, _imports: &[ImportNode]) -> Vec<Edge> {
440 vec![]
441 }
442 fn collect_type_refs(&self, _fragment: &mut ExtractionFragment) {}
443}