Skip to content

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)
}
go run .
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

if err := doc.Set("server.port", 9090); err != nil {
    panic(err)
}

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

if err := doc.Set("server.tls.cert", "/etc/ssl/service.pem"); err != nil {
    panic(err)
}

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:

err := doc.Set("logging.level.verbose", true)
// node kind does not match requested type

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

if err := doc.Remove("logging.debug"); err != nil {
    panic(err)
}

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:

err := doc.Remove("server.nope")
// selection does not exist

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