feat(cmd): add version, plain json, struct inference and schema modes
Test / test (push) Successful in 1m35s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-22 00:55:02 +02:00
parent d18935ebc2
commit 3e741e7790
7 changed files with 951 additions and 14 deletions
+156
View File
@@ -0,0 +1,156 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package main
import (
"fmt"
"io"
"strings"
"time"
"unicode"
"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.
func inferStruct(data []byte, stdout io.Writer) error {
doc, err := interpres.Parse(data)
if err != nil {
return err
}
fmt.Fprintln(stdout, "// Generated by interpres-decode -struct; decode with")
fmt.Fprintln(stdout, "// sourcedock.dev/petrbalvin/interpres/v2.")
fmt.Fprintln(stdout, "type inferred struct {")
if err := writeInferredFields(stdout, doc.Root(), map[string]bool{}); err != nil {
return err
}
fmt.Fprintln(stdout, "}")
return nil
}
// writeInferredFields writes one field per entry of the table. 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 io.Writer, t *interpres.Table, invented map[string]bool) error {
for _, key := range t.Keys() {
entry, _ := t.Get(key)
name := goFieldName(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, so the nils give
// it away.
var tables []*interpres.Table
for _, el := range entry.Elements() {
if el != nil {
tables = append(tables, el)
}
}
if len(tables) > 0 {
// The type comes from the first element.
fmt.Fprintf(w, "\t%s []struct {\n", name)
if err := writeInferredFields(w, tables[0], map[string]bool{}); err != nil {
return err
}
fmt.Fprintf(w, "\t} `toml:%q`\n", key)
continue
}
val := entry.Value()
if child := entry.Table(); child != nil {
fmt.Fprintf(w, "\t%s struct {\n", name)
if err := writeInferredFields(w, child, map[string]bool{}); err != nil {
return err
}
fmt.Fprintf(w, "\t} `toml:%q`\n", key)
continue
}
if items, ok := val.([]any); ok {
fmt.Fprintf(w, "\t%s []%s `toml:%q`\n", name, inferScalarType(items), key)
continue
}
fmt.Fprintf(w, "\t%s %s `toml:%q`\n", name, goTypeOf(val), key)
}
return nil
}
// 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
// an underscore, 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"
}
if unicode.IsDigit(rune(name[0])) {
name = "_" + 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
}
+146 -5
View File
@@ -22,9 +22,13 @@ import (
"flag"
"fmt"
"io"
"io/fs"
"math"
"os"
"path/filepath"
"runtime/debug"
"strconv"
"strings"
"time"
"sourcedock.dev/petrbalvin/interpres/v2"
@@ -40,21 +44,54 @@ func main() {
func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("interpres-decode", flag.ContinueOnError)
fs.SetOutput(stderr)
version := fs.Bool("version", false, "print the version and exit")
validate := fs.Bool("validate", false, "validate the documents instead of emitting tagged JSON")
encode := fs.Bool("encode", false, "read tagged JSON from stdin and write TOML instead")
plainJSON := fs.Bool("json", false, "with the default mode, print plain indented JSON instead of tagged JSON")
infer := fs.Bool("struct", false, "infer a Go struct definition from the document on stdin and print it")
schemaType := fs.String("schema", "", "write a TOML template for the named struct type; the source file follows as the first argument")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
if *validate && *encode {
fmt.Fprintln(stderr, "interpres-decode: -validate and -encode cannot be combined")
if *version {
fmt.Fprintf(stdout, "interpres-decode %s\n", versionString())
return 0
}
modes := 0
for _, on := range []*bool{validate, encode, infer} {
if *on {
modes++
}
}
if *schemaType != "" {
modes++
}
if modes > 1 {
fmt.Fprintln(stderr, "interpres-decode: -validate, -encode, -struct and -schema cannot be combined")
return 2
}
if *schemaType != "" {
rest := fs.Args()
if len(rest) != 1 {
fmt.Fprintln(stderr, "interpres-decode: -schema needs the type name and exactly one Go source file")
return 2
}
if err := runSchema(*schemaType, rest[0], stdout); err != nil {
fmt.Fprintf(stderr, "interpres-decode: %v\n", err)
return 2
}
return 0
}
if *validate {
return validatePaths(fs.Args(), stdin, stderr)
}
if *encode && *plainJSON {
fmt.Fprintln(stderr, "interpres-decode: -json shapes the decoder output and cannot be combined with -encode")
return 2
}
if fs.NArg() > 0 {
fmt.Fprintln(stderr, "interpres-decode: the adapter mode takes no arguments; name files with -validate")
return 2
@@ -67,11 +104,28 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, "read stdin:", err)
return 2
}
if *infer {
if err := inferStruct(data, stdout); err != nil {
fmt.Fprintf(stderr, "interpres-decode: %v\n", err)
return 1
}
return 0
}
tree, err := interpres.ParseMap(data)
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
if *plainJSON {
enc := json.NewEncoder(stdout)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(plainJSONValue(tree)); err != nil {
fmt.Fprintln(stderr, "encode:", err)
return 2
}
return 0
}
tagged, err := tag(tree)
if err != nil {
fmt.Fprintln(stderr, err)
@@ -86,15 +140,95 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return 0
}
// versionString names the version the binary was built at: the module
// version the toolchain recorded, which is the tag when the release pipeline
// builds it, and (devel) for an ordinary build from a working tree.
func versionString() string {
if info, ok := debug.ReadBuildInfo(); ok {
if v := info.Main.Version; strings.HasPrefix(v, "v") {
return v
}
}
return "(devel)"
}
// plainJSONValue converts the parsed tree into the values encoding/json
// renders: the date-time wrappers print in their TOML form, which is the
// same text a reader of the document saw.
func plainJSONValue(v any) any {
switch x := v.(type) {
case map[string]any:
for k, val := range x {
x[k] = plainJSONValue(val)
}
return x
case []any:
for i, val := range x {
x[i] = plainJSONValue(val)
}
return x
case []map[string]any:
out := make([]any, len(x))
for i, val := range x {
out[i] = plainJSONValue(val)
}
return out
case time.Time:
return x.Format(time.RFC3339Nano)
case interpres.OffsetDateTime:
return x.String()
case interpres.LocalDateTime:
return x.String()
case interpres.LocalDate:
return x.String()
case interpres.LocalTime:
return x.String()
}
return v
}
// validatePaths parses every named file, or standard input when none are
// named, and reports each invalid document on stderr. It returns 0 when all
// documents parse, 1 when one does not, and 2 on a usage or read failure.
// named, and reports each invalid document on stderr. A named directory is
// walked for .toml files. It returns 0 when all documents parse, 1 when one
// does not, and 2 on a usage or read failure. A summary names the counts.
func validatePaths(paths []string, stdin io.Reader, stderr io.Writer) int {
if len(paths) == 0 {
paths = []string{"-"}
}
valid := true
var files []string
dirs := 0
for _, p := range paths {
if p == "-" {
files = append(files, "-")
continue
}
info, err := os.Stat(p)
if err != nil {
fmt.Fprintf(stderr, "interpres-decode: %s: %v\n", p, err)
return 2
}
if !info.IsDir() {
files = append(files, p)
continue
}
dirs++
err = filepath.WalkDir(p, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && strings.EqualFold(filepath.Ext(path), ".toml") {
files = append(files, path)
}
return nil
})
if err != nil {
fmt.Fprintf(stderr, "interpres-decode: walk %s: %v\n", p, err)
return 2
}
}
valid := true
checked := 0
for _, p := range files {
name := p
var data []byte
var err error
@@ -108,11 +242,18 @@ func validatePaths(paths []string, stdin io.Reader, stderr io.Writer) int {
fmt.Fprintf(stderr, "interpres-decode: %s: %v\n", name, err)
return 2
}
checked++
if _, err := interpres.ParseMap(data); err != nil {
fmt.Fprintf(stderr, "%s: %v\n", name, err)
valid = false
}
}
// The single-document run stays quiet on success, the contract the
// compliance tooling relies on; a directory walk closes with the
// summary that makes the sweep readable.
if dirs > 0 {
fmt.Fprintf(stderr, "checked %d documents, %d invalid\n", checked, map[bool]int{true: 0, false: 1}[valid])
}
if !valid {
return 1
}
+121
View File
@@ -8,6 +8,7 @@ import (
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
@@ -446,3 +447,123 @@ n = "a"
t.Errorf("round trip changed the document:\noriginal: %#v\nencoded: %#v\noutput: %q", want, got, out.String())
}
}
func TestRunVersion(t *testing.T) {
var stdout, stderr bytes.Buffer
code := Run([]string{"-version"}, strings.NewReader(""), &stdout, &stderr)
if code != 0 {
t.Fatalf("Run returned %d, want 0; stderr = %q", code, stderr.String())
}
out := stdout.String()
if !strings.HasPrefix(out, "interpres-decode ") {
t.Errorf("output = %q, want the version prefix", out)
}
}
func TestRunPlainJSON(t *testing.T) {
var stdout, stderr bytes.Buffer
in := strings.NewReader("host = \"db\"\nwhen = 1979-05-27T07:32:00-07:00\nitems = [1, 2]\n")
code := Run([]string{"-json"}, in, &stdout, &stderr)
if code != 0 {
t.Fatalf("Run returned %d, want 0; stderr = %q", code, stderr.String())
}
out := stdout.String()
if !strings.Contains(out, "\"host\": \"db\"") {
t.Errorf("output = %q, want plain JSON keys", out)
}
if strings.Contains(out, "\"type\"") {
t.Errorf("output = %q, want no tags", out)
}
if !strings.Contains(out, "\n \"") {
t.Errorf("output = %q, want indentation", out)
}
}
func TestValidateDirectorySummary(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "good.toml"), []byte("a = 1\n"), 0o644)
os.WriteFile(filepath.Join(dir, "bad.toml"), []byte("a =\n"), 0o644)
sub := filepath.Join(dir, "nested")
os.Mkdir(sub, 0o755)
os.WriteFile(filepath.Join(sub, "deep.toml"), []byte("b = true\n"), 0o644)
var stdout, stderr bytes.Buffer
code := Run([]string{"-validate", dir}, strings.NewReader(""), &stdout, &stderr)
if code != 1 {
t.Fatalf("Run returned %d, want 1 for a directory with an invalid file", code)
}
if !strings.Contains(stderr.String(), "checked 3 documents, 1 invalid") {
t.Errorf("stderr = %q, want the summary", stderr.String())
}
}
func TestInferStruct(t *testing.T) {
var stdout, stderr bytes.Buffer
in := strings.NewReader("host = \"db\"\nport = 5432\ntags = [\"a\"]\n\n[server]\nname = \"edge\"\n\n[[items]]\nn = 1\n")
code := Run([]string{"-struct"}, in, &stdout, &stderr)
if code != 0 {
t.Fatalf("Run returned %d, want 0; stderr = %q", code, stderr.String())
}
out := stdout.String()
for _, want := range []string{
"type inferred struct {",
"Host string `toml:\"host\"`",
"Port int64 `toml:\"port\"`",
"Tags []string `toml:\"tags\"`",
"Server struct {",
"Items []struct {",
} {
if !strings.Contains(out, want) {
t.Errorf("output missing %q:\n%s", want, out)
}
}
}
func TestRunSchemaTemplate(t *testing.T) {
src := filepath.Join(t.TempDir(), "config.go")
body := `package cfg
type Server struct {
Host string ` + "`toml:\"host,comment=The host to dial,default=example.org\"`" + `
Port int ` + "`toml:\"port,default=8080\"`" + `
}
type Config struct {
Name string ` + "`toml:\"name\"`" + `
Rate float64 ` + "`toml:\"rate,default=0.5\"`" + `
On bool ` + "`toml:\"on\"`" + `
Started time.Time ` + "`toml:\"started\"`" + `
Server Server ` + "`toml:\"server,comment=The server section\"`" + `
Items []Item ` + "`toml:\"items\"`" + `
}
type Item struct {
N int ` + "`toml:\"n\"`" + `
}
`
if err := os.WriteFile(src, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
code := Run([]string{"-schema", "Config", src}, strings.NewReader(""), &stdout, &stderr)
if code != 0 {
t.Fatalf("Run returned %d, want 0; stderr = %q", code, stderr.String())
}
out := stdout.String()
for _, want := range []string{
"# The server section",
"[server]",
"# The host to dial",
"host = \"example.org\"",
"port = 8080",
"rate = 0.5",
"on = false",
"started = 1979-05-27T00:00:00Z",
"[[items]]",
"n = 0",
} {
if !strings.Contains(out, want) {
t.Errorf("output missing %q:\n%s", want, out)
}
}
}
+288
View File
@@ -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"
}