Skip to main content

boundary_java/
lib.rs

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
9/// Java language analyzer using tree-sitter.
10pub 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        // Annotation on class declarations for classification hints
57        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 (ports)
108        extract_interfaces(
109            &self.interface_query,
110            parsed,
111            &package_path,
112            &mut components,
113        );
114
115        // Extract classes
116        extract_classes(&self.class_query, parsed, &package_path, &mut components);
117
118        // Enrich with annotation info
119        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                    // Skip java.lang.* and standard library
155                    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
293/// Enrich class components with annotation-based classification.
294fn 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
352/// Classify a class by its name suffix heuristic and implements clause.
353fn 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
386/// Extract text from a tree-sitter node.
387fn node_text(node: tree_sitter::Node, source: &str) -> String {
388    source[node.byte_range()].to_string()
389}
390
391/// Derive a package path from a file path.
392fn 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        // Name-based classification should match "Repository"
461        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        // Should skip java.* imports
479        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}