Skip to content

Edit a value

Two routes lead to the same engine. Document.Set is a complete transaction that applies one change; File.Edit is a callback that applies several as one. Both take the same Go values and give the same result for a single change.

Change an existing scalar

err := doc.Set("server.port", 9090)

The scalar is edited where it sits, so its inline comment, its column and its quoting style survive:

port: 8080     # the port we bind to
# becomes
port: 9090     # the port we bind to

A value that was quoted stays quoted; one that was bare stays bare, unless the new value needs quoting to keep its meaning:

doc.Set("name", "after")   // quoted: "before"  →  quoted: "after"
doc.Set("kind", "after")   // kind: before      →  kind: after
doc.Set("kind", "true")    // kind: before      →  kind: "true"   (a string, not a boolean)
doc.Set("note", "a # b")   // note: before      →  note: "a # b"  (not a comment)

That last pair is the type contract: the value you write is the value that reads back, under the document's own YAML version. A Go string never becomes a boolean, a number or a comment; a float64(1) is written as 1.0, not 1; float64(0.1) is written as 0.1. Strings that contain characters a reader cannot see, such as a zero-width space or a bidi override, are written double-quoted with escapes.

A string with line breaks is written as a literal block, the way a person writes one, with the chomping indicator the text's trailing breaks call for:

doc.Set("script", "echo start\necho end\n")
script: |
  echo start
  echo end

"a\nb" gives |-, "a\n\n" gives |+. The block goes below the key at the document's own indentation step. It stays double-quoted where a block cannot go, and a single-quoted scalar keeps its quotes; the complete rules for every kind of value and every kind of existing scalar are in How a written value is spelt.

Set accepts strings, booleans, every integer and float width, *big.Int, time.Time and []byte (each written with an explicit timestamp or binary tag, so it reads back as what it was), nil, and maps, slices and arrays of those for whole subtrees. A Go map has no order, so its keys are written sorted; use MappingInput to write entries in an order of your choosing.

Create a key

Setting a path that does not exist creates it, appended to the end of the deepest existing ancestor's entries. Appending rather than inserting is deliberate: existing keys keep their comments and their order.

err := doc.Set("server.timeout", "30s")
server:
  host: localhost   # where we bind
  port: 8080
  timeout: 30s      # ← added here

Missing intermediate mappings are created on the way, in block style at the document's own indentation step:

err := doc.Set("server.tls.cert", "/etc/cert.pem")
server:
  host: localhost
  tls:
    cert: /etc/cert.pem

Replace a subtree

Passing a map or a MappingInput replaces the addressed mapping: keys absent from the input are removed, keys present are updated, and the input's order wins.

err := doc.Set("avatars.matt", yamldoc.MappingInput{
    {Key: likeness, Value: "a bearded developer"},
    {Key: style, Value: "flat"},
})

An entry whose key matches one already there keeps its comments and, for a scalar that has not changed, its exact bytes:

avatars:
  matt:
    # who he is
    likeness: old # inline
    style: flat
    extra: 1
# becomes
avatars:
  matt:
    # who he is
    likeness: a bearded developer # inline
    style: flat

Keys are matched by YAML value, so 8080 and 0x1f90 are the same key, and a replacement that could match one input key to two existing entries is refused as ambiguous rather than guessed at.

A sequence is replaced whole

A sequence has no keys to match on, so replacing one replaces every item: the old items' comments go with them, whatever the new values happen to be. To change one item, address it with an index instead.

Change one item in a list

An index in square brackets reaches into a sequence from a dotted path:

_ = doc.Set("servers[0].port", 9090)   // replace a value inside the first item
_ = doc.Set("servers[2]", newServer)   // append: 2 is the sequence's length
_ = doc.Remove("servers[1]")           // the survivors keep their comments

Inside Edit, the same reach is a Step:

err := f.Edit(func(tx *yamldoc.Transaction) error {
    s, _ := tx.Snapshot()
    d, _ := s.Document(0)

    servers, err := d.Get("servers")
    if err != nil {
        return err
    }

    port, err := servers.Get(yamldoc.IndexStep(0), yamldoc.StringStep("port"))
    if err != nil {
        return err
    }

    return tx.Replace(port, 9090)
})
servers:
  - name: a # first
    port: 9090
  - name: b

tx.Insert(servers, yamldoc.IndexStep(i), value) adds an item before index i (or at the end when i is the length), and tx.Remove(servers, yamldoc.IndexStep(i)) removes one. Surviving items keep their comments.

An index that names a gap (servers[5] on a two-item sequence) is ErrNotFound rather than a guess at padding; the rules are in Paths and steps.

Change a value's kind

A scalar can become a mapping or a sequence and the other way round. The enclosing entry's comments stay with it; the old value's own comments go.

doc.Set("server.host", yamldoc.MappingInput{{Key: name, Value: "primary"}})
server:
  host: localhost
# becomes
server:
  host:
    name: primary

Where an inline comment follows the scalar, the new collection is written in flow style on the same line so the comment keeps its place.

What cannot be created

A path that runs through a scalar is refused rather than silently rewriting it:

// given "a: 1"
err := doc.Set("a.b.c", "value")   // ErrKind: "a" is not a mapping

Creating it would mean destroying a, which you did not ask for.

A key cannot be created in a document whose root is a sequence or a scalar. In an empty stream, tx.CreateDocument(value) creates the first document; a stream that already has one is edited, not extended.

Writes through aliases and anchors

An edit under an anchor works and keeps the anchor, so every alias sees the new value. An edit over an alias is refused: changing the shared value and detaching this one reference are different intentions, and the library will not guess. A removal that would leave an alias dangling is refused too.

doc.Set("defaults.retries", 5)     // fine: edits under &defaults
doc.Set("prod.settings", value)    // ErrAlias: prod.settings is *defaults
doc.Remove("defaults")             // ErrReference: *defaults still needs it

Errors worth branching on

switch {
case errors.Is(err, yamldoc.ErrNotFound):   // the path does not resolve
case errors.Is(err, yamldoc.ErrKind):       // a step runs through the wrong kind of node
case errors.Is(err, yamldoc.ErrPath):       // malformed dotted path
case errors.Is(err, yamldoc.ErrAlias):      // the target is an alias
case errors.Is(err, yamldoc.ErrReference):  // the edit would strand an alias
case errors.Is(err, yamldoc.ErrSemantic):   // the document has invalid content the edit depends on
}

Each sentinel is in Errors.