Skip to main content

codesynapse_core/
extract.rs

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        // Try full filename first (e.g. "package.json" or "mcp.json")
37        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        // Then try by extension
48        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            // Default: basic text extraction (no structured AST)
58            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
85/// Python-specific extractor
86pub 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        // File-level node
100        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        // Simple regex-based extraction for initial pass
117        // Class definitions
118        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        // Function definitions
144        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        // Import statements
170        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        // From-imports
198        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                // Handle "import X as Y"
310                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
409/// JavaScript-specific extractor
410pub 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        // Class definitions with extends
440        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        // ESM imports: import { x } from './mod'
491        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        // Dynamic imports: import('./mod')
543        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        // CJS require: require('./mod')
570        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
724/// TypeScript-specific extractor
725pub 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        // Interface declarations
737        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        // Type aliases
768        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
885/// Go-specific extractor
886pub 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        // Struct definitions
916        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        // Imports
945        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
1030/// Rust-specific extractor
1031pub 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        // Struct definitions with generics
1061        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        // Use imports
1116        for path_parts in rs_use_captures(content) {
1117            // Create nodes for each segment in the path
1118            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        // Should have file node + class node
1230        assert!(result.nodes.len() >= 2, "expected at least 2 nodes");
1231
1232        // Should have contains edge
1233        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        // Deeply nested but valid file - should not crash
1270        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"; // invalid syntax
1279        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    // --- JavaScript extractor tests (15-18) ---
1301
1302    #[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    // --- TypeScript extractor tests (19-20) ---
1356
1357    #[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    // --- Go extractor tests (21-22) ---
1387
1388    #[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    // --- Rust extractor tests (23-24) ---
1412
1413    #[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    // --- Edge case tests ---
1441
1442    #[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        // Should have at least a file node
1448        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        // Unknown extension returns Ok with empty fragment
1458        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        // Non-UTF8 bytes return a parse error from from_utf8
1470        assert!(result.is_err(), "non-UTF8 should return an error");
1471    }
1472
1473    #[test]
1474    fn test_extract_binary_content() {
1475        // Null bytes are valid UTF-8 in Rust, but tree-sitter may parse them
1476        let source = b"\x00\x01\x02\x03\x04\x05";
1477        let path = Path::new("test.py");
1478        let result = PythonExtractor.extract(source, path);
1479        // Should not panic regardless of outcome
1480        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}