mirror of
https://github.com/lukaszraczylo/go-telegram.git
synced 2026-10-12 00:45:02 +00:00
The generator gated all union decoder emission behind knownDiscriminators, a hand-curated map of 13 entries. Any sealed union absent from that map received no decoder, so decoding must fail. Bot API v10 added RichBlock and RichText, which were absent. Roughly 8.6 percent of Telegram updates failed with HTTP 400. The generator now derives discriminators from the specification instead of a fixed list. A gate check fails the build when a union resolves no discriminator. Eleven unions gain decoders that never had one: BotCommandScope, InputMedia, InputPaidMedia, InputPollMedia, InputPollOptionMedia, InputProfilePhoto, InputRichBlock, InputStoryContent, PassportElementError, RichBlock and RichText. RichText can also appear as a bare string or an array of RichText. The scraper now extracts these alternate shapes into the intermediate representation. The emitter adds RichTextPlain and RichTextSequence. The decoder dispatches on the first JSON byte before it reads any discriminator. Unions without alternates must regenerate byte for byte. The golden test proves this. Claude-Session: https://claude.ai/code/session_01Fu2wXqGUrfVBi5aLafKk8U
527 lines
15 KiB
Go
527 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 (shared with deriveDiscriminators
|
|
// in emitter.go so enum naming and decode dispatch never disagree
|
|
// on where to look). Priority order:
|
|
// 1. discriminator GoField from knownDiscriminators (resolved via JSONName);
|
|
// 2. "Type", "Status", "Source".
|
|
candidateNames := discriminatorFieldCandidates(u.Name, variants)
|
|
|
|
// 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:])
|
|
}
|