Zero-dep TOML editing library for Go with comment preservation
Every Go TOML library either discards comments during parsing or provides read-only access to them. If you parse a config file, change one value, and write it back, the comments are gone. Python has tomlkit; Go had nothing. go-toml-edit fills this gap with a lossless AST that preserves every comment, blank line, and formatting detail through arbitrary edits.
What it preserves is content — comments, values, structure. Whitespace is
formatting, not content: this library also reformats TOML, and Format() is
strict about how output looks.
package main
import (
"fmt"
"log"
tomledit "github.com/stricttools/go-toml-edit"
)
func main() {
doc, err := tomledit.Parse([]byte(`# Server config
[server]
host = "localhost" # primary host
port = 8080
`))
if err != nil {
log.Fatal(err)
}
if err := doc.Set("server.port", 9090); err != nil {
log.Fatal(err)
}
fmt.Print(string(doc.Bytes()))
}Output -- comments survive the edit:
# Server config
[server]
host = "localhost" # primary host
port = 9090go get github.com/stricttools/go-toml-edit
No runtime dependencies. The package imports the standard library and
nothing else; the module's BurntSushi/toml and toml-test requirements are
test-only (the benchmark comparison and the compliance corpus). A test asserts
this over the non-test sources, and another asserts that the tomledit package
touches no operating system: file reads and writes are the tomlfile
subpackage's.
A parsed document is readable through two surfaces, and which one to use follows from the question being asked.
- The syntactic surface is the AST:
Walk,Resolveand the concrete node types. It answers what the file contains, in the form it was written -- spellings, quoting styles, integer bases, comments, blank lines, and the source span of every construct. - The logical surface is the read-layer:
doc.Root()returns aRecordwhoseEntryvalues are the document's keys in first-appearance order. It answers what the document means, with the spellings folded away. A dotted key, a[header]table and an inline table are indistinguishable through it.
Reads are spelling-blind; writes are structurally conservative. A value write
touches value fragments and nothing else, and a structural construct changes
only through a structural operation or an explicit Delete.
| Feature | go-toml-edit | BurntSushi/toml | pelletier/go-toml/v2 |
|---|---|---|---|
| Comment preservation | Yes | No | Read-only (unstable) |
| Round-trip editing | Yes | No | No |
| Set/Delete/RenameKey API | Yes | No | No |
| Unmarshal to struct | Yes (strict-only) | Yes | Yes |
| Marshal from Go values | No | Yes | Yes |
| Position spans on AST nodes | Yes | No | Unstable parser only |
| TOML 1.0 compliance | Full | Full | Full |
| Formatter | Yes | No | No |
| Document diffing | Yes | No | No |
| Deep merge | Yes | No | No |
doc, err := tomledit.Parse([]byte(`[server]
host = "localhost"
port = 8080
`))
host, err := doc.GetString("server.host") // "localhost", nil
port, err := doc.GetInt("server.port") // 8080, nilEvery getter returns (T, error). A missing path, a bad path, a wrong type and
a value the target cannot hold exactly are distinct diagnostics -- see
Diagnostics.
for entry := range doc.Root().Entries() {
switch entry.Kind() {
case tomledit.EntryValue:
node, _ := entry.Node()
fmt.Printf("%s = %v\n", entry.Key(), node.(tomledit.Scalar).Value())
case tomledit.EntryRecord:
rec, _ := entry.Record()
fmt.Printf("%s is a table of %d keys\n", entry.Key(), rec.Len())
case tomledit.EntryRecords:
recs, _ := entry.Records()
fmt.Printf("%s is an array of %d tables\n", entry.Key(), len(recs))
}
}doc.Set("server.port", 9090) // update existing key
doc.SetCreate("database.host", "db.local") // create key + intermediate table
doc.Delete("server.debug") // remove a key (idempotent)
doc.RenameKey("server.host", "address") // rename the binding, every spellingSet is a no-op if and only if the bytes it would write are the bytes already
there -- so a tool that writes back what it read touches nothing. When they
differ, the value is written in the library's canonical spelling, and a
container value (a map, a slice, a []Pair) is replaced wholesale.
doc.NewTable("logging")
doc.NewArrayTable("products")
doc.AppendToArray("server.ports", 8082)
doc.RemoveFromArray("server.ports", 0)
doc.PermuteChildren("server", []int{2, 0, 1}) // order[i] = index moving to i
doc.EnsureDefaults([]tomledit.Default{
{Path: "server.port", Value: 8080},
{Path: "logging.level", Value: "info"},
})doc.SetComment("server.port", "default: 8080")
doc.SetLeadingComments("server", []string{
"Server configuration",
"See docs for all options",
})
inline, err := doc.GetComment("server.port") // "default: 8080"
leading, err := doc.GetLeadingComments("server") // the two lines aboveThe getters resolve a path to the same node the setters write to, so what one
writes the other reads back, and they refuse the same paths with the same error
kinds. Both answer normalized text -- the content without the # and the
whitespace around it -- which is exactly what the setters take. A node with no
inline comment answers the empty string, one with no leading comments answers
nil, and neither is an error.
host, err := doc.Key("database").Key("host").String()
port, err := doc.Key("database").Key("port").Int()type Config struct {
Title string `toml:"title"`
Server struct {
Host string `toml:"host"`
Port int `toml:"port"`
} `toml:"server"`
}
cfg, err := tomledit.Unmarshal[Config](data)Decoding is strict and strictness is the only mode: an unknown key, an unknown
table, a value of a refused kind, a value the target cannot hold exactly, and a
missing toml:"...,required" key are all errors. Key matching is exact -- a
document key differing only in case matches nothing. Every independent violation
is collected and reported together, in document order -- with one placement rule
source order cannot give: a record's missing required keys are reported after
that record's own entries, and among themselves in lexicographic key order.
The entry points return the value they build -- a *T, pointing at memory they
allocated -- so a failed decode leaves nothing to inspect:
Unmarshal[T](data), Decode[T](doc), DecodeNode[T](node), and
DecodeOver[T](doc, seed) -- the last overlaying a document on a seed the
factory builds, and reporting which paths the document supplied.
For a schema known only at runtime, describe it as data instead of as a struct:
spec := &tomledit.Spec{Fields: map[string]tomledit.Field{
"host": {Kind: tomledit.FieldKindString, Required: true},
"port": {Kind: tomledit.FieldKindInteger},
}}
err := doc.Validate(spec) // diagnostics only
values, err := doc.DecodeSpec(spec) // validated, then native Go dataDecodeSpec is atomic: it returns a map only when the document has no
violations at all.
A file whose rules go beyond its shape -- a key whose meaning depends on
another, entries that must not repeat, refusals that say what to write instead
-- is read key by key through a Reader, which reports to a Collector:
var c tomledit.Collector
if r := c.Read("tool.toml", data); r != nil {
name, _ := r.Str("name", true, `name = "<name>"`)
fmt.Println(name)
for _, cmd := range r.Tables("command") {
cmd.Str("help", true, `help = "<one sentence>"`)
if cmd.Flag("verbose") { /* ... */ }
cmd.Done()
}
r.Done()
}
if err := c.Err(); err != nil {
return err
}Every key read through a Reader becomes known, and Done reports every key
nobody read as unknown (KindUnknownKey, or KindUnknownTable for a table),
the same kinds a strict decode reports. A required key that is absent is
reported at its table's line, a value of the wrong type at its own, and each
diagnostic carries a Fix saying what to write:
tool.toml:4: command[0].help: is missing; write help = "<one sentence>".
Collector.Read reports a syntax error as a diagnostic with its line, and
Collector.Add takes a decode's diagnostics into the same list. Err sorts
everything by file, line, path, and message, so problems from several files
read in any order come back as one list in one order.
formatted := doc.Format(tomledit.WithIndentWidth(2))Format re-renders everything in canonical spellings. It preserves the writer's
blank-line grouping at document and table-body level -- a run of blank lines
becomes one. One gap it opens itself, unconditionally: a table header written
flush against what precedes it always gets a blank line above it. What the
library preserves is content -- the comments -- while whitespace is formatting,
and Format is where the library is strict about output looking good, so the
separation is not a caller's option. The insertion never removes a blank line
and never doubles one already there.
Walk is the syntactic traversal: it hands the visitor the concrete nodes, with
the spellings and trivia they were written with.
doc.Walk(func(path string, node tomledit.Node) error {
fmt.Printf("%s = %v\n", path, node.(tomledit.Scalar).Value())
return nil
}, tomledit.WalkLeaves)Value() is on Scalar, the sub-interface the value-carrying node kinds
implement; Node itself carries Type(), Span(), Raw(), Comment() and
LeadingComments().
Every AST node carries its source position from the most recent parse, enabling custom semantic validation with precise line/column diagnostics:
doc.Walk(func(path string, node tomledit.Node) error {
sp := node.Span() // 1-based line/column, plus a byte offset
fmt.Printf("%s at %d:%d-%d:%d\n", path,
sp.Start.Line, sp.Start.Column, sp.End.Line, sp.End.Column)
return nil
}, tomledit.WalkLeaves)Span() is available on all nodes: tables and array-tables (covering only
the [header]), key-value pairs (key through value), keys, all scalars,
arrays, inline tables, and comments. Span.End is exclusive -- the position
immediately after the node's last byte.
Span policy: spans reflect the last Parse. Edit operations do not
recompute them: nodes created programmatically (via Set, Merge, ...) return
the zero span (Span.IsValid() reports false), and edited nodes keep their
parse-time spans. Re-parse the output of Bytes() when fresh positions are
needed after editing.
import "github.com/stricttools/go-toml-edit/tomlfile"
doc, err := tomlfile.ParseFile("config.toml") // remembers the filename
err = tomlfile.WriteFile(doc, "config.toml") // atomic, round-trip checkedThe tomledit package reads and writes no file: it computes only over the
bytes and documents it is handed. File access is the tomlfile subpackage's.
A document loaded with tomlfile.ParseFile names the file in every diagnostic
it later produces; tomledit.ParseWithFileName does the same for bytes the
caller read. tomlfile.WriteFile writes the bytes
Document.RoundTripVerifiedBytes returns -- bytes proved to re-parse to
themselves -- and replaces the destination in one step.
_, err := doc.GetInt("server.port")
if errors.Is(err, tomledit.ErrTypeMismatch) { /* ... */ }
var diag *tomledit.Error
if errors.As(err, &diag) {
fmt.Println(diag.Kind, diag.Path, diag.Pos.Line, diag.Expected, diag.Got)
}Every failure that depends on the document -- parse, decode, edit, access --
is an *Error with a kind, a path and a position, rendered in the compiler
convention (config.toml:12:7: server.port: expected integer, got string). A
decode reports its violations together as an *Errors aggregate, reachable with
errors.As; its Diagnostics method lists them. A diagnostic reported through
a Reader names a line without a column and a fix after the message
(config.toml:4: server.port: is missing; write port = <integer>).
changes := tomledit.Diff(a, b)
for _, c := range changes {
fmt.Printf("%s: %s\n", c.Kind, c.Path)
}
// removed: age
// added: email
// modified: nameDiff reads values, never spellings: two documents writing one value
differently report no difference, so a document always compares equal to itself.
Types are not bridged -- 1 and 1.0 are a modification.
base, _ := tomledit.Parse([]byte(`[server]
host = "localhost"
`))
defaults, _ := tomledit.Parse([]byte(`[server]
host = "0.0.0.0"
port = 8080
`))
base.Merge(defaults)
// host keeps "localhost" (already set); port added from defaults.go-toml-edit retains the full AST -- source positions, trivia, comment nodes, and the lexeme every value was written as -- because that is what comment-preserving round-trip editing needs. A decode-only library discards all of it, so it parses faster and allocates less. Reach for a decode-only library when you only need Go values out of a TOML file; reach for this one when the comments have to survive the write-back.
Run go test -bench . to measure on your own machine. benchmarks.txt holds a
captured run beside BurntSushi/toml.
pkg.go.dev/github.com/stricttools/go-toml-edit
A struct-to-TOML Marshal, the remaining structural-manipulation suite (node
moves, positional insertion, table/inline conversion), external node
construction with spelling control, and a streaming parser are deferred rather
than planned. TOML 1.1 is not supported: this library targets TOML 1.0 only.
See todo/ for details.