// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: MIT // Command interpres-decode is the toml-test harness adapter and a TOML // validator. Without flags it reads a TOML document from standard input and // writes the toml-test "tagged JSON" representation to standard output. With // -encode it is the reverse: it reads tagged JSON and writes the TOML document // it describes. With -validate it checks the named documents, or standard // input when none are named, and exits non-zero on the first invalid one: // // interpres-decode -validate config.toml // interpres-decode -encode < case.json // // Run the official suite in both directions against the adapter with: // // toml-test test -decoder=./interpres-decode -encoder='./interpres-decode -encode' package main import ( "encoding/json" "errors" "flag" "fmt" "io" "io/fs" "math" "os" "path/filepath" "runtime/debug" "strconv" "strings" "time" "sourcedock.dev/petrbalvin/interpres/v2" ) func main() { os.Exit(Run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) } // Run runs the command line and returns the process exit code: 0 success, // 1 an invalid document, 2 a usage, reading, encoding, or // unsupported-value error. 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 *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 } if *encode { return encodeJSON(stdin, stdout, stderr) } data, err := io.ReadAll(stdin) if err != nil { 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) return 2 } enc := json.NewEncoder(stdout) enc.SetEscapeHTML(false) if err := enc.Encode(tagged); err != nil { fmt.Fprintln(stderr, "encode:", err) return 2 } 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. 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{"-"} } 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 if p == "-" { data, err = io.ReadAll(stdin) name = "" } else { data, err = os.ReadFile(p) } if err != nil { 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 } return 0 } // encodeJSON reads a toml-test tagged JSON description from standard input and // writes the TOML document it describes to standard output. func encodeJSON(stdin io.Reader, stdout, stderr io.Writer) int { data, err := io.ReadAll(stdin) if err != nil { fmt.Fprintln(stderr, "read stdin:", err) return 2 } var desc any if err := json.Unmarshal(data, &desc); err != nil { fmt.Fprintln(stderr, "decode JSON:", err) return 2 } tree, err := untag(desc) if err != nil { fmt.Fprintln(stderr, err) return 2 } doc, ok := tree.(map[string]any) if !ok { fmt.Fprintln(stderr, "interpres-decode: the description must be a JSON object at the top level") return 2 } out, err := interpres.Marshal(doc) if err != nil { fmt.Fprintln(stderr, err) return 2 } if _, err := stdout.Write(out); err != nil { fmt.Fprintln(stderr, "write stdout:", err) return 2 } return 0 } // untag converts a toml-test JSON description into the value tree Marshal // expects: a JSON object becomes a map[string]any, a JSON array becomes a // []any, and an object carrying exactly the keys "type" and "value" becomes // the Go value for that TOML type. func untag(v any) (any, error) { switch x := v.(type) { case map[string]any: if typ, val, ok := taggedValue(x); ok { return decodeTagged(typ, val) } out := make(map[string]any, len(x)) for k, e := range x { u, err := untag(e) if err != nil { return nil, fmt.Errorf("%s: %w", k, err) } out[k] = u } return out, nil case []any: out := make([]any, len(x)) for i, e := range x { u, err := untag(e) if err != nil { return nil, fmt.Errorf("[%d]: %w", i, err) } out[i] = u } return asTables(out), nil default: return nil, fmt.Errorf("unsupported JSON value %T", v) } } // asTables returns the elements as a []map[string]any when there is at least // one and every element is a table, the shape the encoder renders as an array // of tables. The tagged JSON cannot tell an array of tables from a value array // of inline tables, and both parse back to the same value, so the header form // is chosen because it is the one the encoder otherwise never exercises. An // empty array stays a []any, because TOML has no empty array of tables. func asTables(items []any) any { if len(items) == 0 { return items } tbls := make([]map[string]any, len(items)) for i, e := range items { tbl, ok := e.(map[string]any) if !ok { return items } tbls[i] = tbl } return tbls } // taggedValue reports whether m is a toml-test value object: a JSON object of // exactly the two string keys "type" and "value", carrying a type this adapter // knows. Any other object is a table. func taggedValue(m map[string]any) (typ, val string, ok bool) { if len(m) != 2 { return "", "", false } ts, ok := m["type"].(string) if !ok || !knownType(ts) { return "", "", false } vs, ok := m["value"].(string) if !ok { return "", "", false } return ts, vs, true } func knownType(typ string) bool { switch typ { case "string", "integer", "float", "bool", "datetime", "datetime-local", "date-local", "time-local": return true } return false } // decodeTagged returns the Go value for one tagged JSON value. Every type but // string is parsed by the library itself, so the adapter and the library agree // on what an integer, a float or a date-time is. func decodeTagged(typ, val string) (any, error) { if typ == "string" { return val, nil } v, err := parseAtom(val) if err != nil { return nil, fmt.Errorf("%s %q: %w", typ, val, err) } // A float with no fractional part and no exponent is described by a bare // integer literal, so here the tag decides and not the literal. if n, ok := v.(int64); ok && typ == "float" { return float64(n), nil } if !typeMatches(typ, v) { return nil, fmt.Errorf("%s %q parsed as %T", typ, val, v) } return v, nil } // parseAtom parses one bare TOML value, by handing `v = ` to the library's // parser and requiring the result to hold exactly that one statement, so a // value carrying a newline or a comment cannot smuggle a second one in. func parseAtom(val string) (any, error) { tree, err := interpres.ParseMap([]byte("v = " + val + "\n")) if err != nil { return nil, err } if len(tree) != 1 { return nil, errors.New("not a single bare value") } return tree["v"], nil } // typeMatches reports whether v is the Go value the tagged type names. func typeMatches(typ string, v any) bool { switch typ { case "integer": _, ok := v.(int64) return ok case "float": _, ok := v.(float64) return ok case "bool": _, ok := v.(bool) return ok case "datetime": switch v.(type) { case time.Time, interpres.OffsetDateTime: return true } return false case "datetime-local": _, ok := v.(interpres.LocalDateTime) return ok case "date-local": _, ok := v.(interpres.LocalDate) return ok case "time-local": _, ok := v.(interpres.LocalTime) return ok } return false } // tag converts an interpres value into its toml-test tagged-JSON form. Tables // become JSON objects and arrays become JSON arrays; scalars are wrapped in a // {"type", "value"} object. An error is returned for value types the encoder // has no tagged representation for. func tag(v any) (any, error) { switch x := v.(type) { case map[string]any: out := make(map[string]any, len(x)) for k, val := range x { tagged, err := tag(val) if err != nil { return nil, err } out[k] = tagged } return out, nil case []any: out := make([]any, len(x)) for i, e := range x { tagged, err := tag(e) if err != nil { return nil, err } out[i] = tagged } return out, nil case []map[string]any: out := make([]any, len(x)) for i, e := range x { tagged, err := tag(e) if err != nil { return nil, err } out[i] = tagged } return out, nil case string: return tagged("string", x), nil case bool: return tagged("bool", strconv.FormatBool(x)), nil case int64: return tagged("integer", strconv.FormatInt(x, 10)), nil case float64: return tagged("float", formatFloat(x)), nil case time.Time: return tagged("datetime", x.Format(time.RFC3339Nano)), nil case interpres.OffsetDateTime: return tagged("datetime", x.Format(time.RFC3339Nano)), nil case interpres.LocalDateTime: return tagged("datetime-local", x.Format("2006-01-02T15:04:05.999999999")), nil case interpres.LocalDate: return tagged("date-local", x.Format("2006-01-02")), nil case interpres.LocalTime: return tagged("time-local", x.Format("15:04:05.999999999")), nil default: return nil, fmt.Errorf("unsupported value type %T", v) } } func tagged(typ, value string) map[string]string { return map[string]string{"type": typ, "value": value} } func formatFloat(f float64) string { switch { case math.IsInf(f, 1): return "inf" case math.IsInf(f, -1): return "-inf" case math.IsNaN(f): return "nan" default: return strconv.FormatFloat(f, 'g', -1, 64) } }