feat: toml-test adapter command
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,129 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Command interpres-decode reads a TOML document from standard input and writes
|
||||||
|
// the toml-test "tagged JSON" representation to standard output.
|
||||||
|
//
|
||||||
|
// It exits non-zero on a parse error, which is how the toml-test harness checks
|
||||||
|
// that invalid documents are rejected. Run the official suite against it with:
|
||||||
|
//
|
||||||
|
// toml-test ./interpres-decode
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sourcedock.dev/petrbalvin/interpres"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
os.Exit(Run(os.Stdin, os.Stdout, os.Stderr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run reads a TOML document from stdin, emits the toml-test tagged-JSON form
|
||||||
|
// on stdout, and returns the process exit code (0 success, 1 parse error,
|
||||||
|
// 2 I/O, encoding, or unsupported-value error).
|
||||||
|
func Run(stdin io.Reader, stdout, stderr io.Writer) int {
|
||||||
|
data, err := io.ReadAll(stdin)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(stderr, "read stdin:", err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
tree, err := interpres.Parse(data)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sourcedock.dev/petrbalvin/interpres"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunParsesValidTOML(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
in := bytes.NewReader([]byte(`name = "x"
|
||||||
|
port = 8080
|
||||||
|
enabled = true
|
||||||
|
`))
|
||||||
|
if code := Run(in, &stdout, &stderr); code != 0 {
|
||||||
|
t.Fatalf("Run returned %d, stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
var got map[string]any
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if name := got["name"].(map[string]any); name["type"] != "string" || name["value"] != "x" {
|
||||||
|
t.Errorf("name = %#v", got["name"])
|
||||||
|
}
|
||||||
|
if port := got["port"].(map[string]any); port["type"] != "integer" || port["value"] != "8080" {
|
||||||
|
t.Errorf("port = %#v", got["port"])
|
||||||
|
}
|
||||||
|
if en := got["enabled"].(map[string]any); en["type"] != "bool" || en["value"] != "true" {
|
||||||
|
t.Errorf("enabled = %#v", got["enabled"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunRejectsInvalidInput(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
in := bytes.NewReader([]byte("v = \n"))
|
||||||
|
code := Run(in, &stdout, &stderr)
|
||||||
|
if code != 1 {
|
||||||
|
t.Errorf("Run returned %d, want 1 (parse error); stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if stdout.Len() != 0 {
|
||||||
|
t.Errorf("stdout should be empty on parse error, got %q", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunReadErrorReturnsTwo(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
code := Run(errorReader{}, &stdout, &stderr)
|
||||||
|
if code != 2 {
|
||||||
|
t.Errorf("Run returned %d, want 2 (read error); stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "read stdin") {
|
||||||
|
t.Errorf("stderr = %q, want it to mention 'read stdin'", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorReader is an io.Reader that always reports an error.
|
||||||
|
type errorReader struct{}
|
||||||
|
|
||||||
|
func (errorReader) Read(_ []byte) (int, error) { return 0, errors.New("boom") }
|
||||||
|
|
||||||
|
func TestRunEncodeErrorReturnsTwo(t *testing.T) {
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
w := errorWriter{}
|
||||||
|
in := bytes.NewReader([]byte(`k = "v"` + "\n"))
|
||||||
|
code := Run(in, w, &stderr)
|
||||||
|
if code != 2 {
|
||||||
|
t.Errorf("Run returned %d, want 2 (encode error); stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "encode") {
|
||||||
|
t.Errorf("stderr = %q, want it to mention 'encode'", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorWriter is an io.Writer that always reports an error.
|
||||||
|
type errorWriter struct{}
|
||||||
|
|
||||||
|
func (errorWriter) Write(_ []byte) (int, error) { return 0, errors.New("boom") }
|
||||||
|
|
||||||
|
func TestTagHandlesAllScalarTypes(t *testing.T) {
|
||||||
|
now := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||||
|
ldt := interpres.LocalDateTime{Time: now}
|
||||||
|
ld := interpres.LocalDate{Time: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)}
|
||||||
|
lt := interpres.LocalTime{Time: time.Date(0, 1, 1, 3, 4, 5, 0, time.UTC)}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in any
|
||||||
|
want map[string]string
|
||||||
|
}{
|
||||||
|
{"string", "x", map[string]string{"type": "string", "value": "x"}},
|
||||||
|
{"bool", true, map[string]string{"type": "bool", "value": "true"}},
|
||||||
|
{"bool-false", false, map[string]string{"type": "bool", "value": "false"}},
|
||||||
|
{"int64", int64(42), map[string]string{"type": "integer", "value": "42"}},
|
||||||
|
{"float", 1.5, map[string]string{"type": "float", "value": "1.5"}},
|
||||||
|
{"time", now, map[string]string{"type": "datetime", "value": "2026-01-02T03:04:05Z"}},
|
||||||
|
{"ldt", ldt, map[string]string{"type": "datetime-local", "value": "2026-01-02T03:04:05"}},
|
||||||
|
{"ld", ld, map[string]string{"type": "date-local", "value": "2026-01-02"}},
|
||||||
|
{"lt", lt, map[string]string{"type": "time-local", "value": "03:04:05"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, err := tag(c.in)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s: tag returned error %v", c.name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m, ok := got.(map[string]string)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%s: tag returned %T, want map[string]string", c.name, got)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m["type"] != c.want["type"] || m["value"] != c.want["value"] {
|
||||||
|
t.Errorf("%s: tag = %#v, want %#v", c.name, m, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagRejectsUnsupportedType(t *testing.T) {
|
||||||
|
// Channels are not produced by the parser; tag must reject them explicitly.
|
||||||
|
var ch chan int
|
||||||
|
got, err := tag(ch)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for chan int, got value %#v", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "unsupported") {
|
||||||
|
t.Errorf("err = %v, want substring 'unsupported'", err)
|
||||||
|
}
|
||||||
|
if got != nil {
|
||||||
|
t.Errorf("got = %#v, want nil", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same applies when an unsupported type is nested inside a table.
|
||||||
|
got, err = tag(map[string]any{"x": ch})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for nested chan int, got %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exercise the table, array-of-tables, and array shapes so all of tag's
|
||||||
|
// switch arms are visited.
|
||||||
|
func TestTagHandlesContainers(t *testing.T) {
|
||||||
|
tbl := map[string]any{
|
||||||
|
"name": "x",
|
||||||
|
"items": []any{
|
||||||
|
int64(1), int64(2),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got, err := tag(tbl)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("tag(tbl) returned error: %v", err)
|
||||||
|
}
|
||||||
|
m, ok := got.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("tag returned %T, want map", got)
|
||||||
|
}
|
||||||
|
name, ok := m["name"].(map[string]string)
|
||||||
|
if !ok || name["value"] != "x" {
|
||||||
|
t.Errorf("name = %#v", m["name"])
|
||||||
|
}
|
||||||
|
items, ok := m["items"].([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("items = %T, want []any", m["items"])
|
||||||
|
}
|
||||||
|
if len(items) != 2 {
|
||||||
|
t.Fatalf("len(items) = %d, want 2", len(items))
|
||||||
|
}
|
||||||
|
for _, raw := range items {
|
||||||
|
if _, ok := raw.(map[string]string); !ok {
|
||||||
|
t.Errorf("item = %T, want map[string]string", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct tests of the array branches.
|
||||||
|
if got, err := tag([]any{int64(1)}); err != nil {
|
||||||
|
t.Errorf("tag([]any) returned error: %v", err)
|
||||||
|
} else if len(got.([]any)) != 1 {
|
||||||
|
t.Errorf("[]any tag = %#v", got)
|
||||||
|
}
|
||||||
|
if got, err := tag([]map[string]any{{"k": "v"}}); err != nil {
|
||||||
|
t.Errorf("tag([]map) returned error: %v", err)
|
||||||
|
} else if len(got.([]any)) != 1 {
|
||||||
|
t.Errorf("[]map[string]any tag = %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatFloatAllBranches(t *testing.T) {
|
||||||
|
cases := map[float64]string{
|
||||||
|
1.5: "1.5",
|
||||||
|
0: "0",
|
||||||
|
1e10: "1e+10",
|
||||||
|
-2.5e-3: "-0.0025",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := formatFloat(in); got != want {
|
||||||
|
t.Errorf("formatFloat(%v) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaggedHelper(t *testing.T) {
|
||||||
|
got := tagged("string", "x")
|
||||||
|
if got["type"] != "string" || got["value"] != "x" {
|
||||||
|
t.Errorf("tagged = %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user