From 1cb96a3dd672b6b4a6e140a9d5c7fe9643d055ea Mon Sep 17 00:00:00 2001 From: Greg Nazario Date: Thu, 13 Mar 2025 17:18:18 -0400 Subject: [PATCH] [TypeTag] Add parsing for strings to TypeTag Similar to the TypeScript SDK, this ports over the logic that handles all of the different types into structured types. Resolves https://github.com/aptos-labs/aptos-go-sdk/issues/76 --- internal/types/account.go | 28 +++ typetag.go | 348 ++++++++++++++++++++++++++++++++++++-- typetag_test.go | 121 +++++++++++++ 3 files changed, 485 insertions(+), 12 deletions(-) diff --git a/internal/types/account.go b/internal/types/account.go index 7db9229..4e61f24 100644 --- a/internal/types/account.go +++ b/internal/types/account.go @@ -90,6 +90,9 @@ func (account *Account) AccountAddress() AccountAddress { return account.Address } +// ErrAddressMissing0x is returned when an AccountAddress is missing the leading 0x +var ErrAddressMissing0x = errors.New("AccountAddress missing 0x") + // ErrAddressTooShort is returned when an AccountAddress is too short var ErrAddressTooShort = errors.New("AccountAddress too short") @@ -120,3 +123,28 @@ func (aa *AccountAddress) ParseStringRelaxed(x string) error { return nil } + +// ParseStringWithPrefixRelaxed parses a string into an AccountAddress +func (aa *AccountAddress) ParseStringWithPrefixRelaxed(x string) error { + if !strings.HasPrefix(x, "0x") { + return ErrAddressTooShort + } + x = x[2:] + if len(x) < 1 { + return ErrAddressTooShort + } + if len(x) > 64 { + return ErrAddressTooLong + } + if len(x)%2 != 0 { + x = "0" + x + } + bytes, err := hex.DecodeString(x) + if err != nil { + return err + } + // zero-prefix/right-align what bytes we got + copy((*aa)[32-len(bytes):], bytes) + + return nil +} diff --git a/typetag.go b/typetag.go index 5e3b7cc..258e355 100644 --- a/typetag.go +++ b/typetag.go @@ -1,9 +1,13 @@ package aptos import ( + "errors" "fmt" - "github.com/aptos-labs/aptos-go-sdk/bcs" + "regexp" + "strconv" "strings" + + "github.com/aptos-labs/aptos-go-sdk/bcs" ) //region TypeTag @@ -12,17 +16,19 @@ import ( type TypeTagVariant uint32 const ( - TypeTagBool TypeTagVariant = 0 // Represents the bool type in Move BoolTag - TypeTagU8 TypeTagVariant = 1 // Represents the u8 type in Move U8Tag - TypeTagU64 TypeTagVariant = 2 // Represents the u64 type in Move U64Tag - TypeTagU128 TypeTagVariant = 3 // Represents the u128 type in Move U128Tag - TypeTagAddress TypeTagVariant = 4 // Represents the address type in Move AddressTag - TypeTagSigner TypeTagVariant = 5 // Represents the signer type in Move SignerTag - TypeTagVector TypeTagVariant = 6 // Represents the vector type in Move VectorTag - TypeTagStruct TypeTagVariant = 7 // Represents the struct type in Move StructTag - TypeTagU16 TypeTagVariant = 8 // Represents the u16 type in Move U16Tag - TypeTagU32 TypeTagVariant = 9 // Represents the u32 type in Move U32Tag - TypeTagU256 TypeTagVariant = 10 // Represents the u256 type in Move U256Tag + TypeTagBool TypeTagVariant = 0 // Represents the bool type in Move BoolTag + TypeTagU8 TypeTagVariant = 1 // Represents the u8 type in Move U8Tag + TypeTagU64 TypeTagVariant = 2 // Represents the u64 type in Move U64Tag + TypeTagU128 TypeTagVariant = 3 // Represents the u128 type in Move U128Tag + TypeTagAddress TypeTagVariant = 4 // Represents the address type in Move AddressTag + TypeTagSigner TypeTagVariant = 5 // Represents the signer type in Move SignerTag + TypeTagVector TypeTagVariant = 6 // Represents the vector type in Move VectorTag + TypeTagStruct TypeTagVariant = 7 // Represents the struct type in Move StructTag + TypeTagU16 TypeTagVariant = 8 // Represents the u16 type in Move U16Tag + TypeTagU32 TypeTagVariant = 9 // Represents the u32 type in Move U32Tag + TypeTagU256 TypeTagVariant = 10 // Represents the u256 type in Move U256Tag + TypeTagGeneric TypeTagVariant = 254 // Represents a generic type in Move GenericTag + TypeTagReference TypeTagVariant = 255 // Represents the reference type in Move ReferenceTag ) // TypeTagImpl is an interface describing all the different types of [TypeTag]. Unfortunately because of how serialization @@ -42,6 +48,264 @@ type TypeTag struct { Value TypeTagImpl } +type parseInfo struct { + expectedTypes int + types []TypeTag + str string +} + +func ParseTypeTag(inputStr string) (*TypeTag, error) { + inputRunes := []rune(inputStr) + // Represents the stack of types currently being processed + saved := make([]parseInfo, 0) + // Represents the inner types for a type tag e.g. '0x1::coin::Coin' + innerTypes := make([]TypeTag, 0) + // Represents the current parsed types in a comma list e.g. 'u8, u8' + curTypes := make([]TypeTag, 0) + // The current character index of the whole string + cur := 0 + // The current working string as type name + currentStr := "" + // The expected types based on the number of commas + expectedTypes := 1 + + // Iterate through characters, handling border conditions, we don't use a range because we sometimes skip ahead + for cur < len(inputRunes) { + r := inputRunes[cur] + //println(fmt.Printf("%c | %s | %s\n", r, currentStr, util.PrettyJson(saved))) + + switch r { + case '<': + // Start of a type argument, save the current state + saved = append(saved, parseInfo{ + expectedTypes: expectedTypes, + types: curTypes, + str: currentStr, + }) + + // Clear current state + currentStr = "" + curTypes = make([]TypeTag, 0) + expectedTypes = 1 + case '>': + // End of type arguments, process last type, if there is no type string then don't parse it + if currentStr != "" { + newType, err := ParseTypeTagInner(currentStr, innerTypes) + if err != nil { + return nil, err + } + + curTypes = append(curTypes, *newType) + } + + // If there's nothing left there were too many '>' + savedLength := len(saved) + if savedLength == 0 { + return nil, errors.New("no inner types found") + } + + // Ensure commas match types + if expectedTypes != len(curTypes) { + return nil, errors.New("inner type count mismatch, too many commas") + } + + // Pop off stack + savedPop := saved[savedLength-1] + saved = saved[:savedLength-1] + + innerTypes = curTypes + curTypes = savedPop.types + currentStr = savedPop.str + expectedTypes = savedPop.expectedTypes + case ',': + if len(saved) == 0 { + return nil, fmt.Errorf("unexpected comma at top level type") + } + if len(currentStr) == 0 { + return nil, fmt.Errorf("unexpected comma, TypeTag is missing") + } + + newType, err := ParseTypeTagInner(currentStr, innerTypes) + if err != nil { + return nil, err + } + innerTypes = make([]TypeTag, 0) + curTypes = append(curTypes, *newType) + currentStr = "" + expectedTypes += 1 + case ' ': + // TODO whitespace, do we include tabs, etc. + parsedTypeTag := false + + if len(currentStr) != 0 { + // parse type tag, and push it on the current types + newType, err := ParseTypeTagInner(currentStr, innerTypes) + if err != nil { + return nil, err + } + + innerTypes = make([]TypeTag, 0) + curTypes = append(curTypes, *newType) + currentStr = "" + parsedTypeTag = true + } + + // Skip any additional whitespace + for cur < len(inputRunes) { + if inputRunes[cur] != ' ' { + break + } + cur += 1 + } + + // Next char must be a comma or a closing > if something was parsed before it + nextChar := inputRunes[cur] + if cur < len(inputRunes) && parsedTypeTag && nextChar != ',' && nextChar != '>' { + return nil, fmt.Errorf("unexpected character at top level type") + } + + // Skip over incrementing, we already did it above + continue + default: + currentStr += string(r) + } + + cur += 1 + } + + if len(saved) > 0 { + return nil, fmt.Errorf("missing type argument close '>'") + } + + switch len(curTypes) { + case 0: + return ParseTypeTagInner(currentStr, innerTypes) + case 1: + if currentStr == "" { + return &curTypes[0], nil + } + return nil, fmt.Errorf("unexpected comma ','") + default: + return nil, fmt.Errorf("unexpected whitespace") + } +} + +func ParseTypeTagInner(input string, types []TypeTag) (*TypeTag, error) { + str := strings.TrimSpace(input) + + //println(fmt.Printf("-- %s | %s\n", input, util.PrettyJson(types))) + // TODO: for now we aren't going to lowercase this + + // Handle primitive types + switch str { + case "bool": + if len(types) > 0 { + return nil, fmt.Errorf("invalid type tag, primitive with generics") + } + return &TypeTag{Value: &BoolTag{}}, nil + case "u8": + if len(types) > 0 { + return nil, fmt.Errorf("invalid type tag, primitive with generics") + } + return &TypeTag{Value: &U8Tag{}}, nil + case "u16": + if len(types) > 0 { + return nil, fmt.Errorf("invalid type tag, primitive with generics") + } + return &TypeTag{Value: &U16Tag{}}, nil + case "u32": + if len(types) > 0 { + return nil, fmt.Errorf("invalid type tag, primitive with generics") + } + return &TypeTag{Value: &U32Tag{}}, nil + case "u64": + if len(types) > 0 { + return nil, fmt.Errorf("invalid type tag, primitive with generics") + } + return &TypeTag{Value: &U64Tag{}}, nil + case "u128": + if len(types) > 0 { + return nil, fmt.Errorf("invalid type tag, primitive with generics") + } + return &TypeTag{Value: &U128Tag{}}, nil + case "u256": + if len(types) > 0 { + return nil, fmt.Errorf("invalid type tag, primitive with generics") + } + return &TypeTag{Value: &U256Tag{}}, nil + case "address": + if len(types) > 0 { + return nil, fmt.Errorf("invalid type tag, primitive with generics") + } + return &TypeTag{Value: &AddressTag{}}, nil + case "signer": + if len(types) > 0 { + return nil, fmt.Errorf("invalid type tag, primitive with generics") + } + return &TypeTag{Value: &SignerTag{}}, nil + case "vector": + if len(types) != 1 { + return nil, fmt.Errorf("unexpected number of types for vector, expected 1, got %d", len(types)) + } + return &TypeTag{Value: &VectorTag{TypeParam: types[0]}}, nil + default: + // If it's a reference + if strings.HasPrefix(str, "&") { + actualType, _ := strings.CutPrefix(str, "&") + inner, err := ParseTypeTagInner(actualType, types) + if err != nil { + return nil, err + } + return &TypeTag{Value: &ReferenceTag{TypeParam: *inner}}, nil + } + + // If it's generic + if strings.HasPrefix(str, "T") { + numStr := strings.TrimPrefix(str, "T") + num, err := strconv.ParseUint(numStr, 10, 32) + if err != nil { + return nil, err + } + return &TypeTag{Value: &GenericTag{Num: num}}, nil + } + + parts := strings.Split(str, "::") + if len(parts) != 3 { + // TODO: More informative message + return nil, errors.New("invalid type tag") + } + + // Validate struct address + address := &AccountAddress{} + //println("PARTS:", util.PrettyJson(parts)) + err := address.ParseStringWithPrefixRelaxed(parts[0]) + if err != nil { + return nil, errors.New("invalid type tag struct address") + } + + // Validate module + module := parts[1] + moduleValid, err := regexp.MatchString("^[a-zA-Z_0-9]+$", module) + if !moduleValid || err != nil { + return nil, errors.New("invalid type tag struct module") + } + + // Validate name + name := parts[2] + nameValid, err := regexp.MatchString("^[a-zA-Z_0-9]+$", name) + if !nameValid || err != nil { + return nil, errors.New("invalid type tag struct name") + } + + return &TypeTag{Value: &StructTag{ + Address: *address, + Module: module, + Name: name, + TypeParams: types, + }}, nil + } +} + // String gives the canonical TypeTag string value used in Move func (tt *TypeTag) String() string { return tt.Value.String() @@ -418,6 +682,66 @@ func (xt *StructTag) UnmarshalBCS(des *bcs.Deserializer) { //endregion //endregion +//region ReferenceTag + +// ReferenceTag represents a reference of a type in Move +type ReferenceTag struct { + TypeParam TypeTag +} + +//region ReferenceTag TypeTagImpl + +func (xt *ReferenceTag) String() string { + out := strings.Builder{} + out.WriteString("&") + out.WriteString(xt.TypeParam.Value.String()) + return out.String() +} + +func (xt *ReferenceTag) GetType() TypeTagVariant { + return TypeTagReference +} + +//endregion + +//region Reference bcs.Struct + +// TODO: Do we need a proper serialization here +func (xt *ReferenceTag) MarshalBCS(_ *bcs.Serializer) {} +func (xt *ReferenceTag) UnmarshalBCS(_ *bcs.Deserializer) {} + +//endregion + +//region GenericTag + +// GenericTag represents a generic of a type in Move +type GenericTag struct { + Num uint64 +} + +//region GenericTag TypeTagImpl + +func (xt *GenericTag) String() string { + out := strings.Builder{} + out.WriteString("T") + out.WriteString(strconv.FormatUint(xt.Num, 10)) + return out.String() +} + +func (xt *GenericTag) GetType() TypeTagVariant { + return TypeTagGeneric +} + +//endregion + +//region Generic bcs.Struct + +// TODO: Do we need a proper serialization here +func (xt *GenericTag) MarshalBCS(_ *bcs.Serializer) {} +func (xt *GenericTag) UnmarshalBCS(_ *bcs.Deserializer) {} + +//endregion + //region TypeTag helpers // NewTypeTag wraps a TypeTagImpl in a TypeTag diff --git a/typetag_test.go b/typetag_test.go index 05d6d3d..2439770 100644 --- a/typetag_test.go +++ b/typetag_test.go @@ -88,3 +88,124 @@ func TestInvalidTypeTag(t *testing.T) { err := bcs.Deserialize(tag, bytes) assert.Error(t, err) } + +func TestParseTypeTag(t *testing.T) { + tests := []struct { + name string + input string + expected *TypeTag + wantErr bool + }{ + // Invalid cases + {"empty string", "", nil, true}, + {"invalid type", "invalid", nil, true}, + {"unclosed vector", "vector<", nil, true}, + {"unopened vector", "vector>", nil, true}, + {"incomplete address", "0x1::string", nil, true}, + {"incomplete module", "0x1::string::", nil, true}, + {"invalid address format", "0x1::::String", nil, true}, + {"missing address", "::string::String", nil, true}, + {"invalid hex address", "dead::string::String", nil, true}, + {"unclosed generic", "0x1::string::String<", nil, true}, + {"unopened generic", "0x1::string::String>", nil, true}, + {"empty generic", "0x1::string::String<>", nil, true}, + {"incomplete generic", "0x1::string::String", nil, true}, + + // Primitive types + {"bool type", "bool", &TypeTag{Value: &BoolTag{}}, false}, + {"u8 type", "u8", &TypeTag{Value: &U8Tag{}}, false}, + {"u16 type", "u16", &TypeTag{Value: &U16Tag{}}, false}, + {"u32 type", "u32", &TypeTag{Value: &U32Tag{}}, false}, + {"u64 type", "u64", &TypeTag{Value: &U64Tag{}}, false}, + {"u128 type", "u128", &TypeTag{Value: &U128Tag{}}, false}, + {"u256 type", "u256", &TypeTag{Value: &U256Tag{}}, false}, + {"address type", "address", &TypeTag{Value: &AddressTag{}}, false}, + {"signer type", "signer", &TypeTag{Value: &SignerTag{}}, false}, + + // Handle references + {"signer reference", "&signer", &TypeTag{Value: &ReferenceTag{TypeParam: TypeTag{Value: &SignerTag{}}}}, false}, + {"u8 reference", "&u8", &TypeTag{Value: &ReferenceTag{TypeParam: TypeTag{Value: &U8Tag{}}}}, false}, + + // Vector types + {"simple vector", "vector", &TypeTag{Value: &VectorTag{TypeParam: TypeTag{Value: &U8Tag{}}}}, false}, + {"nested vector", "vector>", &TypeTag{Value: &VectorTag{TypeParam: TypeTag{Value: &VectorTag{TypeParam: TypeTag{Value: &U8Tag{}}}}}}, false}, + {"vector of string", "vector<0x1::string::String>", &TypeTag{Value: &VectorTag{TypeParam: TypeTag{Value: &StructTag{Address: AccountOne, Module: "string", Name: "String", TypeParams: []TypeTag{}}}}}, false}, + + // Struct types + {"simple string", "0x1::string::String", &TypeTag{Value: &StructTag{Address: AccountOne, Module: "string", Name: "String", TypeParams: []TypeTag{}}}, false}, + {"nested object", "0x1::object::Object<0x1::object::ObjectCore>", &TypeTag{Value: &StructTag{Address: AccountOne, Module: "object", Name: "Object", TypeParams: []TypeTag{{Value: &StructTag{Address: AccountOne, Module: "object", Name: "ObjectCore", TypeParams: []TypeTag{}}}}}}, false}, + {"option type", "0x1::option::Option", &TypeTag{Value: &StructTag{Address: AccountOne, Module: "option", Name: "Option", TypeParams: []TypeTag{{Value: &U8Tag{}}}}}, false}, + {"coin store", "0x1::coin::CoinStore<0x1::aptos_coin::AptosCoin>", &TypeTag{Value: &StructTag{Address: AccountOne, Module: "coin", Name: "CoinStore", TypeParams: []TypeTag{{Value: &StructTag{Address: AccountOne, Module: "aptos_coin", Name: "AptosCoin", TypeParams: []TypeTag{}}}}}}, false}, + {"coin store with multiple params", "0x1::coin::CoinStore<0x1::aptos_coin::AptosCoin,0x3::other::thing>", &TypeTag{Value: &StructTag{Address: AccountOne, Module: "coin", Name: "CoinStore", TypeParams: []TypeTag{{Value: &StructTag{Address: AccountOne, Module: "aptos_coin", Name: "AptosCoin", TypeParams: []TypeTag{}}}, {Value: &StructTag{Address: AccountThree, Module: "other", Name: "thing", TypeParams: []TypeTag{}}}}}}, false}, + + // Complex nested types + {"complex nested type", "vector<0x1::option::Option>>>", &TypeTag{Value: &VectorTag{TypeParam: TypeTag{Value: &StructTag{Address: AccountOne, Module: "option", Name: "Option", TypeParams: []TypeTag{{Value: &VectorTag{TypeParam: TypeTag{Value: &StructTag{Address: AccountOne, Module: "object", Name: "Object", TypeParams: []TypeTag{{Value: &StructTag{Address: AccountOne, Module: "object", Name: "ObjectCore", TypeParams: []TypeTag{}}}}}}}}}}}}}, false}, + + // Generic type parameters + {"generic coin", "0x1::coin::Coin<0x1::aptos_coin::AptosCoin>", &TypeTag{Value: &StructTag{Address: AccountOne, Module: "coin", Name: "Coin", TypeParams: []TypeTag{{Value: &StructTag{Address: AccountOne, Module: "aptos_coin", Name: "AptosCoin", TypeParams: []TypeTag{}}}}}}, false}, + {"generic type", "0x1::coin::Coin", &TypeTag{Value: &StructTag{Address: AccountOne, Module: "coin", Name: "Coin", TypeParams: []TypeTag{{Value: &GenericTag{Num: 0}}}}}, false}, + {"generic 2 type", "0x1::pair::Pair", &TypeTag{Value: &StructTag{Address: AccountOne, Module: "pair", Name: "Pair", TypeParams: []TypeTag{{Value: &GenericTag{Num: 0}}, {Value: &GenericTag{Num: 1}}}}}, false}, + + // Multiple generic parameters + {"pair with coins", "0x1::pair::Pair<0x1::coin::Coin<0x1::aptos_coin::AptosCoin>, 0x1::coin::Coin<0x1::usd_coin::UsdCoin>>", &TypeTag{Value: &StructTag{Address: AccountOne, Module: "pair", Name: "Pair", TypeParams: []TypeTag{{Value: &StructTag{Address: AccountOne, Module: "coin", Name: "Coin", TypeParams: []TypeTag{{Value: &StructTag{Address: AccountOne, Module: "aptos_coin", Name: "AptosCoin", TypeParams: []TypeTag{}}}}}}, {Value: &StructTag{Address: AccountOne, Module: "coin", Name: "Coin", TypeParams: []TypeTag{{Value: &StructTag{Address: AccountOne, Module: "usd_coin", Name: "UsdCoin", TypeParams: []TypeTag{}}}}}}}}}, false}, + + // Reference type parameters + {"vector with reference", "0x1::vector::Vector<&0x1::coin::Coin<0x1::aptos_coin::AptosCoin>>", &TypeTag{Value: &StructTag{Address: AccountOne, Module: "vector", Name: "Vector", TypeParams: []TypeTag{{Value: &ReferenceTag{TypeParam: TypeTag{Value: &StructTag{Address: AccountOne, Module: "coin", Name: "Coin", TypeParams: []TypeTag{{Value: &StructTag{Address: AccountOne, Module: "aptos_coin", Name: "AptosCoin", TypeParams: []TypeTag{}}}}}}}}}}}, false}, + + // Mutable reference type parameters + // &mut not supported atm + {"vector with mutable reference", "0x1::vector::Vector<&mut 0x1::coin::Coin<0x1::aptos_coin::AptosCoin>>", nil, true}, + + // Whitespace handling + {"spaces in type parameters", "0x1::vector::Vector< 0x1::coin::Coin< 0x1::aptos_coin::AptosCoin > >", &TypeTag{Value: &StructTag{Address: AccountOne, Module: "vector", Name: "Vector", TypeParams: []TypeTag{{Value: &StructTag{Address: AccountOne, Module: "coin", Name: "Coin", TypeParams: []TypeTag{{Value: &StructTag{Address: AccountOne, Module: "aptos_coin", Name: "AptosCoin", TypeParams: []TypeTag{}}}}}}}}}, false}, + + // Invalid cases + {"unclosed generic", "0x1::vector::Vector<0x1::coin::Coin<0x1::aptos_coin::AptosCoin", nil, true}, + {"unclosed reference", "0x1::vector::Vector<&0x1::coin::Coin<0x1::aptos_coin::AptosCoin", nil, true}, + {"unclosed mutable reference", "0x1::vector::Vector<&mut 0x1::coin::Coin<0x1::aptos_coin::AptosCoin", nil, true}, + {"unclosed spaces", "0x1::vector::Vector< 0x1::coin::Coin< 0x1::aptos_coin::AptosCoin >", nil, true}, + {"unclosed newlines", "0x1::vector::Vector<\n0x1::coin::Coin<\n0x1::aptos_coin::AptosCoin\n>", nil, true}, + {"unclosed multiple params", "0x1::pair::Pair< 0x1::coin::Coin< 0x1::aptos_coin::AptosCoin >, 0x1::coin::Coin< 0x1::usd_coin::UsdCoin", nil, true}, + {"invalid comma before", ",u8", nil, true}, + {"invalid comma after", "u8,", nil, true}, + {"invalid comma in generics before", "0x1::pair::Pair<,u8>", nil, true}, + {"invalid comma in generics after", "0x1::pair::Pair", nil, true}, + {"invalid comma in generics only", "0x1::pair::Pair<,>", nil, true}, + {"invalid type in generics before comma", "0x1::pair::Pair", nil, true}, + {"invalid type in generics after comma", "0x1::pair::Pair", nil, true}, + {"invalid type space before comma", " ,", nil, true}, + {"invalid type space after comma", ", ", nil, true}, + {"invalid type space before close angle bracket", " >", nil, true}, + {"invalid type space before open angle bracket", " <", nil, true}, + {"invalid pair", "u8,u8", nil, true}, + {"invalid bool generic", "bool", nil, true}, + {"invalid address generic", "address", nil, true}, + {"invalid u8 generic", "u8", nil, true}, + {"invalid u16 generic", "u16", nil, true}, + {"invalid u32 generic", "u32", nil, true}, + {"invalid u64 generic", "u64", nil, true}, + {"invalid u128 generic", "u128", nil, true}, + {"invalid u256 generic", "u256", nil, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseTypeTag(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ParseTypeTag() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && got == nil { + t.Error("ParseTypeTag() returned nil without error") + return + } + if tt.wantErr { + return + } + if got.String() != tt.expected.String() { + t.Errorf("ParseTypeTag() = %v, want %v", got.String(), tt.expected.String()) + } + }) + } +}