feat: add the -validate mode to interpres-decode

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-17 21:23:29 +02:00
parent 3f41266710
commit 5a270d0879
5 changed files with 194 additions and 26 deletions
+6
View File
@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- `interpres-decode -validate [file ...]`: a validate mode beside the
toml-test adapter. It parses each named file, or stdin when none are named,
prints one line per invalid document to stderr, and exits 0 when all are
valid, 1 when one is not, and 2 on a usage or read failure. Install it with
`go install .../cmd/interpres-decode@latest`; releases still ship no
binaries.
- `DecodeError` and `EncodeError`: decode and encode failures are wrapped in
typed errors carrying the key path, read with `errors.AsType` instead of
parsing the message text. The rendered messages keep their shape; the only
+1 -1
View File
@@ -160,7 +160,7 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for the full workflow, and
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): components and data flow
- [docs/API.md](docs/API.md): the API reference, decoding and encoding rules
- [docs/CLI.md](docs/CLI.md): the interpres-decode toml-test adapter
- [docs/CLI.md](docs/CLI.md): the interpres-decode toml-test adapter and validator
## Licence
+64 -9
View File
@@ -1,17 +1,23 @@
// 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.
// 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
// -validate it checks the named documents, or standard input when none are
// named, and exits non-zero on the first invalid one:
//
// 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:
// interpres-decode -validate config.toml
//
// Run the official suite against the adapter with:
//
// toml-test ./interpres-decode
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"math"
@@ -23,13 +29,29 @@ import (
)
func main() {
os.Exit(Run(os.Stdin, os.Stdout, os.Stderr))
os.Exit(Run(os.Args[1:], 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 {
// 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")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
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
}
data, err := io.ReadAll(stdin)
if err != nil {
fmt.Fprintln(stderr, "read stdin:", err)
@@ -54,6 +76,39 @@ func Run(stdin io.Reader, stdout, stderr io.Writer) int {
return 0
}
// 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
}
if _, err := interpres.Parse(data); err != nil {
fmt.Fprintf(stderr, "%s: %v\n", name, err)
valid = false
}
}
if !valid {
return 1
}
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
+77 -4
View File
@@ -7,6 +7,7 @@ import (
"bytes"
"encoding/json"
"errors"
"os"
"strings"
"testing"
"time"
@@ -20,7 +21,7 @@ func TestRunParsesValidTOML(t *testing.T) {
port = 8080
enabled = true
`))
if code := Run(in, &stdout, &stderr); code != 0 {
if code := Run(nil, in, &stdout, &stderr); code != 0 {
t.Fatalf("Run returned %d, stderr = %q", code, stderr.String())
}
var got map[string]any
@@ -41,7 +42,7 @@ enabled = true
func TestRunRejectsInvalidInput(t *testing.T) {
var stdout, stderr bytes.Buffer
in := bytes.NewReader([]byte("v = \n"))
code := Run(in, &stdout, &stderr)
code := Run(nil, in, &stdout, &stderr)
if code != 1 {
t.Errorf("Run returned %d, want 1 (parse error); stderr = %q", code, stderr.String())
}
@@ -52,7 +53,7 @@ func TestRunRejectsInvalidInput(t *testing.T) {
func TestRunReadErrorReturnsTwo(t *testing.T) {
var stdout, stderr bytes.Buffer
code := Run(errorReader{}, &stdout, &stderr)
code := Run(nil, errorReader{}, &stdout, &stderr)
if code != 2 {
t.Errorf("Run returned %d, want 2 (read error); stderr = %q", code, stderr.String())
}
@@ -70,7 +71,7 @@ func TestRunEncodeErrorReturnsTwo(t *testing.T) {
var stderr bytes.Buffer
w := errorWriter{}
in := bytes.NewReader([]byte(`k = "v"` + "\n"))
code := Run(in, w, &stderr)
code := Run(nil, in, w, &stderr)
if code != 2 {
t.Errorf("Run returned %d, want 2 (encode error); stderr = %q", code, stderr.String())
}
@@ -210,3 +211,75 @@ func TestTaggedHelper(t *testing.T) {
t.Errorf("tagged = %#v", got)
}
}
func TestValidateStdinAcceptsValidDocument(t *testing.T) {
var stdout, stderr bytes.Buffer
in := bytes.NewReader([]byte("title = \"ok\"\n"))
if code := Run([]string{"-validate"}, in, &stdout, &stderr); code != 0 {
t.Fatalf("Run returned %d, stderr = %q", code, stderr.String())
}
if stdout.Len() != 0 || stderr.Len() != 0 {
t.Fatalf("validate should be quiet on success, stdout %q stderr %q", stdout.String(), stderr.String())
}
}
func TestValidateStdinRejectsInvalidDocument(t *testing.T) {
var stdout, stderr bytes.Buffer
in := bytes.NewReader([]byte("title = \"unterminated\n"))
if code := Run([]string{"-validate"}, in, &stdout, &stderr); code != 1 {
t.Fatalf("Run returned %d, want 1; stderr = %q", code, stderr.String())
}
if !strings.Contains(stderr.String(), "<stdin>") || !strings.Contains(stderr.String(), "line 1") {
t.Fatalf("stderr = %q, want the name and the line", stderr.String())
}
if stdout.Len() != 0 {
t.Fatalf("stdout should stay empty, got %q", stdout.String())
}
}
func TestValidateFiles(t *testing.T) {
dir := t.TempDir()
good := dir + "/good.toml"
bad := dir + "/bad.toml"
if err := os.WriteFile(good, []byte("a = 1\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(bad, []byte("a =\n"), 0o644); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
if code := Run([]string{"-validate", good}, nil, &stdout, &stderr); code != 0 {
t.Fatalf("one valid file: Run returned %d, stderr = %q", code, stderr.String())
}
if code := Run([]string{"-validate", good, bad}, nil, &stdout, &stderr); code != 1 {
t.Fatalf("valid plus invalid: Run returned %d, want 1; stderr = %q", code, stderr.String())
}
if !strings.Contains(stderr.String(), bad) || !strings.Contains(stderr.String(), "line 1") {
t.Fatalf("stderr = %q, want the file name and the line", stderr.String())
}
}
func TestValidateMissingFileReturnsTwo(t *testing.T) {
var stdout, stderr bytes.Buffer
if code := Run([]string{"-validate", "no-such-file.toml"}, nil, &stdout, &stderr); code != 2 {
t.Fatalf("Run returned %d, want 2; stderr = %q", code, stderr.String())
}
}
func TestAdapterModeRejectsPositionalArgument(t *testing.T) {
var stdout, stderr bytes.Buffer
in := bytes.NewReader([]byte("a = 1\n"))
if code := Run([]string{"file.toml"}, in, &stdout, &stderr); code != 2 {
t.Fatalf("Run returned %d, want 2; stderr = %q", code, stderr.String())
}
if !strings.Contains(stderr.String(), "-validate") {
t.Fatalf("stderr = %q, want it to point at -validate", stderr.String())
}
}
func TestUnknownFlagReturnsTwo(t *testing.T) {
var stdout, stderr bytes.Buffer
if code := Run([]string{"-nope"}, nil, &stdout, &stderr); code != 2 {
t.Fatalf("Run returned %d, want 2; stderr = %q", code, stderr.String())
}
}
+46 -12
View File
@@ -1,26 +1,45 @@
# Command line
The reference below is taken from the program itself. `interpres-decode` is the
toml-test harness adapter, not a general-purpose tool: it takes no flags and no
arguments, reads one TOML document from stdin, and writes the toml-test
tagged-JSON form to stdout.
The reference below is taken from the program itself. `interpres-decode` is
the toml-test harness adapter, and it also validates documents. Install it
with Go itself, no release assets involved:
```sh
go install sourcedock.dev/petrbalvin/interpres/cmd/interpres-decode@latest
```
## Synopsis
```sh
interpres-decode < document.toml
interpres-decode [flags]
interpres-decode -validate [file ...]
```
Build it with `just build`, which compiles it into `bin/interpres-decode`, or
run it straight from the module directory with `just run`.
Without `-validate` the program is the toml-test adapter: it takes no
arguments, reads one TOML document from stdin, and writes the toml-test
tagged-JSON form to stdout. Build it locally with `just build`, which
compiles it into `bin/interpres-decode`, or run it straight from the module
directory with `just run`.
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
quiet on valid documents, which is the shape a CI step wants. The `-` name
means stdin.
## Flags
| Flag | Effect |
|---|---|
| `-validate` | validate the documents instead of emitting tagged JSON |
| `-h` | print the usage |
## Exit codes
| Code | Meaning |
|---|---|
| `0` | the document parsed, tagged JSON written to stdout |
| `1` | parse error, the document is malformed; the message goes to stderr |
| `2` | reading stdin failed, or a value has no tagged representation |
| `0` | adapter: the document parsed and the tagged JSON was written; validate: every document parsed |
| `1` | adapter: parse error; validate: at least one document is invalid |
| `2` | a usage error, a read failure, or a value with no tagged representation |
## Wire format
@@ -59,8 +78,23 @@ port = 9090
' | ./bin/interpres-decode
```
The output is the equivalent value tree as one JSON object. Run the official
compliance suite against the binary:
The output is the equivalent value tree as one JSON object. Validate the
TOML files of another repository in CI:
```sh
interpres-decode -validate config.toml deploy/example.toml
```
An invalid document reports the file and the library's line number:
```sh
$ interpres-decode -validate bad.toml
bad.toml: interpres: line 1: expected a value
$ echo $?
1
```
Run the official compliance suite against the adapter:
```sh
just toml-test