gabb_cli/languages/
kotlin.rs

1use crate::languages::ImportBindingInfo;
2use crate::store::{normalize_path, EdgeRecord, FileDependency, ReferenceRecord, SymbolRecord};
3use anyhow::{Context, Result};
4use once_cell::sync::Lazy;
5use std::collections::{HashMap, HashSet};
6use std::path::Path;
7use tree_sitter::{Language, Node, Parser, TreeCursor};
8
9static KOTLIN_LANGUAGE: Lazy<Language> = Lazy::new(tree_sitter_kotlin_codanna::language);
10
11/// Index a Kotlin file, returning symbols, edges, references, file dependencies, and import bindings.
12#[allow(clippy::type_complexity)]
13pub fn index_file(
14    path: &Path,
15    source: &str,
16) -> Result<(
17    Vec<SymbolRecord>,
18    Vec<EdgeRecord>,
19    Vec<ReferenceRecord>,
20    Vec<FileDependency>,
21    Vec<ImportBindingInfo>,
22)> {
23    let mut parser = Parser::new();
24    parser
25        .set_language(&KOTLIN_LANGUAGE)
26        .context("failed to set Kotlin language")?;
27    let tree = parser
28        .parse(source, None)
29        .context("failed to parse Kotlin file")?;
30
31    let mut symbols = Vec::new();
32    let mut edges = Vec::new();
33    let mut declared_spans: HashSet<(usize, usize)> = HashSet::new();
34    let mut symbol_by_name: HashMap<String, String> = HashMap::new();
35
36    {
37        let mut cursor = tree.walk();
38        walk_symbols(
39            path,
40            source,
41            &mut cursor,
42            None,
43            &mut symbols,
44            &mut edges,
45            &mut declared_spans,
46            &mut symbol_by_name,
47        );
48    }
49
50    let references = collect_references(
51        path,
52        source,
53        &tree.root_node(),
54        &declared_spans,
55        &symbol_by_name,
56    );
57
58    let (dependencies, import_bindings) = collect_imports(path, source, &tree.root_node());
59
60    Ok((symbols, edges, references, dependencies, import_bindings))
61}
62
63/// Walk the AST and extract symbols
64#[allow(clippy::too_many_arguments)]
65fn walk_symbols(
66    path: &Path,
67    source: &str,
68    cursor: &mut TreeCursor,
69    container: Option<String>,
70    symbols: &mut Vec<SymbolRecord>,
71    edges: &mut Vec<EdgeRecord>,
72    declared_spans: &mut HashSet<(usize, usize)>,
73    symbol_by_name: &mut HashMap<String, String>,
74) {
75    loop {
76        let node = cursor.node();
77        match node.kind() {
78            "class_declaration" => {
79                handle_class(
80                    path,
81                    source,
82                    &node,
83                    container.clone(),
84                    symbols,
85                    edges,
86                    declared_spans,
87                    symbol_by_name,
88                );
89            }
90            "object_declaration" => {
91                handle_object(
92                    path,
93                    source,
94                    &node,
95                    container.clone(),
96                    symbols,
97                    edges,
98                    declared_spans,
99                    symbol_by_name,
100                );
101            }
102            "interface_declaration" => {
103                handle_interface(
104                    path,
105                    source,
106                    &node,
107                    container.clone(),
108                    symbols,
109                    edges,
110                    declared_spans,
111                    symbol_by_name,
112                );
113            }
114            "function_declaration" => {
115                handle_function(
116                    path,
117                    source,
118                    &node,
119                    container.clone(),
120                    symbols,
121                    declared_spans,
122                    symbol_by_name,
123                );
124            }
125            "property_declaration" => {
126                handle_property(
127                    path,
128                    source,
129                    &node,
130                    container.clone(),
131                    symbols,
132                    declared_spans,
133                    symbol_by_name,
134                );
135            }
136            "companion_object" => {
137                // Companion objects are treated as nested objects
138                if let Some(name) = find_name(&node, source) {
139                    let sym = make_symbol(
140                        path,
141                        &node,
142                        &name,
143                        "object",
144                        container.clone(),
145                        source.as_bytes(),
146                    );
147                    declared_spans.insert((sym.start as usize, sym.end as usize));
148                    symbol_by_name.insert(name.clone(), sym.id.clone());
149                    symbols.push(sym);
150                } else {
151                    // Anonymous companion object - use "Companion" as the name
152                    let sym = make_symbol(
153                        path,
154                        &node,
155                        "Companion",
156                        "object",
157                        container.clone(),
158                        source.as_bytes(),
159                    );
160                    declared_spans.insert((sym.start as usize, sym.end as usize));
161                    symbol_by_name.insert("Companion".to_string(), sym.id.clone());
162                    symbols.push(sym);
163                }
164            }
165            _ => {}
166        }
167
168        // Recurse into children
169        if cursor.goto_first_child() {
170            let child_container = match node.kind() {
171                "class_declaration" | "interface_declaration" | "object_declaration" => {
172                    find_name(&node, source).or(container.clone())
173                }
174                _ => container.clone(),
175            };
176            walk_symbols(
177                path,
178                source,
179                cursor,
180                child_container,
181                symbols,
182                edges,
183                declared_spans,
184                symbol_by_name,
185            );
186            cursor.goto_parent();
187        }
188
189        if !cursor.goto_next_sibling() {
190            break;
191        }
192    }
193}
194
195#[allow(clippy::too_many_arguments)]
196fn handle_class(
197    path: &Path,
198    source: &str,
199    node: &Node,
200    container: Option<String>,
201    symbols: &mut Vec<SymbolRecord>,
202    edges: &mut Vec<EdgeRecord>,
203    declared_spans: &mut HashSet<(usize, usize)>,
204    symbol_by_name: &mut HashMap<String, String>,
205) {
206    if let Some(name) = find_name(node, source) {
207        // Determine the kind based on modifiers and keywords
208        let kind = determine_class_kind(node);
209
210        let sym = make_symbol(
211            path,
212            node,
213            &name,
214            &kind,
215            container.clone(),
216            source.as_bytes(),
217        );
218        declared_spans.insert((sym.start as usize, sym.end as usize));
219        symbol_by_name.insert(name.clone(), sym.id.clone());
220
221        // Record inheritance edges
222        record_inheritance_edges(path, source, node, &sym.id, edges, symbol_by_name);
223
224        symbols.push(sym);
225
226        // For enum classes, also extract enum entries
227        if kind == "enum_class" {
228            extract_enum_entries(
229                path,
230                source,
231                node,
232                Some(name.clone()),
233                symbols,
234                declared_spans,
235                symbol_by_name,
236            );
237        }
238
239        // Extract constructor properties (val/var parameters in primary constructor)
240        extract_constructor_properties(
241            path,
242            source,
243            node,
244            Some(name),
245            symbols,
246            declared_spans,
247            symbol_by_name,
248        );
249    }
250}
251
252/// Extract properties defined in primary constructor (val/var parameters)
253fn extract_constructor_properties(
254    path: &Path,
255    source: &str,
256    node: &Node,
257    container: Option<String>,
258    symbols: &mut Vec<SymbolRecord>,
259    declared_spans: &mut HashSet<(usize, usize)>,
260    symbol_by_name: &mut HashMap<String, String>,
261) {
262    // Find primary_constructor
263    let mut cursor = node.walk();
264    for child in node.children(&mut cursor) {
265        if child.kind() == "primary_constructor" {
266            // Find class_parameter nodes
267            let mut param_cursor = child.walk();
268            for param in child.children(&mut param_cursor) {
269                if param.kind() == "class_parameter" {
270                    // Check if this parameter has val/var (making it a property)
271                    if has_property_binding(&param) {
272                        if let Some(prop_name) = find_parameter_name(&param, source) {
273                            let sym = make_symbol(
274                                path,
275                                &param,
276                                &prop_name,
277                                "property",
278                                container.clone(),
279                                source.as_bytes(),
280                            );
281                            declared_spans.insert((sym.start as usize, sym.end as usize));
282                            symbol_by_name.insert(prop_name, sym.id.clone());
283                            symbols.push(sym);
284                        }
285                    }
286                }
287            }
288            break;
289        }
290    }
291}
292
293/// Check if a class_parameter has val/var binding (making it a property)
294fn has_property_binding(node: &Node) -> bool {
295    let mut cursor = node.walk();
296    for child in node.children(&mut cursor) {
297        if child.kind() == "binding_pattern_kind" {
298            return true;
299        }
300    }
301    false
302}
303
304/// Find the parameter name from a class_parameter node
305fn find_parameter_name(node: &Node, source: &str) -> Option<String> {
306    let mut cursor = node.walk();
307    for child in node.children(&mut cursor) {
308        if child.kind() == "simple_identifier" {
309            let name = slice(source, &child);
310            if !name.is_empty() {
311                return Some(name);
312            }
313        }
314    }
315    None
316}
317
318/// Determine the kind of a class_declaration node
319fn determine_class_kind(node: &Node) -> String {
320    let mut is_data = false;
321    let mut is_sealed = false;
322    let mut is_enum = false;
323    let mut is_interface = false;
324
325    let mut cursor = node.walk();
326    for child in node.children(&mut cursor) {
327        match child.kind() {
328            "modifiers" => {
329                // Check for data/sealed modifiers
330                let mut mod_cursor = child.walk();
331                for modifier in child.children(&mut mod_cursor) {
332                    if modifier.kind() == "class_modifier" {
333                        let mut class_mod_cursor = modifier.walk();
334                        for cm in modifier.children(&mut class_mod_cursor) {
335                            match cm.kind() {
336                                "data" => is_data = true,
337                                "sealed" => is_sealed = true,
338                                _ => {}
339                            }
340                        }
341                    }
342                }
343            }
344            "enum" => is_enum = true,
345            "interface" => is_interface = true,
346            _ => {}
347        }
348    }
349
350    // Determine final kind based on flags
351    if is_enum {
352        "enum_class".to_string()
353    } else if is_sealed && is_interface {
354        "sealed_interface".to_string()
355    } else if is_sealed {
356        "sealed_class".to_string()
357    } else if is_data {
358        "data_class".to_string()
359    } else if is_interface {
360        "interface".to_string()
361    } else {
362        "class".to_string()
363    }
364}
365
366/// Extract enum entries from an enum class
367fn extract_enum_entries(
368    path: &Path,
369    source: &str,
370    node: &Node,
371    container: Option<String>,
372    symbols: &mut Vec<SymbolRecord>,
373    declared_spans: &mut HashSet<(usize, usize)>,
374    symbol_by_name: &mut HashMap<String, String>,
375) {
376    let mut stack = vec![*node];
377    while let Some(n) = stack.pop() {
378        if n.kind() == "enum_entry" {
379            if let Some(entry_name) = find_name(&n, source) {
380                let sym = make_symbol(
381                    path,
382                    &n,
383                    &entry_name,
384                    "enum_entry",
385                    container.clone(),
386                    source.as_bytes(),
387                );
388                declared_spans.insert((sym.start as usize, sym.end as usize));
389                symbol_by_name.insert(entry_name, sym.id.clone());
390                symbols.push(sym);
391            }
392        }
393        let mut cursor = n.walk();
394        for child in n.children(&mut cursor) {
395            stack.push(child);
396        }
397    }
398}
399
400#[allow(clippy::too_many_arguments)]
401fn handle_object(
402    path: &Path,
403    source: &str,
404    node: &Node,
405    container: Option<String>,
406    symbols: &mut Vec<SymbolRecord>,
407    edges: &mut Vec<EdgeRecord>,
408    declared_spans: &mut HashSet<(usize, usize)>,
409    symbol_by_name: &mut HashMap<String, String>,
410) {
411    if let Some(name) = find_name(node, source) {
412        let sym = make_symbol(
413            path,
414            node,
415            &name,
416            "object",
417            container.clone(),
418            source.as_bytes(),
419        );
420        declared_spans.insert((sym.start as usize, sym.end as usize));
421        symbol_by_name.insert(name.clone(), sym.id.clone());
422
423        // Objects can also implement interfaces
424        record_inheritance_edges(path, source, node, &sym.id, edges, symbol_by_name);
425
426        symbols.push(sym);
427    }
428}
429
430#[allow(clippy::too_many_arguments)]
431fn handle_interface(
432    path: &Path,
433    source: &str,
434    node: &Node,
435    container: Option<String>,
436    symbols: &mut Vec<SymbolRecord>,
437    edges: &mut Vec<EdgeRecord>,
438    declared_spans: &mut HashSet<(usize, usize)>,
439    symbol_by_name: &mut HashMap<String, String>,
440) {
441    if let Some(name) = find_name(node, source) {
442        let sym = make_symbol(
443            path,
444            node,
445            &name,
446            "interface",
447            container.clone(),
448            source.as_bytes(),
449        );
450        declared_spans.insert((sym.start as usize, sym.end as usize));
451        symbol_by_name.insert(name.clone(), sym.id.clone());
452
453        // Interfaces can extend other interfaces
454        record_inheritance_edges(path, source, node, &sym.id, edges, symbol_by_name);
455
456        symbols.push(sym);
457    }
458}
459
460fn handle_function(
461    path: &Path,
462    source: &str,
463    node: &Node,
464    container: Option<String>,
465    symbols: &mut Vec<SymbolRecord>,
466    declared_spans: &mut HashSet<(usize, usize)>,
467    symbol_by_name: &mut HashMap<String, String>,
468) {
469    if let Some(name) = find_name(node, source) {
470        // Check for receiver_type (indicates extension function)
471        let receiver_type = extract_receiver_type(node, source);
472
473        let (kind, qualifier) = if let Some(ref recv_type) = receiver_type {
474            // Extension function/method
475            let kind = if container.is_some() {
476                "extension_method"
477            } else {
478                "extension_function"
479            };
480            // Qualifier includes receiver type for searchability
481            let qual = match &container {
482                Some(c) => Some(format!("{}.{}", c, recv_type)),
483                None => Some(recv_type.clone()),
484            };
485            (kind, qual)
486        } else {
487            // Regular function/method
488            let kind = if container.is_some() {
489                "method"
490            } else {
491                "function"
492            };
493            (kind, container.clone())
494        };
495
496        let sym = make_symbol_with_qualifier(
497            path,
498            node,
499            &name,
500            kind,
501            container,
502            qualifier,
503            source.as_bytes(),
504        );
505        declared_spans.insert((sym.start as usize, sym.end as usize));
506        symbol_by_name.insert(name.clone(), sym.id.clone());
507        symbols.push(sym);
508    }
509}
510
511/// Extract the receiver type from a function declaration if it's an extension function
512fn extract_receiver_type(node: &Node, source: &str) -> Option<String> {
513    let mut cursor = node.walk();
514    for child in node.children(&mut cursor) {
515        if child.kind() == "receiver_type" {
516            // Extract the type name from the receiver_type node
517            return extract_type_name(&child, source);
518        }
519    }
520    None
521}
522
523fn handle_property(
524    path: &Path,
525    source: &str,
526    node: &Node,
527    container: Option<String>,
528    symbols: &mut Vec<SymbolRecord>,
529    declared_spans: &mut HashSet<(usize, usize)>,
530    symbol_by_name: &mut HashMap<String, String>,
531) {
532    if let Some(name) = find_property_name(node, source) {
533        // Check for receiver_type (indicates extension property)
534        let receiver_type = extract_receiver_type(node, source);
535
536        let (kind, qualifier) = if let Some(ref recv_type) = receiver_type {
537            // Extension property
538            let qual = match &container {
539                Some(c) => Some(format!("{}.{}", c, recv_type)),
540                None => Some(recv_type.clone()),
541            };
542            ("extension_property", qual)
543        } else {
544            ("property", container.clone())
545        };
546
547        let sym = make_symbol_with_qualifier(
548            path,
549            node,
550            &name,
551            kind,
552            container,
553            qualifier,
554            source.as_bytes(),
555        );
556        declared_spans.insert((sym.start as usize, sym.end as usize));
557        symbol_by_name.insert(name.clone(), sym.id.clone());
558        symbols.push(sym);
559    }
560}
561
562/// Record extends and implements edges from delegation_specifiers
563fn record_inheritance_edges(
564    path: &Path,
565    source: &str,
566    node: &Node,
567    src_id: &str,
568    edges: &mut Vec<EdgeRecord>,
569    symbol_by_name: &HashMap<String, String>,
570) {
571    // Look for delegation_specifiers (the `: BaseClass, Interface` part)
572    let mut stack = vec![*node];
573    while let Some(n) = stack.pop() {
574        if n.kind() == "delegation_specifier" || n.kind() == "user_type" {
575            // Extract the type name
576            if let Some(type_name) = extract_type_name(&n, source) {
577                // Try to resolve to known symbol, otherwise use name-based ID
578                let dst_id = symbol_by_name
579                    .get(&type_name)
580                    .cloned()
581                    .unwrap_or_else(|| format!("{}#{}", normalize_path(path), type_name));
582
583                // Determine if extends or implements (heuristic: first is usually extends for classes)
584                let kind = if type_name
585                    .chars()
586                    .next()
587                    .map(|c| c.is_uppercase())
588                    .unwrap_or(false)
589                {
590                    // Could be either - Kotlin doesn't syntactically distinguish
591                    // We'll use "extends" for the first one and "implements" for others
592                    "implements"
593                } else {
594                    "extends"
595                };
596
597                edges.push(EdgeRecord {
598                    src: src_id.to_string(),
599                    dst: dst_id,
600                    kind: kind.to_string(),
601                });
602            }
603        }
604
605        // Continue walking
606        let mut cursor = n.walk();
607        for child in n.children(&mut cursor) {
608            stack.push(child);
609        }
610    }
611}
612
613/// Extract the primary type name from a type node (e.g., "List" from "List<Int>")
614fn extract_type_name(node: &Node, source: &str) -> Option<String> {
615    // BFS approach to find the first type_identifier at the shallowest level
616    // This ensures we get "List" from "List<Int>" rather than "Int"
617    let mut queue = std::collections::VecDeque::new();
618    queue.push_back(*node);
619
620    while let Some(n) = queue.pop_front() {
621        // Check if this node is a type identifier
622        if n.kind() == "type_identifier"
623            || n.kind() == "simple_identifier"
624            || n.kind() == "identifier"
625        {
626            let name = slice(source, &n);
627            if !name.is_empty() {
628                return Some(name);
629            }
630        }
631
632        // Add children, but skip type_arguments to avoid descending into generic params
633        let mut cursor = n.walk();
634        for child in n.children(&mut cursor) {
635            // Skip type_arguments to avoid getting type params like <Int> in List<Int>
636            if child.kind() != "type_arguments" {
637                queue.push_back(child);
638            }
639        }
640    }
641    None
642}
643
644/// Collect references to symbols
645fn collect_references(
646    path: &Path,
647    source: &str,
648    root: &Node,
649    declared_spans: &HashSet<(usize, usize)>,
650    symbol_by_name: &HashMap<String, String>,
651) -> Vec<ReferenceRecord> {
652    let mut refs = Vec::new();
653    let mut stack = vec![*root];
654    let file = normalize_path(path);
655
656    while let Some(node) = stack.pop() {
657        if node.kind() == "simple_identifier" {
658            let span = (node.start_byte(), node.end_byte());
659            if !declared_spans.contains(&span) {
660                let name = slice(source, &node);
661                if let Some(sym_id) = symbol_by_name.get(&name) {
662                    refs.push(ReferenceRecord {
663                        file: file.clone(),
664                        start: node.start_byte() as i64,
665                        end: node.end_byte() as i64,
666                        symbol_id: sym_id.clone(),
667                    });
668                }
669            }
670        }
671
672        let mut cursor = node.walk();
673        for child in node.children(&mut cursor) {
674            stack.push(child);
675        }
676    }
677
678    refs
679}
680
681/// Collect import statements and create dependencies
682fn collect_imports(
683    path: &Path,
684    source: &str,
685    root: &Node,
686) -> (Vec<FileDependency>, Vec<ImportBindingInfo>) {
687    let mut dependencies = Vec::new();
688    let mut import_bindings = Vec::new();
689    let from_file = normalize_path(path);
690
691    let mut stack = vec![*root];
692    while let Some(node) = stack.pop() {
693        if node.kind() == "import_header" {
694            if let Some((import_path, alias)) = parse_import(&node, source) {
695                // For now, we create import bindings but can't resolve to files
696                // without knowing the project structure
697                let last_segment = import_path.rsplit('.').next().unwrap_or(&import_path);
698                let local_name = alias.unwrap_or_else(|| last_segment.to_string());
699
700                import_bindings.push(ImportBindingInfo {
701                    local_name,
702                    source_file: from_file.clone(), // Will need proper resolution
703                    original_name: last_segment.to_string(),
704                });
705
706                // Create a dependency record (path-based resolution would need project context)
707                dependencies.push(FileDependency {
708                    from_file: from_file.clone(),
709                    to_file: import_path,
710                    kind: "import".to_string(),
711                });
712            }
713        }
714
715        let mut cursor = node.walk();
716        for child in node.children(&mut cursor) {
717            stack.push(child);
718        }
719    }
720
721    (dependencies, import_bindings)
722}
723
724/// Parse an import statement and return (path, optional alias)
725fn parse_import(node: &Node, source: &str) -> Option<(String, Option<String>)> {
726    let mut import_path = String::new();
727    let mut alias = None;
728
729    let mut cursor = node.walk();
730    for child in node.children(&mut cursor) {
731        match child.kind() {
732            "identifier" | "simple_identifier" => {
733                if import_path.is_empty() {
734                    import_path = slice(source, &child);
735                } else {
736                    import_path.push('.');
737                    import_path.push_str(&slice(source, &child));
738                }
739            }
740            "import_alias" => {
741                // Extract alias name
742                let mut alias_cursor = child.walk();
743                for alias_child in child.children(&mut alias_cursor) {
744                    if alias_child.kind() == "simple_identifier"
745                        || alias_child.kind() == "identifier"
746                    {
747                        alias = Some(slice(source, &alias_child));
748                        break;
749                    }
750                }
751            }
752            _ => {
753                // Recurse to find identifiers in nested structures
754                let mut inner_stack = vec![child];
755                while let Some(inner) = inner_stack.pop() {
756                    if inner.kind() == "simple_identifier" || inner.kind() == "identifier" {
757                        if import_path.is_empty() {
758                            import_path = slice(source, &inner);
759                        } else {
760                            import_path.push('.');
761                            import_path.push_str(&slice(source, &inner));
762                        }
763                    }
764                    let mut inner_cursor = inner.walk();
765                    for inner_child in inner.children(&mut inner_cursor) {
766                        inner_stack.push(inner_child);
767                    }
768                }
769            }
770        }
771    }
772
773    if import_path.is_empty() {
774        None
775    } else {
776        Some((import_path, alias))
777    }
778}
779
780/// Find the name of a declaration node
781fn find_name(node: &Node, source: &str) -> Option<String> {
782    // First try field lookup
783    if let Some(name_node) = node.child_by_field_name("name") {
784        let name = slice(source, &name_node);
785        if !name.is_empty() {
786            return Some(name);
787        }
788    }
789
790    // Walk children looking for simple_identifier that's the name
791    let mut cursor = node.walk();
792    for child in node.children(&mut cursor) {
793        if child.kind() == "simple_identifier" || child.kind() == "type_identifier" {
794            let name = slice(source, &child);
795            if !name.is_empty() {
796                return Some(name);
797            }
798        }
799    }
800    None
801}
802
803/// Find property name from variable declaration
804fn find_property_name(node: &Node, source: &str) -> Option<String> {
805    // Look for variable_declaration within property
806    let mut stack = vec![*node];
807    while let Some(n) = stack.pop() {
808        if n.kind() == "variable_declaration" {
809            if let Some(name) = find_name(&n, source) {
810                return Some(name);
811            }
812        }
813        if n.kind() == "simple_identifier" {
814            let name = slice(source, &n);
815            if !name.is_empty() {
816                return Some(name);
817            }
818        }
819        let mut cursor = n.walk();
820        for child in n.children(&mut cursor) {
821            stack.push(child);
822        }
823    }
824    None
825}
826
827fn make_symbol(
828    path: &Path,
829    node: &Node,
830    name: &str,
831    kind: &str,
832    container: Option<String>,
833    source: &[u8],
834) -> SymbolRecord {
835    let visibility = extract_visibility(node);
836    let content_hash = super::compute_content_hash(source, node.start_byte(), node.end_byte());
837    let qualifier = container.as_ref().map(|c| c.to_string());
838
839    SymbolRecord {
840        id: format!(
841            "{}#{}-{}",
842            normalize_path(path),
843            node.start_byte(),
844            node.end_byte()
845        ),
846        file: normalize_path(path),
847        kind: kind.to_string(),
848        name: name.to_string(),
849        start: node.start_byte() as i64,
850        end: node.end_byte() as i64,
851        qualifier,
852        visibility,
853        container,
854        content_hash,
855    }
856}
857
858/// Create a symbol with separate qualifier (used for extension functions where
859/// qualifier shows the receiver type but container shows the enclosing class)
860fn make_symbol_with_qualifier(
861    path: &Path,
862    node: &Node,
863    name: &str,
864    kind: &str,
865    container: Option<String>,
866    qualifier: Option<String>,
867    source: &[u8],
868) -> SymbolRecord {
869    let visibility = extract_visibility(node);
870    let content_hash = super::compute_content_hash(source, node.start_byte(), node.end_byte());
871
872    SymbolRecord {
873        id: format!(
874            "{}#{}-{}",
875            normalize_path(path),
876            node.start_byte(),
877            node.end_byte()
878        ),
879        file: normalize_path(path),
880        kind: kind.to_string(),
881        name: name.to_string(),
882        start: node.start_byte() as i64,
883        end: node.end_byte() as i64,
884        qualifier,
885        visibility,
886        container,
887        content_hash,
888    }
889}
890
891/// Extract visibility modifier from a node
892fn extract_visibility(node: &Node) -> Option<String> {
893    let mut cursor = node.walk();
894    for child in node.children(&mut cursor) {
895        if child.kind() == "modifiers" {
896            let mut mod_cursor = child.walk();
897            for modifier in child.children(&mut mod_cursor) {
898                if modifier.kind() == "visibility_modifier" {
899                    let mut vis_cursor = modifier.walk();
900                    for vis in modifier.children(&mut vis_cursor) {
901                        match vis.kind() {
902                            "public" => return Some("public".to_string()),
903                            "private" => return Some("private".to_string()),
904                            "protected" => return Some("protected".to_string()),
905                            "internal" => return Some("internal".to_string()),
906                            _ => {}
907                        }
908                    }
909                }
910            }
911        }
912    }
913    // Default visibility in Kotlin is public
914    Some("public".to_string())
915}
916
917fn slice(source: &str, node: &Node) -> String {
918    let bytes = node.byte_range();
919    source.get(bytes).unwrap_or_default().trim().to_string()
920}
921
922#[cfg(test)]
923mod tests {
924    use super::*;
925    use std::fs;
926    use tempfile::tempdir;
927
928    #[test]
929    fn extracts_kotlin_symbols() {
930        let dir = tempdir().unwrap();
931        let path = dir.path().join("Test.kt");
932        let source = r#"
933            class Person(val name: String) {
934                fun greet() {
935                    println("Hello, $name")
936                }
937            }
938
939            interface Greeter {
940                fun greet()
941            }
942
943            object Singleton {
944                val instance = "single"
945            }
946
947            fun topLevel() {}
948        "#;
949        fs::write(&path, source).unwrap();
950
951        let (symbols, _edges, _refs, _deps, _imports) = index_file(&path, source).unwrap();
952        let names: Vec<_> = symbols.iter().map(|s| s.name.as_str()).collect();
953
954        assert!(names.contains(&"Person"), "Should find Person class");
955        assert!(names.contains(&"greet"), "Should find greet method");
956        assert!(names.contains(&"Greeter"), "Should find Greeter interface");
957        assert!(names.contains(&"Singleton"), "Should find Singleton object");
958        assert!(names.contains(&"topLevel"), "Should find topLevel function");
959    }
960
961    #[test]
962    fn extracts_visibility_modifiers() {
963        let dir = tempdir().unwrap();
964        let path = dir.path().join("Visibility.kt");
965        let source = r#"
966            public class PublicClass
967            private class PrivateClass
968            internal class InternalClass
969            protected class ProtectedClass
970        "#;
971        fs::write(&path, source).unwrap();
972
973        let (symbols, _edges, _refs, _deps, _imports) = index_file(&path, source).unwrap();
974
975        let public_class = symbols.iter().find(|s| s.name == "PublicClass").unwrap();
976        assert_eq!(public_class.visibility.as_deref(), Some("public"));
977
978        let private_class = symbols.iter().find(|s| s.name == "PrivateClass").unwrap();
979        assert_eq!(private_class.visibility.as_deref(), Some("private"));
980
981        let internal_class = symbols.iter().find(|s| s.name == "InternalClass").unwrap();
982        assert_eq!(internal_class.visibility.as_deref(), Some("internal"));
983    }
984
985    #[test]
986    fn captures_inheritance_edges() {
987        let dir = tempdir().unwrap();
988        let path = dir.path().join("Inheritance.kt");
989        let source = r#"
990            interface Animal {
991                fun speak()
992            }
993
994            open class Mammal
995
996            class Dog : Mammal(), Animal {
997                override fun speak() {}
998            }
999        "#;
1000        fs::write(&path, source).unwrap();
1001
1002        let (symbols, edges, _refs, _deps, _imports) = index_file(&path, source).unwrap();
1003
1004        assert!(symbols.iter().any(|s| s.name == "Dog"));
1005        assert!(symbols.iter().any(|s| s.name == "Animal"));
1006        assert!(symbols.iter().any(|s| s.name == "Mammal"));
1007
1008        // Dog should have edges to Animal and Mammal
1009        assert!(
1010            edges
1011                .iter()
1012                .any(|e| e.kind == "implements" || e.kind == "extends"),
1013            "Should have inheritance edges"
1014        );
1015    }
1016
1017    #[test]
1018    fn extracts_companion_objects() {
1019        let dir = tempdir().unwrap();
1020        let path = dir.path().join("Companion.kt");
1021        let source = r#"
1022            class Factory {
1023                companion object {
1024                    fun create(): Factory = Factory()
1025                }
1026            }
1027        "#;
1028        fs::write(&path, source).unwrap();
1029
1030        let (symbols, _edges, _refs, _deps, _imports) = index_file(&path, source).unwrap();
1031        let names: Vec<_> = symbols.iter().map(|s| s.name.as_str()).collect();
1032
1033        assert!(names.contains(&"Factory"));
1034        assert!(names.contains(&"Companion"));
1035        assert!(names.contains(&"create"));
1036    }
1037
1038    #[test]
1039    fn extracts_data_sealed_enum_classes() {
1040        let dir = tempdir().unwrap();
1041        let path = dir.path().join("SpecialClasses.kt");
1042        let source = r#"
1043data class Person(val name: String, val age: Int)
1044
1045sealed class Result {
1046    data class Success(val value: String) : Result()
1047    data class Error(val message: String) : Result()
1048}
1049
1050sealed interface Event {
1051    data class Click(val x: Int) : Event
1052    object Close : Event
1053}
1054
1055enum class Color {
1056    RED,
1057    GREEN,
1058    BLUE
1059}
1060
1061enum class Direction(val degrees: Int) {
1062    NORTH(0),
1063    EAST(90),
1064    SOUTH(180),
1065    WEST(270)
1066}
1067        "#;
1068        fs::write(&path, source).unwrap();
1069
1070        let (symbols, _edges, _refs, _deps, _imports) = index_file(&path, source).unwrap();
1071
1072        // Check data class
1073        let person = symbols.iter().find(|s| s.name == "Person").unwrap();
1074        assert_eq!(person.kind, "data_class", "Person should be a data_class");
1075
1076        // Check sealed class
1077        let result = symbols.iter().find(|s| s.name == "Result").unwrap();
1078        assert_eq!(
1079            result.kind, "sealed_class",
1080            "Result should be a sealed_class"
1081        );
1082
1083        // Check nested data classes inside sealed class
1084        let success = symbols.iter().find(|s| s.name == "Success").unwrap();
1085        assert_eq!(success.kind, "data_class", "Success should be a data_class");
1086        assert_eq!(
1087            success.container.as_deref(),
1088            Some("Result"),
1089            "Success should be inside Result"
1090        );
1091
1092        // Check sealed interface
1093        let event = symbols.iter().find(|s| s.name == "Event").unwrap();
1094        assert_eq!(
1095            event.kind, "sealed_interface",
1096            "Event should be a sealed_interface"
1097        );
1098
1099        // Check enum class
1100        let color = symbols.iter().find(|s| s.name == "Color").unwrap();
1101        assert_eq!(color.kind, "enum_class", "Color should be an enum_class");
1102
1103        // Check enum entries
1104        let red = symbols.iter().find(|s| s.name == "RED").unwrap();
1105        assert_eq!(red.kind, "enum_entry", "RED should be an enum_entry");
1106        assert_eq!(
1107            red.container.as_deref(),
1108            Some("Color"),
1109            "RED should be inside Color"
1110        );
1111
1112        let green = symbols.iter().find(|s| s.name == "GREEN").unwrap();
1113        assert_eq!(green.kind, "enum_entry", "GREEN should be an enum_entry");
1114
1115        // Check Direction enum
1116        let direction = symbols.iter().find(|s| s.name == "Direction").unwrap();
1117        assert_eq!(
1118            direction.kind, "enum_class",
1119            "Direction should be an enum_class"
1120        );
1121
1122        let north = symbols.iter().find(|s| s.name == "NORTH").unwrap();
1123        assert_eq!(north.kind, "enum_entry", "NORTH should be an enum_entry");
1124        assert_eq!(
1125            north.container.as_deref(),
1126            Some("Direction"),
1127            "NORTH should be inside Direction"
1128        );
1129    }
1130
1131    #[test]
1132    fn extracts_extension_functions_and_properties() {
1133        let dir = tempdir().unwrap();
1134        let path = dir.path().join("Extensions.kt");
1135        let source = r#"
1136fun String.addExclamation() = "$this!"
1137
1138fun List<Int>.sum(): Int = this.fold(0) { acc, i -> acc + i }
1139
1140fun <T> MutableList<T>.swap(i: Int, j: Int) {
1141    val tmp = this[i]
1142    this[i] = this[j]
1143    this[j] = tmp
1144}
1145
1146val String.lastChar: Char
1147    get() = this[length - 1]
1148
1149class StringUtils {
1150    fun String.toTitleCase(): String = this.capitalize()
1151}
1152        "#;
1153        fs::write(&path, source).unwrap();
1154
1155        let (symbols, _edges, _refs, _deps, _imports) = index_file(&path, source).unwrap();
1156
1157        // Check extension function
1158        let add_excl = symbols.iter().find(|s| s.name == "addExclamation").unwrap();
1159        assert_eq!(
1160            add_excl.kind, "extension_function",
1161            "addExclamation should be an extension_function"
1162        );
1163        assert_eq!(
1164            add_excl.qualifier.as_deref(),
1165            Some("String"),
1166            "addExclamation should have String as qualifier"
1167        );
1168
1169        // Check extension function on generic type
1170        let sum = symbols.iter().find(|s| s.name == "sum").unwrap();
1171        assert_eq!(
1172            sum.kind, "extension_function",
1173            "sum should be an extension_function"
1174        );
1175        assert_eq!(
1176            sum.qualifier.as_deref(),
1177            Some("List"),
1178            "sum should have List as qualifier"
1179        );
1180
1181        // Check generic extension function
1182        let swap = symbols.iter().find(|s| s.name == "swap").unwrap();
1183        assert_eq!(
1184            swap.kind, "extension_function",
1185            "swap should be an extension_function"
1186        );
1187        assert_eq!(
1188            swap.qualifier.as_deref(),
1189            Some("MutableList"),
1190            "swap should have MutableList as qualifier"
1191        );
1192
1193        // Check extension property
1194        let last_char = symbols.iter().find(|s| s.name == "lastChar").unwrap();
1195        assert_eq!(
1196            last_char.kind, "extension_property",
1197            "lastChar should be an extension_property"
1198        );
1199        assert_eq!(
1200            last_char.qualifier.as_deref(),
1201            Some("String"),
1202            "lastChar should have String as qualifier"
1203        );
1204
1205        // Check extension function defined inside a class
1206        let to_title = symbols.iter().find(|s| s.name == "toTitleCase").unwrap();
1207        assert_eq!(
1208            to_title.kind, "extension_method",
1209            "toTitleCase should be an extension_method"
1210        );
1211        assert_eq!(
1212            to_title.container.as_deref(),
1213            Some("StringUtils"),
1214            "toTitleCase should be inside StringUtils"
1215        );
1216        assert_eq!(
1217            to_title.qualifier.as_deref(),
1218            Some("StringUtils.String"),
1219            "toTitleCase should have StringUtils.String as qualifier"
1220        );
1221    }
1222
1223    #[test]
1224    fn extracts_constructor_properties() {
1225        let dir = tempdir().unwrap();
1226        let path = dir.path().join("Constructors.kt");
1227        let source = r#"
1228class Person(val name: String, var age: Int, email: String)
1229
1230data class User(val id: Long, val username: String)
1231
1232class Config(private val secret: String, public val host: String)
1233        "#;
1234        fs::write(&path, source).unwrap();
1235
1236        let (symbols, _edges, _refs, _deps, _imports) = index_file(&path, source).unwrap();
1237
1238        // Check Person class
1239        let person = symbols.iter().find(|s| s.name == "Person").unwrap();
1240        assert_eq!(person.kind, "class");
1241
1242        // Check constructor properties
1243        let name = symbols.iter().find(|s| s.name == "name").unwrap();
1244        assert_eq!(name.kind, "property", "name should be a property");
1245        assert_eq!(
1246            name.container.as_deref(),
1247            Some("Person"),
1248            "name should be inside Person"
1249        );
1250
1251        let age = symbols.iter().find(|s| s.name == "age").unwrap();
1252        assert_eq!(age.kind, "property", "age should be a property");
1253        assert_eq!(
1254            age.container.as_deref(),
1255            Some("Person"),
1256            "age should be inside Person"
1257        );
1258
1259        // email is NOT a property (no val/var)
1260        assert!(
1261            !symbols.iter().any(|s| s.name == "email"),
1262            "email should not be extracted (no val/var)"
1263        );
1264
1265        // Check data class properties
1266        let id = symbols.iter().find(|s| s.name == "id").unwrap();
1267        assert_eq!(id.kind, "property", "id should be a property");
1268        assert_eq!(
1269            id.container.as_deref(),
1270            Some("User"),
1271            "id should be inside User"
1272        );
1273
1274        let username = symbols.iter().find(|s| s.name == "username").unwrap();
1275        assert_eq!(username.kind, "property", "username should be a property");
1276
1277        // Check properties with visibility modifiers
1278        let secret = symbols.iter().find(|s| s.name == "secret").unwrap();
1279        assert_eq!(secret.kind, "property", "secret should be a property");
1280        assert_eq!(
1281            secret.visibility.as_deref(),
1282            Some("private"),
1283            "secret should be private"
1284        );
1285
1286        let host = symbols.iter().find(|s| s.name == "host").unwrap();
1287        assert_eq!(host.kind, "property", "host should be a property");
1288        assert_eq!(
1289            host.visibility.as_deref(),
1290            Some("public"),
1291            "host should be public"
1292        );
1293    }
1294}