1use crate::error::{CodeSynapseError, Result};
2use crate::types::{Edge, ExtractionFragment, Node, NodeId};
3use std::collections::HashMap;
4use std::path::Path;
5
6pub trait LanguageExtractor {
7 fn file_extensions(&self) -> Vec<&'static str>;
8 fn extract(&self, source: &[u8], path: &Path) -> Result<ExtractionFragment>;
9 fn resolve_imports(&self, imports: &[ImportNode]) -> Vec<Edge>;
10 fn collect_type_refs(&self, fragment: &mut ExtractionFragment);
11}
12
13#[derive(Debug, Clone)]
14pub struct ImportNode {
15 pub source: String,
16 pub specifiers: Vec<String>,
17 pub is_relative: bool,
18}
19
20pub struct Extractor {
21 pub language_extractors: HashMap<String, Box<dyn LanguageExtractor + Send + Sync>>,
22}
23
24impl Extractor {
25 pub fn new() -> Self {
26 Extractor {
27 language_extractors: HashMap::new(),
28 }
29 }
30
31 pub fn register(&mut self, ext: &str, extractor: Box<dyn LanguageExtractor + Send + Sync>) {
32 self.language_extractors.insert(ext.to_string(), extractor);
33 }
34
35 pub fn extract_file(&self, path: &Path, source: &[u8]) -> Result<ExtractionFragment> {
36 let filename = path
38 .file_name()
39 .and_then(|n| n.to_str())
40 .unwrap_or("")
41 .to_lowercase();
42
43 if let Some(extractor) = self.language_extractors.get(filename.as_str()) {
44 return extractor.extract(source, path);
45 }
46
47 let ext = path
49 .extension()
50 .and_then(|e| e.to_str())
51 .unwrap_or("")
52 .to_lowercase();
53
54 if let Some(extractor) = self.language_extractors.get(ext.as_str()) {
55 extractor.extract(source, path)
56 } else {
57 Ok(ExtractionFragment {
59 nodes: vec![],
60 edges: vec![],
61 })
62 }
63 }
64
65 pub fn extract_all(
66 &self,
67 files: &[(std::path::PathBuf, &[u8])],
68 ) -> Vec<(std::path::PathBuf, Result<ExtractionFragment>)> {
69 files
70 .iter()
71 .map(|(path, source)| {
72 let result = self.extract_file(path, source);
73 (path.clone(), result)
74 })
75 .collect()
76 }
77}
78
79impl Default for Extractor {
80 fn default() -> Self {
81 Self::new()
82 }
83}
84
85pub struct PythonExtractor;
87
88impl PythonExtractor {
89 fn extract_fragment(source: &[u8], path: &Path) -> Result<ExtractionFragment> {
90 let content = std::str::from_utf8(source).map_err(|e| {
91 CodeSynapseError::Parse(format!("invalid UTF-8 in {}: {}", path.display(), e))
92 })?;
93
94 let mut nodes = Vec::new();
95 let mut edges = Vec::new();
96
97 let file_id = path_to_file_id(path);
98
99 nodes.push(Node {
101 id: file_id.clone(),
102 label: path
103 .file_name()
104 .unwrap_or_default()
105 .to_string_lossy()
106 .to_string(),
107 file_type: "code".to_string(),
108 source_file: path.to_string_lossy().to_string(),
109 source_location: None,
110 community: None,
111 rationale: None,
112 docstring: None,
113 metadata: HashMap::new(),
114 });
115
116 for cap in regex_class_captures(content) {
119 let class_name = cap;
120 let class_id = make_id(&[&file_id, class_name]);
121 nodes.push(Node {
122 id: class_id.clone(),
123 label: class_name.to_string(),
124 file_type: "code".to_string(),
125 source_file: path.to_string_lossy().to_string(),
126 source_location: None,
127 community: None,
128 rationale: None,
129 docstring: None,
130 metadata: HashMap::new(),
131 });
132 edges.push(Edge {
133 source: file_id.clone(),
134 target: class_id,
135 relation: "contains".to_string(),
136 confidence: "EXTRACTED".to_string(),
137 source_file: Some(path.to_string_lossy().to_string()),
138 weight: 1.0,
139 context: None,
140 });
141 }
142
143 for cap in regex_fn_captures(content) {
145 let fn_name = cap;
146 let fn_id = make_id(&[&file_id, fn_name, "()"]);
147 nodes.push(Node {
148 id: fn_id.clone(),
149 label: format!("{}()", fn_name),
150 file_type: "code".to_string(),
151 source_file: path.to_string_lossy().to_string(),
152 source_location: None,
153 community: None,
154 rationale: None,
155 docstring: None,
156 metadata: HashMap::new(),
157 });
158 edges.push(Edge {
159 source: file_id.clone(),
160 target: fn_id,
161 relation: "contains".to_string(),
162 confidence: "EXTRACTED".to_string(),
163 source_file: Some(path.to_string_lossy().to_string()),
164 weight: 1.0,
165 context: None,
166 });
167 }
168
169 for (module, alias) in regex_import_captures(content) {
171 let import_target = alias.unwrap_or(module);
172 let import_id = make_id(&[import_target]);
173 if !nodes.iter().any(|n| n.id == import_id) {
174 nodes.push(Node {
175 id: import_id.clone(),
176 label: import_target.to_string(),
177 file_type: "code".to_string(),
178 source_file: path.to_string_lossy().to_string(),
179 source_location: None,
180 community: None,
181 rationale: None,
182 docstring: None,
183 metadata: HashMap::new(),
184 });
185 }
186 edges.push(Edge {
187 source: file_id.clone(),
188 target: import_id,
189 relation: "imports".to_string(),
190 confidence: "EXTRACTED".to_string(),
191 source_file: Some(path.to_string_lossy().to_string()),
192 weight: 1.0,
193 context: None,
194 });
195 }
196
197 for (_module, names) in regex_from_import_captures(content) {
199 for name in names {
200 let import_id = make_id(&[&file_id, name]);
201 if !nodes.iter().any(|n| n.id == import_id) {
202 nodes.push(Node {
203 id: import_id.clone(),
204 label: name.to_string(),
205 file_type: "code".to_string(),
206 source_file: path.to_string_lossy().to_string(),
207 source_location: None,
208 community: None,
209 rationale: None,
210 docstring: None,
211 metadata: HashMap::new(),
212 });
213 }
214 edges.push(Edge {
215 source: file_id.clone(),
216 target: import_id,
217 relation: "imports".to_string(),
218 confidence: "EXTRACTED".to_string(),
219 source_file: Some(path.to_string_lossy().to_string()),
220 weight: 1.0,
221 context: None,
222 });
223 }
224 }
225
226 Ok(ExtractionFragment { nodes, edges })
227 }
228}
229
230impl LanguageExtractor for PythonExtractor {
231 fn file_extensions(&self) -> Vec<&'static str> {
232 vec!["py"]
233 }
234
235 fn extract(&self, source: &[u8], path: &Path) -> Result<ExtractionFragment> {
236 Self::extract_fragment(source, path)
237 }
238
239 fn resolve_imports(&self, _imports: &[ImportNode]) -> Vec<Edge> {
240 vec![]
241 }
242
243 fn collect_type_refs(&self, _fragment: &mut ExtractionFragment) {}
244}
245
246fn regex_class_captures(content: &str) -> Vec<&str> {
247 content
248 .lines()
249 .filter_map(|line| {
250 let trimmed = line.trim();
251 if trimmed.starts_with("class ") && trimmed.contains('(')
252 || trimmed.starts_with("class ") && trimmed.contains(':')
253 {
254 let name_part = trimmed
255 .strip_prefix("class ")
256 .and_then(|s| s.split('(').next())
257 .or_else(|| {
258 trimmed
259 .strip_prefix("class ")
260 .and_then(|s| s.split(':').next())
261 })
262 .unwrap_or("")
263 .trim();
264 if !name_part.is_empty() {
265 Some(name_part)
266 } else {
267 None
268 }
269 } else {
270 None
271 }
272 })
273 .collect()
274}
275
276fn regex_fn_captures(content: &str) -> Vec<&str> {
277 content
278 .lines()
279 .filter_map(|line| {
280 let trimmed = line.trim();
281 if trimmed.starts_with("def ") {
282 let name = trimmed
283 .strip_prefix("def ")
284 .and_then(|s| s.split('(').next())
285 .unwrap_or("")
286 .trim();
287 if !name.is_empty() {
288 Some(name)
289 } else {
290 None
291 }
292 } else {
293 None
294 }
295 })
296 .collect()
297}
298
299fn regex_import_captures(content: &str) -> Vec<(&str, Option<&str>)> {
300 content
301 .lines()
302 .filter_map(|line| {
303 let trimmed = line.trim();
304 if trimmed.starts_with("import ") && !trimmed.starts_with("import ") {
305 return None;
306 }
307 if trimmed.starts_with("import ") {
308 let rest = trimmed.strip_prefix("import ").unwrap_or("");
309 if let Some(as_pos) = rest.find(" as ") {
311 let module = rest[..as_pos].trim();
312 let alias = rest[as_pos + 4..].trim();
313 Some((module, Some(alias)))
314 } else {
315 let module = rest.trim();
316 if !module.contains('(') {
317 Some((module, None))
318 } else {
319 None
320 }
321 }
322 } else {
323 None
324 }
325 })
326 .collect()
327}
328
329fn regex_from_import_captures(content: &str) -> Vec<(&str, Vec<&str>)> {
330 content
331 .lines()
332 .filter_map(|line| {
333 let trimmed = line.trim();
334 if trimmed.starts_with("from ") {
335 let rest = trimmed.strip_prefix("from ")?;
336 let (module, import_part) = rest.split_once(" import ")?;
337 let names: Vec<&str> = import_part
338 .trim()
339 .strip_prefix('(')
340 .and_then(|s| s.strip_suffix(')'))
341 .map(|s| {
342 s.split(',')
343 .map(|n| n.trim())
344 .filter(|n| !n.is_empty())
345 .collect()
346 })
347 .unwrap_or_else(|| {
348 import_part
349 .split(',')
350 .map(|n| n.trim())
351 .filter(|n| !n.is_empty())
352 .collect()
353 });
354 let module = module.trim();
355 Some((module, names))
356 } else {
357 None
358 }
359 })
360 .collect()
361}
362
363pub fn path_to_file_id(path: &Path) -> NodeId {
364 let stem = path
365 .file_stem()
366 .unwrap_or_default()
367 .to_string_lossy()
368 .to_string();
369 let ext = path
370 .extension()
371 .map(|e| format!("_{}", e.to_string_lossy()))
372 .unwrap_or_default();
373 let parent = path
374 .parent()
375 .and_then(|p| p.file_name())
376 .map(|n| format!("{}_", n.to_string_lossy()))
377 .unwrap_or_default();
378 make_id(&[&format!("{}{}{}", parent, stem, ext)])
379}
380
381pub fn make_id(parts: &[&str]) -> NodeId {
382 let joined = parts.join("_");
383 joined
384 .chars()
385 .map(|c| {
386 if c.is_alphanumeric() || c == '_' {
387 c
388 } else {
389 '_'
390 }
391 })
392 .collect::<String>()
393 .to_lowercase()
394}
395
396pub fn normalize_id(id: &str) -> NodeId {
397 id.chars()
398 .map(|c| {
399 if c.is_alphanumeric() || c == '_' {
400 c
401 } else {
402 '_'
403 }
404 })
405 .collect::<String>()
406 .to_lowercase()
407}
408
409pub struct JavaScriptExtractor;
411
412impl JavaScriptExtractor {
413 fn extract_fragment(source: &[u8], path: &Path) -> Result<ExtractionFragment> {
414 let content = std::str::from_utf8(source).map_err(|e| {
415 CodeSynapseError::Parse(format!("invalid UTF-8 in {}: {}", path.display(), e))
416 })?;
417
418 let mut nodes = Vec::new();
419 let mut edges = Vec::new();
420
421 let file_id = path_to_file_id(path);
422
423 nodes.push(Node {
424 id: file_id.clone(),
425 label: path
426 .file_name()
427 .unwrap_or_default()
428 .to_string_lossy()
429 .to_string(),
430 file_type: "code".to_string(),
431 source_file: path.to_string_lossy().to_string(),
432 source_location: None,
433 community: None,
434 rationale: None,
435 docstring: None,
436 metadata: HashMap::new(),
437 });
438
439 for (class_name, base_class) in js_class_captures(content) {
441 let class_id = make_id(&[&file_id, class_name]);
442 nodes.push(Node {
443 id: class_id.clone(),
444 label: class_name.to_string(),
445 file_type: "code".to_string(),
446 source_file: path.to_string_lossy().to_string(),
447 source_location: None,
448 community: None,
449 rationale: None,
450 docstring: None,
451 metadata: HashMap::new(),
452 });
453 edges.push(Edge {
454 source: file_id.clone(),
455 target: class_id.clone(),
456 relation: "contains".to_string(),
457 confidence: "EXTRACTED".to_string(),
458 source_file: Some(path.to_string_lossy().to_string()),
459 weight: 1.0,
460 context: None,
461 });
462
463 if let Some(base) = base_class {
464 let base_id = make_id(&[base]);
465 if !nodes.iter().any(|n| n.id == base_id) {
466 nodes.push(Node {
467 id: base_id.clone(),
468 label: base.to_string(),
469 file_type: "code".to_string(),
470 source_file: path.to_string_lossy().to_string(),
471 source_location: None,
472 community: None,
473 rationale: None,
474 docstring: None,
475 metadata: HashMap::new(),
476 });
477 }
478 edges.push(Edge {
479 source: class_id,
480 target: base_id,
481 relation: "inherits".to_string(),
482 confidence: "EXTRACTED".to_string(),
483 source_file: Some(path.to_string_lossy().to_string()),
484 weight: 1.0,
485 context: None,
486 });
487 }
488 }
489
490 for (specifiers, source_module) in js_esm_import_captures(content) {
492 let mod_id = make_id(&[&file_id, source_module]);
493 if !nodes.iter().any(|n| n.id == mod_id) {
494 nodes.push(Node {
495 id: mod_id.clone(),
496 label: source_module.to_string(),
497 file_type: "code".to_string(),
498 source_file: path.to_string_lossy().to_string(),
499 source_location: None,
500 community: None,
501 rationale: None,
502 docstring: None,
503 metadata: HashMap::new(),
504 });
505 }
506 for spec in specifiers {
507 let spec_id = make_id(&[&file_id, spec]);
508 if !nodes.iter().any(|n| n.id == spec_id) {
509 nodes.push(Node {
510 id: spec_id.clone(),
511 label: spec.to_string(),
512 file_type: "code".to_string(),
513 source_file: path.to_string_lossy().to_string(),
514 source_location: None,
515 community: None,
516 rationale: None,
517 docstring: None,
518 metadata: HashMap::new(),
519 });
520 }
521 edges.push(Edge {
522 source: file_id.clone(),
523 target: spec_id,
524 relation: "imports".to_string(),
525 confidence: "EXTRACTED".to_string(),
526 source_file: Some(path.to_string_lossy().to_string()),
527 weight: 1.0,
528 context: None,
529 });
530 }
531 edges.push(Edge {
532 source: file_id.clone(),
533 target: mod_id,
534 relation: "imports_from".to_string(),
535 confidence: "EXTRACTED".to_string(),
536 source_file: Some(path.to_string_lossy().to_string()),
537 weight: 1.0,
538 context: None,
539 });
540 }
541
542 for source_module in js_dynamic_import_captures(content) {
544 let mod_id = make_id(&[&file_id, source_module]);
545 if !nodes.iter().any(|n| n.id == mod_id) {
546 nodes.push(Node {
547 id: mod_id.clone(),
548 label: source_module.to_string(),
549 file_type: "code".to_string(),
550 source_file: path.to_string_lossy().to_string(),
551 source_location: None,
552 community: None,
553 rationale: None,
554 docstring: None,
555 metadata: HashMap::new(),
556 });
557 }
558 edges.push(Edge {
559 source: file_id.clone(),
560 target: mod_id,
561 relation: "imports_from".to_string(),
562 confidence: "EXTRACTED".to_string(),
563 source_file: Some(path.to_string_lossy().to_string()),
564 weight: 1.0,
565 context: None,
566 });
567 }
568
569 for source_module in js_require_captures(content) {
571 let mod_id = make_id(&[&file_id, source_module]);
572 if !nodes.iter().any(|n| n.id == mod_id) {
573 nodes.push(Node {
574 id: mod_id.clone(),
575 label: source_module.to_string(),
576 file_type: "code".to_string(),
577 source_file: path.to_string_lossy().to_string(),
578 source_location: None,
579 community: None,
580 rationale: None,
581 docstring: None,
582 metadata: HashMap::new(),
583 });
584 }
585 edges.push(Edge {
586 source: file_id.clone(),
587 target: mod_id,
588 relation: "imports_from".to_string(),
589 confidence: "EXTRACTED".to_string(),
590 source_file: Some(path.to_string_lossy().to_string()),
591 weight: 1.0,
592 context: None,
593 });
594 }
595
596 Ok(ExtractionFragment { nodes, edges })
597 }
598}
599
600impl LanguageExtractor for JavaScriptExtractor {
601 fn file_extensions(&self) -> Vec<&'static str> {
602 vec!["js", "jsx", "mjs", "cjs"]
603 }
604
605 fn extract(&self, source: &[u8], path: &Path) -> Result<ExtractionFragment> {
606 Self::extract_fragment(source, path)
607 }
608
609 fn resolve_imports(&self, _imports: &[ImportNode]) -> Vec<Edge> {
610 vec![]
611 }
612
613 fn collect_type_refs(&self, _fragment: &mut ExtractionFragment) {}
614}
615
616fn js_class_captures(content: &str) -> Vec<(&str, Option<&str>)> {
617 content
618 .lines()
619 .filter_map(|line| {
620 let trimmed = line.trim();
621 if trimmed.starts_with("class ") {
622 let rest = trimmed.strip_prefix("class ")?;
623 let class_name = rest.split_whitespace().next()?;
624 let base = if let Some(extends_pos) = rest.find("extends ") {
625 let after_extends = &rest[extends_pos + 8..];
626 Some(
627 after_extends
628 .split_whitespace()
629 .next()?
630 .trim_end_matches('{')
631 .trim(),
632 )
633 } else {
634 None
635 };
636 Some((class_name, base))
637 } else {
638 None
639 }
640 })
641 .collect()
642}
643
644fn js_esm_import_captures(content: &str) -> Vec<(Vec<&str>, &str)> {
645 content
646 .lines()
647 .filter_map(|line| {
648 let trimmed = line.trim();
649 if trimmed.starts_with("import ") && trimmed.contains(" from ") {
650 let spec_part = &trimmed[7..];
651 let (specifiers_str, source) = spec_part.split_once(" from ")?;
652 let source = source.trim().trim_matches('\'').trim_matches('"');
653 let specifiers: Vec<&str> = specifiers_str
654 .trim()
655 .strip_prefix('{')
656 .and_then(|s| s.strip_suffix('}'))
657 .map(|s| {
658 s.split(',')
659 .map(|n| n.trim())
660 .filter(|n| !n.is_empty())
661 .collect()
662 })
663 .unwrap_or_else(|| vec![specifiers_str.trim()]);
664 Some((specifiers, source))
665 } else {
666 None
667 }
668 })
669 .collect()
670}
671
672fn js_dynamic_import_captures(content: &str) -> Vec<&str> {
673 content
674 .lines()
675 .filter_map(|line| {
676 let trimmed = line.trim();
677 if trimmed.contains("import(") {
678 let start = trimmed.find("import(")?;
679 let rest = &trimmed[start + 7..];
680 let source = rest
681 .split(')')
682 .next()?
683 .trim()
684 .trim_matches('\'')
685 .trim_matches('"');
686 if !source.is_empty() {
687 Some(source)
688 } else {
689 None
690 }
691 } else {
692 None
693 }
694 })
695 .collect()
696}
697
698fn js_require_captures(content: &str) -> Vec<&str> {
699 content
700 .lines()
701 .filter_map(|line| {
702 let trimmed = line.trim();
703 if trimmed.contains("require(") {
704 let start = trimmed.find("require(")?;
705 let rest = &trimmed[start + 8..];
706 let source = rest
707 .split(')')
708 .next()?
709 .trim()
710 .trim_matches('\'')
711 .trim_matches('"');
712 if !source.is_empty() {
713 Some(source)
714 } else {
715 None
716 }
717 } else {
718 None
719 }
720 })
721 .collect()
722}
723
724pub struct TypeScriptExtractor;
726
727impl TypeScriptExtractor {
728 fn extract_fragment(source: &[u8], path: &Path) -> Result<ExtractionFragment> {
729 let content = std::str::from_utf8(source).map_err(|e| {
730 CodeSynapseError::Parse(format!("invalid UTF-8 in {}: {}", path.display(), e))
731 })?;
732
733 let mut fragment = JavaScriptExtractor::extract_fragment(source, path)?;
734 let file_id = path_to_file_id(path);
735
736 for name in ts_interface_captures(content) {
738 let iface_id = make_id(&[&file_id, name]);
739 if !fragment.nodes.iter().any(|n| n.id == iface_id) {
740 fragment.nodes.push(Node {
741 id: iface_id.clone(),
742 label: name.to_string(),
743 file_type: "code".to_string(),
744 source_file: path.to_string_lossy().to_string(),
745 source_location: None,
746 community: None,
747 rationale: None,
748 docstring: None,
749 metadata: {
750 let mut m = HashMap::new();
751 m.insert("kind".to_string(), "interface".to_string());
752 m
753 },
754 });
755 fragment.edges.push(Edge {
756 source: file_id.clone(),
757 target: iface_id,
758 relation: "contains".to_string(),
759 confidence: "EXTRACTED".to_string(),
760 source_file: Some(path.to_string_lossy().to_string()),
761 weight: 1.0,
762 context: None,
763 });
764 }
765 }
766
767 for (name, ref_type) in ts_type_alias_captures(content) {
769 let alias_id = make_id(&[&file_id, name]);
770 if !fragment.nodes.iter().any(|n| n.id == alias_id) {
771 fragment.nodes.push(Node {
772 id: alias_id.clone(),
773 label: name.to_string(),
774 file_type: "code".to_string(),
775 source_file: path.to_string_lossy().to_string(),
776 source_location: None,
777 community: None,
778 rationale: None,
779 docstring: None,
780 metadata: {
781 let mut m = HashMap::new();
782 m.insert("kind".to_string(), "type_alias".to_string());
783 m
784 },
785 });
786 fragment.edges.push(Edge {
787 source: file_id.clone(),
788 target: alias_id.clone(),
789 relation: "contains".to_string(),
790 confidence: "EXTRACTED".to_string(),
791 source_file: Some(path.to_string_lossy().to_string()),
792 weight: 1.0,
793 context: None,
794 });
795 }
796 if let Some(ref_ty) = ref_type {
797 let ref_id = make_id(&[ref_ty]);
798 if !fragment.nodes.iter().any(|n| n.id == ref_id) {
799 fragment.nodes.push(Node {
800 id: ref_id.clone(),
801 label: ref_ty.to_string(),
802 file_type: "code".to_string(),
803 source_file: path.to_string_lossy().to_string(),
804 source_location: None,
805 community: None,
806 rationale: None,
807 docstring: None,
808 metadata: HashMap::new(),
809 });
810 }
811 fragment.edges.push(Edge {
812 source: alias_id,
813 target: ref_id,
814 relation: "type_ref".to_string(),
815 confidence: "EXTRACTED".to_string(),
816 source_file: Some(path.to_string_lossy().to_string()),
817 weight: 1.0,
818 context: None,
819 });
820 }
821 }
822
823 Ok(fragment)
824 }
825}
826
827impl LanguageExtractor for TypeScriptExtractor {
828 fn file_extensions(&self) -> Vec<&'static str> {
829 vec!["ts", "tsx", "mts", "cts"]
830 }
831
832 fn extract(&self, source: &[u8], path: &Path) -> Result<ExtractionFragment> {
833 Self::extract_fragment(source, path)
834 }
835
836 fn resolve_imports(&self, _imports: &[ImportNode]) -> Vec<Edge> {
837 vec![]
838 }
839
840 fn collect_type_refs(&self, _fragment: &mut ExtractionFragment) {}
841}
842
843fn ts_interface_captures(content: &str) -> Vec<&str> {
844 content
845 .lines()
846 .filter_map(|line| {
847 let trimmed = line.trim();
848 if trimmed.starts_with("interface ") {
849 let name = trimmed
850 .strip_prefix("interface ")?
851 .split_whitespace()
852 .next()?
853 .trim_end_matches('{')
854 .trim();
855 if !name.is_empty() {
856 Some(name)
857 } else {
858 None
859 }
860 } else {
861 None
862 }
863 })
864 .collect()
865}
866
867fn ts_type_alias_captures(content: &str) -> Vec<(&str, Option<&str>)> {
868 content
869 .lines()
870 .filter_map(|line| {
871 let trimmed = line.trim();
872 if trimmed.starts_with("type ") && trimmed.contains('=') {
873 let rest = trimmed.strip_prefix("type ")?;
874 let name = rest.split_whitespace().next()?;
875 let rhs = rest.split('=').nth(1)?.trim();
876 let ref_type = rhs.split('<').next()?.split_whitespace().next();
877 Some((name, ref_type))
878 } else {
879 None
880 }
881 })
882 .collect()
883}
884
885pub struct GoExtractor;
887
888impl GoExtractor {
889 fn extract_fragment(source: &[u8], path: &Path) -> Result<ExtractionFragment> {
890 let content = std::str::from_utf8(source).map_err(|e| {
891 CodeSynapseError::Parse(format!("invalid UTF-8 in {}: {}", path.display(), e))
892 })?;
893
894 let mut nodes = Vec::new();
895 let mut edges = Vec::new();
896
897 let file_id = path_to_file_id(path);
898
899 nodes.push(Node {
900 id: file_id.clone(),
901 label: path
902 .file_name()
903 .unwrap_or_default()
904 .to_string_lossy()
905 .to_string(),
906 file_type: "code".to_string(),
907 source_file: path.to_string_lossy().to_string(),
908 source_location: None,
909 community: None,
910 rationale: None,
911 docstring: None,
912 metadata: HashMap::new(),
913 });
914
915 for name in go_struct_captures(content) {
917 let struct_id = make_id(&[&file_id, name]);
918 nodes.push(Node {
919 id: struct_id.clone(),
920 label: name.to_string(),
921 file_type: "code".to_string(),
922 source_file: path.to_string_lossy().to_string(),
923 source_location: None,
924 community: None,
925 rationale: None,
926 docstring: None,
927 metadata: {
928 let mut m = HashMap::new();
929 m.insert("kind".to_string(), "struct".to_string());
930 m
931 },
932 });
933 edges.push(Edge {
934 source: file_id.clone(),
935 target: struct_id,
936 relation: "contains".to_string(),
937 confidence: "EXTRACTED".to_string(),
938 source_file: Some(path.to_string_lossy().to_string()),
939 weight: 1.0,
940 context: None,
941 });
942 }
943
944 for module in go_import_captures(content) {
946 let mod_id = make_id(&[&file_id, module]);
947 if !nodes.iter().any(|n| n.id == mod_id) {
948 nodes.push(Node {
949 id: mod_id.clone(),
950 label: module.to_string(),
951 file_type: "code".to_string(),
952 source_file: path.to_string_lossy().to_string(),
953 source_location: None,
954 community: None,
955 rationale: None,
956 docstring: None,
957 metadata: HashMap::new(),
958 });
959 }
960 edges.push(Edge {
961 source: file_id.clone(),
962 target: mod_id,
963 relation: "imports".to_string(),
964 confidence: "EXTRACTED".to_string(),
965 source_file: Some(path.to_string_lossy().to_string()),
966 weight: 1.0,
967 context: None,
968 });
969 }
970
971 Ok(ExtractionFragment { nodes, edges })
972 }
973}
974
975impl LanguageExtractor for GoExtractor {
976 fn file_extensions(&self) -> Vec<&'static str> {
977 vec!["go"]
978 }
979
980 fn extract(&self, source: &[u8], path: &Path) -> Result<ExtractionFragment> {
981 Self::extract_fragment(source, path)
982 }
983
984 fn resolve_imports(&self, _imports: &[ImportNode]) -> Vec<Edge> {
985 vec![]
986 }
987
988 fn collect_type_refs(&self, _fragment: &mut ExtractionFragment) {}
989}
990
991fn go_struct_captures(content: &str) -> Vec<&str> {
992 content
993 .lines()
994 .filter_map(|line| {
995 let trimmed = line.trim();
996 if trimmed.starts_with("type ") && trimmed.contains(" struct") {
997 let name = trimmed.strip_prefix("type ")?.split_whitespace().next()?;
998 if !name.is_empty() {
999 Some(name)
1000 } else {
1001 None
1002 }
1003 } else {
1004 None
1005 }
1006 })
1007 .collect()
1008}
1009
1010fn go_import_captures(content: &str) -> Vec<&str> {
1011 content
1012 .lines()
1013 .filter_map(|line| {
1014 let trimmed = line.trim();
1015 if trimmed.starts_with("import ") {
1016 let rest = trimmed.strip_prefix("import ")?;
1017 let module = rest.trim().trim_matches('"');
1018 if !module.is_empty() {
1019 Some(module)
1020 } else {
1021 None
1022 }
1023 } else {
1024 None
1025 }
1026 })
1027 .collect()
1028}
1029
1030pub struct RustExtractor;
1032
1033impl RustExtractor {
1034 fn extract_fragment(source: &[u8], path: &Path) -> Result<ExtractionFragment> {
1035 let content = std::str::from_utf8(source).map_err(|e| {
1036 CodeSynapseError::Parse(format!("invalid UTF-8 in {}: {}", path.display(), e))
1037 })?;
1038
1039 let mut nodes = Vec::new();
1040 let mut edges = Vec::new();
1041
1042 let file_id = path_to_file_id(path);
1043
1044 nodes.push(Node {
1045 id: file_id.clone(),
1046 label: path
1047 .file_name()
1048 .unwrap_or_default()
1049 .to_string_lossy()
1050 .to_string(),
1051 file_type: "code".to_string(),
1052 source_file: path.to_string_lossy().to_string(),
1053 source_location: None,
1054 community: None,
1055 rationale: None,
1056 docstring: None,
1057 metadata: HashMap::new(),
1058 });
1059
1060 for (name, generic_param) in rs_struct_captures(content) {
1062 let struct_id = make_id(&[&file_id, name]);
1063 nodes.push(Node {
1064 id: struct_id.clone(),
1065 label: name.to_string(),
1066 file_type: "code".to_string(),
1067 source_file: path.to_string_lossy().to_string(),
1068 source_location: None,
1069 community: None,
1070 rationale: None,
1071 docstring: None,
1072 metadata: HashMap::new(),
1073 });
1074 edges.push(Edge {
1075 source: file_id.clone(),
1076 target: struct_id.clone(),
1077 relation: "contains".to_string(),
1078 confidence: "EXTRACTED".to_string(),
1079 source_file: Some(path.to_string_lossy().to_string()),
1080 weight: 1.0,
1081 context: None,
1082 });
1083
1084 if let Some(generic) = generic_param {
1085 let gen_id = make_id(&[&file_id, generic, "ty"]);
1086 if !nodes.iter().any(|n| n.id == gen_id) {
1087 nodes.push(Node {
1088 id: gen_id.clone(),
1089 label: generic.to_string(),
1090 file_type: "code".to_string(),
1091 source_file: path.to_string_lossy().to_string(),
1092 source_location: None,
1093 community: None,
1094 rationale: None,
1095 docstring: None,
1096 metadata: {
1097 let mut m = HashMap::new();
1098 m.insert("kind".to_string(), "generic_param".to_string());
1099 m
1100 },
1101 });
1102 }
1103 edges.push(Edge {
1104 source: struct_id,
1105 target: gen_id,
1106 relation: "generic".to_string(),
1107 confidence: "EXTRACTED".to_string(),
1108 source_file: Some(path.to_string_lossy().to_string()),
1109 weight: 1.0,
1110 context: None,
1111 });
1112 }
1113 }
1114
1115 for path_parts in rs_use_captures(content) {
1117 let mut prev_id: Option<NodeId> = None;
1119 let parts: Vec<&str> = path_parts.split("::").collect();
1120 for (i, part) in parts.iter().enumerate() {
1121 let seg_id = if i == 0 {
1122 make_id(&[part])
1123 } else {
1124 let parent = make_id(parts[..i].as_ref());
1125 make_id(&[&parent, part])
1126 };
1127 if !nodes.iter().any(|n| n.id == seg_id) {
1128 nodes.push(Node {
1129 id: seg_id.clone(),
1130 label: part.to_string(),
1131 file_type: "code".to_string(),
1132 source_file: path.to_string_lossy().to_string(),
1133 source_location: None,
1134 community: None,
1135 rationale: None,
1136 docstring: None,
1137 metadata: HashMap::new(),
1138 });
1139 }
1140 if let Some(_prev) = prev_id {
1141 edges.push(Edge {
1142 source: file_id.clone(),
1143 target: seg_id.clone(),
1144 relation: "imports".to_string(),
1145 confidence: "EXTRACTED".to_string(),
1146 source_file: Some(path.to_string_lossy().to_string()),
1147 weight: 1.0,
1148 context: None,
1149 });
1150 }
1151 prev_id = Some(seg_id);
1152 }
1153 }
1154
1155 Ok(ExtractionFragment { nodes, edges })
1156 }
1157}
1158
1159impl LanguageExtractor for RustExtractor {
1160 fn file_extensions(&self) -> Vec<&'static str> {
1161 vec!["rs"]
1162 }
1163
1164 fn extract(&self, source: &[u8], path: &Path) -> Result<ExtractionFragment> {
1165 Self::extract_fragment(source, path)
1166 }
1167
1168 fn resolve_imports(&self, _imports: &[ImportNode]) -> Vec<Edge> {
1169 vec![]
1170 }
1171
1172 fn collect_type_refs(&self, _fragment: &mut ExtractionFragment) {}
1173}
1174
1175fn rs_struct_captures(content: &str) -> Vec<(&str, Option<&str>)> {
1176 content
1177 .lines()
1178 .filter_map(|line| {
1179 let trimmed = line.trim();
1180 if trimmed.starts_with("struct ") {
1181 let rest = trimmed.strip_prefix("struct ")?;
1182 let name = rest
1183 .split(|c: char| c.is_whitespace() || c == '<' || c == '{')
1184 .next()?;
1185 let generic = rest.find('<').and_then(|start| {
1186 let inner = &rest[start + 1..];
1187 inner.find('>').map(|end| inner[..end].trim())
1188 });
1189 Some((name, generic))
1190 } else {
1191 None
1192 }
1193 })
1194 .collect()
1195}
1196
1197fn rs_use_captures(content: &str) -> Vec<&str> {
1198 content
1199 .lines()
1200 .filter_map(|line| {
1201 let trimmed = line.trim();
1202 if trimmed.starts_with("use ") {
1203 let rest = trimmed.strip_prefix("use ")?.trim_end_matches(';').trim();
1204 if !rest.is_empty() {
1205 Some(rest)
1206 } else {
1207 None
1208 }
1209 } else {
1210 None
1211 }
1212 })
1213 .collect()
1214}
1215
1216#[cfg(test)]
1217mod tests {
1218 use super::*;
1219
1220 #[test]
1221 fn test_extract_python_class() {
1222 let source = b"class Foo(Bar):\n pass\n";
1223 let path = Path::new("test.py");
1224 let result = PythonExtractor.extract(source, path).unwrap();
1225
1226 let foo_node = result.nodes.iter().find(|n| n.label == "Foo");
1227 assert!(foo_node.is_some(), "expected Foo node");
1228
1229 assert!(result.nodes.len() >= 2, "expected at least 2 nodes");
1231
1232 let contains_edge = result.edges.iter().find(|e| e.relation == "contains");
1234 assert!(contains_edge.is_some(), "expected contains edge");
1235 }
1236
1237 #[test]
1238 fn test_extract_python_function() {
1239 let source = b"def foo(x: int) -> str:\n pass\n";
1240 let path = Path::new("test.py");
1241 let result = PythonExtractor.extract(source, path).unwrap();
1242
1243 let fn_node = result.nodes.iter().find(|n| n.label == "foo()");
1244 assert!(fn_node.is_some(), "expected foo() node");
1245 }
1246
1247 #[test]
1248 fn test_extract_python_import() {
1249 let source = b"import os\n";
1250 let path = Path::new("test.py");
1251 let result = PythonExtractor.extract(source, path).unwrap();
1252
1253 let import_edge = result.edges.iter().find(|e| e.relation == "imports");
1254 assert!(import_edge.is_some(), "expected imports edge");
1255 }
1256
1257 #[test]
1258 fn test_extract_python_from_import() {
1259 let source = b"from .helper import transform\n";
1260 let path = Path::new("test.py");
1261 let result = PythonExtractor.extract(source, path).unwrap();
1262
1263 let import_edge = result.edges.iter().find(|e| e.relation == "imports");
1264 assert!(import_edge.is_some(), "expected imports edge");
1265 }
1266
1267 #[test]
1268 fn test_extract_recursion_limit() {
1269 let source = b"# deeply nested\n";
1271 let path = Path::new("test.py");
1272 let result = PythonExtractor.extract(source, path).unwrap();
1273 assert!(!result.nodes.is_empty(), "should at least have file node");
1274 }
1275
1276 #[test]
1277 fn test_extract_syntax_error() {
1278 let source = b"def foo( bar : \n"; let path = Path::new("test.py");
1280 let result = PythonExtractor.extract(source, path);
1281 assert!(result.is_ok(), "should gracefully handle syntax errors");
1282 }
1283
1284 #[test]
1285 fn test_make_id() {
1286 assert_eq!(make_id(&["foo", "bar"]), "foo_bar");
1287 assert_eq!(make_id(&["Foo", "Bar"]), "foo_bar");
1288 assert_eq!(make_id(&["foo-bar"]), "foo_bar");
1289 }
1290
1291 #[test]
1292 fn test_normalize_id() {
1293 assert_eq!(normalize_id("Foo-Bar"), "foo_bar");
1294 assert_eq!(
1295 normalize_id("Session ValidateToken"),
1296 "session_validatetoken"
1297 );
1298 }
1299
1300 #[test]
1303 fn test_extract_js_class() {
1304 let source = b"class Foo extends Bar {}";
1305 let path = Path::new("class_def.js");
1306 let result = JavaScriptExtractor.extract(source, path).unwrap();
1307
1308 let foo_node = result.nodes.iter().find(|n| n.label == "Foo");
1309 assert!(foo_node.is_some(), "expected Foo node");
1310
1311 let inherits_edge = result.edges.iter().find(|e| e.relation == "inherits");
1312 assert!(inherits_edge.is_some(), "expected inherits edge");
1313
1314 let bar_node = result.nodes.iter().find(|n| n.label == "Bar");
1315 assert!(bar_node.is_some(), "expected Bar node");
1316 }
1317
1318 #[test]
1319 fn test_extract_js_import() {
1320 let source = b"import { x } from './mod'";
1321 let path = Path::new("imports.js");
1322 let result = JavaScriptExtractor.extract(source, path).unwrap();
1323
1324 let imports_from_edge = result.edges.iter().find(|e| e.relation == "imports_from");
1325 assert!(imports_from_edge.is_some(), "expected imports_from edge");
1326
1327 let imports_edge = result
1328 .edges
1329 .iter()
1330 .filter(|e| e.relation == "imports")
1331 .count();
1332 assert!(imports_edge > 0, "expected at least one imports edge");
1333 }
1334
1335 #[test]
1336 fn test_extract_js_dynamic_import() {
1337 let source = b"const mod = await import('./mod')";
1338 let path = Path::new("imports.js");
1339 let result = JavaScriptExtractor.extract(source, path).unwrap();
1340
1341 let imports_from_edge = result.edges.iter().find(|e| e.relation == "imports_from");
1342 assert!(imports_from_edge.is_some(), "expected imports_from edge");
1343 }
1344
1345 #[test]
1346 fn test_extract_js_require() {
1347 let source = b"const m = require('./mod')";
1348 let path = Path::new("imports.js");
1349 let result = JavaScriptExtractor.extract(source, path).unwrap();
1350
1351 let imports_from_edge = result.edges.iter().find(|e| e.relation == "imports_from");
1352 assert!(imports_from_edge.is_some(), "expected imports_from edge");
1353 }
1354
1355 #[test]
1358 fn test_extract_ts_interface() {
1359 let source = b"interface Foo {\n bar(): void\n}";
1360 let path = Path::new("interface.ts");
1361 let result = TypeScriptExtractor.extract(source, path).unwrap();
1362
1363 let iface_node = result.nodes.iter().find(|n| n.label == "Foo");
1364 assert!(iface_node.is_some(), "expected Foo interface node");
1365 if let Some(n) = iface_node {
1366 assert_eq!(
1367 n.metadata.get("kind").map(|s| s.as_str()),
1368 Some("interface")
1369 );
1370 }
1371 }
1372
1373 #[test]
1374 fn test_extract_ts_type_alias() {
1375 let source = b"type Foo = Bar<string>";
1376 let path = Path::new("interface.ts");
1377 let result = TypeScriptExtractor.extract(source, path).unwrap();
1378
1379 let alias_node = result.nodes.iter().find(|n| n.label == "Foo");
1380 assert!(alias_node.is_some(), "expected Foo type alias node");
1381
1382 let type_ref_edge = result.edges.iter().find(|e| e.relation == "type_ref");
1383 assert!(type_ref_edge.is_some(), "expected type_ref edge");
1384 }
1385
1386 #[test]
1389 fn test_extract_go_struct() {
1390 let source = b"type Foo struct {\n\tBar string\n}";
1391 let path = Path::new("struct.go");
1392 let result = GoExtractor.extract(source, path).unwrap();
1393
1394 let struct_node = result.nodes.iter().find(|n| n.label == "Foo");
1395 assert!(struct_node.is_some(), "expected Foo struct node");
1396 }
1397
1398 #[test]
1399 fn test_extract_go_import() {
1400 let source = b"import \"fmt\"";
1401 let path = Path::new("struct.go");
1402 let result = GoExtractor.extract(source, path).unwrap();
1403
1404 let import_edge = result.edges.iter().find(|e| e.relation == "imports");
1405 assert!(import_edge.is_some(), "expected imports edge");
1406
1407 let fmt_node = result.nodes.iter().find(|n| n.label == "fmt");
1408 assert!(fmt_node.is_some(), "expected fmt node");
1409 }
1410
1411 #[test]
1414 fn test_extract_rs_struct() {
1415 let source = b"struct Foo<T> {\n bar: T,\n}";
1416 let path = Path::new("generic.rs");
1417 let result = RustExtractor.extract(source, path).unwrap();
1418
1419 let struct_node = result.nodes.iter().find(|n| n.label == "Foo");
1420 assert!(struct_node.is_some(), "expected Foo struct node");
1421
1422 let generic_edge = result.edges.iter().find(|e| e.relation == "generic");
1423 assert!(generic_edge.is_some(), "expected generic edge");
1424 }
1425
1426 #[test]
1427 fn test_extract_rs_import() {
1428 let source = b"use crate::mod::Foo;";
1429 let path = Path::new("generic.rs");
1430 let result = RustExtractor.extract(source, path).unwrap();
1431
1432 let imports = result
1433 .edges
1434 .iter()
1435 .filter(|e| e.relation == "imports")
1436 .count();
1437 assert!(imports > 0, "expected at least one imports edge");
1438 }
1439
1440 #[test]
1443 fn test_extract_empty_source() {
1444 let source = b"";
1445 let path = Path::new("empty.py");
1446 let result = PythonExtractor.extract(source, path).unwrap();
1447 assert!(!result.nodes.is_empty());
1449 }
1450
1451 #[test]
1452 fn test_extract_unsupported_extension() {
1453 let extractor = Extractor::new();
1454 let path = Path::new("data.bin");
1455 let source = b"\x00\x01\x02";
1456 let result = extractor.extract_file(path, source);
1457 assert!(result.is_ok(), "unsupported extension returns Ok(fragment)");
1459 let fragment = result.unwrap();
1460 assert!(fragment.nodes.is_empty());
1461 assert!(fragment.edges.is_empty());
1462 }
1463
1464 #[test]
1465 fn test_extract_non_utf8() {
1466 let source = b"\xff\xfe\x00\x01";
1467 let path = Path::new("main.py");
1468 let result = PythonExtractor.extract(source, path);
1469 assert!(result.is_err(), "non-UTF8 should return an error");
1471 }
1472
1473 #[test]
1474 fn test_extract_binary_content() {
1475 let source = b"\x00\x01\x02\x03\x04\x05";
1477 let path = Path::new("test.py");
1478 let result = PythonExtractor.extract(source, path);
1479 let _ = result;
1481 }
1482
1483 #[test]
1484 fn test_extract_trailing_whitespace_content() {
1485 let source = b" \n \n ";
1486 let path = Path::new("whitespace.py");
1487 let result = PythonExtractor.extract(source, path).unwrap();
1488 assert!(
1489 !result.nodes.is_empty(),
1490 "whitespace-only file should still have file node"
1491 );
1492 }
1493
1494 #[test]
1495 fn test_extract_unicode_identifiers() {
1496 let source = "def función(ñ): pass\n".as_bytes();
1497 let path = Path::new("unicode.py");
1498 let result = PythonExtractor.extract(source, path).unwrap();
1499 let fn_node = result
1500 .nodes
1501 .iter()
1502 .find(|n| n.label.contains("función") || n.label.contains("fun"));
1503 assert!(fn_node.is_some(), "expected unicode function node");
1504 }
1505
1506 #[test]
1507 fn test_extract_js_empty_source() {
1508 let source = b"";
1509 let path = Path::new("empty.js");
1510 let result = JavaScriptExtractor.extract(source, path).unwrap();
1511 assert!(!result.nodes.is_empty());
1512 }
1513
1514 #[test]
1515 fn test_extract_rs_empty_source() {
1516 let source = b"";
1517 let path = Path::new("empty.rs");
1518 let result = RustExtractor.extract(source, path).unwrap();
1519 assert!(!result.nodes.is_empty());
1520 }
1521
1522 #[test]
1523 fn test_extract_ts_empty_source() {
1524 let source = b"";
1525 let path = Path::new("empty.ts");
1526 let result = TypeScriptExtractor.extract(source, path).unwrap();
1527 assert!(!result.nodes.is_empty());
1528 }
1529
1530 #[test]
1531 fn test_extract_go_empty_source() {
1532 let source = b"";
1533 let path = Path::new("empty.go");
1534 let result = GoExtractor.extract(source, path).unwrap();
1535 assert!(!result.nodes.is_empty());
1536 }
1537
1538 #[test]
1539 fn test_extract_extractor_register_multi_extension() {
1540 let mut extractor = Extractor::new();
1541 extractor.register("py", Box::new(PythonExtractor));
1542 let path = Path::new("main.py");
1543 let source = b"class Foo: pass";
1544 let result = extractor.extract_file(path, source);
1545 assert!(result.is_ok());
1546 let fragment = result.unwrap();
1547 assert!(!fragment.nodes.is_empty());
1548 }
1549
1550 #[test]
1551 fn test_file_node_ids_differ_for_same_stem_different_dirs() {
1552 let source = b"class Auth: pass\n";
1553 let py_result = PythonExtractor
1554 .extract(source, Path::new("python/auth.py"))
1555 .unwrap();
1556 let rs_result = RustExtractor
1557 .extract(source, Path::new("rust/auth.rs"))
1558 .unwrap();
1559
1560 let py_file_node = py_result
1561 .nodes
1562 .iter()
1563 .find(|n| n.label == "auth.py")
1564 .unwrap();
1565 let rs_file_node = rs_result
1566 .nodes
1567 .iter()
1568 .find(|n| n.label == "auth.rs")
1569 .unwrap();
1570
1571 assert_ne!(
1572 py_file_node.id, rs_file_node.id,
1573 "same stem in different dirs must produce different node IDs"
1574 );
1575 }
1576
1577 #[test]
1578 fn test_file_node_ids_differ_for_same_stem_same_dir() {
1579 let source = b"class Auth: pass\n";
1580 let py_result = PythonExtractor
1581 .extract(source, Path::new("src/auth.py"))
1582 .unwrap();
1583 let rs_result = RustExtractor
1584 .extract(source, Path::new("src/auth.rs"))
1585 .unwrap();
1586
1587 let py_file_node = py_result
1588 .nodes
1589 .iter()
1590 .find(|n| n.label == "auth.py")
1591 .unwrap();
1592 let rs_file_node = rs_result
1593 .nodes
1594 .iter()
1595 .find(|n| n.label == "auth.rs")
1596 .unwrap();
1597
1598 assert_ne!(
1599 py_file_node.id, rs_file_node.id,
1600 "same stem same dir different extension must produce different node IDs"
1601 );
1602 }
1603}