1use std::path::Path;
2
3use anyhow::{Context, Result};
4use tree_sitter::{Language, Parser, Query, QueryCursor, StreamingIterator};
5
6use boundary_core::analyzer::{LanguageAnalyzer, ParsedFile};
7use boundary_core::types::*;
8
9pub struct JavaAnalyzer {
11 language: Language,
12 interface_query: Query,
13 class_query: Query,
14 import_query: Query,
15 annotation_query: Query,
16}
17
18impl JavaAnalyzer {
19 pub fn new() -> Result<Self> {
20 let language: Language = tree_sitter_java::LANGUAGE.into();
21
22 let interface_query = Query::new(
23 &language,
24 r#"
25 (interface_declaration
26 name: (identifier) @name
27 body: (interface_body
28 (method_declaration
29 name: (identifier) @method)*))
30 "#,
31 )
32 .context("failed to compile interface query")?;
33
34 let class_query = Query::new(
35 &language,
36 r#"
37 (class_declaration
38 name: (identifier) @name
39 interfaces: (super_interfaces
40 (type_list
41 (type_identifier) @implements))?
42 body: (class_body))
43 "#,
44 )
45 .context("failed to compile class query")?;
46
47 let import_query = Query::new(
48 &language,
49 r#"
50 (import_declaration
51 (scoped_identifier) @path)
52 "#,
53 )
54 .context("failed to compile import query")?;
55
56 let annotation_query = Query::new(
58 &language,
59 r#"
60 (class_declaration
61 (modifiers
62 (marker_annotation
63 name: (identifier) @annotation))
64 name: (identifier) @class_name)
65 "#,
66 )
67 .context("failed to compile annotation query")?;
68
69 Ok(Self {
70 language,
71 interface_query,
72 class_query,
73 import_query,
74 annotation_query,
75 })
76 }
77}
78
79impl LanguageAnalyzer for JavaAnalyzer {
80 fn language(&self) -> &'static str {
81 "java"
82 }
83
84 fn file_extensions(&self) -> &[&str] {
85 &["java"]
86 }
87
88 fn parse_file(&self, path: &Path, content: &str) -> Result<ParsedFile> {
89 let mut parser = Parser::new();
90 parser
91 .set_language(&self.language)
92 .context("failed to set Java language")?;
93 let tree = parser
94 .parse(content, None)
95 .context("failed to parse Java file")?;
96 Ok(ParsedFile {
97 path: path.to_path_buf(),
98 tree,
99 content: content.to_string(),
100 })
101 }
102
103 fn extract_components(&self, parsed: &ParsedFile) -> Vec<Component> {
104 let mut components = Vec::new();
105 let package_path = derive_package_path(&parsed.path);
106
107 extract_interfaces(
109 &self.interface_query,
110 parsed,
111 &package_path,
112 &mut components,
113 );
114
115 extract_classes(&self.class_query, parsed, &package_path, &mut components);
117
118 enrich_with_annotations(
120 &self.annotation_query,
121 parsed,
122 &package_path,
123 &mut components,
124 );
125
126 components
127 }
128
129 fn extract_dependencies(&self, parsed: &ParsedFile) -> Vec<Dependency> {
130 let mut deps = Vec::new();
131 let package_path = derive_package_path(&parsed.path);
132 let from_id = ComponentId::new(&package_path, "<file>");
133
134 let mut cursor = QueryCursor::new();
135 let path_idx = self
136 .import_query
137 .capture_names()
138 .iter()
139 .position(|n| *n == "path")
140 .unwrap_or(0);
141
142 let mut matches = cursor.matches(
143 &self.import_query,
144 parsed.tree.root_node(),
145 parsed.content.as_bytes(),
146 );
147
148 while let Some(m) = matches.next() {
149 for capture in m.captures {
150 if capture.index as usize == path_idx {
151 let node = capture.node;
152 let import_path = node_text(node, &parsed.content);
153
154 if import_path.starts_with("java.") || import_path.starts_with("javax.") {
156 continue;
157 }
158
159 let to_id = ComponentId::new(&import_path, "<class>");
160
161 deps.push(Dependency {
162 from: from_id.clone(),
163 to: to_id,
164 kind: DependencyKind::Import,
165 location: SourceLocation {
166 file: parsed.path.clone(),
167 line: node.start_position().row + 1,
168 column: node.start_position().column + 1,
169 },
170 import_path: Some(import_path),
171 });
172 }
173 }
174 }
175
176 deps
177 }
178}
179
180fn extract_interfaces(
181 query: &Query,
182 parsed: &ParsedFile,
183 package_path: &str,
184 components: &mut Vec<Component>,
185) {
186 let mut cursor = QueryCursor::new();
187 let name_idx = query
188 .capture_names()
189 .iter()
190 .position(|n| *n == "name")
191 .unwrap_or(0);
192 let method_idx = query.capture_names().iter().position(|n| *n == "method");
193
194 let mut matches = cursor.matches(query, parsed.tree.root_node(), parsed.content.as_bytes());
195
196 while let Some(m) = matches.next() {
197 let mut name = String::new();
198 let mut methods = Vec::new();
199 let mut start_row = 0;
200 let mut start_col = 0;
201
202 for capture in m.captures {
203 if capture.index as usize == name_idx {
204 name = node_text(capture.node, &parsed.content);
205 start_row = capture.node.start_position().row;
206 start_col = capture.node.start_position().column;
207 } else if Some(capture.index as usize) == method_idx {
208 methods.push(MethodInfo {
209 name: node_text(capture.node, &parsed.content),
210 parameters: String::new(),
211 return_type: String::new(),
212 });
213 }
214 }
215
216 if name.is_empty() {
217 continue;
218 }
219
220 components.push(Component {
221 id: ComponentId::new(package_path, &name),
222 name: name.clone(),
223 kind: ComponentKind::Port(PortInfo { name, methods }),
224 layer: None,
225 location: SourceLocation {
226 file: parsed.path.clone(),
227 line: start_row + 1,
228 column: start_col + 1,
229 },
230 is_cross_cutting: false,
231 architecture_mode: ArchitectureMode::default(),
232 });
233 }
234}
235
236fn extract_classes(
237 query: &Query,
238 parsed: &ParsedFile,
239 package_path: &str,
240 components: &mut Vec<Component>,
241) {
242 let mut cursor = QueryCursor::new();
243 let name_idx = query
244 .capture_names()
245 .iter()
246 .position(|n| *n == "name")
247 .unwrap_or(0);
248 let implements_idx = query
249 .capture_names()
250 .iter()
251 .position(|n| *n == "implements");
252
253 let mut matches = cursor.matches(query, parsed.tree.root_node(), parsed.content.as_bytes());
254
255 while let Some(m) = matches.next() {
256 let mut name = String::new();
257 let mut implements = Vec::new();
258 let mut start_row = 0;
259 let mut start_col = 0;
260
261 for capture in m.captures {
262 if capture.index as usize == name_idx {
263 name = node_text(capture.node, &parsed.content);
264 start_row = capture.node.start_position().row;
265 start_col = capture.node.start_position().column;
266 } else if Some(capture.index as usize) == implements_idx {
267 implements.push(node_text(capture.node, &parsed.content));
268 }
269 }
270
271 if name.is_empty() {
272 continue;
273 }
274
275 let kind = classify_class_kind(&name, &implements);
276
277 components.push(Component {
278 id: ComponentId::new(package_path, &name),
279 name: name.clone(),
280 kind,
281 layer: None,
282 location: SourceLocation {
283 file: parsed.path.clone(),
284 line: start_row + 1,
285 column: start_col + 1,
286 },
287 is_cross_cutting: false,
288 architecture_mode: ArchitectureMode::default(),
289 });
290 }
291}
292
293fn enrich_with_annotations(
295 query: &Query,
296 parsed: &ParsedFile,
297 package_path: &str,
298 components: &mut [Component],
299) {
300 let mut cursor = QueryCursor::new();
301 let annotation_idx = query
302 .capture_names()
303 .iter()
304 .position(|n| *n == "annotation");
305 let class_name_idx = query
306 .capture_names()
307 .iter()
308 .position(|n| *n == "class_name");
309
310 let mut matches = cursor.matches(query, parsed.tree.root_node(), parsed.content.as_bytes());
311
312 while let Some(m) = matches.next() {
313 let mut annotation = String::new();
314 let mut class_name = String::new();
315
316 for capture in m.captures {
317 if Some(capture.index as usize) == annotation_idx {
318 annotation = node_text(capture.node, &parsed.content);
319 }
320 if Some(capture.index as usize) == class_name_idx {
321 class_name = node_text(capture.node, &parsed.content);
322 }
323 }
324
325 if class_name.is_empty() || annotation.is_empty() {
326 continue;
327 }
328
329 let id = ComponentId::new(package_path, &class_name);
330 if let Some(comp) = components.iter_mut().find(|c| c.id == id) {
331 match annotation.as_str() {
332 "Repository" => {
333 comp.kind = ComponentKind::Repository;
334 }
335 "Service" => {
336 comp.kind = ComponentKind::Service;
337 }
338 "Controller" | "RestController" => {
339 comp.kind = ComponentKind::Adapter(AdapterInfo {
340 name: class_name,
341 implements: vec![],
342 confidence: AdapterConfidence::default(),
343 returns_concrete: None,
344 });
345 }
346 _ => {}
347 }
348 }
349 }
350}
351
352fn classify_class_kind(name: &str, implements: &[String]) -> ComponentKind {
354 let lower = name.to_lowercase();
355 if lower.ends_with("repository") || lower.ends_with("repo") {
356 ComponentKind::Repository
357 } else if lower.ends_with("service") || lower.ends_with("svc") {
358 ComponentKind::Service
359 } else if lower.ends_with("handler") || lower.ends_with("controller") {
360 ComponentKind::Adapter(AdapterInfo {
361 name: name.to_string(),
362 implements: implements.to_vec(),
363 confidence: AdapterConfidence::default(),
364 returns_concrete: None,
365 })
366 } else if lower.ends_with("usecase") || lower.ends_with("interactor") {
367 ComponentKind::UseCase
368 } else if !implements.is_empty() {
369 ComponentKind::Adapter(AdapterInfo {
370 name: name.to_string(),
371 implements: implements.to_vec(),
372 confidence: AdapterConfidence::default(),
373 returns_concrete: None,
374 })
375 } else {
376 ComponentKind::Entity(EntityInfo {
377 name: name.to_string(),
378 fields: vec![],
379 methods: Vec::new(),
380 is_active_record: false,
381 is_anemic_domain_model: false,
382 })
383 }
384}
385
386fn node_text(node: tree_sitter::Node, source: &str) -> String {
388 source[node.byte_range()].to_string()
389}
390
391fn derive_package_path(path: &Path) -> String {
393 path.parent()
394 .map(|p| p.to_string_lossy().replace('\\', "/"))
395 .unwrap_or_default()
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401 use std::path::PathBuf;
402
403 #[test]
404 fn test_parse_java_interface() {
405 let analyzer = JavaAnalyzer::new().unwrap();
406 let content = r#"
407package com.example.domain.user;
408
409public interface UserRepository {
410 void save(User user);
411 User findById(String id);
412}
413"#;
414 let path = PathBuf::from("src/main/java/com/example/domain/user/UserRepository.java");
415 let parsed = analyzer.parse_file(&path, content).unwrap();
416 let components = analyzer.extract_components(&parsed);
417
418 let repo = components.iter().find(|c| c.name == "UserRepository");
419 assert!(repo.is_some(), "should find UserRepository interface");
420 assert!(matches!(repo.unwrap().kind, ComponentKind::Port(_)));
421
422 if let ComponentKind::Port(ref info) = repo.unwrap().kind {
423 assert!(info.methods.iter().any(|m| m.name == "save"));
424 assert!(info.methods.iter().any(|m| m.name == "findById"));
425 }
426 }
427
428 #[test]
429 fn test_parse_java_class_with_implements() {
430 let analyzer = JavaAnalyzer::new().unwrap();
431 let content = r#"
432package com.example.infrastructure.postgres;
433
434public class PostgresUserRepository implements UserRepository {
435 private final DataSource dataSource;
436
437 public PostgresUserRepository(DataSource dataSource) {
438 this.dataSource = dataSource;
439 }
440
441 public void save(User user) {
442 // save implementation
443 }
444
445 public User findById(String id) {
446 return null;
447 }
448}
449"#;
450 let path = PathBuf::from(
451 "src/main/java/com/example/infrastructure/postgres/PostgresUserRepository.java",
452 );
453 let parsed = analyzer.parse_file(&path, content).unwrap();
454 let components = analyzer.extract_components(&parsed);
455
456 let repo = components
457 .iter()
458 .find(|c| c.name == "PostgresUserRepository");
459 assert!(repo.is_some(), "should find PostgresUserRepository");
460 assert!(matches!(repo.unwrap().kind, ComponentKind::Repository));
462 }
463
464 #[test]
465 fn test_extract_imports() {
466 let analyzer = JavaAnalyzer::new().unwrap();
467 let content = r#"
468package com.example.application;
469
470import java.util.List;
471import com.example.domain.user.User;
472import com.example.domain.user.UserRepository;
473"#;
474 let path = PathBuf::from("src/main/java/com/example/application/UserService.java");
475 let parsed = analyzer.parse_file(&path, content).unwrap();
476 let deps = analyzer.extract_dependencies(&parsed);
477
478 let paths: Vec<&str> = deps
480 .iter()
481 .filter_map(|d| d.import_path.as_deref())
482 .collect();
483 assert!(!paths.iter().any(|p| p.starts_with("java.")));
484 assert!(paths.iter().any(|p| p.contains("domain.user.User")));
485 assert!(paths
486 .iter()
487 .any(|p| p.contains("domain.user.UserRepository")));
488 }
489
490 #[test]
491 fn test_annotation_classification() {
492 let analyzer = JavaAnalyzer::new().unwrap();
493 let content = r#"
494package com.example.application;
495
496@Service
497public class UserService {
498 private final UserRepository repo;
499
500 public UserService(UserRepository repo) {
501 this.repo = repo;
502 }
503}
504"#;
505 let path = PathBuf::from("src/main/java/com/example/application/UserService.java");
506 let parsed = analyzer.parse_file(&path, content).unwrap();
507 let components = analyzer.extract_components(&parsed);
508
509 let svc = components.iter().find(|c| c.name == "UserService");
510 assert!(svc.is_some(), "should find UserService");
511 assert!(
512 matches!(svc.unwrap().kind, ComponentKind::Service),
513 "should be classified as Service by annotation"
514 );
515 }
516
517 #[test]
518 fn test_controller_annotation() {
519 let analyzer = JavaAnalyzer::new().unwrap();
520 let content = r#"
521package com.example.presentation;
522
523@Controller
524public class UserController {
525 public void getUser() {}
526}
527"#;
528 let path = PathBuf::from("src/main/java/com/example/presentation/UserController.java");
529 let parsed = analyzer.parse_file(&path, content).unwrap();
530 let components = analyzer.extract_components(&parsed);
531
532 let ctrl = components.iter().find(|c| c.name == "UserController");
533 assert!(ctrl.is_some(), "should find UserController");
534 assert!(
535 matches!(ctrl.unwrap().kind, ComponentKind::Adapter(_)),
536 "should be classified as Adapter by @Controller annotation"
537 );
538 }
539
540 #[test]
541 fn test_entity_class() {
542 let analyzer = JavaAnalyzer::new().unwrap();
543 let content = r#"
544package com.example.domain.user;
545
546public class User {
547 private String id;
548 private String name;
549 private String email;
550}
551"#;
552 let path = PathBuf::from("src/main/java/com/example/domain/user/User.java");
553 let parsed = analyzer.parse_file(&path, content).unwrap();
554 let components = analyzer.extract_components(&parsed);
555
556 let user = components.iter().find(|c| c.name == "User");
557 assert!(user.is_some(), "should find User");
558 assert!(matches!(user.unwrap().kind, ComponentKind::Entity(_)));
559 }
560}