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:
@@ -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.
|
is an error wrapped with the key path.
|
||||||
- `MarshalAppend(buf, v)` appends the TOML encoding of v to buf and returns
|
- `MarshalAppend(buf, v)` appends the TOML encoding of v to buf and returns
|
||||||
the extended buffer, the shape `json.MarshalAppend` has.
|
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
|
- `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
|
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
|
failure alike. `Valid(data)` reports whether a document parses, nil on
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -22,9 +22,13 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime/debug"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sourcedock.dev/petrbalvin/interpres/v2"
|
"sourcedock.dev/petrbalvin/interpres/v2"
|
||||||
@@ -40,21 +44,54 @@ func main() {
|
|||||||
func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||||
fs := flag.NewFlagSet("interpres-decode", flag.ContinueOnError)
|
fs := flag.NewFlagSet("interpres-decode", flag.ContinueOnError)
|
||||||
fs.SetOutput(stderr)
|
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")
|
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")
|
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 err := fs.Parse(args); err != nil {
|
||||||
if errors.Is(err, flag.ErrHelp) {
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
if *validate && *encode {
|
if *version {
|
||||||
fmt.Fprintln(stderr, "interpres-decode: -validate and -encode cannot be combined")
|
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
|
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 {
|
if *validate {
|
||||||
return validatePaths(fs.Args(), stdin, stderr)
|
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 {
|
if fs.NArg() > 0 {
|
||||||
fmt.Fprintln(stderr, "interpres-decode: the adapter mode takes no arguments; name files with -validate")
|
fmt.Fprintln(stderr, "interpres-decode: the adapter mode takes no arguments; name files with -validate")
|
||||||
return 2
|
return 2
|
||||||
@@ -67,11 +104,28 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
|||||||
fmt.Fprintln(stderr, "read stdin:", err)
|
fmt.Fprintln(stderr, "read stdin:", err)
|
||||||
return 2
|
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)
|
tree, err := interpres.ParseMap(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(stderr, err)
|
fmt.Fprintln(stderr, err)
|
||||||
return 1
|
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)
|
tagged, err := tag(tree)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(stderr, err)
|
fmt.Fprintln(stderr, err)
|
||||||
@@ -86,15 +140,95 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
|||||||
return 0
|
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
|
// validatePaths parses every named file, or standard input when none are
|
||||||
// named, and reports each invalid document on stderr. It returns 0 when all
|
// named, and reports each invalid document on stderr. A named directory is
|
||||||
// documents parse, 1 when one does not, and 2 on a usage or read failure.
|
// 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 {
|
func validatePaths(paths []string, stdin io.Reader, stderr io.Writer) int {
|
||||||
if len(paths) == 0 {
|
if len(paths) == 0 {
|
||||||
paths = []string{"-"}
|
paths = []string{"-"}
|
||||||
}
|
}
|
||||||
valid := true
|
var files []string
|
||||||
|
dirs := 0
|
||||||
for _, p := range paths {
|
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
|
name := p
|
||||||
var data []byte
|
var data []byte
|
||||||
var err error
|
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)
|
fmt.Fprintf(stderr, "interpres-decode: %s: %v\n", name, err)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
checked++
|
||||||
if _, err := interpres.ParseMap(data); err != nil {
|
if _, err := interpres.ParseMap(data); err != nil {
|
||||||
fmt.Fprintf(stderr, "%s: %v\n", name, err)
|
fmt.Fprintf(stderr, "%s: %v\n", name, err)
|
||||||
valid = false
|
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 {
|
if !valid {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -446,3 +447,123 @@ n = "a"
|
|||||||
t.Errorf("round trip changed the document:\noriginal: %#v\nencoded: %#v\noutput: %q", want, got, out.String())
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
+86
-9
@@ -15,37 +15,68 @@ go install sourcedock.dev/petrbalvin/interpres/v2/cmd/interpres-decode@latest
|
|||||||
interpres-decode [flags]
|
interpres-decode [flags]
|
||||||
interpres-decode -encode
|
interpres-decode -encode
|
||||||
interpres-decode -validate [file ...]
|
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
|
Without `-validate`, `-encode`, `-json` or `-struct` the program is the
|
||||||
toml-test adapter: it takes no arguments, reads one TOML document from stdin,
|
decoding half of the toml-test adapter: it takes no arguments, reads one TOML
|
||||||
and writes the toml-test tagged-JSON form to stdout. Build it locally with
|
document from stdin, and writes the toml-test tagged-JSON form to stdout.
|
||||||
`just build`, which compiles it into `bin/interpres-decode`, or run it
|
Build it locally with `just build`, which compiles it into
|
||||||
straight from the module directory with `just run`.
|
`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
|
With `-encode` the direction is reversed: the program reads a tagged-JSON
|
||||||
description from stdin and writes the TOML document it describes to stdout,
|
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
|
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
|
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
|
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
|
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
|
## Flags
|
||||||
|
|
||||||
| Flag | Effect |
|
| 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 |
|
| `-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 |
|
| `-h` | print the usage |
|
||||||
|
|
||||||
## Exit codes
|
## Exit codes
|
||||||
|
|
||||||
| Code | Meaning |
|
| 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 |
|
| `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 |
|
| `2` | a usage error, a read failure, malformed tagged JSON, or a value with no TOML representation |
|
||||||
|
|
||||||
@@ -120,6 +151,52 @@ $ echo $?
|
|||||||
1
|
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:
|
Run the official compliance suite against the adapter:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user