Skip to main content

agentstatedeveloper_kotlin/
lib.rs

1//! Kotlin language adapter for AgentStateDeveloper.
2//!
3//! Implements [`LanguageAdapter`](agentstatedeveloper_core::LanguageAdapter)
4//! on top of `tree-sitter-kotlin`. Parses classes, objects, interfaces, and
5//! functions/methods with package-qualified names, then runs substring-based
6//! effect inference.
7
8use 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/// Kotlin language adapter.
29#[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
89// -----------------------------------------------------------------------------
90// qname helpers
91// -----------------------------------------------------------------------------
92
93fn 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
110/// Find first child with a given node kind.
111fn 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
153/// Extract Kotlin function signature: text up to (not including) the body `{`.
154fn 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
193// -----------------------------------------------------------------------------
194// Symbol walking
195// -----------------------------------------------------------------------------
196
197fn 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        // Kotlin grammar (0.3.x) has no named fields — children are positional.
205        // Class name is the first `type_identifier` child; body is `class_body`.
206        "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            // companion object { ... } — name defaults to "Companion"
223            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            // Name is the first `simple_identifier` child.
236            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
259// -----------------------------------------------------------------------------
260// Effect inference
261// -----------------------------------------------------------------------------
262
263fn 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
336// -----------------------------------------------------------------------------
337// Cross-service endpoint detection (t-015) — Spring (annotations, like Java) +
338// Ktor DSL routes. Outbound: Spring RestTemplate. Ktor-client (.get on an
339// object) is ambiguous and deferred.
340// -----------------------------------------------------------------------------
341
342const 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        // Spring annotation routes (owner = the function below the annotation).
363        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        // Ktor DSL route `get("/path") {` (owner = enclosing symbol).
374        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        // Spring RestTemplate clients.
380        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
494/// Ktor DSL: a line starting with `verb("/path"`.
495fn 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}
541/// First `"…"` literal; rejects Kotlin string interpolation (`$`).
542fn 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    // FS Read
560    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    // FS Write
581    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    // Network
603    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        // Spring RestTemplate — already recognized by the cross-service
617        // outbound detector; the effect detector must agree (conformance
618        // matrix caught the inconsistency).
619        "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    // Database
657    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    // Process spawn
706    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    // Env read
721    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    // Logging
746    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    // Time sleep
770    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    // Time read
781    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    // Random
805    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    // Throw
827    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
840// -----------------------------------------------------------------------------
841// Call-edge extraction
842// -----------------------------------------------------------------------------
843
844fn 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        // child[0] is either simple_identifier (bare call) or navigation_expression (qualified)
883        let first = node.child(0);
884        if let Some(cn) = first {
885            let (receiver, method) = if cn.kind() == "navigation_expression" {
886                // receiver.method(…) — receiver is first child, method is in navigation_suffix
887                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// -----------------------------------------------------------------------------
1009// Tests
1010// -----------------------------------------------------------------------------
1011
1012#[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        // RestTemplate is recognized by the cross-service outbound detector;
1099        // the effect detector must agree. Regression guard for the gap the
1100        // conformance matrix surfaced (Kotlin effects column was blank).
1101        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}