1use std::collections::{HashMap, HashSet};
9
10use agentstatedeveloper_core::adapter::{
11 CallEdge, LanguageAdapter, ParsedSymbol, WorkspaceSymbols,
12};
13use agentstatedeveloper_core::cross_service::{
14 DetectedEndpoint, Direction, Transport, http_contract,
15};
16use agentstatedeveloper_core::error::{AsdError, Result};
17use agentstatedeveloper_core::schema::{Effect, EffectCategory, SymbolKind};
18use serde_json::json;
19use tree_sitter::{Node, Parser};
20use tree_sitter_language::LanguageFn;
21
22unsafe extern "C" {
23 fn tree_sitter_kotlin() -> *const ();
24}
25
26const KOTLIN: LanguageFn = unsafe { LanguageFn::from_raw(tree_sitter_kotlin) };
27
28#[derive(Debug, Default, Clone, Copy)]
30pub struct KotlinAdapter;
31
32impl KotlinAdapter {
33 pub fn new() -> Self {
34 Self
35 }
36}
37
38impl LanguageAdapter for KotlinAdapter {
39 fn language(&self) -> &str {
40 "kotlin"
41 }
42
43 fn file_extensions(&self) -> &'static [&'static str] {
44 &["kt", "kts"]
45 }
46
47 fn parse_symbols(&self, file: &str, source: &str) -> Result<Vec<ParsedSymbol>> {
48 let mut parser = Parser::new();
49 parser
50 .set_language(&KOTLIN.into())
51 .map_err(|e| AsdError::Parse(format!("failed to set kotlin language: {e}")))?;
52
53 let src_bytes = source.as_bytes();
54 let tree = parser
55 .parse(src_bytes, None)
56 .ok_or_else(|| AsdError::Parse(format!("failed to parse {file}")))?;
57
58 let pkg_prefix = package_prefix(source);
59 let root = tree.root_node();
60 let mut out = Vec::new();
61 walk(root, src_bytes, &pkg_prefix, &mut out);
62 Ok(out)
63 }
64
65 fn infer_effects(&self, _source: &str, symbol: &ParsedSymbol) -> Vec<Effect> {
66 infer_effects_from_body(&symbol.body)
67 }
68
69 fn infer_service_endpoints(
70 &self,
71 file: &str,
72 source: &str,
73 symbols: &[ParsedSymbol],
74 ) -> Vec<DetectedEndpoint> {
75 infer_service_endpoints_in_kotlin(file, source, symbols)
76 }
77
78 fn extract_call_edges(
79 &self,
80 file: &str,
81 source: &str,
82 symbols: &[ParsedSymbol],
83 workspace: &WorkspaceSymbols,
84 ) -> Vec<CallEdge> {
85 extract_call_edges_impl(file, source, symbols, workspace)
86 }
87}
88
89fn package_prefix(source: &str) -> String {
94 for line in source.lines() {
95 let t = line.trim();
96 if let Some(rest) = t.strip_prefix("package ") {
97 let pkg = rest.trim();
98 if !pkg.is_empty() {
99 return pkg.to_string();
100 }
101 }
102 }
103 String::new()
104}
105
106fn node_text<'a>(node: Node<'_>, src: &'a [u8]) -> &'a str {
107 node.utf8_text(src).unwrap_or("")
108}
109
110fn find_child_by_kind<'a>(node: Node<'a>, kind: &str) -> Option<Node<'a>> {
112 for i in 0..node.child_count() {
113 let c = node.child(i).unwrap();
114 if c.kind() == kind {
115 return Some(c);
116 }
117 }
118 None
119}
120
121fn join_qname(prefix: &str, name: &str) -> String {
122 if prefix.is_empty() {
123 name.to_string()
124 } else {
125 format!("{}.{}", prefix, name)
126 }
127}
128
129fn make_symbol(node: Node<'_>, src: &[u8], qname: String, kind: SymbolKind) -> ParsedSymbol {
130 make_symbol_sig(node, src, qname, kind, None)
131}
132
133fn make_symbol_sig(
134 node: Node<'_>,
135 src: &[u8],
136 qname: String,
137 kind: SymbolKind,
138 signature: Option<String>,
139) -> ParsedSymbol {
140 ParsedSymbol {
141 qname,
142 kind,
143 start_line: node.start_position().row as u32 + 1,
144 start_col: node.start_position().column as u32,
145 end_line: node.end_position().row as u32 + 1,
146 end_col: node.end_position().column as u32,
147 body: node_text(node, src).to_string(),
148 signature,
149 doc: None,
150 }
151}
152
153fn extract_sig_before_brace(node: Node<'_>, src: &[u8]) -> Option<String> {
155 let text = std::str::from_utf8(&src[node.start_byte()..node.end_byte()]).ok()?;
156 let bytes = text.as_bytes();
157 let mut depth: i32 = 0;
158 let mut i = 0;
159 let mut sig_end = text.len();
160 while i < bytes.len() {
161 match bytes[i] {
162 b'"' => {
163 i += 1;
164 while i < bytes.len() {
165 if bytes[i] == b'\\' {
166 i += 2;
167 continue;
168 }
169 if bytes[i] == b'"' {
170 break;
171 }
172 i += 1;
173 }
174 }
175 b'(' | b'[' => depth += 1,
176 b')' | b']' => {
177 if depth > 0 {
178 depth -= 1;
179 }
180 }
181 b'{' if depth == 0 => {
182 sig_end = i;
183 break;
184 }
185 _ => {}
186 }
187 i += 1;
188 }
189 let sig = text[..sig_end].trim().to_string();
190 if sig.is_empty() { None } else { Some(sig) }
191}
192
193fn walk(node: Node<'_>, src: &[u8], scope: &str, out: &mut Vec<ParsedSymbol>) {
198 match node.kind() {
199 "source_file" => {
200 for i in 0..node.child_count() {
201 walk(node.child(i).unwrap(), src, scope, out);
202 }
203 }
204 "class_declaration" | "object_declaration" => {
207 let name = find_child_by_kind(node, "type_identifier")
208 .map(|n| node_text(n, src))
209 .unwrap_or("");
210 if name.is_empty() {
211 return;
212 }
213 let qname = join_qname(scope, name);
214 out.push(make_symbol(node, src, qname.clone(), SymbolKind::Class));
215 if let Some(body) = find_child_by_kind(node, "class_body") {
216 for i in 0..body.child_count() {
217 walk(body.child(i).unwrap(), src, &qname, out);
218 }
219 }
220 }
221 "companion_object" => {
222 let name = find_child_by_kind(node, "type_identifier")
224 .map(|n| node_text(n, src))
225 .unwrap_or("Companion");
226 let qname = join_qname(scope, name);
227 out.push(make_symbol(node, src, qname.clone(), SymbolKind::Class));
228 if let Some(body) = find_child_by_kind(node, "class_body") {
229 for i in 0..body.child_count() {
230 walk(body.child(i).unwrap(), src, &qname, out);
231 }
232 }
233 }
234 "function_declaration" => {
235 let name = find_child_by_kind(node, "simple_identifier")
237 .map(|n| node_text(n, src))
238 .unwrap_or("");
239 if name.is_empty() {
240 return;
241 }
242 let qname = join_qname(scope, name);
243 let kind = if scope.is_empty() {
244 SymbolKind::Function
245 } else {
246 SymbolKind::Method
247 };
248 let sig = extract_sig_before_brace(node, src);
249 out.push(make_symbol_sig(node, src, qname, kind, sig));
250 }
251 _ => {
252 for i in 0..node.child_count() {
253 walk(node.child(i).unwrap(), src, scope, out);
254 }
255 }
256 }
257}
258
259fn find_occurrences(body: &str, needle: &str) -> Vec<usize> {
264 let mut out = Vec::new();
265 let mut from = 0;
266 while let Some(idx) = body[from..].find(needle) {
267 out.push(from + idx);
268 from += idx + needle.len();
269 }
270 out
271}
272
273fn extract_url_host(s: &str) -> Option<String> {
274 for scheme in ["https://", "http://"] {
275 if let Some(idx) = s.find(scheme) {
276 let tail = &s[idx + scheme.len()..];
277 let end = tail
278 .find(|c: char| c == '/' || c == '"' || c == ')' || c.is_whitespace())
279 .unwrap_or(tail.len());
280 let host = &tail[..end];
281 if !host.is_empty() {
282 return Some(host.to_string());
283 }
284 }
285 }
286 None
287}
288
289fn first_matching_line(body: &str, needles: &[&str]) -> Option<String> {
290 for line in body.lines() {
291 for n in needles {
292 if line.contains(n) {
293 return Some(line.trim().to_string());
294 }
295 }
296 }
297 None
298}
299
300fn first_match_note(body: &str, needles: &[&str]) -> Option<String> {
301 for n in needles {
302 if body.contains(n) {
303 return first_matching_line(body, needles);
304 }
305 }
306 None
307}
308
309fn extract_first_string_literal(s: &str) -> Option<String> {
310 let trimmed = s.trim_start();
311 let bytes = trimmed.as_bytes();
312 if bytes.is_empty() {
313 return None;
314 }
315 if bytes[0] != b'"' {
316 return None;
317 }
318 let mut j = 1;
319 let mut out = String::new();
320 while j < bytes.len() {
321 let c = bytes[j];
322 if c == b'\\' && j + 1 < bytes.len() {
323 out.push(bytes[j + 1] as char);
324 j += 2;
325 continue;
326 }
327 if c == b'"' {
328 return Some(out);
329 }
330 out.push(c as char);
331 j += 1;
332 }
333 None
334}
335
336const KT_HTTP_ATTRS: &[(&str, &str)] = &[
343 ("GetMapping", "GET"),
344 ("PostMapping", "POST"),
345 ("PutMapping", "PUT"),
346 ("DeleteMapping", "DELETE"),
347 ("PatchMapping", "PATCH"),
348];
349const KT_VERBS: &[&str] = &["get", "post", "put", "patch", "delete"];
350
351fn infer_service_endpoints_in_kotlin(
352 file: &str,
353 source: &str,
354 symbols: &[ParsedSymbol],
355) -> Vec<DetectedEndpoint> {
356 let prefix = kt_class_prefix(source);
357 let mut out = Vec::new();
358 for (idx, raw) in source.lines().enumerate() {
359 let line_no = (idx + 1) as u32;
360 let line = raw.trim();
361
362 for (method, sub) in kt_method_mappings(line) {
364 if let Some(owner) = kt_owner_for_annotation(symbols, line_no) {
365 out.push(mk_in(
366 file,
367 line_no,
368 &http_contract(&method, &kt_join(&prefix, &sub)),
369 owner,
370 ));
371 }
372 }
373 if let Some((method, path)) = kt_ktor_route(line) {
375 if let Some(owner) = kt_owner_for_body(symbols, line_no) {
376 out.push(mk_in(file, line_no, &http_contract(&method, &path), owner));
377 }
378 }
379 for (method, url) in kt_clients(line) {
381 if let Some(owner) = kt_owner_for_body(symbols, line_no) {
382 out.push(mk_out(file, line_no, &http_contract(&method, &url), owner));
383 }
384 }
385 }
386 out
387}
388
389fn mk_in(file: &str, line: u32, contract: &str, owner: &ParsedSymbol) -> DetectedEndpoint {
390 DetectedEndpoint {
391 transport: Transport::Http,
392 direction: Direction::Inbound,
393 contract: contract.to_string(),
394 owner_qname: owner.qname.clone(),
395 file: file.to_string(),
396 line,
397 confidence: 0.9,
398 note: None,
399 }
400}
401fn mk_out(file: &str, line: u32, contract: &str, owner: &ParsedSymbol) -> DetectedEndpoint {
402 DetectedEndpoint {
403 transport: Transport::Http,
404 direction: Direction::Outbound,
405 contract: contract.to_string(),
406 owner_qname: owner.qname.clone(),
407 file: file.to_string(),
408 line,
409 confidence: 0.85,
410 note: None,
411 }
412}
413
414fn kt_owner_for_body(symbols: &[ParsedSymbol], line: u32) -> Option<&ParsedSymbol> {
415 symbols
416 .iter()
417 .filter(|s| s.start_line <= line && line <= s.end_line)
418 .max_by_key(|s| s.start_line)
419}
420fn kt_owner_for_annotation(symbols: &[ParsedSymbol], line: u32) -> Option<&ParsedSymbol> {
421 if let Some(s) = kt_owner_for_body(symbols, line) {
422 return Some(s);
423 }
424 symbols
425 .iter()
426 .filter(|s| s.start_line > line && s.start_line <= line + 6)
427 .min_by_key(|s| s.start_line)
428}
429
430fn kt_class_prefix(source: &str) -> String {
431 for line in source.lines() {
432 let t = line.trim();
433 if t.starts_with("@RequestMapping") {
434 if let Some(p) = kt_mapping_path(t) {
435 return p;
436 }
437 }
438 if t.contains("class ") || t.contains("interface ") {
439 break;
440 }
441 }
442 String::new()
443}
444
445fn kt_method_mappings(line: &str) -> Vec<(String, String)> {
446 let mut out = Vec::new();
447 for (anno, method) in KT_HTTP_ATTRS {
448 if line.starts_with(&format!("@{anno}")) {
449 out.push((
450 method.to_string(),
451 kt_mapping_path(line).unwrap_or_default(),
452 ));
453 }
454 }
455 if line.starts_with("@RequestMapping") {
456 if let Some(m) = kt_request_method(line) {
457 out.push((m, kt_mapping_path(line).unwrap_or_default()));
458 }
459 }
460 out
461}
462
463fn kt_mapping_path(line: &str) -> Option<String> {
464 let open = line.find('(')?;
465 let args = &line[open + 1..];
466 for key in ["value", "path"] {
467 if let Some(p) = kt_kv_string(args, key) {
468 return Some(p);
469 }
470 }
471 let a = args.trim_start();
472 if a.starts_with('"') {
473 return first_kt_string(a);
474 }
475 None
476}
477fn kt_kv_string(args: &str, key: &str) -> Option<String> {
478 let pos = args.find(key)?;
479 let rest = args[pos + key.len()..]
480 .trim_start()
481 .strip_prefix('=')?
482 .trim_start();
483 first_kt_string(rest)
484}
485fn kt_request_method(line: &str) -> Option<String> {
486 let pos = line.find("RequestMethod.")?;
487 let v: String = line[pos + 14..]
488 .chars()
489 .take_while(|c| c.is_ascii_alphabetic())
490 .collect();
491 (!v.is_empty()).then(|| v.to_uppercase())
492}
493
494fn kt_ktor_route(line: &str) -> Option<(String, String)> {
496 for verb in KT_VERBS {
497 if let Some(rest) = line.strip_prefix(&format!("{verb}(")) {
498 if let Some(path) = first_kt_string(rest.trim_start()) {
499 if path.starts_with('/') {
500 return Some((verb.to_uppercase(), path));
501 }
502 }
503 }
504 }
505 None
506}
507
508fn kt_clients(line: &str) -> Vec<(String, String)> {
509 let mut out = Vec::new();
510 let simple = [
511 ("getForObject", "GET"),
512 ("getForEntity", "GET"),
513 ("postForObject", "POST"),
514 ("postForEntity", "POST"),
515 ];
516 for (m, method) in simple {
517 let needle = format!(".{m}(");
518 if let Some(pos) = line.find(&needle) {
519 if let Some(url) = first_kt_string(line[pos + needle.len()..].trim_start()) {
520 if kt_looks_like_url(&url) {
521 out.push((method.to_string(), url));
522 }
523 }
524 }
525 }
526 out
527}
528fn kt_looks_like_url(s: &str) -> bool {
529 s.starts_with('/') || s.contains("://")
530}
531fn kt_join(prefix: &str, sub: &str) -> String {
532 let p = prefix.trim_end_matches('/');
533 let s = sub.trim_start_matches('/');
534 match (p.is_empty(), s.is_empty()) {
535 (true, true) => "/".to_string(),
536 (false, true) => p.to_string(),
537 (true, false) => format!("/{s}"),
538 (false, false) => format!("{p}/{s}"),
539 }
540}
541fn first_kt_string(s: &str) -> Option<String> {
543 let s = s.trim_start();
544 if !s.starts_with('"') {
545 return None;
546 }
547 let inner = &s[1..];
548 let end = inner.find('"')?;
549 let val = &inner[..end];
550 if val.contains('$') {
551 return None;
552 }
553 Some(val.to_string())
554}
555
556fn infer_effects_from_body(body: &str) -> Vec<Effect> {
557 let mut effects: Vec<Effect> = Vec::new();
558
559 let fs_read_needles = [
561 "File(",
562 ".readText(",
563 ".readLines(",
564 ".readBytes(",
565 "Files.readString(",
566 "Files.readAllBytes(",
567 "BufferedReader(",
568 "FileInputStream(",
569 "FileReader(",
570 ];
571 if let Some(note) = first_match_note(body, &fs_read_needles) {
572 effects.push(Effect {
573 effect: EffectCategory::IoFsRead,
574 qualifiers: serde_json::Value::Null,
575 note: Some(note),
576 ..Default::default()
577 });
578 }
579
580 let fs_write_needles = [
582 ".writeText(",
583 ".writeBytes(",
584 ".appendText(",
585 "Files.write(",
586 "FileOutputStream(",
587 "FileWriter(",
588 ".createNewFile(",
589 ".mkdir(",
590 ".mkdirs(",
591 ".delete(",
592 ];
593 if let Some(note) = first_match_note(body, &fs_write_needles) {
594 effects.push(Effect {
595 effect: EffectCategory::IoFsWrite,
596 qualifiers: serde_json::Value::Null,
597 note: Some(note),
598 ..Default::default()
599 });
600 }
601
602 let net_needles = [
604 "OkHttpClient(",
605 "OkHttpClient.Builder(",
606 ".newCall(",
607 "Retrofit.Builder(",
608 ".create(Service",
609 "HttpURLConnection",
610 "URL(",
611 "ktor",
612 "client.get(",
613 "client.post(",
614 "client.request(",
615 "HttpClient(",
616 "RestTemplate(",
620 ".getForObject(",
621 ".getForEntity(",
622 ".postForObject(",
623 ".postForEntity(",
624 ".exchange(",
625 ];
626 let mut net_hosts: Vec<String> = Vec::new();
627 let mut net_note: Option<String> = None;
628 for needle in net_needles {
629 if body.contains(needle) && net_note.is_none() {
630 net_note = first_matching_line(body, &[needle]);
631 for off in find_occurrences(body, needle) {
632 let end = (off + 200).min(body.len());
633 let snippet = &body[off..end];
634 if let Some(host) = extract_url_host(snippet) {
635 if !net_hosts.contains(&host) {
636 net_hosts.push(host);
637 }
638 }
639 }
640 }
641 }
642 if net_note.is_some() {
643 let qualifiers = if net_hosts.is_empty() {
644 serde_json::Value::Null
645 } else {
646 json!({ "hosts": net_hosts })
647 };
648 effects.push(Effect {
649 effect: EffectCategory::IoNetOut,
650 qualifiers,
651 note: net_note,
652 ..Default::default()
653 });
654 }
655
656 let db_needles = [
658 "DriverManager.getConnection(",
659 "dataSource.connection",
660 ".prepareStatement(",
661 ".executeQuery(",
662 ".executeUpdate(",
663 "Room.databaseBuilder(",
664 "dao.",
665 "@Query(",
666 "@Insert",
667 "@Update",
668 "@Delete",
669 "Exposed.",
670 "transaction {",
671 ".from(",
672 ".select(",
673 ".insertAndGetId {",
674 ];
675 if let Some(note) = first_match_note(body, &db_needles) {
676 let has_write = body.contains(".executeUpdate(")
677 || body.contains("@Insert")
678 || body.contains("@Update")
679 || body.contains("@Delete")
680 || body.contains(".insertAndGetId {")
681 || body.contains(".update {")
682 || body.contains(".deleteWhere {");
683 let has_read = body.contains(".executeQuery(")
684 || body.contains("@Query(")
685 || body.contains(".select(")
686 || body.contains(".from(");
687 if has_read || (!has_read && !has_write) {
688 effects.push(Effect {
689 effect: EffectCategory::IoDbRead,
690 qualifiers: serde_json::Value::Null,
691 note: Some(note.clone()),
692 ..Default::default()
693 });
694 }
695 if has_write || (!has_read && !has_write) {
696 effects.push(Effect {
697 effect: EffectCategory::IoDbWrite,
698 qualifiers: serde_json::Value::Null,
699 note: Some(note),
700 ..Default::default()
701 });
702 }
703 }
704
705 let proc_needles = [
707 "ProcessBuilder(",
708 "Runtime.getRuntime().exec(",
709 "ProcessBuilder.start(",
710 ];
711 if let Some(note) = first_match_note(body, &proc_needles) {
712 effects.push(Effect {
713 effect: EffectCategory::ProcSpawn,
714 qualifiers: serde_json::Value::Null,
715 note: Some(note),
716 ..Default::default()
717 });
718 }
719
720 let env_needles = ["System.getenv(", "System.getProperty(", "dotenv["];
722 if let Some(note) = first_match_note(body, &env_needles) {
723 let mut vars: Vec<String> = Vec::new();
724 for off in find_occurrences(body, "System.getenv(") {
725 let args = &body[off + "System.getenv(".len()..];
726 if let Some(v) = extract_first_string_literal(args) {
727 if !vars.contains(&v) {
728 vars.push(v);
729 }
730 }
731 }
732 let qualifiers = if vars.is_empty() {
733 serde_json::Value::Null
734 } else {
735 json!({ "vars": vars })
736 };
737 effects.push(Effect {
738 effect: EffectCategory::EnvRead,
739 qualifiers,
740 note: Some(note),
741 ..Default::default()
742 });
743 }
744
745 let log_needles = [
747 "println(",
748 "print(",
749 "Log.d(",
750 "Log.i(",
751 "Log.w(",
752 "Log.e(",
753 "logger.info(",
754 "logger.debug(",
755 "logger.warn(",
756 "logger.error(",
757 "Timber.",
758 "slf4j",
759 ];
760 if let Some(note) = first_match_note(body, &log_needles) {
761 effects.push(Effect {
762 effect: EffectCategory::Log,
763 qualifiers: serde_json::Value::Null,
764 note: Some(note),
765 ..Default::default()
766 });
767 }
768
769 let sleep_needles = ["Thread.sleep(", "delay(", "runBlocking { delay("];
771 if let Some(note) = first_match_note(body, &sleep_needles) {
772 effects.push(Effect {
773 effect: EffectCategory::TimeSleep,
774 qualifiers: serde_json::Value::Null,
775 note: Some(note),
776 ..Default::default()
777 });
778 }
779
780 let time_needles = [
782 "System.currentTimeMillis()",
783 "System.nanoTime()",
784 "Instant.now()",
785 "LocalDateTime.now()",
786 "LocalDate.now()",
787 "LocalTime.now()",
788 "ZonedDateTime.now()",
789 "OffsetDateTime.now()",
790 "Clock.System.now()",
791 "measureTimeMillis",
792 "measureNanoTime",
793 "measureTimedValue",
794 ];
795 if let Some(note) = first_match_note(body, &time_needles) {
796 effects.push(Effect {
797 effect: EffectCategory::TimeRead,
798 qualifiers: serde_json::Value::Null,
799 note: Some(note),
800 ..Default::default()
801 });
802 }
803
804 let rand_needles = [
806 "Random.nextInt(",
807 "Random.nextLong(",
808 "Random.nextFloat(",
809 "Random.nextDouble(",
810 "Random.nextBoolean(",
811 "Random.Default",
812 "SecureRandom(",
813 "UUID.randomUUID()",
814 "kotlin.random.Random",
815 ".random()",
816 ];
817 if let Some(note) = first_match_note(body, &rand_needles) {
818 effects.push(Effect {
819 effect: EffectCategory::Random,
820 qualifiers: serde_json::Value::Null,
821 note: Some(note),
822 ..Default::default()
823 });
824 }
825
826 let throw_needles = ["throw ", "error(", "check(", "require("];
828 if let Some(note) = first_match_note(body, &throw_needles) {
829 effects.push(Effect {
830 effect: EffectCategory::Throw,
831 qualifiers: serde_json::Value::Null,
832 note: Some(note),
833 ..Default::default()
834 });
835 }
836
837 effects
838}
839
840fn parse_imports(source: &str) -> HashMap<String, String> {
845 let mut map: HashMap<String, String> = HashMap::new();
846 for line in source.lines() {
847 let t = line.trim();
848 if let Some(rest) = t.strip_prefix("import ") {
849 let fqn = rest.trim();
850 if fqn.ends_with('*') {
851 continue;
852 }
853 let simple = fqn.rsplit('.').next().unwrap_or(fqn);
854 map.insert(simple.to_string(), fqn.to_string());
855 }
856 }
857 map
858}
859
860fn enclosing_type_qname(qname: &str, known: &HashSet<&str>) -> Option<String> {
861 let idx = qname.rfind('.')?;
862 let parent = &qname[..idx];
863 if known.contains(parent) {
864 Some(parent.to_string())
865 } else {
866 None
867 }
868}
869
870fn collect_calls(
871 node: Node<'_>,
872 src: &[u8],
873 sym: &ParsedSymbol,
874 by_simple: &HashMap<String, String>,
875 known: &HashSet<&str>,
876 imports: &HashMap<String, String>,
877 workspace: &WorkspaceSymbols,
878 enclosing_type: Option<&str>,
879 edges: &mut HashSet<CallEdge>,
880) {
881 if node.kind() == "call_expression" {
882 let first = node.child(0);
884 if let Some(cn) = first {
885 let (receiver, method) = if cn.kind() == "navigation_expression" {
886 let recv = cn.child(0).map(|n| node_text(n, src)).unwrap_or("");
888 let method = find_child_by_kind(cn, "navigation_suffix")
889 .and_then(|s| find_child_by_kind(s, "simple_identifier"))
890 .map(|n| node_text(n, src))
891 .unwrap_or("");
892 (recv, method)
893 } else if cn.kind() == "simple_identifier" {
894 ("", node_text(cn, src))
895 } else {
896 ("", "")
897 };
898
899 if !method.is_empty() {
900 let callee = if receiver.is_empty() {
901 if let Some(et) = enclosing_type {
902 let q = format!("{}.{}", et, method);
903 if known.contains(q.as_str()) {
904 Some(q)
905 } else {
906 by_simple.get(method).cloned()
907 }
908 } else {
909 by_simple.get(method).cloned()
910 }
911 } else if let Some(fqn) = imports.get(receiver) {
912 let q = format!("{}.{}", fqn, method);
913 if known.contains(q.as_str()) || workspace.contains(&q) {
914 Some(q)
915 } else {
916 None
917 }
918 } else {
919 let q = format!("{}.{}", receiver, method);
920 if known.contains(q.as_str()) || workspace.contains(&q) {
921 Some(q)
922 } else {
923 None
924 }
925 };
926 if let Some(callee_qname) = callee {
927 if callee_qname != sym.qname {
928 edges.insert(CallEdge {
929 caller_qname: sym.qname.clone(),
930 callee_qname,
931 });
932 }
933 }
934 }
935 }
936 }
937
938 for i in 0..node.child_count() {
939 collect_calls(
940 node.child(i).unwrap(),
941 src,
942 sym,
943 by_simple,
944 known,
945 imports,
946 workspace,
947 enclosing_type,
948 edges,
949 );
950 }
951}
952
953fn extract_call_edges_impl(
954 file: &str,
955 source: &str,
956 symbols: &[ParsedSymbol],
957 workspace: &WorkspaceSymbols,
958) -> Vec<CallEdge> {
959 let _ = file;
960 let known: HashSet<&str> = symbols.iter().map(|s| s.qname.as_str()).collect();
961
962 let mut by_simple: HashMap<String, String> = HashMap::new();
963 for s in symbols {
964 let simple = s.qname.rsplit('.').next().unwrap_or(&s.qname).to_string();
965 by_simple.entry(simple).or_insert_with(|| s.qname.clone());
966 }
967
968 let mut parser = Parser::new();
969 if parser.set_language(&KOTLIN.into()).is_err() {
970 return Vec::new();
971 }
972
973 let imports = parse_imports(source);
974 let mut edges: HashSet<CallEdge> = HashSet::new();
975
976 for sym in symbols {
977 if !matches!(sym.kind, SymbolKind::Function | SymbolKind::Method) {
978 continue;
979 }
980 let src_bytes = sym.body.as_bytes();
981 let tree = match parser.parse(src_bytes, None) {
982 Some(t) => t,
983 None => continue,
984 };
985 let enclosing_type = enclosing_type_qname(&sym.qname, &known);
986 collect_calls(
987 tree.root_node(),
988 src_bytes,
989 sym,
990 &by_simple,
991 &known,
992 &imports,
993 workspace,
994 enclosing_type.as_deref(),
995 &mut edges,
996 );
997 }
998
999 let mut out: Vec<CallEdge> = edges.into_iter().collect();
1000 out.sort_by(|a, b| {
1001 a.caller_qname
1002 .cmp(&b.caller_qname)
1003 .then_with(|| a.callee_qname.cmp(&b.callee_qname))
1004 });
1005 out
1006}
1007
1008#[cfg(test)]
1013mod tests {
1014 use super::*;
1015 use agentstatedeveloper_core::adapter::{LanguageAdapter, WorkspaceSymbols};
1016
1017 fn adapter() -> KotlinAdapter {
1018 KotlinAdapter::new()
1019 }
1020
1021 #[test]
1022 fn parses_package_class_method_object() {
1023 let src = r#"
1024package com.example.payments
1025
1026class PaymentService {
1027 fun charge(customerId: String, amount: Long): Receipt {
1028 return Receipt()
1029 }
1030
1031 companion object {
1032 fun create(): PaymentService = PaymentService()
1033 }
1034}
1035
1036interface Gateway {
1037 fun process(payment: Payment)
1038}
1039
1040object Config {
1041 val timeout = 30
1042}
1043"#;
1044 let syms = adapter().parse_symbols("PaymentService.kt", src).unwrap();
1045 let qnames: Vec<&str> = syms.iter().map(|s| s.qname.as_str()).collect();
1046 assert!(
1047 qnames.contains(&"com.example.payments.PaymentService"),
1048 "{qnames:?}"
1049 );
1050 assert!(
1051 qnames.contains(&"com.example.payments.PaymentService.charge"),
1052 "{qnames:?}"
1053 );
1054 assert!(
1055 qnames.contains(&"com.example.payments.Gateway"),
1056 "{qnames:?}"
1057 );
1058 assert!(
1059 qnames.contains(&"com.example.payments.Config"),
1060 "{qnames:?}"
1061 );
1062 }
1063
1064 #[test]
1065 fn package_prefix_parses_correctly() {
1066 assert_eq!(
1067 package_prefix("package com.example.payments"),
1068 "com.example.payments"
1069 );
1070 assert_eq!(package_prefix("// no package"), "");
1071 assert_eq!(package_prefix("package org.acme"), "org.acme");
1072 }
1073
1074 #[test]
1075 fn infers_fs_read_and_net_out() {
1076 let src = r#"
1077class Fetcher {
1078 fun fetch(): String {
1079 val data = File("/tmp/config.yml").readText()
1080 val client = OkHttpClient()
1081 val request = Request.Builder()
1082 .url("https://api.example.com/v1/data")
1083 .build()
1084 return data
1085 }
1086}
1087"#;
1088 let syms = adapter().parse_symbols("Fetcher.kt", src).unwrap();
1089 let fetch = syms.iter().find(|s| s.qname.ends_with(".fetch")).unwrap();
1090 let effs = adapter().infer_effects("", fetch);
1091 let cats: Vec<_> = effs.iter().map(|e| &e.effect).collect();
1092 assert!(cats.contains(&&EffectCategory::IoFsRead), "{cats:?}");
1093 assert!(cats.contains(&&EffectCategory::IoNetOut), "{cats:?}");
1094 }
1095
1096 #[test]
1097 fn infers_net_out_from_resttemplate() {
1098 let src = r#"
1102class UserClient {
1103 fun getUser(): String {
1104 val rt = RestTemplate()
1105 return rt.getForObject("https://api.example.com/users/1", String::class.java)
1106 }
1107}
1108"#;
1109 let syms = adapter().parse_symbols("UserClient.kt", src).unwrap();
1110 let f = syms.iter().find(|s| s.qname.ends_with(".getUser")).unwrap();
1111 let effs = adapter().infer_effects("", f);
1112 let cats: Vec<_> = effs.iter().map(|e| &e.effect).collect();
1113 assert!(cats.contains(&&EffectCategory::IoNetOut), "{cats:?}");
1114 }
1115
1116 #[test]
1117 fn infers_db_and_env() {
1118 let src = r#"
1119class UserRepo {
1120 fun findUser(id: Long): User? {
1121 val dbUrl = System.getenv("DATABASE_URL")
1122 return dao.findById(id)
1123 }
1124 fun saveUser(user: User) {
1125 dao.insertAndGetId { it[Users.name] = user.name }
1126 }
1127}
1128"#;
1129 let syms = adapter().parse_symbols("UserRepo.kt", src).unwrap();
1130 let find = syms
1131 .iter()
1132 .find(|s| s.qname.ends_with(".findUser"))
1133 .unwrap();
1134 let save = syms
1135 .iter()
1136 .find(|s| s.qname.ends_with(".saveUser"))
1137 .unwrap();
1138 let find_effs = adapter().infer_effects("", find);
1139 let save_effs = adapter().infer_effects("", save);
1140 assert!(
1141 find_effs
1142 .iter()
1143 .any(|e| e.effect == EffectCategory::EnvRead)
1144 );
1145 assert!(
1146 save_effs
1147 .iter()
1148 .any(|e| e.effect == EffectCategory::IoDbWrite)
1149 );
1150
1151 let env_eff = find_effs
1152 .iter()
1153 .find(|e| e.effect == EffectCategory::EnvRead)
1154 .unwrap();
1155 if let Some(vars) = env_eff.qualifiers.get("vars") {
1156 let vars: Vec<&str> = vars
1157 .as_array()
1158 .unwrap()
1159 .iter()
1160 .map(|v| v.as_str().unwrap())
1161 .collect();
1162 assert!(vars.contains(&"DATABASE_URL"), "{vars:?}");
1163 }
1164 }
1165
1166 #[test]
1167 fn empty_when_no_patterns() {
1168 let src = r#"
1169class Math {
1170 fun add(a: Int, b: Int): Int = a + b
1171}
1172"#;
1173 let syms = adapter().parse_symbols("Math.kt", src).unwrap();
1174 let add = syms.iter().find(|s| s.qname.ends_with(".add")).unwrap();
1175 let effs = adapter().infer_effects("", add);
1176 assert!(effs.is_empty(), "{effs:?}");
1177 }
1178
1179 #[test]
1180 fn extracts_intra_class_call_edges() {
1181 let src = r#"
1182package com.example
1183
1184class OrderService {
1185 fun placeOrder(order: Order) {
1186 charge(order.customerId, order.total)
1187 }
1188
1189 fun charge(customerId: String, amount: Long) {
1190 // process
1191 }
1192}
1193"#;
1194 let ws = WorkspaceSymbols {
1195 qnames: HashSet::new(),
1196 kinds: HashMap::new(),
1197 ..Default::default()
1198 };
1199 let syms = adapter().parse_symbols("OrderService.kt", src).unwrap();
1200 let edges = adapter().extract_call_edges("OrderService.kt", src, &syms, &ws);
1201 let found = edges.iter().any(|e| {
1202 e.caller_qname.ends_with(".placeOrder") && e.callee_qname.ends_with(".charge")
1203 });
1204 assert!(found, "expected intra-class edge; got: {edges:?}");
1205 }
1206}
1207
1208#[cfg(test)]
1209mod service_endpoint_tests {
1210 use super::*;
1211 use agentstatedeveloper_core::adapter::LanguageAdapter;
1212
1213 fn detect(src: &str) -> Vec<DetectedEndpoint> {
1214 let a = KotlinAdapter;
1215 let s = a.parse_symbols("C.kt", src).unwrap();
1216 a.infer_service_endpoints("C.kt", src, &s)
1217 }
1218 fn inb(e: &[DetectedEndpoint]) -> Vec<&DetectedEndpoint> {
1219 e.iter()
1220 .filter(|e| e.direction == Direction::Inbound)
1221 .collect()
1222 }
1223 fn outb(e: &[DetectedEndpoint]) -> Vec<&DetectedEndpoint> {
1224 e.iter()
1225 .filter(|e| e.direction == Direction::Outbound)
1226 .collect()
1227 }
1228
1229 #[test]
1230 fn spring_controller_prefix_plus_method() {
1231 let src = "@RestController\n@RequestMapping(\"/api/users\")\nclass UserController {\n @GetMapping(\"/{id}\")\n fun get(id: Long): User { return user }\n}\n";
1232 let eps = detect(src);
1233 assert_eq!(inb(&eps)[0].contract, "http:GET /api/users/{}", "{eps:?}");
1234 }
1235
1236 #[test]
1237 fn ktor_dsl_route() {
1238 let src = "fun Application.routes() {\n routing {\n get(\"/users/{id}\") {\n call.respond(1)\n }\n }\n}\n";
1239 let eps = detect(src);
1240 assert_eq!(inb(&eps)[0].contract, "http:GET /users/{}", "{eps:?}");
1241 }
1242
1243 #[test]
1244 fn resttemplate_client_outbound() {
1245 let src = "class S {\n fun call() {\n restTemplate.getForObject(\"https://users.svc/users/{id}\", User::class.java)\n }\n}\n";
1246 let eps = detect(src);
1247 assert_eq!(outb(&eps)[0].contract, "http:GET /users/{}");
1248 }
1249
1250 #[test]
1251 fn map_get_is_not_a_route() {
1252 let src = "fun f() {\n val v = cache.get(\"key\")\n}\n";
1253 assert!(inb(&detect(src)).is_empty(), "{:?}", detect(src));
1254 }
1255}