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
1365 lines
46 KiB
Go
1365 lines
46 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
_ "embed"
|
|
"errors"
|
|
"fmt"
|
|
"github.com/goccy/go-json"
|
|
"go/format"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"text/template"
|
|
|
|
"github.com/lukaszraczylo/go-telegram/internal/spec"
|
|
)
|
|
|
|
// Discriminator-value extractors. The curly form ("always “X”") is
|
|
// authoritative because Telegram quotes wire literals with curly quotes
|
|
// throughout the docs; the bare form ("must be X") is the looser
|
|
// non-quoted variant used for BotCommandScope, InputMedia, etc.
|
|
var (
|
|
discCurlyRE = regexp.MustCompile(`(?:must be|always)\s+“([^”]+)”`)
|
|
discBareRE = regexp.MustCompile(`must be\s+([A-Za-z0-9_]+)(?:[\s.,]|$)`)
|
|
)
|
|
|
|
//go:embed types.tmpl
|
|
var typesTmpl string
|
|
|
|
//go:embed methods.tmpl
|
|
var methodsTmpl string
|
|
|
|
//go:embed enums.tmpl
|
|
var enumsTmpl string
|
|
|
|
//go:embed tests.tmpl
|
|
var testsTmpl string
|
|
|
|
// runtimeTypes lists types that are intentionally hand-coded and must not be
|
|
// emitted by the code generator. Skipping them prevents collisions between
|
|
// generated and hand-coded definitions.
|
|
var runtimeTypes = map[string]bool{
|
|
"InputFile": true,
|
|
"ResponseParameters": true,
|
|
"ChatID": true,
|
|
"MessageOrBool": true,
|
|
}
|
|
|
|
// fieldTypeOverrides maps "<TypeOrParamsName>.<FieldName>" → Go type expression.
|
|
// Used for fields whose values are restricted but whose enum the scraper
|
|
// can't detect (Telegram's curly-quoted emoji literals are routinely
|
|
// stripped by the scraper's regex due to byte-boundary issues with
|
|
// multi-byte sequences). The hand-curated typed-string enum lives in
|
|
// api/enums.go (manual file); this override just retypes the field so
|
|
// callers get IDE completion and compile-time checks. Generated fields
|
|
// stay typed even after `make regen`.
|
|
var fieldTypeOverrides = map[string]string{
|
|
"ReactionTypeEmoji.Emoji": "ReactionEmoji",
|
|
"SendDiceParams.Emoji": "DiceEmoji",
|
|
}
|
|
|
|
// fieldTypeOverride returns the override type for a (parent, fieldName)
|
|
// pair, or "" if none. parent is the Go type name owning the field —
|
|
// either a struct type (e.g. "ReactionTypeEmoji") or a method-params
|
|
// type (e.g. "SendDiceParams").
|
|
func fieldTypeOverride(parent, fieldName string) string {
|
|
if parent == "" {
|
|
return ""
|
|
}
|
|
return fieldTypeOverrides[parent+"."+fieldName]
|
|
}
|
|
|
|
// discriminatorSpec describes how to decode a sealed-interface union by
|
|
// peeking at a single JSON field.
|
|
type discriminatorSpec struct {
|
|
Field string // JSON field name to peek at
|
|
Variants map[string]string // discriminator value → concrete Go type name
|
|
}
|
|
|
|
// knownDiscriminators is the hand-curated OVERRIDE table for sealed-union
|
|
// discriminators. deriveDiscriminators computes the same kind of map
|
|
// automatically from the IR for every union that has one (see
|
|
// mergeDiscriminators); an entry here always wins the merge regardless of
|
|
// what derivation would have produced. Use this table only for unions the
|
|
// generic rule can't handle — e.g. MaybeInaccessibleMessage's integer
|
|
// discriminator, which needs bespoke decode logic no discriminatorSpec
|
|
// can express (Field is "" and Variants is nil; the template emits a
|
|
// hand-coded Unmarshal via isMaybeInaccessibleMessage instead). Every
|
|
// other entry here exists only because it predates derivation — removing
|
|
// one and re-running `go test -run TestEmit -update ./cmd/genapi/...`
|
|
// should reproduce it byte-for-byte from deriveDiscriminators.
|
|
var knownDiscriminators = map[string]discriminatorSpec{
|
|
"ChatMember": {
|
|
Field: "status",
|
|
Variants: map[string]string{
|
|
"creator": "ChatMemberOwner",
|
|
"administrator": "ChatMemberAdministrator",
|
|
"member": "ChatMemberMember",
|
|
"restricted": "ChatMemberRestricted",
|
|
"left": "ChatMemberLeft",
|
|
"kicked": "ChatMemberBanned",
|
|
},
|
|
},
|
|
"MessageOrigin": {
|
|
Field: "type",
|
|
Variants: map[string]string{
|
|
"user": "MessageOriginUser",
|
|
"hidden_user": "MessageOriginHiddenUser",
|
|
"chat": "MessageOriginChat",
|
|
"channel": "MessageOriginChannel",
|
|
},
|
|
},
|
|
"ReactionType": {
|
|
Field: "type",
|
|
Variants: map[string]string{
|
|
"emoji": "ReactionTypeEmoji",
|
|
"custom_emoji": "ReactionTypeCustomEmoji",
|
|
"paid": "ReactionTypePaid",
|
|
},
|
|
},
|
|
"PaidMedia": {
|
|
Field: "type",
|
|
Variants: map[string]string{
|
|
"preview": "PaidMediaPreview",
|
|
"photo": "PaidMediaPhoto",
|
|
"video": "PaidMediaVideo",
|
|
},
|
|
},
|
|
"BackgroundType": {
|
|
Field: "type",
|
|
Variants: map[string]string{
|
|
"fill": "BackgroundTypeFill",
|
|
"wallpaper": "BackgroundTypeWallpaper",
|
|
"pattern": "BackgroundTypePattern",
|
|
"chat_theme": "BackgroundTypeChatTheme",
|
|
},
|
|
},
|
|
"BackgroundFill": {
|
|
Field: "type",
|
|
Variants: map[string]string{
|
|
"solid": "BackgroundFillSolid",
|
|
"gradient": "BackgroundFillGradient",
|
|
"freeform_gradient": "BackgroundFillFreeformGradient",
|
|
},
|
|
},
|
|
"ChatBoostSource": {
|
|
Field: "source",
|
|
Variants: map[string]string{
|
|
"premium": "ChatBoostSourcePremium",
|
|
"gift_code": "ChatBoostSourceGiftCode",
|
|
"giveaway": "ChatBoostSourceGiveaway",
|
|
},
|
|
},
|
|
"RevenueWithdrawalState": {
|
|
Field: "type",
|
|
Variants: map[string]string{
|
|
"pending": "RevenueWithdrawalStatePending",
|
|
"succeeded": "RevenueWithdrawalStateSucceeded",
|
|
"failed": "RevenueWithdrawalStateFailed",
|
|
},
|
|
},
|
|
"TransactionPartner": {
|
|
Field: "type",
|
|
Variants: map[string]string{
|
|
"fragment": "TransactionPartnerFragment",
|
|
"user": "TransactionPartnerUser",
|
|
"telegram_ads": "TransactionPartnerTelegramAds",
|
|
"telegram_api": "TransactionPartnerTelegramApi",
|
|
"other": "TransactionPartnerOther",
|
|
},
|
|
},
|
|
"MenuButton": {
|
|
Field: "type",
|
|
Variants: map[string]string{
|
|
"commands": "MenuButtonCommands",
|
|
"web_app": "MenuButtonWebApp",
|
|
"default": "MenuButtonDefault",
|
|
},
|
|
},
|
|
"OwnedGift": {
|
|
Field: "type",
|
|
Variants: map[string]string{
|
|
"regular": "OwnedGiftRegular",
|
|
"unique": "OwnedGiftUnique",
|
|
},
|
|
},
|
|
"StoryAreaType": {
|
|
Field: "type",
|
|
Variants: map[string]string{
|
|
"location": "StoryAreaTypeLocation",
|
|
"suggested_reaction": "StoryAreaTypeSuggestedReaction",
|
|
"link": "StoryAreaTypeLink",
|
|
"weather": "StoryAreaTypeWeather",
|
|
"unique_gift": "StoryAreaTypeUniqueGift",
|
|
},
|
|
},
|
|
// MaybeInaccessibleMessage uses an integer discriminator (date field).
|
|
// Variants is nil — the standard template block is skipped; a
|
|
// hand-coded UnmarshalMaybeInaccessibleMessage is emitted instead.
|
|
"MaybeInaccessibleMessage": {
|
|
Field: "",
|
|
Variants: nil,
|
|
},
|
|
}
|
|
|
|
// emitter renders Go source from a spec.API IR.
|
|
type emitter struct {
|
|
api *spec.API
|
|
outDir string
|
|
enums *enumPlan
|
|
// variantDiscs maps a concrete variant type name (e.g.
|
|
// "BotCommandScopeAllPrivateChats") to its discriminator wire-field
|
|
// + value. Populated once at construction; consulted by the types
|
|
// template to emit per-variant MarshalJSON that hardcodes the
|
|
// discriminator so callers don't have to set it by hand.
|
|
variantDiscs map[string]variantDiscriminator
|
|
// discs is the merged union-level discriminator table: derivation
|
|
// output with knownDiscriminators layered on top as overrides (see
|
|
// mergeDiscriminators). Every codegen path that decides whether a
|
|
// union auto-decodes — and how — consults this instead of the
|
|
// package-level knownDiscriminators map, so a union qualifies the
|
|
// moment deriveDiscriminators can resolve it, hand-curated or not.
|
|
discs map[string]discriminatorSpec
|
|
}
|
|
|
|
func newEmitter(api *spec.API, outDir string) *emitter {
|
|
knownInterfaceTypes = buildUnionTypeSet(api)
|
|
derived, _ := deriveDiscriminators(api)
|
|
return &emitter{
|
|
api: api,
|
|
outDir: outDir,
|
|
enums: planEnums(api),
|
|
variantDiscs: variantDiscriminators(api),
|
|
discs: mergeDiscriminators(derived),
|
|
}
|
|
}
|
|
|
|
// variantDiscriminator describes the JSON field+value that identifies a
|
|
// concrete variant of a sealed-interface union on the wire.
|
|
type variantDiscriminator struct {
|
|
JSONField string // wire field name, e.g. "type" or "source"
|
|
GoField string // Go struct field name, e.g. "Type" or "Source"
|
|
Value string // the wire value, e.g. "all_private_chats"
|
|
}
|
|
|
|
// variantDiscriminators returns variantTypeName → discriminator for every
|
|
// concrete struct that participates in a sealed-interface union and has
|
|
// a string-typed first field whose doc fixes its value (the canonical
|
|
// "must be X" / "always “X”" patterns Telegram uses).
|
|
//
|
|
// Resolution order:
|
|
//
|
|
// 1. knownDiscriminators reverse-lookup (the 13 auto-decode unions).
|
|
// This guarantees parity with UnmarshalXxx dispatch for the unions
|
|
// that round-trip through the library.
|
|
// 2. Doc-string analysis of the variant's first field, for marker-only
|
|
// unions (BotCommandScope, InputMedia, etc.) where the IR has no
|
|
// explicit discriminator metadata.
|
|
//
|
|
// Variants whose first field has no discriminator hint (Message,
|
|
// InaccessibleMessage, the InputMessageContent family) are omitted —
|
|
// the caller writes the dispatching fields directly and Telegram
|
|
// identifies them structurally.
|
|
func variantDiscriminators(api *spec.API) map[string]variantDiscriminator {
|
|
out := make(map[string]variantDiscriminator, 128)
|
|
|
|
// Pass 1: reverse-lookup from knownDiscriminators.
|
|
for _, ds := range knownDiscriminators {
|
|
if ds.Field == "" {
|
|
continue
|
|
}
|
|
for value, variant := range ds.Variants {
|
|
out[variant] = variantDiscriminator{
|
|
JSONField: ds.Field,
|
|
Value: value,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build the set of every variant type referenced by any OneOf so we
|
|
// can scan only those (avoids matching free-text "must be" prose in
|
|
// non-variant types like Message).
|
|
variantSet := make(map[string]bool, 128)
|
|
for _, t := range api.Types {
|
|
for _, v := range t.OneOf {
|
|
variantSet[v] = true
|
|
}
|
|
}
|
|
|
|
// Pass 2: doc-parse for variants without a known discriminator.
|
|
for _, t := range api.Types {
|
|
if !variantSet[t.Name] {
|
|
continue
|
|
}
|
|
if _, ok := out[t.Name]; ok {
|
|
// Pass-1 already provided the wire value; we still need
|
|
// the Go field name (mirrors the JSON field but with
|
|
// proper case). Resolve from t.Fields by JSONName match.
|
|
disc := out[t.Name]
|
|
for _, f := range t.Fields {
|
|
if f.JSONName == disc.JSONField {
|
|
disc.GoField = f.Name
|
|
out[t.Name] = disc
|
|
break
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
disc, ok := extractVariantDiscriminator(t)
|
|
if !ok {
|
|
continue
|
|
}
|
|
out[t.Name] = disc
|
|
}
|
|
|
|
// Drop entries we couldn't resolve a Go field for (defensive — every
|
|
// pass-1 hit should have matched, but better to skip than emit
|
|
// broken code referencing an unknown field name).
|
|
for name, d := range out {
|
|
if d.GoField == "" {
|
|
delete(out, name)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// extractVariantDiscriminator inspects the first field of a variant
|
|
// struct and returns its discriminator if the field is a required
|
|
// string whose doc nails the value via "must be X" or "always “X”".
|
|
// Returns (zero, false) when no clear discriminator is present.
|
|
func extractVariantDiscriminator(t spec.TypeDecl) (variantDiscriminator, bool) {
|
|
if len(t.Fields) == 0 {
|
|
return variantDiscriminator{}, false
|
|
}
|
|
f := t.Fields[0]
|
|
if !f.Required || f.Type.Kind != spec.KindPrimitive || f.Type.Name != "string" {
|
|
return variantDiscriminator{}, false
|
|
}
|
|
value := parseDiscriminatorDoc(f.Doc)
|
|
if value == "" {
|
|
return variantDiscriminator{}, false
|
|
}
|
|
return variantDiscriminator{
|
|
JSONField: f.JSONName,
|
|
GoField: f.Name,
|
|
Value: value,
|
|
}, true
|
|
}
|
|
|
|
// parseDiscriminatorDoc extracts the wire-level discriminator value
|
|
// from a field doc string. Handles both Telegram phrasings:
|
|
//
|
|
// - "Scope type, must be all_private_chats" (bare token)
|
|
// - "Type of the message origin, always “user”" (curly-quoted)
|
|
//
|
|
// Returns "" when no discriminator is present.
|
|
func parseDiscriminatorDoc(doc string) string {
|
|
// Curly-quoted form takes priority: "must be “X”" or "always “X”".
|
|
if m := discCurlyRE.FindStringSubmatch(doc); len(m) == 2 {
|
|
return m[1]
|
|
}
|
|
// Bare-token form: "must be <ident>" terminated by end-of-string,
|
|
// punctuation, or whitespace.
|
|
if m := discBareRE.FindStringSubmatch(doc); len(m) == 2 {
|
|
return m[1]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// discriminatorFieldCandidates returns the Go field names to try, in
|
|
// priority order, when looking for a discriminator shared by every
|
|
// variant of a union: the hand-curated field from knownDiscriminators
|
|
// (resolved to its Go name via the first variant that declares it) when
|
|
// the union already has an override entry, then "Type", "Status",
|
|
// "Source" — the three names Telegram's docs consistently use for this
|
|
// purpose. Shared by planUnifiedUnionEnums (enum naming, enums.go) and
|
|
// deriveDiscriminators (decode dispatch, below) so the two never disagree
|
|
// on where to look.
|
|
func discriminatorFieldCandidates(unionName string, variants []*spec.TypeDecl) []string {
|
|
var candidates []string
|
|
seen := map[string]bool{}
|
|
add := func(name string) {
|
|
if name == "" || seen[name] {
|
|
return
|
|
}
|
|
seen[name] = true
|
|
candidates = append(candidates, name)
|
|
}
|
|
if ds, ok := knownDiscriminators[unionName]; ok && ds.Field != "" {
|
|
for _, v := range variants {
|
|
for _, f := range v.Fields {
|
|
if f.JSONName == ds.Field {
|
|
add(f.Name)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
add("Type")
|
|
add("Status")
|
|
add("Source")
|
|
return candidates
|
|
}
|
|
|
|
// fieldByGoName returns the field named name on fields, or nil if none matches.
|
|
func fieldByGoName(fields []spec.Field, name string) *spec.Field {
|
|
for i := range fields {
|
|
if fields[i].Name == name {
|
|
return &fields[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// discriminatorValue extracts the wire-level value a variant field
|
|
// contributes to a discriminator: a single-entry EnumValues list (the
|
|
// scraper's detected value), or a doc-parsed "must be X" / "always “X”"
|
|
// literal via parseDiscriminatorDoc (the same extraction
|
|
// extractVariantDiscriminator uses for the first-field case). Returns ""
|
|
// when the field isn't a required string or carries no resolvable value.
|
|
func discriminatorValue(f spec.Field) string {
|
|
if !f.Required || f.Type.Kind != spec.KindPrimitive || f.Type.Name != "string" {
|
|
return ""
|
|
}
|
|
if len(f.EnumValues) == 1 {
|
|
return f.EnumValues[0]
|
|
}
|
|
return parseDiscriminatorDoc(f.Doc)
|
|
}
|
|
|
|
// deriveDiscriminators inspects every union TypeDecl (len(OneOf) > 0) in
|
|
// api and tries to pick a discriminator JSON field automatically, so the
|
|
// hand-maintained knownDiscriminators map no longer has to name every
|
|
// union that qualifies. A union qualifies when, for some candidate field
|
|
// (see discriminatorFieldCandidates), EVERY variant declares that field
|
|
// and discriminatorValue resolves a non-empty wire value for it, and
|
|
// those values are unique across variants.
|
|
//
|
|
// Returns the derived map plus the names of unions that did not qualify,
|
|
// in declaration order. Callers merge the derived map with
|
|
// knownDiscriminators (mergeDiscriminators) and decide what to do about
|
|
// the leftovers — rescue via a hand-curated override, or fail the build
|
|
// if the union is reachable from real Telegram data (checkDiscriminatorGate).
|
|
func deriveDiscriminators(api *spec.API) (map[string]discriminatorSpec, []string) {
|
|
typeByName := make(map[string]*spec.TypeDecl, len(api.Types))
|
|
for i := range api.Types {
|
|
typeByName[api.Types[i].Name] = &api.Types[i]
|
|
}
|
|
|
|
derived := make(map[string]discriminatorSpec, 32)
|
|
var ungated []string
|
|
|
|
for i := range api.Types {
|
|
u := &api.Types[i]
|
|
if len(u.OneOf) == 0 {
|
|
continue
|
|
}
|
|
|
|
variants := make([]*spec.TypeDecl, 0, len(u.OneOf))
|
|
resolved := true
|
|
for _, vName := range u.OneOf {
|
|
v, found := typeByName[vName]
|
|
if !found {
|
|
resolved = false
|
|
break
|
|
}
|
|
variants = append(variants, v)
|
|
}
|
|
if !resolved {
|
|
ungated = append(ungated, u.Name)
|
|
continue
|
|
}
|
|
|
|
ds, qualified := deriveUnionDiscriminator(u.Name, variants)
|
|
if !qualified {
|
|
ungated = append(ungated, u.Name)
|
|
continue
|
|
}
|
|
derived[u.Name] = ds
|
|
}
|
|
return derived, ungated
|
|
}
|
|
|
|
// deriveUnionDiscriminator tries each candidate Go field name in
|
|
// priority order and returns the first one where every variant declares
|
|
// that field with a resolvable, unique wire value.
|
|
func deriveUnionDiscriminator(unionName string, variants []*spec.TypeDecl) (discriminatorSpec, bool) {
|
|
for _, goName := range discriminatorFieldCandidates(unionName, variants) {
|
|
jsonField := ""
|
|
values := make(map[string]string, len(variants)) // wire value -> variant Go type name
|
|
qualifies := true
|
|
for _, v := range variants {
|
|
f := fieldByGoName(v.Fields, goName)
|
|
if f == nil {
|
|
qualifies = false
|
|
break
|
|
}
|
|
value := discriminatorValue(*f)
|
|
if value == "" {
|
|
qualifies = false
|
|
break
|
|
}
|
|
if jsonField == "" {
|
|
jsonField = f.JSONName
|
|
} else if f.JSONName != jsonField {
|
|
qualifies = false
|
|
break
|
|
}
|
|
if _, dup := values[value]; dup {
|
|
qualifies = false
|
|
break
|
|
}
|
|
values[value] = v.Name
|
|
}
|
|
if qualifies {
|
|
return discriminatorSpec{Field: jsonField, Variants: values}, true
|
|
}
|
|
}
|
|
return discriminatorSpec{}, false
|
|
}
|
|
|
|
// mergeDiscriminators layers the hand-curated knownDiscriminators table
|
|
// on top of derived: every entry deriveDiscriminators produces is used
|
|
// as-is unless knownDiscriminators also names that union, in which case
|
|
// the hand-curated entry wins outright (the whole discriminatorSpec, not
|
|
// a per-key merge).
|
|
func mergeDiscriminators(derived map[string]discriminatorSpec) map[string]discriminatorSpec {
|
|
merged := make(map[string]discriminatorSpec, len(derived)+len(knownDiscriminators))
|
|
for name, ds := range derived {
|
|
merged[name] = ds
|
|
}
|
|
for name, ds := range knownDiscriminators {
|
|
merged[name] = ds
|
|
}
|
|
return merged
|
|
}
|
|
|
|
// isInboundReachable reports whether Telegram can actually send data
|
|
// typed as the named union to the bot: as a field on some type that
|
|
// isn't itself send-only, or as a method's return value. "Input*"-named
|
|
// unions are excluded outright regardless of where they're referenced —
|
|
// by Telegram's own naming convention in this API, every such union is
|
|
// something the bot constructs and marshals, never something it decodes.
|
|
func isInboundReachable(api *spec.API, unionName string) bool {
|
|
if strings.HasPrefix(unionName, "Input") {
|
|
return false
|
|
}
|
|
for _, m := range api.Methods {
|
|
if typeRefIsUnion(m.Returns, api, unionName) {
|
|
return true
|
|
}
|
|
}
|
|
for _, t := range api.Types {
|
|
if strings.HasPrefix(t.Name, "Input") {
|
|
continue // send-only struct; its fields are never decoded either.
|
|
}
|
|
for _, f := range t.Fields {
|
|
if typeRefIsUnion(f.Type, api, unionName) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// typeRefIsUnion reports whether tr resolves to the named union: directly,
|
|
// as an array element, or via an inline oneOf variant-set match against
|
|
// unionName's own OneOf list.
|
|
func typeRefIsUnion(tr spec.TypeRef, api *spec.API, unionName string) bool {
|
|
switch tr.Kind {
|
|
case spec.KindNamed:
|
|
return tr.Name == unionName
|
|
case spec.KindArray:
|
|
if tr.ElemType != nil {
|
|
return typeRefIsUnion(*tr.ElemType, api, unionName)
|
|
}
|
|
case spec.KindOneOf:
|
|
for i := range api.Types {
|
|
if api.Types[i].Name == unionName && matchesVariants(tr.Variants, api.Types[i].OneOf...) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// checkDiscriminatorGate fails codegen when a union Telegram can actually
|
|
// send to the bot has no usable discriminator — neither derived nor
|
|
// hand-curated. Such a union decodes to a bare interface field that no
|
|
// JSON codec can ever fill (the RichBlock/RichText Bot API v10
|
|
// regression this generalisation fixes: both had 20+ variants and no
|
|
// knownDiscriminators entry, so genapi silently emitted no UnmarshalXxx
|
|
// helper at all). Send-only "Input*" unions are exempt — see
|
|
// isInboundReachable.
|
|
func checkDiscriminatorGate(api *spec.API) error {
|
|
_, ungated := deriveDiscriminators(api)
|
|
|
|
typeByName := make(map[string]*spec.TypeDecl, len(api.Types))
|
|
for i := range api.Types {
|
|
typeByName[api.Types[i].Name] = &api.Types[i]
|
|
}
|
|
|
|
var offending []string
|
|
for _, name := range ungated {
|
|
if _, overridden := knownDiscriminators[name]; overridden {
|
|
continue // hand-curated rescue, e.g. MaybeInaccessibleMessage.
|
|
}
|
|
if isInboundReachable(api, name) {
|
|
offending = append(offending, name)
|
|
}
|
|
}
|
|
if len(offending) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "genapi: %d inbound union(s) have no derivable or hand-curated discriminator "+
|
|
"and would decode as an unfillable interface field:", len(offending))
|
|
for _, name := range offending {
|
|
fmt.Fprintf(&b, "\n - %s (variants: %s): no single field is present, required, and holds a unique non-empty wire value on every variant",
|
|
name, strings.Join(typeByName[name].OneOf, ", "))
|
|
}
|
|
return errors.New(b.String())
|
|
}
|
|
|
|
// knownInterfaceTypes is the full set of sealed-interface union type names
|
|
// (both auto-decoded ones in knownDiscriminators and marker-only ones from
|
|
// types with OneOf). Populated at emitter construction. goType and
|
|
// unionTypeFor consult this so optional fields of any union type stay
|
|
// bare interface, never *Interface (which is meaningless in Go and trips
|
|
// users at every call site).
|
|
var knownInterfaceTypes = map[string]bool{}
|
|
|
|
// emitTypes renders types.gen.go.
|
|
func (e *emitter) emitTypes() error {
|
|
t, err := template.New("types").Funcs(funcsWithDiscs(e.enums, e.variantDiscs, e.discs)).Parse(typesTmpl)
|
|
if err != nil {
|
|
return fmt.Errorf("parse types.tmpl: %w", err)
|
|
}
|
|
filtered := *e.api
|
|
filtered.Types = nil
|
|
for _, typ := range e.api.Types {
|
|
if !runtimeTypes[typ.Name] {
|
|
filtered.Types = append(filtered.Types, typ)
|
|
}
|
|
}
|
|
var buf bytes.Buffer
|
|
if execErr := t.Execute(&buf, &filtered); execErr != nil {
|
|
return fmt.Errorf("execute types.tmpl: %w", execErr)
|
|
}
|
|
src, err := format.Source(buf.Bytes())
|
|
if err != nil {
|
|
// Surface the unformatted output so debugging is possible.
|
|
return fmt.Errorf("gofmt types.gen.go: %w\n--- unformatted ---\n%s", err, buf.String())
|
|
}
|
|
return os.WriteFile(filepath.Join(e.outDir, "types.gen.go"), src, 0o600)
|
|
}
|
|
|
|
// loadAPI reads and decodes the IR JSON.
|
|
func loadAPI(path string) (*spec.API, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var api spec.API
|
|
if err := json.Unmarshal(data, &api); err != nil {
|
|
return nil, err
|
|
}
|
|
return &api, nil
|
|
}
|
|
|
|
// funcsWithDiscs returns the shared FuncMap with the variant
|
|
// discriminator helpers bound to variantDiscs. types.tmpl uses
|
|
// variantDiscFor/variantHasDisc to emit per-variant MarshalJSON that
|
|
// hardcodes the wire discriminator value. unionDiscs is the merged
|
|
// union-level table (see mergeDiscriminators), threaded into funcs.
|
|
func funcsWithDiscs(plan *enumPlan, variantDiscs map[string]variantDiscriminator, unionDiscs map[string]discriminatorSpec) template.FuncMap {
|
|
fm := funcs(plan, unionDiscs)
|
|
fm["variantHasDisc"] = func(name string) bool {
|
|
_, ok := variantDiscs[name]
|
|
return ok
|
|
}
|
|
fm["variantDiscField"] = func(name string) string { return variantDiscs[name].JSONField }
|
|
fm["variantDiscGoField"] = func(name string) string { return variantDiscs[name].GoField }
|
|
fm["variantDiscValue"] = func(name string) string { return variantDiscs[name].Value }
|
|
return fm
|
|
}
|
|
|
|
// funcs is the FuncMap shared across templates. plan is the resolved
|
|
// enum plan; pass nil only in unit tests that don't exercise enums. discs
|
|
// is the merged union-level discriminator table (see
|
|
// mergeDiscriminators); pass nil (or knownDiscriminators directly) in
|
|
// unit tests that only exercise the hand-curated unions.
|
|
func funcs(plan *enumPlan, discs map[string]discriminatorSpec) template.FuncMap {
|
|
return template.FuncMap{
|
|
"goType": goType,
|
|
"goField": func(parent string, f spec.Field) string {
|
|
return goField(plan, parent, f)
|
|
},
|
|
"goFieldP": func(methodName string, f spec.Field) string {
|
|
return goFieldX(plan, methodEnumParent(methodName), title(methodName)+"Params", f)
|
|
},
|
|
"docComment": docComment,
|
|
"isOptional": func(f spec.Field) bool { return !f.Required },
|
|
"not": func(b bool) bool { return !b },
|
|
"title": title,
|
|
"isFileField": isFileField,
|
|
"fileCheck": fileCheck,
|
|
"multipartFieldEntry": func(parent string, f spec.Field) string {
|
|
return multipartFieldEntry(plan, parent, f)
|
|
},
|
|
"multipartFieldEntryP": func(methodName string, f spec.Field) string {
|
|
return multipartFieldEntryX(plan, methodEnumParent(methodName), title(methodName)+"Params", f)
|
|
},
|
|
"multipartFileEntry": multipartFileEntry,
|
|
"returnGoType": returnGoType,
|
|
// enum helpers
|
|
"enums": func() []enumDecl {
|
|
if plan == nil {
|
|
return nil
|
|
}
|
|
return plan.All()
|
|
},
|
|
"enumConstName": constName,
|
|
// discriminator helpers for types.tmpl — all consult the merged
|
|
// table (discs), not the package-level knownDiscriminators, so a
|
|
// derived-only union gates exactly like a hand-curated one.
|
|
"hasDiscriminator": func(name string) bool { s, ok := discs[name]; return ok && len(s.Variants) > 0 },
|
|
"isSealedUnionReturn": func(tr spec.TypeRef) bool {
|
|
if tr.Kind != spec.KindNamed {
|
|
return false
|
|
}
|
|
s, ok := discs[tr.Name]
|
|
return ok && len(s.Variants) > 0
|
|
},
|
|
"isSealedUnionArrayReturn": func(tr spec.TypeRef) bool {
|
|
if tr.Kind != spec.KindArray || tr.ElemType == nil || tr.ElemType.Kind != spec.KindNamed {
|
|
return false
|
|
}
|
|
s, ok := discs[tr.ElemType.Name]
|
|
return ok && len(s.Variants) > 0
|
|
},
|
|
"sealedUnionElemName": func(tr spec.TypeRef) string {
|
|
if tr.Kind == spec.KindArray && tr.ElemType != nil {
|
|
return tr.ElemType.Name
|
|
}
|
|
return ""
|
|
},
|
|
"isMaybeInaccessibleMessage": func(name string) bool { return name == "MaybeInaccessibleMessage" },
|
|
"discriminatorField": func(name string) string { return discs[name].Field },
|
|
"discriminatorMap": func(name string) map[string]string { return discs[name].Variants },
|
|
// union-field helpers for per-struct UnmarshalJSON emission
|
|
"unionFields": func(t spec.TypeDecl) []unionField { return unionFieldsOf(t, discs) },
|
|
"isArrayUnion": func(tr spec.TypeRef) bool { return hasUnionElem(tr, discs) },
|
|
"unionTypeName": func(tr spec.TypeRef) string { name, _ := unionTypeFor(tr, discs); return name },
|
|
"unionAlternates": unionAlternates,
|
|
}
|
|
}
|
|
|
|
// title upper-cases the first byte of s (ASCII only — all Telegram method names are ASCII).
|
|
func title(s string) string {
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
r := s[0]
|
|
if r >= 'a' && r <= 'z' {
|
|
r = r - 'a' + 'A'
|
|
}
|
|
return string(r) + s[1:]
|
|
}
|
|
|
|
// isFileField reports whether the field carries an InputFile.
|
|
func isFileField(f spec.Field) bool {
|
|
return mentionsInputFileTr(f.Type)
|
|
}
|
|
|
|
func mentionsInputFileTr(tr spec.TypeRef) bool {
|
|
switch tr.Kind {
|
|
case spec.KindNamed:
|
|
return tr.Name == "InputFile"
|
|
case spec.KindArray:
|
|
if tr.ElemType != nil {
|
|
return mentionsInputFileTr(*tr.ElemType)
|
|
}
|
|
case spec.KindOneOf:
|
|
for _, v := range tr.Variants {
|
|
if v == "InputFile" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// fileCheck returns the HasFile guard line for a file-carrying field.
|
|
// Both named InputFile and InputFile-or-String oneOf fields are now *InputFile,
|
|
// so no type assertion is needed in either case.
|
|
func fileCheck(f spec.Field) string {
|
|
return fmt.Sprintf("\tif p.%s != nil && p.%s.IsLocalUpload() { return true }\n", f.Name, f.Name)
|
|
}
|
|
|
|
// multipartFileEntry returns the MultipartFiles append block for a file field.
|
|
// Both named InputFile and InputFile-or-String oneOf fields are now *InputFile,
|
|
// so the same code works for both cases.
|
|
func multipartFileEntry(f spec.Field) string {
|
|
jsonName := f.JSONName
|
|
return fmt.Sprintf(
|
|
"\tif p.%s != nil && p.%s.IsLocalUpload() {\n\t\tname := p.%s.Filename\n\t\tif name == \"\" { name = %q }\n\t\tfiles = append(files, client.MultipartFile{FieldName: %q, Filename: name, Reader: p.%s.Reader})\n\t}\n",
|
|
f.Name, f.Name, f.Name, jsonName, jsonName, f.Name)
|
|
}
|
|
|
|
// multipartFieldEntry generates the line that adds f to the multipart map.
|
|
// Required scalar fields go in unconditionally; optional ones go in only
|
|
// when non-zero/non-empty. Typed-string enum fields are cast to string
|
|
// before assignment because the multipart map is map[string]string.
|
|
func multipartFieldEntry(plan *enumPlan, parent string, f spec.Field) string {
|
|
return multipartFieldEntryX(plan, parent, parent, f)
|
|
}
|
|
|
|
// multipartFieldEntryX mirrors goFieldX: enumParent keys the enum plan,
|
|
// overrideParent keys fieldTypeOverrides. They differ only for method
|
|
// params.
|
|
func multipartFieldEntryX(plan *enumPlan, enumParent, overrideParent string, f spec.Field) string {
|
|
enumName := plan.FieldEnum(enumParent, f.Name)
|
|
if enumName == "" {
|
|
enumName = fieldTypeOverride(overrideParent, f.Name)
|
|
}
|
|
switch f.Type.Kind {
|
|
case spec.KindPrimitive:
|
|
switch f.Type.Name {
|
|
case "int64":
|
|
if f.Required {
|
|
return fmt.Sprintf("\tout[%q] = strconv.FormatInt(p.%s, 10)\n", f.JSONName, f.Name)
|
|
}
|
|
return fmt.Sprintf("\tif p.%s != nil { out[%q] = strconv.FormatInt(*p.%s, 10) }\n", f.Name, f.JSONName, f.Name)
|
|
case "string":
|
|
if enumName != "" {
|
|
if f.Required {
|
|
return fmt.Sprintf("\tout[%q] = string(p.%s)\n", f.JSONName, f.Name)
|
|
}
|
|
return fmt.Sprintf("\tif p.%s != \"\" { out[%q] = string(p.%s) }\n", f.Name, f.JSONName, f.Name)
|
|
}
|
|
if f.Required {
|
|
return fmt.Sprintf("\tout[%q] = p.%s\n", f.JSONName, f.Name)
|
|
}
|
|
return fmt.Sprintf("\tif p.%s != \"\" { out[%q] = p.%s }\n", f.Name, f.JSONName, f.Name)
|
|
case "bool":
|
|
if f.Required {
|
|
return fmt.Sprintf("\tout[%q] = strconv.FormatBool(p.%s)\n", f.JSONName, f.Name)
|
|
}
|
|
return fmt.Sprintf("\tif p.%s != nil { out[%q] = strconv.FormatBool(*p.%s) }\n", f.Name, f.JSONName, f.Name)
|
|
case "float64":
|
|
if f.Required {
|
|
return fmt.Sprintf("\tout[%q] = strconv.FormatFloat(p.%s, 'f', -1, 64)\n", f.JSONName, f.Name)
|
|
}
|
|
return fmt.Sprintf("\tif p.%s != nil { out[%q] = strconv.FormatFloat(*p.%s, 'f', -1, 64) }\n", f.Name, f.JSONName, f.Name)
|
|
}
|
|
case spec.KindOneOf:
|
|
// Integer-or-String → ChatID: use .String() wire form.
|
|
if matchesVariants(f.Type.Variants, "int64", "string") {
|
|
if f.Required {
|
|
return fmt.Sprintf("\tout[%q] = p.%s.String()\n", f.JSONName, f.Name)
|
|
}
|
|
return fmt.Sprintf("\tif !p.%s.IsZero() { out[%q] = p.%s.String() }\n", f.Name, f.JSONName, f.Name)
|
|
}
|
|
// InputFile-or-String → *InputFile: non-upload branch sends PathOrID.
|
|
if matchesVariants(f.Type.Variants, "InputFile", "string") {
|
|
return fmt.Sprintf("\tif p.%s != nil && !p.%s.IsLocalUpload() && p.%s.PathOrID != \"\" { out[%q] = p.%s.PathOrID }\n",
|
|
f.Name, f.Name, f.Name, f.JSONName, f.Name)
|
|
}
|
|
// Sealed-interface unions — JSON-marshal.
|
|
if f.Required {
|
|
return fmt.Sprintf("\tif b, _ := json.Marshal(p.%s); len(b) > 0 && string(b) != \"null\" { out[%q] = string(b) }\n", f.Name, f.JSONName)
|
|
}
|
|
return fmt.Sprintf("\tif p.%s != nil { if b, _ := json.Marshal(p.%s); len(b) > 0 && string(b) != \"null\" { out[%q] = string(b) } }\n", f.Name, f.Name, f.JSONName)
|
|
}
|
|
// Named or array: fall back to JSON-marshal to JSON string.
|
|
if f.Required {
|
|
return fmt.Sprintf("\tif b, _ := json.Marshal(p.%s); len(b) > 0 { out[%q] = string(b) }\n", f.Name, f.JSONName)
|
|
}
|
|
return fmt.Sprintf("\tif p.%s != nil { if b, _ := json.Marshal(p.%s); len(b) > 0 { out[%q] = string(b) } }\n", f.Name, f.Name, f.JSONName)
|
|
}
|
|
|
|
func returnGoType(tr spec.TypeRef) string {
|
|
switch tr.Kind {
|
|
case spec.KindPrimitive:
|
|
return tr.Name
|
|
case spec.KindNamed:
|
|
// Sealed-interface unions are returned by interface value, not pointer
|
|
// (you can't take a pointer to an interface in any useful way; the
|
|
// generated UnmarshalXxx returns the interface directly).
|
|
if _, ok := knownDiscriminators[tr.Name]; ok {
|
|
return tr.Name
|
|
}
|
|
// MessageOrBool is a hand-coded runtime wrapper — pointer return.
|
|
return "*" + tr.Name
|
|
case spec.KindArray:
|
|
if tr.ElemType == nil {
|
|
return "[]any"
|
|
}
|
|
return "[]" + returnGoElem(*tr.ElemType)
|
|
case spec.KindOneOf:
|
|
// Integer-or-String return (rare but possible).
|
|
if matchesVariants(tr.Variants, "int64", "string") {
|
|
return "ChatID"
|
|
}
|
|
return "any"
|
|
}
|
|
return "any"
|
|
}
|
|
|
|
func returnGoElem(tr spec.TypeRef) string {
|
|
switch tr.Kind {
|
|
case spec.KindPrimitive:
|
|
return tr.Name
|
|
case spec.KindNamed:
|
|
return tr.Name
|
|
case spec.KindArray:
|
|
if tr.ElemType == nil {
|
|
return "any"
|
|
}
|
|
return "[]" + returnGoElem(*tr.ElemType)
|
|
}
|
|
return "any"
|
|
}
|
|
|
|
// emitMethods renders methods.gen.go.
|
|
func (e *emitter) emitMethods() error {
|
|
t, err := template.New("methods").Funcs(funcs(e.enums, e.discs)).Parse(methodsTmpl)
|
|
if err != nil {
|
|
return fmt.Errorf("parse methods.tmpl: %w", err)
|
|
}
|
|
var buf bytes.Buffer
|
|
if execErr := t.Execute(&buf, e.api); execErr != nil {
|
|
return fmt.Errorf("execute methods.tmpl: %w", execErr)
|
|
}
|
|
src, err := format.Source(buf.Bytes())
|
|
if err != nil {
|
|
return fmt.Errorf("gofmt methods.gen.go: %w\n--- unformatted ---\n%s", err, buf.String())
|
|
}
|
|
return os.WriteFile(filepath.Join(e.outDir, "methods.gen.go"), src, 0o600)
|
|
}
|
|
|
|
// emitEnums renders enums.gen.go.
|
|
func (e *emitter) emitEnums() error {
|
|
t, err := template.New("enums").Funcs(funcs(e.enums, e.discs)).Parse(enumsTmpl)
|
|
if err != nil {
|
|
return fmt.Errorf("parse enums.tmpl: %w", err)
|
|
}
|
|
var buf bytes.Buffer
|
|
if execErr := t.Execute(&buf, e.api); execErr != nil {
|
|
return fmt.Errorf("execute enums.tmpl: %w", execErr)
|
|
}
|
|
src, err := format.Source(buf.Bytes())
|
|
if err != nil {
|
|
return fmt.Errorf("gofmt enums.gen.go: %w\n--- unformatted ---\n%s", err, buf.String())
|
|
}
|
|
return os.WriteFile(filepath.Join(e.outDir, "enums.gen.go"), src, 0o600)
|
|
}
|
|
|
|
// goType returns the Go type expression for a TypeRef.
|
|
// Optional fields use pointer types for primitives and named types,
|
|
// or rely on omitempty for slices and maps. parameter `optional` controls
|
|
// whether to wrap pointer-style.
|
|
func goType(tr spec.TypeRef, optional bool) string {
|
|
switch tr.Kind {
|
|
case spec.KindPrimitive:
|
|
if optional && (tr.Name == "bool" || tr.Name == "int64" || tr.Name == "float64") {
|
|
return "*" + tr.Name
|
|
}
|
|
return tr.Name
|
|
case spec.KindNamed:
|
|
// Named types are always pointer-optional when optional, except:
|
|
// 1. Union (interface) types — they are naturally nil-able; pointer-to-interface is invalid.
|
|
// 2. InputFile is always pointer-typed even when required: the
|
|
// multipart helpers (fileCheck, multipartFileEntry) call
|
|
// f.IsLocalUpload() and dereference Reader, both of which
|
|
// expect a pointer receiver.
|
|
if knownInterfaceTypes[tr.Name] {
|
|
// Interface type — never add *.
|
|
return tr.Name
|
|
}
|
|
if optional || tr.Name == "InputFile" {
|
|
return "*" + tr.Name
|
|
}
|
|
return tr.Name
|
|
case spec.KindArray:
|
|
if tr.ElemType == nil {
|
|
return "[]any"
|
|
}
|
|
// Inside slices, the element shape is its own thing — never wrap
|
|
// the element in a pointer just because the field is optional.
|
|
return "[]" + goType(*tr.ElemType, false)
|
|
case spec.KindOneOf:
|
|
// Integer-or-String: typed ChatID wrapper.
|
|
if matchesVariants(tr.Variants, "int64", "string") {
|
|
if optional {
|
|
return "*ChatID"
|
|
}
|
|
return "ChatID"
|
|
}
|
|
// InputFile-or-String: *InputFile runtime helper handles both.
|
|
if matchesVariants(tr.Variants, "InputFile", "string") {
|
|
return "*InputFile"
|
|
}
|
|
// All-named variants sealed interface: fall back to interface.
|
|
return "any"
|
|
}
|
|
return "any"
|
|
}
|
|
|
|
// unionField pairs a struct field with the name of its union type.
|
|
type unionField struct {
|
|
Field spec.Field
|
|
UnionName string // e.g. "ChatMember"
|
|
}
|
|
|
|
// unionFieldsOf returns the subset of t.Fields whose type is a known
|
|
// discriminated union (directly or as array element), per discs — the
|
|
// merged union-level discriminator table (see mergeDiscriminators).
|
|
func unionFieldsOf(t spec.TypeDecl, discs map[string]discriminatorSpec) []unionField {
|
|
var out []unionField
|
|
for _, f := range t.Fields {
|
|
if u, ok := unionTypeFor(f.Type, discs); ok {
|
|
out = append(out, unionField{Field: f, UnionName: u})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// unionTypeFor inspects a TypeRef and reports whether it (or its array
|
|
// element) is a known discriminated union in discs. Returns the union
|
|
// name and true.
|
|
func unionTypeFor(tr spec.TypeRef, discs map[string]discriminatorSpec) (string, bool) {
|
|
switch tr.Kind {
|
|
case spec.KindNamed:
|
|
if _, ok := discs[tr.Name]; ok {
|
|
return tr.Name, true
|
|
}
|
|
case spec.KindArray:
|
|
if tr.ElemType != nil {
|
|
return unionTypeFor(*tr.ElemType, discs)
|
|
}
|
|
case spec.KindOneOf:
|
|
if u := unionNameByVariants(tr.Variants, discs); u != "" {
|
|
return u, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// unionNameByVariants finds the parent union whose variant type names exactly
|
|
// match the given variant set (order-insensitive).
|
|
func unionNameByVariants(variants []string, discs map[string]discriminatorSpec) string {
|
|
for parentName, ds := range discs {
|
|
wanted := make([]string, 0, len(ds.Variants))
|
|
for _, vt := range ds.Variants {
|
|
wanted = append(wanted, vt)
|
|
}
|
|
if matchesVariants(variants, wanted...) {
|
|
return parentName
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// hasUnionElem reports whether tr is an array whose element type is a known union.
|
|
func hasUnionElem(tr spec.TypeRef, discs map[string]discriminatorSpec) bool {
|
|
if tr.Kind != spec.KindArray || tr.ElemType == nil {
|
|
return false
|
|
}
|
|
_, ok := unionTypeFor(*tr.ElemType, discs)
|
|
return ok
|
|
}
|
|
|
|
// alternateVariant describes one synthetic sealed-interface variant the
|
|
// generator emits for a union's non-object wire shape (see
|
|
// spec.Alternate): a bare-JSON-string variant, or a variant holding a
|
|
// JSON array of the union itself. Exactly one of IsString / IsArray is
|
|
// set. types.tmpl emits the struct, marker method, and MarshalJSON for
|
|
// each, and Unmarshal<Union> dispatches to it by inspecting the first
|
|
// non-whitespace byte of the wire value.
|
|
type alternateVariant struct {
|
|
Name string // synthetic Go type name, e.g. "RichTextPlain"
|
|
IsString bool
|
|
IsArray bool
|
|
}
|
|
|
|
// unionAlternates maps a union TypeDecl's spec.Alternate entries (see
|
|
// cmd/scrape's extractAlternates) to the synthetic Go variant
|
|
// descriptors types.tmpl emits alongside the regular OneOf variants:
|
|
// "<Union>Plain" for the bare-string shape, "<Union>Sequence" for the
|
|
// array-of-<Union> shape. Returns nil for a union with no alternates,
|
|
// which is every union except RichText as of Bot API v10 — so this is a
|
|
// no-op for the rest of the type list.
|
|
func unionAlternates(t spec.TypeDecl) []alternateVariant {
|
|
if len(t.Alternates) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]alternateVariant, 0, len(t.Alternates))
|
|
for _, a := range t.Alternates {
|
|
switch a.Shape {
|
|
case spec.AlternateString:
|
|
out = append(out, alternateVariant{Name: t.Name + "Plain", IsString: true})
|
|
case spec.AlternateArray:
|
|
out = append(out, alternateVariant{Name: t.Name + "Sequence", IsArray: true})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// matchesVariants reports whether got equals want as a set (order-insensitive).
|
|
func matchesVariants(got []string, want ...string) bool {
|
|
if len(got) != len(want) {
|
|
return false
|
|
}
|
|
seen := make(map[string]int, len(got))
|
|
for _, g := range got {
|
|
seen[g]++
|
|
}
|
|
for _, w := range want {
|
|
seen[w]--
|
|
}
|
|
for _, v := range seen {
|
|
if v != 0 {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// goField returns the Go struct-field declaration for a Field.
|
|
// When the field carries scraper-detected enum values and the emitter
|
|
// has a planned enum name for (parent, field), the field's Go type is
|
|
// the enum identifier. Typed-string enums use the zero string ""
|
|
// behaviour for omitempty, so we do not pointer-wrap optional enum
|
|
// fields. Parent is "" for method parameters; pass the params type
|
|
// name (e.g. "SendDiceParams") via overrideParent when calling from
|
|
// the methods template so fieldTypeOverrides can resolve.
|
|
func goField(plan *enumPlan, parent string, f spec.Field) string {
|
|
return goFieldX(plan, parent, parent, f)
|
|
}
|
|
|
|
// goFieldX is the underlying field-emitter. enumParent is used for the
|
|
// enum-plan lookup (which keys method params under ""); overrideParent
|
|
// is used for fieldTypeOverrides (which keys method params under the
|
|
// params type name). For struct types both are the same; for method
|
|
// params they differ.
|
|
func goFieldX(plan *enumPlan, enumParent, overrideParent string, f spec.Field) string {
|
|
tag := fmt.Sprintf("`json:%q`", f.JSONName+omitempty(f))
|
|
if name := plan.FieldEnum(enumParent, f.Name); name != "" {
|
|
return fmt.Sprintf("%s %s %s", f.Name, name, tag)
|
|
}
|
|
if name := fieldTypeOverride(overrideParent, f.Name); name != "" {
|
|
return fmt.Sprintf("%s %s %s", f.Name, name, tag)
|
|
}
|
|
// Pinned companion-enum retype: allowed_updates is an Array of String
|
|
// in the upstream spec, but the Go API exposes a hand-curated
|
|
// UpdateType (api/enums.go) since the values are not enumerated
|
|
// inline by Telegram. Retype []string → []UpdateType wherever the
|
|
// wire field is allowed_updates so callers can pass typed constants
|
|
// (api.UpdateMessage, ...) without string casts. Wire format is
|
|
// unchanged: UpdateType is a typed string, marshals identically.
|
|
if f.JSONName == "allowed_updates" &&
|
|
f.Type.Kind == spec.KindArray &&
|
|
f.Type.ElemType != nil &&
|
|
f.Type.ElemType.Kind == spec.KindPrimitive &&
|
|
f.Type.ElemType.Name == "string" {
|
|
return fmt.Sprintf("%s []UpdateType %s", f.Name, tag)
|
|
}
|
|
return fmt.Sprintf("%s %s %s", f.Name, goType(f.Type, !f.Required), tag)
|
|
}
|
|
|
|
func omitempty(f spec.Field) string {
|
|
if f.Required {
|
|
return ""
|
|
}
|
|
return ",omitempty"
|
|
}
|
|
|
|
// docComment converts a doc string into a Go-style block comment with
|
|
// a leading "// " on each line.
|
|
func docComment(s string) string {
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
var buf bytes.Buffer
|
|
for _, line := range splitLines(s) {
|
|
buf.WriteString("// ")
|
|
buf.WriteString(line)
|
|
buf.WriteByte('\n')
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
func splitLines(s string) []string {
|
|
var out []string
|
|
start := 0
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == '\n' {
|
|
out = append(out, s[start:i])
|
|
start = i + 1
|
|
}
|
|
}
|
|
if start < len(s) {
|
|
out = append(out, s[start:])
|
|
}
|
|
return out
|
|
}
|
|
|
|
// hasVariants reports whether the variant list contains all of the named strings (order-insensitive).
|
|
func hasVariants(variants []string, names ...string) bool {
|
|
return matchesVariants(variants, names...)
|
|
}
|
|
|
|
// buildUnionTypeSet returns the set of all type names that generate interface types
|
|
// (i.e., types with one_of). This includes knownDiscriminators and marker-interface
|
|
// unions not covered by the discriminator map.
|
|
func buildUnionTypeSet(api *spec.API) map[string]bool {
|
|
s := make(map[string]bool, len(knownDiscriminators)+16)
|
|
for name := range knownDiscriminators {
|
|
s[name] = true
|
|
}
|
|
for _, t := range api.Types {
|
|
if len(t.OneOf) > 0 {
|
|
s[t.Name] = true
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// makeSentinelValue returns a sentinelValue func that uses the given union type set.
|
|
// It returns a minimal valid Go expression for a spec.Field's type,
|
|
// 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(string, spec.Field) string {
|
|
return func(methodName string, f spec.Field) string {
|
|
return sentinelForField(methodName, f, unionTypes, plan)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
case spec.KindPrimitive:
|
|
switch tr.Name {
|
|
case "int64":
|
|
return "42"
|
|
case "string":
|
|
return `"test_value"`
|
|
case "bool":
|
|
return "true"
|
|
case "float64":
|
|
return "1.0"
|
|
}
|
|
case spec.KindNamed:
|
|
switch tr.Name {
|
|
case "ChatID":
|
|
return "ChatIDFromInt(123)"
|
|
case "InputFile":
|
|
return `&InputFile{PathOrID: "file_id_test"}`
|
|
}
|
|
// Interface (union) types are nil-able.
|
|
if unionTypes[tr.Name] {
|
|
return "nil"
|
|
}
|
|
// Required named struct types are value types in the generated struct.
|
|
if f.Required {
|
|
return tr.Name + "{}"
|
|
}
|
|
return "&" + tr.Name + "{}"
|
|
case spec.KindArray:
|
|
return "nil"
|
|
case spec.KindOneOf:
|
|
if hasVariants(tr.Variants, "int64", "string") {
|
|
return "ChatIDFromInt(123)"
|
|
}
|
|
if hasVariants(tr.Variants, "InputFile", "string") {
|
|
return `&InputFile{PathOrID: "file_id_test"}`
|
|
}
|
|
// Sealed named-union interface: use nil (any).
|
|
return "nil"
|
|
}
|
|
return "nil"
|
|
}
|
|
|
|
// successResp returns a backtick Go string literal containing a minimal
|
|
// {"ok":true,"result":...} JSON body for the method's return type.
|
|
func successResp(m spec.MethodDecl) string {
|
|
body := successBody(m.Returns)
|
|
return "`{\"ok\":true,\"result\":" + body + "}`"
|
|
}
|
|
|
|
func successBody(tr spec.TypeRef) string {
|
|
switch tr.Kind {
|
|
case spec.KindPrimitive:
|
|
switch tr.Name {
|
|
case "bool":
|
|
return "true"
|
|
case "int64", "float64":
|
|
return "0"
|
|
case "string":
|
|
return `""`
|
|
}
|
|
case spec.KindNamed:
|
|
if tr.Name == "MessageOrBool" {
|
|
return "true"
|
|
}
|
|
// Sealed-interface unions need a discriminator field so UnmarshalXxx can dispatch.
|
|
// Pick the lexicographically first variant value for determinism (map
|
|
// iteration order in Go is randomized — using `range` directly produces
|
|
// non-deterministic regen output).
|
|
if disc, ok := knownDiscriminators[tr.Name]; ok && disc.Field != "" {
|
|
values := make([]string, 0, len(disc.Variants))
|
|
for v := range disc.Variants {
|
|
values = append(values, v)
|
|
}
|
|
sort.Strings(values)
|
|
if len(values) > 0 {
|
|
return fmt.Sprintf(`{"%s":"%s"}`, disc.Field, values[0])
|
|
}
|
|
}
|
|
// MaybeInaccessibleMessage uses date==0 → InaccessibleMessage variant.
|
|
if tr.Name == "MaybeInaccessibleMessage" {
|
|
return `{"date":0,"chat":{"id":1,"type":"private"},"message_id":1}`
|
|
}
|
|
return "{}"
|
|
case spec.KindArray:
|
|
return "[]"
|
|
case spec.KindOneOf:
|
|
return "null"
|
|
}
|
|
return "null"
|
|
}
|
|
|
|
// emitTests renders methods_gen_test.go.
|
|
func (e *emitter) emitTests() error {
|
|
unionTypes := buildUnionTypeSet(e.api)
|
|
|
|
// Add test-specific helpers to the shared func map.
|
|
fm := funcs(e.enums, e.discs)
|
|
fm["sentinelValue"] = makeSentinelValue(unionTypes, e.enums)
|
|
fm["successResp"] = successResp
|
|
|
|
t, err := template.New("tests").Funcs(fm).Parse(testsTmpl)
|
|
if err != nil {
|
|
return fmt.Errorf("parse tests.tmpl: %w", err)
|
|
}
|
|
var buf bytes.Buffer
|
|
if execErr := t.Execute(&buf, e.api); execErr != nil {
|
|
return fmt.Errorf("execute tests.tmpl: %w", execErr)
|
|
}
|
|
src, err := format.Source(buf.Bytes())
|
|
if err != nil {
|
|
return fmt.Errorf("gofmt methods_gen_test.go: %w\n--- unformatted ---\n%s", err, buf.String())
|
|
}
|
|
return os.WriteFile(filepath.Join(e.outDir, "methods_gen_test.go"), src, 0o600)
|
|
}
|