Skip to main content

open_kioku_parse/
lib.rs

1use chrono::Utc;
2use open_kioku_core::{
3    identity, AnalysisFact, CodeChunk, Confidence, EvidenceSourceType, File, GraphEdgeType,
4    GraphNodeType, Import, Language, LineRange, ScoreComponent, Symbol, SymbolId, SymbolKind,
5    TestTarget,
6};
7use regex::Regex;
8use sha2::{Digest, Sha256};
9use std::collections::HashSet;
10
11#[derive(Debug, Clone)]
12pub struct ParsedFile {
13    pub chunks: Vec<CodeChunk>,
14    pub symbols: Vec<Symbol>,
15    pub imports: Vec<Import>,
16    pub analysis_facts: Vec<AnalysisFact>,
17    pub tests: Vec<TestTarget>,
18}
19
20pub trait Parser: Send + Sync {
21    fn parse(&self, file: &File, content: &str) -> ParsedFile {
22        self.parse_with_hint(file, content, None)
23    }
24    fn parse_with_hint(&self, file: &File, content: &str, build_hint: Option<&str>) -> ParsedFile;
25}
26
27#[derive(Default)]
28pub struct HeuristicParser;
29
30impl Parser for HeuristicParser {
31    fn parse_with_hint(&self, file: &File, content: &str, build_hint: Option<&str>) -> ParsedFile {
32        let imports = extract_imports(file, content);
33        let mut symbols = extract_symbols(file, content);
34        dedupe_symbols(&mut symbols);
35        let analysis_facts = extract_analysis_facts(file, content, &symbols);
36        let mut chunks = extract_chunks(file, content, &symbols);
37        dedupe_chunks(&mut chunks);
38        let tests = extract_tests(file, content, &symbols, build_hint);
39        ParsedFile {
40            chunks,
41            symbols,
42            imports,
43            analysis_facts,
44            tests,
45        }
46    }
47}
48
49fn dedupe_symbols(symbols: &mut Vec<Symbol>) {
50    let mut seen = HashSet::new();
51    symbols.retain(|symbol| seen.insert(symbol.id.clone()));
52}
53
54fn dedupe_chunks(chunks: &mut Vec<CodeChunk>) {
55    let mut seen = HashSet::new();
56    chunks.retain(|chunk| seen.insert(chunk.id.clone()));
57}
58
59pub fn extract_symbols(file: &File, content: &str) -> Vec<Symbol> {
60    if let Ok(symbols) = open_kioku_tree_sitter::parse_symbols(file, content) {
61        if !symbols.is_empty() {
62            return symbols;
63        }
64    }
65    match file.language {
66        Language::Rust => extract_with_patterns(
67            file,
68            content,
69            &[
70                (
71                    r"^\s*(pub\s+)?(async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)",
72                    SymbolKind::Function,
73                    3,
74                ),
75                (
76                    r"^\s*(pub\s+)?struct\s+([A-Za-z_][A-Za-z0-9_]*)",
77                    SymbolKind::Class,
78                    2,
79                ),
80                (
81                    r"^\s*(pub\s+)?enum\s+([A-Za-z_][A-Za-z0-9_]*)",
82                    SymbolKind::Class,
83                    2,
84                ),
85                (
86                    r"^\s*(pub\s+)?trait\s+([A-Za-z_][A-Za-z0-9_]*)",
87                    SymbolKind::Trait,
88                    2,
89                ),
90                (r"^\s*mod\s+([A-Za-z_][A-Za-z0-9_]*)", SymbolKind::Module, 1),
91            ],
92        ),
93        Language::Java => extract_with_patterns(
94            file,
95            content,
96            &[
97                (
98                    r"\b(class|record)\s+([A-Za-z_][A-Za-z0-9_]*)",
99                    SymbolKind::Class,
100                    2,
101                ),
102                (
103                    r"\binterface\s+([A-Za-z_][A-Za-z0-9_]*)",
104                    SymbolKind::Interface,
105                    1,
106                ),
107                (
108                    r"\b(?:public|private|protected)?\s*(?:static\s+)?[A-Za-z0-9_<>\[\], ?]+\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(",
109                    SymbolKind::Method,
110                    1,
111                ),
112            ],
113        ),
114        Language::TypeScript | Language::JavaScript => extract_with_patterns(
115            file,
116            content,
117            &[
118                (
119                    r"\bfunction\s+([A-Za-z_$][A-Za-z0-9_$]*)",
120                    SymbolKind::Function,
121                    1,
122                ),
123                (
124                    r"\bclass\s+([A-Za-z_$][A-Za-z0-9_$]*)",
125                    SymbolKind::Class,
126                    1,
127                ),
128                (
129                    r"\binterface\s+([A-Za-z_$][A-Za-z0-9_$]*)",
130                    SymbolKind::Interface,
131                    1,
132                ),
133                (
134                    r"\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?\(",
135                    SymbolKind::Function,
136                    1,
137                ),
138                (
139                    r"\bexport\s+(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)",
140                    SymbolKind::Variable,
141                    1,
142                ),
143            ],
144        ),
145        Language::Python => extract_with_patterns(
146            file,
147            content,
148            &[
149                (
150                    r"^\s*def\s+([A-Za-z_][A-Za-z0-9_]*)",
151                    SymbolKind::Function,
152                    1,
153                ),
154                (
155                    r"^\s*async\s+def\s+([A-Za-z_][A-Za-z0-9_]*)",
156                    SymbolKind::Function,
157                    1,
158                ),
159                (
160                    r"^\s*class\s+([A-Za-z_][A-Za-z0-9_]*)",
161                    SymbolKind::Class,
162                    1,
163                ),
164            ],
165        ),
166        Language::Go => extract_with_patterns(
167            file,
168            content,
169            &[
170                (
171                    r"^\s*func\s+(?:\([^)]+\)\s*)?([A-Za-z_][A-Za-z0-9_]*)",
172                    SymbolKind::Function,
173                    1,
174                ),
175                (
176                    r"^\s*type\s+([A-Za-z_][A-Za-z0-9_]*)\s+struct",
177                    SymbolKind::Class,
178                    1,
179                ),
180                (
181                    r"^\s*type\s+([A-Za-z_][A-Za-z0-9_]*)\s+interface",
182                    SymbolKind::Interface,
183                    1,
184                ),
185            ],
186        ),
187        Language::Sql => extract_with_patterns(
188            file,
189            content,
190            &[(
191                r"(?i)^\s*create\s+table\s+([A-Za-z_][A-Za-z0-9_\.]*)",
192                SymbolKind::DatabaseTable,
193                1,
194            )],
195        ),
196        _ => Vec::new(),
197    }
198}
199
200fn extract_with_patterns(
201    file: &File,
202    content: &str,
203    specs: &[(&str, SymbolKind, usize)],
204) -> Vec<Symbol> {
205    let compiled = specs
206        .iter()
207        .filter_map(|(pattern, kind, capture)| {
208            Regex::new(pattern)
209                .ok()
210                .map(|re| (re, kind.clone(), *capture))
211        })
212        .collect::<Vec<_>>();
213    let mut symbols = Vec::new();
214    for (idx, line) in content.lines().enumerate() {
215        for (regex, kind, capture) in &compiled {
216            if let Some(captures) = regex.captures(line) {
217                if let Some(name) = captures.get(*capture) {
218                    let line_number = (idx + 1) as u32;
219                    let qualified_name = qualified_name(file, content, name.as_str());
220                    symbols.push(Symbol {
221                        id: SymbolId::new(stable_id(&format!(
222                            "{}:{}:{}",
223                            file.path.display(),
224                            line_number,
225                            qualified_name
226                        ))),
227                        name: name.as_str().to_string(),
228                        qualified_name,
229                        kind: kind.clone(),
230                        file_id: file.id.clone(),
231                        range: Some(LineRange::single(line_number)),
232                        language: file.language.clone(),
233                        confidence: Confidence::Medium,
234                        provenance: EvidenceSourceType::Heuristic,
235                    });
236                }
237            }
238        }
239    }
240    symbols
241}
242
243pub fn extract_imports(file: &File, content: &str) -> Vec<Import> {
244    let patterns = match file.language {
245        Language::Rust => vec![r"^\s*use\s+([^;]+)", r"^\s*mod\s+([A-Za-z_][A-Za-z0-9_]*)"],
246        Language::Java => vec![r"^\s*import\s+([^;]+)"],
247        Language::TypeScript | Language::JavaScript => {
248            vec![r#"from\s+["']([^"']+)["']"#, r#"import\s+["']([^"']+)["']"#]
249        }
250        Language::Python => vec![
251            r"^\s*import\s+([A-Za-z0-9_\.]+)",
252            r"^\s*from\s+([A-Za-z0-9_\.]+)\s+import",
253        ],
254        Language::Go => vec![r#"^\s*import\s+"([^"]+)""#],
255        _ => Vec::new(),
256    };
257    let compiled = patterns
258        .iter()
259        .filter_map(|pattern| Regex::new(pattern).ok())
260        .collect::<Vec<_>>();
261    let mut imports = Vec::new();
262    for (idx, line) in content.lines().enumerate() {
263        for regex in &compiled {
264            if let Some(captures) = regex.captures(line) {
265                if let Some(value) = captures.get(1) {
266                    imports.push(Import {
267                        file_id: file.id.clone(),
268                        imported: value.as_str().trim().to_string(),
269                        range: Some(LineRange::single((idx + 1) as u32)),
270                        confidence: Confidence::Medium,
271                    });
272                }
273            }
274        }
275    }
276    imports
277}
278
279pub fn extract_analysis_facts(file: &File, content: &str, symbols: &[Symbol]) -> Vec<AnalysisFact> {
280    match file.language {
281        Language::Java => extract_java_analysis_facts(file, content, symbols),
282        Language::TypeScript | Language::JavaScript => {
283            extract_javascript_analysis_facts(file, content, symbols)
284        }
285        Language::Python => extract_python_analysis_facts(file, content, symbols),
286        Language::Rust => extract_rust_analysis_facts(file, content, symbols),
287        Language::Yaml | Language::Json | Language::Toml | Language::Text => {
288            extract_infra_analysis_facts(file, content)
289        }
290        _ => Vec::new(),
291    }
292}
293
294fn extract_java_analysis_facts(
295    file: &File,
296    content: &str,
297    symbols: &[Symbol],
298) -> Vec<AnalysisFact> {
299    let mut facts = Vec::new();
300    let class_re = Regex::new(
301        r"\b(?:class|record|enum)\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s+extends\s+([A-Za-z0-9_.$<>]+))?(?:\s+implements\s+([A-Za-z0-9_.$<>,\s]+))?",
302    )
303    .expect("valid Java class regex");
304    let interface_re = Regex::new(
305        r"\binterface\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s+extends\s+([A-Za-z0-9_.$<>,\s]+))?",
306    )
307    .expect("valid Java interface regex");
308    let mapping_re = Regex::new(
309        r#"@(GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping|RequestMapping)(?:\s*\(\s*(?:value\s*=\s*)?["']([^"']+)["'])?"#,
310    )
311    .expect("valid Spring mapping regex");
312    let env_re =
313        Regex::new(r#"System\.getenv\(\s*["']([^"']+)["']\s*\)"#).expect("valid getenv regex");
314    let value_re = Regex::new(r#"@Value\(\s*["']\$\{([^}:]+)(?::[^}]*)?\}["']\s*\)"#)
315        .expect("valid Spring value regex");
316    let table_re =
317        Regex::new(r#"@Table\(\s*name\s*=\s*["']([^"']+)["']"#).expect("valid table regex");
318    let http_client_re =
319        Regex::new(r#"\b(?:getForObject|postForObject|put|delete|exchange)\(\s*["']([^"']+)["']"#)
320            .expect("valid Java HTTP client regex");
321    let kafka_listener_re = Regex::new(r#"@KafkaListener\([^)]*topics\s*=\s*["']([^"']+)["']"#)
322        .expect("valid Kafka listener regex");
323    let kafka_send_re = Regex::new(r#"\bkafkaTemplate\.send\(\s*["']([^"']+)["']"#)
324        .expect("valid Kafka send regex");
325
326    for (idx, line) in content.lines().enumerate() {
327        let line_number = (idx + 1) as u32;
328        if let Some(captures) = class_re.captures(line) {
329            let source = captures.get(1).map(|value| value.as_str());
330            let source_symbol = source.and_then(|name| symbol_named(symbols, name));
331            if let Some(base) = captures.get(2) {
332                facts.push(analysis_fact(
333                    file,
334                    source_symbol,
335                    GraphEdgeType::Extends,
336                    GraphNodeType::Class,
337                    clean_java_type(base.as_str()),
338                    line_number,
339                    ("open-kioku-static/java", "Java class inheritance"),
340                ));
341            }
342            if let Some(interfaces) = captures.get(3) {
343                for interface in split_java_types(interfaces.as_str()) {
344                    facts.push(analysis_fact(
345                        file,
346                        source_symbol,
347                        GraphEdgeType::Implements,
348                        GraphNodeType::Interface,
349                        interface,
350                        line_number,
351                        ("open-kioku-static/java", "Java implemented interface"),
352                    ));
353                }
354            }
355        }
356        if let Some(captures) = interface_re.captures(line) {
357            let source = captures.get(1).map(|value| value.as_str());
358            let source_symbol = source.and_then(|name| symbol_named(symbols, name));
359            if let Some(parents) = captures.get(2) {
360                for parent in split_java_types(parents.as_str()) {
361                    facts.push(analysis_fact(
362                        file,
363                        source_symbol,
364                        GraphEdgeType::Extends,
365                        GraphNodeType::Interface,
366                        parent,
367                        line_number,
368                        ("open-kioku-static/java", "Java interface inheritance"),
369                    ));
370                }
371            }
372        }
373        if let Some(captures) = mapping_re.captures(line) {
374            let method = spring_http_method(captures.get(1).map(|value| value.as_str()));
375            let route = captures.get(2).map(|value| value.as_str()).unwrap_or("/");
376            let source_symbol = symbol_at_or_after(symbols, line_number, 4);
377            facts.push(analysis_fact(
378                file,
379                source_symbol,
380                GraphEdgeType::ExposesEndpoint,
381                GraphNodeType::Endpoint,
382                format!("{method} {route}"),
383                line_number,
384                ("open-kioku-static/java", "Spring MVC endpoint mapping"),
385            ));
386        }
387        for captures in env_re.captures_iter(line) {
388            if let Some(key) = captures.get(1) {
389                facts.push(analysis_fact(
390                    file,
391                    symbol_at_or_before(symbols, line_number),
392                    GraphEdgeType::ReadsConfig,
393                    GraphNodeType::ConfigKey,
394                    key.as_str().to_string(),
395                    line_number,
396                    ("open-kioku-static/java", "Java environment variable read"),
397                ));
398            }
399        }
400        if let Some(captures) = value_re.captures(line) {
401            if let Some(key) = captures.get(1) {
402                facts.push(analysis_fact(
403                    file,
404                    symbol_at_or_after(symbols, line_number, 3),
405                    GraphEdgeType::ReadsConfig,
406                    GraphNodeType::ConfigKey,
407                    key.as_str().to_string(),
408                    line_number,
409                    ("open-kioku-static/java", "Spring configuration value read"),
410                ));
411            }
412        }
413        if let Some(captures) = table_re.captures(line) {
414            if let Some(table) = captures.get(1) {
415                facts.push(analysis_fact(
416                    file,
417                    symbol_at_or_after(symbols, line_number, 3),
418                    GraphEdgeType::ReadsTable,
419                    GraphNodeType::DatabaseTable,
420                    table.as_str().to_string(),
421                    line_number,
422                    ("open-kioku-static/java", "JPA table mapping"),
423                ));
424            }
425        }
426        for captures in http_client_re.captures_iter(line) {
427            let Some(route) = captures.get(1) else {
428                continue;
429            };
430            facts.push(analysis_fact(
431                file,
432                symbol_at_or_before(symbols, line_number),
433                GraphEdgeType::CallsEndpoint,
434                GraphNodeType::Endpoint,
435                format!("HTTP {}", route.as_str()),
436                line_number,
437                ("open-kioku-static/java", "Java HTTP client call"),
438            ));
439        }
440        for captures in kafka_listener_re.captures_iter(line) {
441            let Some(topic) = captures.get(1) else {
442                continue;
443            };
444            facts.push(analysis_fact(
445                file,
446                symbol_at_or_after(symbols, line_number, 3),
447                GraphEdgeType::ConsumesEvent,
448                GraphNodeType::Topic,
449                topic.as_str().to_string(),
450                line_number,
451                ("open-kioku-static/java", "Java Kafka topic listener"),
452            ));
453        }
454        for captures in kafka_send_re.captures_iter(line) {
455            let Some(topic) = captures.get(1) else {
456                continue;
457            };
458            facts.push(analysis_fact(
459                file,
460                symbol_at_or_before(symbols, line_number),
461                GraphEdgeType::PublishesEvent,
462                GraphNodeType::Topic,
463                topic.as_str().to_string(),
464                line_number,
465                ("open-kioku-static/java", "Java Kafka topic publish"),
466            ));
467        }
468    }
469    dedupe_analysis_facts(&mut facts);
470    facts
471}
472
473fn extract_javascript_analysis_facts(
474    file: &File,
475    content: &str,
476    symbols: &[Symbol],
477) -> Vec<AnalysisFact> {
478    let mut facts = Vec::new();
479    let route_re =
480        Regex::new(r#"\b(?:app|router)\.(get|post|put|delete|patch|all)\(\s*["']([^"']+)["']"#)
481            .expect("valid JavaScript route regex");
482    let client_re =
483        Regex::new(r#"\b(?:axios|client|http)\.(get|post|put|delete|patch)\(\s*["']([^"']+)["']"#)
484            .expect("valid JavaScript HTTP client regex");
485    let fetch_re = Regex::new(r#"\bfetch\(\s*["']([^"']+)["']"#).expect("valid fetch regex");
486    let publish_re = Regex::new(
487        r#"\b(?:producer|publisher|pubsub|channel)\.(?:send|publish|emit)\(\s*(?:\{[^}]*topic\s*:\s*)?["']([^"']+)["']"#,
488    )
489    .expect("valid JavaScript publish regex");
490    let subscribe_re = Regex::new(
491        r#"\b(?:consumer|subscriber|pubsub|channel)\.(?:subscribe|on)\(\s*(?:\{[^}]*topic\s*:\s*)?["']([^"']+)["']"#,
492    )
493    .expect("valid JavaScript subscribe regex");
494    for (idx, line) in content.lines().enumerate() {
495        let line_number = (idx + 1) as u32;
496        for captures in route_re.captures_iter(line) {
497            let method = captures
498                .get(1)
499                .map(|value| value.as_str().to_ascii_uppercase())
500                .unwrap_or_else(|| "HTTP".into());
501            let route = captures.get(2).map(|value| value.as_str()).unwrap_or("/");
502            facts.push(analysis_fact(
503                file,
504                symbol_at_or_before(symbols, line_number),
505                GraphEdgeType::ExposesEndpoint,
506                GraphNodeType::Endpoint,
507                format!("{method} {route}"),
508                line_number,
509                ("open-kioku-static/javascript", "JavaScript HTTP route"),
510            ));
511        }
512        for captures in client_re.captures_iter(line) {
513            let method = captures
514                .get(1)
515                .map(|value| value.as_str().to_ascii_uppercase())
516                .unwrap_or_else(|| "HTTP".into());
517            let route = captures.get(2).map(|value| value.as_str()).unwrap_or("/");
518            facts.push(analysis_fact(
519                file,
520                symbol_at_or_before(symbols, line_number),
521                GraphEdgeType::CallsEndpoint,
522                GraphNodeType::Endpoint,
523                format!("{method} {route}"),
524                line_number,
525                (
526                    "open-kioku-static/javascript",
527                    "JavaScript HTTP client call",
528                ),
529            ));
530        }
531        for captures in fetch_re.captures_iter(line) {
532            let route = captures.get(1).map(|value| value.as_str()).unwrap_or("/");
533            facts.push(analysis_fact(
534                file,
535                symbol_at_or_before(symbols, line_number),
536                GraphEdgeType::CallsEndpoint,
537                GraphNodeType::Endpoint,
538                format!("HTTP {route}"),
539                line_number,
540                ("open-kioku-static/javascript", "JavaScript fetch call"),
541            ));
542        }
543        for captures in publish_re.captures_iter(line) {
544            let Some(topic) = captures.get(1) else {
545                continue;
546            };
547            facts.push(analysis_fact(
548                file,
549                symbol_at_or_before(symbols, line_number),
550                GraphEdgeType::PublishesEvent,
551                GraphNodeType::Topic,
552                topic.as_str().to_string(),
553                line_number,
554                ("open-kioku-static/javascript", "JavaScript topic publish"),
555            ));
556        }
557        for captures in subscribe_re.captures_iter(line) {
558            let Some(topic) = captures.get(1) else {
559                continue;
560            };
561            facts.push(analysis_fact(
562                file,
563                symbol_at_or_before(symbols, line_number),
564                GraphEdgeType::ConsumesEvent,
565                GraphNodeType::Topic,
566                topic.as_str().to_string(),
567                line_number,
568                (
569                    "open-kioku-static/javascript",
570                    "JavaScript topic subscription",
571                ),
572            ));
573        }
574    }
575    dedupe_analysis_facts(&mut facts);
576    facts
577}
578
579fn extract_python_analysis_facts(
580    file: &File,
581    content: &str,
582    symbols: &[Symbol],
583) -> Vec<AnalysisFact> {
584    let mut facts = Vec::new();
585    let route_re = Regex::new(
586        r#"@(?:app|router|blueprint)\.(get|post|put|delete|patch|route)\(\s*["']([^"']+)["']"#,
587    )
588    .expect("valid Python route regex");
589    let client_re =
590        Regex::new(r#"\b(?:requests|httpx)\.(get|post|put|delete|patch)\(\s*["']([^"']+)["']"#)
591            .expect("valid Python HTTP client regex");
592    let publish_re = Regex::new(r#"\b(?:producer|publisher|client)\.send\(\s*["']([^"']+)["']"#)
593        .expect("valid Python publish regex");
594    let subscribe_re =
595        Regex::new(r#"\b(?:consumer|subscriber)\.subscribe\(\s*(?:\[)?\s*["']([^"']+)["']"#)
596            .expect("valid Python subscribe regex");
597    for (idx, line) in content.lines().enumerate() {
598        let line_number = (idx + 1) as u32;
599        for captures in route_re.captures_iter(line) {
600            let method = match captures.get(1).map(|value| value.as_str()) {
601                Some("route") => "HTTP".to_string(),
602                Some(value) => value.to_ascii_uppercase(),
603                None => "HTTP".into(),
604            };
605            let route = captures.get(2).map(|value| value.as_str()).unwrap_or("/");
606            facts.push(analysis_fact(
607                file,
608                symbol_at_or_after(symbols, line_number, 2),
609                GraphEdgeType::ExposesEndpoint,
610                GraphNodeType::Endpoint,
611                format!("{method} {route}"),
612                line_number,
613                ("open-kioku-static/python", "Python HTTP route decorator"),
614            ));
615        }
616        for captures in client_re.captures_iter(line) {
617            let method = captures
618                .get(1)
619                .map(|value| value.as_str().to_ascii_uppercase())
620                .unwrap_or_else(|| "HTTP".into());
621            let route = captures.get(2).map(|value| value.as_str()).unwrap_or("/");
622            facts.push(analysis_fact(
623                file,
624                symbol_at_or_before(symbols, line_number),
625                GraphEdgeType::CallsEndpoint,
626                GraphNodeType::Endpoint,
627                format!("{method} {route}"),
628                line_number,
629                ("open-kioku-static/python", "Python HTTP client call"),
630            ));
631        }
632        for captures in publish_re.captures_iter(line) {
633            let Some(topic) = captures.get(1) else {
634                continue;
635            };
636            facts.push(analysis_fact(
637                file,
638                symbol_at_or_before(symbols, line_number),
639                GraphEdgeType::PublishesEvent,
640                GraphNodeType::Topic,
641                topic.as_str().to_string(),
642                line_number,
643                ("open-kioku-static/python", "Python topic publish"),
644            ));
645        }
646        for captures in subscribe_re.captures_iter(line) {
647            let Some(topic) = captures.get(1) else {
648                continue;
649            };
650            facts.push(analysis_fact(
651                file,
652                symbol_at_or_before(symbols, line_number),
653                GraphEdgeType::ConsumesEvent,
654                GraphNodeType::Topic,
655                topic.as_str().to_string(),
656                line_number,
657                ("open-kioku-static/python", "Python topic subscription"),
658            ));
659        }
660    }
661    dedupe_analysis_facts(&mut facts);
662    facts
663}
664
665fn extract_rust_analysis_facts(
666    file: &File,
667    content: &str,
668    symbols: &[Symbol],
669) -> Vec<AnalysisFact> {
670    let mut facts = Vec::new();
671    let route_re = Regex::new(r#"#\[(get|post|put|delete|patch)\(\s*["']([^"']+)["']\s*\)\]"#)
672        .expect("valid Rust route regex");
673    let client_re = Regex::new(r#"\breqwest::(get|post|put|delete|patch)\(\s*["']([^"']+)["']"#)
674        .expect("valid Rust HTTP client regex");
675    for (idx, line) in content.lines().enumerate() {
676        let line_number = (idx + 1) as u32;
677        for captures in route_re.captures_iter(line) {
678            let method = captures
679                .get(1)
680                .map(|value| value.as_str().to_ascii_uppercase())
681                .unwrap_or_else(|| "HTTP".into());
682            let route = captures.get(2).map(|value| value.as_str()).unwrap_or("/");
683            facts.push(analysis_fact(
684                file,
685                symbol_at_or_after(symbols, line_number, 2),
686                GraphEdgeType::ExposesEndpoint,
687                GraphNodeType::Endpoint,
688                format!("{method} {route}"),
689                line_number,
690                ("open-kioku-static/rust", "Rust HTTP route attribute"),
691            ));
692        }
693        for captures in client_re.captures_iter(line) {
694            let method = captures
695                .get(1)
696                .map(|value| value.as_str().to_ascii_uppercase())
697                .unwrap_or_else(|| "HTTP".into());
698            let route = captures.get(2).map(|value| value.as_str()).unwrap_or("/");
699            facts.push(analysis_fact(
700                file,
701                symbol_at_or_before(symbols, line_number),
702                GraphEdgeType::CallsEndpoint,
703                GraphNodeType::Endpoint,
704                format!("{method} {route}"),
705                line_number,
706                ("open-kioku-static/rust", "Rust HTTP client call"),
707            ));
708        }
709    }
710    dedupe_analysis_facts(&mut facts);
711    facts
712}
713
714fn extract_infra_analysis_facts(file: &File, content: &str) -> Vec<AnalysisFact> {
715    let path = file.path.to_string_lossy().to_ascii_lowercase();
716    let mut facts = Vec::new();
717    if path.ends_with("dockerfile") || path.contains("dockerfile.") {
718        extract_dockerfile_facts(file, content, &mut facts);
719    }
720    if path.ends_with("docker-compose.yml")
721        || path.ends_with("docker-compose.yaml")
722        || path.ends_with("compose.yml")
723        || path.ends_with("compose.yaml")
724    {
725        extract_compose_facts(file, content, &mut facts);
726    }
727    if matches!(file.language, Language::Yaml) {
728        extract_kubernetes_facts(file, content, &mut facts);
729    }
730    if path.ends_with(".tf") || path.ends_with(".tfvars") || path.ends_with(".hcl") {
731        extract_terraform_facts(file, content, &mut facts);
732    }
733    extract_url_binding_facts(file, content, &mut facts);
734    dedupe_analysis_facts(&mut facts);
735    facts
736}
737
738fn extract_dockerfile_facts(file: &File, content: &str, facts: &mut Vec<AnalysisFact>) {
739    for (idx, line) in content.lines().enumerate() {
740        let line_number = (idx + 1) as u32;
741        let trimmed = line.trim();
742        if let Some(rest) = trimmed.strip_prefix("EXPOSE ") {
743            for port in rest.split_whitespace() {
744                let port = port.split('/').next().unwrap_or(port);
745                facts.push(analysis_fact(
746                    file,
747                    None,
748                    GraphEdgeType::ExposesEndpoint,
749                    GraphNodeType::Endpoint,
750                    format!("TCP :{port}"),
751                    line_number,
752                    ("open-kioku-static/dockerfile", "Dockerfile exposed port"),
753                ));
754            }
755        }
756        if let Some(rest) = trimmed.strip_prefix("ENV ") {
757            if let Some((key, _)) = rest.split_once('=') {
758                facts.push(analysis_fact(
759                    file,
760                    None,
761                    GraphEdgeType::WritesConfig,
762                    GraphNodeType::ConfigKey,
763                    key.trim().to_string(),
764                    line_number,
765                    (
766                        "open-kioku-static/dockerfile",
767                        "Dockerfile environment binding",
768                    ),
769                ));
770            }
771        }
772    }
773}
774
775fn extract_compose_facts(file: &File, content: &str, facts: &mut Vec<AnalysisFact>) {
776    let service_re =
777        Regex::new(r#"^\s{2}([A-Za-z0-9_.-]+):\s*$"#).expect("valid compose service regex");
778    let port_re = Regex::new(r#"^\s*-\s*["']?(?:\d+:)?(\d+)(?:/tcp|/udp)?["']?\s*$"#)
779        .expect("valid compose port regex");
780    let env_re = Regex::new(r#"^\s*([A-Z_][A-Z0-9_]+):"#).expect("valid compose env regex");
781    let dep_re = Regex::new(r#"^\s*-\s*([A-Za-z0-9_.-]+)\s*$"#).expect("valid compose dep regex");
782    let mut in_services = false;
783    let mut in_environment = false;
784    let mut in_depends = false;
785    for (idx, line) in content.lines().enumerate() {
786        let line_number = (idx + 1) as u32;
787        if line.trim() == "services:" {
788            in_services = true;
789            continue;
790        }
791        if !in_services {
792            continue;
793        }
794        if let Some(captures) = service_re.captures(line) {
795            let service = captures.get(1).unwrap().as_str();
796            facts.push(analysis_fact(
797                file,
798                None,
799                GraphEdgeType::Defines,
800                GraphNodeType::Resource,
801                format!("compose:service:{service}"),
802                line_number,
803                ("open-kioku-static/compose", "Docker Compose service"),
804            ));
805            in_environment = false;
806            in_depends = false;
807            continue;
808        }
809        let trimmed = line.trim();
810        in_environment =
811            trimmed == "environment:" || (in_environment && line.starts_with("      "));
812        in_depends = trimmed == "depends_on:" || (in_depends && line.starts_with("      "));
813        if let Some(captures) = port_re.captures(line) {
814            let port = captures.get(1).unwrap().as_str();
815            facts.push(analysis_fact(
816                file,
817                None,
818                GraphEdgeType::ExposesEndpoint,
819                GraphNodeType::Endpoint,
820                format!("TCP :{port}"),
821                line_number,
822                ("open-kioku-static/compose", "Docker Compose published port"),
823            ));
824        }
825        if in_environment {
826            if let Some(captures) = env_re.captures(line) {
827                facts.push(analysis_fact(
828                    file,
829                    None,
830                    GraphEdgeType::WritesConfig,
831                    GraphNodeType::ConfigKey,
832                    captures.get(1).unwrap().as_str().to_string(),
833                    line_number,
834                    (
835                        "open-kioku-static/compose",
836                        "Docker Compose environment binding",
837                    ),
838                ));
839            }
840        }
841        if in_depends {
842            if let Some(captures) = dep_re.captures(line) {
843                facts.push(analysis_fact(
844                    file,
845                    None,
846                    GraphEdgeType::DependsOn,
847                    GraphNodeType::Resource,
848                    format!("compose:service:{}", captures.get(1).unwrap().as_str()),
849                    line_number,
850                    (
851                        "open-kioku-static/compose",
852                        "Docker Compose service dependency",
853                    ),
854                ));
855            }
856        }
857    }
858}
859
860fn extract_kubernetes_facts(file: &File, content: &str, facts: &mut Vec<AnalysisFact>) {
861    let mut kind: Option<String> = None;
862    let mut name: Option<String> = None;
863    for (idx, line) in content.lines().enumerate() {
864        let line_number = (idx + 1) as u32;
865        let trimmed = line.trim();
866        if let Some(value) = trimmed.strip_prefix("kind:") {
867            kind = Some(value.trim().to_string());
868        } else if name.is_none() && trimmed.starts_with("name:") {
869            name = Some(trimmed.trim_start_matches("name:").trim().to_string());
870        } else if let Some(value) = trimmed
871            .strip_prefix("port:")
872            .or_else(|| trimmed.strip_prefix("- port:"))
873        {
874            let port = value.trim();
875            if port.chars().all(|ch| ch.is_ascii_digit()) {
876                facts.push(analysis_fact(
877                    file,
878                    None,
879                    GraphEdgeType::ExposesEndpoint,
880                    GraphNodeType::Endpoint,
881                    format!("TCP :{port}"),
882                    line_number,
883                    ("open-kioku-static/kubernetes", "Kubernetes service port"),
884                ));
885            }
886        }
887        if let (Some(resource_kind), Some(resource_name)) = (&kind, &name) {
888            facts.push(analysis_fact(
889                file,
890                None,
891                GraphEdgeType::Defines,
892                GraphNodeType::Resource,
893                format!("kubernetes:{resource_kind}:{resource_name}"),
894                line_number,
895                ("open-kioku-static/kubernetes", "Kubernetes resource"),
896            ));
897            kind = None;
898            name = None;
899        }
900    }
901}
902
903fn extract_terraform_facts(file: &File, content: &str, facts: &mut Vec<AnalysisFact>) {
904    let resource_re =
905        Regex::new(r#"resource\s+"([^"]+)"\s+"([^"]+)""#).expect("valid Terraform resource regex");
906    let variable_re =
907        Regex::new(r#"variable\s+"([^"]+)""#).expect("valid Terraform variable regex");
908    for (idx, line) in content.lines().enumerate() {
909        let line_number = (idx + 1) as u32;
910        if let Some(captures) = resource_re.captures(line) {
911            let kind = captures.get(1).unwrap().as_str();
912            let name = captures.get(2).unwrap().as_str();
913            let (target_kind, edge_type, target) = if kind.contains("sqs") || kind.contains("queue")
914            {
915                (
916                    GraphNodeType::Queue,
917                    GraphEdgeType::Defines,
918                    name.to_string(),
919                )
920            } else if kind.contains("sns") || kind.contains("topic") {
921                (
922                    GraphNodeType::Topic,
923                    GraphEdgeType::Defines,
924                    name.to_string(),
925                )
926            } else {
927                (
928                    GraphNodeType::Resource,
929                    GraphEdgeType::Defines,
930                    format!("terraform:{kind}:{name}"),
931                )
932            };
933            facts.push(analysis_fact(
934                file,
935                None,
936                edge_type,
937                target_kind,
938                target,
939                line_number,
940                ("open-kioku-static/terraform", "Terraform resource"),
941            ));
942        }
943        if let Some(captures) = variable_re.captures(line) {
944            facts.push(analysis_fact(
945                file,
946                None,
947                GraphEdgeType::ReadsConfig,
948                GraphNodeType::ConfigKey,
949                captures.get(1).unwrap().as_str().to_string(),
950                line_number,
951                ("open-kioku-static/terraform", "Terraform variable"),
952            ));
953        }
954    }
955}
956
957fn extract_url_binding_facts(file: &File, content: &str, facts: &mut Vec<AnalysisFact>) {
958    let url_re =
959        Regex::new(r#"["']?(?:url|endpoint|base_url)["']?\s*[:=]\s*["'](https?://[^"']+)["']"#)
960            .expect("valid URL binding regex");
961    for (idx, line) in content.lines().enumerate() {
962        for captures in url_re.captures_iter(line) {
963            facts.push(analysis_fact(
964                file,
965                None,
966                GraphEdgeType::CallsEndpoint,
967                GraphNodeType::Endpoint,
968                format!("HTTP {}", captures.get(1).unwrap().as_str()),
969                (idx + 1) as u32,
970                ("open-kioku-static/config", "configuration URL binding"),
971            ));
972        }
973    }
974}
975
976fn analysis_fact(
977    file: &File,
978    symbol: Option<&Symbol>,
979    edge_type: GraphEdgeType,
980    target_kind: GraphNodeType,
981    target: String,
982    line_number: u32,
983    source: (&str, &str),
984) -> AnalysisFact {
985    AnalysisFact {
986        id: stable_id(&format!(
987            "analysis:{}:{}:{:?}:{}:{}",
988            file.path.display(),
989            symbol
990                .map(|symbol| symbol.id.0.as_str())
991                .unwrap_or("<file>"),
992            edge_type,
993            target,
994            line_number
995        )),
996        file_id: file.id.clone(),
997        symbol_id: symbol.map(|symbol| symbol.id.clone()),
998        target,
999        target_kind,
1000        edge_type,
1001        range: Some(LineRange::single(line_number)),
1002        confidence: Confidence::Medium,
1003        source: source.0.into(),
1004        source_type: EvidenceSourceType::StaticAnalysis,
1005        message: source.1.into(),
1006    }
1007}
1008
1009fn symbol_named<'a>(symbols: &'a [Symbol], name: &str) -> Option<&'a Symbol> {
1010    symbols.iter().find(|symbol| symbol.name == name)
1011}
1012
1013fn symbol_at_or_after(symbols: &[Symbol], line_number: u32, max_distance: u32) -> Option<&Symbol> {
1014    symbols
1015        .iter()
1016        .filter_map(|symbol| {
1017            let start = symbol.range.as_ref()?.start;
1018            (start >= line_number && start <= line_number + max_distance).then_some((start, symbol))
1019        })
1020        .min_by_key(|(start, _)| *start)
1021        .map(|(_, symbol)| symbol)
1022}
1023
1024fn symbol_at_or_before(symbols: &[Symbol], line_number: u32) -> Option<&Symbol> {
1025    symbols
1026        .iter()
1027        .filter_map(|symbol| {
1028            let start = symbol.range.as_ref()?.start;
1029            (start <= line_number).then_some((start, symbol))
1030        })
1031        .max_by_key(|(start, _)| *start)
1032        .map(|(_, symbol)| symbol)
1033}
1034
1035fn clean_java_type(value: &str) -> String {
1036    value
1037        .trim()
1038        .trim_matches(',')
1039        .split('<')
1040        .next()
1041        .unwrap_or(value)
1042        .trim()
1043        .to_string()
1044}
1045
1046fn split_java_types(value: &str) -> Vec<String> {
1047    value
1048        .split(',')
1049        .map(clean_java_type)
1050        .filter(|value| !value.is_empty())
1051        .collect()
1052}
1053
1054fn spring_http_method(annotation: Option<&str>) -> &'static str {
1055    match annotation {
1056        Some("GetMapping") => "GET",
1057        Some("PostMapping") => "POST",
1058        Some("PutMapping") => "PUT",
1059        Some("DeleteMapping") => "DELETE",
1060        Some("PatchMapping") => "PATCH",
1061        Some("RequestMapping") => "HTTP",
1062        _ => "HTTP",
1063    }
1064}
1065
1066fn dedupe_analysis_facts(facts: &mut Vec<AnalysisFact>) {
1067    let mut seen = HashSet::new();
1068    facts.retain(|fact| seen.insert(fact.id.clone()));
1069}
1070
1071pub fn extract_chunks(file: &File, content: &str, symbols: &[Symbol]) -> Vec<CodeChunk> {
1072    if content.trim().is_empty() {
1073        return Vec::new();
1074    }
1075    let lines = content.lines().collect::<Vec<_>>();
1076    let mut chunks = Vec::new();
1077    let mut starts = symbols
1078        .iter()
1079        .filter_map(|symbol| {
1080            symbol
1081                .range
1082                .as_ref()
1083                .map(|range| (range.start as usize, symbol.id.clone()))
1084        })
1085        .collect::<Vec<_>>();
1086    starts.sort_by_key(|(line, _)| *line);
1087    starts.dedup_by_key(|(line, _)| *line);
1088    if starts.is_empty() {
1089        for (idx, window) in lines.chunks(80).enumerate() {
1090            let start = idx * 80 + 1;
1091            let end = start + window.len().saturating_sub(1);
1092            chunks.push(CodeChunk {
1093                id: stable_id(&format!("{}:{start}:{end}", file.path.display())),
1094                file_id: file.id.clone(),
1095                range: LineRange {
1096                    start: start as u32,
1097                    end: end as u32,
1098                },
1099                language: file.language.clone(),
1100                text: window.join("\n"),
1101                symbol_id: None,
1102            });
1103        }
1104        return chunks;
1105    }
1106    for (idx, (start, symbol_id)) in starts.iter().enumerate() {
1107        let next = starts
1108            .get(idx + 1)
1109            .map(|(line, _)| *line)
1110            .unwrap_or(lines.len() + 1);
1111        let end = next.saturating_sub(1).min(lines.len());
1112        let text = lines[start.saturating_sub(1)..end].join("\n");
1113        chunks.push(CodeChunk {
1114            id: stable_id(&format!("{}:{start}:{end}", file.path.display())),
1115            file_id: file.id.clone(),
1116            range: LineRange {
1117                start: *start as u32,
1118                end: end as u32,
1119            },
1120            language: file.language.clone(),
1121            text,
1122            symbol_id: Some(symbol_id.clone()),
1123        });
1124    }
1125    chunks
1126}
1127
1128pub fn extract_tests(
1129    file: &File,
1130    content: &str,
1131    symbols: &[Symbol],
1132    build_hint: Option<&str>,
1133) -> Vec<TestTarget> {
1134    let path = file.path.to_string_lossy().to_ascii_lowercase();
1135    let is_test_file = path.contains("/test/")
1136        || path.contains("/tests/")
1137        || path.ends_with("_test.rs")
1138        || path.ends_with("_test.go")
1139        || path.ends_with("test.java")
1140        || path.ends_with(".spec.ts")
1141        || path.ends_with(".test.ts")
1142        || path.ends_with("_test.py");
1143
1144    symbols
1145        .iter()
1146        .filter(|symbol| {
1147            is_test_file
1148                || symbol.name.starts_with("test")
1149                || content
1150                    .lines()
1151                    .any(|line| line.contains("#[test]") || line.contains("@Test"))
1152        })
1153        .map(|symbol| TestTarget {
1154            id: stable_id(&format!("test:{}:{}", file.path.display(), symbol.name)),
1155            name: symbol.name.clone(),
1156            file_id: file.id.clone(),
1157            range: symbol.range.clone(),
1158            command: recommended_command(&file.language, &file.path.to_string_lossy(), build_hint),
1159            confidence: if is_test_file {
1160                Confidence::High
1161            } else {
1162                Confidence::Medium
1163            },
1164            reason: "test-like path, annotation, or naming convention".into(),
1165            evidence_refs: vec![stable_id(&format!(
1166                "test:{}:{}",
1167                file.path.display(),
1168                symbol.name
1169            ))],
1170            score_breakdown: vec![ScoreComponent::single(
1171                "indexed_test_confidence",
1172                if is_test_file {
1173                    Confidence::High.score()
1174                } else {
1175                    Confidence::Medium.score()
1176                },
1177                vec![stable_id(&format!(
1178                    "test:{}:{}",
1179                    file.path.display(),
1180                    symbol.name
1181                ))],
1182                "test-like path, annotation, or naming convention",
1183            )],
1184        })
1185        .collect()
1186}
1187
1188fn qualified_name(file: &File, content: &str, name: &str) -> String {
1189    identity::qualified_name(&file.path, &file.language, Some(content), name).unwrap_or_else(|_| {
1190        let stem = file
1191            .path
1192            .with_extension("")
1193            .to_string_lossy()
1194            .replace(['/', '\\'], "::");
1195        format!("{stem}::{name}")
1196    })
1197}
1198
1199fn stable_id(value: &str) -> String {
1200    let mut hasher = Sha256::new();
1201    hasher.update(value.as_bytes());
1202    format!("{:x}", hasher.finalize())
1203}
1204
1205fn recommended_command(
1206    language: &Language,
1207    path: &str,
1208    build_hint: Option<&str>,
1209) -> Option<String> {
1210    match (language, build_hint) {
1211        (Language::Java, Some("gradle")) => Some("./gradlew test".into()),
1212        (Language::Java, Some("bazel")) => Some("bazel test //...".into()),
1213        (Language::Java, Some("maven") | _) => Some("mvn test".into()),
1214        (Language::Rust, _) => Some("cargo test".into()),
1215        (Language::TypeScript | Language::JavaScript, _) => Some("npm test".into()),
1216        (Language::Python, _) => Some("pytest".into()),
1217        (Language::Go, _) => Some("go test ./...".into()),
1218        _ if path.contains("test") => Some("run repository test command".into()),
1219        _ => None,
1220    }
1221}
1222
1223pub fn evidence_timestamp() -> chrono::DateTime<Utc> {
1224    Utc::now()
1225}
1226
1227#[cfg(test)]
1228mod tests {
1229    use super::{
1230        extract_analysis_facts, extract_chunks, extract_imports, extract_symbols, extract_tests,
1231        qualified_name,
1232    };
1233    use open_kioku_core::{
1234        Confidence, EvidenceSourceType, File, FileId, GraphEdgeType, GraphNodeType, Language,
1235        LineRange, RepositoryId, Symbol, SymbolId, SymbolKind,
1236    };
1237
1238    fn rust_file() -> File {
1239        File {
1240            id: FileId::new("file-rs"),
1241            repository_id: RepositoryId::new("repo"),
1242            path: "src/lib.rs".into(),
1243            language: Language::Rust,
1244            size_bytes: 0,
1245            content_hash: "hash".into(),
1246            is_generated: false,
1247            is_vendor: false,
1248        }
1249    }
1250
1251    fn python_file() -> File {
1252        File {
1253            id: FileId::new("file-py"),
1254            repository_id: RepositoryId::new("repo"),
1255            path: "app/service.py".into(),
1256            language: Language::Python,
1257            size_bytes: 0,
1258            content_hash: "hash".into(),
1259            is_generated: false,
1260            is_vendor: false,
1261        }
1262    }
1263
1264    fn ts_file() -> File {
1265        File {
1266            id: FileId::new("file-ts"),
1267            repository_id: RepositoryId::new("repo"),
1268            path: "src/index.ts".into(),
1269            language: Language::TypeScript,
1270            size_bytes: 0,
1271            content_hash: "hash".into(),
1272            is_generated: false,
1273            is_vendor: false,
1274        }
1275    }
1276
1277    fn java_file() -> File {
1278        File {
1279            id: FileId::new("file-java"),
1280            repository_id: RepositoryId::new("repo"),
1281            path: "src/main/java/com/acme/OrderController.java".into(),
1282            language: Language::Java,
1283            size_bytes: 0,
1284            content_hash: "hash".into(),
1285            is_generated: false,
1286            is_vendor: false,
1287        }
1288    }
1289
1290    // ─── extract_symbols ──────────────────────────────────────────────────────
1291
1292    #[test]
1293    fn extracts_rust_functions_and_structs() {
1294        let file = rust_file();
1295        let src = "pub fn do_work() {}\npub struct Worker;\npub trait Runnable {}\nmod utils {}";
1296        let symbols = extract_symbols(&file, src);
1297        let names: Vec<_> = symbols.iter().map(|s| s.name.as_str()).collect();
1298        assert!(names.contains(&"do_work"), "should find function");
1299        assert!(names.contains(&"Worker"), "should find struct");
1300        assert!(names.contains(&"Runnable"), "should find trait");
1301        assert!(names.contains(&"utils"), "should find module");
1302    }
1303
1304    #[test]
1305    fn extracts_python_class_and_function() {
1306        let file = python_file();
1307        let src = "class MyService:\n    pass\n\ndef handle_request():\n    pass\n";
1308        let symbols = extract_symbols(&file, src);
1309        let names: Vec<_> = symbols.iter().map(|s| s.name.as_str()).collect();
1310        assert!(names.contains(&"MyService"), "should find class");
1311        assert!(names.contains(&"handle_request"), "should find function");
1312    }
1313
1314    #[test]
1315    fn extracts_typescript_class_and_function() {
1316        let file = ts_file();
1317        let src = "class ApiClient {}\nfunction fetchData() {}\nconst handler = () => {};";
1318        let symbols = extract_symbols(&file, src);
1319        let names: Vec<_> = symbols.iter().map(|s| s.name.as_str()).collect();
1320        assert!(names.contains(&"ApiClient") || !symbols.is_empty());
1321    }
1322
1323    #[test]
1324    fn qualified_names_follow_language_entrypoint_rules() {
1325        let mut file = ts_file();
1326        file.path = "src/index.ts".into();
1327        assert_eq!(qualified_name(&file, "", "handler"), "src::handler");
1328
1329        file.path = "pkg/__init__.py".into();
1330        file.language = Language::Python;
1331        assert_eq!(qualified_name(&file, "", "Factory"), "pkg::Factory");
1332
1333        file.path = "src/api/mod.rs".into();
1334        file.language = Language::Rust;
1335        assert_eq!(qualified_name(&file, "", "run"), "src::api::run");
1336
1337        file.path = "src/main/java/com/acme/OrderController.java".into();
1338        file.language = Language::Java;
1339        assert_eq!(
1340            qualified_name(
1341                &file,
1342                "package com.acme;\nclass OrderController {}",
1343                "getOrder"
1344            ),
1345            "com::acme::OrderController::getOrder"
1346        );
1347
1348        file.path = "internal/orders/handler.go".into();
1349        file.language = Language::Go;
1350        assert_eq!(
1351            qualified_name(&file, "package orders\nfunc Load() {}", "Load"),
1352            "orders::Load"
1353        );
1354    }
1355
1356    // ─── extract_imports ──────────────────────────────────────────────────────
1357
1358    #[test]
1359    fn extracts_rust_use_imports() {
1360        let file = rust_file();
1361        let src = "use std::collections::HashMap;\nuse crate::worker::Worker;";
1362        let imports = extract_imports(&file, src);
1363        assert_eq!(imports.len(), 2);
1364        assert!(imports.iter().any(|i| i.imported.contains("HashMap")));
1365    }
1366
1367    #[test]
1368    fn extracts_python_imports() {
1369        let file = python_file();
1370        let src = "import os\nfrom pathlib import Path\n";
1371        let imports = extract_imports(&file, src);
1372        assert_eq!(imports.len(), 2);
1373        assert!(imports.iter().any(|i| i.imported == "os"));
1374        assert!(imports.iter().any(|i| i.imported == "pathlib"));
1375    }
1376
1377    #[test]
1378    fn extracts_typescript_imports() {
1379        let file = ts_file();
1380        let src = "import { foo } from './foo';\nimport './styles.css';";
1381        let imports = extract_imports(&file, src);
1382        assert!(!imports.is_empty());
1383        assert!(imports.iter().any(|i| i.imported.contains("foo")));
1384    }
1385
1386    #[test]
1387    fn extracts_java_static_analysis_facts() {
1388        let file = java_file();
1389        let src = r#"
1390class OrderController extends BaseController implements OrderApi, Audited {
1391    @GetMapping("/orders/{id}")
1392    public Order getOrder() {
1393        System.getenv("ORDER_REGION");
1394        return null;
1395    }
1396}
1397"#;
1398        let symbols = extract_symbols(&file, src);
1399        let facts = extract_analysis_facts(&file, src, &symbols);
1400        assert!(facts.iter().any(|fact| {
1401            fact.edge_type == GraphEdgeType::Extends
1402                && fact.target == "BaseController"
1403                && fact.target_kind == GraphNodeType::Class
1404        }));
1405        assert!(facts.iter().any(|fact| {
1406            fact.edge_type == GraphEdgeType::Implements
1407                && fact.target == "OrderApi"
1408                && fact.target_kind == GraphNodeType::Interface
1409        }));
1410        assert!(facts.iter().any(|fact| {
1411            fact.edge_type == GraphEdgeType::ExposesEndpoint && fact.target == "GET /orders/{id}"
1412        }));
1413        assert!(facts.iter().any(|fact| {
1414            fact.edge_type == GraphEdgeType::ReadsConfig && fact.target == "ORDER_REGION"
1415        }));
1416    }
1417
1418    #[test]
1419    fn extracts_route_facts_for_script_languages() {
1420        let ts = ts_file();
1421        let ts_src = r#"router.post("/v1/orders", handler);"#;
1422        let ts_facts = extract_analysis_facts(&ts, ts_src, &extract_symbols(&ts, ts_src));
1423        assert!(ts_facts.iter().any(|fact| {
1424            fact.edge_type == GraphEdgeType::ExposesEndpoint && fact.target == "POST /v1/orders"
1425        }));
1426
1427        let py = python_file();
1428        let py_src = "@app.get('/health')\ndef health():\n    return {}\n";
1429        let py_facts = extract_analysis_facts(&py, py_src, &extract_symbols(&py, py_src));
1430        assert!(py_facts.iter().any(|fact| {
1431            fact.edge_type == GraphEdgeType::ExposesEndpoint && fact.target == "GET /health"
1432        }));
1433    }
1434
1435    #[test]
1436    fn extracts_service_boundary_facts_for_clients_channels_and_infra() {
1437        let ts = ts_file();
1438        let ts_src = r#"
1439router.post("/v1/orders", handler);
1440await fetch("https://billing.example.com/v1/orders");
1441producer.send({ topic: "orders.created" });
1442consumer.subscribe({ topic: "orders.created" });
1443"#;
1444        let ts_facts = extract_analysis_facts(&ts, ts_src, &extract_symbols(&ts, ts_src));
1445        assert!(ts_facts.iter().any(|fact| {
1446            fact.edge_type == GraphEdgeType::CallsEndpoint
1447                && fact.target == "HTTP https://billing.example.com/v1/orders"
1448        }));
1449        assert!(ts_facts.iter().any(|fact| {
1450            fact.edge_type == GraphEdgeType::PublishesEvent
1451                && fact.target_kind == GraphNodeType::Topic
1452                && fact.target == "orders.created"
1453        }));
1454        assert!(ts_facts.iter().any(|fact| {
1455            fact.edge_type == GraphEdgeType::ConsumesEvent
1456                && fact.target_kind == GraphNodeType::Topic
1457                && fact.target == "orders.created"
1458        }));
1459
1460        let docker = File {
1461            language: Language::Text,
1462            path: "Dockerfile".into(),
1463            ..rust_file()
1464        };
1465        let docker_facts =
1466            extract_analysis_facts(&docker, "ENV SERVICE_PORT=8080\nEXPOSE 8080\n", &[]);
1467        assert!(docker_facts.iter().any(|fact| {
1468            fact.edge_type == GraphEdgeType::ExposesEndpoint && fact.target == "TCP :8080"
1469        }));
1470        assert!(docker_facts.iter().any(|fact| {
1471            fact.edge_type == GraphEdgeType::WritesConfig && fact.target == "SERVICE_PORT"
1472        }));
1473
1474        let compose = File {
1475            language: Language::Yaml,
1476            path: "docker-compose.yml".into(),
1477            ..rust_file()
1478        };
1479        let compose_src = r#"
1480services:
1481  api:
1482    ports:
1483      - "8080:8080"
1484    environment:
1485      DATABASE_URL: postgres://db/app
1486    depends_on:
1487      - db
1488  db:
1489    image: postgres
1490"#;
1491        let compose_facts = extract_analysis_facts(&compose, compose_src, &[]);
1492        assert!(compose_facts.iter().any(|fact| {
1493            fact.target_kind == GraphNodeType::Resource && fact.target == "compose:service:api"
1494        }));
1495        assert!(compose_facts.iter().any(|fact| {
1496            fact.edge_type == GraphEdgeType::DependsOn && fact.target == "compose:service:db"
1497        }));
1498        assert!(compose_facts.iter().any(|fact| {
1499            fact.edge_type == GraphEdgeType::WritesConfig && fact.target == "DATABASE_URL"
1500        }));
1501
1502        let k8s = File {
1503            language: Language::Yaml,
1504            path: "k8s/service.yaml".into(),
1505            ..rust_file()
1506        };
1507        let k8s_src =
1508            "kind: Service\nmetadata:\n  name: orders-api\nspec:\n  ports:\n    - port: 80\n";
1509        let k8s_facts = extract_analysis_facts(&k8s, k8s_src, &[]);
1510        assert!(k8s_facts.iter().any(|fact| {
1511            fact.target_kind == GraphNodeType::Resource
1512                && fact.target == "kubernetes:Service:orders-api"
1513        }));
1514        assert!(k8s_facts.iter().any(|fact| {
1515            fact.edge_type == GraphEdgeType::ExposesEndpoint && fact.target == "TCP :80"
1516        }));
1517
1518        let terraform = File {
1519            language: Language::Text,
1520            path: "infra/main.tf".into(),
1521            ..rust_file()
1522        };
1523        let terraform_src = r#"
1524resource "aws_sns_topic" "orders_created" {}
1525variable "DATABASE_URL" {}
1526endpoint = "https://orders.example.com/v1/orders"
1527"#;
1528        let terraform_facts = extract_analysis_facts(&terraform, terraform_src, &[]);
1529        assert!(terraform_facts.iter().any(|fact| {
1530            fact.target_kind == GraphNodeType::Topic && fact.target == "orders_created"
1531        }));
1532        assert!(terraform_facts.iter().any(|fact| {
1533            fact.target_kind == GraphNodeType::ConfigKey && fact.target == "DATABASE_URL"
1534        }));
1535        assert!(terraform_facts.iter().any(|fact| {
1536            fact.edge_type == GraphEdgeType::CallsEndpoint
1537                && fact.target == "HTTP https://orders.example.com/v1/orders"
1538        }));
1539    }
1540
1541    // ─── extract_chunks ──────────────────────────────────────────────────────
1542
1543    #[test]
1544    fn chunks_file_with_no_symbols_into_80_line_windows() {
1545        let file = rust_file();
1546        let content: String = (1..=200).map(|i| format!("line {i}\n")).collect();
1547        let chunks = extract_chunks(&file, &content, &[]);
1548        assert!(
1549            chunks.len() >= 2,
1550            "200 lines should produce at least 2 chunks"
1551        );
1552        for chunk in &chunks {
1553            assert!(chunk.symbol_id.is_none());
1554        }
1555    }
1556
1557    #[test]
1558    fn chunks_file_by_symbol_boundaries() {
1559        let file = rust_file();
1560        let src = "pub fn alpha() {}\npub fn beta() {}\npub fn gamma() {}";
1561        let symbols = extract_symbols(&file, src);
1562        assert!(
1563            !symbols.is_empty(),
1564            "should have symbols from heuristic parser"
1565        );
1566        let chunks = extract_chunks(&file, src, &symbols);
1567        // Each symbol becomes a chunk boundary.
1568        assert!(!chunks.is_empty());
1569        assert!(chunks.iter().all(|c| c.symbol_id.is_some()));
1570    }
1571
1572    #[test]
1573    fn chunks_deduplicate_symbols_starting_on_same_line() {
1574        let file = ts_file();
1575        let src = "export const handler = () => call();\ncall();";
1576        let symbols = vec![
1577            Symbol {
1578                id: SymbolId::new("handler"),
1579                name: "handler".into(),
1580                qualified_name: "src::index::handler".into(),
1581                kind: SymbolKind::Function,
1582                file_id: file.id.clone(),
1583                range: Some(LineRange { start: 1, end: 1 }),
1584                language: Language::TypeScript,
1585                confidence: Confidence::High,
1586                provenance: EvidenceSourceType::TreeSitter,
1587            },
1588            Symbol {
1589                id: SymbolId::new("call"),
1590                name: "call".into(),
1591                qualified_name: "src::index::call".into(),
1592                kind: SymbolKind::Function,
1593                file_id: file.id.clone(),
1594                range: Some(LineRange { start: 1, end: 1 }),
1595                language: Language::TypeScript,
1596                confidence: Confidence::High,
1597                provenance: EvidenceSourceType::TreeSitter,
1598            },
1599        ];
1600
1601        let chunks = extract_chunks(&file, src, &symbols);
1602
1603        assert_eq!(chunks.len(), 1);
1604        assert_eq!(chunks[0].range.start, 1);
1605        assert_eq!(chunks[0].range.end, 2);
1606    }
1607
1608    // ─── extract_tests ────────────────────────────────────────────────────────
1609
1610    #[test]
1611    fn detects_rust_test_attribute() {
1612        let file = rust_file();
1613        let src = "#[test]\nfn it_works() {\n    assert!(true);\n}\n";
1614        let symbols = extract_symbols(&file, src);
1615        let tests = extract_tests(&file, src, &symbols, None);
1616        assert!(!tests.is_empty(), "should detect #[test] function");
1617        assert!(tests[0].command.as_deref() == Some("cargo test"));
1618    }
1619
1620    #[test]
1621    fn test_file_path_causes_all_symbols_to_be_tests() {
1622        let file = File {
1623            id: FileId::new("test-file"),
1624            repository_id: RepositoryId::new("repo"),
1625            path: "src/worker_test.rs".into(),
1626            language: Language::Rust,
1627            size_bytes: 0,
1628            content_hash: "hash".into(),
1629            is_generated: false,
1630            is_vendor: false,
1631        };
1632        let src = "pub fn some_helper() {}\n";
1633        let symbols = extract_symbols(&file, src);
1634        let tests = extract_tests(&file, src, &symbols, None);
1635        // All symbols in a test file become test targets.
1636        assert_eq!(tests.len(), symbols.len());
1637    }
1638}