From 3ffae35a20d7fa3fcf01f4a82f363ee386dcf4f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Tue, 22 Sep 2026 01:22:46 +0200 Subject: [PATCH] feat: add the Document edit pipeline with comment-preserving write Assisted-by: GLM 5.3 Flash --- CHANGELOG.md | 10 ++- docs/API.md | 37 ++++++++++- document.go | 151 ++++++++++++++++++++++++++++++++++++++++++ document_test.go | 138 +++++++++++++++++++++++++++++++++----- docwrite.go | 168 +++++++++++++++++++++++++++++++++++++++++++++++ encode.go | 7 +- 6 files changed, 488 insertions(+), 23 deletions(-) create mode 100644 docwrite.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fba478..2f82fa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 table or under a header, and the comments, with `Keys`, `Entries`, `Get`, `Comments` and `SetComments` to read and write them. `ParseMap` returns the - plain `map[string]any` tree, the shape `Parse` used to give. `Marshal` does - not accept a `Document`; it writes values, so `doc.Map()` is the way through. + plain `map[string]any` tree, the shape `Parse` used to give. +- 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 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. diff --git a/docs/API.md b/docs/API.md index 495de2f..7deb51e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 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 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` -See [Documents](#documents). A `Document` is what `Parse` returns, and it is -not a value `Marshal` accepts. +See [Documents](#documents). A `Document` is what `Parse` returns, and +`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) }` diff --git a/document.go b/document.go index d3182c6..2026050 100644 --- a/document.go +++ b/document.go @@ -3,6 +3,11 @@ package interpres +import ( + "maps" + "slices" +) + // 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 // 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. 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 // comments around the header or the key that introduced it. type Table struct { @@ -199,3 +236,117 @@ func (e *Entry) Trailing() string { return e.trailing } // SetTrailing replaces that comment. 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 + } + } +} diff --git a/document_test.go b/document_test.go index 7c463ef..9f41bb9 100644 --- a/document_test.go +++ b/document_test.go @@ -288,27 +288,131 @@ func TestParseMapIsTheValueTree(t *testing.T) { } } -func TestMarshalRejectsDocument(t *testing.T) { - // A Document is not a value to marshal: its order and comments would be - // dropped, and a struct walk would silently write nothing at all. - doc, err := Parse([]byte("a = 1\n")) +func TestMarshalDocument(t *testing.T) { + // A Document writes back: the keys in written order, the comments above + // the lines and headers they belonged to, and inline tables inline again. + doc, err := Parse([]byte("# leading\na = 1 # trailing\n\n[t]\nb = \"x\"\n\ninline = { n = 1 }\n")) if err != nil { t.Fatalf("parse: %v", err) } - if _, err := Marshal(doc); err == nil { - 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()) + out, err := Marshal(doc) 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 { - t.Errorf("output = %q, want %q", out, want) + want := "# leading\na = 1 # trailing\n\n[t]\nb = \"x\"\ninline = {n = 1}\n" + 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") + } + }) +} diff --git a/docwrite.go b/docwrite.go new file mode 100644 index 0000000..584d4a1 --- /dev/null +++ b/docwrite.go @@ -0,0 +1,168 @@ +// Copyright (c) 2026 Petr BalvĂ­n (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 +} diff --git a/encode.go b/encode.go index 5518b64..c8a9fc9 100644 --- a/encode.go +++ b/encode.go @@ -181,9 +181,12 @@ func (e *encoder) encode(v any) error { if x == nil { 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: - 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: return e.encodeOrderedMap(&x) case *OrderedMap: