Files

364 lines
9.9 KiB
Go
Raw Permalink Normal View History

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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"
}