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 ### 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 - `DecodeError` and `EncodeError`: decode and encode failures are wrapped in
typed errors carrying the key path, read with `errors.AsType` instead of typed errors carrying the key path, read with `errors.AsType` instead of
parsing the message text. The rendered messages keep their shape; the only 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/ARCHITECTURE.md](docs/ARCHITECTURE.md): components and data flow
- [docs/API.md](docs/API.md): the API reference, decoding and encoding rules - [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 ## Licence
+64 -9
View File
@@ -1,17 +1,23 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org) // Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
// Command interpres-decode reads a TOML document from standard input and writes // Command interpres-decode is the toml-test harness adapter and a TOML
// the toml-test "tagged JSON" representation to standard output. // 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 // interpres-decode -validate config.toml
// that invalid documents are rejected. Run the official suite against it with: //
// Run the official suite against the adapter with:
// //
// toml-test ./interpres-decode // toml-test ./interpres-decode
package main package main
import ( import (
"encoding/json" "encoding/json"
"errors"
"flag"
"fmt" "fmt"
"io" "io"
"math" "math"
@@ -23,13 +29,29 @@ import (
) )
func main() { 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 // Run runs the command line and returns the process exit code: 0 success,
// on stdout, and returns the process exit code (0 success, 1 parse error, // 1 an invalid document, 2 a usage, reading, encoding, or
// 2 I/O, encoding, or unsupported-value error). // unsupported-value error.
func Run(stdin io.Reader, stdout, stderr io.Writer) int { 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) data, err := io.ReadAll(stdin)
if err != nil { if err != nil {
fmt.Fprintln(stderr, "read stdin:", err) fmt.Fprintln(stderr, "read stdin:", err)
@@ -54,6 +76,39 @@ func Run(stdin io.Reader, stdout, stderr io.Writer) int {
return 0 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 // 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 // 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 // {"type", "value"} object. An error is returned for value types the encoder
+77 -4
View File
@@ -7,6 +7,7 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors" "errors"
"os"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -20,7 +21,7 @@ func TestRunParsesValidTOML(t *testing.T) {
port = 8080 port = 8080
enabled = true 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()) t.Fatalf("Run returned %d, stderr = %q", code, stderr.String())
} }
var got map[string]any var got map[string]any
@@ -41,7 +42,7 @@ enabled = true
func TestRunRejectsInvalidInput(t *testing.T) { func TestRunRejectsInvalidInput(t *testing.T) {
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
in := bytes.NewReader([]byte("v = \n")) in := bytes.NewReader([]byte("v = \n"))
code := Run(in, &stdout, &stderr) code := Run(nil, in, &stdout, &stderr)
if code != 1 { if code != 1 {
t.Errorf("Run returned %d, want 1 (parse error); stderr = %q", code, stderr.String()) 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) { func TestRunReadErrorReturnsTwo(t *testing.T) {
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
code := Run(errorReader{}, &stdout, &stderr) code := Run(nil, errorReader{}, &stdout, &stderr)
if code != 2 { if code != 2 {
t.Errorf("Run returned %d, want 2 (read error); stderr = %q", code, stderr.String()) 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 var stderr bytes.Buffer
w := errorWriter{} w := errorWriter{}
in := bytes.NewReader([]byte(`k = "v"` + "\n")) in := bytes.NewReader([]byte(`k = "v"` + "\n"))
code := Run(in, w, &stderr) code := Run(nil, in, w, &stderr)
if code != 2 { if code != 2 {
t.Errorf("Run returned %d, want 2 (encode error); stderr = %q", code, stderr.String()) 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) 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 # Command line
The reference below is taken from the program itself. `interpres-decode` is the The reference below is taken from the program itself. `interpres-decode` is
toml-test harness adapter, not a general-purpose tool: it takes no flags and no the toml-test harness adapter, and it also validates documents. Install it
arguments, reads one TOML document from stdin, and writes the toml-test with Go itself, no release assets involved:
tagged-JSON form to stdout.
```sh
go install sourcedock.dev/petrbalvin/interpres/cmd/interpres-decode@latest
```
## Synopsis ## Synopsis
```sh ```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 Without `-validate` the program is the toml-test adapter: it takes no
run it straight from the module directory with `just run`. 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 ## Exit codes
| Code | Meaning | | Code | Meaning |
|---|---| |---|---|
| `0` | the document parsed, tagged JSON written to stdout | | `0` | adapter: the document parsed and the tagged JSON was written; validate: every document parsed |
| `1` | parse error, the document is malformed; the message goes to stderr | | `1` | adapter: parse error; validate: at least one document is invalid |
| `2` | reading stdin failed, or a value has no tagged representation | | `2` | a usage error, a read failure, or a value with no tagged representation |
## Wire format ## Wire format
@@ -59,8 +78,23 @@ port = 9090
' | ./bin/interpres-decode ' | ./bin/interpres-decode
``` ```
The output is the equivalent value tree as one JSON object. Run the official The output is the equivalent value tree as one JSON object. Validate the
compliance suite against the binary: 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 ```sh
just toml-test just toml-test