fix(genapi): scope method-param enums per method, dedupe case-colliding enum consts

Bot API update added RichBlockListItem.type values (a/A/i/I/1) that
Pascal-case to identical const idents, and answerGuestQuery's non-enum
'result' param picked up answerChatJoinRequestQuery's Result enum via
the shared parent="" enum-plan key.

- key method params as method:<name> in the enum plan byField map
- enumDecl.ConstName resolves case collisions with Lower/Upper prefix
- regenerate api/ from 2026-06-11 snapshot
This commit is contained in:
2026-06-12 00:02:38 +01:00
parent 0731f10907
commit 140ea13bde
14 changed files with 29151 additions and 800 deletions
+8 -8
View File
@@ -420,7 +420,7 @@ func funcs(plan *enumPlan) template.FuncMap {
return goField(plan, parent, f)
},
"goFieldP": func(methodName string, f spec.Field) string {
return goFieldX(plan, "", title(methodName)+"Params", f)
return goFieldX(plan, methodEnumParent(methodName), title(methodName)+"Params", f)
},
"docComment": docComment,
"isOptional": func(f spec.Field) bool { return !f.Required },
@@ -432,7 +432,7 @@ func funcs(plan *enumPlan) template.FuncMap {
return multipartFieldEntry(plan, parent, f)
},
"multipartFieldEntryP": func(methodName string, f spec.Field) string {
return multipartFieldEntryX(plan, "", title(methodName)+"Params", f)
return multipartFieldEntryX(plan, methodEnumParent(methodName), title(methodName)+"Params", f)
},
"multipartFileEntry": multipartFileEntry,
"returnGoType": returnGoType,
@@ -915,15 +915,15 @@ func buildUnionTypeSet(api *spec.API) map[string]bool {
// used in generated test param literals. plan supplies typed-enum names
// so a method-param sentinel for a ParseMode field becomes a typed
// constant rather than a magic string.
func makeSentinelValue(unionTypes map[string]bool, plan *enumPlan) func(spec.Field) string {
return func(f spec.Field) string {
return sentinelForField(f, unionTypes, plan)
func makeSentinelValue(unionTypes map[string]bool, plan *enumPlan) func(string, spec.Field) string {
return func(methodName string, f spec.Field) string {
return sentinelForField(methodName, f, unionTypes, plan)
}
}
func sentinelForField(f spec.Field, unionTypes map[string]bool, plan *enumPlan) string {
if name := plan.FieldEnum("", f.Name); name != "" && len(f.EnumValues) > 0 {
return constName(name, f.EnumValues[0])
func sentinelForField(methodName string, f spec.Field, unionTypes map[string]bool, plan *enumPlan) string {
if name := plan.FieldEnum(methodEnumParent(methodName), f.Name); name != "" && len(f.EnumValues) > 0 {
return plan.ConstFor(name, f.EnumValues[0])
}
tr := f.Type
switch tr.Kind {
+73 -10
View File
@@ -26,15 +26,21 @@ type enumPlan struct {
}
// enumKey identifies a single Field occurrence so the emitter can look
// up the enum name later. Parent is "" for method params (the method
// doesn't share a Go type with the field).
// up the enum name later. Parent is the type name for struct fields and
// methodEnumParent(name) for method params, so two methods sharing a
// param name never alias each other's enum (or non-enum) fields.
func enumKey(parent, fieldName string) string { return parent + "::" + fieldName }
// methodEnumParent is the enum-plan key parent for a method's params.
// The "method:" prefix keeps it disjoint from type names.
func methodEnumParent(methodName string) string { return "method:" + methodName }
// planEnums walks the IR, decides on enum names, deduplicates, and
// returns an enumPlan. All scraper-marked enum fields are covered.
func planEnums(api *spec.API) *enumPlan {
type ref struct {
parent string
parent string // naming parent ("" for method params)
keyParent string // byField key parent (methodEnumParent(...) for method params)
fieldName string
jsonName string
values []string
@@ -52,7 +58,7 @@ func planEnums(api *spec.API) *enumPlan {
}
var refs []ref
collect := func(parent string, fields []spec.Field) {
collect := func(parent, keyParent string, fields []spec.Field) {
for _, f := range fields {
if len(f.EnumValues) == 0 {
continue
@@ -62,6 +68,7 @@ func planEnums(api *spec.API) *enumPlan {
}
refs = append(refs, ref{
parent: parent,
keyParent: keyParent,
fieldName: f.Name,
jsonName: f.JSONName,
values: f.EnumValues,
@@ -70,13 +77,15 @@ func planEnums(api *spec.API) *enumPlan {
}
}
for _, t := range api.Types {
collect(t.Name, t.Fields)
collect(t.Name, t.Name, t.Fields)
}
for _, m := range api.Methods {
// Method params have no shared Go parent type, so we pass "" as
// the parent. The default-name heuristic still produces the
// right answer for ParseMode-style enums.
collect("", m.Params)
// Method params have no shared Go parent type, so the naming
// parent is "" (the default-name heuristic still produces the
// right answer for ParseMode-style enums), but the byField key
// is method-scoped so a same-named non-enum param on another
// method can never pick up this enum.
collect("", methodEnumParent(m.Name), m.Params)
}
// candidate name per ref (before collision resolution)
@@ -196,7 +205,7 @@ func planEnums(api *spec.API) *enumPlan {
}
for i, r := range refs {
name := groups[r.valueKey].name
plan.byField[enumKey(r.parent, r.fieldName)] = name
plan.byField[enumKey(r.keyParent, r.fieldName)] = name
_ = i
}
for vk, g := range groups {
@@ -389,6 +398,18 @@ func (p *enumPlan) FieldEnum(parent, fieldName string) string {
return p.byField[enumKey(parent, fieldName)]
}
// ConstFor returns the collision-resolved constant identifier for value
// within the named enum declaration. Falls back to the plain constName
// when the declaration is unknown (unit tests with partial plans).
func (p *enumPlan) ConstFor(enumName, value string) string {
if p != nil {
if d, ok := p.decls[enumName]; ok {
return d.ConstName(value)
}
}
return constName(enumName, value)
}
// defaultEnumName picks an initial Go enum name for a field. parse_mode
// fields collapse to the canonical "ParseMode"; otherwise the name is
// parent + PascalCase(jsonName).
@@ -406,6 +427,48 @@ func constName(enumName, value string) string {
return enumName + valuePascal(value)
}
// ConstName returns the constant identifier for value within this enum,
// resolving case-collisions between values that Pascal-case to the same
// identifier (e.g. RichBlockListItem.type values "a" and "A"). Colliding
// values get a Lower/Upper prefix on the value part based on the case of
// the value's first letter; any residual collision gets a numeric suffix.
func (e enumDecl) ConstName(value string) string {
counts := map[string]int{}
for _, v := range e.Values {
counts[constName(e.Name, v)]++
}
plain := constName(e.Name, value)
if counts[plain] <= 1 {
return plain
}
cased := e.Name + casePrefix(value) + valuePascal(value)
// Residual collision (same value text repeated, or Lower/Upper still
// ambiguous): append the value's 1-based position for determinism.
casedCounts := map[string]int{}
pos := 0
for i, v := range e.Values {
if counts[constName(e.Name, v)] > 1 {
casedCounts[e.Name+casePrefix(v)+valuePascal(v)]++
}
if v == value {
pos = i + 1
}
}
if casedCounts[cased] > 1 {
return cased + itoa(pos)
}
return cased
}
// casePrefix distinguishes case-colliding enum values: "Lower" when the
// value starts with a lowercase letter, "Upper" otherwise.
func casePrefix(v string) string {
if v != "" && v[0] >= 'a' && v[0] <= 'z' {
return "Lower"
}
return "Upper"
}
func valuePascal(v string) string {
// "image/jpeg" → "ImageOfJpeg"
parts := strings.Split(v, "/")
+1 -1
View File
@@ -8,6 +8,6 @@ package api
type {{$e.Name}} string
const (
{{range $v := $e.Values}} {{enumConstName $e.Name $v}} {{$e.Name}} = {{printf "%q" $v}}
{{range $v := $e.Values}} {{$e.ConstName $v}} {{$e.Name}} = {{printf "%q" $v}}
{{end}})
{{end}}
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"testing"
"github.com/lukaszraczylo/go-telegram/internal/spec"
"github.com/stretchr/testify/require"
)
// Regression: Bot API 9.x RichBlockListItem.type enumerates "a"/"A"/"i"/"I"/"1",
// which Pascal-case to the same identifier. ConstName must disambiguate.
func TestEnumDecl_ConstName_CaseCollision(t *testing.T) {
d := enumDecl{Name: "RichBlockListItemType", Values: []string{"a", "A", "i", "I", "1"}}
got := map[string]bool{}
for _, v := range d.Values {
n := d.ConstName(v)
require.False(t, got[n], "duplicate const ident %q for value %q", n, v)
got[n] = true
}
require.Equal(t, "RichBlockListItemTypeLowerA", d.ConstName("a"))
require.Equal(t, "RichBlockListItemTypeUpperA", d.ConstName("A"))
// Non-colliding values keep the plain name.
require.Equal(t, "RichBlockListItemType1", d.ConstName("1"))
}
// Regression: answerChatJoinRequestQuery has an enum param named "result";
// answerGuestQuery has a NON-enum param also named "result". The enum plan
// must scope method params per method so the enum never leaks onto the
// other method's field.
func TestPlanEnums_MethodParamsScopedPerMethod(t *testing.T) {
api := &spec.API{
Methods: []spec.MethodDecl{
{
Name: "answerChatJoinRequestQuery",
Params: []spec.Field{{
Name: "Result", JSONName: "result", Required: true,
Type: spec.TypeRef{Kind: spec.KindPrimitive, Name: "string"},
EnumValues: []string{"approve", "decline", "queue"},
}},
},
{
Name: "answerGuestQuery",
Params: []spec.Field{{
Name: "Result", JSONName: "result", Required: true,
Type: spec.TypeRef{Kind: spec.KindNamed, Name: "InlineQueryResult"},
}},
},
},
}
plan := planEnums(api)
require.Equal(t, "Result",
plan.FieldEnum(methodEnumParent("answerChatJoinRequestQuery"), "Result"))
require.Empty(t,
plan.FieldEnum(methodEnumParent("answerGuestQuery"), "Result"),
"non-enum param must not inherit another method's enum")
}
+1 -1
View File
@@ -574,7 +574,7 @@ func TestSentinelForField(t *testing.T) {
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := sentinelForField(c.field, unionTypes, nil)
got := sentinelForField("testMethod", c.field, unionTypes, nil)
require.Contains(t, got, c.contains, "sentinelForField for %q", c.name)
})
}
+7 -7
View File
@@ -48,7 +48,7 @@ func Test_{{$mName}}_Success(t *testing.T) {
{{- if .Params}}
params := &{{$mName}}Params{
{{- range .Params}}{{if .Required}}
{{.Name}}: {{sentinelValue .}},{{end}}
{{.Name}}: {{sentinelValue $m.Name .}},{{end}}
{{- end}}
}
_, err := {{$mName}}(context.Background(), bot, params)
@@ -67,7 +67,7 @@ func Test_{{$mName}}_APIError(t *testing.T) {
{{- if .Params}}
params := &{{$mName}}Params{
{{- range .Params}}{{if .Required}}
{{.Name}}: {{sentinelValue .}},{{end}}
{{.Name}}: {{sentinelValue $m.Name .}},{{end}}
{{- end}}
}
_, err := {{$mName}}(context.Background(), bot, params)
@@ -89,7 +89,7 @@ func Test_{{$mName}}_NetworkError(t *testing.T) {
{{- if .Params}}
params := &{{$mName}}Params{
{{- range .Params}}{{if .Required}}
{{.Name}}: {{sentinelValue .}},{{end}}
{{.Name}}: {{sentinelValue $m.Name .}},{{end}}
{{- end}}
}
_, err := {{$mName}}(context.Background(), bot, params)
@@ -109,7 +109,7 @@ func Test_{{$mName}}_ParseError(t *testing.T) {
{{- if .Params}}
params := &{{$mName}}Params{
{{- range .Params}}{{if .Required}}
{{.Name}}: {{sentinelValue .}},{{end}}
{{.Name}}: {{sentinelValue $m.Name .}},{{end}}
{{- end}}
}
_, err := {{$mName}}(context.Background(), bot, params)
@@ -132,7 +132,7 @@ func Test_{{$mName}}_ContextCanceled(t *testing.T) {
{{- if .Params}}
params := &{{$mName}}Params{
{{- range .Params}}{{if .Required}}
{{.Name}}: {{sentinelValue .}},{{end}}
{{.Name}}: {{sentinelValue $m.Name .}},{{end}}
{{- end}}
}
_, err := {{$mName}}(ctx, bot, params)
@@ -177,7 +177,7 @@ func Test_{{$mName}}_Forbidden(t *testing.T) {
{{- if .Params}}
params := &{{$mName}}Params{
{{- range .Params}}{{if .Required}}
{{.Name}}: {{sentinelValue .}},{{end}}
{{.Name}}: {{sentinelValue $m.Name .}},{{end}}
{{- end}}
}
_, err := {{$mName}}(context.Background(), bot, params)
@@ -203,7 +203,7 @@ func Test_{{$mName}}_ServerError(t *testing.T) {
{{- if .Params}}
params := &{{$mName}}Params{
{{- range .Params}}{{if .Required}}
{{.Name}}: {{sentinelValue .}},{{end}}
{{.Name}}: {{sentinelValue $m.Name .}},{{end}}
{{- end}}
}
_, err := {{$mName}}(context.Background(), bot, params)