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 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 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 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 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 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 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 let mut re_export_map: HashMap<(String, String), (String, String)> = HashMap::new();
411
412 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 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 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 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 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 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 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 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}