Skip to main content

codesynapse_core/
python_import_resolution.rs

1use crate::extract::make_id;
2use crate::types::{Edge, Node};
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6struct FileFact {
7    rel_path: String,
8    file_id: String,
9}
10
11struct DeclFact {
12    file_rel: String,
13    name: String,
14    node_id: String,
15}
16
17struct ImportFact {
18    importer_rel: String,
19    module: String,
20    imported_name: String,
21    local_name: String,
22    is_relative: bool,
23    dot_level: usize,
24}
25
26struct TypeAnnotFact {
27    fn_rel: String,
28    fn_name: String,
29    type_name: String,
30    context: String,
31}
32
33struct CallFact {
34    caller_rel: String,
35    caller_fn: String,
36    callee_name: String,
37}
38
39fn ntext<'a>(source: &'a [u8], node: &tree_sitter::Node) -> &'a str {
40    std::str::from_utf8(&source[node.start_byte()..node.end_byte()]).unwrap_or("")
41}
42
43fn make_file_id(rel_path: &str) -> String {
44    make_id(&[rel_path])
45}
46
47fn make_sym_id(file_rel: &str, name: &str, is_fn: bool) -> String {
48    if is_fn {
49        make_id(&[file_rel, name, "()"])
50    } else {
51        make_id(&[file_rel, name])
52    }
53}
54
55fn walk_all(node: tree_sitter::Node) -> Vec<tree_sitter::Node> {
56    let mut stack = vec![node];
57    let mut out = Vec::new();
58    while let Some(n) = stack.pop() {
59        out.push(n);
60        let mut c = n.walk();
61        for child in n.children(&mut c) {
62            stack.push(child);
63        }
64    }
65    out
66}
67
68fn make_code_node(id: String, label: String, rel_path: &str) -> Node {
69    Node {
70        id,
71        label,
72        file_type: "code".to_string(),
73        source_file: rel_path.to_string(),
74        source_location: None,
75        community: None,
76        rationale: None,
77        docstring: None,
78        metadata: HashMap::new(),
79    }
80}
81
82#[allow(clippy::too_many_arguments)]
83fn parse_file(
84    source: &[u8],
85    rel_path: &str,
86    file_facts: &mut Vec<FileFact>,
87    decl_facts: &mut Vec<DeclFact>,
88    import_facts: &mut Vec<ImportFact>,
89    type_annot_facts: &mut Vec<TypeAnnotFact>,
90    call_facts: &mut Vec<CallFact>,
91    all_nodes: &mut Vec<Node>,
92) {
93    let mut parser = tree_sitter::Parser::new();
94    let lang: tree_sitter::Language = tree_sitter_python::LANGUAGE.into();
95    if parser.set_language(&lang).is_err() {
96        return;
97    }
98    let tree = match parser.parse(source, None) {
99        Some(t) => t,
100        None => return,
101    };
102
103    let file_id = make_file_id(rel_path);
104    let file_label = Path::new(rel_path)
105        .file_name()
106        .unwrap_or_default()
107        .to_string_lossy()
108        .to_string();
109
110    file_facts.push(FileFact {
111        rel_path: rel_path.to_string(),
112        file_id: file_id.clone(),
113    });
114    all_nodes.push(make_code_node(file_id.clone(), file_label, rel_path));
115
116    let root = tree.root_node();
117
118    for node in walk_all(root) {
119        match node.kind() {
120            "function_definition" => {
121                if let Some(name_node) = node.child_by_field_name("name") {
122                    let name = ntext(source, &name_node).to_string();
123                    let node_id = make_sym_id(rel_path, &name, true);
124                    decl_facts.push(DeclFact {
125                        file_rel: rel_path.to_string(),
126                        name: name.clone(),
127                        node_id: node_id.clone(),
128                    });
129                    all_nodes.push(make_code_node(node_id, format!("{}()", name), rel_path));
130
131                    // Collect type annotations from parameters
132                    if let Some(params) = node.child_by_field_name("parameters") {
133                        let mut pc = params.walk();
134                        for param in params.children(&mut pc) {
135                            if param.kind() == "typed_parameter"
136                                || param.kind() == "typed_default_parameter"
137                            {
138                                if let Some(type_node) = param.child_by_field_name("type") {
139                                    collect_type_refs(
140                                        source,
141                                        type_node,
142                                        &name,
143                                        rel_path,
144                                        "parameter_type",
145                                        "generic_arg",
146                                        type_annot_facts,
147                                    );
148                                }
149                            }
150                        }
151                    }
152
153                    // Collect return type
154                    if let Some(ret_node) = node.child_by_field_name("return_type") {
155                        collect_type_refs(
156                            source,
157                            ret_node,
158                            &name,
159                            rel_path,
160                            "return_type",
161                            "return_generic_arg",
162                            type_annot_facts,
163                        );
164                    }
165
166                    // Collect calls in body
167                    if let Some(body) = node.child_by_field_name("body") {
168                        for bn in walk_all(body) {
169                            if bn.kind() == "call" {
170                                if let Some(fn_node) = bn.child_by_field_name("function") {
171                                    if fn_node.kind() == "identifier" {
172                                        let callee = ntext(source, &fn_node).to_string();
173                                        call_facts.push(CallFact {
174                                            caller_rel: rel_path.to_string(),
175                                            caller_fn: name.clone(),
176                                            callee_name: callee,
177                                        });
178                                    }
179                                }
180                            }
181                        }
182                    }
183                }
184            }
185            "class_definition" => {
186                if let Some(name_node) = node.child_by_field_name("name") {
187                    let name = ntext(source, &name_node).to_string();
188                    let node_id = make_sym_id(rel_path, &name, false);
189                    decl_facts.push(DeclFact {
190                        file_rel: rel_path.to_string(),
191                        name: name.clone(),
192                        node_id: node_id.clone(),
193                    });
194                    all_nodes.push(make_code_node(node_id, name, rel_path));
195                }
196            }
197            "import_from_statement" => {
198                let module_node = match node.child_by_field_name("module_name") {
199                    Some(n) => n,
200                    None => continue,
201                };
202                let is_relative = module_node.kind() == "relative_import";
203                let (dot_level, module_name) = if is_relative {
204                    let mut dots = 0usize;
205                    let mut mod_name = String::new();
206                    let mut mc = module_node.walk();
207                    for child in module_node.children(&mut mc) {
208                        match child.kind() {
209                            "import_prefix" => {
210                                dots = ntext(source, &child).chars().filter(|&c| c == '.').count();
211                            }
212                            "dotted_name" => {
213                                mod_name = ntext(source, &child).to_string();
214                            }
215                            _ => {}
216                        }
217                    }
218                    (dots, mod_name)
219                } else {
220                    (0, ntext(source, &module_node).to_string())
221                };
222
223                let module_id = module_node.id();
224                let mut nc = node.walk();
225                for child in node.children(&mut nc) {
226                    if child.id() == module_id {
227                        continue;
228                    }
229                    match child.kind() {
230                        "dotted_name" => {
231                            let imported = ntext(source, &child).to_string();
232                            import_facts.push(ImportFact {
233                                importer_rel: rel_path.to_string(),
234                                module: module_name.clone(),
235                                imported_name: imported.clone(),
236                                local_name: imported,
237                                is_relative,
238                                dot_level,
239                            });
240                        }
241                        "aliased_import" => {
242                            let orig = child
243                                .child_by_field_name("name")
244                                .map(|n| ntext(source, &n).to_string())
245                                .unwrap_or_default();
246                            // name field on aliased_import is dotted_name; text is the full thing
247                            // Get just the last identifier
248                            let orig_simple =
249                                orig.split('.').next_back().unwrap_or(&orig).to_string();
250                            let alias = child
251                                .child_by_field_name("alias")
252                                .map(|n| ntext(source, &n).to_string())
253                                .unwrap_or_else(|| orig_simple.clone());
254                            import_facts.push(ImportFact {
255                                importer_rel: rel_path.to_string(),
256                                module: module_name.clone(),
257                                imported_name: orig_simple,
258                                local_name: alias,
259                                is_relative,
260                                dot_level,
261                            });
262                        }
263                        _ => {}
264                    }
265                }
266            }
267            _ => {}
268        }
269    }
270}
271
272fn collect_type_refs(
273    source: &[u8],
274    type_node: tree_sitter::Node,
275    fn_name: &str,
276    rel_path: &str,
277    simple_ctx: &str,
278    generic_ctx: &str,
279    facts: &mut Vec<TypeAnnotFact>,
280) {
281    let mut tc = type_node.walk();
282    for child in type_node.children(&mut tc) {
283        if !child.is_named() {
284            continue;
285        }
286        match child.kind() {
287            "identifier" => {
288                let name = ntext(source, &child);
289                if !is_builtin(name) {
290                    facts.push(TypeAnnotFact {
291                        fn_rel: rel_path.to_string(),
292                        fn_name: fn_name.to_string(),
293                        type_name: name.to_string(),
294                        context: simple_ctx.to_string(),
295                    });
296                }
297            }
298            "generic_type" => {
299                // generic_type: identifier (outer) + type_parameter (args)
300                let mut gc = child.walk();
301                for gchild in child.children(&mut gc) {
302                    if gchild.kind() == "type_parameter" {
303                        let mut tpc = gchild.walk();
304                        for tnode in gchild.children(&mut tpc) {
305                            if tnode.kind() == "type" {
306                                collect_type_refs(
307                                    source,
308                                    tnode,
309                                    fn_name,
310                                    rel_path,
311                                    generic_ctx,
312                                    generic_ctx,
313                                    facts,
314                                );
315                            }
316                        }
317                    }
318                }
319            }
320            _ => {}
321        }
322    }
323}
324
325fn is_builtin(name: &str) -> bool {
326    matches!(
327        name,
328        "int"
329            | "str"
330            | "float"
331            | "bool"
332            | "bytes"
333            | "None"
334            | "list"
335            | "dict"
336            | "set"
337            | "tuple"
338            | "Any"
339            | "Optional"
340            | "Union"
341            | "List"
342            | "Dict"
343            | "Set"
344            | "Tuple"
345            | "Type"
346            | "Callable"
347            | "Iterable"
348            | "Iterator"
349    )
350}
351
352fn resolve_relative(importer_rel: &str, dots: usize, module: &str) -> String {
353    let path = Path::new(importer_rel);
354    let mut dir = path.parent().unwrap_or(Path::new("")).to_path_buf();
355    for _ in 1..dots {
356        dir = dir.parent().unwrap_or(Path::new("")).to_path_buf();
357    }
358    if module.is_empty() {
359        dir.join("__init__.py").to_string_lossy().replace('\\', "/")
360    } else {
361        dir.join(format!("{}.py", module.replace('.', "/")))
362            .to_string_lossy()
363            .replace('\\', "/")
364    }
365}
366
367pub fn extract_python_files(paths: &[PathBuf], root: &Path) -> (Vec<Node>, Vec<Edge>) {
368    let mut file_facts: Vec<FileFact> = Vec::new();
369    let mut decl_facts: Vec<DeclFact> = Vec::new();
370    let mut import_facts: Vec<ImportFact> = Vec::new();
371    let mut type_annot_facts: Vec<TypeAnnotFact> = Vec::new();
372    let mut call_facts: Vec<CallFact> = Vec::new();
373    let mut all_nodes: Vec<Node> = Vec::new();
374
375    for path in paths {
376        let source = match std::fs::read(path) {
377            Ok(s) => s,
378            Err(_) => continue,
379        };
380        let rel = path.strip_prefix(root).unwrap_or(path);
381        let rel_str = rel.to_string_lossy().replace('\\', "/");
382        parse_file(
383            &source,
384            &rel_str,
385            &mut file_facts,
386            &mut decl_facts,
387            &mut import_facts,
388            &mut type_annot_facts,
389            &mut call_facts,
390            &mut all_nodes,
391        );
392    }
393
394    let file_id_map: HashMap<String, String> = file_facts
395        .iter()
396        .map(|f| (f.rel_path.clone(), f.file_id.clone()))
397        .collect();
398
399    // (file_rel, symbol_name) → node_id
400    let mut symbol_map: HashMap<(String, String), String> = HashMap::new();
401    for d in &decl_facts {
402        symbol_map.insert((d.file_rel.clone(), d.name.clone()), d.node_id.clone());
403    }
404
405    let known_files: Vec<String> = file_facts.iter().map(|f| f.rel_path.clone()).collect();
406
407    let mut edges: Vec<Edge> = Vec::new();
408
409    // (barrel_file_rel, local_name) → (origin_file_rel, origin_name)
410    let mut re_export_map: HashMap<(String, String), (String, String)> = HashMap::new();
411
412    // Pass 1: handle relative imports from __init__.py (re-exports)
413    for imp in &import_facts {
414        if !imp.is_relative {
415            continue;
416        }
417        let target_file = resolve_relative(&imp.importer_rel, imp.dot_level, &imp.module);
418        let is_barrel = imp.importer_rel.ends_with("__init__.py");
419
420        if is_barrel {
421            if let (Some(barrel_id), Some(origin_id)) = (
422                file_id_map.get(&imp.importer_rel),
423                file_id_map.get(&target_file),
424            ) {
425                // Only emit re_exports once per barrel→target pair
426                let already = edges.iter().any(|e| {
427                    e.source == *barrel_id && e.target == *origin_id && e.relation == "re_exports"
428                });
429                if !already {
430                    edges.push(Edge {
431                        source: barrel_id.clone(),
432                        target: origin_id.clone(),
433                        relation: "re_exports".to_string(),
434                        confidence: "EXTRACTED".to_string(),
435                        source_file: Some(imp.importer_rel.clone()),
436                        weight: 1.0,
437                        context: None,
438                    });
439                }
440            }
441            re_export_map.insert(
442                (imp.importer_rel.clone(), imp.local_name.clone()),
443                (target_file, imp.imported_name.clone()),
444            );
445        }
446    }
447
448    // Pass 2: build import_resolution (all imports → origin symbol)
449    // (importer_rel, local_name) → (origin_file_rel, origin_name)
450    let mut import_resolution: HashMap<(String, String), (String, String)> = HashMap::new();
451
452    for imp in &import_facts {
453        if imp.is_relative {
454            let target_file = resolve_relative(&imp.importer_rel, imp.dot_level, &imp.module);
455            import_resolution.insert(
456                (imp.importer_rel.clone(), imp.local_name.clone()),
457                (target_file, imp.imported_name.clone()),
458            );
459        } else {
460            // Absolute: from pkg import X → look in pkg/__init__.py
461            let pkg_init = format!("{}/__init__.py", imp.module.replace('.', "/"));
462            if let Some(barrel_rel) = known_files.iter().find(|f| **f == pkg_init) {
463                let key = (barrel_rel.clone(), imp.imported_name.clone());
464                if let Some((origin_file, origin_name)) = re_export_map.get(&key) {
465                    // consumer imports origin_symbol
466                    if let (Some(consumer_id), Some(sym_id)) = (
467                        file_id_map.get(&imp.importer_rel),
468                        symbol_map.get(&(origin_file.clone(), origin_name.clone())),
469                    ) {
470                        edges.push(Edge {
471                            source: consumer_id.clone(),
472                            target: sym_id.clone(),
473                            relation: "imports".to_string(),
474                            confidence: "EXTRACTED".to_string(),
475                            source_file: Some(imp.importer_rel.clone()),
476                            weight: 1.0,
477                            context: None,
478                        });
479                    }
480                    import_resolution.insert(
481                        (imp.importer_rel.clone(), imp.local_name.clone()),
482                        (origin_file.clone(), origin_name.clone()),
483                    );
484                }
485            }
486        }
487    }
488
489    // Pass 3: calls
490    for call in &call_facts {
491        let caller_id = symbol_map.get(&(call.caller_rel.clone(), call.caller_fn.clone()));
492        let key = (call.caller_rel.clone(), call.callee_name.clone());
493        if let Some((origin_file, origin_name)) = import_resolution.get(&key) {
494            let callee_id = symbol_map.get(&(origin_file.clone(), origin_name.clone()));
495            if let (Some(cid), Some(eid)) = (caller_id, callee_id) {
496                edges.push(Edge {
497                    source: cid.clone(),
498                    target: eid.clone(),
499                    relation: "calls".to_string(),
500                    confidence: "EXTRACTED".to_string(),
501                    source_file: Some(call.caller_rel.clone()),
502                    weight: 1.0,
503                    context: None,
504                });
505            }
506        }
507    }
508
509    // Pass 4: type annotation references
510    for annot in &type_annot_facts {
511        let fn_id = symbol_map.get(&(annot.fn_rel.clone(), annot.fn_name.clone()));
512        let imp_key = (annot.fn_rel.clone(), annot.type_name.clone());
513        let type_id = if let Some((origin_file, origin_name)) = import_resolution.get(&imp_key) {
514            symbol_map
515                .get(&(origin_file.clone(), origin_name.clone()))
516                .cloned()
517        } else {
518            // Fallback: find any symbol with this name
519            symbol_map
520                .iter()
521                .find(|((_, name), _)| name == &annot.type_name)
522                .map(|(_, id)| id.clone())
523        };
524
525        if let (Some(fid), Some(tid)) = (fn_id, type_id) {
526            edges.push(Edge {
527                source: fid.clone(),
528                target: tid.clone(),
529                relation: "references".to_string(),
530                confidence: "EXTRACTED".to_string(),
531                source_file: Some(annot.fn_rel.clone()),
532                weight: 1.0,
533                context: Some(annot.context.clone()),
534            });
535        }
536    }
537
538    (all_nodes, edges)
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544    use std::collections::HashSet;
545
546    fn write(root: &Path, rel: &str, content: &str) -> PathBuf {
547        let p = root.join(rel);
548        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
549        std::fs::write(&p, content).unwrap();
550        p
551    }
552
553    fn node_id_by(nodes: &[Node], label: &str, source_file: &str) -> String {
554        nodes
555            .iter()
556            .find(|n| n.label == label && n.source_file == source_file)
557            .unwrap_or_else(|| {
558                panic!(
559                    "node not found: label={:?} source_file={:?}\nnodes: {:?}",
560                    label,
561                    source_file,
562                    nodes
563                        .iter()
564                        .map(|n| (&n.label, &n.source_file))
565                        .collect::<Vec<_>>()
566                )
567            })
568            .id
569            .clone()
570    }
571
572    fn has_edge(edges: &[Edge], src: &str, tgt: &str, rel: &str) -> bool {
573        edges
574            .iter()
575            .any(|e| e.source == src && e.target == tgt && e.relation == rel)
576    }
577
578    #[test]
579    fn test_python_package_reexport_resolves_import_and_call_to_origin_symbol() {
580        let dir = tempfile::tempdir().unwrap();
581        let root = dir.path();
582
583        write(root, "pkg/foo.py", "def Foo():\n    return 1\n");
584        write(
585            root,
586            "pkg/__init__.py",
587            "from .foo import Foo as PublicFoo\n",
588        );
589        write(
590            root,
591            "app.py",
592            "from pkg import PublicFoo\n\ndef X():\n    return PublicFoo()\n",
593        );
594
595        let paths = vec![
596            root.join("pkg/foo.py"),
597            root.join("pkg/__init__.py"),
598            root.join("app.py"),
599        ];
600        let (nodes, edges) = extract_python_files(&paths, root);
601
602        let origin_file = node_id_by(&nodes, "foo.py", "pkg/foo.py");
603        let barrel_file = node_id_by(&nodes, "__init__.py", "pkg/__init__.py");
604        let consumer_file = node_id_by(&nodes, "app.py", "app.py");
605        let origin_symbol = node_id_by(&nodes, "Foo()", "pkg/foo.py");
606        let consumer_symbol = node_id_by(&nodes, "X()", "app.py");
607
608        assert!(
609            has_edge(&edges, &barrel_file, &origin_file, "re_exports"),
610            "barrel→origin re_exports missing; edges: {:?}",
611            edges
612                .iter()
613                .map(|e| (&e.source, &e.target, &e.relation))
614                .collect::<Vec<_>>()
615        );
616        assert!(
617            has_edge(&edges, &consumer_file, &origin_symbol, "imports"),
618            "consumer_file→origin_symbol imports missing; edges: {:?}",
619            edges
620                .iter()
621                .map(|e| (&e.source, &e.target, &e.relation))
622                .collect::<Vec<_>>()
623        );
624        assert!(
625            has_edge(&edges, &consumer_symbol, &origin_symbol, "calls"),
626            "consumer_symbol→origin_symbol calls missing; edges: {:?}",
627            edges
628                .iter()
629                .map(|e| (&e.source, &e.target, &e.relation))
630                .collect::<Vec<_>>()
631        );
632    }
633
634    #[test]
635    fn test_python_parameter_return_and_generic_contexts() {
636        let dir = tempfile::tempdir().unwrap();
637        let root = dir.path();
638
639        write(
640            root,
641            "pkg/model.py",
642            "class Payload:\n    pass\n\nclass Result:\n    pass\n",
643        );
644        write(
645            root,
646            "pkg/service.py",
647            "from .model import Payload, Result\n\n\
648             def process(item: Payload) -> Result:\n    return Result()\n\n\
649             def process_many(items: list[Payload]) -> Result:\n    return Result()\n",
650        );
651
652        let paths = vec![root.join("pkg/model.py"), root.join("pkg/service.py")];
653        let (nodes, edges) = extract_python_files(&paths, root);
654
655        let labels: HashMap<String, String> = nodes
656            .iter()
657            .map(|n| (n.id.clone(), n.label.clone()))
658            .collect();
659
660        let pairs: HashSet<(String, String, Option<String>)> = edges
661            .iter()
662            .filter(|e| e.relation == "references")
663            .map(|e| {
664                (
665                    labels
666                        .get(&e.source)
667                        .cloned()
668                        .unwrap_or_else(|| e.source.clone()),
669                    labels
670                        .get(&e.target)
671                        .cloned()
672                        .unwrap_or_else(|| e.target.clone()),
673                    e.context.clone(),
674                )
675            })
676            .collect();
677
678        assert!(
679            pairs.contains(&(
680                "process()".to_string(),
681                "Payload".to_string(),
682                Some("parameter_type".to_string())
683            )),
684            "process()→Payload(parameter_type) missing; pairs={:?}",
685            pairs
686        );
687        assert!(
688            pairs.contains(&(
689                "process()".to_string(),
690                "Result".to_string(),
691                Some("return_type".to_string())
692            )),
693            "process()→Result(return_type) missing; pairs={:?}",
694            pairs
695        );
696        assert!(
697            pairs.contains(&(
698                "process_many()".to_string(),
699                "Payload".to_string(),
700                Some("generic_arg".to_string())
701            )),
702            "process_many()→Payload(generic_arg) missing; pairs={:?}",
703            pairs
704        );
705    }
706}