// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: MIT package main import ( "fmt" "go/ast" "go/parser" "go/token" "io" "maps" "path/filepath" "reflect" "slices" "strconv" "strings" ) // runSchema writes a TOML template for the named struct type of a Go source // file: one key per exported field, the comment a `comment=` tag option // carries printed above it, and a `default=` option as the value, or the // type's zero value where no default is given. Struct fields resolve into // [sections], slices of them into [[array of tables]] blocks, and an // untagged embedded struct flattens into its parent, the way the library // decodes it. func runSchema(typeName, sourcePath string, stdout io.Writer) error { fset := token.NewFileSet() file, err := parser.ParseFile(fset, sourcePath, nil, parser.ParseComments) if err != nil { return fmt.Errorf("%s: %w", filepath.Base(sourcePath), err) } types := declaredStructs(file) st, ok := types[typeName] if !ok { return fmt.Errorf("no struct type %q in %s", typeName, filepath.Base(sourcePath)) } body := &strings.Builder{} if err := writeSchemaFields(body, st, types, "", nil); err != nil { return err } _, err = io.WriteString(stdout, strings.TrimLeft(body.String(), "\n")) return err } // declaredStructs collects the field lists of the file's top-level struct // type declarations. func declaredStructs(file *ast.File) map[string]*ast.StructType { out := map[string]*ast.StructType{} for _, decl := range file.Decls { gd, ok := decl.(*ast.GenDecl) if !ok { continue } for _, spec := range gd.Specs { ts, ok := spec.(*ast.TypeSpec) if !ok { continue } st, ok := ts.Type.(*ast.StructType) if !ok { continue } out[ts.Name.Name] = st } } return out } // fieldMeta is what the generator reads off one struct field. type fieldMeta struct { key string comment string def string typ ast.Expr } // writeSchemaFields writes the fields of one struct level: the scalar lines // first, then the sections, so the template re-parses with every value under // the header it belongs to. prefix is the dotted path the nested headers // carry. path holds the struct types of the levels currently being written, // so a type that reaches itself is reported as recursion instead of // exhausting the stack. func writeSchemaFields(w *strings.Builder, st *ast.StructType, types map[string]*ast.StructType, prefix string, path []*ast.StructType) error { if slices.Contains(path, st) { return recursionError(st, types) } path = append(path, st) metas, err := metasOf(st, types) if err != nil { return err } for _, m := range metas { if _, elemSt := elementStruct(m.typ, types); elemSt != nil { continue } if isStructKind(m.typ, types) || isMapKind(m.typ) { continue } writeComment(w, m.comment) if _, ok := baseType(m.typ).(*ast.ArrayType); ok { fmt.Fprintf(w, "%s = []\n", m.key) continue } fmt.Fprintf(w, "%s = %s\n", m.key, scalarLiteral(m)) } for _, m := range metas { if !isStructKind(m.typ, types) && !isMapKind(m.typ) { continue } writeComment(w, m.comment) fmt.Fprintf(w, "[%s%s]\n", prefix, m.key) if sub := structOf(m.typ, types); sub != nil { if err := writeSchemaFields(w, sub, types, prefix+m.key+".", path); err != nil { return err } } fmt.Fprintln(w) } for _, m := range metas { _, elemSt := elementStruct(m.typ, types) if elemSt == nil { continue } writeComment(w, m.comment) fmt.Fprintf(w, "[[%s%s]]\n", prefix, m.key) if err := writeSchemaFields(w, elemSt, types, "", path); err != nil { return err } fmt.Fprintln(w) } return nil } // writeComment writes the comment lines above a binding. func writeComment(w *strings.Builder, text string) { if text == "" { return } for line := range strings.SplitSeq(text, "\n") { fmt.Fprintf(w, "# %s\n", line) } } // metasOf flattens the exported fields of a struct. The key comes from the // toml tag, or the lower-cased field name; a `-` key drops the field. An // embedded struct without a tag name flattens into its parent, the way the // library inlines it, while a tagged one keeps its own section. func metasOf(st *ast.StructType, types map[string]*ast.StructType) ([]fieldMeta, error) { return flattenMetas(st, types, nil) } // flattenMetas is metasOf with the chain of struct types currently being // flattened, which stops a struct that embeds itself, directly or through // another embedded type. func flattenMetas(st *ast.StructType, types map[string]*ast.StructType, chain map[*ast.StructType]bool) ([]fieldMeta, error) { if chain[st] { return nil, recursionError(st, types) } // A copy per branch: the chain is the path being flattened now, not the // set ever visited, so a type embedded in two siblings is not mistaken // for recursion. chain = maps.Clone(chain) if chain == nil { chain = map[*ast.StructType]bool{} } chain[st] = true var out []fieldMeta for _, field := range st.Fields.List { tagText := "" if field.Tag != nil { tagText, _ = strconv.Unquote(field.Tag.Value) } toml := reflect.StructTag(tagText).Get("toml") key, opts, _ := strings.Cut(toml, ",") if len(field.Names) == 0 { if key == "" { // An untagged embedded struct flattens into its parent. if ident, ok := baseType(field.Type).(*ast.Ident); ok { if inner, ok := types[ident.Name]; ok { metas, err := flattenMetas(inner, types, chain) if err != nil { return nil, err } out = append(out, metas...) } } continue } if key == "-" { continue } // A tagged embedded struct is a section of its own; the tag // name is the only name it has. out = append(out, fieldMeta{ key: key, comment: tagOption(opts, "comment="), def: tagOption(opts, "default="), typ: field.Type, }) continue } if key == "-" { continue } // A field list may name several fields of one type, `Min, Max int`; // each name is one TOML key. for _, name := range field.Names { if !ast.IsExported(name.Name) { continue } fieldKey := key if fieldKey == "" { fieldKey = strings.ToLower(name.Name) } out = append(out, fieldMeta{ key: fieldKey, comment: tagOption(opts, "comment="), def: tagOption(opts, "default="), typ: field.Type, }) } } return out, nil } // recursionError names the struct type that reached itself. Such a type has // no finite TOML template: every level would nest another copy of the same // shape. func recursionError(st *ast.StructType, types map[string]*ast.StructType) error { return fmt.Errorf("recursive type %s: the struct contains itself, so it has no finite template", typeName(st, types)) } // typeName names the declared struct type st refers to, and "anonymous // struct" for a literal one that no declaration names. func typeName(st *ast.StructType, types map[string]*ast.StructType) string { for name, t := range types { if t == st { return name } } return "anonymous struct" } // tagOption returns the text a `name=` option carries in the option part of // a tag. func tagOption(opts, name string) string { for opts != "" { var opt string opt, opts, _ = strings.Cut(opts, ",") if text, ok := strings.CutPrefix(opt, name); ok { return text } } return "" } // baseType unwraps pointers and parentheses. func baseType(e ast.Expr) ast.Expr { for { switch x := e.(type) { case *ast.StarExpr: e = x.X case *ast.ParenExpr: e = x.X default: return e } } } // structOf returns the struct type an expression denotes when its // declaration sits in the same file, or when it is an anonymous struct. func structOf(e ast.Expr, types map[string]*ast.StructType) *ast.StructType { if ident, ok := baseType(e).(*ast.Ident); ok { return types[ident.Name] } if st, ok := baseType(e).(*ast.StructType); ok { return st } return nil } // isStructKind reports whether the type is a struct the generator renders as // a section. func isStructKind(e ast.Expr, types map[string]*ast.StructType) bool { return structOf(e, types) != nil } // isMapKind reports whether the type is a map, which renders as an empty // section. func isMapKind(e ast.Expr) bool { _, ok := baseType(e).(*ast.MapType) return ok } // elementStruct returns the struct type a slice's element denotes, for the // [[array of tables]] blocks. func elementStruct(e ast.Expr, types map[string]*ast.StructType) (ast.Expr, *ast.StructType) { arr, ok := baseType(e).(*ast.ArrayType) if !ok { return nil, nil } return arr.Elt, structOf(arr.Elt, types) } // scalarLiteral renders the value line for a scalar field: the default= // option when it is set, and the type's zero value otherwise. func scalarLiteral(m fieldMeta) string { kind := scalarKind(m.typ) if m.def != "" { if kind == "string" { return strconv.Quote(m.def) } return m.def } switch kind { case "int": return "0" case "float": return "0.0" case "bool": return "false" case "datetime": return "1979-05-27T00:00:00Z" } return `""` } // scalarKind classifies a scalar type for the zero-value rendering. func scalarKind(e ast.Expr) string { switch t := baseType(e).(type) { case *ast.Ident: switch t.Name { case "bool": return "bool" case "float32", "float64": return "float" case "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64", "uintptr", "byte", "rune": return "int" } if t.Name != "string" { // A named type in the file may be a scalar alias; the string // zero value is the safe default for it and everything unknown. return "unknown" } return "string" case *ast.SelectorExpr: if pkg, ok := t.X.(*ast.Ident); ok { if pkg.Name == "time" && t.Sel.Name == "Time" { return "datetime" } if pkg.Name == "interpres" { switch t.Sel.Name { case "OffsetDateTime", "LocalDateTime", "LocalDate", "LocalTime": return "datetime" } } } } return "unknown" }