From 5a270d0879583017b5c0253c696454f4cd905cc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Thu, 17 Sep 2026 21:23:29 +0200 Subject: [PATCH] feat: add the -validate mode to interpres-decode Assisted-by: GLM 5.3 Flash --- CHANGELOG.md | 6 +++ README.md | 2 +- cmd/interpres-decode/main.go | 73 ++++++++++++++++++++++++---- cmd/interpres-decode/main_test.go | 81 +++++++++++++++++++++++++++++-- docs/CLI.md | 58 +++++++++++++++++----- 5 files changed, 194 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 682e0f1..9b2cb00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 0ec1972..7d281c6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/interpres-decode/main.go b/cmd/interpres-decode/main.go index 60bace0..d168328 100644 --- a/cmd/interpres-decode/main.go +++ b/cmd/interpres-decode/main.go @@ -1,17 +1,23 @@ // Copyright (c) 2026 Petr BalvĂ­n (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 = "" + } 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 diff --git a/cmd/interpres-decode/main_test.go b/cmd/interpres-decode/main_test.go index 4d8d1da..4f354b2 100644 --- a/cmd/interpres-decode/main_test.go +++ b/cmd/interpres-decode/main_test.go @@ -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(), "") || !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()) + } +} diff --git a/docs/CLI.md b/docs/CLI.md index f0ee4a2..c051f12 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -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