Files
go-telegram/cmd/genapi/emitter.go
T
lukaszraczylo 3c04d7b0b1 feat(api): typed enums for all string-enum fields
The Telegram docs describe many string fields and parameters with
phrases like "can be ..., or ...", "must be one of ...", or "always X",
yet the generated Go API surface used raw `string` for every one of
them. Callers had to write magic strings or `string(api.ChatTypePrivate)`
to satisfy the field type. This change makes those fields typed Go
string enums emitted from the IR, so the IDE autocompletes valid values
and breaking-value drift surfaces at compile time.

Pipeline changes:

- internal/spec/ir.go: Field gains EnumValues []string. Empty for non-
  enum fields; otherwise the wire-level values in doc order, deduped.

- cmd/scrape/enums.go: extractEnumValues recognises the curly-quoted
  patterns Telegram uses ("can be either", "currently can be", "one
  of", "must be", "always X") and rejects free-text quoted refs (e.g.
  "Can be available only for X") via a tight gap check between the
  trigger phrase and the first quoted value. parse_mode parameters
  get the canonical Markdown / MarkdownV2 / HTML triple injected
  because Telegram links to a separate formatting-options section
  instead of listing values inline.

- cmd/genapi/enums.go: planEnums groups fields by sorted value-tuple,
  picks a canonical Go enum name (most-common candidate, parent-
  prefixed beats plain, shortest beats longer, alphabetical for
  determinism), resolves cross-group name collisions by parent prefix.

- cmd/genapi/emitter.go + templates: goField rewrites the field type
  to the planned enum name; multipartFieldEntry casts typed enum
  values back to string when composing the wire map; enums.tmpl now
  iterates the planned enums instead of hardcoding four hand-curated
  ones; sentinelForField produces typed-constant test fixtures.

- api/enums.gen.go: regenerated from the live IR. 66 enum types, 155
  constants. ParseMode, ChatType, MessageEntityType, ChatMember /
  MessageOrigin / PaidMedia / Background / StoryAreaType / Reaction /
  TransactionPartner / PassportElement variant Status & Type fields
  are now typed.

- api/enums.go: hand-coded UpdateType (used by transport.LongPoller).
  The Telegram docs do not enumerate Update payload kinds inline, so
  the codegen pipeline cannot synthesise this enum.

- api/types.gen.go, api/methods.gen.go, api/methods_gen_test.go: 137
  field declarations rewritten string -> typed enum.

- dispatch/, examples/: dropped every string(api.<Const>) cast. The
  HasEntity filter now takes api.MessageEntityType; ChatType filter
  compares typed values directly. ChatMember discriminator filter
  casts variant.Status (typed per variant) to string for comparison.

- internal/spec/api.json, testdata/golden/*: regenerated and
  refreshed. make regen-from-fixture is byte-deterministic across
  runs.

Renames (no compat shims; v1 pre-public):
- EntityX  -> MessageEntityTypeX  (e.g. EntityBotCommand -> MessageEntityTypeBotCommand)
- EntityStrike -> MessageEntityTypeStrikethrough (full wire name)
2026-05-09 17:55:34 +01:00

785 lines
24 KiB
Go

package main
import (
"bytes"
_ "embed"
"fmt"
"github.com/goccy/go-json"
"go/format"
"os"
"path/filepath"
"sort"
"text/template"
"github.com/lukaszraczylo/go-telegram/internal/spec"
)
//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,
}
// 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 maps parent union name → discriminator spec.
// Used by the template helpers hasDiscriminator / discriminatorField /
// discriminatorMap to emit UnmarshalXxx helpers.
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
}
func newEmitter(api *spec.API, outDir string) *emitter {
return &emitter{api: api, outDir: outDir, enums: planEnums(api)}
}
// emitTypes renders types.gen.go.
func (e *emitter) emitTypes() error {
t, err := template.New("types").Funcs(funcs(e.enums)).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
}
// funcs is the FuncMap shared across templates. plan is the resolved
// enum plan; pass nil only in unit tests that don't exercise enums.
func funcs(plan *enumPlan) template.FuncMap {
return template.FuncMap{
"goType": goType,
"goField": func(parent string, f spec.Field) string {
return goField(plan, parent, 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)
},
"multipartFileEntry": multipartFileEntry,
"returnGoType": returnGoType,
// enum helpers
"enums": func() []enumDecl {
if plan == nil {
return nil
}
return plan.All()
},
"enumConstName": constName,
// discriminator helpers for types.tmpl
"hasDiscriminator": func(name string) bool { s, ok := knownDiscriminators[name]; return ok && len(s.Variants) > 0 },
"isSealedUnionReturn": func(tr spec.TypeRef) bool {
if tr.Kind != spec.KindNamed {
return false
}
s, ok := knownDiscriminators[tr.Name]
return ok && len(s.Variants) > 0
},
"isMaybeInaccessibleMessage": func(name string) bool { return name == "MaybeInaccessibleMessage" },
"discriminatorField": func(name string) string { return knownDiscriminators[name].Field },
"discriminatorMap": func(name string) map[string]string { return knownDiscriminators[name].Variants },
// union-field helpers for per-struct UnmarshalJSON emission
"unionFields": unionFieldsOf,
"isArrayUnion": func(tr spec.TypeRef) bool { return hasUnionElem(tr) },
"unionTypeName": func(tr spec.TypeRef) string { name, _ := unionTypeFor(tr); return name },
}
}
// 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 {
enumName := plan.FieldEnum(parent, 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)).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)).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 _, isUnion := knownDiscriminators[tr.Name]; isUnion {
// 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).
func unionFieldsOf(t spec.TypeDecl) []unionField {
var out []unionField
for _, f := range t.Fields {
if u, ok := unionTypeFor(f.Type); 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. Returns the union name and true.
func unionTypeFor(tr spec.TypeRef) (string, bool) {
switch tr.Kind {
case spec.KindNamed:
if _, ok := knownDiscriminators[tr.Name]; ok {
return tr.Name, true
}
case spec.KindArray:
if tr.ElemType != nil {
return unionTypeFor(*tr.ElemType)
}
case spec.KindOneOf:
if u := unionNameByVariants(tr.Variants); 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) string {
for parentName, ds := range knownDiscriminators {
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) bool {
if tr.Kind != spec.KindArray || tr.ElemType == nil {
return false
}
_, ok := unionTypeFor(*tr.ElemType)
return ok
}
// 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.
func goField(plan *enumPlan, parent string, f spec.Field) string {
tag := fmt.Sprintf("`json:%q`", f.JSONName+omitempty(f))
if name := plan.FieldEnum(parent, f.Name); name != "" {
return fmt.Sprintf("%s %s %s", f.Name, 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(spec.Field) string {
return func(f spec.Field) string {
return sentinelForField(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])
}
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)
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)
}