feat(cmd): add version, plain json, struct inference and schema modes
Test / test (push) Successful in 1m35s
Test / test (push) Successful in 1m35s
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
// 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"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"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{}
|
||||
writeSchemaFields(body, st, types, "")
|
||||
_, 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.
|
||||
func writeSchemaFields(w *strings.Builder, st *ast.StructType, types map[string]*ast.StructType, prefix string) {
|
||||
metas := metasOf(st, types)
|
||||
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 {
|
||||
writeSchemaFields(w, sub, types, prefix+m.key+".")
|
||||
}
|
||||
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)
|
||||
writeSchemaFields(w, elemSt, types, "")
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func metasOf(st *ast.StructType, types map[string]*ast.StructType) []fieldMeta {
|
||||
var out []fieldMeta
|
||||
for _, field := range st.Fields.List {
|
||||
if len(field.Names) == 0 {
|
||||
// An untagged embedded struct flattens into the parent.
|
||||
if ident, ok := baseType(field.Type).(*ast.Ident); ok {
|
||||
if inner, ok := types[ident.Name]; ok {
|
||||
out = append(out, metasOf(inner, types)...)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
name := field.Names[0].Name
|
||||
if !ast.IsExported(name) {
|
||||
continue
|
||||
}
|
||||
tagText := ""
|
||||
if field.Tag != nil {
|
||||
tagText, _ = strconv.Unquote(field.Tag.Value)
|
||||
}
|
||||
toml := reflect.StructTag(tagText).Get("toml")
|
||||
key, opts := "", ""
|
||||
if toml != "" {
|
||||
key, opts, _ = strings.Cut(toml, ",")
|
||||
}
|
||||
if key == "-" {
|
||||
continue
|
||||
}
|
||||
if key == "" {
|
||||
key = strings.ToLower(name)
|
||||
}
|
||||
out = append(out, fieldMeta{
|
||||
key: key,
|
||||
comment: tagOption(opts, "comment="),
|
||||
def: tagOption(opts, "default="),
|
||||
typ: field.Type,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 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"
|
||||
}
|
||||
Reference in New Issue
Block a user