Getting started: change a config file without wrecking it¶
By the end of this you'll have a small Go program that opens a hand-written YAML config file, changes a port, adds a key, deletes another, and writes the file back with every comment still where its author put it and every other byte untouched.
Allow about fifteen minutes.
Before you start¶
You'll need Go 1.27 or later; go version will tell you. Everything else you
need arrives with one go get.
Work in a scratch directory. The last step overwrites the file you create, so don't point it at a config you care about until you've seen it work.
mkdir yamldoc-tour && cd yamldoc-tour
go mod init example.com/tour
go get gitlab.com/phpboyscout/go/yamldoc
That pulls in one dependency, the estate's errors module. There is no YAML
parser underneath: the engine is the library's own.
Create a file worth protecting¶
Save this as config.yaml. It has the things a re-marshal would destroy: a
header, an inline comment padded out to a column, a trailing comment on a
block, and blank lines that group it.
# Service configuration.
# Edited by hand; keep the comments.
server:
host: localhost # the interface we bind to
port: 8080
# end of the server block
logging:
level: info
debug: true
Read the file and look around¶
yamldoc works on bytes and never touches the filesystem, so you do the reading
and writing. Start with a program that only looks:
package main
import (
"fmt"
"os"
"gitlab.com/phpboyscout/go/yamldoc"
)
func main() {
src, err := os.ReadFile("config.yaml")
if err != nil {
panic(err)
}
f, err := yamldoc.Parse(src, yamldoc.Options{})
if err != nil {
panic(err)
}
doc, _ := f.Snapshot().Document(0)
root, _ := doc.Root()
entries, _ := root.Entries()
for _, e := range entries {
key, _ := e.Key.String()
fmt.Println("top-level key:", key)
}
port, err := doc.Get("server.port")
fmt.Println("found:", err == nil)
value, _ := port.Int64()
fmt.Println("value:", value)
info, _ := port.Syntax()
fmt.Println("line:", info.Location.Start.Line)
server, _ := doc.Get("server")
serverEntries, _ := server.Entries()
comments, _ := serverEntries[0].Comments()
content, _ := comments[0].Content()
fmt.Printf("host comment: %q\n", content)
}
top-level key: server
top-level key: logging
found: true
value: 8080
line: 6
host comment: " the interface we bind to"
A few things to notice. Parse takes an Options; the zero value selects the
defaults, which are sized for configuration files. Everything you read comes
from a Snapshot, which is immutable: hold one and it keeps describing the
file as it was, however many edits land afterwards. Reads are typed, so port
came back as an int64 and asking for port.String() would have told you it
is not a string. And a comment's content is the text after the #, spacing
included; trim it if you want the words alone.
Paths are dotted mapping keys with square-bracket indexes for lists:
server.port walks from the root to server and then to port, and
servers[0].port reaches into the first item of a list. A key containing a
dot or a bracket is out of a path's reach and takes a Step; if your file
has such keys, read Paths and steps before you go
much further.
Change a value¶
Set on a Document is a complete transaction of its own: it opens one,
applies one change, checks the result and commits. The existing scalar is
edited where it sits, so its inline comment and its quoting style stay with it.
Always check the error. Set refuses several things rather than guessing, and a
silent failure here means a config change that never happened.
Add a key that isn't there yet¶
Setting a path that doesn't exist creates it, and missing intermediate mappings
are created on the way: tls appears so that cert has somewhere to live, in
block style at the document's own indentation. New keys are appended to the end
of their parent's entries, which is what keeps the existing keys' order and
comments untouched. A comment that closes the block stays at the end of the
block, after the new key; you'll see that in the output below.
One thing Set won't do is create a path that runs through a scalar:
Creating it would mean deleting logging.level, which you didn't ask for. The
error matches yamldoc.ErrKind.
Remove a key and watch the comments¶
Removing a key takes the comments that describe it and leaves the ones that describe something else. A comment directly above the key goes with it; one separated from the key by a blank line stays with the section; one after the last entry stays with the mapping. That's the part a naive editor gets wrong; the rules are in Comment ownership.
Removing something that isn't there is an error rather than a no-op:
That's deliberate: silence there hides a typo in a key name. The error matches
yamldoc.ErrNotFound, so a caller that genuinely wants "absent is fine" can
test for it.
Write it back¶
out, err := f.Snapshot().Bytes()
if err != nil {
panic(err)
}
if err := os.WriteFile("config.yaml", out, 0o644); err != nil {
panic(err)
}
Bytes hands you the current revision. Each transaction has already re-parsed
its own output and checked it against an independent statement of what the
edit meant before it committed, so a structurally broken document can't reach
the file: an edit that would produce one fails at Set or Remove and leaves
the file as it was.
Run it, then look at config.yaml:
# Service configuration.
# Edited by hand; keep the comments.
server:
host: localhost # the interface we bind to
port: 9090
tls:
cert: /etc/ssl/service.pem
# end of the server block
logging:
level: info
Both comments survived, the blank line between the sections survived, the
inline comment kept its column, debug is gone, port changed, and tls
landed inside the block, before the comment that closes it.
Feed that output straight back through Parse and Bytes and you get the same
bytes: a no-edit round trip is the identity function, so a tool that saves on
every keystroke doesn't accumulate drift. Re-running this program is not the
way to check that, though. The second run panics on Remove("logging.debug"),
because debug is already gone and removing an absent key is an error rather
than a no-op.
Several changes that belong together¶
The two Set calls and the Remove above were three transactions. If a
failure in the third should undo the first two, put them in one:
err := f.Edit(func(tx *yamldoc.Transaction) error {
s, _ := tx.Snapshot()
d, _ := s.Document(0)
server, err := d.Get("server")
if err != nil {
return err
}
if err := tx.Set(server, yamldoc.StringStep("port"), 9090); err != nil {
return err
}
// Every command produces a new working revision and expires the handles
// into the old one, so select again before the next command.
s, _ = tx.Snapshot()
d, _ = s.Document(0)
logging, err := d.Get("logging")
if err != nil {
return err
}
return tx.Remove(logging, yamldoc.StringStep("debug"))
})
The callback is the transaction. Return an error, or let a command fail, and
nothing is committed. Inside it, tx.Set takes the container node and a
Step for the key rather than a dotted path, which is what lets it address a
key containing a dot or an element of a sequence.
Where to go next¶
- Edit a value: replacing subtrees, typed writes, and what changes shape
- Remove a key safely: the comment rules in detail
- API overview: the types, what each is for, and where the detail lives
- What is preserved, and what is not: the guarantee, precisely