// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: MIT package main import ( "fmt" "io" "strconv" "strings" "time" "unicode" "unicode/utf8" "sourcedock.dev/petrbalvin/interpres/v2" ) // inferStruct reads a TOML document and writes a Go struct definition shaped // like the document: one field per key in written order, nested tables as // nested struct types, an array of tables as a slice, and the field names // invented from the keys. It is the onboarding aid: the printed type compiles // and decodes the document it came from. The definition is built whole and // written with a single call, so a failing standard output surfaces as one // error instead of being dropped mid-print. func inferStruct(data []byte, stdout io.Writer) error { doc, err := interpres.Parse(data) if err != nil { return err } body := &strings.Builder{} fmt.Fprintln(body, "// Generated by interpres-decode --struct; decode with") fmt.Fprintln(body, "// sourcedock.dev/petrbalvin/interpres/v2.") fmt.Fprintln(body, "type inferred struct {") writeInferredFields(body, tableFields(doc.Root()), map[string]bool{}) fmt.Fprintln(body, "}") _, err = io.WriteString(stdout, body.String()) return err } // inferredField is one document key with the entry it is inferred from. type inferredField struct { key string entry *interpres.Entry } // tableFields lists a table's entries in written order. func tableFields(t *interpres.Table) []inferredField { out := make([]inferredField, 0, len(t.Keys())) for _, key := range t.Keys() { entry, _ := t.Get(key) out = append(out, inferredField{key: key, entry: entry}) } return out } // mergedTableFields merges the key sets of an array's elements in first-seen // order. An array's type has to cover every element, and a key may appear // only in a later one, so the first element alone does not decide the shape; // each key is inferred from the first element that carries it. func mergedTableFields(tables []*interpres.Table) []inferredField { var out []inferredField seen := map[string]bool{} for _, t := range tables { for _, f := range tableFields(t) { if seen[f.key] { continue } seen[f.key] = true out = append(out, f) } } return out } // writeInferredFields writes one field per entry, in the order given. // invented tracks the field names already used at one level, so two keys // that clean to the same name do not collide. func writeInferredFields(w *strings.Builder, fields []inferredField, invented map[string]bool) { for _, f := range fields { writeInferredField(w, f, invented) } } // writeInferredField writes one field for one entry: an array of tables as a // slice of structs, a child table as a nested struct, and everything else as // the scalar or slice the decoded value names. func writeInferredField(w *strings.Builder, f inferredField, invented map[string]bool) { name := goFieldName(f.key, invented) // An array of tables carries a node per element; the nodes of a value // array are nil wherever an element is not a table. Every node present // is what tells the two apart: [1, {x=1}] stays a value array even // though one of its elements is a table. elements := f.entry.Elements() allTables := len(elements) > 0 for _, el := range elements { if el == nil { allTables = false break } } if allTables { fmt.Fprintf(w, "\t%s []struct {\n", name) writeInferredFields(w, mergedTableFields(elements), map[string]bool{}) fmt.Fprintf(w, "\t} %s\n", structTag(f.key)) return } if child := f.entry.Table(); child != nil { fmt.Fprintf(w, "\t%s struct {\n", name) writeInferredFields(w, tableFields(child), map[string]bool{}) fmt.Fprintf(w, "\t} %s\n", structTag(f.key)) return } val := f.entry.Value() if items, ok := val.([]any); ok { fmt.Fprintf(w, "\t%s []%s %s\n", name, inferScalarType(items), structTag(f.key)) return } fmt.Fprintf(w, "\t%s %s %s\n", name, goTypeOf(val), structTag(f.key)) } // structTag renders the toml tag of one key as a Go string literal. The raw // backtick literal is the conventional shape, but a key carrying a backtick // would end that literal early and the printed definition would not compile, // so such tags are rendered with strconv.Quote instead. func structTag(key string) string { tag := `toml:"` + key + `"` if !strings.ContainsAny(tag, "`\r") { return "`" + tag + "`" } return strconv.Quote(tag) } // goTypeOf names the Go type the decoded value asks for. func goTypeOf(val any) string { switch val.(type) { case string: return "string" case bool: return "bool" case int64: return "int64" case float64: return "float64" case interpres.OffsetDateTime: return "interpres.OffsetDateTime" case interpres.LocalDateTime: return "interpres.LocalDateTime" case interpres.LocalDate: return "interpres.LocalDate" case interpres.LocalTime: return "interpres.LocalTime" case time.Time: return "time.Time" case []any: return "[]any" case map[string]any: return "map[string]any" } return "any" } // goFieldName cleans a document key into an exported Go identifier: the // words the punctuation splits become capitalised runs, a leading digit // gains a Field prefix, because an underscore would leave the field // unexported and the decoder would skip it, and a collision with an earlier // name gains a counter. func goFieldName(key string, invented map[string]bool) string { var b strings.Builder nextUpper := true for _, r := range key { switch { case unicode.IsLetter(r) || unicode.IsDigit(r): if nextUpper { r = unicode.ToUpper(r) nextUpper = false } b.WriteRune(r) default: nextUpper = true } } name := b.String() if name == "" { name = "Field" } // The first rune is decoded rather than taken as a byte, because a key // may open with a digit beyond ASCII. if first, _ := utf8.DecodeRuneInString(name); unicode.IsDigit(first) { name = "Field" + name } for invented[name] { name += "2" } invented[name] = true return name } // inferScalarType names the Go element type of a scalar array when every // element agrees, and any when they do not. func inferScalarType(items []any) string { seen := "" for i, item := range items { t := goTypeOf(item) if i == 0 { seen = t } else if t != seen { return "any" } } if seen == "" { return "any" } return seen }