From e6815294eac6a63ffb7ca9b594cf7448948c276d Mon Sep 17 00:00:00 2001 From: "wangzekun.zekin" Date: Thu, 12 Mar 2026 14:28:56 +0800 Subject: [PATCH] fix: improve Identity parsing compatibility for Java generic wildcards --- lang/uniast/ast.go | 45 +++++++--- lang/uniast/identity_test.go | 166 +++++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+), 10 deletions(-) create mode 100644 lang/uniast/identity_test.go diff --git a/lang/uniast/ast.go b/lang/uniast/ast.go index ce30036c..d62f5d1f 100644 --- a/lang/uniast/ast.go +++ b/lang/uniast/ast.go @@ -346,17 +346,35 @@ func NewIdentity(mod, pkg, name string) Identity { } func NewIdentityFromString(str string) (ret Identity) { - sp := strings.Split(str, "?") - if len(sp) == 2 { - ret.ModPath = sp[0] - str = sp[1] + // Identity format: ModPath?PkgPath#Name + // + // We parse LAST '#' AND FIRST '?' to isolate ModPath, PkgPath, and Name. + // 1. Locate the LAST '#' to isolate the Name. This is crucial for Java where the Name + // itself may contain '?' (e.g., generic wildcards like List). + // 2. Locate the FIRST '?' in the remaining part to separate ModPath and PkgPath. + // Using the first '?' is more robust if PkgPath is a URL containing query parameters. + + // Step 1: Separate PkgPath and Name using the last '#' + hashIdx := strings.LastIndex(str, "#") + if hashIdx != -1 { + ret.Name = str[hashIdx+1:] + str = str[:hashIdx] + } else { + // If no '#', the entire string is treated as the Name + ret.Name = str + return ret } - sp = strings.Split(str, "#") - if len(sp) == 2 { - ret.PkgPath = sp[0] - str = sp[1] + + // Step 2: Separate ModPath and PkgPath using the first '?' + questionIdx := strings.Index(str, "?") + if questionIdx != -1 { + ret.ModPath = str[:questionIdx] + ret.PkgPath = str[questionIdx+1:] + } else { + // If no '?', the remaining part is the PkgPath + ret.PkgPath = str } - ret.Name = str + return ret } @@ -374,7 +392,14 @@ func (i Identity) CallName() string { } func (i Identity) Full() string { - return i.ModPath + "?" + i.PkgPath + "#" + i.Name + builder := strings.Builder{} + builder.Grow(len(i.ModPath) + len(i.PkgPath) + len(i.Name) + 2) + builder.WriteString(i.ModPath) + builder.WriteString("?") + builder.WriteString(i.PkgPath) + builder.WriteString("#") + builder.WriteString(i.Name) + return builder.String() } // GetFunction the function identified by id. diff --git a/lang/uniast/identity_test.go b/lang/uniast/identity_test.go new file mode 100644 index 00000000..dc556514 --- /dev/null +++ b/lang/uniast/identity_test.go @@ -0,0 +1,166 @@ +/** + * Copyright 2025 ByteDance Inc. + * + * 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 uniast + +import ( + "testing" +) + +func TestNewIdentityFromString(t *testing.T) { + tests := []struct { + name string + input string + expected Identity + }{ + { + name: "standard format", + input: "mod?pkg#name", + expected: Identity{ModPath: "mod", PkgPath: "pkg", Name: "name"}, + }, + { + name: "name with question mark - Java wildcard", + input: "mod?pkg#name", + expected: Identity{ModPath: "mod", PkgPath: "pkg", Name: "name"}, + }, + { + name: "name with multiple question marks", + input: "mod?pkg#Map", + expected: Identity{ModPath: "mod", PkgPath: "pkg", Name: "Map"}, + }, + { + name: "no ModPath", + input: "pkg#name", + expected: Identity{ModPath: "", PkgPath: "pkg", Name: "name"}, + }, + { + name: "no PkgPath and ModPath", + input: "name", + expected: Identity{ModPath: "", PkgPath: "", Name: "name"}, + }, + { + name: "complex Java generic", + input: "mod?pkg#Function", + expected: Identity{ModPath: "mod", PkgPath: "pkg", Name: "Function"}, + }, + { + name: "with version number", + input: "mod@v1.0?pkg#name", + expected: Identity{ModPath: "mod@v1.0", PkgPath: "pkg", Name: "name"}, + }, + { + name: "Java method with generic parameters", + input: "com.example@1.0?com.example.utils#process", + expected: Identity{ModPath: "com.example@1.0", PkgPath: "com.example.utils", Name: "process"}, + }, + { + name: "nested generics", + input: "mod?pkg#List>", + expected: Identity{ModPath: "mod", PkgPath: "pkg", Name: "List>"}, + }, + { + name: "capture wildcard", + input: "mod?pkg#capture of ?", + expected: Identity{ModPath: "mod", PkgPath: "pkg", Name: "capture of ?"}, + }, + { + name: "ModPath with empty PkgPath and Name", + input: "mod?#", + expected: Identity{ModPath: "mod", PkgPath: "", Name: ""}, + }, + { + name: "only PkgPath separator", + input: "pkg#", + expected: Identity{ModPath: "", PkgPath: "pkg", Name: ""}, + }, + { + name: "both separators but empty parts", + input: "?#", + expected: Identity{ModPath: "", PkgPath: "", Name: ""}, + }, + { + name: "ModPath with version and question mark in name", + input: "mod@v1.0?pkg#method", + expected: Identity{ModPath: "mod@v1.0", PkgPath: "pkg", Name: "method"}, + }, + { + name: "empty string", + input: "", + expected: Identity{ModPath: "", PkgPath: "", Name: ""}, + }, + { + "java example 1", + `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)`, + 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)"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := NewIdentityFromString(tt.input) + if result != tt.expected { + t.Errorf("NewIdentityFromString(%q) = %+v, want %+v", tt.input, result, tt.expected) + } + }) + } +} + +func TestIdentity_Full(t *testing.T) { + tests := []struct { + name string + identity Identity + expected string + }{ + { + name: "all parts present", + identity: Identity{ModPath: "mod", PkgPath: "pkg", Name: "name"}, + expected: "mod?pkg#name", + }, + { + name: "no ModPath", + identity: Identity{ModPath: "", PkgPath: "pkg", Name: "name"}, + expected: "?pkg#name", + }, + { + name: "no PkgPath", + identity: Identity{ModPath: "mod", PkgPath: "", Name: "name"}, + expected: "mod?#name", + }, + { + name: "only Name", + identity: Identity{ModPath: "", PkgPath: "", Name: "name"}, + expected: "?#name", + }, + { + name: "all empty", + identity: Identity{ModPath: "", PkgPath: "", Name: ""}, + expected: "?#", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.identity.Full(); got != tt.expected { + t.Errorf("Identity.Full() = %v, want %v", got, tt.expected) + } + // Round-trip test + parsed := NewIdentityFromString(tt.expected) + if parsed != tt.identity { + t.Errorf("NewIdentityFromString(Full()) = %+v, want %+v", parsed, tt.identity) + } + }) + } +}