diff --git a/lang/collect/collect.go b/lang/collect/collect.go
index 49a4d265..dbd4bcfe 100644
--- a/lang/collect/collect.go
+++ b/lang/collect/collect.go
@@ -67,6 +67,11 @@ type Collector struct {
// symbol => [deps]
deps map[*DocumentSymbol][]dependency
+ // type symbol => list of interfaces it implements.
+ // Populated alongside c.deps but kept separately so Export can emit
+ // Type.Implements distinct from the generic SubStruct dependency list.
+ implementsRel map[*DocumentSymbol][]dependency
+
// variable (or const) => type
vars map[*DocumentSymbol]dependency
@@ -101,6 +106,19 @@ func (c *Collector) UseJavaIPC(conv *javaipc.Converter) {
c.javaIPC = conv
}
+// addImplementsRel records that `from` implements `iface`. Idempotent on (from, iface).
+func (c *Collector) addImplementsRel(from *DocumentSymbol, iface *DocumentSymbol, tokenLoc Location) {
+ if from == nil || iface == nil {
+ return
+ }
+ for _, existing := range c.implementsRel[from] {
+ if existing.Symbol == iface {
+ return
+ }
+ }
+ c.implementsRel[from] = append(c.implementsRel[from], dependency{Location: tokenLoc, Symbol: iface})
+}
+
type methodInfo struct {
Receiver dependency `json:"receiver"`
Interface *dependency `json:"implement,omitempty"` // which interface it implements
@@ -143,6 +161,7 @@ func NewCollector(repo string, cli *LSPClient) *Collector {
syms: map[Location]*DocumentSymbol{},
funcs: map[*DocumentSymbol]functionInfo{},
deps: map[*DocumentSymbol][]dependency{},
+ implementsRel: map[*DocumentSymbol][]dependency{},
vars: map[*DocumentSymbol]dependency{},
files: map[string]*uniast.File{},
fileContentCache: make(map[string]string),
@@ -760,6 +779,7 @@ func (c *Collector) ScannerByJavaIPC(ctx context.Context) ([]*DocumentSymbol, er
}
tokLoc := locFromPos(fileAbs, impl.StartLine, impl.StartColumn, impl.EndLine, impl.EndColumn)
addDep(classSym, depSym, tokLoc)
+ c.addImplementsRel(classSym, depSym, tokLoc)
}
} else {
for _, impl := range ci.ImplementsTypes {
@@ -777,6 +797,7 @@ func (c *Collector) ScannerByJavaIPC(ctx context.Context) ([]*DocumentSymbol, er
depSym.Kind = SKInterface
}
addDep(classSym, depSym, classSym.Location)
+ c.addImplementsRel(classSym, depSym, classSym.Location)
}
}
@@ -1587,6 +1608,7 @@ func (c *Collector) walk(node *sitter.Node, uri DocumentURI, content []byte, fil
impl.Kind = SKInterface
impl.Role = REFERENCE
c.addReferenceDeps(sym, impl)
+ c.addImplementsRel(sym, impl, impl.Location)
}
}
}
diff --git a/lang/collect/export.go b/lang/collect/export.go
index 2f63f9af..f7e7d560 100644
--- a/lang/collect/export.go
+++ b/lang/collect/export.go
@@ -573,10 +573,29 @@ func (c *Collector) exportSymbol(repo *uniast.Repository, symbol *DocumentSymbol
TypeKind: mapKind(k),
Exported: public,
}
- // collect deps
+ // Implements relationship is preserved as a first-class field rather
+ // than blended into the generic SubStruct dependency list.
+ implSyms := map[*DocumentSymbol]bool{}
+ if rels := c.implementsRel[symbol]; rels != nil {
+ for _, rel := range rels {
+ tok := ""
+ if c.cli != nil {
+ tok, _ = c.cli.Locate(rel.Location)
+ }
+ iid, err := c.exportSymbol(repo, rel.Symbol, tok, visited)
+ if err != nil {
+ continue
+ }
+ obj.Implements = append(obj.Implements, *iid)
+ implSyms[rel.Symbol] = true
+ }
+ }
// collect deps
if deps := c.deps[symbol]; deps != nil {
for _, dep := range deps {
+ if implSyms[dep.Symbol] {
+ continue
+ }
tok := ""
if c.cli != nil {
tok, _ = c.cli.Locate(dep.Location)
diff --git a/lang/collect/java_interface_test.go b/lang/collect/java_interface_test.go
new file mode 100644
index 00000000..ad63ffbc
--- /dev/null
+++ b/lang/collect/java_interface_test.go
@@ -0,0 +1,194 @@
+// Copyright 2025 CloudWeGo Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package collect
+
+import (
+ "context"
+ "path/filepath"
+ "runtime"
+ "testing"
+
+ javaipc "github.com/cloudwego/abcoder/lang/java/ipc"
+ javapb "github.com/cloudwego/abcoder/lang/java/pb"
+ "github.com/cloudwego/abcoder/lang/lsp"
+ "github.com/cloudwego/abcoder/lang/uniast"
+)
+
+// TestJavaIPC_InterfaceKindAndImplements drives the Java parser → universal AST
+// pipeline for the two regressions:
+// 1. Interface declarations must be exported with TypeKind == "interface".
+// 2. A class that "implements I" must have I in Type.Implements (and not be
+// a plain SubStruct dependency).
+//
+// We hand-build the javaipc.Converter so the test does not require the real
+// java parser binary; it still exercises ScannerByJavaIPC + Export end-to-end
+// against the real fixture source files under testdata/java/5_interface_impl.
+func TestJavaIPC_InterfaceKindAndImplements(t *testing.T) {
+ repo := fixtureRepo(t)
+ conv := buildInterfaceFixtureConverter(repo)
+
+ cli := &lsp.LSPClient{ClientOptions: lsp.ClientOptions{Language: uniast.Java}}
+ c := NewCollector(repo, cli)
+ c.Language = uniast.Java
+ c.NeedStdSymbol = true
+ c.UseJavaIPC(conv)
+
+ if _, err := c.ScannerByJavaIPC(context.Background()); err != nil {
+ t.Fatalf("ScannerByJavaIPC failed: %v", err)
+ }
+ rep, err := c.Export(context.Background())
+ if err != nil {
+ t.Fatalf("Export failed: %v", err)
+ }
+
+ types := collectExportedTypes(rep)
+
+ animal, ok := types["Animal"]
+ if !ok {
+ t.Fatalf("Animal type not exported; got types: %v", typeNames(types))
+ }
+ if animal.TypeKind != uniast.TypeKindInterface {
+ t.Errorf("Animal.TypeKind = %q, want %q", animal.TypeKind, uniast.TypeKindInterface)
+ }
+
+ swimmer, ok := types["Swimmer"]
+ if !ok {
+ t.Fatalf("Swimmer type not exported; got types: %v", typeNames(types))
+ }
+ if swimmer.TypeKind != uniast.TypeKindInterface {
+ t.Errorf("Swimmer.TypeKind = %q, want %q", swimmer.TypeKind, uniast.TypeKindInterface)
+ }
+
+ dog, ok := types["Dog"]
+ if !ok {
+ t.Fatalf("Dog type not exported; got types: %v", typeNames(types))
+ }
+ if dog.TypeKind != uniast.TypeKindStruct {
+ t.Errorf("Dog.TypeKind = %q, want %q", dog.TypeKind, uniast.TypeKindStruct)
+ }
+ if !containsIdentityName(dog.Implements, "Animal") {
+ t.Errorf("Dog.Implements does not contain Animal; got %v", identityNames(dog.Implements))
+ }
+ if containsDependencyName(dog.SubStruct, "Animal") {
+ t.Errorf("Dog.SubStruct should not duplicate the Animal implements relation; got %v",
+ dependencyNames(dog.SubStruct))
+ }
+
+ fish, ok := types["Fish"]
+ if !ok {
+ t.Fatalf("Fish type not exported; got types: %v", typeNames(types))
+ }
+ if !containsIdentityName(fish.Implements, "Animal") {
+ t.Errorf("Fish.Implements missing Animal; got %v", identityNames(fish.Implements))
+ }
+ if !containsIdentityName(fish.Implements, "Swimmer") {
+ t.Errorf("Fish.Implements missing Swimmer; got %v", identityNames(fish.Implements))
+ }
+}
+
+// fixtureRepo returns the absolute path to testdata/java/5_interface_impl.
+func fixtureRepo(t *testing.T) string {
+ t.Helper()
+ _, thisFile, _, ok := runtime.Caller(0)
+ if !ok {
+ t.Fatalf("runtime.Caller failed")
+ }
+ return filepath.Join(filepath.Dir(thisFile), "..", "..", "testdata", "java", "5_interface_impl")
+}
+
+func buildInterfaceFixtureConverter(repo string) *javaipc.Converter {
+ conv := javaipc.NewConverter(repo, "test-mod")
+
+ srcDir := filepath.Join(repo, "src", "main", "java", "org", "example")
+ mk := func(fqcn, fname string, kind javapb.ClassType, startLine, endLine int32, implements []string) *javapb.ClassInfo {
+ return &javapb.ClassInfo{
+ ClassName: fqcn,
+ PackageName: "org.example",
+ FilePath: filepath.Join(srcDir, fname),
+ ClassType: kind,
+ ImplementsTypes: implements,
+ StartLine: startLine,
+ StartColumn: 1,
+ EndLine: endLine,
+ EndColumn: 2,
+ Source: &javapb.SourceInfo{Type: javapb.SourceType_SOURCE_TYPE_LOCAL},
+ }
+ }
+ conv.LocalClassCache["org.example.Animal"] =
+ mk("org.example.Animal", "Animal.java", javapb.ClassType_CLASS_TYPE_INTERFACE, 3, 6, nil)
+ conv.LocalClassCache["org.example.Swimmer"] =
+ mk("org.example.Swimmer", "Swimmer.java", javapb.ClassType_CLASS_TYPE_INTERFACE, 3, 5, nil)
+ conv.LocalClassCache["org.example.Dog"] =
+ mk("org.example.Dog", "Dog.java", javapb.ClassType_CLASS_TYPE_CLASS, 3, 19,
+ []string{"org.example.Animal"})
+ conv.LocalClassCache["org.example.Fish"] =
+ mk("org.example.Fish", "Fish.java", javapb.ClassType_CLASS_TYPE_CLASS, 3, 23,
+ []string{"org.example.Animal", "org.example.Swimmer"})
+ return conv
+}
+
+func collectExportedTypes(rep *uniast.Repository) map[string]*uniast.Type {
+ out := map[string]*uniast.Type{}
+ for _, mod := range rep.Modules {
+ for _, pkg := range mod.Packages {
+ for _, ty := range pkg.Types {
+ out[ty.Identity.Name] = ty
+ }
+ }
+ }
+ return out
+}
+
+func typeNames(m map[string]*uniast.Type) []string {
+ out := make([]string, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ return out
+}
+
+func containsIdentityName(ids []uniast.Identity, name string) bool {
+ for _, id := range ids {
+ if id.Name == name {
+ return true
+ }
+ }
+ return false
+}
+
+func identityNames(ids []uniast.Identity) []string {
+ out := make([]string, 0, len(ids))
+ for _, id := range ids {
+ out = append(out, id.Name)
+ }
+ return out
+}
+
+func containsDependencyName(deps []uniast.Dependency, name string) bool {
+ for _, d := range deps {
+ if d.Identity.Name == name {
+ return true
+ }
+ }
+ return false
+}
+
+func dependencyNames(deps []uniast.Dependency) []string {
+ out := make([]string, 0, len(deps))
+ for _, d := range deps {
+ out = append(out, d.Identity.Name)
+ }
+ return out
+}
diff --git a/lang/java/ipc/abcoder-java-analyzer-1.0-SNAPSHOT.jar b/lang/java/ipc/abcoder-java-analyzer-1.0-SNAPSHOT.jar
index 49978268..dca3520c 100644
Binary files a/lang/java/ipc/abcoder-java-analyzer-1.0-SNAPSHOT.jar and b/lang/java/ipc/abcoder-java-analyzer-1.0-SNAPSHOT.jar differ
diff --git a/testdata/java/0_simple/pom.xml b/testdata/java/0_simple/pom.xml
new file mode 100644
index 00000000..fc66a296
--- /dev/null
+++ b/testdata/java/0_simple/pom.xml
@@ -0,0 +1,15 @@
+
+
+ 4.0.0
+
+ simple
+ simple
+ 1.0.0
+ jar
+
+
+ .
+
+
diff --git a/testdata/java/1_advanced/pom.xml b/testdata/java/1_advanced/pom.xml
new file mode 100644
index 00000000..42115658
--- /dev/null
+++ b/testdata/java/1_advanced/pom.xml
@@ -0,0 +1,11 @@
+
+
+ 4.0.0
+
+ org.example
+ advanced
+ 1.0.0
+ jar
+
diff --git a/testdata/java/2_inheritance/pom.xml b/testdata/java/2_inheritance/pom.xml
new file mode 100644
index 00000000..26fa691f
--- /dev/null
+++ b/testdata/java/2_inheritance/pom.xml
@@ -0,0 +1,11 @@
+
+
+ 4.0.0
+
+ org.example
+ inheritance
+ 1.0.0
+ jar
+
diff --git a/testdata/java/5_interface_impl/pom.xml b/testdata/java/5_interface_impl/pom.xml
new file mode 100644
index 00000000..496e8596
--- /dev/null
+++ b/testdata/java/5_interface_impl/pom.xml
@@ -0,0 +1,11 @@
+
+
+ 4.0.0
+
+ org.example
+ interface-impl
+ 1.0.0
+ jar
+
diff --git a/testdata/java/5_interface_impl/src/main/java/org/example/Animal.java b/testdata/java/5_interface_impl/src/main/java/org/example/Animal.java
new file mode 100644
index 00000000..4c745a94
--- /dev/null
+++ b/testdata/java/5_interface_impl/src/main/java/org/example/Animal.java
@@ -0,0 +1,6 @@
+package org.example;
+
+public interface Animal {
+ void eat();
+ String name();
+}
diff --git a/testdata/java/5_interface_impl/src/main/java/org/example/Dog.java b/testdata/java/5_interface_impl/src/main/java/org/example/Dog.java
new file mode 100644
index 00000000..88a7861b
--- /dev/null
+++ b/testdata/java/5_interface_impl/src/main/java/org/example/Dog.java
@@ -0,0 +1,19 @@
+package org.example;
+
+public class Dog implements Animal {
+ private final String n;
+
+ public Dog(String n) {
+ this.n = n;
+ }
+
+ @Override
+ public void eat() {
+ System.out.println(n + " eats.");
+ }
+
+ @Override
+ public String name() {
+ return n;
+ }
+}
diff --git a/testdata/java/5_interface_impl/src/main/java/org/example/Fish.java b/testdata/java/5_interface_impl/src/main/java/org/example/Fish.java
new file mode 100644
index 00000000..b9939a6a
--- /dev/null
+++ b/testdata/java/5_interface_impl/src/main/java/org/example/Fish.java
@@ -0,0 +1,24 @@
+package org.example;
+
+public class Fish implements Animal, Swimmer {
+ private final String n;
+
+ public Fish(String n) {
+ this.n = n;
+ }
+
+ @Override
+ public void eat() {
+ System.out.println(n + " eats.");
+ }
+
+ @Override
+ public void swim() {
+ System.out.println(n + " swims.");
+ }
+
+ @Override
+ public String name() {
+ return n;
+ }
+}
diff --git a/testdata/java/5_interface_impl/src/main/java/org/example/Swimmer.java b/testdata/java/5_interface_impl/src/main/java/org/example/Swimmer.java
new file mode 100644
index 00000000..7978e1cd
--- /dev/null
+++ b/testdata/java/5_interface_impl/src/main/java/org/example/Swimmer.java
@@ -0,0 +1,5 @@
+package org.example;
+
+public interface Swimmer {
+ void swim();
+}