From a7a942a8e19e30fd7d202aca555fa502cf42b8f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Tue, 22 Sep 2026 01:12:04 +0200 Subject: [PATCH] feat: add Statements, the top-level statement iterator Assisted-by: GLM 5.3 Flash --- CHANGELOG.md | 5 +++ docs/API.md | 21 +++++++++++ examples/statements/main.go | 39 +++++++++++++++++++ examples/statements/main_test.go | 39 +++++++++++++++++++ interpres.go | 65 ++++++++++++++++++++++++++++++++ interpres_test.go | 64 +++++++++++++++++++++++++++++++ 6 files changed, 233 insertions(+) create mode 100644 examples/statements/main.go create mode 100644 examples/statements/main_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c24fb44..9fba478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (10000 levels, which no hand-written document approaches): a document that nests arrays or inline tables deeper used to run the stack out and is now rejected with a `SyntaxError` naming the limit. +- `Statements(r)`, an iterator over the top-level statements of the document + the reader carries, in written order: key/value statements, a `[table]` + header as one statement with its node, an `[[array of tables]]` as one + statement per element. A caller that breaks after the statement it wanted + reads no further ones. `examples/statements` shows the walk. - `ParseAs[T](data)`, the generic one-line decode, and `NewSchema[T]()`, which precompiles the struct schema and the interface flags for a hot path before the first document arrives. diff --git a/docs/API.md b/docs/API.md index a73b2c8..495de2f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -97,6 +97,27 @@ interface flags the decoder and encoder resolve through are built once and cached, so the first document pays the cost instead of the hot path. A `T` that is not a struct warms nothing. +### `func Statements(r io.Reader) iter.Seq2[Statement, error]` + +Iterates the top-level statements of the document r carries, in written +order: key/value statements, a `[table]` header as one statement carrying +its `Table` node, and an `[[array of tables]]` as one statement per element +with the element's node and its `Index`. Iteration stops at the first error +and at a false yield, so a caller looking for one section reads no further. +The reader is consumed in full before the first yield, because the parser +scans the source in place. + +```go +for stmt, err := range interpres.Statements(file) { + if err != nil { + return err + } + if stmt.Table != nil { + fmt.Println(stmt.Key, stmt.Table.Keys()) + } +} +``` + ## Documents `Parse` returns a `Document`: the value tree together with what a map cannot diff --git a/examples/statements/main.go b/examples/statements/main.go new file mode 100644 index 0000000..c690e8d --- /dev/null +++ b/examples/statements/main.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: MIT + +// Command statements walks the top-level statements of a TOML document with +// interpres.Statements, the shape a configuration tool uses to read the +// sections it cares about and skip the rest. +package main + +import ( + "fmt" + "io" + "os" + + "sourcedock.dev/petrbalvin/interpres/v2" +) + +func main() { + if err := run(os.Stdin, os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(stdin io.Reader, stdout io.Writer) error { + for stmt, err := range interpres.Statements(stdin) { + if err != nil { + return err + } + switch { + case stmt.Index >= 0: + fmt.Fprintf(stdout, "[[%s]] #%d\n", stmt.Key, stmt.Index) + case stmt.Table != nil: + fmt.Fprintf(stdout, "[%s] keys: %v\n", stmt.Key, stmt.Table.Keys()) + default: + fmt.Fprintf(stdout, "%s = %v\n", stmt.Key, stmt.Value) + } + } + return nil +} diff --git a/examples/statements/main_test.go b/examples/statements/main_test.go new file mode 100644 index 0000000..e4ca7f9 --- /dev/null +++ b/examples/statements/main_test.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: MIT + +package main + +import ( + "strings" + "testing" +) + +func TestStatementsExample(t *testing.T) { + in := strings.NewReader(`title = "demo" +port = 8080 + +[server] +host = "127.0.0.1" + +[[items]] +name = "a" + +[[items]] +name = "b" +`) + var out strings.Builder + if err := run(in, &out); err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "title = demo", + "port = 8080", + "[server] keys: [host]", + "[[items]] #0", + "[[items]] #1", + } { + if !strings.Contains(out.String(), want) { + t.Errorf("output missing %q:\n%s", want, out.String()) + } + } +} diff --git a/interpres.go b/interpres.go index 09b9f8e..d168424 100644 --- a/interpres.go +++ b/interpres.go @@ -25,6 +25,8 @@ import ( "context" "errors" "fmt" + "io" + "iter" "os" "reflect" "slices" @@ -526,6 +528,69 @@ func Marshal(v any) ([]byte, error) { return MarshalContext(context.Background(), v) } +// A Statement is one top-level statement of a document, what Statements +// yields: a key with its value, a table with its node, or one element of an +// array of tables with its node. +type Statement struct { + // Key is the key as the document wrote it. + Key string + // Value is the value of a key/value statement, and the value map of a + // table statement. + Value any + // Table is the node of a table or array-of-tables statement, carrying the + // written key order and the comments; nil for a plain key/value. + Table *Table + // Index is the element's position when the statement is one element of an + // array of tables, and -1 otherwise. + Index int +} + +// Statements reads a TOML document from r and returns an iterator over its +// top-level statements in written order: key/value statements, a [table] +// header as one statement carrying its Table node, and an [[array of +// tables]] as one statement per element, each with the element's node and +// its Index. Iteration stops at the first error, which arrives as the second +// value, and at a false yield: a caller that breaks after the statement it +// wanted reads no further ones. +// +// The reader is consumed in full before the first statement is yielded, +// because the parser scans the source in place; processing the yielded +// statements one at a time is what bounds what the caller holds, and a +// later direct-to-target parse removes the whole-source hold. +func Statements(r io.Reader) iter.Seq2[Statement, error] { + return func(yield func(Statement, error) bool) { + data, err := io.ReadAll(r) + if err != nil { + yield(Statement{Index: -1}, err) + return + } + doc, err := Parse(data) + if err != nil { + yield(Statement{Index: -1}, err) + return + } + for _, e := range doc.Root().Entries() { + if els := e.Elements(); len(els) > 0 { + for i, el := range els { + if !yield(Statement{Key: e.Key(), Value: e.Value(), Table: el, Index: i}, nil) { + return + } + } + continue + } + if child := e.Table(); child != nil { + if !yield(Statement{Key: e.Key(), Value: e.Value(), Table: child, Index: -1}, nil) { + return + } + continue + } + if !yield(Statement{Key: e.Key(), Value: e.Value(), Index: -1}, nil) { + return + } + } + } +} + // MarshalAppend appends the TOML encoding of v to buf and returns the extended // buffer, the shape json.MarshalAppend has. A failed encoding leaves buf // untouched and comes back with a nil slice. diff --git a/interpres_test.go b/interpres_test.go index c771e41..446bad2 100644 --- a/interpres_test.go +++ b/interpres_test.go @@ -5,10 +5,12 @@ package interpres import ( "errors" + "fmt" "math" "os" "path/filepath" "reflect" + "slices" "strings" "testing" "time" @@ -821,3 +823,65 @@ func TestZeroOffsetRoundTrip(t *testing.T) { t.Errorf("a = %q, want 1979-05-27T07:32Z", got) } } + +func TestStatements(t *testing.T) { + src := strings.NewReader(`title = "demo" +port = 8080 + +[server] +host = "127.0.0.1" + +[[items]] +name = "a" + +[[items]] +name = "b" +`) + var lines []string + for stmt, err := range Statements(src) { + if err != nil { + t.Fatal(err) + } + switch { + case stmt.Index >= 0: + lines = append(lines, fmt.Sprintf("%s #%d", stmt.Key, stmt.Index)) + case stmt.Table != nil: + lines = append(lines, fmt.Sprintf("[%s] %v", stmt.Key, stmt.Table.Keys())) + default: + lines = append(lines, fmt.Sprintf("%s = %v", stmt.Key, stmt.Value)) + } + } + want := []string{ + `title = demo`, + `port = 8080`, + `[server] [host]`, + `items #0`, + `items #1`, + } + if !slices.Equal(lines, want) { + t.Errorf("statements =\n%v\nwant:\n%v", lines, want) + } + + t.Run("breaking stops the iteration", func(t *testing.T) { + src := strings.NewReader("a = 1\nb = 2\nc = 3\n") + count := 0 + for range Statements(src) { + count++ + break + } + if count != 1 { + t.Errorf("iterated %d statements after break, want 1", count) + } + }) + t.Run("a parse error arrives as the second value", func(t *testing.T) { + for stmt, err := range Statements(strings.NewReader("broken =\n")) { + if err == nil { + t.Fatalf("statement %+v without an error", stmt) + } + if _, ok := errors.AsType[*SyntaxError](err); !ok { + t.Errorf("err = %v, want a SyntaxError", err) + } + break + } + }) +}