Skip to main content

agentstatedeveloper_java/
lib.rs

1//! Java language adapter for AgentStateDeveloper.
2//!
3//! Implements [`LanguageAdapter`](agentstatedeveloper_core::LanguageAdapter)
4//! on top of `tree-sitter-java`. Parses top-level classes, interfaces, enums,
5//! and their methods/constructors, then runs substring-based effect inference.
6
7use std::collections::{HashMap, HashSet};
8
9use agentstatedeveloper_core::adapter::{
10    CallEdge, LanguageAdapter, ParsedSymbol, WorkspaceSymbols,
11};
12use agentstatedeveloper_core::cross_service::{
13    DetectedEndpoint, Direction, Transport, http_contract,
14};
15use agentstatedeveloper_core::error::{AsdError, Result};
16use agentstatedeveloper_core::schema::{Effect, EffectCategory, SymbolKind};
17use serde_json::json;
18use tree_sitter::{Node, Parser};
19
20/// Java language adapter.
21#[derive(Debug, Default, Clone, Copy)]
22pub struct JavaAdapter;
23
24impl JavaAdapter {
25    pub fn new() -> Self {
26        Self
27    }
28}
29
30impl LanguageAdapter for JavaAdapter {
31    fn language(&self) -> &str {
32        "java"
33    }
34
35    fn file_extensions(&self) -> &'static [&'static str] {
36        &["java"]
37    }
38
39    fn parse_symbols(&self, file: &str, source: &str) -> Result<Vec<ParsedSymbol>> {
40        let mut parser = Parser::new();
41        parser
42            .set_language(&tree_sitter_java::LANGUAGE.into())
43            .map_err(|e| AsdError::Parse(format!("failed to set java language: {e}")))?;
44
45        let src_bytes = source.as_bytes();
46        let tree = parser
47            .parse(src_bytes, None)
48            .ok_or_else(|| AsdError::Parse(format!("failed to parse {file}")))?;
49
50        let pkg_prefix = package_prefix(source);
51        let root = tree.root_node();
52        let mut out = Vec::new();
53        walk(root, src_bytes, &pkg_prefix, None, &mut out);
54        Ok(out)
55    }
56
57    fn infer_effects(&self, _source: &str, symbol: &ParsedSymbol) -> Vec<Effect> {
58        infer_effects_from_body(&symbol.body)
59    }
60
61    fn infer_service_endpoints(
62        &self,
63        file: &str,
64        source: &str,
65        symbols: &[ParsedSymbol],
66    ) -> Vec<DetectedEndpoint> {
67        infer_service_endpoints_in_java(file, source, symbols)
68    }
69
70    fn extract_call_edges(
71        &self,
72        file: &str,
73        source: &str,
74        symbols: &[ParsedSymbol],
75        workspace: &WorkspaceSymbols,
76    ) -> Vec<CallEdge> {
77        extract_call_edges_impl(file, source, symbols, workspace)
78    }
79}
80
81// -----------------------------------------------------------------------------
82// qname helpers
83// -----------------------------------------------------------------------------
84
85/// Extract the package declaration as a dotted prefix, e.g. `com.example.payments`.
86fn package_prefix(source: &str) -> String {
87    for line in source.lines() {
88        let t = line.trim();
89        if let Some(rest) = t.strip_prefix("package ") {
90            let pkg = rest.trim_end_matches(';').trim();
91            if !pkg.is_empty() {
92                return pkg.to_string();
93            }
94        }
95    }
96    String::new()
97}
98
99fn node_text<'a>(node: Node<'_>, src: &'a [u8]) -> &'a str {
100    node.utf8_text(src).unwrap_or("")
101}
102
103fn child_by_field<'a>(node: Node<'a>, field: &str) -> Option<Node<'a>> {
104    node.child_by_field_name(field)
105}
106
107fn join_qname(prefix: &str, name: &str) -> String {
108    if prefix.is_empty() {
109        name.to_string()
110    } else {
111        format!("{}.{}", prefix, name)
112    }
113}
114
115// -----------------------------------------------------------------------------
116// Symbol walking
117// -----------------------------------------------------------------------------
118
119fn walk(
120    node: Node<'_>,
121    src: &[u8],
122    scope: &str,
123    enclosing_type: Option<&str>,
124    out: &mut Vec<ParsedSymbol>,
125) {
126    match node.kind() {
127        "program" => {
128            for i in 0..node.child_count() {
129                walk(node.child(i).unwrap(), src, scope, None, out);
130            }
131        }
132        "class_declaration"
133        | "interface_declaration"
134        | "enum_declaration"
135        | "record_declaration"
136        | "annotation_type_declaration" => {
137            let name = child_by_field(node, "name")
138                .map(|n| node_text(n, src))
139                .unwrap_or("");
140            if name.is_empty() {
141                return;
142            }
143            let qname = join_qname(scope, name);
144            let kind = SymbolKind::Class;
145            out.push(ParsedSymbol {
146                qname: qname.clone(),
147                kind,
148                start_line: node.start_position().row as u32 + 1,
149                start_col: node.start_position().column as u32,
150                end_line: node.end_position().row as u32 + 1,
151                end_col: node.end_position().column as u32,
152                body: node_text(node, src).to_string(),
153                signature: None,
154                doc: None,
155            });
156            // Walk body for nested members
157            if let Some(body) = child_by_field(node, "body") {
158                for i in 0..body.child_count() {
159                    walk(body.child(i).unwrap(), src, &qname, Some(&qname), out);
160                }
161            }
162        }
163        "method_declaration" | "constructor_declaration" => {
164            let name = child_by_field(node, "name")
165                .map(|n| node_text(n, src))
166                .unwrap_or("");
167            if name.is_empty() {
168                return;
169            }
170            let qname = join_qname(scope, name);
171            let kind = if node.kind() == "constructor_declaration" {
172                SymbolKind::Function
173            } else {
174                SymbolKind::Method
175            };
176            out.push(ParsedSymbol {
177                qname,
178                kind,
179                start_line: node.start_position().row as u32 + 1,
180                start_col: node.start_position().column as u32,
181                end_line: node.end_position().row as u32 + 1,
182                end_col: node.end_position().column as u32,
183                body: node_text(node, src).to_string(),
184                signature: extract_sig_before_brace(node, src),
185                doc: None,
186            });
187            // Don't recurse into method bodies for nested classes — Java allows
188            // local class declarations but they're rare; skip for simplicity.
189        }
190        "enum_constant" => {
191            // Enum constants are Class-level symbols
192            let name = child_by_field(node, "name")
193                .map(|n| node_text(n, src))
194                .unwrap_or("");
195            if name.is_empty() {
196                return;
197            }
198            let qname = join_qname(scope, name);
199            out.push(ParsedSymbol {
200                qname,
201                kind: SymbolKind::Variable,
202                start_line: node.start_position().row as u32 + 1,
203                start_col: node.start_position().column as u32,
204                end_line: node.end_position().row as u32 + 1,
205                end_col: node.end_position().column as u32,
206                body: node_text(node, src).to_string(),
207                signature: None,
208                doc: None,
209            });
210        }
211        _ => {
212            // Walk children for anything we don't recognise
213            for i in 0..node.child_count() {
214                walk(node.child(i).unwrap(), src, scope, enclosing_type, out);
215            }
216        }
217    }
218}
219
220// -----------------------------------------------------------------------------
221// Signature extraction
222// -----------------------------------------------------------------------------
223
224/// Extract the method/constructor signature: text from declaration start up to
225/// (but not including) the opening `{` of the body. Tracks `()` and `[]` depth.
226fn extract_sig_before_brace(node: Node<'_>, src: &[u8]) -> Option<String> {
227    let text = std::str::from_utf8(&src[node.start_byte()..node.end_byte()]).ok()?;
228    let bytes = text.as_bytes();
229    let mut depth: i32 = 0;
230    let mut i = 0;
231    let mut sig_end = text.len();
232    while i < bytes.len() {
233        match bytes[i] {
234            b'"' => {
235                i += 1;
236                while i < bytes.len() {
237                    if bytes[i] == b'\\' {
238                        i += 2;
239                        continue;
240                    }
241                    if bytes[i] == b'"' {
242                        break;
243                    }
244                    i += 1;
245                }
246            }
247            b'(' | b'[' => depth += 1,
248            b')' | b']' => {
249                if depth > 0 {
250                    depth -= 1;
251                }
252            }
253            b'{' if depth == 0 => {
254                sig_end = i;
255                break;
256            }
257            _ => {}
258        }
259        i += 1;
260    }
261    let sig = text[..sig_end].trim().to_string();
262    if sig.is_empty() { None } else { Some(sig) }
263}
264
265// -----------------------------------------------------------------------------
266// Effect inference
267// -----------------------------------------------------------------------------
268
269fn find_occurrences(body: &str, needle: &str) -> Vec<usize> {
270    let mut out = Vec::new();
271    let mut from = 0;
272    while let Some(idx) = body[from..].find(needle) {
273        out.push(from + idx);
274        from += idx + needle.len();
275    }
276    out
277}
278
279fn extract_url_host(s: &str) -> Option<String> {
280    for scheme in ["https://", "http://"] {
281        if let Some(idx) = s.find(scheme) {
282            let tail = &s[idx + scheme.len()..];
283            let end = tail
284                .find(|c: char| c == '/' || c == '"' || c == ')' || c.is_whitespace())
285                .unwrap_or(tail.len());
286            let host = &tail[..end];
287            if !host.is_empty() {
288                return Some(host.to_string());
289            }
290        }
291    }
292    None
293}
294
295fn first_matching_line(body: &str, needles: &[&str]) -> Option<String> {
296    for line in body.lines() {
297        for n in needles {
298            if line.contains(n) {
299                return Some(line.trim().to_string());
300            }
301        }
302    }
303    None
304}
305
306fn first_match_note(body: &str, needles: &[&str]) -> Option<String> {
307    for n in needles {
308        if body.contains(n) {
309            return first_matching_line(body, needles);
310        }
311    }
312    None
313}
314
315fn extract_first_string_literal(s: &str) -> Option<String> {
316    let trimmed = s.trim_start();
317    let bytes = trimmed.as_bytes();
318    if bytes.is_empty() {
319        return None;
320    }
321    if bytes[0] != b'"' {
322        return None;
323    }
324    let mut j = 1;
325    let mut out = String::new();
326    while j < bytes.len() {
327        let c = bytes[j];
328        if c == b'\\' && j + 1 < bytes.len() {
329            out.push(bytes[j + 1] as char);
330            j += 2;
331            continue;
332        }
333        if c == b'"' {
334            return Some(out);
335        }
336        out.push(c as char);
337        j += 1;
338    }
339    None
340}
341
342// -----------------------------------------------------------------------------
343// Cross-service endpoint detection (t-014) — Spring MVC + RestTemplate
344//
345// Inbound: method mappings (@GetMapping/@PostMapping/… and
346// @RequestMapping(method=RequestMethod.X)) combined with the controller's
347// class-level @RequestMapping prefix. Outbound: RestTemplate getForObject/
348// postForObject/put/delete/exchange. WebClient's fluent builder is deferred.
349// -----------------------------------------------------------------------------
350
351const MAPPING_VERB: &[(&str, &str)] = &[
352    ("GetMapping", "GET"),
353    ("PostMapping", "POST"),
354    ("PutMapping", "PUT"),
355    ("DeleteMapping", "DELETE"),
356    ("PatchMapping", "PATCH"),
357];
358
359fn infer_service_endpoints_in_java(
360    file: &str,
361    source: &str,
362    symbols: &[ParsedSymbol],
363) -> Vec<DetectedEndpoint> {
364    let prefix = java_class_prefix(source);
365    let mut out = Vec::new();
366    for (idx, raw) in source.lines().enumerate() {
367        let line_no = (idx + 1) as u32;
368        let line = raw.trim();
369
370        for (method, sub) in java_method_mappings(line) {
371            if let Some(owner) = owner_for_annotation(symbols, line_no) {
372                out.push(DetectedEndpoint {
373                    transport: Transport::Http,
374                    direction: Direction::Inbound,
375                    contract: http_contract(&method, &join_path(&prefix, &sub)),
376                    owner_qname: owner.qname.clone(),
377                    file: file.to_string(),
378                    line: line_no,
379                    confidence: 0.9,
380                    note: None,
381                });
382            }
383        }
384        for (method, url) in java_clients(line) {
385            if let Some(owner) = owner_for_body_line(symbols, line_no) {
386                out.push(DetectedEndpoint {
387                    transport: Transport::Http,
388                    direction: Direction::Outbound,
389                    contract: http_contract(&method, &url),
390                    owner_qname: owner.qname.clone(),
391                    file: file.to_string(),
392                    line: line_no,
393                    confidence: 0.85,
394                    note: None,
395                });
396            }
397        }
398    }
399    out
400}
401
402fn owner_for_body_line(symbols: &[ParsedSymbol], line: u32) -> Option<&ParsedSymbol> {
403    symbols
404        .iter()
405        .filter(|s| s.start_line <= line && line <= s.end_line)
406        .max_by_key(|s| s.start_line)
407}
408
409/// The method an annotation on `line` decorates: containing symbol, else the
410/// nearest method starting just below (annotations stack above the method).
411fn owner_for_annotation(symbols: &[ParsedSymbol], line: u32) -> Option<&ParsedSymbol> {
412    if let Some(s) = owner_for_body_line(symbols, line) {
413        return Some(s);
414    }
415    symbols
416        .iter()
417        .filter(|s| s.start_line > line && s.start_line <= line + 6)
418        .min_by_key(|s| s.start_line)
419}
420
421/// Class-level prefix: the first `@RequestMapping` with a path literal
422/// (heuristic — typically the controller annotation).
423fn java_class_prefix(source: &str) -> String {
424    for line in source.lines() {
425        let t = line.trim();
426        if t.starts_with("@RequestMapping") {
427            if let Some(p) = java_mapping_path(t) {
428                return p;
429            }
430        }
431        // Only annotations BEFORE the class declaration are class-level; stop
432        // so a method-level @RequestMapping isn't mistaken for the prefix.
433        if t.contains("class ") || t.contains("interface ") {
434            break;
435        }
436    }
437    String::new()
438}
439
440/// `(METHOD, sub_path)` for a method-level mapping annotation line.
441fn java_method_mappings(line: &str) -> Vec<(String, String)> {
442    let mut out = Vec::new();
443    for (anno, method) in MAPPING_VERB {
444        if line.starts_with(&format!("@{anno}")) {
445            out.push((
446                method.to_string(),
447                java_mapping_path(line).unwrap_or_default(),
448            ));
449        }
450    }
451    // @RequestMapping(method = RequestMethod.GET, value = "/x") at method level.
452    if line.starts_with("@RequestMapping") {
453        if let Some(m) = java_request_method(line) {
454            out.push((m, java_mapping_path(line).unwrap_or_default()));
455        }
456    }
457    out
458}
459
460/// Path from a mapping annotation: explicit `value=`/`path=`, else a bare
461/// leading string argument. `produces=`/`consumes=` strings are not paths.
462fn java_mapping_path(line: &str) -> Option<String> {
463    let open = line.find('(')?;
464    let args = &line[open + 1..];
465    for key in ["value", "path"] {
466        if let Some(p) = java_kv_string(args, key) {
467            return Some(p);
468        }
469    }
470    let a = args.trim_start();
471    if a.starts_with('"') {
472        return first_java_string(a);
473    }
474    None
475}
476
477/// String value of a `key = "…"` kwarg.
478fn java_kv_string(args: &str, key: &str) -> Option<String> {
479    let pos = args.find(key)?;
480    let rest = args[pos + key.len()..].trim_start();
481    let rest = rest.strip_prefix('=')?.trim_start();
482    first_java_string(rest)
483}
484
485fn java_request_method(line: &str) -> Option<String> {
486    let pos = line.find("RequestMethod.")?;
487    let verb: String = line[pos + "RequestMethod.".len()..]
488        .chars()
489        .take_while(|c| c.is_ascii_alphabetic())
490        .collect();
491    (!verb.is_empty()).then(|| verb.to_uppercase())
492}
493
494fn join_path(prefix: &str, sub: &str) -> String {
495    let p = prefix.trim_end_matches('/');
496    let s = sub.trim_start_matches('/');
497    match (p.is_empty(), s.is_empty()) {
498        (true, true) => "/".to_string(),
499        (false, true) => p.to_string(),
500        (true, false) => format!("/{s}"),
501        (false, false) => format!("{p}/{s}"),
502    }
503}
504
505/// RestTemplate client calls. URLs must look like a URL (start with `/` or
506/// contain `://`) so generic `.put`/`.delete` on maps/lists don't match.
507fn java_clients(line: &str) -> Vec<(String, String)> {
508    let mut out = Vec::new();
509    let simple = [
510        ("getForObject", "GET"),
511        ("getForEntity", "GET"),
512        ("postForObject", "POST"),
513        ("postForEntity", "POST"),
514        ("put", "PUT"),
515        ("delete", "DELETE"),
516    ];
517    for (m, method) in simple {
518        let needle = format!(".{m}(");
519        if let Some(pos) = line.find(&needle) {
520            if let Some(url) = first_java_string(line[pos + needle.len()..].trim_start()) {
521                if looks_like_url(&url) {
522                    out.push((method.to_string(), url));
523                }
524            }
525        }
526    }
527    if let Some(pos) = line.find(".exchange(") {
528        let after = &line[pos + ".exchange(".len()..];
529        if let (Some(url), Some(m)) = (
530            first_java_string(after.trim_start()),
531            java_http_method(after),
532        ) {
533            if looks_like_url(&url) {
534                out.push((m, url));
535            }
536        }
537    }
538    out
539}
540
541fn java_http_method(s: &str) -> Option<String> {
542    let pos = s.find("HttpMethod.")?;
543    let verb: String = s[pos + "HttpMethod.".len()..]
544        .chars()
545        .take_while(|c| c.is_ascii_alphabetic())
546        .collect();
547    (!verb.is_empty()).then(|| verb.to_uppercase())
548}
549
550fn looks_like_url(s: &str) -> bool {
551    s.starts_with('/') || s.contains("://")
552}
553
554fn first_java_string(s: &str) -> Option<String> {
555    let s = s.trim_start();
556    if !s.starts_with('"') {
557        return None;
558    }
559    let inner = &s[1..];
560    let end = inner.find('"')?;
561    Some(inner[..end].to_string())
562}
563
564fn infer_effects_from_body(body: &str) -> Vec<Effect> {
565    let mut effects: Vec<Effect> = Vec::new();
566
567    // FS Read
568    let fs_read_needles = [
569        "new FileInputStream(",
570        "new FileReader(",
571        "Files.readAllBytes(",
572        "Files.readAllLines(",
573        "Files.newBufferedReader(",
574        "Files.lines(",
575        "new BufferedReader(",
576        "new Scanner(new File(",
577    ];
578    if let Some(note) = first_match_note(body, &fs_read_needles) {
579        effects.push(Effect {
580            effect: EffectCategory::IoFsRead,
581            qualifiers: serde_json::Value::Null,
582            note: Some(note),
583            ..Default::default()
584        });
585    }
586
587    // FS Write
588    let fs_write_needles = [
589        "new FileOutputStream(",
590        "new FileWriter(",
591        "Files.write(",
592        "Files.newBufferedWriter(",
593        "Files.createFile(",
594        "Files.createDirectory(",
595        "new PrintWriter(new File(",
596    ];
597    if let Some(note) = first_match_note(body, &fs_write_needles) {
598        effects.push(Effect {
599            effect: EffectCategory::IoFsWrite,
600            qualifiers: serde_json::Value::Null,
601            note: Some(note),
602            ..Default::default()
603        });
604    }
605
606    // Network
607    let net_needles = [
608        "new URL(",
609        "HttpURLConnection",
610        "HttpClient.newHttpClient(",
611        "HttpClient.newBuilder(",
612        "OkHttpClient",
613        "new OkHttpClient(",
614        "Retrofit.Builder(",
615        "RestTemplate(",
616        "WebClient.",
617        "CloseableHttpClient",
618        "HttpGet(",
619        "HttpPost(",
620    ];
621    let mut net_hosts: Vec<String> = Vec::new();
622    let mut net_note: Option<String> = None;
623    for needle in net_needles {
624        if body.contains(needle) && net_note.is_none() {
625            net_note = first_matching_line(body, &[needle]);
626            for off in find_occurrences(body, needle) {
627                let end = (off + 200).min(body.len());
628                let snippet = &body[off..end];
629                if let Some(host) = extract_url_host(snippet) {
630                    if !net_hosts.contains(&host) {
631                        net_hosts.push(host);
632                    }
633                }
634            }
635        }
636    }
637    if net_note.is_some() {
638        let qualifiers = if net_hosts.is_empty() {
639            serde_json::Value::Null
640        } else {
641            json!({ "hosts": net_hosts })
642        };
643        effects.push(Effect {
644            effect: EffectCategory::IoNetOut,
645            qualifiers,
646            note: net_note,
647            ..Default::default()
648        });
649    }
650
651    // Database
652    let db_needles = [
653        "DriverManager.getConnection(",
654        "dataSource.getConnection(",
655        ".prepareStatement(",
656        ".createStatement(",
657        ".executeQuery(",
658        ".executeUpdate(",
659        "entityManager.",
660        "session.save(",
661        "session.get(",
662        "repository.find",
663        "repository.save",
664        ".createQuery(",
665        "jdbcTemplate.",
666        "namedParameterJdbcTemplate.",
667    ];
668    if let Some(note) = first_match_note(body, &db_needles) {
669        let has_write = body.contains(".executeUpdate(")
670            || body.contains("session.save(")
671            || body.contains("repository.save")
672            || body.contains(".persist(")
673            || body.contains(".merge(")
674            || body.contains(".delete(")
675            || body.contains(".remove(");
676        let has_read = body.contains(".executeQuery(")
677            || body.contains("session.get(")
678            || body.contains("repository.find")
679            || body.contains(".getSingleResult(")
680            || body.contains(".getResultList(");
681        if has_read || (!has_read && !has_write) {
682            effects.push(Effect {
683                effect: EffectCategory::IoDbRead,
684                qualifiers: serde_json::Value::Null,
685                note: Some(note.clone()),
686                ..Default::default()
687            });
688        }
689        if has_write || (!has_read && !has_write) {
690            effects.push(Effect {
691                effect: EffectCategory::IoDbWrite,
692                qualifiers: serde_json::Value::Null,
693                note: Some(note),
694                ..Default::default()
695            });
696        }
697    }
698
699    // Process spawn
700    let proc_needles = [
701        "Runtime.getRuntime().exec(",
702        "new ProcessBuilder(",
703        "ProcessBuilder(",
704    ];
705    if let Some(note) = first_match_note(body, &proc_needles) {
706        effects.push(Effect {
707            effect: EffectCategory::ProcSpawn,
708            qualifiers: serde_json::Value::Null,
709            note: Some(note),
710            ..Default::default()
711        });
712    }
713
714    // Env read
715    let env_needles = ["System.getenv(", "System.getProperties("];
716    if let Some(note) = first_match_note(body, &env_needles) {
717        let mut vars: Vec<String> = Vec::new();
718        for off in find_occurrences(body, "System.getenv(") {
719            let args = &body[off + "System.getenv(".len()..];
720            if let Some(v) = extract_first_string_literal(args) {
721                if !vars.contains(&v) {
722                    vars.push(v);
723                }
724            }
725        }
726        let qualifiers = if vars.is_empty() {
727            serde_json::Value::Null
728        } else {
729            json!({ "vars": vars })
730        };
731        effects.push(Effect {
732            effect: EffectCategory::EnvRead,
733            qualifiers,
734            note: Some(note),
735            ..Default::default()
736        });
737    }
738
739    // Logging
740    let log_needles = [
741        "System.out.println(",
742        "System.err.println(",
743        "logger.info(",
744        "logger.debug(",
745        "logger.warn(",
746        "logger.error(",
747        "log.info(",
748        "log.debug(",
749        "log.warn(",
750        "log.error(",
751        "Logger.getLogger(",
752        "LoggerFactory.getLogger(",
753    ];
754    if let Some(note) = first_match_note(body, &log_needles) {
755        effects.push(Effect {
756            effect: EffectCategory::Log,
757            qualifiers: serde_json::Value::Null,
758            note: Some(note),
759            ..Default::default()
760        });
761    }
762
763    // Time sleep
764    let sleep_needles = ["Thread.sleep(", "TimeUnit.", ".sleep("];
765    if let Some(note) = first_match_note(body, &sleep_needles) {
766        effects.push(Effect {
767            effect: EffectCategory::TimeSleep,
768            qualifiers: serde_json::Value::Null,
769            note: Some(note),
770            ..Default::default()
771        });
772    }
773
774    // Time read
775    let time_needles = [
776        "System.currentTimeMillis()",
777        "System.nanoTime()",
778        "Instant.now()",
779        "LocalDateTime.now()",
780        "LocalDate.now()",
781        "LocalTime.now()",
782        "ZonedDateTime.now()",
783        "OffsetDateTime.now()",
784        "new Date()",
785        "Calendar.getInstance()",
786    ];
787    if let Some(note) = first_match_note(body, &time_needles) {
788        effects.push(Effect {
789            effect: EffectCategory::TimeRead,
790            qualifiers: serde_json::Value::Null,
791            note: Some(note),
792            ..Default::default()
793        });
794    }
795
796    // Random
797    let rand_needles = [
798        "new Random(",
799        "new SecureRandom(",
800        "Math.random()",
801        "ThreadLocalRandom.current()",
802        "UUID.randomUUID()",
803        "RandomGenerator",
804    ];
805    if let Some(note) = first_match_note(body, &rand_needles) {
806        effects.push(Effect {
807            effect: EffectCategory::Random,
808            qualifiers: serde_json::Value::Null,
809            note: Some(note),
810            ..Default::default()
811        });
812    }
813
814    // Throw
815    let throw_needles = ["throw new ", "throw e;", "throw ex;", "throw t;"];
816    if let Some(note) = first_match_note(body, &throw_needles) {
817        effects.push(Effect {
818            effect: EffectCategory::Throw,
819            qualifiers: serde_json::Value::Null,
820            note: Some(note),
821            ..Default::default()
822        });
823    }
824
825    effects
826}
827
828// -----------------------------------------------------------------------------
829// Call-edge extraction
830// -----------------------------------------------------------------------------
831
832/// Import binding: fully-qualified class → simple name.
833#[derive(Debug, Clone)]
834struct ImportBinding {
835    /// The fully-qualified class name (dotted), e.g. `com.example.payments.ChargeService`.
836    fqn: String,
837    /// Simple name used in code, e.g. `ChargeService`.
838    simple: String,
839}
840
841fn parse_imports(source: &str) -> Vec<ImportBinding> {
842    let mut out = Vec::new();
843    for line in source.lines() {
844        let t = line.trim();
845        // skip static imports and wildcards
846        if let Some(rest) = t.strip_prefix("import ") {
847            let fqn = rest
848                .trim_end_matches(';')
849                .trim()
850                .trim_start_matches("static ");
851            if fqn.ends_with('*') || fqn.is_empty() {
852                continue;
853            }
854            let simple = fqn.rsplit('.').next().unwrap_or(fqn).to_string();
855            out.push(ImportBinding {
856                fqn: fqn.to_string(),
857                simple,
858            });
859        }
860    }
861    out
862}
863
864fn enclosing_type_qname<'a>(qname: &str, known: &HashSet<&'a str>) -> Option<String> {
865    // Strip last segment to get enclosing class, e.g. com.example.Foo.bar -> com.example.Foo
866    let idx = qname.rfind('.')?;
867    let parent = &qname[..idx];
868    if known.contains(parent) {
869        Some(parent.to_string())
870    } else {
871        None
872    }
873}
874
875fn collect_method_calls(
876    node: Node<'_>,
877    src: &[u8],
878    sym: &ParsedSymbol,
879    pkg_prefix: &str,
880    by_simple: &HashMap<String, String>,
881    known: &HashSet<&str>,
882    imports: &[ImportBinding],
883    workspace: &WorkspaceSymbols,
884    enclosing_type: Option<&str>,
885    edges: &mut HashSet<CallEdge>,
886) {
887    if node.kind() == "method_invocation" {
888        // object.method(args) → child 0 = object, field "name" = method
889        let method_name = child_by_field(node, "name")
890            .map(|n| node_text(n, src))
891            .unwrap_or("");
892        if !method_name.is_empty() {
893            let object_node = node.child(0);
894            let object_text = object_node.map(|n| node_text(n, src)).unwrap_or("");
895
896            // Build candidate callee qname
897            let callee = if object_text.is_empty() || object_text == method_name {
898                // Bare method call — try within enclosing type then module
899                if let Some(et) = enclosing_type {
900                    let q = format!("{}.{}", et, method_name);
901                    if known.contains(q.as_str()) {
902                        Some(q)
903                    } else if let Some(q2) = by_simple.get(method_name) {
904                        Some(q2.clone())
905                    } else {
906                        None
907                    }
908                } else {
909                    by_simple.get(method_name).cloned()
910                }
911            } else {
912                // Qualified: look up object type via imports
913                let import_fqn = imports.iter().find(|b| b.simple == object_text);
914                if let Some(binding) = import_fqn {
915                    // e.g. com.example.ChargeService.charge
916                    let q = format!("{}.{}", binding.fqn, method_name);
917                    if known.contains(q.as_str()) || workspace.contains(&q) {
918                        Some(q)
919                    } else {
920                        None
921                    }
922                } else {
923                    // Try package-local
924                    let q = if pkg_prefix.is_empty() {
925                        format!("{}.{}", object_text, method_name)
926                    } else {
927                        format!("{}.{}.{}", pkg_prefix, object_text, method_name)
928                    };
929                    if known.contains(q.as_str()) || workspace.contains(&q) {
930                        Some(q)
931                    } else {
932                        None
933                    }
934                }
935            };
936
937            if let Some(callee_qname) = callee {
938                if callee_qname != sym.qname {
939                    edges.insert(CallEdge {
940                        caller_qname: sym.qname.clone(),
941                        callee_qname,
942                    });
943                }
944            }
945        }
946    }
947
948    for i in 0..node.child_count() {
949        collect_method_calls(
950            node.child(i).unwrap(),
951            src,
952            sym,
953            pkg_prefix,
954            by_simple,
955            known,
956            imports,
957            workspace,
958            enclosing_type,
959            edges,
960        );
961    }
962}
963
964fn extract_call_edges_impl(
965    file: &str,
966    source: &str,
967    symbols: &[ParsedSymbol],
968    workspace: &WorkspaceSymbols,
969) -> Vec<CallEdge> {
970    let _ = file;
971    let pkg_prefix = package_prefix(source);
972    let known: HashSet<&str> = symbols.iter().map(|s| s.qname.as_str()).collect();
973
974    let mut by_simple: HashMap<String, String> = HashMap::new();
975    for s in symbols {
976        let simple = s.qname.rsplit('.').next().unwrap_or(&s.qname).to_string();
977        by_simple.entry(simple).or_insert_with(|| s.qname.clone());
978    }
979
980    let mut parser = Parser::new();
981    if parser
982        .set_language(&tree_sitter_java::LANGUAGE.into())
983        .is_err()
984    {
985        return Vec::new();
986    }
987
988    let imports = parse_imports(source);
989    let mut edges: HashSet<CallEdge> = HashSet::new();
990
991    for sym in symbols {
992        if !matches!(sym.kind, SymbolKind::Function | SymbolKind::Method) {
993            continue;
994        }
995        let src_bytes = sym.body.as_bytes();
996        let tree = match parser.parse(src_bytes, None) {
997            Some(t) => t,
998            None => continue,
999        };
1000        let enclosing_type = enclosing_type_qname(&sym.qname, &known);
1001        collect_method_calls(
1002            tree.root_node(),
1003            src_bytes,
1004            sym,
1005            &pkg_prefix,
1006            &by_simple,
1007            &known,
1008            &imports,
1009            workspace,
1010            enclosing_type.as_deref(),
1011            &mut edges,
1012        );
1013    }
1014
1015    let mut out: Vec<CallEdge> = edges.into_iter().collect();
1016    out.sort_by(|a, b| {
1017        a.caller_qname
1018            .cmp(&b.caller_qname)
1019            .then_with(|| a.callee_qname.cmp(&b.callee_qname))
1020    });
1021    out
1022}
1023
1024// -----------------------------------------------------------------------------
1025// Tests
1026// -----------------------------------------------------------------------------
1027
1028#[cfg(test)]
1029mod tests {
1030    use super::*;
1031    use agentstatedeveloper_core::adapter::{LanguageAdapter, WorkspaceSymbols};
1032
1033    fn adapter() -> JavaAdapter {
1034        JavaAdapter::new()
1035    }
1036
1037    #[test]
1038    fn parses_class_method_interface_enum() {
1039        let src = r#"
1040package com.example;
1041
1042public class PaymentService {
1043    public void charge(String customerId, long amount) {
1044        System.out.println("charging");
1045    }
1046
1047    public static String format(long cents) {
1048        return String.format("$%.2f", cents / 100.0);
1049    }
1050}
1051
1052public interface PaymentGateway {
1053    void process(Payment p);
1054}
1055
1056public enum Currency { USD, EUR, GBP }
1057"#;
1058        let syms = adapter()
1059            .parse_symbols("src/PaymentService.java", src)
1060            .unwrap();
1061        let qnames: Vec<&str> = syms.iter().map(|s| s.qname.as_str()).collect();
1062        assert!(qnames.contains(&"com.example.PaymentService"), "{qnames:?}");
1063        assert!(
1064            qnames.contains(&"com.example.PaymentService.charge"),
1065            "{qnames:?}"
1066        );
1067        assert!(
1068            qnames.contains(&"com.example.PaymentService.format"),
1069            "{qnames:?}"
1070        );
1071        assert!(qnames.contains(&"com.example.PaymentGateway"), "{qnames:?}");
1072        assert!(qnames.contains(&"com.example.Currency"), "{qnames:?}");
1073    }
1074
1075    #[test]
1076    fn module_prefix_strips_package() {
1077        assert_eq!(
1078            package_prefix("package com.example.payments;"),
1079            "com.example.payments"
1080        );
1081        assert_eq!(package_prefix("// no package"), "");
1082        assert_eq!(package_prefix("package  org.acme ;"), "org.acme");
1083    }
1084
1085    #[test]
1086    fn infers_fs_read_and_net_out() {
1087        let src = r#"
1088public class Fetcher {
1089    public String fetch() throws Exception {
1090        byte[] data = Files.readAllBytes(Paths.get("/tmp/config"));
1091        HttpClient client = HttpClient.newHttpClient();
1092        HttpRequest req = HttpRequest.newBuilder()
1093            .uri(URI.create("https://api.example.com/data"))
1094            .build();
1095        return new String(data);
1096    }
1097}
1098"#;
1099        let syms = adapter().parse_symbols("Fetcher.java", src).unwrap();
1100        let fetch = syms.iter().find(|s| s.qname.ends_with(".fetch")).unwrap();
1101        let effs = adapter().infer_effects("", fetch);
1102        let cats: Vec<_> = effs.iter().map(|e| &e.effect).collect();
1103        assert!(cats.contains(&&EffectCategory::IoFsRead), "{cats:?}");
1104        assert!(cats.contains(&&EffectCategory::IoNetOut), "{cats:?}");
1105        // Check host extraction
1106        let net = effs
1107            .iter()
1108            .find(|e| e.effect == EffectCategory::IoNetOut)
1109            .unwrap();
1110        if let Some(hosts) = net.qualifiers.get("hosts") {
1111            let hosts: Vec<&str> = hosts
1112                .as_array()
1113                .unwrap()
1114                .iter()
1115                .map(|v| v.as_str().unwrap())
1116                .collect();
1117            assert!(hosts.contains(&"api.example.com"), "{hosts:?}");
1118        }
1119    }
1120
1121    #[test]
1122    fn infers_db_read_and_write() {
1123        let src = r#"
1124public class UserRepo {
1125    public User find(long id) {
1126        return entityManager.find(User.class, id);
1127    }
1128    public void save(User u) {
1129        entityManager.persist(u);
1130    }
1131}
1132"#;
1133        let syms = adapter().parse_symbols("UserRepo.java", src).unwrap();
1134        let find_sym = syms.iter().find(|s| s.qname.ends_with(".find")).unwrap();
1135        let save_sym = syms.iter().find(|s| s.qname.ends_with(".save")).unwrap();
1136        let find_effs = adapter().infer_effects("", find_sym);
1137        let save_effs = adapter().infer_effects("", save_sym);
1138        assert!(
1139            find_effs
1140                .iter()
1141                .any(|e| e.effect == EffectCategory::IoDbRead)
1142        );
1143        assert!(
1144            save_effs
1145                .iter()
1146                .any(|e| e.effect == EffectCategory::IoDbWrite)
1147        );
1148    }
1149
1150    #[test]
1151    fn infers_log_and_env() {
1152        let src = r#"
1153public class Config {
1154    private static final Logger logger = LoggerFactory.getLogger(Config.class);
1155    public String load() {
1156        String env = System.getenv("APP_ENV");
1157        logger.info("Loading config for env: {}", env);
1158        return env;
1159    }
1160}
1161"#;
1162        let syms = adapter().parse_symbols("Config.java", src).unwrap();
1163        let load = syms.iter().find(|s| s.qname.ends_with(".load")).unwrap();
1164        let effs = adapter().infer_effects("", load);
1165        let cats: Vec<_> = effs.iter().map(|e| &e.effect).collect();
1166        assert!(cats.contains(&&EffectCategory::EnvRead), "{cats:?}");
1167        assert!(cats.contains(&&EffectCategory::Log), "{cats:?}");
1168        // Check env var extraction
1169        let env_eff = effs
1170            .iter()
1171            .find(|e| e.effect == EffectCategory::EnvRead)
1172            .unwrap();
1173        if let Some(vars) = env_eff.qualifiers.get("vars") {
1174            let vars: Vec<&str> = vars
1175                .as_array()
1176                .unwrap()
1177                .iter()
1178                .map(|v| v.as_str().unwrap())
1179                .collect();
1180            assert!(vars.contains(&"APP_ENV"), "{vars:?}");
1181        }
1182    }
1183
1184    #[test]
1185    fn empty_when_no_patterns() {
1186        let src = r#"
1187public class Pure {
1188    public int add(int a, int b) { return a + b; }
1189}
1190"#;
1191        let syms = adapter().parse_symbols("Pure.java", src).unwrap();
1192        let add = syms.iter().find(|s| s.qname.ends_with(".add")).unwrap();
1193        let effs = adapter().infer_effects("", add);
1194        assert!(effs.is_empty(), "{effs:?}");
1195    }
1196
1197    #[test]
1198    fn extracts_cross_module_call_edges() {
1199        let src = r#"
1200package com.example;
1201
1202import com.payments.ChargeService;
1203
1204public class OrderService {
1205    public void placeOrder(Order order) {
1206        ChargeService.charge(order.getCustomerId(), order.getTotal());
1207    }
1208}
1209
1210public class ChargeService {
1211    public static void charge(String customerId, long amount) {
1212        // process payment
1213    }
1214}
1215"#;
1216        let ws = WorkspaceSymbols {
1217            qnames: ["com.payments.ChargeService.charge".to_string()].into(),
1218            kinds: HashMap::new(),
1219            ..Default::default()
1220        };
1221        let syms = adapter()
1222            .parse_symbols("src/OrderService.java", src)
1223            .unwrap();
1224        let edges = adapter().extract_call_edges("src/OrderService.java", src, &syms, &ws);
1225        // Should find a call from placeOrder to com.payments.ChargeService.charge
1226        let found = edges.iter().any(|e| {
1227            e.caller_qname.ends_with(".placeOrder")
1228                && e.callee_qname == "com.payments.ChargeService.charge"
1229        });
1230        assert!(found, "expected cross-module edge; got: {edges:?}");
1231    }
1232}
1233
1234#[cfg(test)]
1235mod service_endpoint_tests {
1236    use super::*;
1237    use agentstatedeveloper_core::adapter::LanguageAdapter;
1238
1239    fn detect(src: &str) -> Vec<DetectedEndpoint> {
1240        let a = JavaAdapter;
1241        let symbols = a.parse_symbols("C.java", src).unwrap();
1242        a.infer_service_endpoints("C.java", src, &symbols)
1243    }
1244    fn inbound(eps: &[DetectedEndpoint]) -> Vec<&DetectedEndpoint> {
1245        eps.iter()
1246            .filter(|e| e.direction == Direction::Inbound)
1247            .collect()
1248    }
1249    fn outbound(eps: &[DetectedEndpoint]) -> Vec<&DetectedEndpoint> {
1250        eps.iter()
1251            .filter(|e| e.direction == Direction::Outbound)
1252            .collect()
1253    }
1254
1255    #[test]
1256    fn spring_controller_prefix_plus_method_mappings() {
1257        let src = "\
1258@RestController
1259@RequestMapping(\"/api/users\")
1260public class UserController {
1261  @GetMapping(\"/{id}\")
1262  public User get(Long id) { return null; }
1263
1264  @PostMapping
1265  public void create(User u) {}
1266}
1267";
1268        let eps = detect(src);
1269        let mut got: Vec<String> = inbound(&eps).iter().map(|e| e.contract.clone()).collect();
1270        got.sort();
1271        assert_eq!(
1272            got,
1273            vec![
1274                "http:GET /api/users/{}".to_string(),
1275                "http:POST /api/users".to_string()
1276            ],
1277            "{eps:?}"
1278        );
1279    }
1280
1281    #[test]
1282    fn request_mapping_method_level_without_class_prefix() {
1283        let src = "\
1284public class C {
1285  @RequestMapping(value = \"/x\", method = RequestMethod.DELETE)
1286  public void d() {}
1287}
1288";
1289        let eps = detect(src);
1290        let inb = inbound(&eps);
1291        assert_eq!(inb.len(), 1, "{eps:?}");
1292        assert_eq!(inb[0].contract, "http:DELETE /x");
1293    }
1294
1295    #[test]
1296    fn rest_template_clients() {
1297        let src = "\
1298public class S {
1299  void call() {
1300    restTemplate.getForObject(\"https://users.svc/users/{id}\", User.class);
1301    restTemplate.exchange(\"/items/{id}\", HttpMethod.PUT, req, Void.class);
1302  }
1303}
1304";
1305        let eps = detect(src);
1306        let mut got: Vec<String> = outbound(&eps).iter().map(|e| e.contract.clone()).collect();
1307        got.sort();
1308        assert_eq!(
1309            got,
1310            vec![
1311                "http:GET /users/{}".to_string(),
1312                "http:PUT /items/{}".to_string()
1313            ]
1314        );
1315    }
1316
1317    #[test]
1318    fn map_put_is_not_a_client_call() {
1319        // `.put` on a Map with a non-URL key must not become an HTTP edge.
1320        let src = "public class S {\n  void f() { cache.put(\"key\", value); }\n}\n";
1321        assert!(outbound(&detect(src)).is_empty(), "{:?}", detect(src));
1322    }
1323
1324    #[test]
1325    fn java_server_matches_other_language_client() {
1326        let server = detect(
1327            "@RequestMapping(\"/api\")\npublic class C {\n  @GetMapping(\"/users/{id}\")\n  public User g() { return null; }\n}\n",
1328        );
1329        // Same contract a TS/Go client to /api/users/:id would produce.
1330        assert_eq!(inbound(&server)[0].contract, "http:GET /api/users/{}");
1331        assert_eq!(
1332            inbound(&server)[0].contract,
1333            http_contract("get", "https://svc/api/users/:id")
1334        );
1335    }
1336}