1use 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#[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
81fn 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
115fn 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 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 }
190 "enum_constant" => {
191 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 for i in 0..node.child_count() {
214 walk(node.child(i).unwrap(), src, scope, enclosing_type, out);
215 }
216 }
217 }
218}
219
220fn 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
265fn 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
342const 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
409fn 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
421fn 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 if t.contains("class ") || t.contains("interface ") {
434 break;
435 }
436 }
437 String::new()
438}
439
440fn 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 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
460fn 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
477fn 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
505fn 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 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone)]
834struct ImportBinding {
835 fqn: String,
837 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 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 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 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 let callee = if object_text.is_empty() || object_text == method_name {
898 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 let import_fqn = imports.iter().find(|b| b.simple == object_text);
914 if let Some(binding) = import_fqn {
915 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 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#[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 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 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 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 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 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}