feat: add the Document edit pipeline with comment-preserving write
Test / test (push) Successful in 1m35s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-22 01:22:46 +02:00
parent a7a942a8e1
commit 3ffae35a20
6 changed files with 488 additions and 23 deletions
+8 -2
View File
@@ -32,8 +32,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
values together with the key order, whether a table was written as an inline values together with the key order, whether a table was written as an inline
table or under a header, and the comments, with `Keys`, `Entries`, `Get`, table or under a header, and the comments, with `Keys`, `Entries`, `Get`,
`Comments` and `SetComments` to read and write them. `ParseMap` returns the `Comments` and `SetComments` to read and write them. `ParseMap` returns the
plain `map[string]any` tree, the shape `Parse` used to give. `Marshal` does plain `map[string]any` tree, the shape `Parse` used to give.
not accept a `Document`; it writes values, so `doc.Map()` is the way through. - The edit pipeline on a `Document`: typed getters on `Document` and `Table`
(`GetString`, `GetInt`, `GetFloat`, `GetBool`, `GetArray`, `GetTable`),
`Set` and `Delete` that keep the surviving keys' positions and comments,
`UnmarshalDocument`, which decodes the document into a typed destination
without parsing again, and `Marshal` of a `Document`, which writes the keys
in written order, the comments above the lines and headers they belonged
to, and the inline tables inline.
- `OffsetDateTime`, the Go type of the offset date-time kind, so that all four - `OffsetDateTime`, the Go type of the offset date-time kind, so that all four
TOML date-time kinds have one of their own. `Parse` and `Unmarshal` hand it TOML date-time kinds have one of their own. `Parse` and `Unmarshal` hand it
back where they produced a bare `time.Time` before, and `Marshal` accepts it. back where they produced a bare `time.Time` before, and `Marshal` accepts it.
+35 -2
View File
@@ -150,6 +150,37 @@ document and from `doc.Map()` is the same value.
array of tables, and the inline tables inside a value array, with `nil` for the array of tables, and the inline tables inside a value array, with `nil` for the
elements that are not tables. elements that are not tables.
### Editing a document
The document is writable, which makes the read-change-write loop a round trip
through one value. The typed getters read with one call:
| Getter | Returns |
|---|---|
| `GetString(key)` | `(string, bool)` |
| `GetInt(key)` | `(int64, bool)` |
| `GetFloat(key)` | `(float64, bool)` |
| `GetBool(key)` | `(bool, bool)` |
| `GetArray(key)` | `([]any, bool)` |
| `GetTable(key)` | `(*Table, bool)` |
`Set(key, value)` stores a value, keeping an existing key's position and
comments and appending a new key to the end; a `map[string]any` value becomes
a table of its own under a header, its keys in sorted order. `Delete(key)`
removes a key and everything it holds. Every method exists on `Document` for
the root table and on `Table` for the table itself.
`Marshal` writes the document back as it stands: keys in written order, the
comments above the lines and headers they belonged to, tables that were
written inline written inline again. `UnmarshalDocument(doc, v)` decodes the
edited document into a typed destination without parsing again.
```go
doc, err := interpres.Parse(data)
doc.Set("port", 9090)
out, err := interpres.Marshal(doc)
```
### Comments ### Comments
A comment belongs to the line it precedes or follows, and to the node that line A comment belongs to the line it precedes or follows, and to the node that line
@@ -868,8 +899,10 @@ long as no setter races with a call.
### `type Document`, `type Table`, `type Entry` ### `type Document`, `type Table`, `type Entry`
See [Documents](#documents). A `Document` is what `Parse` returns, and it is See [Documents](#documents). A `Document` is what `Parse` returns, and
not a value `Marshal` accepts. `Marshal` writes it back: the keys in written order, the comments in place,
the inline tables inline. `UnmarshalDocument(doc, v)` decodes it without
parsing again.
### `type Marshaler interface{ MarshalTOML() (any, error) }` ### `type Marshaler interface{ MarshalTOML() (any, error) }`
+151
View File
@@ -3,6 +3,11 @@
package interpres package interpres
import (
"maps"
"slices"
)
// A Document is a parsed TOML document: the values, plus what the map shape // A Document is a parsed TOML document: the values, plus what the map shape
// cannot carry, which is the order the keys were written in, whether a table // cannot carry, which is the order the keys were written in, whether a table
// was written inline or under a header, and the comments. // was written inline or under a header, and the comments.
@@ -39,6 +44,38 @@ func (d *Document) Footer() []string { return d.footer }
// SetFooter replaces those lines. // SetFooter replaces those lines.
func (d *Document) SetFooter(lines []string) { d.footer = lines } func (d *Document) SetFooter(lines []string) { d.footer = lines }
// The document-level convenience forms of the Table edit API; they act on
// the root table.
// Get returns the root table's entry for key, and whether the document has
// one. See Table.Get.
func (d *Document) Get(key string) (*Entry, bool) { return d.Root().Get(key) }
// GetString returns the string the key holds, and whether it holds one.
func (d *Document) GetString(key string) (string, bool) { return d.Root().GetString(key) }
// GetInt returns the integer the key holds, and whether it holds one.
func (d *Document) GetInt(key string) (int64, bool) { return d.Root().GetInt(key) }
// GetFloat returns the float the key holds, and whether it holds one.
func (d *Document) GetFloat(key string) (float64, bool) { return d.Root().GetFloat(key) }
// GetBool returns the boolean the key holds, and whether it holds one.
func (d *Document) GetBool(key string) (bool, bool) { return d.Root().GetBool(key) }
// GetArray returns the value array the key holds, and whether it holds one.
func (d *Document) GetArray(key string) ([]any, bool) { return d.Root().GetArray(key) }
// GetTable returns the node of the table the key holds, and whether it holds
// one.
func (d *Document) GetTable(key string) (*Table, bool) { return d.Root().GetTable(key) }
// Set stores value under the key in the root table. See Table.Set.
func (d *Document) Set(key string, value any) { d.Root().Set(key, value) }
// Delete removes the key from the root table. See Table.Delete.
func (d *Document) Delete(key string) { d.Root().Delete(key) }
// A Table is one TOML table: its values, its keys in written order, and the // A Table is one TOML table: its values, its keys in written order, and the
// comments around the header or the key that introduced it. // comments around the header or the key that introduced it.
type Table struct { type Table struct {
@@ -199,3 +236,117 @@ func (e *Entry) Trailing() string { return e.trailing }
// SetTrailing replaces that comment. // SetTrailing replaces that comment.
func (e *Entry) SetTrailing(line string) { e.trailing = line } func (e *Entry) SetTrailing(line string) { e.trailing = line }
// GetString returns the string the key holds, and whether it holds one.
func (t *Table) GetString(key string) (string, bool) {
v, ok := t.values[key]
s, ok := v.(string)
return s, ok
}
// GetInt returns the integer the key holds, and whether it holds one.
func (t *Table) GetInt(key string) (int64, bool) {
v, ok := t.values[key]
i, ok := v.(int64)
return i, ok
}
// GetFloat returns the float the key holds, and whether it holds one.
func (t *Table) GetFloat(key string) (float64, bool) {
v, ok := t.values[key]
f, ok := v.(float64)
return f, ok
}
// GetBool returns the boolean the key holds, and whether it holds one.
func (t *Table) GetBool(key string) (bool, bool) {
v, ok := t.values[key]
b, ok := v.(bool)
return b, ok
}
// GetArray returns the value array the key holds, and whether it holds one.
func (t *Table) GetArray(key string) ([]any, bool) {
v, ok := t.values[key]
a, ok := v.([]any)
return a, ok
}
// GetTable returns the node of the table the key holds, and whether it holds
// one, whichever way the document wrote the table.
func (t *Table) GetTable(key string) (*Table, bool) {
c := t.child(key)
return c, c != nil
}
// Set stores value under key. A key the table already has keeps its position
// and its comments; a new one joins the end. A value of map[string]any
// becomes a table node of its own, written under a header like any other
// table; a Go map carries no order, so its keys take sorted order. A value
// of []map[string]any becomes an array-of-tables node.
func (t *Table) Set(key string, value any) {
e, ok := t.index[key]
if !ok {
t.values[key] = value
e = t.addValue(key, value, false)
if m, isMap := value.(map[string]any); isMap {
e.child = newOrderedTable(m)
}
if items, isArray := value.([]map[string]any); isArray {
e.elements = make([]*Table, len(items))
for i, item := range items {
e.elements[i] = newOrderedTable(item)
}
}
return
}
t.values[key] = value
switch v := value.(type) {
case map[string]any:
if e.child == nil {
e.child = newOrderedTable(v)
} else {
e.child.values = v
}
e.elements = nil
case []map[string]any:
e.child = nil
e.elements = make([]*Table, len(v))
for i, item := range v {
e.elements[i] = newOrderedTable(item)
}
default:
e.child = nil
e.elements = nil
}
}
// newOrderedTable builds a table node for a value the caller set, its keys
// entered as entries in sorted order, the order Marshal writes maps in.
func newOrderedTable(m map[string]any) *Table {
t := newTable(m)
for _, k := range slices.Sorted(maps.Keys(m)) {
v := m[k]
_, isMap := v.(map[string]any)
e := t.addValue(k, v, false)
if isMap {
e.child = newOrderedTable(v.(map[string]any))
}
}
return t
}
// Delete removes key and everything it holds.
func (t *Table) Delete(key string) {
if _, ok := t.values[key]; !ok {
return
}
delete(t.values, key)
delete(t.index, key)
for i, e := range t.entries {
if e.key == key {
t.entries = append(t.entries[:i], t.entries[i+1:]...)
break
}
}
}
+121 -17
View File
@@ -288,27 +288,131 @@ func TestParseMapIsTheValueTree(t *testing.T) {
} }
} }
func TestMarshalRejectsDocument(t *testing.T) { func TestMarshalDocument(t *testing.T) {
// A Document is not a value to marshal: its order and comments would be // A Document writes back: the keys in written order, the comments above
// dropped, and a struct walk would silently write nothing at all. // the lines and headers they belonged to, and inline tables inline again.
doc, err := Parse([]byte("a = 1\n")) doc, err := Parse([]byte("# leading\na = 1 # trailing\n\n[t]\nb = \"x\"\n\ninline = { n = 1 }\n"))
if err != nil { if err != nil {
t.Fatalf("parse: %v", err) t.Fatalf("parse: %v", err)
} }
if _, err := Marshal(doc); err == nil { out, err := Marshal(doc)
t.Fatal("expected an error for a Document")
} else if !strings.Contains(err.Error(), "Map()") {
t.Errorf("err = %v, want it to point at Map()", err)
}
if _, err := Marshal(*doc); err == nil {
t.Fatal("expected an error for a Document value")
}
// The tree marshals, which is the way through.
out, err := Marshal(doc.Map())
if err != nil { if err != nil {
t.Fatalf("marshal of the tree: %v", err) t.Fatalf("marshal of a Document: %v", err)
} }
if want := "a = 1\n"; string(out) != want { want := "# leading\na = 1 # trailing\n\n[t]\nb = \"x\"\ninline = {n = 1}\n"
t.Errorf("output = %q, want %q", out, want) if string(out) != want {
t.Errorf("output:\n%q\nwant:\n%q", out, want)
}
// The written document parses back to the same values.
re, err := Parse(out)
if err != nil {
t.Fatalf("re-parse: %v", err)
}
if got := re.Map()["a"]; got != int64(1) {
t.Errorf("a = %#v", got)
}
if _, err := Marshal(*doc); err != nil {
t.Errorf("marshal of a Document value: %v", err)
} }
} }
func TestDocumentEditPipeline(t *testing.T) {
doc, err := Parse([]byte("host = \"db\"\nport = 5432\n\n# The cache section\ntimeout = 1.5\n"))
if err != nil {
t.Fatal(err)
}
t.Run("typed getters", func(t *testing.T) {
if s, ok := doc.GetString("host"); !ok || s != "db" {
t.Errorf("host = %q, %v", s, ok)
}
if i, ok := doc.GetInt("port"); !ok || i != 5432 {
t.Errorf("port = %d, %v", i, ok)
}
if f, ok := doc.GetFloat("timeout"); !ok || f != 1.5 {
t.Errorf("timeout = %g, %v", f, ok)
}
if _, ok := doc.GetBool("host"); ok {
t.Error("host claimed as bool")
}
})
t.Run("set keeps the position and the comments", func(t *testing.T) {
doc.Set("port", int64(9090))
if got := doc.Root().Keys(); !slices.Equal(got, []string{"host", "port", "timeout"}) {
t.Fatalf("keys = %v", got)
}
out, err := Marshal(doc)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(out), "port = 9090") {
t.Errorf("output missing the new value:\n%s", out)
}
})
t.Run("a new key joins the end", func(t *testing.T) {
doc.Set("lang", "cs")
if got := doc.Root().Keys(); !slices.Equal(got, []string{"host", "port", "timeout", "lang"}) {
t.Fatalf("keys = %v", got)
}
})
t.Run("a set table keeps an order of its own", func(t *testing.T) {
sub := map[string]any{"z": int64(1), "a": int64(2)}
doc.Set("cache", sub)
out, err := Marshal(doc)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(out), "[cache]\na = 2\nz = 1\n") {
t.Errorf("output missing the new table in order:\n%s", out)
}
})
t.Run("delete removes the key", func(t *testing.T) {
doc.Delete("lang")
if _, ok := doc.Get("lang"); ok {
t.Fatal("lang survived Delete")
}
out, err := Marshal(doc)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(out), "lang") {
t.Errorf("output still names lang:\n%s", out)
}
})
t.Run("UnmarshalDocument decodes without reparsing", func(t *testing.T) {
type Cfg struct {
Host string `toml:"host"`
Port int `toml:"port"`
}
var cfg Cfg
if err := UnmarshalDocument(doc, &cfg); err != nil {
t.Fatal(err)
}
if cfg.Host != "db" || cfg.Port != 9090 {
t.Errorf("decoded %+v", cfg)
}
})
t.Run("comments survive the round trip", func(t *testing.T) {
src := "# header comment\n[a]\n# key comment\nb = 2\n"
doc, err := Parse([]byte(src))
if err != nil {
t.Fatal(err)
}
out, err := Marshal(doc)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"# header comment", "[a]", "# key comment", "b = 2"} {
if !strings.Contains(string(out), want) {
t.Errorf("output missing %q:\n%s", want, out)
}
}
})
t.Run("a nil document refuses to decode", func(t *testing.T) {
var cfg struct {
A int `toml:"a"`
}
if err := UnmarshalDocument(nil, &cfg); err == nil {
t.Error("UnmarshalDocument(nil) succeeded, want an error")
}
})
}
+168
View File
@@ -0,0 +1,168 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package interpres
import (
"fmt"
)
// UnmarshalDocument decodes a parsed Document into v without parsing again,
// the shape an edit pipeline wants: read the document, change the values it
// holds, decode the result into a typed destination. The key order and the
// comments the document carries are untouched; the decode reads the value
// tree the document shares with its nodes.
//
// UnmarshalDocument accepts the same destinations Unmarshal does.
func UnmarshalDocument(doc *Document, v any) error {
if doc == nil {
return fmt.Errorf("interpres: cannot decode a nil Document")
}
dec := newDecoder()
dec.nodes = indexNodes(doc.Root())
return dec.decode(doc.Map(), v)
}
// writeDocument renders a Document back to TOML: the keys in written order,
// the comments above the lines and headers they belonged to, tables that
// were written inline written inline again, and an array of tables in its
// header form. It is the write side of the edit pipeline: read with Parse,
// change with the Table and Document mutators, write with Marshal.
func (e *encoder) writeDocument(doc *Document) error {
if err := e.checkCtx(); err != nil {
return err
}
if doc == nil || doc.root == nil {
return fmt.Errorf("interpres: cannot marshal a nil Document")
}
if err := e.writeTableEntries(doc.root, nil); err != nil {
return err
}
e.writeDocumentFooter(doc.footer)
return nil
}
// writeDocumentFooter writes the comment lines that follow the last
// statement, each separated from it by a blank line, the shape the parser
// reads them back from.
func (e *encoder) writeDocumentFooter(footer []string) {
for _, line := range footer {
e.buf.WriteString("# ")
e.buf.WriteString(line)
e.buf.WriteByte('\n')
}
}
// writeTableEntries writes one table's entries in written order at the given
// header path, nil for the document root, whose keys need no header.
func (e *encoder) writeTableEntries(t *Table, path []string) error {
if t == nil {
return nil
}
if path != nil {
e.writeBlankLine()
e.writeComments(t.Comments())
e.buf.WriteString("[")
if err := e.writeKeyPath(path); err != nil {
return err
}
e.buf.WriteString("]\n")
if tr := t.Trailing(); tr != "" {
e.buf.WriteString(" # ")
e.buf.WriteString(tr)
e.buf.WriteByte('\n')
}
}
for _, entry := range t.Entries() {
if err := e.checkCtx(); err != nil {
return err
}
if _, isTables := entry.Value().([]map[string]any); isTables && len(entry.Elements()) > 0 {
for i, el := range entry.Elements() {
elemPath := append(append([]string{}, path...), entry.Key())
e.writeBlankLine()
if i == 0 {
e.writeComments(entry.Comments())
}
e.buf.WriteString("[[")
if err := e.writeKeyPath(elemPath); err != nil {
return err
}
e.buf.WriteString("]]\n")
if err := e.writeTableEntries(el, elemPath); err != nil {
return err
}
}
continue
}
if child := entry.Table(); child != nil && !entry.Inline() {
headerPath := append(append([]string{}, path...), entry.Key())
if err := e.writeTableEntries(child, headerPath); err != nil {
return err
}
continue
}
if err := e.writeDocumentEntry(entry, path); err != nil {
return err
}
}
return nil
}
// writeDocumentEntry writes one "key = value" line of a document, with the
// comments the key carried. A value that is itself an inline table renders
// inline from its node, in the written order.
func (e *encoder) writeDocumentEntry(entry *Entry, path []string) error {
e.writeComments(entry.Comments())
if err := e.writeKey(entry.Key()); err != nil {
return err
}
e.buf.WriteString(" = ")
if child := entry.Table(); child != nil {
if err := e.writeInlineTableNode(child); err != nil {
return err
}
if tr := entry.Trailing(); tr != "" {
e.buf.WriteString(" # ")
e.buf.WriteString(tr)
}
e.buf.WriteByte('\n')
return nil
}
if err := e.writeValue(entry.Value()); err != nil {
return err
}
if tr := entry.Trailing(); tr != "" {
e.buf.WriteString(" # ")
e.buf.WriteString(tr)
}
e.buf.WriteByte('\n')
return nil
}
// writeInlineTableNode renders a table node as an inline table, its keys in
// written order, values that are tables inline in turn.
func (e *encoder) writeInlineTableNode(t *Table) error {
e.buf.WriteByte('{')
for i, key := range t.Keys() {
if i > 0 {
e.buf.WriteString(", ")
}
if err := e.writeKey(key); err != nil {
return err
}
e.buf.WriteString(" = ")
entry, _ := t.Get(key)
if child := entry.Table(); child != nil {
if err := e.writeInlineTableNode(child); err != nil {
return err
}
continue
}
if err := e.writeValue(t.Values()[key]); err != nil {
return err
}
}
e.buf.WriteByte('}')
return nil
}
+5 -2
View File
@@ -181,9 +181,12 @@ func (e *encoder) encode(v any) error {
if x == nil { if x == nil {
return fmt.Errorf("interpres: cannot marshal nil value") return fmt.Errorf("interpres: cannot marshal nil value")
} }
return fmt.Errorf("interpres: cannot marshal a Document; marshal its Map() to write the values") return e.writeDocument(x)
case Document: case Document:
return fmt.Errorf("interpres: cannot marshal a Document; marshal its Map() to write the values") if x.root == nil {
return fmt.Errorf("interpres: cannot marshal nil value")
}
return e.writeDocument(&x)
case OrderedMap: case OrderedMap:
return e.encodeOrderedMap(&x) return e.encodeOrderedMap(&x)
case *OrderedMap: case *OrderedMap: