From 3e741e77902b8e7ec530264536842428aa1acdf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Tue, 22 Sep 2026 00:55:02 +0200 Subject: [PATCH] feat(cmd): add version, plain json, struct inference and schema modes Assisted-by: GLM 5.3 Flash --- CHANGELOG.md | 16 ++ cmd/interpres-decode/infer.go | 156 ++++++++++++++++ cmd/interpres-decode/main.go | 151 +++++++++++++++- cmd/interpres-decode/main_test.go | 121 +++++++++++++ cmd/interpres-decode/schema.go | 288 ++++++++++++++++++++++++++++++ docs/CLI.md | 95 +++++++++- man/interpres-decode.1 | 138 ++++++++++++++ 7 files changed, 951 insertions(+), 14 deletions(-) create mode 100644 cmd/interpres-decode/infer.go create mode 100644 cmd/interpres-decode/schema.go create mode 100644 man/interpres-decode.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bf04ab..b1c0ba7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 is an error wrapped with the key path. - `MarshalAppend(buf, v)` appends the TOML encoding of v to buf and returns the extended buffer, the shape `json.MarshalAppend` has. +- `interpres-decode -version` prints the binary's version, the module version + the toolchain recorded, so a release-built binary names its own tag. +- `interpres-decode -json` prints plain indented JSON instead of the tagged + form, the shape for people and diffs, with the date-time wrappers in their + TOML form. +- `interpres-decode -validate` walks a named directory for `.toml` files and + closes the sweep with a summary naming how many documents were checked and + how many were invalid; single files stay quiet on success as before. +- `interpres-decode -struct` infers a Go struct definition from a document: + one field per key in written order, nested tables as nested struct types, + an array of tables as a slice. The printed type compiles and decodes the + document it came from. +- `interpres-decode -schema TYPE file.go` writes a TOML template for the + named struct type of a Go source, the `comment=` tag option printed as a + comment and the `default=` option as the value. It is the inverse of + `-struct`. - `ParseFile(path)` reads the file and parses it into a `Document`, with the file name at the front of every error it returns, read failure and parse failure alike. `Valid(data)` reports whether a document parses, nil on diff --git a/cmd/interpres-decode/infer.go b/cmd/interpres-decode/infer.go new file mode 100644 index 0000000..2dce450 --- /dev/null +++ b/cmd/interpres-decode/infer.go @@ -0,0 +1,156 @@ +// Copyright (c) 2026 Petr Balvín (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 +} diff --git a/cmd/interpres-decode/main.go b/cmd/interpres-decode/main.go index 3a2e882..da443e4 100644 --- a/cmd/interpres-decode/main.go +++ b/cmd/interpres-decode/main.go @@ -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 } diff --git a/cmd/interpres-decode/main_test.go b/cmd/interpres-decode/main_test.go index 16e2502..4997823 100644 --- a/cmd/interpres-decode/main_test.go +++ b/cmd/interpres-decode/main_test.go @@ -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) + } + } +} diff --git a/cmd/interpres-decode/schema.go b/cmd/interpres-decode/schema.go new file mode 100644 index 0000000..1143c40 --- /dev/null +++ b/cmd/interpres-decode/schema.go @@ -0,0 +1,288 @@ +// Copyright (c) 2026 Petr Balvín (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" +} diff --git a/docs/CLI.md b/docs/CLI.md index ae62acb..acc2d92 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -15,37 +15,68 @@ go install sourcedock.dev/petrbalvin/interpres/v2/cmd/interpres-decode@latest interpres-decode [flags] interpres-decode -encode interpres-decode -validate [file ...] +interpres-decode -validate [directory ...] +interpres-decode -json +interpres-decode -struct +interpres-decode -schema TYPE file.go +interpres-decode -version ``` -Without `-validate` or `-encode` the program is the decoding half of the -toml-test adapter: it takes no arguments, reads one TOML document from stdin, -and writes the toml-test tagged-JSON form to stdout. Build it locally with -`just build`, which compiles it into `bin/interpres-decode`, or run it -straight from the module directory with `just run`. +Without `-validate`, `-encode`, `-json` or `-struct` the program is the +decoding half of the toml-test adapter: it takes no arguments, reads one TOML +document from stdin, and writes the toml-test tagged-JSON form to stdout. +Build it locally with `just build`, which compiles it into +`bin/interpres-decode`, or run it straight from the module directory with +`just run`. With `-encode` the direction is reversed: the program reads a tagged-JSON description from stdin and writes the TOML document it describes to stdout, which is the shape toml-test expects of an encoder command. It takes no -arguments either, and `-validate` and `-encode` cannot be combined. +arguments either, and the mode flags cannot be combined. With `-validate` the program parses each named file instead, or stdin when no file is named, and prints one line per invalid document to stderr. It is quiet on valid documents, which is the shape a CI step wants. The `-` name -means stdin. +means stdin. A named directory is walked for `.toml` files, every one of them +validated, and the walk closes with a summary on stderr naming how many +documents were checked and how many were invalid. + +With `-json` the decoding half prints plain indented JSON instead of the +tagged form, the shape for people and diffs: the values keep their types as +JSON sees them, and the date-time wrappers print in their TOML form. + +With `-struct` the program reads a TOML document from stdin and prints a Go +struct definition shaped like it: one field per key in written order, nested +tables as nested struct types, and an array of tables as a slice. The +printed type compiles and decodes the document it came from. + +With `-schema` the program reads a Go source file and writes a TOML template +for the named struct type: one key per exported field, the `comment=` tag +option printed as a comment above it, and the `default=` option as the value +where one is set. It is the inverse of `-struct`, for config-driven +applications that generate their example configuration from the type. + +`-version` prints the binary's version and exits. The release pipeline builds +at the tag, so a released binary prints its own tag; a build from a working +tree prints `(devel)`. ## Flags | Flag | Effect | |---|---| -| `-validate` | validate the documents instead of emitting tagged JSON | +| `-validate` | validate the documents instead of emitting tagged JSON; directories are walked for `.toml` files | | `-encode` | read tagged JSON from stdin and write TOML instead | +| `-json` | with the default mode, print plain indented JSON instead of tagged JSON | +| `-struct` | infer a Go struct definition from the document on stdin and print it | +| `-schema TYPE` | write a TOML template for the struct type TYPE from the Go source file named as the first argument | +| `-version` | print the version and exit | | `-h` | print the usage | ## Exit codes | Code | Meaning | |---|---| -| `0` | adapter: the document parsed and the tagged JSON was written; encode: the TOML was written; validate: every document parsed | +| `0` | adapter: the document parsed and the tagged JSON was written; encode: the TOML was written; validate: every document parsed; schema, struct, version: the output was written | | `1` | adapter: parse error; validate: at least one document is invalid | | `2` | a usage error, a read failure, malformed tagged JSON, or a value with no TOML representation | @@ -120,6 +151,52 @@ $ echo $? 1 ``` +Sweep a whole directory tree of configuration, with the summary the walk +closes on: + +```sh +$ interpres-decode -validate configs/ +configs/old.toml: interpres: line 3: duplicate key "port" +checked 14 documents, 1 invalid +$ echo $? +1 +``` + +See the document a `-struct` template would decode: + +```sh +echo 'host = "db" +port = 5432 +' | ./bin/interpres-decode -struct +``` + +```go +type inferred struct { + Host string `toml:"host"` + Port int64 `toml:"port"` +} +``` + +Write the template back from the type, comments and defaults included, where +the Go source declares fields tagged +`toml:"host,comment=The host to dial,default=example.org"`: + +```sh +./bin/interpres-decode -schema Config config.go +``` + +```toml +# The host to dial +host = "example.org" +``` + +Print the binary's version: + +```sh +$ ./bin/interpres-decode -version +interpres-decode v2.0.0 +``` + Run the official compliance suite against the adapter: ```sh diff --git a/man/interpres-decode.1 b/man/interpres-decode.1 new file mode 100644 index 0000000..2ab3d4c --- /dev/null +++ b/man/interpres-decode.1 @@ -0,0 +1,138 @@ +.TH INTERPRES-DECODE 1 "2026-09-21" "interpres 2.0.0" "User Commands" +.SH NAME +interpres-decode \- TOML validator and toml-test harness adapter +.SH SYNOPSIS +.B interpres-decode +[\fIFLAGS\fR] +.br +.B interpres-decode +.B \-encode +.br +.B interpres-decode +.B \-validate +[\fIFILE\fR...] +.br +.B interpres-decode +.B \-validate +[\fIDIRECTORY\fR...] +.br +.B interpres-decode +.B \-json +.br +.B interpres-decode +.B \-struct +.br +.B interpres-decode +.B \-schema +\fITYPE\fR +\fIFILE.go\fR +.br +.B interpres-decode +.B \-version +.SH DESCRIPTION +.B interpres-decode +is the toml-test harness adapter in both directions and a TOML validator. +Without a mode flag it reads one TOML document from standard input and writes +the toml-test tagged-JSON representation to standard output. +.B \-encode +reads a tagged-JSON description from standard input and writes the TOML +document it describes. +.B \-validate +parses each named file, or standard input when none are named, and prints one +line per invalid document to standard error; a named directory is walked for +.B .toml +files, every one validated, and the walk closes with a summary on standard +error naming the counts. The name +.B \- +means standard input. +.B \-json +prints plain indented JSON instead of the tagged form. +.B \-struct +prints a Go struct definition inferred from the document on standard input. +.B \-schema +writes a TOML template for the struct type +\fITYPE\fR +declared in the Go source file +\fIFILE.go\fR, +taking the key names, comments and defaults from the fields' tags. +.B \-version +prints the binary's version and exits. +.PP +The mode flags +.BR \-validate , +.BR \-encode , +.B \-struct +and +.B \-schema +cannot be combined. +.SH OPTIONS +.TP +.B \-validate +Validate the documents instead of emitting tagged JSON. +.TP +.B \-encode +Read tagged JSON from standard input and write TOML instead. +.TP +.B \-json +With the default mode, print plain indented JSON instead of tagged JSON. +.TP +.B \-struct +Infer a Go struct definition from the document on standard input and print it. +.TP +.BI \-schema " TYPE" +Write a TOML template for the struct type \fITYPE\fR; the Go source file +follows as the first argument. +.TP +.B \-version +Print the version and exit. +.TP +.B \-h +Print the usage. +.SH EXIT STATUS +.TP +.B 0 +The document parsed and the output was written; in validate mode, every +document parsed. +.TP +.B 1 +Adapter: a parse error. Validate: at least one document is invalid. +.TP +.B 2 +A usage error, a read failure, malformed tagged JSON, or a value with no TOML +representation. +.SH EXAMPLES +Decode a document into tagged JSON: +.PP +.nf +.RS +echo 'title = "hello"' | interpres-decode +.RE +.fi +.PP +Validate a directory of configuration, with the summary: +.PP +.nf +.RS +interpres-decode -validate configs/ +.RE +.fi +.PP +Infer a Go type from a document: +.PP +.nf +.RS +interpres-decode -struct < config.toml > config.go +.RE +.fi +.PP +Write the template back from the type: +.PP +.nf +.RS +interpres-decode -schema Config config.go +.RE +.fi +.SH SEE ALSO +The repository's +.B docs/CLI.md +carries the full reference, including the tagged-JSON wire format.