Skip to content

Commit f90ee7e

Browse files
authored
fix: improve Identity parsing compatibility for Java generic wildcards (#168)
1 parent db858ab commit f90ee7e

File tree

2 files changed

+201
-10
lines changed

2 files changed

+201
-10
lines changed

lang/uniast/ast.go

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -346,17 +346,35 @@ func NewIdentity(mod, pkg, name string) Identity {
346346
}
347347

348348
func NewIdentityFromString(str string) (ret Identity) {
349-
sp := strings.Split(str, "?")
350-
if len(sp) == 2 {
351-
ret.ModPath = sp[0]
352-
str = sp[1]
349+
// Identity format: ModPath?PkgPath#Name
350+
//
351+
// We parse LAST '#' AND FIRST '?' to isolate ModPath, PkgPath, and Name.
352+
// 1. Locate the LAST '#' to isolate the Name. This is crucial for Java where the Name
353+
// itself may contain '?' (e.g., generic wildcards like List<?>).
354+
// 2. Locate the FIRST '?' in the remaining part to separate ModPath and PkgPath.
355+
// Using the first '?' is more robust if PkgPath is a URL containing query parameters.
356+
357+
// Step 1: Separate PkgPath and Name using the last '#'
358+
hashIdx := strings.LastIndex(str, "#")
359+
if hashIdx != -1 {
360+
ret.Name = str[hashIdx+1:]
361+
str = str[:hashIdx]
362+
} else {
363+
// If no '#', the entire string is treated as the Name
364+
ret.Name = str
365+
return ret
353366
}
354-
sp = strings.Split(str, "#")
355-
if len(sp) == 2 {
356-
ret.PkgPath = sp[0]
357-
str = sp[1]
367+
368+
// Step 2: Separate ModPath and PkgPath using the first '?'
369+
questionIdx := strings.Index(str, "?")
370+
if questionIdx != -1 {
371+
ret.ModPath = str[:questionIdx]
372+
ret.PkgPath = str[questionIdx+1:]
373+
} else {
374+
// If no '?', the remaining part is the PkgPath
375+
ret.PkgPath = str
358376
}
359-
ret.Name = str
377+
360378
return ret
361379
}
362380

@@ -374,7 +392,14 @@ func (i Identity) CallName() string {
374392
}
375393

376394
func (i Identity) Full() string {
377-
return i.ModPath + "?" + i.PkgPath + "#" + i.Name
395+
builder := strings.Builder{}
396+
builder.Grow(len(i.ModPath) + len(i.PkgPath) + len(i.Name) + 2)
397+
builder.WriteString(i.ModPath)
398+
builder.WriteString("?")
399+
builder.WriteString(i.PkgPath)
400+
builder.WriteString("#")
401+
builder.WriteString(i.Name)
402+
return builder.String()
378403
}
379404

380405
// GetFunction the function identified by id.

lang/uniast/identity_test.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/**
2+
* Copyright 2025 ByteDance Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package uniast
18+
19+
import (
20+
"testing"
21+
)
22+
23+
func TestNewIdentityFromString(t *testing.T) {
24+
tests := []struct {
25+
name string
26+
input string
27+
expected Identity
28+
}{
29+
{
30+
name: "standard format",
31+
input: "mod?pkg#name",
32+
expected: Identity{ModPath: "mod", PkgPath: "pkg", Name: "name"},
33+
},
34+
{
35+
name: "name with question mark - Java wildcard",
36+
input: "mod?pkg#name<?>",
37+
expected: Identity{ModPath: "mod", PkgPath: "pkg", Name: "name<?>"},
38+
},
39+
{
40+
name: "name with multiple question marks",
41+
input: "mod?pkg#Map<?,?>",
42+
expected: Identity{ModPath: "mod", PkgPath: "pkg", Name: "Map<?,?>"},
43+
},
44+
{
45+
name: "no ModPath",
46+
input: "pkg#name",
47+
expected: Identity{ModPath: "", PkgPath: "pkg", Name: "name"},
48+
},
49+
{
50+
name: "no PkgPath and ModPath",
51+
input: "name",
52+
expected: Identity{ModPath: "", PkgPath: "", Name: "name"},
53+
},
54+
{
55+
name: "complex Java generic",
56+
input: "mod?pkg#Function<? super T, ? extends R>",
57+
expected: Identity{ModPath: "mod", PkgPath: "pkg", Name: "Function<? super T, ? extends R>"},
58+
},
59+
{
60+
name: "with version number",
61+
input: "mod@v1.0?pkg#name",
62+
expected: Identity{ModPath: "mod@v1.0", PkgPath: "pkg", Name: "name"},
63+
},
64+
{
65+
name: "Java method with generic parameters",
66+
input: "com.example@1.0?com.example.utils#process<?>",
67+
expected: Identity{ModPath: "com.example@1.0", PkgPath: "com.example.utils", Name: "process<?>"},
68+
},
69+
{
70+
name: "nested generics",
71+
input: "mod?pkg#List<Map<String, ?>>",
72+
expected: Identity{ModPath: "mod", PkgPath: "pkg", Name: "List<Map<String, ?>>"},
73+
},
74+
{
75+
name: "capture wildcard",
76+
input: "mod?pkg#capture of ?",
77+
expected: Identity{ModPath: "mod", PkgPath: "pkg", Name: "capture of ?"},
78+
},
79+
{
80+
name: "ModPath with empty PkgPath and Name",
81+
input: "mod?#",
82+
expected: Identity{ModPath: "mod", PkgPath: "", Name: ""},
83+
},
84+
{
85+
name: "only PkgPath separator",
86+
input: "pkg#",
87+
expected: Identity{ModPath: "", PkgPath: "pkg", Name: ""},
88+
},
89+
{
90+
name: "both separators but empty parts",
91+
input: "?#",
92+
expected: Identity{ModPath: "", PkgPath: "", Name: ""},
93+
},
94+
{
95+
name: "ModPath with version and question mark in name",
96+
input: "mod@v1.0?pkg#method<?>",
97+
expected: Identity{ModPath: "mod@v1.0", PkgPath: "pkg", Name: "method<?>"},
98+
},
99+
{
100+
name: "empty string",
101+
input: "",
102+
expected: Identity{ModPath: "", PkgPath: "", Name: ""},
103+
},
104+
{
105+
"java example 1",
106+
`com.bytedance.ea.travel:travel-web:1.0.0-SNAPSHOT?com.bytedance.ea.travel.web.controller#CommonInfoController.public Result<?> allCountries(@RequestParam(name = "language", required = false) String language)`,
107+
Identity{ModPath: "com.bytedance.ea.travel:travel-web:1.0.0-SNAPSHOT", PkgPath: "com.bytedance.ea.travel.web.controller", Name: "CommonInfoController.public Result<?> allCountries(@RequestParam(name = \"language\", required = false) String language)"},
108+
},
109+
}
110+
111+
for _, tt := range tests {
112+
t.Run(tt.name, func(t *testing.T) {
113+
result := NewIdentityFromString(tt.input)
114+
if result != tt.expected {
115+
t.Errorf("NewIdentityFromString(%q) = %+v, want %+v", tt.input, result, tt.expected)
116+
}
117+
})
118+
}
119+
}
120+
121+
func TestIdentity_Full(t *testing.T) {
122+
tests := []struct {
123+
name string
124+
identity Identity
125+
expected string
126+
}{
127+
{
128+
name: "all parts present",
129+
identity: Identity{ModPath: "mod", PkgPath: "pkg", Name: "name"},
130+
expected: "mod?pkg#name",
131+
},
132+
{
133+
name: "no ModPath",
134+
identity: Identity{ModPath: "", PkgPath: "pkg", Name: "name"},
135+
expected: "?pkg#name",
136+
},
137+
{
138+
name: "no PkgPath",
139+
identity: Identity{ModPath: "mod", PkgPath: "", Name: "name"},
140+
expected: "mod?#name",
141+
},
142+
{
143+
name: "only Name",
144+
identity: Identity{ModPath: "", PkgPath: "", Name: "name"},
145+
expected: "?#name",
146+
},
147+
{
148+
name: "all empty",
149+
identity: Identity{ModPath: "", PkgPath: "", Name: ""},
150+
expected: "?#",
151+
},
152+
}
153+
154+
for _, tt := range tests {
155+
t.Run(tt.name, func(t *testing.T) {
156+
if got := tt.identity.Full(); got != tt.expected {
157+
t.Errorf("Identity.Full() = %v, want %v", got, tt.expected)
158+
}
159+
// Round-trip test
160+
parsed := NewIdentityFromString(tt.expected)
161+
if parsed != tt.identity {
162+
t.Errorf("NewIdentityFromString(Full()) = %+v, want %+v", parsed, tt.identity)
163+
}
164+
})
165+
}
166+
}

0 commit comments

Comments
 (0)