Files
interpres/cmd/interpres-decode/main.go
T
2026-09-22 21:15:07 +02:00

564 lines
16 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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 when one is invalid:
//
// 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, writing, encoding, or
// unsupported-value error.
func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("interpres-decode", flag.ContinueOnError)
// The flag package's own diagnostics and default usage render flags
// with a single dash, while the command spells every flag in its
// two-dash long form, the form the manpage documents. Its output is
// therefore discarded and the usage below is the only one printed.
fs.SetOutput(io.Discard)
fs.Usage = func() {}
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) {
usage(stdout)
return 0
}
fmt.Fprintf(stderr, "interpres-decode: %v\n", err)
usage(stderr)
return 2
}
if *version {
if _, err := fmt.Fprintf(stdout, "interpres-decode %s\n", versionString()); err != nil {
fmt.Fprintln(stderr, "interpres-decode: write stdout:", err)
return 2
}
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
}
// --json shapes the decoding output only, so it is rejected with every
// mode uniformly instead of being silently ignored by some of them.
if *plainJSON && modes > 0 {
fmt.Fprintln(stderr, "interpres-decode: --json shapes the decoder output and cannot be combined with --encode, --struct, --validate or --schema")
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 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, "interpres-decode: read stdin:", err)
return 2
}
if *infer {
if err := inferStruct(data, stdout); err != nil {
fmt.Fprintf(stderr, "interpres-decode: %v\n", err)
// A document that fails to parse keeps the adapter's invalid
// exit; anything else, a failed write among them, is a tool
// failure.
if _, ok := errors.AsType[*interpres.SyntaxError](err); ok {
return 1
}
return 2
}
return 0
}
tree, err := interpres.ParseMap(data)
if err != nil {
fmt.Fprintf(stderr, "interpres-decode: %v\n", 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, "interpres-decode: encode:", err)
return 2
}
return 0
}
tagged, err := tag(tree)
if err != nil {
fmt.Fprintf(stderr, "interpres-decode: %v\n", err)
return 2
}
enc := json.NewEncoder(stdout)
enc.SetEscapeHTML(false)
if err := enc.Encode(tagged); err != nil {
fmt.Fprintln(stderr, "interpres-decode: encode:", err)
return 2
}
return 0
}
// usage prints the command line summary, with every flag in its two-dash
// long form: the flag package's default usage printer renders a single dash,
// and the manpage and docs/CLI.md spell the flags the way this text does.
func usage(w io.Writer) {
fmt.Fprint(w, `Usage: interpres-decode [flags]
Without a mode flag the command reads one TOML document from standard input
and writes the toml-test tagged JSON representation to standard output.
--encode read tagged JSON from standard input and write TOML
instead
--help print this usage
--json with the default mode, print plain indented JSON
instead of tagged JSON
--schema TYPE write a TOML template for the named struct type; the
Go source file follows as the first argument
--struct infer a Go struct definition from the document on
standard input and print it
--validate validate the documents instead of emitting tagged JSON
--version print the version and exit
`)
}
// 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
}
}
checked := 0
invalid := 0
for _, p := range files {
name := p
var data []byte
var err error
if p == "-" {
data, err = io.ReadAll(stdin)
name = "<stdin>"
} 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, "interpres-decode: %s: %v\n", name, err)
invalid++
}
}
// 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, invalid)
}
if invalid > 0 {
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, "interpres-decode: read stdin:", err)
return 2
}
var desc any
if err := json.Unmarshal(data, &desc); err != nil {
fmt.Fprintln(stderr, "interpres-decode: decode JSON:", err)
return 2
}
tree, err := untag(desc)
if err != nil {
fmt.Fprintf(stderr, "interpres-decode: %v\n", 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.Fprintf(stderr, "interpres-decode: %v\n", err)
return 2
}
if _, err := stdout.Write(out); err != nil {
fmt.Fprintln(stderr, "interpres-decode: 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 = <val>` 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)
}
}