mirror of
https://github.com/lukaszraczylo/go-telegram.git
synced 2026-06-11 23:19:31 +00:00
140ea13bde
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
547 lines
15 KiB
Go
547 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/lukaszraczylo/go-telegram/internal/spec"
|
|
)
|
|
|
|
// enumDecl is one generated enum: a Go type alias of string plus a set
|
|
// of named constants. Values keep doc order; constant identifiers are
|
|
// derived from values via constName.
|
|
type enumDecl struct {
|
|
Name string
|
|
Values []string
|
|
}
|
|
|
|
// enumPlan is the deduplicated, name-resolved set of enums emitted from
|
|
// an API IR. Lookup returns the enum name for a given field reference;
|
|
// All returns the deterministic-ordered list of declarations to emit.
|
|
type enumPlan struct {
|
|
// fieldKey -> enum name. The fieldKey is a string built by enumKey.
|
|
byField map[string]string
|
|
// enum name -> declaration.
|
|
decls map[string]enumDecl
|
|
}
|
|
|
|
// enumKey identifies a single Field occurrence so the emitter can look
|
|
// 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 // naming parent ("" for method params)
|
|
keyParent string // byField key parent (methodEnumParent(...) for method params)
|
|
fieldName string
|
|
jsonName string
|
|
values []string
|
|
valueKey string // canonical key for value-set dedup
|
|
}
|
|
|
|
// Unification pass: for each sealed-interface union, fold per-variant
|
|
// single-value enum fields that share a discriminator name into ONE
|
|
// unified enum at union level. Claimed (parent,fieldName) tuples are
|
|
// excluded from the per-field grouping below.
|
|
unifiedDecls, unifiedByField := planUnifiedUnionEnums(api)
|
|
claimed := func(parent, fieldName string) bool {
|
|
_, ok := unifiedByField[enumKey(parent, fieldName)]
|
|
return ok
|
|
}
|
|
|
|
var refs []ref
|
|
collect := func(parent, keyParent string, fields []spec.Field) {
|
|
for _, f := range fields {
|
|
if len(f.EnumValues) == 0 {
|
|
continue
|
|
}
|
|
if claimed(parent, f.Name) {
|
|
continue
|
|
}
|
|
refs = append(refs, ref{
|
|
parent: parent,
|
|
keyParent: keyParent,
|
|
fieldName: f.Name,
|
|
jsonName: f.JSONName,
|
|
values: f.EnumValues,
|
|
valueKey: valueKey(f.EnumValues),
|
|
})
|
|
}
|
|
}
|
|
for _, t := range api.Types {
|
|
collect(t.Name, t.Name, t.Fields)
|
|
}
|
|
for _, m := range api.Methods {
|
|
// 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)
|
|
candidate := make([]string, len(refs))
|
|
for i, r := range refs {
|
|
candidate[i] = defaultEnumName(r.parent, r.jsonName, r.fieldName)
|
|
}
|
|
|
|
// Group by valueKey to coalesce identical value-sets across fields.
|
|
// Choose canonical name: prefer the most common candidate; tie-break
|
|
// by shortest name; final tie-break alphabetical.
|
|
type groupInfo struct {
|
|
values []string
|
|
name string
|
|
first int
|
|
}
|
|
groups := map[string]*groupInfo{}
|
|
for i, r := range refs {
|
|
g, ok := groups[r.valueKey]
|
|
if !ok {
|
|
groups[r.valueKey] = &groupInfo{values: r.values, first: i}
|
|
g = groups[r.valueKey]
|
|
}
|
|
_ = g
|
|
}
|
|
// Rank candidate names per group.
|
|
for vk := range groups {
|
|
counts := map[string]int{}
|
|
hasParent := map[string]bool{}
|
|
var names []string
|
|
for i, r := range refs {
|
|
if r.valueKey != vk {
|
|
continue
|
|
}
|
|
n := candidate[i]
|
|
if _, ok := counts[n]; !ok {
|
|
names = append(names, n)
|
|
}
|
|
counts[n]++
|
|
if r.parent != "" {
|
|
hasParent[n] = true
|
|
}
|
|
}
|
|
// Pick the canonical name for this group:
|
|
// 1. highest occurrence count wins;
|
|
// 2. names that originated from a parent type win over plain
|
|
// method-param candidates (avoids "Format"-style
|
|
// monosyllables);
|
|
// 3. shortest name wins;
|
|
// 4. alphabetical for full determinism.
|
|
sort.SliceStable(names, func(a, b int) bool {
|
|
if counts[names[a]] != counts[names[b]] {
|
|
return counts[names[a]] > counts[names[b]]
|
|
}
|
|
if hasParent[names[a]] != hasParent[names[b]] {
|
|
return hasParent[names[a]]
|
|
}
|
|
if len(names[a]) != len(names[b]) {
|
|
return len(names[a]) < len(names[b])
|
|
}
|
|
return names[a] < names[b]
|
|
})
|
|
groups[vk].name = names[0]
|
|
}
|
|
|
|
// Collision pass: two groups must not share the same enum name.
|
|
// When that happens, suffix the loser(s) with their parent type
|
|
// name so the result is unique. Iterate in deterministic order
|
|
// (groups sorted by valueKey).
|
|
used := map[string]string{} // name -> valueKey owner
|
|
var keys []string
|
|
for vk := range groups {
|
|
keys = append(keys, vk)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, vk := range keys {
|
|
g := groups[vk]
|
|
if _, taken := used[g.name]; !taken {
|
|
used[g.name] = vk
|
|
continue
|
|
}
|
|
// Find a unique name by prepending a parent prefix from one of
|
|
// the contributing refs (the lowest-index ref in this group).
|
|
for i, r := range refs {
|
|
if r.valueKey != vk {
|
|
continue
|
|
}
|
|
if r.parent == "" {
|
|
continue
|
|
}
|
|
cand := r.parent + goNamePart(r.jsonName)
|
|
if _, taken := used[cand]; !taken {
|
|
g.name = cand
|
|
used[cand] = vk
|
|
goto next
|
|
}
|
|
_ = i
|
|
}
|
|
// Fallback: append a numeric disambiguator. Should not happen
|
|
// in practice for the Telegram docs but keeps the algorithm
|
|
// total.
|
|
for n := 2; ; n++ {
|
|
cand := groups[vk].name + itoa(n)
|
|
if _, taken := used[cand]; !taken {
|
|
g.name = cand
|
|
used[cand] = vk
|
|
break
|
|
}
|
|
}
|
|
next:
|
|
}
|
|
|
|
// Build the plan.
|
|
plan := &enumPlan{
|
|
byField: map[string]string{},
|
|
decls: map[string]enumDecl{},
|
|
}
|
|
for i, r := range refs {
|
|
name := groups[r.valueKey].name
|
|
plan.byField[enumKey(r.keyParent, r.fieldName)] = name
|
|
_ = i
|
|
}
|
|
for vk, g := range groups {
|
|
plan.decls[g.name] = enumDecl{Name: g.name, Values: g.values}
|
|
_ = vk
|
|
}
|
|
// Merge unified union enums (already named with stutter handling and
|
|
// keyed per-variant in unifiedByField).
|
|
for k, name := range unifiedByField {
|
|
plan.byField[k] = name
|
|
}
|
|
for name, d := range unifiedDecls {
|
|
plan.decls[name] = d
|
|
}
|
|
return plan
|
|
}
|
|
|
|
// planUnifiedUnionEnums detects sealed-interface unions whose variants
|
|
// share a single discriminator field with one enum value each, and emits
|
|
// ONE unified enum per union covering all variant values. Returns the
|
|
// declarations to emit and the per-(variant,fieldName) map to point each
|
|
// variant's field at the unified enum.
|
|
//
|
|
// A union qualifies when EVERY variant in t.OneOf:
|
|
// 1. defines a field with the same Go-name (e.g. "Status", "Type", "Source");
|
|
// 2. that field is a required string with len(EnumValues)==1.
|
|
//
|
|
// The picked Go-name is the first one tried in this priority order:
|
|
// - knownDiscriminators[union].Field's Go-name (resolved via JSONName match);
|
|
// - "Type", "Status", "Source" (the three discriminators Telegram uses).
|
|
//
|
|
// First match wins; if none qualify, the union is skipped (variants keep
|
|
// their existing per-field treatment, which still single-emits via the
|
|
// regular grouping pass).
|
|
func planUnifiedUnionEnums(api *spec.API) (map[string]enumDecl, map[string]string) {
|
|
decls := map[string]enumDecl{}
|
|
byField := map[string]string{}
|
|
|
|
typeByName := make(map[string]*spec.TypeDecl, len(api.Types))
|
|
for i := range api.Types {
|
|
typeByName[api.Types[i].Name] = &api.Types[i]
|
|
}
|
|
|
|
// Iterate unions in deterministic (declaration) order.
|
|
for ui := range api.Types {
|
|
u := &api.Types[ui]
|
|
if len(u.OneOf) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Resolve the variants. Skip unions where any variant is missing
|
|
// (defensive — shouldn't happen in a well-formed IR).
|
|
variants := make([]*spec.TypeDecl, 0, len(u.OneOf))
|
|
for _, vName := range u.OneOf {
|
|
v, ok := typeByName[vName]
|
|
if !ok {
|
|
variants = nil
|
|
break
|
|
}
|
|
variants = append(variants, v)
|
|
}
|
|
if len(variants) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Build the candidate Go-name list. Priority order:
|
|
// 1. discriminator GoField from knownDiscriminators (resolved via JSONName);
|
|
// 2. "Type", "Status", "Source".
|
|
var candidateNames []string
|
|
seen := map[string]bool{}
|
|
add := func(name string) {
|
|
if name == "" || seen[name] {
|
|
return
|
|
}
|
|
seen[name] = true
|
|
candidateNames = append(candidateNames, name)
|
|
}
|
|
if ds, ok := knownDiscriminators[u.Name]; ok && ds.Field != "" {
|
|
// Resolve Go-name from the first variant whose field matches the JSON name.
|
|
for _, v := range variants {
|
|
for _, f := range v.Fields {
|
|
if f.JSONName == ds.Field {
|
|
add(f.Name)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, n := range []string{"Type", "Status", "Source"} {
|
|
add(n)
|
|
}
|
|
|
|
// Find the first candidate Go-name where every variant has a
|
|
// matching single-value string-enum field.
|
|
var (
|
|
pickedName string
|
|
pickedDocs map[string]spec.Field // variant name -> field
|
|
)
|
|
for _, name := range candidateNames {
|
|
matches := map[string]spec.Field{}
|
|
ok := true
|
|
for _, v := range variants {
|
|
var hit *spec.Field
|
|
for fi := range v.Fields {
|
|
if v.Fields[fi].Name == name {
|
|
hit = &v.Fields[fi]
|
|
break
|
|
}
|
|
}
|
|
if hit == nil ||
|
|
hit.Type.Kind != spec.KindPrimitive ||
|
|
hit.Type.Name != "string" ||
|
|
len(hit.EnumValues) != 1 {
|
|
ok = false
|
|
break
|
|
}
|
|
matches[v.Name] = *hit
|
|
}
|
|
if ok {
|
|
pickedName = name
|
|
pickedDocs = matches
|
|
break
|
|
}
|
|
}
|
|
if pickedName == "" {
|
|
continue
|
|
}
|
|
|
|
// Build the unified enum name with stutter handling.
|
|
enumName := unifiedEnumName(u.Name, pickedName)
|
|
|
|
// Collect values across variants in deterministic order, deduping.
|
|
valueOrder := make([]string, 0, len(variants))
|
|
valueSeen := map[string]bool{}
|
|
for _, v := range u.OneOf {
|
|
f := pickedDocs[v]
|
|
val := f.EnumValues[0]
|
|
if valueSeen[val] {
|
|
continue
|
|
}
|
|
valueSeen[val] = true
|
|
valueOrder = append(valueOrder, val)
|
|
}
|
|
|
|
decls[enumName] = enumDecl{Name: enumName, Values: valueOrder}
|
|
for _, v := range variants {
|
|
byField[enumKey(v.Name, pickedName)] = enumName
|
|
}
|
|
}
|
|
|
|
return decls, byField
|
|
}
|
|
|
|
// unifiedEnumName builds the union-level enum name. Falls back to a
|
|
// "Kind" suffix when the naive concatenation reads as a stutter:
|
|
//
|
|
// - union name ends in the field name verbatim (e.g. BackgroundType+Type);
|
|
// - union name ends in any "concept noun" — Type/Status/Source/State —
|
|
// so appending another such noun would duplicate the suffix
|
|
// (e.g. ChatBoostSource+Source, RevenueWithdrawalState+Type).
|
|
//
|
|
// Otherwise the natural concatenation wins (ChatMember+Status →
|
|
// ChatMemberStatus, MessageOrigin+Type → MessageOriginType).
|
|
func unifiedEnumName(unionName, fieldName string) string {
|
|
for _, suf := range []string{"Type", "Status", "Source", "State"} {
|
|
if strings.HasSuffix(unionName, suf) {
|
|
return unionName + "Kind"
|
|
}
|
|
}
|
|
return unionName + fieldName
|
|
}
|
|
|
|
// All returns the enum declarations sorted by name for deterministic emit.
|
|
func (p *enumPlan) All() []enumDecl {
|
|
out := make([]enumDecl, 0, len(p.decls))
|
|
for _, d := range p.decls {
|
|
out = append(out, d)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out
|
|
}
|
|
|
|
// FieldEnum returns the enum name for a field on a given parent type
|
|
// (use parent="" for method parameters), or "" if the field is not an
|
|
// enum.
|
|
func (p *enumPlan) FieldEnum(parent, fieldName string) string {
|
|
if p == nil {
|
|
return ""
|
|
}
|
|
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).
|
|
func defaultEnumName(parent, jsonName, fieldName string) string {
|
|
if strings.HasSuffix(jsonName, "parse_mode") {
|
|
return "ParseMode"
|
|
}
|
|
return parent + goNamePart(jsonName)
|
|
}
|
|
|
|
// constName builds a Go constant identifier "<EnumName><PascalValue>"
|
|
// from a wire value. Slashes (mime types) become "Of" so
|
|
// "image/jpeg" → "ImageOfJpeg".
|
|
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, "/")
|
|
for i, p := range parts {
|
|
parts[i] = goNamePart(p)
|
|
}
|
|
return strings.Join(parts, "Of")
|
|
}
|
|
|
|
// goNamePart converts a snake_case (or already-PascalCase) token to
|
|
// PascalCase, mirroring scrape.goName behaviour without the acronym
|
|
// special-cases (which apply to wire identifiers, not enum values).
|
|
func goNamePart(s string) string {
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
parts := strings.Split(s, "_")
|
|
var b strings.Builder
|
|
for _, p := range parts {
|
|
if p == "" {
|
|
continue
|
|
}
|
|
// Acronyms used in Telegram wire names. Keeping in sync with
|
|
// scrape/table.go avoids divergent capitalisation between
|
|
// fieldName and constName.
|
|
switch p {
|
|
case "id":
|
|
b.WriteString("ID")
|
|
continue
|
|
case "url":
|
|
b.WriteString("URL")
|
|
continue
|
|
case "ip":
|
|
b.WriteString("IP")
|
|
continue
|
|
case "https":
|
|
b.WriteString("HTTPS")
|
|
continue
|
|
case "json":
|
|
b.WriteString("JSON")
|
|
continue
|
|
case "html":
|
|
b.WriteString("HTML")
|
|
continue
|
|
}
|
|
if c := p[0]; c >= 'a' && c <= 'z' {
|
|
b.WriteByte(c - 'a' + 'A')
|
|
b.WriteString(p[1:])
|
|
} else {
|
|
b.WriteString(p)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func valueKey(values []string) string {
|
|
cp := make([]string, len(values))
|
|
copy(cp, values)
|
|
sort.Strings(cp)
|
|
return strings.Join(cp, "\x00")
|
|
}
|
|
|
|
func itoa(n int) string {
|
|
if n == 0 {
|
|
return "0"
|
|
}
|
|
var buf [20]byte
|
|
i := len(buf)
|
|
for n > 0 {
|
|
i--
|
|
buf[i] = byte('0' + n%10)
|
|
n /= 10
|
|
}
|
|
return string(buf[i:])
|
|
}
|