feat: add Statements, the top-level statement iterator
Test / test (push) Successful in 1m36s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-22 01:12:04 +02:00
parent d92bb56853
commit a7a942a8e1
6 changed files with 233 additions and 0 deletions
+5
View File
@@ -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 (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 nests arrays or inline tables deeper used to run the stack out and is now
rejected with a `SyntaxError` naming the limit. 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]()`, - `ParseAs[T](data)`, the generic one-line decode, and `NewSchema[T]()`,
which precompiles the struct schema and the interface flags for a hot path which precompiles the struct schema and the interface flags for a hot path
before the first document arrives. before the first document arrives.
+21
View File
@@ -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` cached, so the first document pays the cost instead of the hot path. A `T`
that is not a struct warms nothing. 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 ## Documents
`Parse` returns a `Document`: the value tree together with what a map cannot `Parse` returns a `Document`: the value tree together with what a map cannot
+39
View File
@@ -0,0 +1,39 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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
}
+39
View File
@@ -0,0 +1,39 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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())
}
}
}
+65
View File
@@ -25,6 +25,8 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"iter"
"os" "os"
"reflect" "reflect"
"slices" "slices"
@@ -526,6 +528,69 @@ func Marshal(v any) ([]byte, error) {
return MarshalContext(context.Background(), v) 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 // MarshalAppend appends the TOML encoding of v to buf and returns the extended
// buffer, the shape json.MarshalAppend has. A failed encoding leaves buf // buffer, the shape json.MarshalAppend has. A failed encoding leaves buf
// untouched and comes back with a nil slice. // untouched and comes back with a nil slice.
+64
View File
@@ -5,10 +5,12 @@ package interpres
import ( import (
"errors" "errors"
"fmt"
"math" "math"
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
"slices"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -821,3 +823,65 @@ func TestZeroOffsetRoundTrip(t *testing.T) {
t.Errorf("a = %q, want 1979-05-27T07:32Z", got) 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
}
})
}