feat: add the -validate mode to interpres-decode
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user