Skip to main content

codesynapse_core/ts_extract/
kotlin.rs

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
24/// Parse Kotlin delegation specifiers string (after the `:`) into (name, is_class) pairs.
25/// "BaseProcessor(), Loggable" → [("BaseProcessor", true), ("Loggable", false)]
26fn 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
99/// Extract a Kotlin type from a type annotation string like "Result<DataProcessor>".
100/// Returns (bare_type, vec_of_generic_args)
101fn 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            // import header
134            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            // class / interface / object / data class declaration
154            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                // Extract name (up to ':', '(', '<', '{', ' ')
176                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                    // Parse delegation specifiers (after ':')
199                    if let Some(colon_pos) = trimmed.find(':') {
200                        let after_colon = &trimmed[colon_pos + 1..];
201                        // Strip trailing '{', '{'
202                        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            // fun declaration inside or outside class
231            if let Some(rest) = trimmed.strip_prefix("fun ").or_else(|| {
232                // modifiers before fun
233                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                // Extract function name and type info
249                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                // Extract return type (after last `)`): `): Type`
293                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                // Extract parameter types from `(param: Type, ...)`
343                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            // Property/field declaration: `var name: Type` or `val name: Type`
387            if let Some(rest) = trimmed
388                .strip_prefix("var ")
389                .or_else(|| trimmed.strip_prefix("val "))
390            {
391                if let Some(owner) = &current_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            // Track closing of class body (simple heuristic: line with just "}")
422            if trimmed == "}" {
423                // Don't pop current_class on every '}' - keep it for the whole file
424                // since Kotlin functions can be nested inside class body
425            }
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}