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#"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 #[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 #[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 #[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 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 #[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 assert_eq!(tests.len(), symbols.len());
1637 }
1638}