Skip to main content

semantic/
symbol_resolver.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Tree-sitter based symbol resolution for source files.
3//!
4//! Resolves symbol names (like `Repository::open` or `cmd_context_get`)
5//! to line ranges in source files by parsing the AST with tree-sitter.
6//!
7//! Lives in the `semantic` crate so anchor-travel code in `objects`-adjacent
8//! modules can use it without a `repo` dependency. The `repo` crate
9//! re-exports the public surface for backwards compatibility.
10
11use std::path::Path;
12
13use crate::{parser::Language, symbol_extraction::find_definitions};
14
15/// Result of resolving a symbol to lines in a source file.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct ResolvedSymbol {
18    /// The matched symbol name.
19    pub name: String,
20    /// 1-indexed start line (inclusive).
21    pub start_line: u32,
22    /// 1-indexed end line (inclusive).
23    pub end_line: u32,
24    /// Parent scope name, if any (e.g., the impl block or class name).
25    pub parent_name: Option<String>,
26}
27
28/// Errors that can occur during symbol resolution.
29#[derive(Debug, thiserror::Error)]
30pub enum SymbolResolveError {
31    #[error("unsupported file extension: {0}")]
32    UnsupportedLanguage(String),
33
34    #[error("failed to parse source file")]
35    ParseFailed,
36
37    #[error("symbol not found: {0}")]
38    SymbolNotFound(String),
39}
40
41/// Coarse symbol classification used by the reading-order partition.
42/// Mirrors the `state_review::SymbolKind` taxonomy without taking a
43/// dependency on that crate — the consumer maps these tags to the
44/// state-review enum.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum DefinitionKind {
47    /// Type / struct definition.
48    Type,
49    /// Trait declaration (Rust).
50    Trait,
51    /// Class declaration (Python / JS / TS / Java / C++).
52    Class,
53    /// Interface declaration (TS / Java / Go).
54    Interface,
55    /// Type alias (`type Foo = ...`).
56    TypeAlias,
57    /// Enum definition.
58    EnumDef,
59    /// Constant declaration at module scope.
60    ConstDecl,
61    /// Module / namespace.
62    Module,
63    /// Function body — the consequence tier.
64    Function,
65    /// Anything we could parse but couldn't classify.
66    Other,
67}
68
69/// One definition found in a source file.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct Definition {
72    /// Symbol name as it appears in the AST. For methods this is the
73    /// bare name; the parent scope is captured separately so callers
74    /// can build a qualified `Parent::method` form when they want one.
75    pub name: String,
76    pub kind: DefinitionKind,
77    /// 1-indexed start line, inclusive.
78    pub start_line: u32,
79    /// 1-indexed end line, inclusive.
80    pub end_line: u32,
81    /// Surrounding scope name (impl block, class, namespace, ...).
82    pub parent_name: Option<String>,
83}
84
85/// Walk the source file and return one [`Definition`] per top-level or
86/// nested definition node. Returns `Ok(vec![])` for files we can parse
87/// but contain no definitions, `Err(UnsupportedLanguage)` for files
88/// without a tree-sitter parser (binaries, unknown extensions),
89/// `Err(ParseFailed)` if the parser errored. Callers should treat the
90/// `UnsupportedLanguage` arm as "fall back to path-only projection".
91pub fn extract_definitions(
92    source: &[u8],
93    path: &Path,
94) -> Result<Vec<Definition>, SymbolResolveError> {
95    let language = Language::from_path(path).parser_handle().ok_or_else(|| {
96        SymbolResolveError::UnsupportedLanguage(
97            path.extension()
98                .map(|e| e.to_string_lossy().into_owned())
99                .unwrap_or_else(|| "<none>".to_string()),
100        )
101    })?;
102
103    let mut parser = tree_sitter::Parser::new();
104    parser
105        .set_language(&language)
106        .map_err(|_| SymbolResolveError::ParseFailed)?;
107
108    let tree = parser
109        .parse(source, None)
110        .ok_or(SymbolResolveError::ParseFailed)?;
111
112    let mut out = Vec::new();
113    walk_definitions(&tree.root_node(), source, None, &mut out);
114    Ok(out)
115}
116
117fn node_text<'a>(node: &tree_sitter::Node, source: &'a [u8]) -> &'a str {
118    std::str::from_utf8(&source[node.byte_range()]).unwrap_or("")
119}
120
121fn push_named_definition(
122    node: &tree_sitter::Node,
123    source: &[u8],
124    dk: DefinitionKind,
125    parent: Option<&str>,
126    out: &mut Vec<Definition>,
127) {
128    if let Some(name_node) = node.child_by_field_name("name") {
129        let name = node_text(&name_node, source).to_string();
130        if name.is_empty() {
131            return;
132        }
133        out.push(Definition {
134            name,
135            kind: dk,
136            start_line: node.start_position().row as u32 + 1,
137            end_line: node.end_position().row as u32 + 1,
138            parent_name: parent.map(String::from),
139        });
140    }
141}
142
143fn walk_definitions(
144    node: &tree_sitter::Node,
145    source: &[u8],
146    current_parent: Option<&str>,
147    out: &mut Vec<Definition>,
148) {
149    let kind = node.kind();
150
151    match kind {
152        // ── Rust ──────────────────────────────────────────────
153        "function_item" => {
154            push_named_definition(node, source, DefinitionKind::Function, current_parent, out)
155        }
156        "struct_item" => {
157            push_named_definition(node, source, DefinitionKind::Type, current_parent, out)
158        }
159        "enum_item" => {
160            push_named_definition(node, source, DefinitionKind::EnumDef, current_parent, out)
161        }
162        "trait_item" => {
163            push_named_definition(node, source, DefinitionKind::Trait, current_parent, out)
164        }
165        "type_item" => {
166            push_named_definition(node, source, DefinitionKind::TypeAlias, current_parent, out)
167        }
168        "const_item" | "static_item" => {
169            push_named_definition(node, source, DefinitionKind::ConstDecl, current_parent, out)
170        }
171        "mod_item" => {
172            push_named_definition(node, source, DefinitionKind::Module, current_parent, out)
173        }
174        "impl_item" => {
175            // Walk children with the impl's type as parent so methods
176            // get the qualified parent name.
177            let parent_name = extract_rust_impl_type_name(node, source);
178            let parent = parent_name.as_deref();
179            let mut cursor = node.walk();
180            for child in node.children(&mut cursor) {
181                walk_definitions(&child, source, parent, out);
182            }
183            return;
184        }
185
186        // ── Python ───────────────────────────────────────────
187        "function_definition" => {
188            push_named_definition(node, source, DefinitionKind::Function, current_parent, out)
189        }
190        "class_definition" => {
191            let class_name = node
192                .child_by_field_name("name")
193                .map(|n| node_text(&n, source).to_string());
194            if let Some(ref name) = class_name
195                && !name.is_empty()
196            {
197                out.push(Definition {
198                    name: name.clone(),
199                    kind: DefinitionKind::Class,
200                    start_line: node.start_position().row as u32 + 1,
201                    end_line: node.end_position().row as u32 + 1,
202                    parent_name: current_parent.map(String::from),
203                });
204            }
205            let parent = class_name.as_deref();
206            let mut cursor = node.walk();
207            for child in node.children(&mut cursor) {
208                walk_definitions(&child, source, parent, out);
209            }
210            return;
211        }
212
213        // ── Go ───────────────────────────────────────────────
214        "function_declaration" => {
215            // Note: Go and JS/TS share this kind. The kind is `Function`
216            // either way so we just emit it.
217            push_named_definition(node, source, DefinitionKind::Function, current_parent, out)
218        }
219        "method_declaration" => {
220            if let Some(name_node) = node.child_by_field_name("name") {
221                let name = node_text(&name_node, source).to_string();
222                if !name.is_empty() {
223                    let receiver = extract_go_receiver_type(node, source);
224                    out.push(Definition {
225                        name,
226                        kind: DefinitionKind::Function,
227                        start_line: node.start_position().row as u32 + 1,
228                        end_line: node.end_position().row as u32 + 1,
229                        parent_name: receiver.or_else(|| current_parent.map(String::from)),
230                    });
231                }
232            }
233        }
234        "type_declaration" => {
235            // Go: `type Foo struct { ... }` or `type Foo interface { ... }`.
236            let mut cursor = node.walk();
237            for child in node.children(&mut cursor) {
238                if child.kind() == "type_spec"
239                    && let Some(name_node) = child.child_by_field_name("name")
240                {
241                    let name = node_text(&name_node, source).to_string();
242                    if name.is_empty() {
243                        continue;
244                    }
245                    let dk = match child.child_by_field_name("type").map(|t| t.kind()) {
246                        Some("interface_type") => DefinitionKind::Interface,
247                        Some("struct_type") => DefinitionKind::Type,
248                        _ => DefinitionKind::TypeAlias,
249                    };
250                    out.push(Definition {
251                        name,
252                        kind: dk,
253                        start_line: child.start_position().row as u32 + 1,
254                        end_line: child.end_position().row as u32 + 1,
255                        parent_name: current_parent.map(String::from),
256                    });
257                }
258            }
259        }
260
261        // ── JavaScript / TypeScript ──────────────────────────
262        "method_definition" => {
263            push_named_definition(node, source, DefinitionKind::Function, current_parent, out)
264        }
265        "class_declaration" => {
266            let class_name = node
267                .child_by_field_name("name")
268                .map(|n| node_text(&n, source).to_string());
269            if let Some(ref name) = class_name
270                && !name.is_empty()
271            {
272                out.push(Definition {
273                    name: name.clone(),
274                    kind: DefinitionKind::Class,
275                    start_line: node.start_position().row as u32 + 1,
276                    end_line: node.end_position().row as u32 + 1,
277                    parent_name: current_parent.map(String::from),
278                });
279            }
280            let parent = class_name.as_deref();
281            let mut cursor = node.walk();
282            for child in node.children(&mut cursor) {
283                walk_definitions(&child, source, parent, out);
284            }
285            return;
286        }
287        "interface_declaration" => {
288            push_named_definition(node, source, DefinitionKind::Interface, current_parent, out)
289        }
290        "type_alias_declaration" => {
291            push_named_definition(node, source, DefinitionKind::TypeAlias, current_parent, out)
292        }
293        "enum_declaration" => {
294            push_named_definition(node, source, DefinitionKind::EnumDef, current_parent, out)
295        }
296        "lexical_declaration" | "variable_declaration" => {
297            // `const foo = () => { ... }` or `const foo = function() { ... }`
298            let mut cursor = node.walk();
299            for child in node.children(&mut cursor) {
300                if child.kind() == "variable_declarator"
301                    && let Some(name_node) = child.child_by_field_name("name")
302                {
303                    let name = node_text(&name_node, source).to_string();
304                    if name.is_empty() {
305                        continue;
306                    }
307                    if let Some(value_node) = child.child_by_field_name("value") {
308                        let vkind = value_node.kind();
309                        let dk = if vkind == "arrow_function"
310                            || vkind == "function"
311                            || vkind == "function_expression"
312                        {
313                            DefinitionKind::Function
314                        } else {
315                            DefinitionKind::ConstDecl
316                        };
317                        out.push(Definition {
318                            name,
319                            kind: dk,
320                            start_line: node.start_position().row as u32 + 1,
321                            end_line: node.end_position().row as u32 + 1,
322                            parent_name: current_parent.map(String::from),
323                        });
324                    }
325                }
326            }
327        }
328
329        // ── C / C++ / Java ───────────────────────────────────
330        "struct_specifier" | "class_specifier" => {
331            push_named_definition(node, source, DefinitionKind::Class, current_parent, out)
332        }
333        "namespace_definition" => {
334            push_named_definition(node, source, DefinitionKind::Module, current_parent, out)
335        }
336        "enum_specifier" => {
337            push_named_definition(node, source, DefinitionKind::EnumDef, current_parent, out)
338        }
339        "constructor_declaration" => {
340            push_named_definition(node, source, DefinitionKind::Function, current_parent, out)
341        }
342
343        _ => {}
344    }
345
346    // Default recursive descent for non-scope-introducing nodes.
347    let mut cursor = node.walk();
348    for child in node.children(&mut cursor) {
349        walk_definitions(&child, source, current_parent, out);
350    }
351}
352
353fn extract_rust_impl_type_name(node: &tree_sitter::Node, source: &[u8]) -> Option<String> {
354    let type_node = node.child_by_field_name("type")?;
355    Some(extract_type_identifier(&type_node, source))
356}
357
358fn extract_type_identifier(node: &tree_sitter::Node, source: &[u8]) -> String {
359    match node.kind() {
360        "type_identifier" | "identifier" => node_text(node, source).to_string(),
361        "generic_type" | "scoped_type_identifier" => {
362            let mut cursor = node.walk();
363            for child in node.children(&mut cursor) {
364                if child.kind() == "type_identifier" || child.kind() == "identifier" {
365                    return node_text(&child, source).to_string();
366                }
367            }
368            node_text(node, source).to_string()
369        }
370        _ => node_text(node, source).to_string(),
371    }
372}
373
374fn extract_go_receiver_type(node: &tree_sitter::Node, source: &[u8]) -> Option<String> {
375    let params = node.child_by_field_name("receiver")?;
376    let mut cursor = params.walk();
377    for child in params.children(&mut cursor) {
378        if child.kind() == "parameter_declaration"
379            && let Some(type_node) = child.child_by_field_name("type")
380        {
381            let text = node_text(&type_node, source);
382            return Some(text.trim_start_matches('*').to_string());
383        }
384    }
385    None
386}
387
388/// Resolve a symbol name to a line range in source code.
389///
390/// Supports qualified names like `Repository::open` (splits on `::`).
391/// For qualified names, the part before `::` is matched against the parent
392/// scope (impl block, class, etc.) and the part after is the definition name.
393///
394/// Returns `(start_line, end_line)` as 1-indexed, inclusive line numbers.
395pub fn resolve_symbol_lines(
396    source: &[u8],
397    path: &Path,
398    symbol: &str,
399) -> Result<(u32, u32), SymbolResolveError> {
400    let language = Language::from_path(path).parser_handle().ok_or_else(|| {
401        SymbolResolveError::UnsupportedLanguage(
402            path.extension()
403                .map(|e| e.to_string_lossy().into_owned())
404                .unwrap_or_else(|| "<none>".to_string()),
405        )
406    })?;
407
408    let mut parser = tree_sitter::Parser::new();
409    parser
410        .set_language(&language)
411        .map_err(|_| SymbolResolveError::ParseFailed)?;
412
413    let tree = parser
414        .parse(source, None)
415        .ok_or(SymbolResolveError::ParseFailed)?;
416
417    // Split qualified name: "Repository::open" -> parent="Repository", target="open"
418    let (parent_filter, target_name) = if let Some(pos) = symbol.rfind("::") {
419        (Some(&symbol[..pos]), &symbol[pos + 2..])
420    } else {
421        (None, symbol)
422    };
423
424    let definitions = find_definitions(&tree.root_node(), source, target_name);
425
426    // If a parent filter is specified, prefer matches where the parent matches.
427    let matched = if let Some(parent) = parent_filter {
428        definitions
429            .iter()
430            .find(|d| {
431                d.parent_name
432                    .as_deref()
433                    .map(|p| p == parent)
434                    .unwrap_or(false)
435            })
436            .or_else(|| definitions.first())
437    } else {
438        definitions.first()
439    };
440
441    match matched {
442        Some(sym) => Ok((sym.start_line, sym.end_line)),
443        None => Err(SymbolResolveError::SymbolNotFound(symbol.to_string())),
444    }
445}
446
447/// Resolve all definitions of a symbol name, returning all matches.
448///
449/// This is useful when a symbol appears in multiple contexts (e.g.,
450/// multiple impl blocks). Returns an empty vec if no matches found.
451pub fn resolve_all_symbols(
452    source: &[u8],
453    path: &Path,
454    symbol: &str,
455) -> Result<Vec<ResolvedSymbol>, SymbolResolveError> {
456    let language = Language::from_path(path).parser_handle().ok_or_else(|| {
457        SymbolResolveError::UnsupportedLanguage(
458            path.extension()
459                .map(|e| e.to_string_lossy().into_owned())
460                .unwrap_or_else(|| "<none>".to_string()),
461        )
462    })?;
463
464    let mut parser = tree_sitter::Parser::new();
465    parser
466        .set_language(&language)
467        .map_err(|_| SymbolResolveError::ParseFailed)?;
468
469    let tree = parser
470        .parse(source, None)
471        .ok_or(SymbolResolveError::ParseFailed)?;
472
473    let (parent_filter, target_name) = if let Some(pos) = symbol.rfind("::") {
474        (Some(&symbol[..pos]), &symbol[pos + 2..])
475    } else {
476        (None, symbol)
477    };
478
479    let definitions = find_definitions(&tree.root_node(), source, target_name);
480
481    if let Some(parent) = parent_filter {
482        let filtered: Vec<_> = definitions
483            .into_iter()
484            .filter(|d| {
485                d.parent_name
486                    .as_deref()
487                    .map(|p| p == parent)
488                    .unwrap_or(false)
489            })
490            .collect();
491        Ok(filtered)
492    } else {
493        Ok(definitions)
494    }
495}
496
497/// Extract a range of lines from source bytes.
498///
499/// `start` and `end` are 1-indexed, inclusive. Returns the bytes
500/// for those lines (including newlines).
501pub fn extract_line_range(source: &[u8], start: u32, end: u32) -> Vec<u8> {
502    let mut line: u32 = 1;
503    let mut byte_start = 0;
504
505    for (i, &b) in source.iter().enumerate() {
506        if line == start {
507            byte_start = i;
508            break;
509        }
510        if b == b'\n' {
511            line += 1;
512        }
513    }
514
515    if line < start {
516        return Vec::new();
517    }
518
519    for (i, &b) in source[byte_start..].iter().enumerate() {
520        if b == b'\n' {
521            line += 1;
522            if line > end {
523                return source[byte_start..byte_start + i + 1].to_vec();
524            }
525        }
526    }
527
528    source[byte_start..].to_vec()
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534
535    #[test]
536    fn resolve_rust_fn_main() {
537        let source = br#"
538fn helper() -> bool {
539    true
540}
541
542fn main() {
543    println!("hello");
544    let x = 1;
545}
546
547fn after() {}
548"#;
549        let path = Path::new("test.rs");
550        let (start, end) = resolve_symbol_lines(source, path, "main").unwrap();
551        assert_eq!(start, 6);
552        assert_eq!(end, 9);
553    }
554
555    #[test]
556    fn resolve_rust_qualified_impl_method() {
557        let source = br#"
558struct Repository {
559    path: String,
560}
561
562impl Repository {
563    pub fn open(path: &str) -> Self {
564        Repository {
565            path: path.to_string(),
566        }
567    }
568
569    pub fn close(&self) {}
570}
571
572impl Default for Repository {
573    fn default() -> Self {
574        Repository::open(".")
575    }
576}
577"#;
578        let path = Path::new("repo.rs");
579        let (start, end) = resolve_symbol_lines(source, path, "Repository::open").unwrap();
580        assert_eq!(start, 7);
581        assert_eq!(end, 11);
582    }
583
584    #[test]
585    fn resolve_rust_struct() {
586        let source = br#"
587pub struct Config {
588    pub name: String,
589    pub value: u32,
590}
591"#;
592        let path = Path::new("config.rs");
593        let (start, end) = resolve_symbol_lines(source, path, "Config").unwrap();
594        assert_eq!(start, 2);
595        assert_eq!(end, 5);
596    }
597
598    #[test]
599    fn resolve_python_function() {
600        let source = br#"
601def helper():
602    pass
603
604def process_data(items):
605    result = []
606    for item in items:
607        result.append(item * 2)
608    return result
609
610def cleanup():
611    pass
612"#;
613        let path = Path::new("main.py");
614        let (start, end) = resolve_symbol_lines(source, path, "process_data").unwrap();
615        assert_eq!(start, 5);
616        assert_eq!(end, 9);
617    }
618
619    #[test]
620    fn resolve_python_class_method() {
621        let source = br#"
622class Repository:
623    def __init__(self, path):
624        self.path = path
625
626    def open(self):
627        return True
628"#;
629        let path = Path::new("repo.py");
630        let (start, end) = resolve_symbol_lines(source, path, "Repository::open").unwrap();
631        assert_eq!(start, 6);
632        assert_eq!(end, 7);
633    }
634
635    #[test]
636    #[cfg(feature = "lang-go")]
637    fn resolve_go_function() {
638        let source = br#"package main
639
640func helper() bool {
641    return true
642}
643
644func processData(items []int) []int {
645    result := make([]int, 0)
646    for _, item := range items {
647        result = append(result, item*2)
648    }
649    return result
650}
651"#;
652        let path = Path::new("main.go");
653        let (start, end) = resolve_symbol_lines(source, path, "processData").unwrap();
654        assert_eq!(start, 7);
655        assert_eq!(end, 13);
656    }
657
658    #[test]
659    fn resolve_symbol_not_found() {
660        let source = br#"
661fn main() {}
662"#;
663        let path = Path::new("test.rs");
664        let err = resolve_symbol_lines(source, path, "nonexistent").unwrap_err();
665        assert!(matches!(err, SymbolResolveError::SymbolNotFound(_)));
666    }
667
668    #[test]
669    fn resolve_unsupported_extension() {
670        let source = b"some content";
671        let path = Path::new("test.xyz");
672        let err = resolve_symbol_lines(source, path, "main").unwrap_err();
673        assert!(matches!(err, SymbolResolveError::UnsupportedLanguage(_)));
674    }
675
676    #[test]
677    fn extract_line_range_basic() {
678        let source = b"line 1\nline 2\nline 3\nline 4\nline 5\n";
679        let result = extract_line_range(source, 2, 4);
680        assert_eq!(result, b"line 2\nline 3\nline 4\n");
681    }
682
683    #[test]
684    fn extract_line_range_single_line() {
685        let source = b"line 1\nline 2\nline 3\n";
686        let result = extract_line_range(source, 2, 2);
687        assert_eq!(result, b"line 2\n");
688    }
689
690    #[test]
691    fn resolve_js_function_declaration() {
692        let source = br#"
693function helper() {
694    return true;
695}
696
697function processData(items) {
698    return items.map(x => x * 2);
699}
700"#;
701        let path = Path::new("main.js");
702        let (start, end) = resolve_symbol_lines(source, path, "processData").unwrap();
703        assert_eq!(start, 6);
704        assert_eq!(end, 8);
705    }
706
707    #[test]
708    fn resolve_js_arrow_function_const() {
709        let source = br#"
710const helper = () => true;
711
712const processData = (items) => {
713    return items.map(x => x * 2);
714};
715"#;
716        let path = Path::new("utils.js");
717        let (start, end) = resolve_symbol_lines(source, path, "processData").unwrap();
718        assert_eq!(start, 4);
719        assert_eq!(end, 6);
720    }
721
722    /// Regression: real-world TS code often defines methods as arrow-
723    /// function properties of an object literal (e.g. a `db` helper).
724    /// The variable_declarator branch missed these — `pair` handling
725    /// catches them. Without this, `heddle context set --scope symbol:insert`
726    /// against `export const db = { insert: async () => {...} }` shipped
727    /// `resolved_lines: None` and the chip never rendered.
728    #[test]
729    fn resolve_typescript_object_literal_property_arrow_function() {
730        let source = br#"
731export const db = {
732    query: async (sql: string) => {
733        return [];
734    },
735    insert: async (table: string, data: Record<string, any>) => {
736        const keys = Object.keys(data);
737        return keys;
738    },
739};
740"#;
741        let path = Path::new("db.ts");
742        let (start, end) = resolve_symbol_lines(source, path, "insert").unwrap();
743        // `insert` lives at lines 6–9 in the source above (1-indexed,
744        // counting the leading newline as line 1).
745        assert!((5..=7).contains(&start), "got start={start}");
746        assert!(end > start && end <= 10, "got end={end}");
747    }
748
749    #[test]
750    fn resolve_typescript_function() {
751        let source = br#"
752function helper(): boolean {
753    return true;
754}
755
756function processData(items: number[]): number[] {
757    return items.map(x => x * 2);
758}
759"#;
760        let path = Path::new("main.ts");
761        let (start, end) = resolve_symbol_lines(source, path, "processData").unwrap();
762        assert_eq!(start, 6);
763        assert_eq!(end, 8);
764    }
765
766    #[test]
767    fn resolve_all_returns_multiple_matches() {
768        let source = br#"
769impl Foo {
770    fn do_thing(&self) {}
771}
772
773impl Bar {
774    fn do_thing(&self) {}
775}
776"#;
777        let path = Path::new("test.rs");
778        let results = resolve_all_symbols(source, path, "do_thing").unwrap();
779        assert_eq!(results.len(), 2);
780        assert_eq!(results[0].parent_name.as_deref(), Some("Foo"));
781        assert_eq!(results[1].parent_name.as_deref(), Some("Bar"));
782    }
783}