Skip to main content

gobby_code/index/
parser.rs

1//! Tree-sitter AST parsing for symbol, import, and call extraction.
2//! Ports logic from src/gobby/code_index/parser.py.
3
4use std::collections::HashSet;
5use std::path::Path;
6
7use streaming_iterator::StreamingIterator;
8use tree_sitter::{Parser, Query, QueryCursor};
9
10use crate::index::hasher::symbol_content_hash;
11use crate::index::import_resolution::{self, ExtractedImports, ImportBindings};
12use crate::index::languages;
13use crate::index::security;
14use crate::index::semantic::{SemanticCallRequest, SemanticCallResolver};
15use crate::models::{CallRelation, ParseResult, Symbol};
16
17pub use crate::index::import_resolution::{
18    ImportResolutionContext, build_import_resolution_context,
19};
20
21/// Maximum file size to index (10 MB).
22const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25enum CallSyntaxKind {
26    Bare,
27    Member,
28    Other,
29}
30
31pub(crate) fn parse_file_with_semantic(
32    file_path: &Path,
33    project_id: &str,
34    root_path: &Path,
35    exclude_patterns: &[String],
36    import_context: &ImportResolutionContext,
37    semantic_resolver: Option<&mut (dyn SemanticCallResolver + '_)>,
38) -> anyhow::Result<Option<ParseResult>> {
39    // Security checks
40    if !security::validate_path(file_path, root_path) {
41        return Ok(None);
42    }
43    if !security::is_symlink_safe(file_path, root_path) {
44        return Ok(None);
45    }
46    if security::should_exclude_path(root_path, file_path, exclude_patterns) {
47        return Ok(None);
48    }
49    if security::has_secret_extension(file_path) {
50        return Ok(None);
51    }
52
53    let Ok(meta) = file_path.metadata() else {
54        return Ok(None);
55    };
56    if meta.len() == 0 || meta.len() > MAX_FILE_SIZE {
57        return Ok(None);
58    }
59
60    if security::is_binary(file_path) {
61        return Ok(None);
62    }
63
64    let file_str = file_path.to_string_lossy();
65    let Some(language) = languages::detect_language(&file_str) else {
66        return Ok(None);
67    };
68    let Some(spec) = languages::get_spec(language) else {
69        return Ok(None);
70    };
71    let Some(ts_lang) = languages::get_ts_language(language) else {
72        return Ok(None);
73    };
74
75    let Ok(source) = std::fs::read(file_path) else {
76        return Ok(None);
77    };
78
79    let mut parser = Parser::new();
80    if parser.set_language(&ts_lang).is_err() {
81        return Ok(None);
82    }
83    let Some(tree) = parser.parse(&source, None) else {
84        return Ok(None);
85    };
86
87    let rel_path = file_path
88        .canonicalize()
89        .ok()
90        .and_then(|abs| {
91            root_path.canonicalize().ok().and_then(|root| {
92                abs.strip_prefix(&root)
93                    .ok()
94                    .map(|p| p.to_string_lossy().to_string())
95            })
96        })
97        .unwrap_or_else(|| file_str.to_string());
98
99    let mut symbols = extract_symbols(
100        &tree, &source, spec, language, &ts_lang, project_id, &rel_path,
101    );
102    link_parents(&mut symbols);
103    let extracted_imports = extract_imports(
104        &tree,
105        &source,
106        spec,
107        language,
108        &ts_lang,
109        &rel_path,
110        import_context,
111    );
112    let calls = extract_calls(
113        &tree,
114        &source,
115        spec,
116        CallExtractionContext {
117            language,
118            ts_lang: &ts_lang,
119            rel_path: &rel_path,
120            symbols: &symbols,
121            import_context,
122            import_bindings: &extracted_imports.bindings,
123            file_path,
124            root_path,
125        },
126        semantic_resolver,
127    )?;
128
129    Ok(Some(ParseResult {
130        symbols,
131        imports: extracted_imports.imports,
132        calls,
133        source,
134    }))
135}
136
137fn extract_symbols(
138    tree: &tree_sitter::Tree,
139    source: &[u8],
140    spec: &languages::LanguageSpec,
141    language: &str,
142    ts_lang: &tree_sitter::Language,
143    project_id: &str,
144    rel_path: &str,
145) -> Vec<Symbol> {
146    if spec.symbol_query.trim().is_empty() {
147        return Vec::new();
148    }
149
150    let query = match Query::new(ts_lang, spec.symbol_query) {
151        Ok(q) => q,
152        Err(_) => return Vec::new(),
153    };
154
155    let mut cursor = QueryCursor::new();
156    let mut matches = cursor.matches(&query, tree.root_node(), source);
157
158    let mut symbols = Vec::new();
159    let mut seen_ids = HashSet::new();
160    let capture_names: Vec<String> = query
161        .capture_names()
162        .iter()
163        .map(|s| s.to_string())
164        .collect();
165
166    while let Some(m) = matches.next() {
167        let mut name_text: Option<String> = None;
168        let mut def_node = None;
169        let mut kind = String::from("function");
170
171        for cap in m.captures {
172            let cap_name = &capture_names[cap.index as usize];
173            if cap_name == "name" {
174                name_text = Some(
175                    String::from_utf8_lossy(&source[cap.node.start_byte()..cap.node.end_byte()])
176                        .to_string(),
177                );
178            } else if let Some(k) = cap_name.strip_prefix("definition.") {
179                def_node = Some(cap.node);
180                kind = k.to_string();
181            }
182        }
183
184        let (name, node) = match (name_text, def_node) {
185            (Some(n), Some(d)) => (n, d),
186            _ => continue,
187        };
188
189        // Signature: first line of definition
190        let sig_end = source[node.start_byte()..]
191            .iter()
192            .position(|&b| b == b'\n')
193            .map(|p| node.start_byte() + p)
194            .unwrap_or(node.end_byte());
195        let mut signature = String::from_utf8_lossy(&source[node.start_byte()..sig_end])
196            .trim()
197            .to_string();
198        if signature.len() > 200 {
199            signature.truncate(200);
200            signature.push_str("...");
201        }
202
203        let docstring = extract_docstring(&node, source, language);
204        let c_hash =
205            symbol_content_hash(source, node.start_byte(), node.end_byte()).unwrap_or_default();
206        let symbol_id = Symbol::make_id(project_id, rel_path, &name, &kind, node.start_byte());
207
208        if seen_ids.contains(&symbol_id) {
209            continue;
210        }
211        seen_ids.insert(symbol_id.clone());
212
213        symbols.push(Symbol {
214            id: symbol_id,
215            project_id: project_id.to_string(),
216            file_path: rel_path.to_string(),
217            name: name.clone(),
218            qualified_name: name,
219            kind,
220            language: language.to_string(),
221            byte_start: node.start_byte(),
222            byte_end: node.end_byte(),
223            line_start: node.start_position().row + 1,
224            line_end: node.end_position().row + 1,
225            signature: Some(signature),
226            docstring,
227            parent_symbol_id: None,
228            content_hash: c_hash,
229            summary: None,
230            created_at: String::new(),
231            updated_at: String::new(),
232        });
233    }
234
235    symbols
236}
237
238fn link_parents(symbols: &mut [Symbol]) {
239    let mut indices: Vec<usize> = (0..symbols.len()).collect();
240    indices.sort_by_key(|&i| symbols[i].byte_start);
241
242    for idx in 0..indices.len() {
243        let i = indices[idx];
244        for jdx in (0..idx).rev() {
245            let j = indices[jdx];
246            let parent_kind = symbols[j].kind.as_str();
247            if (parent_kind == "class" || parent_kind == "type")
248                && symbols[j].byte_start <= symbols[i].byte_start
249                && symbols[j].byte_end >= symbols[i].byte_end
250            {
251                let parent_name = symbols[j].name.clone();
252                let parent_id = symbols[j].id.clone();
253                let sym = &mut symbols[i];
254                sym.parent_symbol_id = Some(parent_id);
255                sym.qualified_name = format!("{}.{}", parent_name, sym.name);
256                if sym.kind == "function" {
257                    sym.kind = "method".to_string();
258                }
259                break;
260            }
261        }
262    }
263}
264
265fn extract_docstring(node: &tree_sitter::Node, source: &[u8], language: &str) -> Option<String> {
266    if !matches!(language, "python" | "javascript" | "typescript") {
267        return None;
268    }
269
270    let mut body = None;
271    let mut walk = node.walk();
272    for child in node.children(&mut walk) {
273        let ty = child.kind();
274        if ty == "block" || ty == "statement_block" {
275            body = Some(child);
276            break;
277        }
278    }
279    let body = body?;
280
281    let mut walk2 = body.walk();
282    for child in body.children(&mut walk2) {
283        let ty = child.kind();
284        if ty == "comment" || ty == "\n" || ty == "newline" {
285            continue;
286        }
287
288        let string_node = if ty == "string" {
289            Some(child)
290        } else if ty == "expression_statement" {
291            let mut w3 = child.walk();
292            child.children(&mut w3).find(|gc| gc.kind() == "string")
293        } else {
294            None
295        };
296
297        let string_node = string_node?;
298
299        // Try string_content child first
300        let mut w4 = string_node.walk();
301        for sc in string_node.children(&mut w4) {
302            if sc.kind() == "string_content" {
303                let raw = String::from_utf8_lossy(&source[sc.start_byte()..sc.end_byte()]);
304                let trimmed = raw.trim();
305                return if trimmed.is_empty() {
306                    None
307                } else {
308                    Some(trimmed.to_string())
309                };
310            }
311        }
312
313        // Fallback: strip quotes
314        let raw =
315            String::from_utf8_lossy(&source[string_node.start_byte()..string_node.end_byte()]);
316        let raw = raw.trim();
317        let stripped = strip_quotes(raw);
318        return if stripped.is_empty() {
319            None
320        } else {
321            Some(stripped.to_string())
322        };
323    }
324
325    None
326}
327
328fn strip_quotes(s: &str) -> &str {
329    for q in &["\"\"\"", "'''", "\"", "'"] {
330        if s.starts_with(q) && s.ends_with(q) && s.len() >= q.len() * 2 {
331            return s[q.len()..s.len() - q.len()].trim();
332        }
333    }
334    s
335}
336
337fn extract_imports(
338    tree: &tree_sitter::Tree,
339    source: &[u8],
340    spec: &languages::LanguageSpec,
341    language: &str,
342    ts_lang: &tree_sitter::Language,
343    rel_path: &str,
344    import_context: &ImportResolutionContext,
345) -> ExtractedImports {
346    if spec.import_query.trim().is_empty() {
347        return ExtractedImports::default();
348    }
349
350    let query = match Query::new(ts_lang, spec.import_query) {
351        Ok(q) => q,
352        Err(_) => return ExtractedImports::default(),
353    };
354
355    let mut cursor = QueryCursor::new();
356    let mut matches = cursor.matches(&query, tree.root_node(), source);
357    let capture_names: Vec<String> = query
358        .capture_names()
359        .iter()
360        .map(|s| s.to_string())
361        .collect();
362    let mut extracted = ExtractedImports::default();
363
364    while let Some(m) = matches.next() {
365        for cap in m.captures {
366            let cap_name = &capture_names[cap.index as usize];
367            if cap_name == "import" {
368                let text =
369                    String::from_utf8_lossy(&source[cap.node.start_byte()..cap.node.end_byte()])
370                        .trim()
371                        .to_string();
372                import_resolution::parse_import_statement(
373                    language,
374                    &text,
375                    rel_path,
376                    import_context,
377                    &mut extracted,
378                );
379            }
380        }
381    }
382
383    import_resolution::seed_import_bindings(language, import_context, &mut extracted.bindings);
384    extracted
385}
386
387struct CallExtractionContext<'a> {
388    language: &'a str,
389    ts_lang: &'a tree_sitter::Language,
390    rel_path: &'a str,
391    symbols: &'a [Symbol],
392    import_context: &'a ImportResolutionContext,
393    import_bindings: &'a ImportBindings,
394    file_path: &'a Path,
395    root_path: &'a Path,
396}
397
398fn extract_calls(
399    tree: &tree_sitter::Tree,
400    source: &[u8],
401    spec: &languages::LanguageSpec,
402    ctx: CallExtractionContext<'_>,
403    mut semantic_resolver: Option<&mut (dyn SemanticCallResolver + '_)>,
404) -> anyhow::Result<Vec<CallRelation>> {
405    let language = ctx.language;
406    let rel_path = ctx.rel_path;
407    let symbols = ctx.symbols;
408    let import_context = ctx.import_context;
409    let import_bindings = ctx.import_bindings;
410    if language == "dart" {
411        return extract_textual_dart_calls(source, ctx, semantic_resolver);
412    }
413    if spec.call_query.trim().is_empty() {
414        return Ok(Vec::new());
415    }
416
417    let query = match Query::new(ctx.ts_lang, spec.call_query) {
418        Ok(q) => q,
419        Err(_) => return Ok(Vec::new()),
420    };
421
422    let mut cursor = QueryCursor::new();
423    let mut matches = cursor.matches(&query, tree.root_node(), source);
424    let capture_names: Vec<String> = query
425        .capture_names()
426        .iter()
427        .map(|s| s.to_string())
428        .collect();
429    let mut calls = Vec::new();
430
431    while let Some(m) = matches.next() {
432        let mut name_node = None;
433        let mut call_node = None;
434
435        for cap in m.captures {
436            let cap_name = &capture_names[cap.index as usize];
437            if cap_name == "name" {
438                name_node = Some(cap.node);
439            } else if cap_name == "call" {
440                call_node = Some(cap.node);
441            }
442        }
443
444        let name_n = match name_node {
445            Some(n) => n,
446            None => continue,
447        };
448
449        let raw_callee =
450            String::from_utf8_lossy(&source[name_n.start_byte()..name_n.end_byte()]).to_string();
451        let (callee_name, qualifier_from_name) = split_qualified_callee(&raw_callee);
452        if should_ignore_call_name(language, &callee_name) {
453            continue;
454        }
455
456        let target = call_node.unwrap_or(name_n);
457        let caller_symbol = enclosing_symbol(symbols, target.start_byte());
458        let caller_symbol_id = caller_symbol.map(|s| s.id.clone()).unwrap_or_default();
459        // If the captured callee is already qualified, trust that text over a
460        // prefix inferred from the wider call node.
461        let qualifier_path = call_qualifier_path(qualifier_from_name, || {
462            member_qualifier_path(source, target, name_n)
463        });
464        let detected_syntax = call_syntax_kind(name_n, target);
465        let syntax = if detected_syntax == CallSyntaxKind::Bare && qualifier_path.is_some() {
466            CallSyntaxKind::Member
467        } else {
468            detected_syntax
469        };
470        let local_target = resolve_same_file_callee_for_language(
471            language,
472            symbols,
473            caller_symbol,
474            &callee_name,
475            syntax,
476        );
477        let root_alias = qualifier_path
478            .as_deref()
479            .and_then(qualifier_root_alias)
480            .map(ToOwned::to_owned);
481        let external_shadowed = external_call_is_shadowed(
482            source,
483            caller_symbol,
484            target.start_byte(),
485            &callee_name,
486            root_alias.as_deref(),
487            syntax,
488        );
489        let external_target = if external_shadowed {
490            None
491        } else {
492            import_resolution::resolve_external_callee(
493                import_context,
494                import_bindings,
495                symbols,
496                &callee_name,
497                root_alias.as_deref(),
498                qualifier_path.as_deref(),
499                syntax == CallSyntaxKind::Bare,
500            )
501        };
502        let semantic_target =
503            if local_target.is_none() && external_target.is_none() && !external_shadowed {
504                if let Some(resolver) = semantic_resolver.as_deref_mut() {
505                    resolver.resolve(&SemanticCallRequest {
506                        language,
507                        file_path: ctx.file_path,
508                        root_path: ctx.root_path,
509                        source,
510                        callee_name: &callee_name,
511                        line: name_n.start_position().row + 1,
512                        column: utf16_column_at_byte(source, name_n.start_byte()),
513                    })?
514                } else {
515                    None
516                }
517            } else {
518                None
519            };
520
521        let mut call = CallRelation::new(
522            caller_symbol_id,
523            callee_name,
524            rel_path.to_string(),
525            name_n.start_position().row + 1,
526        );
527        match (local_target, external_target) {
528            (Some(callee_symbol_id), None) => {
529                call = call.with_symbol_target(callee_symbol_id);
530            }
531            (None, Some(external_target)) => {
532                call =
533                    call.with_external_target(external_target.callee_name, external_target.module);
534            }
535            (None, None) => {
536                if let Some(semantic_target) = semantic_target {
537                    call = call.with_external_target(
538                        semantic_target.callee_name,
539                        semantic_target.external_module,
540                    );
541                }
542            }
543            _ => {}
544        }
545        calls.push(call);
546    }
547
548    Ok(calls)
549}
550
551fn extract_textual_dart_calls(
552    source: &[u8],
553    ctx: CallExtractionContext<'_>,
554    mut semantic_resolver: Option<&mut (dyn SemanticCallResolver + '_)>,
555) -> anyhow::Result<Vec<CallRelation>> {
556    let rel_path = ctx.rel_path;
557    let symbols = ctx.symbols;
558    let import_context = ctx.import_context;
559    let import_bindings = ctx.import_bindings;
560    let file_path = ctx.file_path;
561    let root_path = ctx.root_path;
562    let text = String::from_utf8_lossy(source);
563    let mut calls = Vec::new();
564    let mut line_start_byte = 0usize;
565    let mut dart_state = DartScanState::default();
566
567    for (row, line) in text.lines().enumerate() {
568        let terminator_len = line_terminator_len(&text, line_start_byte, line.len());
569        let trimmed = line.trim_start();
570        if dart_state.is_code()
571            && (trimmed.starts_with("import ")
572                || trimmed.starts_with("export ")
573                || trimmed.starts_with("class ")
574                || trimmed.starts_with("enum ")
575                || trimmed.starts_with("typedef "))
576        {
577            dart_state = dart_state_after_line(line, dart_state);
578            line_start_byte += line.len() + terminator_len;
579            continue;
580        }
581
582        for candidate in textual_call_candidates(line, line_start_byte, &['.']) {
583            let candidate_line_byte = candidate.call_byte.saturating_sub(line_start_byte);
584            if dart_textual_candidate_in_ignored_context(line, candidate_line_byte, dart_state) {
585                continue;
586            }
587            if should_ignore_call_name("dart", &candidate.name) {
588                continue;
589            }
590            let caller_symbol = enclosing_symbol(symbols, candidate.call_byte);
591            let caller_symbol_id = caller_symbol.map(|s| s.id.clone()).unwrap_or_default();
592            let syntax = if candidate.qualifier_path.is_some() {
593                CallSyntaxKind::Member
594            } else {
595                CallSyntaxKind::Bare
596            };
597            let local_target = resolve_same_file_callee_for_language(
598                "dart",
599                symbols,
600                caller_symbol,
601                &candidate.name,
602                syntax,
603            );
604            let root_alias = candidate
605                .qualifier_path
606                .as_deref()
607                .and_then(qualifier_root_alias)
608                .map(ToOwned::to_owned);
609            let external_shadowed = external_call_is_shadowed(
610                source,
611                caller_symbol,
612                candidate.call_byte,
613                &candidate.name,
614                root_alias.as_deref(),
615                syntax,
616            );
617            let external_target = if external_shadowed {
618                None
619            } else {
620                import_resolution::resolve_external_callee(
621                    import_context,
622                    import_bindings,
623                    symbols,
624                    &candidate.name,
625                    root_alias.as_deref(),
626                    candidate.qualifier_path.as_deref(),
627                    syntax == CallSyntaxKind::Bare,
628                )
629            };
630            let semantic_target =
631                if local_target.is_none() && external_target.is_none() && !external_shadowed {
632                    if let Some(resolver) = semantic_resolver.as_deref_mut() {
633                        resolver.resolve(&SemanticCallRequest {
634                            language: "dart",
635                            file_path,
636                            root_path,
637                            source,
638                            callee_name: &candidate.name,
639                            line: row + 1,
640                            column: utf16_column_at_byte(source, candidate.call_byte),
641                        })?
642                    } else {
643                        None
644                    }
645                } else {
646                    None
647                };
648
649            let mut call = CallRelation::new(
650                caller_symbol_id,
651                candidate.name,
652                rel_path.to_string(),
653                row + 1,
654            );
655            match (local_target, external_target) {
656                (Some(callee_symbol_id), None) => {
657                    call = call.with_symbol_target(callee_symbol_id);
658                }
659                (None, Some(external_target)) => {
660                    call = call
661                        .with_external_target(external_target.callee_name, external_target.module);
662                }
663                (None, None) => {
664                    if let Some(semantic_target) = semantic_target {
665                        call = call.with_external_target(
666                            semantic_target.callee_name,
667                            semantic_target.external_module,
668                        );
669                    }
670                }
671                _ => {}
672            }
673            calls.push(call);
674        }
675
676        dart_state = dart_state_after_line(line, dart_state);
677        line_start_byte += line.len() + terminator_len;
678    }
679
680    Ok(calls)
681}
682
683fn line_terminator_len(text: &str, line_start_byte: usize, line_len: usize) -> usize {
684    let terminator_start = line_start_byte + line_len;
685    let Some(rest) = text.as_bytes().get(terminator_start..) else {
686        return 0;
687    };
688    if rest.starts_with(b"\r\n") {
689        2
690    } else if rest.starts_with(b"\n") {
691        1
692    } else {
693        0
694    }
695}
696
697fn utf16_column_at_byte(source: &[u8], byte_offset: usize) -> usize {
698    let byte_offset = byte_offset.min(source.len());
699    let line_start = source[..byte_offset]
700        .iter()
701        .rposition(|byte| *byte == b'\n')
702        .map(|idx| idx + 1)
703        .unwrap_or(0);
704    String::from_utf8_lossy(&source[line_start..byte_offset])
705        .encode_utf16()
706        .count()
707}
708
709#[derive(Debug)]
710struct TextualCallCandidate {
711    name: String,
712    qualifier_path: Option<String>,
713    call_byte: usize,
714}
715
716fn textual_call_candidates(
717    line: &str,
718    line_start_byte: usize,
719    separators: &[char],
720) -> Vec<TextualCallCandidate> {
721    let bytes = line.as_bytes();
722    let mut candidates = Vec::new();
723    let mut idx = 0usize;
724
725    while idx < bytes.len() {
726        if bytes[idx] != b'(' {
727            idx += 1;
728            continue;
729        }
730        let mut end = idx;
731        while end > 0 && bytes[end - 1].is_ascii_whitespace() {
732            end -= 1;
733        }
734        let (start, name_end) = if end > 0 && bytes[end - 1] == b'>' {
735            let Some(generic_start) = matching_angle_start(bytes, end - 1) else {
736                idx += 1;
737                continue;
738            };
739            let mut start = generic_start;
740            while start > 0 && is_textual_call_name_byte(bytes[start - 1]) {
741                start -= 1;
742            }
743            (start, generic_start)
744        } else {
745            let mut start = end;
746            while start > 0 && is_textual_call_name_byte(bytes[start - 1]) {
747                start -= 1;
748            }
749            (start, end)
750        };
751        if start == end {
752            idx += 1;
753            continue;
754        }
755
756        let name = &line[start..name_end];
757        if name.is_empty() {
758            idx += 1;
759            continue;
760        }
761        if looks_like_textual_function_declaration(line, start, idx) {
762            idx += 1;
763            continue;
764        }
765        let mut qualifier_path = None;
766        let mut prefix_end = start;
767        while prefix_end > 0 && bytes[prefix_end - 1].is_ascii_whitespace() {
768            prefix_end -= 1;
769        }
770        if prefix_end > 0 && separators.contains(&(bytes[prefix_end - 1] as char)) {
771            let mut qualifier_start = prefix_end - 1;
772            while qualifier_start > 0 {
773                let ch = bytes[qualifier_start - 1] as char;
774                if is_identifier_continue(ch) || separators.contains(&ch) {
775                    qualifier_start -= 1;
776                } else {
777                    break;
778                }
779            }
780            let qualifier = line[qualifier_start..prefix_end - 1].trim();
781            if !qualifier.is_empty() {
782                qualifier_path = Some(qualifier.to_string());
783            }
784        }
785
786        candidates.push(TextualCallCandidate {
787            name: name.to_string(),
788            qualifier_path,
789            call_byte: line_start_byte + start,
790        });
791        idx += 1;
792    }
793
794    candidates
795}
796
797fn matching_angle_start(bytes: &[u8], close_idx: usize) -> Option<usize> {
798    let mut depth = 0usize;
799    for idx in (0..=close_idx).rev() {
800        match bytes[idx] {
801            b'>' => depth += 1,
802            b'<' if depth > 0 => {
803                depth -= 1;
804                if depth == 0 {
805                    return Some(idx);
806                }
807            }
808            _ => {}
809        }
810    }
811    None
812}
813
814#[derive(Debug, Clone, Copy, Default)]
815struct DartScanState {
816    in_block_comment: bool,
817    string: Option<DartStringState>,
818}
819
820impl DartScanState {
821    fn is_code(self) -> bool {
822        !self.in_block_comment && self.string.is_none()
823    }
824}
825
826#[derive(Debug, Clone, Copy)]
827struct DartStringState {
828    quote: u8,
829    triple: bool,
830    raw: bool,
831    escaped: bool,
832}
833
834fn dart_textual_candidate_in_ignored_context(
835    line: &str,
836    candidate_byte: usize,
837    state: DartScanState,
838) -> bool {
839    let (state, in_line_comment) = dart_scan_line_until(line, candidate_byte, state);
840    in_line_comment || !state.is_code()
841}
842
843fn dart_state_after_line(line: &str, state: DartScanState) -> DartScanState {
844    dart_scan_line_until(line, line.len(), state).0
845}
846
847fn dart_scan_line_until(
848    line: &str,
849    limit: usize,
850    mut state: DartScanState,
851) -> (DartScanState, bool) {
852    let bytes = line.as_bytes();
853    let limit = limit.min(bytes.len());
854    let mut idx = 0usize;
855
856    while idx < limit {
857        if state.in_block_comment {
858            if bytes[idx..].starts_with(b"*/") {
859                state.in_block_comment = false;
860                idx += 2;
861            } else {
862                idx += 1;
863            }
864            continue;
865        }
866
867        if let Some(mut string) = state.string {
868            if string.triple
869                && bytes[idx..].starts_with(&[string.quote, string.quote, string.quote])
870            {
871                state.string = None;
872                idx += 3;
873                continue;
874            }
875            if !string.triple {
876                if !string.raw && string.escaped {
877                    string.escaped = false;
878                } else if !string.raw && bytes[idx] == b'\\' {
879                    string.escaped = true;
880                } else if bytes[idx] == string.quote {
881                    state.string = None;
882                    idx += 1;
883                    continue;
884                }
885                state.string = Some(string);
886            }
887            idx += 1;
888            continue;
889        }
890
891        if bytes[idx..].starts_with(b"//") {
892            return (state, true);
893        }
894        if bytes[idx..].starts_with(b"/*") {
895            state.in_block_comment = true;
896            idx += 2;
897            continue;
898        }
899        if let Some((string, consumed)) = dart_string_start(bytes, idx) {
900            state.string = Some(string);
901            idx += consumed;
902            continue;
903        }
904        idx += 1;
905    }
906
907    (state, false)
908}
909
910fn dart_string_start(bytes: &[u8], idx: usize) -> Option<(DartStringState, usize)> {
911    let (raw, quote_idx) =
912        if bytes.get(idx) == Some(&b'r') && matches!(bytes.get(idx + 1), Some(b'\'' | b'"')) {
913            (true, idx + 1)
914        } else if matches!(bytes.get(idx), Some(b'\'' | b'"')) {
915            (false, idx)
916        } else {
917            return None;
918        };
919    let quote = bytes[quote_idx];
920    let triple = bytes
921        .get(quote_idx..quote_idx + 3)
922        .is_some_and(|slice| slice == [quote, quote, quote]);
923    Some((
924        DartStringState {
925            quote,
926            triple,
927            raw,
928            escaped: false,
929        },
930        (if raw { 1 } else { 0 }) + if triple { 3 } else { 1 },
931    ))
932}
933
934fn looks_like_textual_function_declaration(
935    line: &str,
936    name_start: usize,
937    open_paren: usize,
938) -> bool {
939    let prefix = line[..name_start].trim_end();
940    let after_paren = &line[open_paren + 1..];
941    let after_args = after_paren
942        .find(')')
943        .and_then(|close| after_paren.get(close + 1..))
944        .unwrap_or_default()
945        .trim_start();
946    let has_declaration_tail = after_args.starts_with('{')
947        || after_args.starts_with("=>")
948        || after_args.starts_with("async")
949        || after_args.starts_with("sync")
950        || after_args.starts_with("external")
951        || after_args.starts_with(';');
952    if !has_declaration_tail {
953        return false;
954    }
955
956    if prefix.is_empty() {
957        return !after_args.starts_with(';');
958    }
959    if prefix.contains(['=', '.', '(', ',', ';']) {
960        return false;
961    }
962    let Some(previous_token) = prefix.split_whitespace().last() else {
963        return false;
964    };
965    previous_token.contains('<')
966        || previous_token
967            .chars()
968            .next()
969            .is_some_and(|ch| ch.is_ascii_uppercase())
970        || matches!(
971            previous_token,
972            "void"
973                | "int"
974                | "double"
975                | "num"
976                | "String"
977                | "bool"
978                | "dynamic"
979                | "Object"
980                | "Future"
981                | "Stream"
982        )
983}
984
985fn enclosing_symbol(symbols: &[Symbol], byte_offset: usize) -> Option<&Symbol> {
986    symbols
987        .iter()
988        .rfind(|s| s.byte_start <= byte_offset && byte_offset <= s.byte_end)
989}
990
991fn call_syntax_kind(name_node: tree_sitter::Node, call_node: tree_sitter::Node) -> CallSyntaxKind {
992    let Some(mut ancestor) = name_node.parent() else {
993        return CallSyntaxKind::Other;
994    };
995    if ancestor.id() == call_node.id() {
996        return CallSyntaxKind::Bare;
997    }
998
999    loop {
1000        if is_memberish_kind(ancestor.kind()) {
1001            return CallSyntaxKind::Member;
1002        }
1003        if ancestor.id() == call_node.id() {
1004            return CallSyntaxKind::Other;
1005        }
1006        let Some(parent) = ancestor.parent() else {
1007            return CallSyntaxKind::Other;
1008        };
1009        ancestor = parent;
1010    }
1011}
1012
1013fn is_memberish_kind(kind: &str) -> bool {
1014    matches!(
1015        kind,
1016        "attribute"
1017            | "member_expression"
1018            | "selector_expression"
1019            | "field_expression"
1020            | "member_access_expression"
1021            | "member_call_expression"
1022            | "navigation_expression"
1023            | "scoped_call_expression"
1024            | "dot"
1025    )
1026}
1027
1028fn is_callable_kind(kind: &str) -> bool {
1029    matches!(kind, "function" | "method")
1030}
1031
1032fn resolve_same_file_callee(
1033    symbols: &[Symbol],
1034    caller_symbol: Option<&Symbol>,
1035    callee_name: &str,
1036    syntax: CallSyntaxKind,
1037) -> Option<String> {
1038    match syntax {
1039        CallSyntaxKind::Bare => unique_symbol_id(
1040            symbols
1041                .iter()
1042                .filter(|symbol| symbol.name == callee_name && is_callable_kind(&symbol.kind)),
1043        ),
1044        CallSyntaxKind::Member => {
1045            let parent_symbol_id =
1046                caller_symbol.and_then(|symbol| symbol.parent_symbol_id.as_deref())?;
1047            unique_symbol_id(symbols.iter().filter(|symbol| {
1048                symbol.name == callee_name
1049                    && symbol.kind == "method"
1050                    && symbol.parent_symbol_id.as_deref() == Some(parent_symbol_id)
1051            }))
1052        }
1053        CallSyntaxKind::Other => None,
1054    }
1055}
1056
1057fn resolve_same_file_callee_for_language(
1058    language: &str,
1059    symbols: &[Symbol],
1060    caller_symbol: Option<&Symbol>,
1061    callee_name: &str,
1062    syntax: CallSyntaxKind,
1063) -> Option<String> {
1064    if language == "ruby" && syntax == CallSyntaxKind::Bare {
1065        // Ruby bare calls are dynamic dispatch/open-class territory; same-file
1066        // name matching creates noisy false edges here.
1067        return None;
1068    }
1069    resolve_same_file_callee(symbols, caller_symbol, callee_name, syntax)
1070}
1071
1072fn unique_symbol_id<'a>(symbols: impl Iterator<Item = &'a Symbol>) -> Option<String> {
1073    let mut symbols = symbols;
1074    let first = symbols.next()?;
1075    if symbols.next().is_some() {
1076        None
1077    } else {
1078        Some(first.id.clone())
1079    }
1080}
1081
1082fn member_qualifier_path(
1083    source: &[u8],
1084    call_node: tree_sitter::Node,
1085    name_node: tree_sitter::Node,
1086) -> Option<String> {
1087    let prefix = String::from_utf8_lossy(&source[call_node.start_byte()..name_node.start_byte()]);
1088    let prefix = prefix.trim();
1089    if prefix.starts_with('$') || prefix.contains("->") || prefix.contains("?->") {
1090        return None;
1091    }
1092    let is_absolute_namespace = prefix.starts_with('\\');
1093
1094    let mut chars = prefix
1095        .trim_end_matches(|ch: char| ch == '.' || ch == ':' || ch == '\\' || ch.is_whitespace())
1096        .chars()
1097        .skip_while(|c| !is_identifier_start(*c));
1098    let first = chars.next()?;
1099    if !is_identifier_start(first) {
1100        return None;
1101    }
1102
1103    let mut qualifier = if is_absolute_namespace {
1104        "\\".to_string()
1105    } else {
1106        String::new()
1107    };
1108    qualifier.push(first);
1109    for ch in chars {
1110        if is_identifier_continue(ch) || matches!(ch, '.' | ':' | '\\') {
1111            qualifier.push(ch);
1112        } else {
1113            break;
1114        }
1115    }
1116
1117    let qualifier = qualifier.trim_end_matches(['.', ':', '\\']).to_string();
1118    if qualifier.is_empty() {
1119        None
1120    } else {
1121        Some(qualifier)
1122    }
1123}
1124
1125fn call_qualifier_path(
1126    qualifier_from_name: Option<String>,
1127    qualifier_from_member: impl FnOnce() -> Option<String>,
1128) -> Option<String> {
1129    qualifier_from_name.or_else(qualifier_from_member)
1130}
1131
1132fn external_call_is_shadowed(
1133    source: &[u8],
1134    caller_symbol: Option<&Symbol>,
1135    call_byte: usize,
1136    callee_name: &str,
1137    root_alias: Option<&str>,
1138    syntax: CallSyntaxKind,
1139) -> bool {
1140    let shadow_name = match syntax {
1141        CallSyntaxKind::Bare => Some(callee_name),
1142        CallSyntaxKind::Member => root_alias,
1143        CallSyntaxKind::Other => None,
1144    };
1145    let Some(shadow_name) = shadow_name.filter(|name| !name.is_empty()) else {
1146        return false;
1147    };
1148    local_name_in_scope_before_call(source, caller_symbol, call_byte, shadow_name)
1149}
1150
1151fn local_name_in_scope_before_call(
1152    source: &[u8],
1153    caller_symbol: Option<&Symbol>,
1154    call_byte: usize,
1155    name: &str,
1156) -> bool {
1157    let start = caller_symbol.map(|symbol| symbol.byte_start).unwrap_or(0);
1158    if start >= source.len() || start >= call_byte {
1159        return false;
1160    }
1161    let end = call_byte.min(source.len());
1162    let prefix = String::from_utf8_lossy(&source[start..end]);
1163    caller_symbol.is_some_and(|_| parameter_list_contains_name(&prefix, name))
1164        || prefix
1165            .lines()
1166            .any(|line| local_binding_line_defines(line, name))
1167}
1168
1169fn parameter_list_contains_name(prefix: &str, name: &str) -> bool {
1170    let Some(open) = prefix.find('(') else {
1171        return false;
1172    };
1173    let Some(close) = matching_paren_in_str(prefix, open) else {
1174        return false;
1175    };
1176    prefix[open + 1..close]
1177        .split(',')
1178        .any(|param| parameter_segment_name(param).is_some_and(|param_name| param_name == name))
1179}
1180
1181fn matching_paren_in_str(text: &str, open: usize) -> Option<usize> {
1182    let mut depth = 0usize;
1183    for (idx, ch) in text.char_indices().skip_while(|(idx, _)| *idx < open) {
1184        match ch {
1185            '(' => depth += 1,
1186            ')' => {
1187                depth = depth.saturating_sub(1);
1188                if depth == 0 {
1189                    return Some(idx);
1190                }
1191            }
1192            _ => {}
1193        }
1194    }
1195    None
1196}
1197
1198fn parameter_segment_name(segment: &str) -> Option<&str> {
1199    let segment = segment
1200        .split('=')
1201        .next()
1202        .unwrap_or(segment)
1203        .split(':')
1204        .next()
1205        .unwrap_or(segment)
1206        .trim();
1207    segment
1208        .split_whitespace()
1209        .find(|token| token.chars().next().is_some_and(is_identifier_start))
1210        .map(trim_identifier_token)
1211        .filter(|token| !token.is_empty())
1212}
1213
1214fn local_binding_line_defines(line: &str, name: &str) -> bool {
1215    let line = line.trim_start();
1216    if line.is_empty()
1217        || line.starts_with("//")
1218        || line.starts_with('#')
1219        || line.starts_with("import ")
1220        || line.starts_with("from ")
1221        || line.starts_with("use ")
1222    {
1223        return false;
1224    }
1225    if let Some(left) = line.split(":=").next()
1226        && line.contains(":=")
1227        && binding_left_side_contains(left, name)
1228    {
1229        return true;
1230    }
1231    if let Some((left, _)) = split_assignment(line)
1232        && binding_left_side_contains(left, name)
1233    {
1234        return true;
1235    }
1236    declaration_without_assignment_contains(line, name)
1237}
1238
1239fn split_assignment(line: &str) -> Option<(&str, &str)> {
1240    for (idx, ch) in line.char_indices() {
1241        if ch != '=' {
1242            continue;
1243        }
1244        let previous = line[..idx].chars().next_back();
1245        let next = line[idx + 1..].chars().next();
1246        if matches!(
1247            previous,
1248            Some('=' | '!' | '<' | '>' | ':' | '+' | '-' | '*' | '/' | '%')
1249        ) || matches!(next, Some('=' | '>'))
1250        {
1251            continue;
1252        }
1253        return Some((&line[..idx], &line[idx + 1..]));
1254    }
1255    None
1256}
1257
1258fn binding_left_side_contains(left: &str, name: &str) -> bool {
1259    left.split(',')
1260        .filter_map(|part| binding_name_from_left_part(part))
1261        .any(|binding_name| binding_name == name)
1262}
1263
1264fn binding_name_from_left_part(part: &str) -> Option<&str> {
1265    let part = part.trim();
1266    if part.contains(['.', '[', ']']) {
1267        return None;
1268    }
1269    part.split_whitespace()
1270        .next_back()
1271        .map(trim_identifier_token)
1272        .filter(|token| !token.is_empty())
1273}
1274
1275fn declaration_without_assignment_contains(line: &str, name: &str) -> bool {
1276    let Some(rest) = line
1277        .strip_prefix("let ")
1278        .or_else(|| line.strip_prefix("const "))
1279        .or_else(|| line.strip_prefix("var "))
1280        .or_else(|| line.strip_prefix("final "))
1281        .or_else(|| line.strip_prefix("late "))
1282        .or_else(|| line.strip_prefix("val "))
1283        .or_else(|| line.strip_prefix("auto "))
1284    else {
1285        return false;
1286    };
1287    rest.split([',', ';'])
1288        .filter_map(binding_name_from_left_part)
1289        .any(|binding_name| binding_name == name)
1290}
1291
1292fn trim_identifier_token(token: &str) -> &str {
1293    token.trim_matches(|ch: char| !is_identifier_continue(ch))
1294}
1295
1296fn split_qualified_callee(raw: &str) -> (String, Option<String>) {
1297    let raw = raw.trim();
1298    for separator in ["::", "\\", "."] {
1299        if let Some((qualifier, name)) = raw.rsplit_once(separator)
1300            && !qualifier.is_empty()
1301            && !name.is_empty()
1302        {
1303            return (name.to_string(), Some(qualifier.to_string()));
1304        }
1305    }
1306    (raw.to_string(), None)
1307}
1308
1309fn qualifier_root_alias(qualifier: &str) -> Option<&str> {
1310    qualifier
1311        .trim_start_matches('\\')
1312        .split(['.', ':', '\\'])
1313        .find(|part| !part.is_empty())
1314}
1315
1316fn is_identifier_start(ch: char) -> bool {
1317    ch.is_ascii_alphabetic() || matches!(ch, '_' | '$')
1318}
1319
1320fn is_identifier_continue(ch: char) -> bool {
1321    ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$')
1322}
1323
1324fn is_textual_call_name_byte(byte: u8) -> bool {
1325    byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$' | b'!' | b'?')
1326}
1327
1328fn should_ignore_call_name(language: &str, name: &str) -> bool {
1329    match language {
1330        "dart" => matches!(
1331            name,
1332            "if" | "for" | "while" | "switch" | "catch" | "assert" | "return" | "throw"
1333        ),
1334        "elixir" => matches!(
1335            name,
1336            "def" | "defp" | "defmacro" | "defmodule" | "alias" | "import" | "use" | "require"
1337        ),
1338        "kotlin" => matches!(
1339            name,
1340            "if" | "for" | "while" | "when" | "catch" | "return" | "throw"
1341        ),
1342        _ => false,
1343    }
1344}
1345
1346#[cfg(test)]
1347mod tests {
1348    use std::fs;
1349    use std::path::{Path, PathBuf};
1350
1351    use tempfile::TempDir;
1352
1353    use super::*;
1354
1355    fn parse_source(file_name: &str, source: &str, extra_files: &[(&str, &str)]) -> ParseResult {
1356        let tempdir = TempDir::new().expect("create tempdir");
1357        let root = tempdir.path();
1358        for (path, contents) in extra_files {
1359            let file_path = root.join(path);
1360            if let Some(parent) = file_path.parent() {
1361                fs::create_dir_all(parent).expect("create parent dirs");
1362            }
1363            fs::write(&file_path, contents).expect("write extra source");
1364        }
1365
1366        let path = root.join(file_name);
1367        if let Some(parent) = path.parent() {
1368            fs::create_dir_all(parent).expect("create parent dirs");
1369        }
1370        fs::write(&path, source).expect("write test source");
1371        let candidates = discover_supported_files(root);
1372        let context = build_import_resolution_context(root, &candidates);
1373        parse_file_with_semantic(&path, "proj", root, &[], &context, None)
1374            .expect("parse result")
1375            .expect("parse file")
1376    }
1377
1378    fn parse_python(source: &str) -> ParseResult {
1379        parse_source("sample.py", source, &[])
1380    }
1381
1382    fn parse_javascript(source: &str, extra_files: &[(&str, &str)]) -> ParseResult {
1383        parse_source("src/sample.js", source, extra_files)
1384    }
1385
1386    fn parse_typescript(source: &str, extra_files: &[(&str, &str)]) -> ParseResult {
1387        parse_source("src/sample.ts", source, extra_files)
1388    }
1389
1390    fn parse_go(source: &str, extra_files: &[(&str, &str)]) -> ParseResult {
1391        parse_source("cmd/sample.go", source, extra_files)
1392    }
1393
1394    fn parse_rust(source: &str, extra_files: &[(&str, &str)]) -> ParseResult {
1395        parse_source("src/main.rs", source, extra_files)
1396    }
1397
1398    fn parse_java(source: &str, extra_files: &[(&str, &str)]) -> ParseResult {
1399        parse_source("src/main/java/app/Sample.java", source, extra_files)
1400    }
1401
1402    fn parse_csharp(source: &str, extra_files: &[(&str, &str)]) -> ParseResult {
1403        parse_source("src/Sample.cs", source, extra_files)
1404    }
1405
1406    fn parse_php(source: &str, extra_files: &[(&str, &str)]) -> ParseResult {
1407        parse_source("src/sample.php", source, extra_files)
1408    }
1409
1410    fn parse_ruby(source: &str, extra_files: &[(&str, &str)]) -> ParseResult {
1411        parse_source("lib/sample.rb", source, extra_files)
1412    }
1413
1414    fn parse_dart(source: &str, extra_files: &[(&str, &str)]) -> ParseResult {
1415        parse_source("lib/sample.dart", source, extra_files)
1416    }
1417
1418    fn parse_elixir(source: &str, extra_files: &[(&str, &str)]) -> ParseResult {
1419        parse_source("lib/sample.ex", source, extra_files)
1420    }
1421
1422    fn parse_kotlin(source: &str, extra_files: &[(&str, &str)]) -> ParseResult {
1423        parse_source("src/main/kotlin/Sample.kt", source, extra_files)
1424    }
1425
1426    fn parse_swift(source: &str, extra_files: &[(&str, &str)]) -> ParseResult {
1427        parse_source("Sources/App/main.swift", source, extra_files)
1428    }
1429
1430    fn discover_supported_files(root: &Path) -> Vec<PathBuf> {
1431        let mut candidates = Vec::new();
1432        let mut stack = vec![root.to_path_buf()];
1433        while let Some(path) = stack.pop() {
1434            let entries = fs::read_dir(&path).expect("read dir");
1435            for entry in entries {
1436                let entry = entry.expect("dir entry");
1437                let entry_path = entry.path();
1438                if entry_path.is_dir() {
1439                    stack.push(entry_path);
1440                } else if let Some(language) =
1441                    languages::detect_language(&entry_path.to_string_lossy())
1442                    && !language.is_empty()
1443                {
1444                    candidates.push(entry_path);
1445                }
1446            }
1447        }
1448        candidates
1449    }
1450
1451    #[test]
1452    fn line_terminator_len_tracks_lf_crlf_and_eof() {
1453        let text = "import 'a';\r\nhttp.Client();\nlast()";
1454        assert_eq!(line_terminator_len(text, 0, "import 'a';".len()), 2);
1455
1456        let second_start = "import 'a';\r\n".len();
1457        assert_eq!(
1458            line_terminator_len(text, second_start, "http.Client();".len()),
1459            1
1460        );
1461
1462        let last_start = "import 'a';\r\nhttp.Client();\n".len();
1463        assert_eq!(line_terminator_len(text, last_start, "last()".len()), 0);
1464    }
1465
1466    struct FakeSemanticResolver {
1467        target: Option<crate::index::semantic::SemanticCallTarget>,
1468        expected_language: &'static str,
1469        expected_callee: &'static str,
1470        requests: Vec<CapturedSemanticRequest>,
1471        error: Option<&'static str>,
1472    }
1473
1474    struct CapturedSemanticRequest {
1475        language: String,
1476        file_path: PathBuf,
1477        root_path: PathBuf,
1478        callee_name: String,
1479        line: usize,
1480        column: usize,
1481    }
1482
1483    impl SemanticCallResolver for FakeSemanticResolver {
1484        fn resolve(
1485            &mut self,
1486            request: &SemanticCallRequest<'_>,
1487        ) -> anyhow::Result<Option<crate::index::semantic::SemanticCallTarget>> {
1488            self.requests.push(CapturedSemanticRequest {
1489                language: request.language.to_string(),
1490                file_path: request.file_path.to_path_buf(),
1491                root_path: request.root_path.to_path_buf(),
1492                callee_name: request.callee_name.to_string(),
1493                line: request.line,
1494                column: request.column,
1495            });
1496            if let Some(error) = self.error {
1497                anyhow::bail!("{error}");
1498            }
1499            if request.language == self.expected_language
1500                && request.callee_name == self.expected_callee
1501            {
1502                Ok(self.target.clone())
1503            } else {
1504                Ok(None)
1505            }
1506        }
1507    }
1508
1509    #[test]
1510    fn explicit_qualified_raw_callee_takes_precedence_over_member_prefix() {
1511        let mut inferred_called = false;
1512        let (_, qualifier_from_name) = split_qualified_callee("Vendor\\Pkg\\helper");
1513
1514        let qualifier_path = call_qualifier_path(qualifier_from_name, || {
1515            inferred_called = true;
1516            Some("Vendor".to_string())
1517        });
1518
1519        assert_eq!(qualifier_path.as_deref(), Some("Vendor\\Pkg"));
1520        assert!(!inferred_called);
1521    }
1522
1523    #[test]
1524    fn resolves_unique_same_file_bare_calls() {
1525        let parsed = parse_python(
1526            r#"
1527def foo():
1528    pass
1529
1530def bar():
1531    foo()
1532"#,
1533        );
1534
1535        let foo = parsed
1536            .symbols
1537            .iter()
1538            .find(|symbol| symbol.name == "foo")
1539            .expect("foo symbol");
1540        let bar = parsed
1541            .symbols
1542            .iter()
1543            .find(|symbol| symbol.name == "bar")
1544            .expect("bar symbol");
1545        let call = parsed.calls.first().expect("call");
1546
1547        assert_eq!(call.caller_symbol_id, bar.id);
1548        assert_eq!(call.callee_symbol_id.as_deref(), Some(foo.id.as_str()));
1549    }
1550
1551    #[test]
1552    fn resolves_same_class_member_calls() {
1553        let parsed = parse_python(
1554            r#"
1555class Greeter:
1556    def greet(self):
1557        self.render()
1558
1559    def render(self):
1560        pass
1561"#,
1562        );
1563
1564        let render = parsed
1565            .symbols
1566            .iter()
1567            .find(|symbol| symbol.qualified_name == "Greeter.render")
1568            .expect("render method");
1569        let call = parsed.calls.first().expect("call");
1570
1571        assert_eq!(call.callee_symbol_id.as_deref(), Some(render.id.as_str()));
1572    }
1573
1574    #[test]
1575    fn leaves_ambiguous_bare_calls_unresolved() {
1576        let parsed = parse_python(
1577            r#"
1578def foo():
1579    pass
1580
1581class A:
1582    def foo(self):
1583        pass
1584
1585def bar():
1586    foo()
1587"#,
1588        );
1589
1590        let call = parsed.calls.first().expect("call");
1591        assert!(call.callee_symbol_id.is_none());
1592    }
1593
1594    #[test]
1595    fn leaves_non_local_member_calls_unresolved() {
1596        let parsed = parse_python(
1597            r#"
1598class A:
1599    def bar(self):
1600        obj.render()
1601
1602class B:
1603    def render(self):
1604        pass
1605"#,
1606        );
1607
1608        let call = parsed.calls.first().expect("call");
1609        assert!(call.callee_symbol_id.is_none());
1610    }
1611
1612    #[test]
1613    fn classifies_external_python_from_import_calls() {
1614        let parsed = parse_python(
1615            r#"
1616from requests import get as fetch
1617
1618def run():
1619    fetch()
1620"#,
1621        );
1622
1623        let call = parsed.calls.first().expect("call");
1624        assert_eq!(call.callee_target_kind.as_str(), "external");
1625        assert_eq!(call.callee_name, "get");
1626        assert_eq!(call.callee_external_module.as_deref(), Some("requests"));
1627    }
1628
1629    #[test]
1630    fn leaves_local_python_imports_unresolved() {
1631        let parsed = parse_source(
1632            "pkg/main.py",
1633            r#"
1634from pkg.utils import helper
1635
1636def run():
1637    helper()
1638"#,
1639            &[("pkg/utils.py", "def helper():\n    pass\n")],
1640        );
1641
1642        let call = parsed.calls.first().expect("call");
1643        assert_eq!(call.callee_target_kind.as_str(), "unresolved");
1644        assert!(call.callee_external_module.is_none());
1645    }
1646
1647    #[test]
1648    fn classifies_external_javascript_named_import_calls() {
1649        let parsed = parse_javascript(
1650            r#"
1651import { useState } from "react";
1652
1653function run() {
1654  useState();
1655}
1656"#,
1657            &[(
1658                "package.json",
1659                r#"{"name":"app","dependencies":{"react":"^18.0.0"}}"#,
1660            )],
1661        );
1662
1663        let call = parsed.calls.first().expect("call");
1664        assert_eq!(call.callee_target_kind.as_str(), "external");
1665        assert_eq!(call.callee_name, "useState");
1666        assert_eq!(call.callee_external_module.as_deref(), Some("react"));
1667    }
1668
1669    #[test]
1670    fn leaves_external_bare_calls_shadowed_by_parameters_unresolved() {
1671        let parsed = parse_javascript(
1672            r#"
1673import { useState } from "react";
1674
1675function run(useState) {
1676  useState();
1677}
1678"#,
1679            &[(
1680                "package.json",
1681                r#"{"name":"app","dependencies":{"react":"^18.0.0"}}"#,
1682            )],
1683        );
1684
1685        let call = parsed.calls.first().expect("call");
1686        assert_eq!(call.callee_target_kind.as_str(), "unresolved");
1687        assert!(call.callee_external_module.is_none());
1688    }
1689
1690    #[test]
1691    fn classifies_external_javascript_namespace_member_calls() {
1692        let parsed = parse_javascript(
1693            r#"
1694import * as React from "react";
1695
1696function run() {
1697  React.useState();
1698}
1699"#,
1700            &[(
1701                "package.json",
1702                r#"{"name":"app","dependencies":{"react":"^18.0.0"}}"#,
1703            )],
1704        );
1705
1706        let call = parsed.calls.first().expect("call");
1707        assert_eq!(call.callee_target_kind.as_str(), "external");
1708        assert_eq!(call.callee_name, "useState");
1709        assert_eq!(call.callee_external_module.as_deref(), Some("react"));
1710    }
1711
1712    #[test]
1713    fn leaves_relative_javascript_imports_unresolved() {
1714        let parsed = parse_javascript(
1715            r#"
1716import { helper } from "./utils";
1717
1718function run() {
1719  helper();
1720}
1721"#,
1722            &[
1723                (
1724                    "package.json",
1725                    r#"{"name":"app","dependencies":{"react":"^18.0.0"}}"#,
1726                ),
1727                ("src/utils.js", "export function helper() {}\n"),
1728            ],
1729        );
1730
1731        let call = parsed.calls.first().expect("call");
1732        assert_eq!(call.callee_target_kind.as_str(), "unresolved");
1733    }
1734
1735    #[test]
1736    fn classifies_external_typescript_default_member_calls() {
1737        let parsed = parse_typescript(
1738            r#"
1739import React from "react";
1740
1741function run() {
1742  React.useState();
1743}
1744"#,
1745            &[(
1746                "package.json",
1747                r#"{"name":"app","dependencies":{"react":"^18.0.0"}}"#,
1748            )],
1749        );
1750
1751        let call = parsed.calls.first().expect("call");
1752        assert_eq!(call.callee_target_kind.as_str(), "external");
1753        assert_eq!(call.callee_name, "useState");
1754        assert_eq!(call.callee_external_module.as_deref(), Some("react"));
1755    }
1756
1757    #[test]
1758    fn leaves_external_qualified_roots_shadowed_by_locals_unresolved() {
1759        let parsed = parse_typescript(
1760            r#"
1761import React from "react";
1762
1763function run() {
1764  const React = makeLocalReact();
1765  React.useState();
1766}
1767"#,
1768            &[(
1769                "package.json",
1770                r#"{"name":"app","dependencies":{"react":"^18.0.0"}}"#,
1771            )],
1772        );
1773
1774        let call = parsed
1775            .calls
1776            .iter()
1777            .find(|call| call.callee_name == "useState")
1778            .expect("call");
1779        assert_eq!(call.callee_target_kind.as_str(), "unresolved");
1780        assert!(call.callee_external_module.is_none());
1781    }
1782
1783    #[test]
1784    fn leaves_unlisted_javascript_package_aliases_unresolved() {
1785        let parsed = parse_javascript(
1786            r#"
1787import { helper } from "@/utils";
1788
1789function run() {
1790  helper();
1791}
1792"#,
1793            &[(
1794                "package.json",
1795                r#"{"name":"app","dependencies":{"react":"^18.0.0"}}"#,
1796            )],
1797        );
1798
1799        let call = parsed.calls.first().expect("call");
1800        assert_eq!(call.callee_target_kind.as_str(), "unresolved");
1801    }
1802
1803    #[test]
1804    fn classifies_external_go_import_alias_selector_calls() {
1805        let parsed = parse_go(
1806            r#"
1807package main
1808
1809import (
1810    "fmt"
1811    cli "github.com/acme/client"
1812    "gopkg.in/yaml.v3"
1813)
1814
1815func run() {
1816    fmt.Println("hello")
1817    cli.Connect()
1818    yaml.Unmarshal(nil, nil)
1819}
1820"#,
1821            &[("go.mod", "module example.com/app\n")],
1822        );
1823
1824        let fmt_call = parsed
1825            .calls
1826            .iter()
1827            .find(|call| call.callee_name == "Println")
1828            .expect("fmt call");
1829        assert_eq!(fmt_call.callee_target_kind.as_str(), "external");
1830        assert_eq!(fmt_call.callee_external_module.as_deref(), Some("fmt"));
1831
1832        let alias_call = parsed
1833            .calls
1834            .iter()
1835            .find(|call| call.callee_name == "Connect")
1836            .expect("alias call");
1837        assert_eq!(alias_call.callee_target_kind.as_str(), "external");
1838        assert_eq!(
1839            alias_call.callee_external_module.as_deref(),
1840            Some("github.com/acme/client")
1841        );
1842
1843        let yaml_call = parsed
1844            .calls
1845            .iter()
1846            .find(|call| call.callee_name == "Unmarshal")
1847            .expect("yaml call");
1848        assert_eq!(yaml_call.callee_target_kind.as_str(), "external");
1849        assert_eq!(
1850            yaml_call.callee_external_module.as_deref(),
1851            Some("gopkg.in/yaml.v3")
1852        );
1853    }
1854
1855    #[test]
1856    fn leaves_self_module_go_imports_unresolved() {
1857        let parsed = parse_go(
1858            r#"
1859package main
1860
1861import "example.com/app/pkg/tool"
1862
1863func run() {
1864    tool.Run()
1865}
1866"#,
1867            &[("go.mod", "module example.com/app\n")],
1868        );
1869
1870        let call = parsed.calls.first().expect("call");
1871        assert_eq!(call.callee_target_kind.as_str(), "unresolved");
1872    }
1873
1874    #[test]
1875    fn leaves_unimported_go_selector_calls_unresolved() {
1876        let parsed = parse_go(
1877            r#"
1878package main
1879
1880func run(client Client) {
1881    client.Do()
1882}
1883"#,
1884            &[("go.mod", "module example.com/app\n")],
1885        );
1886
1887        let call = parsed.calls.first().expect("call");
1888        assert_eq!(call.callee_target_kind.as_str(), "unresolved");
1889    }
1890
1891    #[test]
1892    fn classifies_external_rust_use_alias_and_path_calls() {
1893        let parsed = parse_rust(
1894            r#"
1895use serde_json::from_str as parse_json;
1896use std::fs;
1897
1898fn run() {
1899    parse_json("{}");
1900    serde_json::from_str("{}");
1901    fs::read("Cargo.toml");
1902    std::fs::read("Cargo.toml");
1903}
1904"#,
1905            &[(
1906                "Cargo.toml",
1907                r#"[package]
1908name = "app"
1909
1910[dependencies]
1911serde_json = { version = "1" }
1912"#,
1913            )],
1914        );
1915
1916        let parse_call = parsed
1917            .calls
1918            .iter()
1919            .find(|call| call.callee_name == "from_str")
1920            .expect("from_str call");
1921        assert_eq!(parse_call.callee_target_kind.as_str(), "external");
1922        assert_eq!(
1923            parse_call.callee_external_module.as_deref(),
1924            Some("serde_json")
1925        );
1926
1927        let read_modules: Vec<_> = parsed
1928            .calls
1929            .iter()
1930            .filter(|call| call.callee_name == "read")
1931            .map(|call| call.callee_external_module.as_deref())
1932            .collect();
1933        assert_eq!(read_modules, vec![Some("std::fs"), Some("std::fs")]);
1934    }
1935
1936    #[test]
1937    fn leaves_rust_self_crate_and_glob_imports_unresolved() {
1938        let parsed = parse_rust(
1939            r#"
1940use app::helper;
1941use serde_json::*;
1942
1943fn run() {
1944    helper();
1945    from_str("{}");
1946}
1947"#,
1948            &[(
1949                "Cargo.toml",
1950                r#"[package]
1951name = "app"
1952
1953[dependencies]
1954serde_json = "1"
1955"#,
1956            )],
1957        );
1958
1959        assert_eq!(parsed.calls.len(), 2);
1960        assert!(
1961            parsed
1962                .calls
1963                .iter()
1964                .all(|call| call.callee_target_kind.as_str() == "unresolved")
1965        );
1966    }
1967
1968    #[test]
1969    fn leaves_rust_receiver_method_calls_unresolved() {
1970        let parsed = parse_rust(
1971            r#"
1972fn run(value: Parser) {
1973    value.parse();
1974}
1975"#,
1976            &[(
1977                "Cargo.toml",
1978                r#"[package]
1979name = "app"
1980"#,
1981            )],
1982        );
1983
1984        let call = parsed.calls.first().expect("call");
1985        assert_eq!(call.callee_target_kind.as_str(), "unresolved");
1986    }
1987
1988    #[test]
1989    fn classifies_external_java_import_and_static_import_calls() {
1990        let parsed = parse_java(
1991            r#"
1992package app;
1993
1994import java.util.Collections;
1995import static java.util.Objects.requireNonNull;
1996
1997class Sample {
1998    void run() {
1999        Collections.emptyList();
2000        requireNonNull("x");
2001    }
2002}
2003"#,
2004            &[],
2005        );
2006
2007        let class_call = parsed
2008            .calls
2009            .iter()
2010            .find(|call| call.callee_name == "emptyList")
2011            .expect("class call");
2012        assert_eq!(class_call.callee_target_kind.as_str(), "external");
2013        assert_eq!(
2014            class_call.callee_external_module.as_deref(),
2015            Some("java.util.Collections")
2016        );
2017
2018        let static_call = parsed
2019            .calls
2020            .iter()
2021            .find(|call| call.callee_name == "requireNonNull")
2022            .expect("static call");
2023        assert_eq!(static_call.callee_target_kind.as_str(), "external");
2024        assert_eq!(
2025            static_call.callee_external_module.as_deref(),
2026            Some("java.util.Objects")
2027        );
2028    }
2029
2030    #[test]
2031    fn leaves_java_wildcard_and_instance_calls_unresolved() {
2032        let parsed = parse_java(
2033            r#"
2034package app;
2035
2036import java.util.*;
2037
2038class Sample {
2039    void run(java.util.List<String> list) {
2040        list.add("x");
2041        emptyList();
2042    }
2043}
2044"#,
2045            &[],
2046        );
2047
2048        assert_eq!(parsed.calls.len(), 2);
2049        assert!(
2050            parsed
2051                .calls
2052                .iter()
2053                .all(|call| call.callee_target_kind.as_str() == "unresolved")
2054        );
2055    }
2056
2057    #[test]
2058    fn leaves_local_java_imports_unresolved() {
2059        let parsed = parse_java(
2060            r#"
2061package app;
2062
2063import app.helpers.Helper;
2064
2065class Sample {
2066    void run() {
2067        Helper.render();
2068    }
2069}
2070"#,
2071            &[(
2072                "src/main/java/app/helpers/Helper.java",
2073                r#"
2074package app.helpers;
2075
2076class Helper {
2077    static void render() {}
2078}
2079"#,
2080            )],
2081        );
2082
2083        let call = parsed.calls.first().expect("call");
2084        assert_eq!(call.callee_target_kind.as_str(), "unresolved");
2085    }
2086
2087    #[test]
2088    fn classifies_external_csharp_alias_static_and_qualified_calls() {
2089        let parsed = parse_csharp(
2090            r#"
2091using Json = Newtonsoft.Json.JsonConvert;
2092using static System.Math;
2093using System;
2094
2095class Sample {
2096    void Run() {
2097        Json.SerializeObject(this);
2098        Sqrt(4);
2099        System.Console.WriteLine("x");
2100    }
2101}
2102"#,
2103            &[],
2104        );
2105
2106        let alias_call = parsed
2107            .calls
2108            .iter()
2109            .find(|call| call.callee_name == "SerializeObject")
2110            .expect("alias call");
2111        assert_eq!(alias_call.callee_target_kind.as_str(), "external");
2112        assert_eq!(
2113            alias_call.callee_external_module.as_deref(),
2114            Some("Newtonsoft.Json.JsonConvert")
2115        );
2116
2117        let static_call = parsed
2118            .calls
2119            .iter()
2120            .find(|call| call.callee_name == "Sqrt")
2121            .expect("static call");
2122        assert_eq!(static_call.callee_target_kind.as_str(), "external");
2123        assert_eq!(
2124            static_call.callee_external_module.as_deref(),
2125            Some("System.Math")
2126        );
2127
2128        let qualified_call = parsed
2129            .calls
2130            .iter()
2131            .find(|call| call.callee_name == "WriteLine")
2132            .expect("qualified call");
2133        assert_eq!(qualified_call.callee_target_kind.as_str(), "external");
2134        assert_eq!(
2135            qualified_call.callee_external_module.as_deref(),
2136            Some("System.Console")
2137        );
2138    }
2139
2140    #[test]
2141    fn leaves_csharp_instance_and_local_namespace_calls_unresolved() {
2142        let parsed = parse_csharp(
2143            r#"
2144namespace App;
2145
2146using App.Helpers;
2147
2148class Sample {
2149    void Run(Client client) {
2150        client.Send();
2151        App.Helpers.Tool.Render();
2152    }
2153}
2154"#,
2155            &[],
2156        );
2157
2158        assert_eq!(parsed.calls.len(), 2);
2159        assert!(
2160            parsed
2161                .calls
2162                .iter()
2163                .all(|call| call.callee_target_kind.as_str() == "unresolved")
2164        );
2165    }
2166
2167    #[test]
2168    fn classifies_external_php_namespace_and_fully_qualified_calls() {
2169        let parsed = parse_php(
2170            r#"
2171<?php
2172namespace App;
2173
2174use Vendor\Pkg\Client as ApiClient;
2175use function Vendor\Pkg\do_work as work;
2176
2177function run() {
2178    ApiClient::connect();
2179    work();
2180    \Vendor\Pkg\helper();
2181    \Vendor\Pkg\Service::build();
2182}
2183"#,
2184            &[],
2185        );
2186
2187        let static_call = parsed
2188            .calls
2189            .iter()
2190            .find(|call| call.callee_name == "connect")
2191            .expect("static call");
2192        assert_eq!(static_call.callee_target_kind.as_str(), "external");
2193        assert_eq!(
2194            static_call.callee_external_module.as_deref(),
2195            Some("Vendor\\Pkg\\Client")
2196        );
2197
2198        let function_import_call = parsed
2199            .calls
2200            .iter()
2201            .find(|call| call.callee_name == "do_work")
2202            .expect("function import call");
2203        assert_eq!(function_import_call.callee_target_kind.as_str(), "external");
2204        assert_eq!(
2205            function_import_call.callee_external_module.as_deref(),
2206            Some("Vendor\\Pkg")
2207        );
2208
2209        let qualified_function_call = parsed
2210            .calls
2211            .iter()
2212            .find(|call| call.callee_name == "helper")
2213            .expect("qualified function call");
2214        assert_eq!(
2215            qualified_function_call.callee_target_kind.as_str(),
2216            "external"
2217        );
2218        assert_eq!(
2219            qualified_function_call.callee_external_module.as_deref(),
2220            Some("Vendor\\Pkg")
2221        );
2222
2223        let qualified_static_call = parsed
2224            .calls
2225            .iter()
2226            .find(|call| call.callee_name == "build")
2227            .expect("qualified static call");
2228        assert_eq!(
2229            qualified_static_call.callee_target_kind.as_str(),
2230            "external"
2231        );
2232        assert_eq!(
2233            qualified_static_call.callee_external_module.as_deref(),
2234            Some("Vendor\\Pkg\\Service")
2235        );
2236    }
2237
2238    #[test]
2239    fn leaves_php_dynamic_member_and_local_import_calls_unresolved() {
2240        let parsed = parse_php(
2241            r#"
2242<?php
2243namespace App;
2244
2245use App\Local\Client;
2246
2247function run($obj) {
2248    $obj->connect();
2249    Client::connect();
2250    \missing();
2251    missing();
2252}
2253"#,
2254            &[(
2255                "src/Local/Client.php",
2256                r#"
2257<?php
2258namespace App\Local;
2259
2260class Client {}
2261"#,
2262            )],
2263        );
2264
2265        assert!(
2266            parsed
2267                .calls
2268                .iter()
2269                .all(|call| call.callee_target_kind.as_str() == "unresolved")
2270        );
2271    }
2272
2273    #[test]
2274    fn classifies_external_ruby_constant_qualified_require_calls() {
2275        let parsed = parse_ruby(
2276            r#"
2277require "json"
2278require "fileutils"
2279
2280def run
2281  JSON.parse("{}")
2282  FileUtils.mkdir_p("tmp")
2283  parse("{}")
2284end
2285"#,
2286            &[],
2287        );
2288
2289        let json_call = parsed
2290            .calls
2291            .iter()
2292            .find(|call| call.callee_name == "parse")
2293            .expect("json call");
2294        assert_eq!(json_call.callee_target_kind.as_str(), "external");
2295        assert_eq!(json_call.callee_external_module.as_deref(), Some("json"));
2296
2297        let mkdir_call = parsed
2298            .calls
2299            .iter()
2300            .find(|call| call.callee_name == "mkdir_p")
2301            .expect("fileutils call");
2302        assert_eq!(mkdir_call.callee_target_kind.as_str(), "external");
2303        assert_eq!(
2304            mkdir_call.callee_external_module.as_deref(),
2305            Some("fileutils")
2306        );
2307    }
2308
2309    #[test]
2310    fn leaves_ruby_local_constant_collision_and_receivers_unresolved() {
2311        let parsed = parse_ruby(
2312            r#"
2313require "json"
2314
2315def run(client)
2316  JSON.parse("{}")
2317  client.parse("{}")
2318  send(:parse, "{}")
2319end
2320"#,
2321            &[(
2322                "lib/json.rb",
2323                r#"
2324module JSON
2325end
2326"#,
2327            )],
2328        );
2329
2330        assert!(
2331            parsed
2332                .calls
2333                .iter()
2334                .all(|call| call.callee_target_kind.as_str() == "unresolved")
2335        );
2336    }
2337
2338    #[test]
2339    fn classifies_external_dart_alias_calls_only() {
2340        let parsed = parse_dart(
2341            r#"
2342import 'dart:convert' as convert;
2343import 'package:http/http.dart' as http show Client;
2344import 'package:app/local.dart' as local;
2345import './relative.dart' as relative;
2346
2347void run() {
2348  convert.jsonDecode("{}");
2349  http.Client();
2350  local.helper();
2351  relative.helper();
2352  jsonDecode("{}");
2353}
2354"#,
2355            &[(
2356                "pubspec.yaml",
2357                r#"
2358name: app
2359dependencies:
2360  http: ^1.0.0
2361"#,
2362            )],
2363        );
2364
2365        let json_call = parsed
2366            .calls
2367            .iter()
2368            .find(|call| call.callee_name == "jsonDecode")
2369            .expect("jsonDecode call");
2370        assert_eq!(json_call.callee_target_kind.as_str(), "external");
2371        assert_eq!(
2372            json_call.callee_external_module.as_deref(),
2373            Some("dart:convert")
2374        );
2375
2376        let client_call = parsed
2377            .calls
2378            .iter()
2379            .find(|call| call.callee_name == "Client")
2380            .expect("Client call");
2381        assert_eq!(client_call.callee_target_kind.as_str(), "external");
2382        assert_eq!(
2383            client_call.callee_external_module.as_deref(),
2384            Some("package:http/http.dart")
2385        );
2386
2387        let unresolved: Vec<_> = parsed
2388            .calls
2389            .iter()
2390            .filter(|call| matches!(call.callee_name.as_str(), "helper" | "jsonDecode"))
2391            .filter(|call| call.callee_target_kind.as_str() == "unresolved")
2392            .collect();
2393        assert_eq!(unresolved.len(), 3);
2394        assert!(parsed.calls.iter().all(|call| call.callee_name != "run"));
2395    }
2396
2397    #[test]
2398    fn textual_dart_calls_handle_generics_and_ignore_comments_and_strings() {
2399        let parsed = parse_dart(
2400            r#"
2401void run() {
2402  builder<T>();
2403  final text = "fakeCall()";
2404  final other = 'otherCall()';
2405  // commentedCall();
2406  /* blockCall();
2407     stillBlockCall();
2408  */
2409  afterBlock(); // trailingCommentCall();
2410}
2411"#,
2412            &[],
2413        );
2414
2415        let call_names: Vec<_> = parsed
2416            .calls
2417            .iter()
2418            .map(|call| call.callee_name.as_str())
2419            .collect();
2420        assert!(call_names.contains(&"builder"));
2421        assert!(call_names.contains(&"afterBlock"));
2422        for skipped in [
2423            "fakeCall",
2424            "otherCall",
2425            "commentedCall",
2426            "blockCall",
2427            "stillBlockCall",
2428            "trailingCommentCall",
2429        ] {
2430            assert!(!call_names.contains(&skipped), "unexpected call {skipped}");
2431        }
2432    }
2433
2434    #[test]
2435    fn textual_dart_calls_ignore_raw_and_triple_quoted_multiline_strings() {
2436        let parsed = parse_dart(
2437            r#"
2438void run() {
2439  final raw = r"rawCall()";
2440  final triple = '''
2441    tripleCall();
2442  ''';
2443  final rawTriple = r"""
2444    rawTripleCall();
2445  """;
2446  afterStrings();
2447}
2448"#,
2449            &[],
2450        );
2451
2452        let call_names: Vec<_> = parsed
2453            .calls
2454            .iter()
2455            .map(|call| call.callee_name.as_str())
2456            .collect();
2457        assert_eq!(call_names, vec!["afterStrings"]);
2458    }
2459
2460    #[test]
2461    fn classifies_external_elixir_remote_alias_and_required_calls() {
2462        let parsed = parse_elixir(
2463            r#"
2464defmodule App.Sample do
2465  alias HTTPoison, as: HTTP
2466  require Jason
2467
2468  def run(body) do
2469    Jason.decode!(body)
2470    HTTP.get("https://example.com")
2471  end
2472end
2473"#,
2474            &[
2475                (
2476                    "mix.exs",
2477                    r#"
2478defmodule App.MixProject do
2479  defp deps do
2480    [
2481      {:jason, "~> 1.4"},
2482      {:httpoison, "~> 2.0"}
2483    ]
2484  end
2485end
2486"#,
2487                ),
2488                (
2489                    "mix.lock",
2490                    r#"{"jason": {:hex, :jason}, "httpoison": {:hex, :httpoison}}"#,
2491                ),
2492            ],
2493        );
2494
2495        let decode_call = parsed
2496            .calls
2497            .iter()
2498            .find(|call| call.callee_name == "decode!")
2499            .expect("decode call");
2500        assert_eq!(decode_call.callee_target_kind.as_str(), "external");
2501        assert_eq!(decode_call.callee_external_module.as_deref(), Some("Jason"));
2502
2503        let get_call = parsed
2504            .calls
2505            .iter()
2506            .find(|call| call.callee_name == "get")
2507            .expect("get call");
2508        assert_eq!(get_call.callee_target_kind.as_str(), "external");
2509        assert_eq!(
2510            get_call.callee_external_module.as_deref(),
2511            Some("HTTPoison")
2512        );
2513    }
2514
2515    #[test]
2516    fn leaves_elixir_local_module_collision_and_imported_calls_unresolved() {
2517        let parsed = parse_elixir(
2518            r#"
2519defmodule App.Sample do
2520  import Jason
2521
2522  def run(body) do
2523    Jason.decode!(body)
2524    decode!(body)
2525  end
2526end
2527"#,
2528            &[
2529                ("mix.exs", "{:jason, \"~> 1.4\"}\n"),
2530                (
2531                    "lib/jason.ex",
2532                    r#"
2533defmodule Jason do
2534end
2535"#,
2536                ),
2537            ],
2538        );
2539
2540        assert!(
2541            parsed
2542                .calls
2543                .iter()
2544                .all(|call| call.callee_target_kind.as_str() == "unresolved")
2545        );
2546    }
2547
2548    #[test]
2549    fn extracts_kotlin_symbols_imports_and_calls_without_external_classification() {
2550        let parsed = parse_kotlin(
2551            r#"
2552package app
2553
2554import kotlinx.coroutines.runBlocking
2555
2556class Runner {
2557    fun run() {
2558        runBlocking()
2559        println("hello")
2560    }
2561}
2562"#,
2563            &[],
2564        );
2565
2566        assert!(
2567            parsed
2568                .symbols
2569                .iter()
2570                .any(|symbol| symbol.name == "Runner" && symbol.kind == "class")
2571        );
2572        assert!(
2573            parsed
2574                .symbols
2575                .iter()
2576                .any(|symbol| symbol.name == "run" && symbol.kind == "method")
2577        );
2578        assert!(
2579            parsed
2580                .imports
2581                .iter()
2582                .any(|import| import.module_name == "import kotlinx.coroutines.runBlocking")
2583        );
2584        assert!(
2585            parsed
2586                .calls
2587                .iter()
2588                .any(|call| call.callee_name == "runBlocking"
2589                    && call.callee_target_kind.as_str() == "unresolved")
2590        );
2591    }
2592
2593    #[test]
2594    fn semantic_resolver_can_classify_cpp_calls_as_external() {
2595        let tempdir = TempDir::new().expect("create tempdir");
2596        let root = tempdir.path();
2597        let path = root.join("src/main.cpp");
2598        fs::create_dir_all(path.parent().expect("parent")).expect("create parent dirs");
2599        fs::write(
2600            &path,
2601            r#"
2602void run() {
2603    printf("x");
2604}
2605"#,
2606        )
2607        .expect("write source");
2608        let candidates = discover_supported_files(root);
2609        let context = build_import_resolution_context(root, &candidates);
2610        let mut resolver = FakeSemanticResolver {
2611            target: Some(crate::index::semantic::SemanticCallTarget {
2612                callee_name: "printf".to_string(),
2613                external_module: "/usr/include/stdio.h".to_string(),
2614            }),
2615            expected_language: "cpp",
2616            expected_callee: "printf",
2617            requests: Vec::new(),
2618            error: None,
2619        };
2620        let parsed =
2621            parse_file_with_semantic(&path, "proj", root, &[], &context, Some(&mut resolver))
2622                .expect("parse result")
2623                .expect("parse file");
2624
2625        let call = parsed.calls.first().expect("printf call");
2626        assert_eq!(call.callee_target_kind.as_str(), "external");
2627        assert_eq!(
2628            call.callee_external_module.as_deref(),
2629            Some("/usr/include/stdio.h")
2630        );
2631    }
2632
2633    #[test]
2634    fn semantic_resolver_can_classify_textual_dart_calls_as_external() {
2635        let tempdir = TempDir::new().expect("create tempdir");
2636        let root = tempdir.path();
2637        let path = root.join("lib/sample.dart");
2638        fs::create_dir_all(path.parent().expect("parent")).expect("create parent dirs");
2639        fs::write(
2640            &path,
2641            r#"
2642void run() {
2643  Tooltip(message: 'x');
2644}
2645"#,
2646        )
2647        .expect("write source");
2648        let candidates = discover_supported_files(root);
2649        let context = build_import_resolution_context(root, &candidates);
2650        let mut resolver = FakeSemanticResolver {
2651            target: Some(crate::index::semantic::SemanticCallTarget {
2652                callee_name: "Tooltip".to_string(),
2653                external_module: "package:flutter/material.dart".to_string(),
2654            }),
2655            expected_language: "dart",
2656            expected_callee: "Tooltip",
2657            requests: Vec::new(),
2658            error: None,
2659        };
2660        let parsed =
2661            parse_file_with_semantic(&path, "proj", root, &[], &context, Some(&mut resolver))
2662                .expect("parse result")
2663                .expect("parse file");
2664
2665        let call = parsed
2666            .calls
2667            .iter()
2668            .find(|call| call.callee_name == "Tooltip")
2669            .expect("Tooltip call");
2670        assert_eq!(call.callee_target_kind.as_str(), "external");
2671        assert_eq!(
2672            call.callee_external_module.as_deref(),
2673            Some("package:flutter/material.dart")
2674        );
2675        assert!(resolver.requests.iter().any(|request| {
2676            request.language == "dart"
2677                && request.file_path == path
2678                && request.root_path == root
2679                && request.callee_name == "Tooltip"
2680        }));
2681    }
2682
2683    #[test]
2684    fn semantic_resolver_receives_utf16_columns_for_ast_calls() {
2685        let tempdir = TempDir::new().expect("create tempdir");
2686        let root = tempdir.path();
2687        let path = root.join("src/main.cpp");
2688        fs::create_dir_all(path.parent().expect("parent")).expect("create parent dirs");
2689        let source = format!(
2690            "void run() {{\n    auto s = \"{}\"; printf(\"x\");\n}}\n",
2691            '\u{1F600}'
2692        );
2693        fs::write(&path, source.as_bytes()).expect("write source");
2694        let candidates = discover_supported_files(root);
2695        let context = build_import_resolution_context(root, &candidates);
2696        let mut resolver = FakeSemanticResolver {
2697            target: None,
2698            expected_language: "cpp",
2699            expected_callee: "printf",
2700            requests: Vec::new(),
2701            error: None,
2702        };
2703
2704        parse_file_with_semantic(&path, "proj", root, &[], &context, Some(&mut resolver))
2705            .expect("parse result")
2706            .expect("parse file");
2707
2708        let request = resolver
2709            .requests
2710            .iter()
2711            .find(|request| request.callee_name == "printf")
2712            .expect("printf semantic request");
2713        let prefix = format!("    auto s = \"{}\"; ", '\u{1F600}');
2714        assert_eq!(request.line, 2);
2715        assert_eq!(request.column, prefix.encode_utf16().count());
2716    }
2717
2718    #[test]
2719    fn semantic_resolver_receives_utf16_columns_for_textual_dart_calls() {
2720        let tempdir = TempDir::new().expect("create tempdir");
2721        let root = tempdir.path();
2722        let path = root.join("lib/sample.dart");
2723        fs::create_dir_all(path.parent().expect("parent")).expect("create parent dirs");
2724        let source = format!(
2725            "void run() {{\n  final s = '{}'; Tooltip(message: 'x');\n}}\n",
2726            '\u{1F600}'
2727        );
2728        fs::write(&path, source.as_bytes()).expect("write source");
2729        let candidates = discover_supported_files(root);
2730        let context = build_import_resolution_context(root, &candidates);
2731        let mut resolver = FakeSemanticResolver {
2732            target: None,
2733            expected_language: "dart",
2734            expected_callee: "Tooltip",
2735            requests: Vec::new(),
2736            error: None,
2737        };
2738
2739        parse_file_with_semantic(&path, "proj", root, &[], &context, Some(&mut resolver))
2740            .expect("parse result")
2741            .expect("parse file");
2742
2743        let request = resolver
2744            .requests
2745            .iter()
2746            .find(|request| request.callee_name == "Tooltip")
2747            .expect("Tooltip semantic request");
2748        let prefix = format!("  final s = '{}'; ", '\u{1F600}');
2749        assert_eq!(request.line, 2);
2750        assert_eq!(request.column, prefix.encode_utf16().count());
2751    }
2752
2753    #[test]
2754    fn semantic_resolver_errors_are_propagated() {
2755        let tempdir = TempDir::new().expect("create tempdir");
2756        let root = tempdir.path();
2757        let path = root.join("src/main.cpp");
2758        fs::create_dir_all(path.parent().expect("parent")).expect("create parent dirs");
2759        fs::write(
2760            &path,
2761            r#"
2762void run() {
2763    printf("x");
2764}
2765"#,
2766        )
2767        .expect("write source");
2768        let candidates = discover_supported_files(root);
2769        let context = build_import_resolution_context(root, &candidates);
2770        let mut resolver = FakeSemanticResolver {
2771            target: None,
2772            expected_language: "cpp",
2773            expected_callee: "printf",
2774            requests: Vec::new(),
2775            error: Some("semantic resolver failed"),
2776        };
2777
2778        let err =
2779            match parse_file_with_semantic(&path, "proj", root, &[], &context, Some(&mut resolver))
2780            {
2781                Err(err) => err,
2782                Ok(_) => panic!("expected semantic resolver error"),
2783            };
2784
2785        assert_eq!(err.to_string(), "semantic resolver failed");
2786    }
2787
2788    #[test]
2789    fn classifies_external_swift_module_qualified_calls() {
2790        let parsed = parse_swift(
2791            r#"
2792import Foundation
2793
2794func run() {
2795    Foundation.Date()
2796}
2797"#,
2798            &[],
2799        );
2800
2801        let call = parsed.calls.first().expect("call");
2802        assert_eq!(call.callee_target_kind.as_str(), "external");
2803        assert_eq!(call.callee_name, "Date");
2804        assert_eq!(call.callee_external_module.as_deref(), Some("Foundation"));
2805    }
2806
2807    #[test]
2808    fn classifies_external_swift_scoped_import_module_qualified_calls() {
2809        let parsed = parse_swift(
2810            r#"
2811import struct Foundation.Date
2812
2813func run() {
2814    Foundation.Date()
2815}
2816"#,
2817            &[],
2818        );
2819
2820        let call = parsed.calls.first().expect("call");
2821        assert_eq!(call.callee_target_kind.as_str(), "external");
2822        assert_eq!(call.callee_name, "Date");
2823        assert_eq!(call.callee_external_module.as_deref(), Some("Foundation"));
2824    }
2825
2826    #[test]
2827    fn leaves_swift_unqualified_and_member_calls_unresolved() {
2828        let parsed = parse_swift(
2829            r#"
2830import Foundation
2831
2832func run(date: Date) {
2833    Date()
2834    date.formatted()
2835}
2836"#,
2837            &[],
2838        );
2839
2840        assert_eq!(parsed.calls.len(), 2);
2841        assert!(
2842            parsed
2843                .calls
2844                .iter()
2845                .all(|call| call.callee_target_kind.as_str() == "unresolved")
2846        );
2847    }
2848}