From a1d1077ea7f325e95edc462228a59ea8e6caa026 Mon Sep 17 00:00:00 2001 From: Dejan Menges Date: Tue, 16 Jun 2026 16:22:42 +0200 Subject: [PATCH] Adding and documenting Java support --- ARCHITECTURE.md | 14 +- README.md | 1 + examples/full.yaml | 2 + go.mod | 1 + internal/config/config.go | 2 +- internal/extractors/javaextractor/java.go | 253 ++++++ internal/extractors/javaextractor/java_ast.go | 842 ++++++++++++++++++ .../extractors/javaextractor/java_ast_test.go | 295 ++++++ .../extractors/javaextractor/java_test.go | 48 + internal/extractors/javaextractor/spring.go | 342 +++++++ .../extractors/javaextractor/spring_test.go | 259 ++++++ mcp-arch.yaml | 1 + pkg/bootstrap/bootstrap.go | 2 + 13 files changed, 2056 insertions(+), 6 deletions(-) create mode 100644 internal/extractors/javaextractor/java.go create mode 100644 internal/extractors/javaextractor/java_ast.go create mode 100644 internal/extractors/javaextractor/java_ast_test.go create mode 100644 internal/extractors/javaextractor/java_test.go create mode 100644 internal/extractors/javaextractor/spring.go create mode 100644 internal/extractors/javaextractor/spring_test.go diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c792cd2..bc6e369 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -108,10 +108,10 @@ Repository │ ▼ File Walker ──▶ Extractors ──▶ Fact Store ──▶ Cross-Repo Linker ──▶ Graph Index - (apply (Go, Kotlin, (indexed by (only with 2+ (bidirectional) - ignore Python, TS, kind / file / repos loaded) │ - globs) Swift, Ruby, name / repo) ▼ - C++, OpenAPI) Explainers + (apply (Go, Java, (indexed by (only with 2+ (bidirectional) + ignore Kotlin, Python, kind / file / repos loaded) │ + globs) TS, Swift, name / repo) ▼ + Ruby, C++, OpenAPI) Explainers (cycles, layers, crossrepo) │ @@ -299,6 +299,7 @@ ignore: - "**/*.yaml" extractors: - go + - java - kotlin - openapi - python @@ -322,7 +323,7 @@ The bundled [`mcp-arch.yaml`](mcp-arch.yaml) ships a much fuller `ignore` list ( |-------|-------------|---------| | `repo` | Repository root path | `"."` | | `ignore` | Glob patterns for files/dirs to skip | vendor, node_modules, .git, tests, build dirs, docs, config data, … | -| `extractors` | Enabled extractors | `["cpp", "go", "kotlin", "openapi", "python", "typescript", "swift", "ruby"]` | +| `extractors` | Enabled extractors | `["cpp", "go", "java", "kotlin", "openapi", "python", "typescript", "swift", "ruby"]` | | `explainers` | Enabled explainers | `["cycles", "layers", "crossrepo"]` | | `renderers` | Enabled renderers | `["llm_context"]` | | `output.dir` | Output directory for artifacts | `".enola"` | @@ -337,6 +338,7 @@ Each extractor is detected by characteristic project files and then parses what | Language | Parser | Detected by | |------------|------------------|-------------| | Go | `go/ast` | `go.mod` present | +| Java | tree-sitter | `pom.xml` (Maven) present, or any `.java` source file (a Gradle build file alone does **not** trigger it — Kotlin/Android use Gradle too) | | Kotlin | tree-sitter | `build.gradle.kts` / `build.gradle` with Kotlin/Android | | Python | tree-sitter | `pyproject.toml`, `setup.py`, `requirements.txt`, `Pipfile`, `pytest.ini`, `mypy.ini`, `tox.ini`, or `setup.cfg` (root or up to 3 levels deep) | | TypeScript | tree-sitter | `tsconfig.json`, `tsconfig.base.json`, or `package.json` with TypeScript (root or one level deep) | @@ -351,6 +353,8 @@ Each extractor is detected by characteristic project files and then parses what **Python** is parsed with tree-sitter (the concrete syntax tree handles nested classes/methods and docstrings natively, replacing the older indentation scanner). It understands **FastAPI/Starlette** route decorators and **Django** routes — `@api_view([...])` and `urls.py` `path()`/`re_path()` — emitting a `route` fact per endpoint. It emits `storage` facts for **SQLAlchemy** `__tablename__` and **Django models** (table name inferred from the class name), and classifies Django views and serializers via a `django_component` prop. It captures `async def` (`async: true`), decorator props (`@property`, `@staticmethod`, `@classmethod`, `@abstractmethod`, and Celery `@task`/`@shared_task`), and return-type hints. Each class emits an `implements` edge per base class, with generic type parameters stripped (`CRUDBase[Model, Id]` → `CRUDBase`), and both `import` forms become `dependency` facts. Crucially, the Python extractor now walks function and method bodies for call sites, emitting `calls` and `instantiates` edges (filtering out builtins) — so Python code participates in the dependency/call graph and is reachable by `traverse`, `find_path`, and `impact_analysis`. Monorepo detection walks up to 3 levels. +**Java** (tree-sitter) is framework-aware for the JVM server ecosystem. It emits symbol facts for classes, interfaces, enums, records, and annotation types, plus their methods, constructors, and fields, named with enola's `.` / `..` convention (nested types are qualified through the enclosing type). `extends`/`implements` become `implements` edges, `new X()` becomes `instantiates`, same-class method calls become `calls`, and both import forms become `dependency` facts split into internal vs. external. Because Java imports are explicit, type-reference edges are resolved through a project-wide fully-qualified-name index built in a second pass — so `implements`/`instantiates`/`injects` targets point at the canonical declaring symbol in another file or module rather than a bare name. Framework specialization covers **Spring MVC** (a `@RestController`/`@Controller` class's `@RequestMapping` base path is combined with method-level `@GetMapping`/`@PostMapping`/`@PutMapping`/`@DeleteMapping`/`@PatchMapping`/`@RequestMapping(method=…)` into one `route` per endpoint, carrying the HTTP method and the handler symbol), **Spring stereotypes** (`@Service`/`@Component`/`@Repository`/`@Controller`/`@Configuration` classified via a `component` prop), **dependency injection** (`@Autowired` fields, constructor injection, and Lombok `@RequiredArgsConstructor` over `final` fields → `injects` edges), and **JPA / Spring Data storage** (`@Entity` → a `storage` fact with `storage_kind: entity`; `@Repository` and `JpaRepository`/`CrudRepository`-style interfaces → `storage_kind: repository`). A `@Table(name = …)` is captured, and when the name is given as a `static final String` constant it is resolved to its literal value — the original identifier is preserved in a `table_constant` prop. **Apache Dubbo** is recognized too: `@SPI`/`@Activate`/`@DubboService` tag the type with `framework: "dubbo"` (`dubbo_spi`, `dubbo_activate`). Detection requires Maven (`pom.xml`) or real `.java` sources, so a pure-Kotlin Gradle project is left to the Kotlin extractor. + **Kotlin** is Android-aware: it detects Jetpack Compose (`@Composable`), Hilt DI (`@HiltViewModel`, `@Module`, `@AndroidEntryPoint`), Room (`@Entity`, `@Dao`, `@Database`), ViewModels, Repositories, Use Cases, and Workers. **Swift** is iOS-aware: SwiftUI views (`View`/`App`/`Scene`), UIKit (`UIViewController`/`UIView` subclasses), Combine view models (`ObservableObject`, `@Observable`), architectural roles (Repositories, Use Cases, Coordinators, Services, DI containers), and `@MainActor`. diff --git a/README.md b/README.md index 78ecee7..2120a5d 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,7 @@ Working across several repos? Generate the first, then add the rest with append | Language | Detected by | |------------|-------------| | Go | `go.mod` | +| Java | `pom.xml` (Maven) or `.java` sources (Spring routes / JPA / Lombok DI / Dubbo SPI aware) | | TypeScript | `tsconfig.json` / `package.json` with TypeScript (Next.js & monorepo aware) | | Python | `pyproject.toml`, `requirements.txt`, `setup.py`, … (FastAPI / Django / SQLAlchemy aware) | | Kotlin | `build.gradle(.kts)` with Kotlin/Android (Compose / Hilt / Room aware) | diff --git a/examples/full.yaml b/examples/full.yaml index 4e8bc70..305a8dd 100644 --- a/examples/full.yaml +++ b/examples/full.yaml @@ -6,6 +6,7 @@ # # Supported extractors: # - go (detection: go.mod) +# - java (detection: pom.xml, build.gradle, or .java sources) # - kotlin (detection: build.gradle.kts or build.gradle with Kotlin/Android) # - typescript (detection: tsconfig.json or package.json with TypeScript) # - swift (detection: Package.swift, .xcodeproj, or .xcworkspace) @@ -86,6 +87,7 @@ ignore: extractors: - cpp - go + - java - kotlin - typescript - swift diff --git a/go.mod b/go.mod index 523029b..602fa96 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/tree-sitter-grammars/tree-sitter-kotlin v1.1.0 github.com/tree-sitter/go-tree-sitter v0.24.0 github.com/tree-sitter/tree-sitter-cpp v0.22.4-0.20240818224355-b1a4e2b25148 + github.com/tree-sitter/tree-sitter-java v0.21.1-0.20240824015150-576d8097e495 github.com/tree-sitter/tree-sitter-python v0.23.6 github.com/tree-sitter/tree-sitter-typescript v0.23.2 gopkg.in/yaml.v3 v3.0.1 diff --git a/internal/config/config.go b/internal/config/config.go index 83bcb3f..dea7a15 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -57,7 +57,7 @@ func Default() *Config { "**/Pods/**", "**/.gradle/**", }, - Extractors: []string{"cpp", "go", "kotlin", "openapi", "python", "typescript", "swift", "ruby"}, + Extractors: []string{"cpp", "go", "java", "kotlin", "openapi", "python", "typescript", "swift", "ruby"}, Explainers: []string{"cycles", "layers", "crossrepo"}, Renderers: []string{"llm_context"}, Output: OutputConfig{ diff --git a/internal/extractors/javaextractor/java.go b/internal/extractors/javaextractor/java.go new file mode 100644 index 0000000..7b901e6 --- /dev/null +++ b/internal/extractors/javaextractor/java.go @@ -0,0 +1,253 @@ +package javaextractor + +import ( + "context" + "log" + "os" + "path/filepath" + "strings" + + "github.com/enola-labs/enola/internal/facts" +) + +// JavaExtractor extracts architectural facts from Java source code using +// tree-sitter AST parsing (see java_ast.go for the walker and spring.go for +// Spring/JPA/Dubbo framework specialization). +type JavaExtractor struct{} + +// New creates a new JavaExtractor. +func New() *JavaExtractor { + return &JavaExtractor{} +} + +func (e *JavaExtractor) Name() string { + return "java" +} + +// Detect returns true if the repository looks like a Java project: a Maven project +// (pom.xml), or any actual .java source file. A Gradle build file alone is not +// sufficient — Gradle is equally used by Kotlin, Android, and Groovy projects, so +// detecting on it would wrongly claim pure-Kotlin repos. Requiring real .java +// sources keeps the Java extractor off non-Java JVM projects. +func (e *JavaExtractor) Detect(repoPath string) (bool, error) { + if _, err := os.Stat(filepath.Join(repoPath, "pom.xml")); err == nil { + return true, nil + } + return containsJavaSource(repoPath, 8), nil +} + +// Extract parses Java files and emits architectural facts. +// +// Two passes: pass 1 walks each file's AST (extractFileAST) to emit declaration, +// import, route, storage and call-graph facts while indexing every declared type by +// its fully-qualified name. Pass 2 (canonicalizeTargets) rewrites type-reference +// edge targets (implements/instantiates/injects) and import targets from FQNs to +// canonical "." / module-dir names so reverse traversal connects +// dependents. Module facts are emitted per directory. +func (e *JavaExtractor) Extract(ctx context.Context, repoPath string, files []string) ([]facts.Fact, error) { + var allFacts []facts.Fact + modules := make(map[string]bool) + + for _, relFile := range files { + select { + case <-ctx.Done(): + return allFacts, ctx.Err() + default: + } + + if !isJavaFile(relFile) { + continue + } + + absFile := filepath.Join(repoPath, relFile) + src, err := os.ReadFile(absFile) + if err != nil { + log.Printf("[java-extractor] error reading %s: %v", relFile, err) + continue + } + + allFacts = append(allFacts, extractFileAST(src, relFile)...) + modules[filepath.Dir(relFile)] = true + } + + canonicalizeTargets(allFacts) + resolveTableConstants(allFacts) + + for dir := range modules { + allFacts = append(allFacts, facts.Fact{ + Kind: facts.KindModule, + Name: dir, + File: dir, + Props: map[string]any{ + "language": "java", + }, + }) + } + + return allFacts, nil +} + +// canonicalizeTargets resolves FQN-based edge targets to canonical fact names. +// +// - implements/instantiates/injects targets that match a declared type's FQN are +// rewritten to that type's "." fact name; unresolved targets (external +// libraries) are left as written. +// - import dependency facts whose target FQN resolves to a declared type — or whose +// value names a known source package — are marked source="internal" and pointed at +// the owning module dir. +func canonicalizeTargets(allFacts []facts.Fact) { + typeIndex := make(map[string]string) // FQN -> "." canonical name + typeDir := make(map[string]string) // FQN -> dir + packageDir := make(map[string]string) + for _, f := range allFacts { + if f.Kind != facts.KindSymbol { + continue + } + switch f.Props["symbol_kind"] { + case facts.SymbolClass, facts.SymbolInterface, facts.SymbolEnum: + fqn, _ := f.Props["fqn"].(string) + if fqn == "" { + continue + } + dir := f.File + if i := strings.LastIndex(dir, "/"); i >= 0 { + dir = dir[:i] + } else { + dir = "." + } + typeIndex[fqn] = f.Name + typeDir[fqn] = dir + if pkg := parentName(fqn); pkg != "" { + packageDir[pkg] = dir + } + } + } + + for i := range allFacts { + f := &allFacts[i] + if f.Kind == facts.KindDependency { + resolveImport(f, typeDir, packageDir) + continue + } + for j := range f.Relations { + r := &f.Relations[j] + switch r.Kind { + case facts.RelImplements, facts.RelInstantiates, facts.RelInjects: + if canon, ok := typeIndex[r.Target]; ok { + r.Target = canon + } + } + } + } +} + +func resolveImport(f *facts.Fact, typeDir, packageDir map[string]string) { + imp, _ := f.Props["import"].(string) + if imp == "" { + return + } + var dir string + var ok bool + if dir, ok = typeDir[imp]; !ok { + // Wildcard / package import (e.g. "com.example.foo"). + dir, ok = packageDir[imp] + } + if !ok { + return // external dependency + } + f.Props["source"] = "internal" + for j := range f.Relations { + if f.Relations[j].Kind == facts.RelImports { + f.Relations[j].Target = dir + } + } +} + +// resolveTableConstants rewrites storage facts whose "table" prop names a string +// constant (e.g. @Table(name = ADMIN_SETTINGS_TABLE_NAME)) to that constant's +// literal value. Constants are indexed by simple name across all files, since the +// table-name constants typically live in a shared ModelConstants class. When the +// same simple name maps to conflicting values it is left unresolved (ambiguous). +func resolveTableConstants(allFacts []facts.Fact) { + values := make(map[string]string) + ambiguous := make(map[string]bool) + for _, f := range allFacts { + if f.Kind != facts.KindSymbol { + continue + } + v, ok := f.Props["value"].(string) + if !ok { + continue + } + simple := f.Name + if i := strings.LastIndex(simple, "."); i >= 0 { + simple = simple[i+1:] + } + if existing, seen := values[simple]; seen && existing != v { + ambiguous[simple] = true + continue + } + values[simple] = v + } + + for i := range allFacts { + f := &allFacts[i] + if f.Kind != facts.KindStorage { + continue + } + tbl, ok := f.Props["table"].(string) + if !ok { + continue + } + if ambiguous[tbl] { + continue + } + if v, ok := values[tbl]; ok { + f.Props["table"] = v + f.Props["table_constant"] = tbl + } + } +} + +func parentName(fqn string) string { + if i := strings.LastIndex(fqn, "."); i >= 0 { + return fqn[:i] + } + return "" +} + +func isJavaFile(path string) bool { + return strings.HasSuffix(strings.ToLower(path), ".java") +} + +// containsJavaSource reports whether any .java file exists under root within +// maxDepth directory levels. It returns on the first match and skips hidden and +// common build/dependency directories so it stays cheap on large repos. +func containsJavaSource(root string, maxDepth int) bool { + var search func(dir string, depth int) bool + search = func(dir string, depth int) bool { + if depth > maxDepth { + return false + } + entries, err := os.ReadDir(dir) + if err != nil { + return false + } + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() { + if strings.HasPrefix(name, ".") || name == "build" || + name == "target" || name == "node_modules" { + continue + } + if search(filepath.Join(dir, name), depth+1) { + return true + } + } else if isJavaFile(name) { + return true + } + } + return false + } + return search(root, 0) +} diff --git a/internal/extractors/javaextractor/java_ast.go b/internal/extractors/javaextractor/java_ast.go new file mode 100644 index 0000000..d7f7282 --- /dev/null +++ b/internal/extractors/javaextractor/java_ast.go @@ -0,0 +1,842 @@ +package javaextractor + +import ( + "path/filepath" + "strings" + "unicode" + + "github.com/enola-labs/enola/internal/facts" + sitter "github.com/tree-sitter/go-tree-sitter" + java "github.com/tree-sitter/tree-sitter-java/bindings/go" +) + +// extractFileAST parses a single Java file with tree-sitter and emits architectural +// facts: declaration symbols (classes, interfaces, enums, records, methods, fields), +// import dependencies, and call-graph relations (RelImplements, RelInstantiates, +// RelInjects, RelCalls). +// +// Relation targets for type references (implements/instantiates/injects) are emitted +// as fully-qualified names — resolved through the file's import map, or assumed to be +// same-package when no import matches. java.go's canonicalizeTargets rewrites those +// FQNs to canonical "." fact names once every file has been indexed. +func extractFileAST(src []byte, relFile string) []facts.Fact { + parser := sitter.NewParser() + defer parser.Close() + if err := parser.SetLanguage(sitter.NewLanguage(java.Language())); err != nil { + return nil + } + tree := parser.Parse(src, nil) + if tree == nil { + return nil + } + defer tree.Close() + + w := &astWalker{ + src: src, + relFile: relFile, + dir: filepath.Dir(relFile), + importMap: make(map[string]string), + } + root := tree.RootNode() + w.pkg = w.findPackage(root) + w.walkProgram(root) + return w.out +} + +type astWalker struct { + src []byte + relFile string + dir string + pkg string // dotted package name, e.g. "com.example.auth" ("" if none) + + out []facts.Fact + + // importMap maps an imported simple type name to its fully-qualified name + // (e.g. "Store" -> "com.example.data.Store"). Used to resolve bare type + // references in supertypes, constructor calls, and injected parameters. + importMap map[string]string + + // typeStack holds the simple names of the enclosing type declarations, so a + // method declared in class Foo is named ".Foo.method". methodStack is + // parallel and holds the method-name set of each enclosing type, used to + // resolve same-class bare calls. + typeStack []string + methodStack []map[string]bool + + // ownerStack[len-1] is the symbol fact currently being built; call-graph edges + // discovered while walking its body attach to it. + ownerStack []*facts.Fact + + // routeStack is parallel to typeStack: each entry carries the Spring route + // context (whether the enclosing type is a @Controller/@RestController and its + // class-level base path) so method handlers can emit route facts. + routeStack []routeScope +} + +type routeScope struct { + isController bool + basePath string +} + +func (w *astWalker) enclosingType() string { return strings.Join(w.typeStack, ".") } + +func (w *astWalker) qualify(name string) string { + if t := w.enclosingType(); t != "" { + return t + "." + name + } + return name +} + +func (w *astWalker) currentMethods() map[string]bool { + if len(w.methodStack) == 0 { + return nil + } + return w.methodStack[len(w.methodStack)-1] +} + +func (w *astWalker) currentRoute() *routeScope { + if len(w.routeStack) == 0 { + return nil + } + return &w.routeStack[len(w.routeStack)-1] +} + +func (w *astWalker) pushOwner(f *facts.Fact) { w.ownerStack = append(w.ownerStack, f) } +func (w *astWalker) popOwner() { w.ownerStack = w.ownerStack[:len(w.ownerStack)-1] } +func (w *astWalker) currentOwner() *facts.Fact { + if len(w.ownerStack) == 0 { + return nil + } + return w.ownerStack[len(w.ownerStack)-1] +} + +// canonicalName is the "." fact name of a declaration. +func (w *astWalker) canonicalName(qualified string) string { return w.dir + "." + qualified } + +// fqn is the fully-qualified "." name of a declaration. +func (w *astWalker) fqn(qualified string) string { + if w.pkg == "" { + return qualified + } + return w.pkg + "." + qualified +} + +func (w *astWalker) findPackage(root *sitter.Node) string { + if pd := findChildByKind(root, "package_declaration"); pd != nil { + // The package name is the scoped_identifier / identifier child. + for i := uint(0); i < uint(pd.ChildCount()); i++ { + c := pd.Child(i) + if c.Kind() == "scoped_identifier" || c.Kind() == "identifier" { + return nodeText(c, w.src) + } + } + } + return "" +} + +func (w *astWalker) walkProgram(root *sitter.Node) { + for i := uint(0); i < uint(root.ChildCount()); i++ { + w.walkTopLevel(root.Child(i)) + } +} + +func (w *astWalker) walkTopLevel(node *sitter.Node) { + switch node.Kind() { + case "import_declaration": + w.handleImport(node) + case "class_declaration": + w.handleClassLike(node, facts.SymbolClass) + case "interface_declaration": + w.handleClassLike(node, facts.SymbolInterface) + case "enum_declaration": + w.handleClassLike(node, facts.SymbolEnum) + case "record_declaration": + w.handleClassLike(node, facts.SymbolClass) + case "annotation_type_declaration": + w.handleClassLike(node, facts.SymbolInterface) + } +} + +func (w *astWalker) handleImport(node *sitter.Node) { + isStatic := false + isWildcard := false + var pathNode *sitter.Node + for i := uint(0); i < uint(node.ChildCount()); i++ { + c := node.Child(i) + switch c.Kind() { + case "static": + isStatic = true + case "asterisk": + isWildcard = true + case "scoped_identifier", "identifier": + pathNode = c + } + } + if pathNode == nil { + return + } + importPath := nodeText(pathNode, w.src) + + w.out = append(w.out, facts.Fact{ + Kind: facts.KindDependency, + Name: w.dir + " -> " + importPath, + File: w.relFile, + Line: int(node.StartPosition().Row) + 1, + Props: map[string]any{ + "language": "java", + "import": importPath, + "source": "external", // refined to "internal" in canonicalizeTargets + }, + Relations: []facts.Relation{ + {Kind: facts.RelImports, Target: importPath}, + }, + }) + + // Record a non-static, non-wildcard import's simple name so bare type + // references resolve to its FQN. Static imports name a member, not a type; + // wildcard imports carry no simple name. + if isStatic || isWildcard { + return + } + simple := importPath + if i := strings.LastIndex(importPath, "."); i >= 0 { + simple = importPath[i+1:] + } + if simple != "" { + w.importMap[simple] = importPath + } +} + +func (w *astWalker) handleClassLike(node *sitter.Node, kind string) { + nameNode := node.ChildByFieldName("name") + if nameNode == nil { + return + } + name := nodeText(nameNode, w.src) + + modifiers := findChildByKind(node, "modifiers") + modifierText := "" + var annotations []javaAnnotation + if modifiers != nil { + modifierText = nodeText(modifiers, w.src) + annotations = parseAnnotations(modifiers, w.src) + } + // A top-level type is exported when public; nested types inherit visibility + // loosely — treat anything not explicitly private as part of the surface. + exported := strings.Contains(modifierText, "public") || + (!strings.Contains(modifierText, "private") && len(w.typeStack) > 0) + + f := facts.Fact{ + Kind: facts.KindSymbol, + Name: w.canonicalName(w.qualify(name)), + File: w.relFile, + Line: int(node.StartPosition().Row) + 1, + Props: map[string]any{ + "symbol_kind": kind, + "exported": exported, + "language": "java", + "fqn": w.fqn(w.qualify(name)), + }, + Relations: []facts.Relation{ + {Kind: facts.RelDeclares, Target: w.dir}, + }, + } + if strings.Contains(modifierText, "abstract") { + f.Props["abstract"] = true + } + if node.Kind() == "record_declaration" { + f.Props["record"] = true + } + if node.Kind() == "annotation_type_declaration" { + f.Props["annotation_class"] = true + } + + // Inheritance: `extends` superclass + `implements`/`extends` interfaces. + for _, st := range w.supertypeTargets(node) { + f.Relations = append(f.Relations, facts.Relation{Kind: facts.RelImplements, Target: st}) + } + + // Framework classification (Spring component / JPA / Dubbo SPI) mutates props + // and may emit a companion storage fact. + classifyComponent(&f, name, annotations, w.supertypeSimpleNames(node)) + if sf := detectJpaStorage(name, annotations, w.relFile, int(node.StartPosition().Row)+1, w.dir); sf != nil { + w.out = append(w.out, *sf) + } + + w.out = append(w.out, f) + owner := &w.out[len(w.out)-1] + w.pushOwner(owner) + + // Enter the type scope. + body := classBody(node) + w.typeStack = append(w.typeStack, name) + w.methodStack = append(w.methodStack, collectMethodNames(body, w.src)) + w.routeStack = append(w.routeStack, routeScope{ + isController: isSpringController(annotations), + basePath: requestMappingPath(annotations), + }) + + // Constructor-based DI: a class with a single constructor, or one annotated + // @Autowired/@Inject, injects each of that constructor's parameter types. + w.handleConstructorInjection(node, body, owner, annotations) + // Field-level @Autowired/@Inject and Lombok @RequiredArgsConstructor over + // `private final` fields also produce injection edges; emitted while walking + // the body below. + + if body != nil { + w.walkBody(body, owner) + } + + w.routeStack = w.routeStack[:len(w.routeStack)-1] + w.typeStack = w.typeStack[:len(w.typeStack)-1] + w.methodStack = w.methodStack[:len(w.methodStack)-1] + w.popOwner() +} + +// walkBody iterates the direct members of a class/interface/enum body, handling +// nested declarations, methods, and fields. Non-declaration nodes are scanned for +// constructor calls attributed to `owner`. +func (w *astWalker) walkBody(body *sitter.Node, owner *facts.Fact) { + for i := uint(0); i < uint(body.ChildCount()); i++ { + c := body.Child(i) + switch c.Kind() { + case "class_declaration": + w.handleClassLike(c, facts.SymbolClass) + case "interface_declaration": + w.handleClassLike(c, facts.SymbolInterface) + case "enum_declaration": + w.handleClassLike(c, facts.SymbolEnum) + case "record_declaration": + w.handleClassLike(c, facts.SymbolClass) + case "annotation_type_declaration": + w.handleClassLike(c, facts.SymbolInterface) + case "method_declaration", "constructor_declaration": + w.handleMethod(c) + case "field_declaration": + w.handleField(c, owner) + default: + // init blocks, enum constants, etc. — scan for constructor calls. + w.walkForCalls(c) + } + } +} + +func (w *astWalker) handleMethod(node *sitter.Node) { + nameNode := node.ChildByFieldName("name") + if nameNode == nil { + return + } + name := nodeText(nameNode, w.src) + + modifiers := findChildByKind(node, "modifiers") + modifierText := "" + var annotations []javaAnnotation + if modifiers != nil { + modifierText = nodeText(modifiers, w.src) + annotations = parseAnnotations(modifiers, w.src) + } + exported := strings.Contains(modifierText, "public") + + f := facts.Fact{ + Kind: facts.KindSymbol, + Name: w.canonicalName(w.qualify(name)), + File: w.relFile, + Line: int(node.StartPosition().Row) + 1, + Props: map[string]any{ + "symbol_kind": facts.SymbolMethod, + "exported": exported, + "language": "java", + }, + Relations: []facts.Relation{ + {Kind: facts.RelDeclares, Target: w.dir}, + }, + } + if t := w.enclosingType(); t != "" { + f.Props["receiver"] = t + } + if strings.Contains(modifierText, "static") { + f.Props["static"] = true + } + + // Spring route: a request-mapping annotation on a controller method. + if rs := w.currentRoute(); rs != nil && rs.isController { + for _, rf := range springRouteFacts(rs.basePath, annotations, w.relFile, + int(node.StartPosition().Row)+1, w.dir, w.canonicalName(w.qualify(name))) { + w.out = append(w.out, rf) + } + } + + w.out = append(w.out, f) + owner := &w.out[len(w.out)-1] + w.pushOwner(owner) + if body := node.ChildByFieldName("body"); body != nil { + w.walkForCalls(body) + } + w.popOwner() +} + +func (w *astWalker) handleField(node *sitter.Node, owner *facts.Fact) { + modifiers := findChildByKind(node, "modifiers") + modifierText := "" + var annotations []javaAnnotation + if modifiers != nil { + modifierText = nodeText(modifiers, w.src) + annotations = parseAnnotations(modifiers, w.src) + } + exported := strings.Contains(modifierText, "public") + + symbolKind := facts.SymbolVariable + if strings.Contains(modifierText, "final") { + symbolKind = facts.SymbolConstant + } + + // Field type — used for DI edges when @Autowired/@Inject is present. + typeNode := node.ChildByFieldName("type") + typeTarget := w.targetForType(typeNode) + injected := hasAnnotation(annotations, "Autowired", "Inject", "Resource", "Reference") + // A `static final String FOO = "literal"` constant exposes its value so that + // references to it (e.g. @Table(name = FOO)) can be resolved in a later pass. + captureValue := strings.Contains(modifierText, "static") && + strings.Contains(modifierText, "final") && + typeFullName(typeNode, w.src) == "String" + + for i := uint(0); i < uint(node.ChildCount()); i++ { + c := node.Child(i) + if c.Kind() != "variable_declarator" { + continue + } + nameNode := c.ChildByFieldName("name") + if nameNode == nil { + continue + } + name := nodeText(nameNode, w.src) + props := map[string]any{ + "symbol_kind": symbolKind, + "exported": exported, + "language": "java", + } + if captureValue && isScreamingSnake(name) { + if val, ok := stringLiteralValue(c.ChildByFieldName("value"), w.src); ok { + props["value"] = val + } + } + w.out = append(w.out, facts.Fact{ + Kind: facts.KindSymbol, + Name: w.canonicalName(w.qualify(name)), + File: w.relFile, + Line: int(c.StartPosition().Row) + 1, + Props: props, + Relations: []facts.Relation{ + {Kind: facts.RelDeclares, Target: w.dir}, + }, + }) + } + + if injected && typeTarget != "" && owner != nil { + owner.Relations = append(owner.Relations, facts.Relation{Kind: facts.RelInjects, Target: typeTarget}) + } + // A field initializer may contain a constructor call (`= new Foo()`). + w.walkForCalls(node) +} + +// handleConstructorInjection emits RelInjects edges from `owner` to each parameter +// type of an injectable constructor: a sole constructor, a constructor annotated +// @Autowired/@Inject, or (Lombok) when the class carries @RequiredArgsConstructor / +// @AllArgsConstructor. +func (w *astWalker) handleConstructorInjection(decl, body *sitter.Node, owner *facts.Fact, classAnns []javaAnnotation) { + lombokInject := hasAnnotation(classAnns, "RequiredArgsConstructor", "AllArgsConstructor") + + // record_declaration parameters are constructor parameters too. + if decl.Kind() == "record_declaration" { + if params := decl.ChildByFieldName("parameters"); params != nil && lombokInject { + w.injectParams(params, owner) + } + } + + if lombokInject { + // Inject each `private final` field's type. + if body != nil { + for i := uint(0); i < uint(body.ChildCount()); i++ { + c := body.Child(i) + if c.Kind() != "field_declaration" { + continue + } + mods := findChildByKind(c, "modifiers") + if mods == nil || !strings.Contains(nodeText(mods, w.src), "final") { + continue + } + if t := w.targetForType(c.ChildByFieldName("type")); t != "" && owner != nil { + owner.Relations = append(owner.Relations, facts.Relation{Kind: facts.RelInjects, Target: t}) + } + } + } + } + + if body == nil { + return + } + var ctors []*sitter.Node + for i := uint(0); i < uint(body.ChildCount()); i++ { + if c := body.Child(i); c.Kind() == "constructor_declaration" { + ctors = append(ctors, c) + } + } + for _, ctor := range ctors { + mods := findChildByKind(ctor, "modifiers") + annotated := false + if mods != nil { + annotated = hasAnnotation(parseAnnotations(mods, w.src), "Autowired", "Inject") + } + if annotated || (len(ctors) == 1 && !lombokInject) { + if params := ctor.ChildByFieldName("parameters"); params != nil { + w.injectParams(params, owner) + } + } + } +} + +func (w *astWalker) injectParams(params *sitter.Node, owner *facts.Fact) { + if owner == nil { + return + } + for i := uint(0); i < uint(params.ChildCount()); i++ { + p := params.Child(i) + if p.Kind() != "formal_parameter" { + continue + } + if t := w.targetForType(p.ChildByFieldName("type")); t != "" { + owner.Relations = append(owner.Relations, facts.Relation{Kind: facts.RelInjects, Target: t}) + } + } +} + +// walkForCalls recursively scans a subtree for object_creation_expression (→ +// RelInstantiates) and method_invocation (→ RelCalls for resolvable same-class +// calls), attributing each to the current owner. Nested type declarations are +// dispatched to their own handlers so their calls are attributed correctly. +func (w *astWalker) walkForCalls(node *sitter.Node) { + if node == nil { + return + } + switch node.Kind() { + case "class_declaration": + w.handleClassLike(node, facts.SymbolClass) + return + case "interface_declaration": + w.handleClassLike(node, facts.SymbolInterface) + return + case "enum_declaration": + w.handleClassLike(node, facts.SymbolEnum) + return + case "record_declaration": + w.handleClassLike(node, facts.SymbolClass) + return + case "object_creation_expression": + if t := w.targetForType(node.ChildByFieldName("type")); t != "" { + if owner := w.currentOwner(); owner != nil { + owner.Relations = append(owner.Relations, facts.Relation{Kind: facts.RelInstantiates, Target: t}) + } + } + case "method_invocation": + w.handleInvocation(node) + } + + for i := uint(0); i < uint(node.ChildCount()); i++ { + w.walkForCalls(node.Child(i)) + } +} + +func (w *astWalker) handleInvocation(node *sitter.Node) { + owner := w.currentOwner() + if owner == nil { + return + } + nameNode := node.ChildByFieldName("name") + if nameNode == nil { + return + } + name := nodeText(nameNode, w.src) + obj := node.ChildByFieldName("object") + + // Resolve bare `foo()` and `this.foo()` calls against the enclosing class's + // own methods. Calls on other receivers are left unresolved (the receiver's + // type is not tracked), matching the Kotlin extractor's conservative model. + isThis := obj != nil && nodeText(obj, w.src) == "this" + if obj == nil || isThis { + if methods := w.currentMethods(); methods[name] { + owner.Relations = append(owner.Relations, facts.Relation{ + Kind: facts.RelCalls, + Target: w.dir + "." + w.enclosingType() + "." + name, + }) + } + } +} + +// targetForType returns a relation target for a `_type` node: the rightmost simple +// name resolved through the import map to an FQN, a same-package FQN when not +// imported, or the written FQN when the reference is already qualified. Returns "" +// for primitive/void/unresolvable types. +func (w *astWalker) targetForType(typeNode *sitter.Node) string { + if typeNode == nil { + return "" + } + full := typeFullName(typeNode, w.src) + if full == "" { + return "" + } + if isPrimitiveType(full) { + return "" + } + simple := full + if i := strings.LastIndex(full, "."); i >= 0 { + // Already qualified in source — use as written. + return full + } + if fqn, ok := w.importMap[simple]; ok { + return fqn + } + if javaLangTypes[simple] { + return "" + } + if w.pkg != "" { + return w.pkg + "." + simple + } + return simple +} + +// supertypeTargets returns canonicalization targets (FQNs) for a type's superclass +// and implemented/extended interfaces. +func (w *astWalker) supertypeTargets(node *sitter.Node) []string { + var out []string + for _, n := range w.supertypeNodes(node) { + if t := w.targetForType(n); t != "" { + out = append(out, t) + } + } + return out +} + +// supertypeSimpleNames returns the simple names of a type's supertypes (used by +// component classification, e.g. detecting Spring Data repository interfaces). +func (w *astWalker) supertypeSimpleNames(node *sitter.Node) []string { + var out []string + for _, n := range w.supertypeNodes(node) { + if s := lastTypeComponent(typeFullName(n, w.src)); s != "" { + out = append(out, s) + } + } + return out +} + +func (w *astWalker) supertypeNodes(node *sitter.Node) []*sitter.Node { + var out []*sitter.Node + if sc := node.ChildByFieldName("superclass"); sc != nil { + out = append(out, firstTypeChild(sc)) + } + // `interfaces` field (class/enum/record) or `extends_interfaces` child (interface). + if iface := node.ChildByFieldName("interfaces"); iface != nil { + out = append(out, typeListChildren(iface)...) + } + if ext := findChildByKind(node, "extends_interfaces"); ext != nil { + out = append(out, typeListChildren(ext)...) + } + // Filter nils. + kept := out[:0] + for _, n := range out { + if n != nil { + kept = append(kept, n) + } + } + return kept +} + +// --- tree-sitter / type helpers --- + +func classBody(node *sitter.Node) *sitter.Node { + if b := node.ChildByFieldName("body"); b != nil { + return b + } + return nil +} + +// typeListChildren returns the concrete `_type` children of a super_interfaces / +// extends_interfaces node, which wrap a single type_list. +func typeListChildren(node *sitter.Node) []*sitter.Node { + tl := findChildByKind(node, "type_list") + if tl == nil { + return nil + } + var out []*sitter.Node + for i := uint(0); i < uint(tl.ChildCount()); i++ { + c := tl.Child(i) + if c.IsNamed() && c.Kind() != "annotation" && c.Kind() != "marker_annotation" { + out = append(out, c) + } + } + return out +} + +// firstTypeChild returns the first named, non-annotation child of a wrapper node +// (e.g. the `_type` under a superclass node). +func firstTypeChild(node *sitter.Node) *sitter.Node { + if node == nil { + return nil + } + for i := uint(0); i < uint(node.ChildCount()); i++ { + c := node.Child(i) + if c.IsNamed() && c.Kind() != "annotation" && c.Kind() != "marker_annotation" { + return c + } + } + return nil +} + +// typeFullName returns the source-written dotted name of a `_type` node, stripping +// generic arguments and array dimensions. For `java.util.List` it returns +// "java.util.List"; for `Map` it returns "Map". +func typeFullName(node *sitter.Node, src []byte) string { + if node == nil { + return "" + } + switch node.Kind() { + case "type_identifier", "scoped_type_identifier": + return nodeText(node, src) + case "generic_type": + // First named child is the base type (type_identifier or scoped_type_identifier). + if base := firstNamedChild(node); base != nil { + return typeFullName(base, src) + } + case "annotated_type": + // The type follows the annotations. + for i := uint(0); i < uint(node.ChildCount()); i++ { + c := node.Child(i) + if c.IsNamed() && c.Kind() != "annotation" && c.Kind() != "marker_annotation" { + return typeFullName(c, src) + } + } + case "array_type": + if el := node.ChildByFieldName("element"); el != nil { + return typeFullName(el, src) + } + } + // Primitive / void / fallback: take the raw text, stripped of generics/arrays. + t := nodeText(node, src) + if i := strings.IndexAny(t, "<[ "); i >= 0 { + t = t[:i] + } + return strings.TrimSpace(t) +} + +func lastTypeComponent(full string) string { + if i := strings.LastIndex(full, "."); i >= 0 { + return full[i+1:] + } + return full +} + +func collectMethodNames(body *sitter.Node, src []byte) map[string]bool { + methods := make(map[string]bool) + if body == nil { + return methods + } + for i := uint(0); i < uint(body.ChildCount()); i++ { + c := body.Child(i) + if c.Kind() != "method_declaration" { + continue + } + if nameNode := c.ChildByFieldName("name"); nameNode != nil { + methods[nodeText(nameNode, src)] = true + } + } + return methods +} + +// isScreamingSnake reports whether a name is an UPPER_SNAKE_CASE constant +// identifier (the convention for table/column name constants). +func isScreamingSnake(s string) bool { + if s == "" { + return false + } + hasLetter := false + for _, r := range s { + switch { + case r >= 'A' && r <= 'Z': + hasLetter = true + case r >= '0' && r <= '9', r == '_': + default: + return false + } + } + return hasLetter +} + +// stringLiteralValue returns the unquoted contents of a string_literal node. +func stringLiteralValue(node *sitter.Node, src []byte) (string, bool) { + if node == nil || node.Kind() != "string_literal" { + return "", false + } + return strings.Trim(nodeText(node, src), `"`), true +} + +func isPrimitiveType(name string) bool { + switch name { + case "void", "boolean", "byte", "short", "int", "long", "char", "float", "double", "var": + return true + } + return false +} + +// javaLangTypes are implicitly imported java.lang types that appear as bare names +// without an import statement; resolving them would create dangling edges. +var javaLangTypes = map[string]bool{ + "Object": true, "String": true, "Integer": true, "Long": true, "Short": true, + "Byte": true, "Character": true, "Boolean": true, "Float": true, "Double": true, + "Number": true, "Math": true, "System": true, "Thread": true, "Runnable": true, + "Exception": true, "RuntimeException": true, "Throwable": true, "Error": true, + "IllegalArgumentException": true, "IllegalStateException": true, + "NullPointerException": true, "UnsupportedOperationException": true, + "Class": true, "Enum": true, "Iterable": true, "Comparable": true, "CharSequence": true, + "StringBuilder": true, "StringBuffer": true, "Void": true, "Override": true, + "Deprecated": true, "SuppressWarnings": true, "FunctionalInterface": true, + "AutoCloseable": true, "Cloneable": true, "ClassLoader": true, "Process": true, +} + +func isCapitalized(s string) bool { + if s == "" { + return false + } + return unicode.IsUpper([]rune(s)[0]) +} + +func findChildByKind(node *sitter.Node, kind string) *sitter.Node { + if node == nil { + return nil + } + for i := uint(0); i < uint(node.ChildCount()); i++ { + c := node.Child(i) + if c.Kind() == kind { + return c + } + } + return nil +} + +func firstNamedChild(node *sitter.Node) *sitter.Node { + if node == nil { + return nil + } + for i := uint(0); i < uint(node.ChildCount()); i++ { + c := node.Child(i) + if c.IsNamed() { + return c + } + } + return nil +} + +func nodeText(node *sitter.Node, src []byte) string { + if node == nil { + return "" + } + return string(src[node.StartByte():node.EndByte()]) +} diff --git a/internal/extractors/javaextractor/java_ast_test.go b/internal/extractors/javaextractor/java_ast_test.go new file mode 100644 index 0000000..3f0a5a7 --- /dev/null +++ b/internal/extractors/javaextractor/java_ast_test.go @@ -0,0 +1,295 @@ +package javaextractor + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/enola-labs/enola/internal/facts" +) + +// extractAll writes the given files to a temp repo and runs the full extractor +// (including the two-pass canonicalization), returning all emitted facts. +func extractAll(t *testing.T, files map[string]string) []facts.Fact { + t.Helper() + dir := t.TempDir() + var relFiles []string + for rel, content := range files { + abs := filepath.Join(dir, rel) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(abs, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + relFiles = append(relFiles, rel) + } + ff, err := New().Extract(context.Background(), dir, relFiles) + if err != nil { + t.Fatalf("Extract: %v", err) + } + return ff +} + +func findFact(ff []facts.Fact, name string) (facts.Fact, bool) { + for _, f := range ff { + if f.Name == name { + return f, true + } + } + return facts.Fact{}, false +} + +func findFactKind(ff []facts.Fact, kind, name string) (facts.Fact, bool) { + for _, f := range ff { + if f.Kind == kind && f.Name == name { + return f, true + } + } + return facts.Fact{}, false +} + +func factsByKind(ff []facts.Fact, kind string) []facts.Fact { + var out []facts.Fact + for _, f := range ff { + if f.Kind == kind { + out = append(out, f) + } + } + return out +} + +func hasRelation(f facts.Fact, kind, target string) bool { + for _, r := range f.Relations { + if r.Kind == kind && r.Target == target { + return true + } + } + return false +} + +func TestExtract_ClassMethodField(t *testing.T) { + ff := extractAll(t, map[string]string{ + "src/main/java/pkg/Order.java": `package pkg; + +public class Order { + private final double total; + public static final int MAX = 100; + + public Order(double total) { + this.total = total; + } + + public double calculate() { + return total * 1.2; + } +} +`, + }) + + cls, ok := findFact(ff, "src/main/java/pkg.Order") + if !ok { + t.Fatalf("missing class fact; got %v", names(ff)) + } + if cls.Props["symbol_kind"] != facts.SymbolClass { + t.Errorf("Order symbol_kind = %v, want class", cls.Props["symbol_kind"]) + } + if cls.Props["exported"] != true { + t.Errorf("Order exported = %v, want true", cls.Props["exported"]) + } + if cls.Props["fqn"] != "pkg.Order" { + t.Errorf("Order fqn = %v, want pkg.Order", cls.Props["fqn"]) + } + + m, ok := findFact(ff, "src/main/java/pkg.Order.calculate") + if !ok { + t.Fatalf("missing method fact; got %v", names(ff)) + } + if m.Props["symbol_kind"] != facts.SymbolMethod { + t.Errorf("calculate symbol_kind = %v, want method", m.Props["symbol_kind"]) + } + if m.Props["receiver"] != "Order" { + t.Errorf("calculate receiver = %v, want Order", m.Props["receiver"]) + } + + field, ok := findFact(ff, "src/main/java/pkg.Order.total") + if !ok { + t.Fatalf("missing field fact total; got %v", names(ff)) + } + if field.Props["symbol_kind"] != facts.SymbolConstant { + t.Errorf("total symbol_kind = %v, want constant (final)", field.Props["symbol_kind"]) + } +} + +func TestExtract_InterfaceEnumRecord(t *testing.T) { + ff := extractAll(t, map[string]string{ + "a/Shape.java": "package a;\npublic interface Shape { double area(); }\n", + "a/Color.java": "package a;\npublic enum Color { RED, GREEN, BLUE }\n", + "a/Point.java": "package a;\npublic record Point(int x, int y) {}\n", + }) + + iface, _ := findFact(ff, "a.Shape") + if iface.Props["symbol_kind"] != facts.SymbolInterface { + t.Errorf("Shape symbol_kind = %v, want interface", iface.Props["symbol_kind"]) + } + en, _ := findFact(ff, "a.Color") + if en.Props["symbol_kind"] != facts.SymbolEnum { + t.Errorf("Color symbol_kind = %v, want enum", en.Props["symbol_kind"]) + } + rec, ok := findFact(ff, "a.Point") + if !ok || rec.Props["record"] != true { + t.Errorf("Point record fact missing or not marked record: %+v", rec.Props) + } +} + +func TestExtract_ImplementsExtends(t *testing.T) { + ff := extractAll(t, map[string]string{ + "p/Animal.java": "package p;\npublic abstract class Animal {}\n", + "p/Pet.java": "package p;\npublic interface Pet {}\n", + "p/Dog.java": `package p; + +public class Dog extends Animal implements Pet {} +`, + }) + + dog, ok := findFact(ff, "p.Dog") + if !ok { + t.Fatalf("missing Dog; got %v", names(ff)) + } + // Same-package supertypes resolve to canonical "." names. + if !hasRelation(dog, facts.RelImplements, "p.Animal") { + t.Errorf("Dog should implement/extend p.Animal; got %+v", dog.Relations) + } + if !hasRelation(dog, facts.RelImplements, "p.Pet") { + t.Errorf("Dog should implement p.Pet; got %+v", dog.Relations) + } +} + +func TestExtract_ImportsInternalVsExternal(t *testing.T) { + ff := extractAll(t, map[string]string{ + "app/svc/Service.java": `package app.svc; + +import app.data.Repo; +import java.util.List; + +public class Service { + private Repo repo; +} +`, + "app/data/Repo.java": "package app.data;\npublic class Repo {}\n", + }) + + var internalOK, externalOK bool + for _, f := range factsByKind(ff, facts.KindDependency) { + switch f.Props["import"] { + case "app.data.Repo": + if f.Props["source"] == "internal" && hasRelation(f, facts.RelImports, "app/data") { + internalOK = true + } + case "java.util.List": + if f.Props["source"] == "external" { + externalOK = true + } + } + } + if !internalOK { + t.Error("app.data.Repo import should be internal and target module app/data") + } + if !externalOK { + t.Error("java.util.List import should be external") + } +} + +func TestExtract_InstantiatesAndCalls(t *testing.T) { + ff := extractAll(t, map[string]string{ + "m/Widget.java": "package m;\npublic class Widget {}\n", + "m/Factory.java": `package m; + +public class Factory { + public Widget make() { + helper(); + return new Widget(); + } + + private void helper() {} +} +`, + }) + + mk, ok := findFact(ff, "m.Factory.make") + if !ok { + t.Fatalf("missing Factory.make; got %v", names(ff)) + } + if !hasRelation(mk, facts.RelInstantiates, "m.Widget") { + t.Errorf("make should instantiate m.Widget; got %+v", mk.Relations) + } + if !hasRelation(mk, facts.RelCalls, "m.Factory.helper") { + t.Errorf("make should call m.Factory.helper; got %+v", mk.Relations) + } +} + +func TestExtract_JdkBuiltinsSuppressed(t *testing.T) { + ff := extractAll(t, map[string]string{ + "x/Foo.java": `package x; + +public class Foo { + public void run() { + String s = new String("hi"); + Object o = new Object(); + } +} +`, + }) + run, _ := findFact(ff, "x.Foo.run") + for _, r := range run.Relations { + if r.Kind == facts.RelInstantiates && (r.Target == "String" || r.Target == "Object" || + r.Target == "java.lang.String" || r.Target == "x.String") { + t.Errorf("java.lang type should not produce instantiate edge: %+v", r) + } + } +} + +func TestExtract_NestedTypeQualified(t *testing.T) { + ff := extractAll(t, map[string]string{ + "n/Outer.java": `package n; + +public class Outer { + public static class Inner { + public void ping() {} + } +} +`, + }) + if _, ok := findFact(ff, "n.Outer.Inner"); !ok { + t.Errorf("missing nested type n.Outer.Inner; got %v", names(ff)) + } + if _, ok := findFact(ff, "n.Outer.Inner.ping"); !ok { + t.Errorf("missing nested method n.Outer.Inner.ping; got %v", names(ff)) + } +} + +func TestExtract_ModuleFacts(t *testing.T) { + ff := extractAll(t, map[string]string{ + "a/A.java": "package a;\npublic class A {}\n", + "b/B.java": "package b;\npublic class B {}\n", + }) + for _, dir := range []string{"a", "b"} { + m, ok := findFactKind(ff, facts.KindModule, dir) + if !ok { + t.Errorf("missing module fact %q", dir) + continue + } + if m.Props["language"] != "java" { + t.Errorf("module %q language = %v", dir, m.Props["language"]) + } + } +} + +func names(ff []facts.Fact) []string { + var out []string + for _, f := range ff { + out = append(out, f.Kind+":"+f.Name) + } + return out +} diff --git a/internal/extractors/javaextractor/java_test.go b/internal/extractors/javaextractor/java_test.go new file mode 100644 index 0000000..c312634 --- /dev/null +++ b/internal/extractors/javaextractor/java_test.go @@ -0,0 +1,48 @@ +package javaextractor + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDetect(t *testing.T) { + tests := []struct { + name string + setup map[string]string + want bool + }{ + {"maven pom", map[string]string{"pom.xml": ""}, true}, + {"gradle java", map[string]string{"build.gradle": "plugins {}", "src/main/java/A.java": "class A {}"}, true}, + {"bare java source", map[string]string{"src/main/java/A.java": "class A {}"}, true}, + {"gradle kotlin only", map[string]string{"build.gradle.kts": "plugins {}", "app/src/main/java/A.kt": "class A"}, false}, + {"no java", map[string]string{"main.go": "package main"}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + for rel, content := range tt.setup { + abs := filepath.Join(dir, rel) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(abs, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + got, err := New().Detect(dir) + if err != nil { + t.Fatalf("Detect: %v", err) + } + if got != tt.want { + t.Errorf("Detect = %v, want %v", got, tt.want) + } + }) + } +} + +func TestName(t *testing.T) { + if got := New().Name(); got != "java" { + t.Errorf("Name = %q, want java", got) + } +} diff --git a/internal/extractors/javaextractor/spring.go b/internal/extractors/javaextractor/spring.go new file mode 100644 index 0000000..aa62c88 --- /dev/null +++ b/internal/extractors/javaextractor/spring.go @@ -0,0 +1,342 @@ +package javaextractor + +import ( + "strings" + + "github.com/enola-labs/enola/internal/facts" + sitter "github.com/tree-sitter/go-tree-sitter" +) + +// javaAnnotation is a parsed annotation with its simple name and (string-valued) +// arguments. Non-string argument values (e.g. RequestMethod.GET) are kept as their +// trailing identifier. +type javaAnnotation struct { + name string // simple name, e.g. "RequestMapping" + positional []string // positional argument values, e.g. @GetMapping("/x") + named map[string]string // key=value arguments, e.g. value="/x", method="GET" +} + +// parseAnnotations extracts the annotations from a `modifiers` node. +func parseAnnotations(modifiers *sitter.Node, src []byte) []javaAnnotation { + if modifiers == nil { + return nil + } + var out []javaAnnotation + for i := uint(0); i < uint(modifiers.ChildCount()); i++ { + c := modifiers.Child(i) + switch c.Kind() { + case "marker_annotation": + if n := annotationSimpleName(c, src); n != "" { + out = append(out, javaAnnotation{name: n}) + } + case "annotation": + ann := javaAnnotation{name: annotationSimpleName(c, src), named: map[string]string{}} + if args := findChildByKind(c, "annotation_argument_list"); args != nil { + parseAnnotationArgs(args, src, &ann) + } + if ann.name != "" { + out = append(out, ann) + } + } + } + return out +} + +func annotationSimpleName(node *sitter.Node, src []byte) string { + nameNode := node.ChildByFieldName("name") + if nameNode == nil { + return "" + } + return lastTypeComponent(nodeText(nameNode, src)) +} + +func parseAnnotationArgs(args *sitter.Node, src []byte, ann *javaAnnotation) { + for i := uint(0); i < uint(args.ChildCount()); i++ { + c := args.Child(i) + switch c.Kind() { + case "element_value_pair": + key := c.ChildByFieldName("key") + val := c.ChildByFieldName("value") + if key != nil && val != nil { + ann.named[nodeText(key, src)] = annotationValue(val, src) + } + case "string_literal", "field_access", "identifier", "element_value_array_initializer", "scoped_identifier": + ann.positional = append(ann.positional, annotationValue(c, src)) + } + } +} + +// annotationValue renders an annotation argument value as a string: string literals +// are unquoted; everything else keeps its trailing identifier (so RequestMethod.GET +// becomes "GET"). +func annotationValue(node *sitter.Node, src []byte) string { + switch node.Kind() { + case "string_literal": + return strings.Trim(nodeText(node, src), `"`) + case "field_access", "scoped_identifier": + return lastTypeComponent(nodeText(node, src)) + case "element_value_array_initializer", "array_initializer": + // Join the trailing identifiers of each element, e.g. {GET, POST}. + var parts []string + for i := uint(0); i < uint(node.ChildCount()); i++ { + c := node.Child(i) + if c.IsNamed() { + parts = append(parts, annotationValue(c, src)) + } + } + return strings.Join(parts, ",") + default: + return strings.TrimSpace(nodeText(node, src)) + } +} + +func hasAnnotation(anns []javaAnnotation, names ...string) bool { + for _, a := range anns { + for _, n := range names { + if a.name == n { + return true + } + } + } + return false +} + +func findAnnotation(anns []javaAnnotation, name string) *javaAnnotation { + for i := range anns { + if anns[i].name == name { + return &anns[i] + } + } + return nil +} + +// --- component classification --- + +// classifyComponent tags a type symbol fact with framework/component props for +// Spring stereotypes and Dubbo SPI. It mutates f.Props in place. +func classifyComponent(f *facts.Fact, name string, annotations []javaAnnotation, supertypes []string) { + switch { + case hasAnnotation(annotations, "RestController"): + f.Props["framework"] = "spring" + f.Props["component"] = "controller" + case hasAnnotation(annotations, "Controller"): + f.Props["framework"] = "spring" + f.Props["component"] = "controller" + case hasAnnotation(annotations, "Service"): + f.Props["framework"] = "spring" + f.Props["component"] = "service" + case hasAnnotation(annotations, "Repository"): + f.Props["framework"] = "spring" + f.Props["component"] = "repository" + case hasAnnotation(annotations, "Configuration"): + f.Props["framework"] = "spring" + f.Props["component"] = "configuration" + case hasAnnotation(annotations, "Component"): + f.Props["framework"] = "spring" + f.Props["component"] = "component" + } + + // Dubbo SPI extension mechanism. + if hasAnnotation(annotations, "SPI") { + f.Props["framework"] = "dubbo" + f.Props["dubbo_spi"] = true + } + if hasAnnotation(annotations, "Activate") { + f.Props["dubbo_activate"] = true + if f.Props["framework"] == nil { + f.Props["framework"] = "dubbo" + } + } + if hasAnnotation(annotations, "DubboService") { + f.Props["framework"] = "dubbo" + f.Props["component"] = "service" + } + + // Spring Data repository interface (extends JpaRepository/CrudRepository/...). + if isSpringDataRepository(supertypes) { + f.Props["framework"] = "spring" + f.Props["component"] = "repository" + } +} + +func isSpringDataRepository(supertypes []string) bool { + for _, s := range supertypes { + switch s { + case "JpaRepository", "CrudRepository", "PagingAndSortingRepository", + "ReactiveCrudRepository", "MongoRepository", "Repository": + return true + } + } + return false +} + +// --- JPA / storage --- + +// detectJpaStorage emits a KindStorage fact for JPA entities, Spring @Repository +// classes, and Spring Data repository interfaces. +func detectJpaStorage(name string, annotations []javaAnnotation, relFile string, line int, dir string) *facts.Fact { + var storageKind, framework string + switch { + case hasAnnotation(annotations, "Entity"): + storageKind, framework = "entity", "jpa" + case hasAnnotation(annotations, "Repository"): + storageKind, framework = "repository", "spring-data" + default: + return nil + } + + f := &facts.Fact{ + Kind: facts.KindStorage, + Name: dir + "." + name, + File: relFile, + Line: line, + Props: map[string]any{ + "storage_kind": storageKind, + "language": "java", + "framework": framework, + }, + Relations: []facts.Relation{ + {Kind: facts.RelDeclares, Target: dir}, + }, + } + // @Table(name="...") or @Entity(name="...") → table name. + if t := findAnnotation(annotations, "Table"); t != nil { + if tn := t.named["name"]; tn != "" { + f.Props["table"] = tn + } else if len(t.positional) > 0 { + f.Props["table"] = t.positional[0] + } + } + return f +} + +// --- Spring MVC routes --- + +func isSpringController(annotations []javaAnnotation) bool { + return hasAnnotation(annotations, "RestController", "Controller") +} + +// requestMappingPath returns the base path declared by a class-level @RequestMapping +// (its value/path argument), or "" when absent. +func requestMappingPath(annotations []javaAnnotation) string { + rm := findAnnotation(annotations, "RequestMapping") + if rm == nil { + return "" + } + return mappingPath(rm) +} + +// mappingMethods maps a method-level mapping annotation to its HTTP verb(s). +var mappingMethods = map[string]string{ + "GetMapping": "GET", + "PostMapping": "POST", + "PutMapping": "PUT", + "DeleteMapping": "DELETE", + "PatchMapping": "PATCH", +} + +// springRouteFacts emits a KindRoute fact for each HTTP method a controller method +// handles, combining the class base path with the method-level mapping path. +func springRouteFacts(basePath string, methodAnns []javaAnnotation, relFile string, line int, dir, handler string) []facts.Fact { + var out []facts.Fact + for _, a := range methodAnns { + var methods []string + var sub string + if verb, ok := mappingMethods[a.name]; ok { + methods = []string{verb} + sub = mappingPath(&a) + } else if a.name == "RequestMapping" { + methods = requestMappingVerbs(&a) + sub = mappingPath(&a) + } else { + continue + } + full := joinRoutePath(basePath, sub) + for _, m := range methods { + out = append(out, facts.Fact{ + Kind: facts.KindRoute, + Name: full, + File: relFile, + Line: line, + Props: map[string]any{ + "method": m, + "framework": "spring", + "language": "java", + "handler": handler, + }, + Relations: []facts.Relation{ + {Kind: facts.RelDeclares, Target: dir}, + }, + }) + } + } + return out +} + +// mappingPath extracts the path from a mapping annotation: a positional value, +// value=, or path= argument. +func mappingPath(a *javaAnnotation) string { + if len(a.positional) > 0 { + return firstPath(a.positional[0]) + } + if v := a.named["value"]; v != "" { + return firstPath(v) + } + if v := a.named["path"]; v != "" { + return firstPath(v) + } + return "" +} + +// firstPath returns the first entry of a possibly comma-joined path list. +func firstPath(s string) string { + if i := strings.IndexByte(s, ','); i >= 0 { + return s[:i] + } + return s +} + +// requestMappingVerbs returns the HTTP methods named by a @RequestMapping's +// method= argument, defaulting to "ALL" when unspecified. +func requestMappingVerbs(a *javaAnnotation) []string { + m := a.named["method"] + if m == "" { + return []string{"ALL"} + } + var out []string + for _, part := range strings.Split(m, ",") { + if v := strings.TrimSpace(part); v != "" { + out = append(out, v) + } + } + if len(out) == 0 { + return []string{"ALL"} + } + return out +} + +func joinRoutePath(base, sub string) string { + base = strings.TrimSpace(base) + sub = strings.TrimSpace(sub) + switch { + case base == "": + if sub == "" { + return "/" + } + return ensureLeadingSlash(sub) + case sub == "": + return ensureLeadingSlash(base) + default: + return ensureLeadingSlash(strings.TrimSuffix(base, "/")) + ensureLeadingSlash(sub) + } +} + +func ensureLeadingSlash(s string) string { + if s == "" { + return "" + } + if strings.HasPrefix(s, "/") { + return s + } + return "/" + s +} diff --git a/internal/extractors/javaextractor/spring_test.go b/internal/extractors/javaextractor/spring_test.go new file mode 100644 index 0000000..fe03580 --- /dev/null +++ b/internal/extractors/javaextractor/spring_test.go @@ -0,0 +1,259 @@ +package javaextractor + +import ( + "testing" + + "github.com/enola-labs/enola/internal/facts" +) + +func TestSpring_Routes(t *testing.T) { + ff := extractAll(t, map[string]string{ + "web/EdqsController.java": `package web; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/edqs") +public class EdqsController { + + @GetMapping("/ready") + public void isReady() {} + + @PostMapping("/reset") + public void reset() {} +} +`, + }) + + routes := factsByKind(ff, facts.KindRoute) + if len(routes) != 2 { + t.Fatalf("want 2 routes, got %d: %v", len(routes), names(ff)) + } + + want := map[string]string{ + "/api/edqs/ready": "GET", + "/api/edqs/reset": "POST", + } + for _, r := range routes { + method, ok := want[r.Name] + if !ok { + t.Errorf("unexpected route %q", r.Name) + continue + } + if r.Props["method"] != method { + t.Errorf("route %q method = %v, want %v", r.Name, r.Props["method"], method) + } + if r.Props["framework"] != "spring" { + t.Errorf("route %q framework = %v", r.Name, r.Props["framework"]) + } + if r.Props["handler"] == "" { + t.Errorf("route %q missing handler", r.Name) + } + } +} + +func TestSpring_RequestMappingWithMethod(t *testing.T) { + ff := extractAll(t, map[string]string{ + "web/UserController.java": `package web; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class UserController { + + @RequestMapping(value = "/users", method = RequestMethod.GET) + public void list() {} +} +`, + }) + r, ok := findFactKind(ff, facts.KindRoute, "/users") + if !ok { + t.Fatalf("missing /users route; got %v", names(ff)) + } + if r.Props["method"] != "GET" { + t.Errorf("method = %v, want GET", r.Props["method"]) + } +} + +func TestSpring_Components(t *testing.T) { + ff := extractAll(t, map[string]string{ + "svc/UserService.java": `package svc; + +import org.springframework.stereotype.Service; + +@Service +public class UserService {} +`, + }) + s, _ := findFact(ff, "svc.UserService") + if s.Props["framework"] != "spring" || s.Props["component"] != "service" { + t.Errorf("UserService component props = %+v", s.Props) + } +} + +func TestSpring_JpaEntity(t *testing.T) { + ff := extractAll(t, map[string]string{ + "model/User.java": `package model; + +import javax.persistence.Entity; +import javax.persistence.Table; + +@Entity +@Table(name = "users") +public class User {} +`, + }) + st, ok := findFactKind(ff, facts.KindStorage, "model.User") + if !ok { + t.Fatalf("missing storage fact for @Entity; got %v", names(ff)) + } + if st.Props["storage_kind"] != "entity" { + t.Errorf("storage_kind = %v, want entity", st.Props["storage_kind"]) + } + if st.Props["table"] != "users" { + t.Errorf("table = %v, want users", st.Props["table"]) + } +} + +func TestSpring_JpaTableConstantResolved(t *testing.T) { + ff := extractAll(t, map[string]string{ + "model/ModelConstants.java": `package model; + +public class ModelConstants { + public static final String ADMIN_SETTINGS_TABLE_NAME = "admin_settings"; +} +`, + "model/AdminSettingsEntity.java": `package model; + +import javax.persistence.Entity; +import javax.persistence.Table; +import static model.ModelConstants.ADMIN_SETTINGS_TABLE_NAME; + +@Entity +@Table(name = ADMIN_SETTINGS_TABLE_NAME) +public class AdminSettingsEntity {} +`, + }) + st, ok := findFactKind(ff, facts.KindStorage, "model.AdminSettingsEntity") + if !ok { + t.Fatalf("missing storage fact; got %v", names(ff)) + } + if st.Props["table"] != "admin_settings" { + t.Errorf("table = %v, want admin_settings (resolved from constant)", st.Props["table"]) + } + if st.Props["table_constant"] != "ADMIN_SETTINGS_TABLE_NAME" { + t.Errorf("table_constant = %v, want ADMIN_SETTINGS_TABLE_NAME", st.Props["table_constant"]) + } +} + +func TestSpring_DataRepository(t *testing.T) { + ff := extractAll(t, map[string]string{ + "repo/UserRepository.java": `package repo; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository {} +`, + }) + s, _ := findFact(ff, "repo.UserRepository") + if s.Props["component"] != "repository" { + t.Errorf("UserRepository component = %v, want repository", s.Props["component"]) + } +} + +func TestSpring_ConstructorInjection(t *testing.T) { + ff := extractAll(t, map[string]string{ + "svc/Repo.java": "package svc;\npublic class Repo {}\n", + "svc/Handler.java": `package svc; + +import org.springframework.stereotype.Service; + +@Service +public class Handler { + private final Repo repo; + + public Handler(Repo repo) { + this.repo = repo; + } +} +`, + }) + h, _ := findFact(ff, "svc.Handler") + if !hasRelation(h, facts.RelInjects, "svc.Repo") { + t.Errorf("Handler should inject svc.Repo; got %+v", h.Relations) + } +} + +func TestSpring_FieldInjection(t *testing.T) { + ff := extractAll(t, map[string]string{ + "svc/Dep.java": "package svc;\npublic class Dep {}\n", + "svc/Svc.java": `package svc; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class Svc { + @Autowired + private Dep dep; +} +`, + }) + s, _ := findFact(ff, "svc.Svc") + if !hasRelation(s, facts.RelInjects, "svc.Dep") { + t.Errorf("Svc should inject svc.Dep via @Autowired field; got %+v", s.Relations) + } +} + +func TestSpring_LombokRequiredArgsConstructor(t *testing.T) { + ff := extractAll(t, map[string]string{ + "svc/Store.java": "package svc;\npublic class Store {}\n", + "svc/Worker.java": `package svc; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class Worker { + private final Store store; +} +`, + }) + w, _ := findFact(ff, "svc.Worker") + if !hasRelation(w, facts.RelInjects, "svc.Store") { + t.Errorf("Worker should inject svc.Store via @RequiredArgsConstructor; got %+v", w.Relations) + } +} + +func TestDubbo_SPI(t *testing.T) { + ff := extractAll(t, map[string]string{ + "ext/Protocol.java": `package ext; + +import org.apache.dubbo.common.extension.SPI; + +@SPI +public interface Protocol {} +`, + "ext/DubboProtocol.java": `package ext; + +import org.apache.dubbo.common.extension.Activate; + +@Activate +public class DubboProtocol implements Protocol {} +`, + }) + spi, _ := findFact(ff, "ext.Protocol") + if spi.Props["framework"] != "dubbo" || spi.Props["dubbo_spi"] != true { + t.Errorf("Protocol dubbo props = %+v", spi.Props) + } + act, _ := findFact(ff, "ext.DubboProtocol") + if act.Props["dubbo_activate"] != true { + t.Errorf("DubboProtocol should be marked dubbo_activate; got %+v", act.Props) + } +} diff --git a/mcp-arch.yaml b/mcp-arch.yaml index 1331822..c260d47 100644 --- a/mcp-arch.yaml +++ b/mcp-arch.yaml @@ -68,6 +68,7 @@ ignore: extractors: - cpp - go + - java - kotlin - openapi - python diff --git a/pkg/bootstrap/bootstrap.go b/pkg/bootstrap/bootstrap.go index 048f778..ff2f4dc 100644 --- a/pkg/bootstrap/bootstrap.go +++ b/pkg/bootstrap/bootstrap.go @@ -15,6 +15,7 @@ import ( "github.com/enola-labs/enola/internal/explainers/layers" "github.com/enola-labs/enola/internal/extractors/cppextractor" "github.com/enola-labs/enola/internal/extractors/goextractor" + "github.com/enola-labs/enola/internal/extractors/javaextractor" "github.com/enola-labs/enola/internal/extractors/kotlinextractor" "github.com/enola-labs/enola/internal/extractors/openapiextractor" "github.com/enola-labs/enola/internal/extractors/pythonextractor" @@ -155,6 +156,7 @@ func NewEngine(opts Options) (*Engine, *config.Config, error) { // Register all OSS extractors eng.RegisterExtractor(cppextractor.New()) eng.RegisterExtractor(goextractor.New()) + eng.RegisterExtractor(javaextractor.New()) eng.RegisterExtractor(kotlinextractor.New()) eng.RegisterExtractor(openapiextractor.New()) eng.RegisterExtractor(pythonextractor.New())