2026-08-19 11:23:00 +02:00
|
|
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|
|
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
|
|
2026-09-17 21:23:29 +02:00
|
|
|
// 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
|
2026-09-19 11:38:19 +02:00
|
|
|
// -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:
|
2026-08-19 11:23:00 +02:00
|
|
|
//
|
2026-09-17 21:23:29 +02:00
|
|
|
// interpres-decode -validate config.toml
|
2026-09-19 11:38:19 +02:00
|
|
|
// interpres-decode -encode < case.json
|
2026-09-17 21:23:29 +02:00
|
|
|
//
|
2026-09-19 11:38:19 +02:00
|
|
|
// Run the official suite in both directions against the adapter with:
|
2026-08-19 11:23:00 +02:00
|
|
|
//
|
2026-09-19 11:38:19 +02:00
|
|
|
// toml-test test -decoder=./interpres-decode -encoder='./interpres-decode -encode'
|
2026-08-19 11:23:00 +02:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
2026-09-17 21:23:29 +02:00
|
|
|
"errors"
|
|
|
|
|
"flag"
|
2026-08-19 11:23:00 +02:00
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"math"
|
|
|
|
|
"os"
|
|
|
|
|
"strconv"
|
|
|
|
|
"time"
|
|
|
|
|
|
2026-09-19 00:14:39 +02:00
|
|
|
"sourcedock.dev/petrbalvin/interpres/v2"
|
2026-08-19 11:23:00 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func main() {
|
2026-09-17 21:23:29 +02:00
|
|
|
os.Exit(Run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
|
2026-08-19 11:23:00 +02:00
|
|
|
}
|
|
|
|
|
|
2026-09-17 21:23:29 +02:00
|
|
|
// 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)
|
|
|
|
|
validate := fs.Bool("validate", false, "validate the documents instead of emitting tagged JSON")
|
2026-09-19 11:38:19 +02:00
|
|
|
encode := fs.Bool("encode", false, "read tagged JSON from stdin and write TOML instead")
|
2026-09-17 21:23:29 +02:00
|
|
|
if err := fs.Parse(args); err != nil {
|
|
|
|
|
if errors.Is(err, flag.ErrHelp) {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
return 2
|
|
|
|
|
}
|
2026-09-19 11:38:19 +02:00
|
|
|
if *validate && *encode {
|
|
|
|
|
fmt.Fprintln(stderr, "interpres-decode: -validate and -encode cannot be combined")
|
|
|
|
|
return 2
|
|
|
|
|
}
|
2026-09-17 21:23:29 +02:00
|
|
|
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
|
|
|
|
|
}
|
2026-09-19 11:38:19 +02:00
|
|
|
if *encode {
|
|
|
|
|
return encodeJSON(stdin, stdout, stderr)
|
|
|
|
|
}
|
2026-08-19 11:23:00 +02:00
|
|
|
data, err := io.ReadAll(stdin)
|
|
|
|
|
if err != nil {
|
|
|
|
|
fmt.Fprintln(stderr, "read stdin:", err)
|
|
|
|
|
return 2
|
|
|
|
|
}
|
2026-09-20 10:40:57 +02:00
|
|
|
tree, err := interpres.ParseMap(data)
|
2026-08-19 11:23:00 +02:00
|
|
|
if err != nil {
|
|
|
|
|
fmt.Fprintln(stderr, err)
|
|
|
|
|
return 1
|
|
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 21:23:29 +02:00
|
|
|
// 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.
|
|
|
|
|
func validatePaths(paths []string, stdin io.Reader, stderr io.Writer) int {
|
|
|
|
|
if len(paths) == 0 {
|
|
|
|
|
paths = []string{"-"}
|
|
|
|
|
}
|
|
|
|
|
valid := true
|
|
|
|
|
for _, p := range paths {
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-09-20 10:40:57 +02:00
|
|
|
if _, err := interpres.ParseMap(data); err != nil {
|
2026-09-17 21:23:29 +02:00
|
|
|
fmt.Fprintf(stderr, "%s: %v\n", name, err)
|
|
|
|
|
valid = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !valid {
|
|
|
|
|
return 1
|
|
|
|
|
}
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 11:38:19 +02:00
|
|
|
// 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 = <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) {
|
2026-09-20 10:40:57 +02:00
|
|
|
tree, err := interpres.ParseMap([]byte("v = " + val + "\n"))
|
2026-09-19 11:38:19 +02:00
|
|
|
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":
|
2026-09-19 19:36:24 +02:00
|
|
|
switch v.(type) {
|
|
|
|
|
case time.Time, interpres.OffsetDateTime:
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return false
|
2026-09-19 11:38:19 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 11:23:00 +02:00
|
|
|
// 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
|
2026-09-19 19:36:24 +02:00
|
|
|
case interpres.OffsetDateTime:
|
|
|
|
|
return tagged("datetime", x.Format(time.RFC3339Nano)), nil
|
2026-08-19 11:23:00 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|