diff --git a/document.go b/document.go index 2026050..fa9e077 100644 --- a/document.go +++ b/document.go @@ -34,15 +34,31 @@ func (d *Document) Root() *Table { } // Map returns the value tree, the shape ParseMap gives. It is the tree the -// document was parsed into, not a copy. -func (d *Document) Map() map[string]any { return d.root.values } +// document was parsed into, not a copy. A nil document or one with no root +// holds no values. +func (d *Document) Map() map[string]any { + if d == nil || d.root == nil { + return nil + } + return d.root.values +} // Footer returns the comment lines that follow the last statement, and every // line of a document that holds no statement at all. -func (d *Document) Footer() []string { return d.footer } +func (d *Document) Footer() []string { + if d == nil { + return nil + } + return d.footer +} // SetFooter replaces those lines. -func (d *Document) SetFooter(lines []string) { d.footer = lines } +func (d *Document) SetFooter(lines []string) { + if d == nil { + return + } + d.footer = lines +} // The document-level convenience forms of the Table edit API; they act on // the root table. @@ -87,6 +103,11 @@ type Table struct { // rather than under a header or as a dotted key. inline bool + // dotted records that a dotted key introduced the table, `a.b = 1` + // building the a around the leaf: the write side gives such a table back + // as dotted key lines, the form that holds the position of a line. + dotted bool + // comments are the lines above the table's header, trailing is the comment // on the header's own line. Both are empty for a table a dotted key // introduced, which has no line of its own. @@ -98,8 +119,12 @@ func newTable(values map[string]any) *Table { return &Table{values: values, index: map[string]*Entry{}} } -// Keys returns the table's keys in the order they were written. +// Keys returns the table's keys in the order they were written. A nil table +// holds none, the answer a document without a root gives through Root. func (t *Table) Keys() []string { + if t == nil { + return nil + } keys := make([]string, len(t.entries)) for i, e := range t.entries { keys[i] = e.key @@ -109,43 +134,75 @@ func (t *Table) Keys() []string { // Values returns the table's values, which is the map the value tree holds for // it. -func (t *Table) Values() map[string]any { return t.values } +func (t *Table) Values() map[string]any { + if t == nil { + return nil + } + return t.values +} // Entries returns the table's entries in written order. -func (t *Table) Entries() []*Entry { return t.entries } +func (t *Table) Entries() []*Entry { + if t == nil { + return nil + } + return t.entries +} // Get returns the entry for key, and whether the table has one. func (t *Table) Get(key string) (*Entry, bool) { + if t == nil { + return nil, false + } e, ok := t.index[key] return e, ok } // Inline reports whether the table was written as an inline table, `{…}`, // rather than under a header or introduced by a dotted key. -func (t *Table) Inline() bool { return t.inline } +func (t *Table) Inline() bool { return t != nil && t.inline } // Comments returns the comment lines above the table's header, or above the // key that introduced it. Lines carry no leading '#' and no surrounding space. -func (t *Table) Comments() []string { return t.comments } +func (t *Table) Comments() []string { + if t == nil { + return nil + } + return t.comments +} // SetComments replaces those lines. Each line is written back with a "# " in // front of it, so a line should not carry one. -func (t *Table) SetComments(lines []string) { t.comments = lines } +func (t *Table) SetComments(lines []string) { + if t == nil { + return + } + t.comments = lines +} // Trailing returns the comment on the header's own line, without the '#'. -func (t *Table) Trailing() string { return t.trailing } +func (t *Table) Trailing() string { + if t == nil { + return "" + } + return t.trailing +} // SetTrailing replaces that comment. -func (t *Table) SetTrailing(line string) { t.trailing = line } +func (t *Table) SetTrailing(line string) { + if t == nil { + return + } + t.trailing = line +} -// addValue records a key of the table, in written order. +// addValue records a key of the table, in written order. The caller gives +// the entry a table node or element nodes when the value has that shape; a +// map value left without a node writes as an inline table. func (t *Table) addValue(key string, val any, inline bool) *Entry { e := &Entry{table: t, key: key, inline: inline} t.entries = append(t.entries, e) t.index[key] = e - if node, ok := val.(map[string]any); ok { - e.child = newTable(node) - } return e } @@ -178,6 +235,9 @@ func (t *Table) addElement(key string, values map[string]any) *Table { // child returns the node of a table-valued key, or nil. func (t *Table) child(key string) *Table { + if t == nil { + return nil + } if e, ok := t.index[key]; ok { return e.child } @@ -239,6 +299,9 @@ 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) { + if t == nil { + return "", false + } v, ok := t.values[key] s, ok := v.(string) return s, ok @@ -246,6 +309,9 @@ func (t *Table) GetString(key string) (string, bool) { // GetInt returns the integer the key holds, and whether it holds one. func (t *Table) GetInt(key string) (int64, bool) { + if t == nil { + return 0, false + } v, ok := t.values[key] i, ok := v.(int64) return i, ok @@ -253,6 +319,9 @@ func (t *Table) GetInt(key string) (int64, bool) { // GetFloat returns the float the key holds, and whether it holds one. func (t *Table) GetFloat(key string) (float64, bool) { + if t == nil { + return 0, false + } v, ok := t.values[key] f, ok := v.(float64) return f, ok @@ -260,6 +329,9 @@ func (t *Table) GetFloat(key string) (float64, bool) { // GetBool returns the boolean the key holds, and whether it holds one. func (t *Table) GetBool(key string) (bool, bool) { + if t == nil { + return false, false + } v, ok := t.values[key] b, ok := v.(bool) return b, ok @@ -267,6 +339,9 @@ func (t *Table) GetBool(key string) (bool, bool) { // GetArray returns the value array the key holds, and whether it holds one. func (t *Table) GetArray(key string) ([]any, bool) { + if t == nil { + return nil, false + } v, ok := t.values[key] a, ok := v.([]any) return a, ok @@ -282,9 +357,13 @@ func (t *Table) GetTable(key string) (*Table, bool) { // 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 +// table, and replaces the node the key held, which belonged to the value the +// key held; 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) { + if t == nil { + return + } e, ok := t.index[key] if !ok { t.values[key] = value @@ -303,11 +382,10 @@ func (t *Table) Set(key string, value any) { 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 - } + // The node is rebuilt rather than patched: the entries and the index + // belong to the table the key held, and writing the new value + // through them would leave the old table's keys in the output. + e.child = newOrderedTable(v) e.elements = nil case []map[string]any: e.child = nil @@ -322,15 +400,30 @@ func (t *Table) Set(key string, value any) { } // 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. +// entered in sorted order, the order Marshal writes maps in. func newOrderedTable(m map[string]any) *Table { + return orderedTable(m, 0) +} + +// orderedTable is newOrderedTable's recursion. The depth bound is the value +// encoder's: a cyclic map stopped here is written by the value writer, which +// reports it instead of running the stack out. +func orderedTable(m map[string]any, depth int) *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)) + if depth >= maxEncodeDepth { + continue + } + switch val := v.(type) { + case map[string]any: + e.child = orderedTable(val, depth+1) + case []map[string]any: + e.elements = make([]*Table, len(val)) + for i, item := range val { + e.elements[i] = orderedTable(item, depth+1) + } } } return t @@ -338,6 +431,9 @@ func newOrderedTable(m map[string]any) *Table { // Delete removes key and everything it holds. func (t *Table) Delete(key string) { + if t == nil { + return + } if _, ok := t.values[key]; !ok { return } diff --git a/document_test.go b/document_test.go index 9f41bb9..016d79b 100644 --- a/document_test.go +++ b/document_test.go @@ -4,6 +4,7 @@ package interpres import ( + "reflect" "slices" "strings" "testing" @@ -416,3 +417,143 @@ func TestDocumentEditPipeline(t *testing.T) { } }) } + +// TestMarshalDocumentRoundTrips pins that a parsed document written back +// re-parses to the same tree: arrays of tables keep exactly one header per +// element, dotted keys hold their line position without swallowing the keys +// after them, inline tables inside value arrays keep their written order, +// and comments travel with their statements. +func TestMarshalDocumentRoundTrips(t *testing.T) { + tests := []struct { + name string + src string + }{ + {"array of tables", "[[items]]\nname = \"a\"\n\n[[items]]\nname = \"b\"\n"}, + {"array of tables with comments", "# about items\n[[items]] # first\nname = \"a\"\n"}, + {"dotted key before a later key", "a.b = 1\nc = 2\n"}, + {"dotted keys grouped", "a.b = 1\na.c = 2\nd = 3\n"}, + {"dotted key with a nested leaf", "a.b.c = 1\nz = 2\n"}, + {"header section after a dotted key", "a.b = 1\n\n[a.x]\ny = 2\n"}, + {"inline tables in a value array keep order", "arr = [{y = 1, x = 2}, {second = true, first = false}]\n"}, + {"nested array of tables", "[[items]]\nn = 1\n\n[items.sub]\nk = \"v\"\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + doc, err := Parse([]byte(tt.src)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + out, err := Marshal(doc) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + reparsed, err := Parse(out) + if err != nil { + t.Fatalf("re-parse of %q: %v", out, err) + } + if !reflect.DeepEqual(doc.Map(), reparsed.Map()) { + t.Errorf("round trip changed the tree:\nin: %#v\nout: %#v", doc.Map(), reparsed.Map()) + } + if got, want := reparsed.Root().Keys(), doc.Root().Keys(); !slices.Equal(got, want) { + t.Errorf("root keys = %v, want %v", got, want) + } + }) + } +} + +// TestMarshalDocumentArrayComments pins where the comments of an array of +// tables land: above and beside the [[header]] itself. +func TestMarshalDocumentArrayComments(t *testing.T) { + doc, err := Parse([]byte("# element one\n[[items]] # trailing\nname = \"a\"\n")) + if err != nil { + t.Fatalf("Parse: %v", err) + } + out, err := Marshal(doc) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + want := "# element one\n[[items]] # trailing\nname = \"a\"\n" + if string(out) != want { + t.Errorf("output = %q, want %q", out, want) + } +} + +// TestTableSetReplacesTableNode pins that Set over a key holding a table +// rebuilds the node, so the new map's keys are the ones written. +func TestTableSetReplacesTableNode(t *testing.T) { + doc, err := Parse([]byte("[cache]\nz = 1\n")) + if err != nil { + t.Fatalf("Parse: %v", err) + } + doc.Set("cache", map[string]any{"a": int64(2)}) + out, err := Marshal(doc) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + want := "[cache]\na = 2\n" + if string(out) != want { + t.Errorf("output = %q, want %q", out, want) + } +} + +// TestTableSetNestedArraysOfTables pins that a value set through the edit API +// carries its arrays of tables into the header form. +func TestTableSetNestedArraysOfTables(t *testing.T) { + doc, err := Parse([]byte("x = 1\n")) + if err != nil { + t.Fatalf("Parse: %v", err) + } + doc.Set("t", map[string]any{"items": []map[string]any{{"n": int64(1)}, {"n": int64(2)}}}) + out, err := Marshal(doc) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if !strings.Contains(string(out), "[[t.items]]") { + t.Errorf("output = %q, want the array of tables under a header", out) + } +} + +// TestTableSetCyclicMapErrors pins that a cyclic map set through the edit API +// reaches the depth limit instead of the stack. +func TestTableSetCyclicMapErrors(t *testing.T) { + doc, err := Parse([]byte("x = 1\n")) + if err != nil { + t.Fatalf("Parse: %v", err) + } + m := map[string]any{} + m["self"] = m + doc.Set("cyclic", m) + if _, err := Marshal(doc); err == nil || !strings.Contains(err.Error(), "nests deeper") { + t.Errorf("err = %v, want the depth-limit complaint", err) + } +} + +// TestDocumentNilSafety pins that the nil document answers its readers +// instead of panicking, the contract Root already carries. +func TestDocumentNilSafety(t *testing.T) { + var doc *Document + if doc.Map() != nil { + t.Errorf("Map = %v", doc.Map()) + } + if doc.Footer() != nil { + t.Errorf("Footer = %v", doc.Footer()) + } + doc.SetFooter([]string{"x"}) + if e, ok := doc.Get("k"); e != nil || ok { + t.Errorf("Get = %v, %v", e, ok) + } + if _, ok := doc.GetString("k"); ok { + t.Error("GetString on a nil document reports a value") + } + if _, ok := doc.GetTable("k"); ok { + t.Error("GetTable on a nil document reports a value") + } + doc.Set("k", 1) + doc.Delete("k") + if keys := doc.Root().Keys(); keys != nil { + t.Errorf("Keys = %v", keys) + } + if doc.Root().Entries() != nil { + t.Errorf("Entries = %v", doc.Root().Entries()) + } +} diff --git a/docwrite.go b/docwrite.go index 584d4a1..84f8025 100644 --- a/docwrite.go +++ b/docwrite.go @@ -43,8 +43,8 @@ func (e *encoder) writeDocument(doc *Document) error { } // 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. +// statement. The parser collects them wherever they sit after it, so the +// writer needs no blank line of its own to have them read back. func (e *encoder) writeDocumentFooter(footer []string) { for _, line := range footer { e.buf.WriteString("# ") @@ -53,8 +53,9 @@ func (e *encoder) writeDocumentFooter(footer []string) { } } -// writeTableEntries writes one table's entries in written order at the given -// header path, nil for the document root, whose keys need no header. +// writeTableEntries writes one table at the given header path, nil for the +// document root, whose keys need no header: the blank line, the comments, +// the header line with its trailing comment, then the body. func (e *encoder) writeTableEntries(t *Table, path []string) error { if t == nil { return nil @@ -66,77 +67,222 @@ func (e *encoder) writeTableEntries(t *Table, path []string) error { if err := e.writeKeyPath(path); err != nil { return err } - e.buf.WriteString("]\n") + e.buf.WriteString("]") if tr := t.Trailing(); tr != "" { e.buf.WriteString(" # ") e.buf.WriteString(tr) - e.buf.WriteByte('\n') + } + e.buf.WriteByte('\n') + } + return e.writeTableBody(t, path) +} + +// writeTableBody writes one table's entries: the value lines first, in +// written order, then the header sections. In a valid document every line at +// one level precedes the headers below it, so the split reorders nothing; +// what it prevents is a table a dotted key introduced, which the parse nests +// as a sub-table at the position of a line, from swallowing the lines that +// follow it into its header. +func (e *encoder) writeTableBody(t *Table, path []string) error { + for _, entry := range t.Entries() { + if err := e.checkCtx(); err != nil { + return err + } + if !e.isLineEntry(entry) { + continue + } + if err := e.writeLineEntry(entry, path); err != nil { + return err } } 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 { + if child := entry.Table(); child != nil && child.dotted && !entry.Inline() { + // A dotted table writes as lines above; its own header-form + // sub-tables are sections the document placed after those lines, + // so the section pass reaches through the dotted entry. + if err := e.writeDottedSections(child, append(append([]string{}, path...), entry.Key())); err != nil { return err } continue } - if err := e.writeDocumentEntry(entry, path); err != nil { + if e.isLineEntry(entry) { + continue + } + if err := e.writeSectionEntry(entry, path); err != nil { return err } } return nil } +// writeDottedSections writes the header-form sub-tables of a dotted table: +// the sections the document placed after the dotted lines, reached through +// the dotted entry itself. +func (e *encoder) writeDottedSections(t *Table, path []string) error { + for _, entry := range t.Entries() { + if err := e.checkCtx(); err != nil { + return err + } + if child := entry.Table(); child != nil && child.dotted && !entry.Inline() { + if err := e.writeDottedSections(child, append(append([]string{}, path...), entry.Key())); err != nil { + return err + } + continue + } + if e.isLineEntry(entry) { + continue + } + if err := e.writeSectionEntry(entry, path); err != nil { + return err + } + } + return nil +} + +// writeSectionEntry writes one entry the line pass left behind: a table or +// an array of tables under its header, at the path this level carries. +func (e *encoder) writeSectionEntry(entry *Entry, path []string) error { + if _, isTables := entry.Value().([]map[string]any); isTables { + // An array of tables keeps its header form, one element per header + // with the element's own comments above it; the body that follows is + // the element's, with no header of its own to repeat. + elemPath := append(append([]string{}, path...), entry.Key()) + for i, el := range entry.Elements() { + e.writeBlankLine() + if i == 0 { + e.writeComments(entry.Comments()) + } + e.writeComments(el.Comments()) + e.buf.WriteString("[[") + if err := e.writeKeyPath(elemPath); err != nil { + return err + } + e.buf.WriteString("]]") + if tr := el.Trailing(); tr != "" { + e.buf.WriteString(" # ") + e.buf.WriteString(tr) + } + e.buf.WriteByte('\n') + if err := e.writeTableBody(el, elemPath); err != nil { + return err + } + } + return nil + } + headerPath := append(append([]string{}, path...), entry.Key()) + return e.writeTableEntries(entry.Table(), headerPath) +} + +// isLineEntry reports whether an entry writes as one or more "key = value" +// lines at its own level: a value, an inline table, or a table a dotted key +// introduced, which goes back as dotted keys. An emptied array of tables +// counts as one only so the line pass can drop it, the omission the value +// encoder applies to an empty array of tables too. +func (e *encoder) isLineEntry(entry *Entry) bool { + if child := entry.Table(); child != nil { + return entry.Inline() || child.dotted + } + if _, isTables := entry.Value().([]map[string]any); isTables { + return len(entry.Elements()) == 0 + } + return true +} + +// writeLineEntry writes one entry as lines at this level, and drops an +// emptied array of tables, which has no TOML form. +func (e *encoder) writeLineEntry(entry *Entry, path []string) error { + if child := entry.Table(); child != nil && !entry.Inline() { + return e.writeDottedTable(child, append(append([]string{}, path...), entry.Key())) + } + if _, isTables := entry.Value().([]map[string]any); isTables { + return nil + } + return e.writeDocumentEntry(entry) +} + +// writeDottedTable writes a table a dotted key introduced as one dotted line +// per leaf, in written order: `a.b = 1`. A sub-table the document added +// under a header stays a section and is left to the section pass. +func (e *encoder) writeDottedTable(t *Table, path []string) error { + for _, entry := range t.Entries() { + if err := e.checkCtx(); err != nil { + return err + } + if child := entry.Table(); child != nil && !entry.Inline() && !child.dotted { + continue + } + leafPath := append(append([]string{}, path...), entry.Key()) + if child := entry.Table(); child != nil && !entry.Inline() { + if err := e.writeDottedTable(child, leafPath); err != nil { + return err + } + continue + } + e.writeComments(entry.Comments()) + if err := e.writeKeyPath(leafPath); err != nil { + return err + } + e.buf.WriteString(" = ") + if err := e.writeEntryValueNodes(entry); err != nil { + return err + } + e.buf.WriteByte('\n') + } + 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 { +func (e *encoder) writeDocumentEntry(entry *Entry) error { e.writeComments(entry.Comments()) if err := e.writeKey(entry.Key()); err != nil { return err } e.buf.WriteString(" = ") + if err := e.writeEntryValueNodes(entry); err != nil { + return err + } + e.buf.WriteByte('\n') + return nil +} + +// writeEntryValueNodes writes the value of a document entry. An inline table +// node keeps the written key order even inside a value array, where the +// ordinary value writer would sort the keys. +func (e *encoder) writeEntryValueNodes(entry *Entry) error { 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) + } else if arr, ok := entry.Value().([]any); ok { + elems := entry.Elements() + e.buf.WriteByte('[') + for i, item := range arr { + if i > 0 { + e.buf.WriteString(", ") + } + if i < len(elems) && elems[i] != nil { + if err := e.writeInlineTableNode(elems[i]); err != nil { + return err + } + continue + } + if err := e.writeValue(item); err != nil { + return err + } } - e.buf.WriteByte('\n') - return nil - } - if err := e.writeValue(entry.Value()); err != nil { + e.buf.WriteByte(']') + } else 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 }