diff --git a/internal/sync/adapter/node.go b/internal/sync/adapter/node.go
index 24cd82812..1c05c4fb1 100644
--- a/internal/sync/adapter/node.go
+++ b/internal/sync/adapter/node.go
@@ -31,6 +31,38 @@ type Node struct {
Encode func(any) ([]byte, error)
}
+// PositionalIDReason marks the id of a resource that has no name of its own,
+// such as the second unnamed agent of a file. The id is the position of the
+// resource, so a selection by it is as fragile as an index.
+const PositionalIDReason = "the id is the position of a resource without a name"
+
+// MaxExactInteger is the largest integer that a float64 holds exactly. A
+// number value is a float64, so a larger integer in a source stays read-only
+// text instead of a rounded number.
+const MaxExactInteger = int64(1) << 53
+
+// ExactInteger reports whether a float64 holds an integer exactly.
+func ExactInteger(integer int64) bool {
+ return integer >= -MaxExactInteger && integer <= MaxExactInteger
+}
+
+// PositionalID returns an id node that marks a positional id.
+func PositionalID(id string) *Node {
+ return &Node{Value: id, Capability: CapabilityReadOnly, Reason: PositionalIDReason}
+}
+
+// IsPositionalID reports whether a resource has a positional id.
+func IsPositionalID(resource *Node) bool {
+ if resource == nil || resource.Fields == nil {
+ return false
+ }
+ id := resource.Fields["id"]
+ if id == nil {
+ return false
+ }
+ return id.Reason == PositionalIDReason
+}
+
// String returns a writable string leaf. encode writes a new string in the
// syntax of the source.
func String(value string, start, end int, encode func(string) ([]byte, error)) *Node {
@@ -95,12 +127,38 @@ func Collections(collections map[string][]*Node) *Node {
}
// Resource returns one logical resource with its id field set, so that a
-// keyed selector such as agents[id="support"] can find it.
+// keyed selector such as agents[id="support"] can find it. A resource whose
+// definition is not a literal object has only the id.
func Resource(id string, fields map[string]*Node) *Node {
+ if fields == nil {
+ fields = make(map[string]*Node, 1)
+ }
fields["id"] = &Node{Value: id}
return &Node{Fields: fields}
}
+// Clone returns a deep copy of the node tree, so that a change to one use of
+// a constant does not change another. The encoders are shared.
+func (node *Node) Clone() *Node {
+ if node == nil {
+ return nil
+ }
+ copied := *node
+ if node.Fields != nil {
+ copied.Fields = make(map[string]*Node, len(node.Fields))
+ for key, child := range node.Fields {
+ copied.Fields[key] = child.Clone()
+ }
+ }
+ if node.Items != nil {
+ copied.Items = make([]*Node, len(node.Items))
+ for index, child := range node.Items {
+ copied.Items[index] = child.Clone()
+ }
+ }
+ return &copied
+}
+
// IsCollection reports whether a path can index the node.
func (node *Node) IsCollection() bool {
if node == nil {
@@ -182,10 +240,24 @@ func JSONEncoder(raw []byte) func(any) ([]byte, error) {
return func(next any) ([]byte, error) {
unit, prefix, multiline := jsonIndentation(raw)
if !multiline {
- return json.Marshal(next)
+ return EncodeJSON(next, "", "")
}
- return json.MarshalIndent(next, prefix, unit)
+ return EncodeJSON(next, prefix, unit)
+ }
+}
+
+// EncodeJSON writes a value as JSON without the HTML escapes of the standard
+// encoder, so that <, >, and & in prompt text stay as they are. A non-empty
+// indent writes the value over several lines.
+func EncodeJSON(value any, prefix, indent string) ([]byte, error) {
+ var buffer bytes.Buffer
+ encoder := json.NewEncoder(&buffer)
+ encoder.SetEscapeHTML(false)
+ encoder.SetIndent(prefix, indent)
+ if err := encoder.Encode(value); err != nil {
+ return nil, err
}
+ return bytes.TrimRight(buffer.Bytes(), "\n"), nil
}
// jsonIndentation returns the indentation unit and the indentation of the
diff --git a/internal/sync/adapter/reference.go b/internal/sync/adapter/reference.go
index b20446631..4f94b86b2 100644
--- a/internal/sync/adapter/reference.go
+++ b/internal/sync/adapter/reference.go
@@ -3,6 +3,7 @@ package adapter
import (
"fmt"
"slices"
+ "strings"
syncdomain "github.com/launchdarkly/ldcli/internal/sync"
)
@@ -130,12 +131,22 @@ func fieldAllowed(kind syncdomain.Kind, field syncdomain.Field) bool {
}
}
+// annotateProblem adds the resource and the file to the problem in an error.
+// A wrapper such as `select "x": ...` names the part of the reference that
+// failed, and the output prints the problem alone, so the problem takes that
+// context into its message.
func annotateProblem(err error, id syncdomain.ResourceID, file string) error {
if err == nil {
return nil
}
- if problem, ok := syncdomain.ProblemFrom(err); ok {
- _ = problem.WithResource(id).WithFile(file, 0, 0)
+ problem, ok := syncdomain.ProblemFrom(err)
+ if !ok {
+ return err
}
- return err
+ if err != error(problem) {
+ if prefix, found := strings.CutSuffix(err.Error(), ": "+problem.Error()); found && prefix != "" {
+ problem.Message = prefix + ": " + problem.Message
+ }
+ }
+ return problem.WithResource(id).WithFile(file, 0, 0)
}
diff --git a/internal/sync/adapter/structured/json.go b/internal/sync/adapter/structured/json.go
new file mode 100644
index 000000000..01c2adca1
--- /dev/null
+++ b/internal/sync/adapter/structured/json.go
@@ -0,0 +1,289 @@
+// Package structured inspects JSON, YAML, and TOML documents with exact byte
+// spans for each scalar value.
+package structured
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "strconv"
+ "unicode/utf8"
+
+ syncdomain "github.com/launchdarkly/ldcli/internal/sync"
+ "github.com/launchdarkly/ldcli/internal/sync/adapter"
+)
+
+type jsonAdapter struct{}
+
+func (jsonAdapter) Format() string { return "json" }
+func (jsonAdapter) Behavior() string { return "json/1" }
+
+func (jsonAdapter) Inspect(bundle adapter.SourceBundle) (*adapter.Node, error) {
+ return parseJSONDocument(bundle.Primary)
+}
+
+func (jsonAdapter) Defaults(syncdomain.Kind) (string, map[string]string) { return "$", nil }
+
+func init() { adapter.MustRegister(jsonAdapter{}) }
+
+type jsonParser struct {
+ data []byte
+ offset int
+ path string
+}
+
+func parseJSONDocument(document adapter.Document) (*adapter.Node, error) {
+ if !utf8.Valid(document.Data) {
+ return nil, syncdomain.NewProblem(
+ syncdomain.ErrorInvalidSource,
+ "JSON source is not valid UTF-8",
+ "Save the file as UTF-8.",
+ ).WithFile(document.Path, 0, 0)
+ }
+ parser := jsonParser{data: document.Data, path: document.Path}
+ if bytes.HasPrefix(parser.data, []byte("\ufeff")) {
+ parser.offset = len([]byte("\ufeff"))
+ }
+ parser.skipSpace()
+ root, err := parser.value()
+ if err != nil {
+ return nil, err
+ }
+ parser.skipSpace()
+ if parser.offset != len(parser.data) {
+ return nil, parser.problem("JSON source has trailing input")
+ }
+ return root, nil
+}
+
+func (parser *jsonParser) value() (*adapter.Node, error) {
+ parser.skipSpace()
+ if parser.offset >= len(parser.data) {
+ return nil, parser.problem("JSON value is missing")
+ }
+ switch parser.data[parser.offset] {
+ case '{':
+ return parser.object()
+ case '[':
+ return parser.array()
+ case '"':
+ return parser.string()
+ case 't':
+ return parser.boolean("true")
+ case 'f':
+ return parser.boolean("false")
+ case 'n':
+ return parser.keyword("null", nil)
+ default:
+ return parser.number()
+ }
+}
+
+func (parser *jsonParser) object() (*adapter.Node, error) {
+ start := parser.offset
+ parser.offset++
+ parser.skipSpace()
+ fields := map[string]*adapter.Node{}
+ value := map[string]any{}
+ if parser.take('}') {
+ return parser.objectNode(value, fields, start, parser.offset), nil
+ }
+ for {
+ keyNode, err := parser.string()
+ if err != nil {
+ return nil, err
+ }
+ key := keyNode.Value.(string)
+ if _, duplicate := fields[key]; duplicate {
+ return nil, syncdomain.NewProblem(
+ syncdomain.ErrorDuplicateKey,
+ fmt.Sprintf("JSON object contains duplicate key %q", key),
+ "Remove the duplicate key.",
+ ).WithFile(parser.path, 0, 0)
+ }
+ parser.skipSpace()
+ if !parser.take(':') {
+ return nil, parser.problem("JSON object key needs a colon")
+ }
+ item, err := parser.value()
+ if err != nil {
+ return nil, err
+ }
+ fields[key], value[key] = item, item.Value
+ parser.skipSpace()
+ if parser.take('}') {
+ return parser.objectNode(value, fields, start, parser.offset), nil
+ }
+ if !parser.take(',') {
+ return nil, parser.problem("JSON object needs a comma or closing brace")
+ }
+ parser.skipSpace()
+ }
+}
+
+func (parser *jsonParser) array() (*adapter.Node, error) {
+ start := parser.offset
+ parser.offset++
+ parser.skipSpace()
+ // An empty array is a value of its own, so it never reads as null.
+ items, values := []*adapter.Node{}, []any{}
+ if parser.take(']') {
+ return parser.arrayNode(values, items, start, parser.offset), nil
+ }
+ for {
+ item, err := parser.value()
+ if err != nil {
+ return nil, err
+ }
+ items, values = append(items, item), append(values, item.Value)
+ parser.skipSpace()
+ if parser.take(']') {
+ return parser.arrayNode(values, items, start, parser.offset), nil
+ }
+ if !parser.take(',') {
+ return nil, parser.problem("JSON array needs a comma or closing bracket")
+ }
+ parser.skipSpace()
+ }
+}
+
+func (parser *jsonParser) string() (*adapter.Node, error) {
+ start := parser.offset
+ if !parser.take('"') {
+ return nil, parser.problem("JSON string must start with a quote")
+ }
+ escaped := false
+ for parser.offset < len(parser.data) {
+ next := parser.data[parser.offset]
+ parser.offset++
+ switch {
+ case escaped:
+ escaped = false
+ case next == '\\':
+ escaped = true
+ case next == '"':
+ raw := parser.data[start:parser.offset]
+ var value string
+ if err := json.Unmarshal(raw, &value); err != nil {
+ return nil, parser.problem("JSON string is invalid")
+ }
+ return adapter.String(value, start, parser.offset, func(text string) ([]byte, error) {
+ return adapter.EncodeJSON(text, "", "")
+ }), nil
+ case next < 0x20:
+ return nil, parser.problem("JSON string contains a control character")
+ }
+ }
+ return nil, parser.problem("JSON string is not closed")
+}
+
+func (parser *jsonParser) number() (*adapter.Node, error) {
+ start := parser.offset
+ for parser.offset < len(parser.data) && bytes.ContainsRune([]byte("-+0123456789.eE"), rune(parser.data[parser.offset])) {
+ parser.offset++
+ }
+ if start == parser.offset {
+ return nil, parser.problem("JSON value is invalid")
+ }
+ raw := parser.data[start:parser.offset]
+ var value float64
+ if err := json.Unmarshal(raw, &value); err != nil {
+ return nil, parser.problem("JSON number is invalid")
+ }
+ // An integer beyond 53 bits has no exact float64, so it stays read-only
+ // text instead of a rounded number.
+ if !bytes.ContainsAny(raw, ".eE") {
+ if integer, err := strconv.ParseInt(string(raw), 10, 64); err != nil || !adapter.ExactInteger(integer) {
+ result := adapter.ReadOnly(start, parser.offset, "JSON integer is too large to represent exactly")
+ result.Value = string(raw)
+ return result, nil
+ }
+ }
+ return adapter.Number(value, start, parser.offset, func(number float64) ([]byte, error) {
+ return []byte(strconv.FormatFloat(number, 'g', -1, 64)), nil
+ }), nil
+}
+
+// boolean reads true or false. The leaf accepts only a boolean on a write,
+// as a string or a number leaf accepts only its own type.
+func (parser *jsonParser) boolean(keyword string) (*adapter.Node, error) {
+ start := parser.offset
+ if !bytes.HasPrefix(parser.data[start:], []byte(keyword)) {
+ return nil, parser.problem("JSON value is invalid")
+ }
+ parser.offset += len(keyword)
+ return adapter.Bool(keyword == "true", start, parser.offset, nil), nil
+}
+
+// keyword reads null. A null slot has no type of its own, so a write of any
+// JSON value fills it.
+func (parser *jsonParser) keyword(keyword string, value any) (*adapter.Node, error) {
+ start := parser.offset
+ if !bytes.HasPrefix(parser.data[start:], []byte(keyword)) {
+ return nil, parser.problem("JSON value is invalid")
+ }
+ parser.offset += len(keyword)
+ return &adapter.Node{
+ Value: value, Start: start, End: parser.offset, Capability: adapter.CapabilityDirect,
+ Encode: func(next any) ([]byte, error) { return adapter.EncodeJSON(next, "", "") },
+ }, nil
+}
+
+func (parser *jsonParser) skipSpace() {
+ for parser.offset < len(parser.data) && bytes.ContainsRune([]byte(" \t\r\n"), rune(parser.data[parser.offset])) {
+ parser.offset++
+ }
+}
+
+func (parser *jsonParser) take(expected byte) bool {
+ if parser.offset >= len(parser.data) || parser.data[parser.offset] != expected {
+ return false
+ }
+ parser.offset++
+ return true
+}
+
+// problem returns an invalid-source problem that names the file, as the
+// other adapters do, so a caller that does not annotate it still reports
+// where the JSON failed.
+func (parser *jsonParser) problem(message string) error {
+ return syncdomain.NewProblem(
+ syncdomain.ErrorInvalidSource,
+ fmt.Sprintf("%s at byte %d", message, parser.offset),
+ "Fix the JSON syntax.",
+ ).WithFile(parser.path, 0, 0)
+}
+
+func (parser *jsonParser) objectNode(value map[string]any, fields map[string]*adapter.Node, start, end int) *adapter.Node {
+ result := &adapter.Node{
+ Value: value, Fields: fields, Start: start, End: end, Capability: adapter.CapabilityDirect,
+ Encode: adapter.JSONEncoder(parser.data[start:end]),
+ }
+ for _, field := range fields {
+ jsonContainerHolds(result, field)
+ }
+ return result
+}
+
+func (parser *jsonParser) arrayNode(value []any, items []*adapter.Node, start, end int) *adapter.Node {
+ result := &adapter.Node{
+ Value: value, Items: items, Collection: true, Start: start, End: end, Capability: adapter.CapabilityDirect,
+ Encode: adapter.JSONEncoder(parser.data[start:end]),
+ }
+ for _, item := range items {
+ jsonContainerHolds(result, item)
+ }
+ return result
+}
+
+// jsonContainerHolds makes a container read-only when a value in it is, such
+// as an integer that a float64 cannot hold exactly. A write of the whole
+// container would replace that value with a rounded one.
+func jsonContainerHolds(container, child *adapter.Node) {
+ if child == nil || child.Capability == adapter.CapabilityDirect || container.Capability != adapter.CapabilityDirect {
+ return
+ }
+ container.Capability = adapter.CapabilityReadOnly
+ container.Reason = "JSON container holds a read-only value: " + child.Reason
+ container.Encode = nil
+}
diff --git a/internal/sync/adapter/structured/problem.go b/internal/sync/adapter/structured/problem.go
new file mode 100644
index 000000000..17bc0f52b
--- /dev/null
+++ b/internal/sync/adapter/structured/problem.go
@@ -0,0 +1,12 @@
+package structured
+
+import syncdomain "github.com/launchdarkly/ldcli/internal/sync"
+
+// nameDocument puts the path of the document on a problem that has none, so
+// that a caller that does not annotate the problem still reports the file.
+func nameDocument(err error, path string) error {
+ if problem, ok := syncdomain.ProblemFrom(err); ok && problem.File == "" {
+ _ = problem.WithFile(path, 0, 0)
+ }
+ return err
+}
diff --git a/internal/sync/adapter/structured/structured_test.go b/internal/sync/adapter/structured/structured_test.go
new file mode 100644
index 000000000..7d7e87a4b
--- /dev/null
+++ b/internal/sync/adapter/structured/structured_test.go
@@ -0,0 +1,304 @@
+package structured
+
+import (
+ "testing"
+ "unicode/utf8"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ syncdomain "github.com/launchdarkly/ldcli/internal/sync"
+ "github.com/launchdarkly/ldcli/internal/sync/adapter"
+ "github.com/launchdarkly/ldcli/internal/sync/adapter/yamltree"
+)
+
+func TestJSONAdapterRejectsMalformedSources(t *testing.T) {
+ inputs := []string{
+ "",
+ `{"key" "value"}`,
+ `{"key": true "next": false}`,
+ `{"key": 1, "key": 2}`,
+ `[1 2]`,
+ `{"key":}`,
+ `{"key":"unterminated}`,
+ "{\"key\":\"control\x01\"}",
+ `truth`,
+ `+`,
+ `1e`,
+ `true trailing`,
+ }
+ for _, input := range inputs {
+ t.Run(input, func(t *testing.T) {
+ _, err := parseJSONDocument(adapter.Document{Path: "value.json", Data: []byte(input)})
+ require.Error(t, err)
+ })
+ }
+ _, err := parseJSONDocument(adapter.Document{Path: "value.json", Data: []byte{0xff}})
+ require.Error(t, err)
+
+ root, err := parseJSONDocument(adapter.Document{Path: "value.json", Data: []byte("\ufeff {\"empty\":{},\"array\":[],\"false\":false,\"null\":null}")})
+ require.NoError(t, err)
+ assert.Equal(t, false, root.Fields["false"].Value)
+}
+
+func TestYAMLAdapterHandlesSafetyForms(t *testing.T) {
+ for name, input := range map[string][]byte{
+ "invalid UTF-8": {0xff},
+ "invalid syntax": []byte("value: ["),
+ "empty": nil,
+ "non-string key": []byte("1: value"),
+ "duplicate": []byte("key: one\nkey: two\n"),
+ } {
+ t.Run(name, func(t *testing.T) {
+ _, err := parseYAMLDocument(adapter.Document{Path: "value.yaml", Data: input})
+ require.Error(t, err)
+ })
+ }
+
+ root, err := parseYAMLDocument(adapter.Document{Path: "value.yaml", Data: []byte(`
+defaults: &defaults
+ enabled: true
+ count: 1_000
+copy:
+ <<: *defaults
+tagged: !custom value
+block: |
+ content
+sequence: &items [one, two]
+null_value: null
+---
+second: document
+`)})
+ require.NoError(t, err)
+ assert.Equal(t, adapter.CapabilityReadOnly, root.Capability)
+ assert.Equal(t, adapter.CapabilityReadOnly, root.Fields["copy"].Capability)
+ assert.Equal(t, adapter.CapabilityReadOnly, root.Fields["tagged"].Capability)
+}
+
+func TestTOMLAdapterHandlesSafetyForms(t *testing.T) {
+ for name, input := range map[string][]byte{
+ "invalid UTF-8": {0xff},
+ "invalid syntax": []byte("value = ["),
+ "duplicate": []byte("value = 1\nvalue = 2\n"),
+ "value as table": []byte("value = 1\n[value]\nchild = 2\n"),
+ } {
+ t.Run(name, func(t *testing.T) {
+ _, err := parseTOMLDocument(adapter.Document{Path: "value.toml", Data: input})
+ require.Error(t, err)
+ })
+ }
+
+ root, err := parseTOMLDocument(adapter.Document{Path: "value.toml", Data: []byte("\ufeff" + `
+basic = "text"
+literal = 'text'
+multiline_basic = """text"""
+multiline_literal = '''text'''
+enabled = true
+integer = 10
+float = 1.5
+date = 2026-10-30
+array = [1, # comment
+ 2]
+inline = { name = "value" }
+[[tools]]
+name = "one"
+[[tools]]
+name = "two"
+`)})
+ require.NoError(t, err)
+ assert.Equal(t, "2026-10-30", root.Fields["date"].Value)
+ assert.Len(t, root.Fields["tools"].Items, 2)
+
+ assert.Equal(t, `"""next\"\"\"value"""`, mustTOMLString(t, `next"""value`, `"""old"""`))
+ assert.Equal(t, "'''next'''", mustTOMLString(t, "next", "'''old'''"))
+ _, err = encodeTOMLString("bad'''value", "'''old'''")
+ require.Error(t, err)
+ _, err = encodeTOMLString("bad\nvalue", "'old'")
+ require.Error(t, err)
+ assert.Equal(t, `"\b\t\n\f\r\u0001\u0080\"\\"`, mustTOMLString(t, "\b\t\n\f\r\x01\u0080\"\\", `"old"`))
+}
+
+func TestYAMLStringCodecRejectsUnsupportedCodePoint(t *testing.T) {
+ _, err := yamltree.EncodeString("\uffff", "plain")
+ require.ErrorContains(t, err, "unsupported character U+FFFF")
+}
+
+func mustTOMLString(t *testing.T, value, previous string) string {
+ t.Helper()
+ encoded, err := encodeTOMLString(value, previous)
+ require.NoError(t, err)
+ return string(encoded)
+}
+
+func FuzzTOMLStringCodec(f *testing.F) {
+ for _, value := range []string{"plain", "quotes \" and slash \\", "line one\nline two", "Unicode 🌍"} {
+ f.Add(value)
+ }
+ f.Fuzz(func(t *testing.T, value string) {
+ if !utf8.ValidString(value) {
+ t.Skip()
+ }
+ encoded, err := encodeTOMLString(value, `"plain"`)
+ require.NoError(t, err)
+ source := append([]byte("value = "), encoded...)
+ source = append(source, '\n')
+ root, err := adapter.ParseDocument(adapter.Document{Path: "value.toml", Data: source}, "toml")
+ require.NoError(t, err)
+ node, err := root.Resolve("value")
+ require.NoError(t, err)
+ require.Equal(t, value, node.Value)
+ })
+}
+
+func TestYAMLTrailingDocumentMarkerKeepsTheDocumentWritable(t *testing.T) {
+ root, err := parseYAMLDocument(adapter.Document{Path: "value.yaml", Data: []byte("name: one\n---\n")})
+ require.NoError(t, err)
+ assert.Equal(t, adapter.CapabilityDirect, root.Fields["name"].Capability)
+
+ root, err = parseYAMLDocument(adapter.Document{Path: "value.yaml", Data: []byte("name: one\n---\nname: two\n")})
+ require.NoError(t, err)
+ assert.Equal(t, adapter.CapabilityReadOnly, root.Fields["name"].Capability, "a second document with content is read-only")
+}
+
+func TestYAMLLaterDocumentsAfterAnEmptyOneAreMultiDocument(t *testing.T) {
+ root, err := parseYAMLDocument(adapter.Document{Path: "value.yaml", Data: []byte("name: one\n---\n---\nname: two\n")})
+ require.NoError(t, err)
+ assert.Equal(t, adapter.CapabilityReadOnly, root.Fields["name"].Capability)
+}
+
+func TestJSONWritesKeepHTMLCharacters(t *testing.T) {
+ root, err := parseJSONDocument(adapter.Document{Path: "value.json", Data: []byte(`{"prompt": "a", "schema": {"type": "object"}}`)})
+ require.NoError(t, err)
+ encoded, err := root.Fields["prompt"].Encode("Use bold & more")
+ require.NoError(t, err)
+ assert.Equal(t, `"Use bold & more"`, string(encoded))
+ encoded, err = root.Fields["schema"].Encode(map[string]any{"description": "a < b"})
+ require.NoError(t, err)
+ assert.Equal(t, `{"description":"a < b"}`, string(encoded))
+}
+
+func TestEmptyArraysAreNotNull(t *testing.T) {
+ jsonRoot, err := parseJSONDocument(adapter.Document{Path: "value.json", Data: []byte(`{"items": [], "none": null}`)})
+ require.NoError(t, err)
+ assert.Equal(t, []any{}, jsonRoot.Fields["items"].Value)
+ assert.NotNil(t, jsonRoot.Fields["items"].Value, "an empty JSON array is a value, not null")
+ assert.Nil(t, jsonRoot.Fields["none"].Value)
+ assert.Equal(t, map[string]any{"items": []any{}, "none": nil}, jsonRoot.Value)
+
+ tomlRoot, err := parseTOMLDocument(adapter.Document{Path: "value.toml", Data: []byte("items = []\n")})
+ require.NoError(t, err)
+ assert.Equal(t, []any{}, tomlRoot.Fields["items"].Value)
+}
+
+func TestJSONBooleanWritesCheckTheType(t *testing.T) {
+ root, err := parseJSONDocument(adapter.Document{Path: "value.json", Data: []byte(`{"flag": true, "slot": null}`)})
+ require.NoError(t, err)
+ flag := root.Fields["flag"]
+ assert.Equal(t, true, flag.Value)
+ _, err = flag.Encode("yes")
+ require.Error(t, err, "a boolean leaf rejects a string")
+ encoded, err := flag.Encode(false)
+ require.NoError(t, err)
+ assert.Equal(t, "false", string(encoded))
+
+ encoded, err = root.Fields["slot"].Encode("text")
+ require.NoError(t, err, "a null slot takes any JSON value")
+ assert.Equal(t, `"text"`, string(encoded))
+}
+
+func TestJSONIntegersBeyondExactFloatsAreReadOnly(t *testing.T) {
+ root, err := parseJSONDocument(adapter.Document{Path: "value.json", Data: []byte(
+ `{"exact": 9007199254740992, "big": 9007199254740993, "huge": 99999999999999999999, "negative": -9007199254740993, "ratio": 1.5, "exponent": 1e20}`,
+ )})
+ require.NoError(t, err)
+ assert.Equal(t, float64(1<<53), root.Fields["exact"].Value, "the largest exact integer is a number")
+ for _, field := range []string{"big", "huge", "negative"} {
+ value := root.Fields[field]
+ assert.Equal(t, adapter.CapabilityReadOnly, value.Capability, "%s has no exact float64", field)
+ assert.Equal(t, "JSON integer is too large to represent exactly", value.Reason, field)
+ }
+ assert.Equal(t, "9007199254740993", root.Fields["big"].Value, "the text of the integer is kept")
+ assert.Equal(t, 1.5, root.Fields["ratio"].Value)
+ assert.Equal(t, 1e20, root.Fields["exponent"].Value, "a float literal keeps its float value")
+ assert.Equal(t, adapter.CapabilityReadOnly, root.Capability, "the object that holds the integer is read-only as a whole")
+ assert.Nil(t, root.Encode)
+
+ nested, err := parseJSONDocument(adapter.Document{Path: "value.json", Data: []byte(`{"schema": {"limits": [9007199254740993]}, "name": "ok"}`)})
+ require.NoError(t, err)
+ assert.Equal(t, adapter.CapabilityReadOnly, nested.Fields["schema"].Capability, "a nested object that holds the integer is read-only")
+ assert.Equal(t, adapter.CapabilityReadOnly, nested.Fields["schema"].Fields["limits"].Capability, "the array that holds the integer is read-only")
+ assert.Equal(t, adapter.CapabilityDirect, nested.Fields["name"].Capability, "a sibling leaf stays writable")
+}
+
+func TestYAMLIntegersBeyondExactFloatsAreReadOnly(t *testing.T) {
+ root, err := parseYAMLDocument(adapter.Document{Path: "value.yaml", Data: []byte(
+ "exact: 9007199254740992\nbig: 9007199254740993\nhuge: 99999999999999999999\nnegative: -9007199254740993\nratio: 1.5\n",
+ )})
+ require.NoError(t, err)
+ assert.Equal(t, float64(1<<53), root.Fields["exact"].Value, "the largest exact integer is a number")
+ for _, field := range []string{"big", "huge", "negative"} {
+ value := root.Fields[field]
+ assert.Equal(t, adapter.CapabilityReadOnly, value.Capability, "%s has no exact float64", field)
+ assert.Equal(t, "YAML integer is too large to represent exactly", value.Reason, field)
+ }
+ assert.Equal(t, "9007199254740993", root.Fields["big"].Value, "the text of the integer is kept")
+ assert.Equal(t, 1.5, root.Fields["ratio"].Value)
+
+ encoded, err := root.Fields["exact"].Encode(float64(1000000))
+ require.NoError(t, err)
+ assert.Equal(t, "1000000", string(encoded), "an integer slot writes an integer, not scientific notation")
+ _, err = root.Fields["exact"].Encode(1.5)
+ require.Error(t, err, "an integer slot rejects a fraction")
+ encoded, err = root.Fields["ratio"].Encode(float64(1000000))
+ require.NoError(t, err)
+ assert.Equal(t, "1e+06", string(encoded), "a float slot keeps the float spelling")
+ encoded, err = root.Fields["ratio"].Encode(float64(2))
+ require.NoError(t, err)
+ assert.Equal(t, "2.0", string(encoded), "a whole number in a float slot keeps a fraction")
+}
+
+func TestYAMLWritesKeepHTMLCharacters(t *testing.T) {
+ root, err := parseYAMLDocument(adapter.Document{Path: "value.yaml", Data: []byte("prompt: \"a < b\"\nplain: text\n")})
+ require.NoError(t, err)
+ encoded, err := root.Fields["prompt"].Encode("x > y & z")
+ require.NoError(t, err)
+ assert.Equal(t, `"x > y & z"`, string(encoded), "a quoted YAML string keeps <, >, and &")
+ encoded, err = root.Fields["plain"].Encode("a: b & c")
+ require.NoError(t, err)
+ assert.Equal(t, `"a: b & c"`, string(encoded))
+}
+
+func TestJSONProblemsNameTheFile(t *testing.T) {
+ for name, data := range map[string]string{
+ "syntax": `{"a": 1,`,
+ "duplicate": `{"a": 1, "a": 2}`,
+ "trailing": `{} x`,
+ } {
+ _, err := parseJSONDocument(adapter.Document{Path: "config.json", Data: []byte(data)})
+ require.Error(t, err, name)
+ problem, ok := syncdomain.ProblemFrom(err)
+ require.True(t, ok, name)
+ assert.Equal(t, "config.json", problem.File, "the %s problem names the file", name)
+ }
+}
+
+func TestYAMLAndTOMLProblemsNameTheFile(t *testing.T) {
+ cases := map[string]func() (*adapter.Node, error){
+ "yaml empty": func() (*adapter.Node, error) {
+ return parseYAMLDocument(adapter.Document{Path: "config.yaml", Data: []byte("")})
+ },
+ "yaml duplicate": func() (*adapter.Node, error) {
+ return parseYAMLDocument(adapter.Document{Path: "config.yaml", Data: []byte("a: 1\na: 2\n")})
+ },
+ "toml duplicate": func() (*adapter.Node, error) {
+ return parseTOMLDocument(adapter.Document{Path: "config.yaml", Data: []byte("a = 1\na = 2\n")})
+ },
+ }
+ for name, parse := range cases {
+ _, err := parse()
+ require.Error(t, err, name)
+ problem, ok := syncdomain.ProblemFrom(err)
+ require.True(t, ok, name)
+ assert.Equal(t, "config.yaml", problem.File, "the %s problem names the file", name)
+ }
+}
diff --git a/internal/sync/adapter/structured/testdata/agents.yaml b/internal/sync/adapter/structured/testdata/agents.yaml
new file mode 100644
index 000000000..2bb224b47
--- /dev/null
+++ b/internal/sync/adapter/structured/testdata/agents.yaml
@@ -0,0 +1,16 @@
+service: support
+agents:
+ - name: support
+ prompt: "Ask for an order ID."
+ model:
+ name: gpt-5
+ temperature: 0.2
+ enabled: true
+ - name: refunds
+ prompt: 'Explain the refund policy.'
+ model:
+ name: gpt-5
+ temperature: 0.1
+ enabled: false
+metadata:
+ "display.name": Support agents
diff --git a/internal/sync/adapter/structured/testdata/mcp.json b/internal/sync/adapter/structured/testdata/mcp.json
new file mode 100644
index 000000000..341469504
--- /dev/null
+++ b/internal/sync/adapter/structured/testdata/mcp.json
@@ -0,0 +1,32 @@
+{
+ "server": "orders",
+ "tools": [
+ {
+ "name": "lookup_order",
+ "description": "Look up one order.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "orderId": { "type": "string" }
+ },
+ "required": ["orderId"]
+ }
+ },
+ {
+ "name": "refund_order",
+ "description": "Refund one order.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "orderId": { "type": "string" },
+ "reason": { "type": "string" }
+ },
+ "required": ["orderId", "reason"]
+ }
+ }
+ ],
+ "metadata": {
+ "display.name": "Order tools",
+ "enabled": true
+ }
+}
diff --git a/internal/sync/adapter/structured/testdata/pyproject.toml b/internal/sync/adapter/structured/testdata/pyproject.toml
new file mode 100644
index 000000000..689ed0c28
--- /dev/null
+++ b/internal/sync/adapter/structured/testdata/pyproject.toml
@@ -0,0 +1,20 @@
+[project]
+name = "support-agents"
+version = "1.0.0"
+
+[tool.agent.support]
+prompt = """Ask for an order ID.
+Keep the answer short."""
+model = "gpt-5"
+temperature = 0.2
+enabled = true
+
+[tool.agent.refunds]
+prompt = 'Explain the refund policy.'
+model = 'gpt-5'
+temperature = 0.1
+enabled = false
+
+[[tool.agent.plugins]]
+name = "audit"
+enabled = true
diff --git a/internal/sync/adapter/structured/toml.go b/internal/sync/adapter/structured/toml.go
new file mode 100644
index 000000000..a0f8edd91
--- /dev/null
+++ b/internal/sync/adapter/structured/toml.go
@@ -0,0 +1,490 @@
+package structured
+
+import (
+ "bytes"
+ "fmt"
+ "math"
+ "strconv"
+ "strings"
+ "unicode/utf8"
+
+ toml "github.com/pelletier/go-toml/v2"
+ "github.com/pelletier/go-toml/v2/unstable"
+
+ syncdomain "github.com/launchdarkly/ldcli/internal/sync"
+ "github.com/launchdarkly/ldcli/internal/sync/adapter"
+)
+
+type tomlAdapter struct{}
+
+func (tomlAdapter) Format() string { return "toml" }
+func (tomlAdapter) Behavior() string { return "toml/1" }
+
+func (tomlAdapter) Inspect(bundle adapter.SourceBundle) (*adapter.Node, error) {
+ return parseTOMLDocument(bundle.Primary)
+}
+
+func (tomlAdapter) Defaults(syncdomain.Kind) (string, map[string]string) { return "$", nil }
+
+func init() { adapter.MustRegister(tomlAdapter{}) }
+
+func parseTOMLDocument(document adapter.Document) (*adapter.Node, error) {
+ if !utf8.Valid(document.Data) {
+ return nil, syncdomain.NewProblem(
+ syncdomain.ErrorInvalidSource,
+ "TOML source is not valid UTF-8",
+ "Save the file as UTF-8.",
+ ).WithFile(document.Path, 0, 0)
+ }
+ data := document.Data
+ offset := 0
+ if bytes.HasPrefix(data, []byte("\ufeff")) {
+ offset = len([]byte("\ufeff"))
+ data = data[offset:]
+ }
+ var parser unstable.Parser
+ parser.Reset(data)
+ root := newTOMLTable(nil)
+ table := root
+ tree := newTOMLTree()
+ for parser.NextExpression() {
+ expression := parser.Expression()
+ switch expression.Kind {
+ case unstable.KeyValue:
+ keys := tomlKeys(expression)
+ if err := addTOMLValue(data, offset, table, keys, expression.Value(), tree); err != nil {
+ return nil, nameDocument(err, document.Path)
+ }
+ case unstable.Table:
+ var err error
+ table, err = tree.table(root, tomlKeys(expression))
+ if err != nil {
+ return nil, nameDocument(err, document.Path)
+ }
+ case unstable.ArrayTable:
+ var err error
+ table, err = tree.arrayTable(root, tomlKeys(expression))
+ if err != nil {
+ return nil, nameDocument(err, document.Path)
+ }
+ }
+ }
+ if err := parser.Error(); err != nil {
+ return nil, syncdomain.WrapProblem(syncdomain.NewProblem(
+ syncdomain.ErrorInvalidSource, "TOML source is invalid", "Fix the TOML syntax.",
+ ).WithFile(document.Path, 0, 0), err)
+ }
+ return root, nil
+}
+
+func tomlKeys(node *unstable.Node) []string {
+ var keys []string
+ iterator := node.Key()
+ for iterator.Next() {
+ keys = append(keys, string(iterator.Node().Data))
+ }
+ return keys
+}
+
+// tomlTree records how each table of a document was defined, because TOML
+// limits what can extend it. A [header] defines a table once, and a later
+// header can add a sub-table to it. A dotted key cannot add to a table that a
+// header defined. Nothing can add to an inline table.
+type tomlTree struct {
+ // arrayTables are the arrays that [[header]] lines build.
+ arrayTables map[*adapter.Node]bool
+ // headers are the tables that a [header] or a [[header]] item defined.
+ headers map[*adapter.Node]bool
+ // implicit are the tables that a header path created on its way, as
+ // [a.b.c] creates a and a.b. A later header can define one of them once,
+ // and a dotted key cannot extend it.
+ implicit map[*adapter.Node]bool
+ // dotted are the tables that a dotted key created.
+ dotted map[*adapter.Node]bool
+ // inline are the inline tables.
+ inline map[*adapter.Node]bool
+}
+
+func newTOMLTree() *tomlTree {
+ return &tomlTree{
+ arrayTables: map[*adapter.Node]bool{}, headers: map[*adapter.Node]bool{},
+ dotted: map[*adapter.Node]bool{}, inline: map[*adapter.Node]bool{}, implicit: map[*adapter.Node]bool{},
+ }
+}
+
+// addTOMLValue adds one key and value to a table. An inline table has a tree
+// of its own, because no header can open it again and its own keys follow
+// the same extension rules.
+func addTOMLValue(
+ data []byte,
+ offset int,
+ table *adapter.Node,
+ keys []string,
+ valueNode *unstable.Node,
+ tree *tomlTree,
+) error {
+ if len(keys) == 0 {
+ return syncdomain.NewProblem(
+ syncdomain.ErrorInvalidSource, "TOML key is missing", "Add a key to the value.",
+ )
+ }
+ parent, err := tree.objectPath(table, keys[:len(keys)-1], true)
+ if err != nil {
+ return err
+ }
+ key := keys[len(keys)-1]
+ if _, duplicate := parent.Fields[key]; duplicate {
+ return syncdomain.NewProblem(
+ syncdomain.ErrorDuplicateKey,
+ fmt.Sprintf("TOML table contains duplicate key %q", strings.Join(keys, ".")),
+ "Remove the duplicate key.",
+ )
+ }
+ value, err := tomlValueNode(data, offset, valueNode)
+ if err != nil {
+ return err
+ }
+ if tomlFrozen(table) {
+ value.MarkReadOnly(table.Reason)
+ }
+ if tree != nil && valueNode.Kind == unstable.InlineTable {
+ tree.inline[value] = true
+ }
+ parent.Fields[key] = value
+ parent.Value.(map[string]any)[key] = value.Value
+ return nil
+}
+
+const (
+ tomlArrayTableReason = "TOML arrays of tables are read-only"
+ tomlStructureReason = "TOML structural changes are read-only"
+)
+
+// tomlFrozen reports whether a table and every value in it are read-only,
+// as an item of an array of tables is. A table that is only structurally
+// read-only keeps its scalar values writable.
+func tomlFrozen(table *adapter.Node) bool {
+ return table.Capability == adapter.CapabilityReadOnly && table.Reason == tomlArrayTableReason
+}
+
+// table returns the table that a [table] header opens. A header can define a
+// table once, and cannot open an array of tables, an inline table, or a
+// table that a dotted key defined.
+func (tree *tomlTree) table(root *adapter.Node, keys []string) (*adapter.Node, error) {
+ if len(keys) == 0 {
+ return nil, syncdomain.NewProblem(
+ syncdomain.ErrorInvalidSource, "TOML table key is missing", "Add a table key.",
+ )
+ }
+ parent, err := tree.objectPath(root, keys[:len(keys)-1], false)
+ if err != nil {
+ return nil, err
+ }
+ key := keys[len(keys)-1]
+ table := parent.Fields[key]
+ switch {
+ case table == nil:
+ table = newTOMLTable(parent)
+ parent.Fields[key] = table
+ parent.Value.(map[string]any)[key] = table.Value
+ case table.Fields == nil || tree.arrayTables[table] || tree.headers[table] || tree.dotted[table] || tree.inline[table]:
+ return nil, syncdomain.NewProblem(
+ syncdomain.ErrorDuplicateKey,
+ fmt.Sprintf("TOML table %q is defined more than once", strings.Join(keys, ".")),
+ "Keep one table definition.",
+ )
+ }
+ tree.headers[table] = true
+ delete(tree.implicit, table)
+ return table, nil
+}
+
+// arrayTable returns the new item that a [[table]] header appends to its
+// array of tables. A key that already holds a value, a table, or a plain
+// array cannot become an array of tables.
+func (tree *tomlTree) arrayTable(root *adapter.Node, keys []string) (*adapter.Node, error) {
+ if len(keys) == 0 {
+ return nil, syncdomain.NewProblem(
+ syncdomain.ErrorInvalidSource, "TOML array table key is missing", "Add a table key.",
+ )
+ }
+ parent, err := tree.objectPath(root, keys[:len(keys)-1], false)
+ if err != nil {
+ return nil, err
+ }
+ key := keys[len(keys)-1]
+ collection := parent.Fields[key]
+ if collection == nil {
+ collection = &adapter.Node{
+ Value: []any{}, Collection: true, Capability: adapter.CapabilityReadOnly,
+ Reason: tomlArrayTableReason,
+ }
+ tree.arrayTables[collection] = true
+ parent.Fields[key] = collection
+ parent.Value.(map[string]any)[key] = collection.Value
+ }
+ if !tree.arrayTables[collection] {
+ return nil, syncdomain.NewProblem(
+ syncdomain.ErrorDuplicateKey,
+ fmt.Sprintf("TOML key %q has incompatible definitions", strings.Join(keys, ".")),
+ "Keep one table definition.",
+ )
+ }
+ item := &adapter.Node{
+ Value: map[string]any{}, Fields: map[string]*adapter.Node{},
+ Capability: adapter.CapabilityReadOnly, Reason: tomlArrayTableReason,
+ }
+ tree.headers[item] = true
+ collection.Items = append(collection.Items, item)
+ collection.Value = append(collection.Value.([]any), item.Value)
+ parent.Value.(map[string]any)[key] = collection.Value
+ return item, nil
+}
+
+// newTOMLTable returns an empty table under parent, or the root table when
+// parent is nil. Sync edits the values of a table and never rewrites the
+// table as a whole, so the table is structurally read-only. A table inside
+// an item of an array of tables is read-only with its values, as the item is.
+func newTOMLTable(parent *adapter.Node) *adapter.Node {
+ table := &adapter.Node{
+ Value: map[string]any{}, Fields: map[string]*adapter.Node{},
+ Capability: adapter.CapabilityReadOnly, Reason: tomlStructureReason,
+ }
+ if parent != nil && tomlFrozen(parent) {
+ table.Reason = parent.Reason
+ }
+ return table
+}
+
+// objectPath returns the table at a dotted key path and creates the tables
+// that do not exist yet. A header path through an array of tables continues
+// in its last item, as [servers.limits] after [[servers]] does. A dotted key
+// cannot extend a table that a header defined or an array of tables, and no
+// path can extend an inline table.
+func (tree *tomlTree) objectPath(root *adapter.Node, keys []string, dotted bool) (*adapter.Node, error) {
+ current := root
+ for index, key := range keys {
+ child := current.Fields[key]
+ if child == nil {
+ child = newTOMLTable(current)
+ current.Fields[key] = child
+ current.Value.(map[string]any)[key] = child.Value
+ if tree != nil {
+ if dotted {
+ tree.dotted[child] = true
+ } else {
+ tree.implicit[child] = true
+ }
+ }
+ }
+ if tree != nil && (tree.inline[child] ||
+ dotted && (tree.headers[child] || tree.arrayTables[child] || tree.implicit[child])) {
+ return nil, syncdomain.NewProblem(
+ syncdomain.ErrorDuplicateKey,
+ fmt.Sprintf("TOML table %q cannot be extended here", strings.Join(keys[:index+1], ".")),
+ "Define the keys where the table is defined.",
+ )
+ }
+ if tree != nil && tree.arrayTables[child] && len(child.Items) != 0 {
+ child = child.Items[len(child.Items)-1]
+ }
+ if child.Fields == nil {
+ return nil, syncdomain.NewProblem(
+ syncdomain.ErrorDuplicateKey,
+ fmt.Sprintf("TOML key %q is already a value", key),
+ "Keep one compatible key definition.",
+ )
+ }
+ current = child
+ }
+ return current, nil
+}
+
+func tomlValueNode(data []byte, offset int, node *unstable.Node) (*adapter.Node, error) {
+ start := offset + int(node.Raw.Offset)
+ end := start + int(node.Raw.Length)
+ raw := data[node.Raw.Offset : node.Raw.Offset+node.Raw.Length]
+ switch node.Kind {
+ case unstable.String:
+ return adapter.String(string(node.Data), start, end, func(value string) ([]byte, error) {
+ return encodeTOMLString(value, string(raw))
+ }), nil
+ case unstable.Bool:
+ return adapter.Bool(string(node.Data) == "true", start, end, nil), nil
+ case unstable.Integer, unstable.Float:
+ // An integer beyond int64 fails to decode, so it stays read-only
+ // text, as an integer beyond 53 bits does, and the rest of the
+ // document stays usable.
+ if node.Kind == unstable.Integer {
+ if _, err := strconv.ParseInt(strings.ReplaceAll(string(raw), "_", ""), 0, 64); err != nil {
+ result := adapter.ReadOnly(start, end, "TOML integer is too large to represent exactly")
+ result.Value = string(raw)
+ return result, nil
+ }
+ }
+ var decoded map[string]any
+ if err := toml.Unmarshal(append([]byte("value = "), raw...), &decoded); err != nil {
+ return nil, err
+ }
+ // An integer beyond 53 bits has no exact float64, so it stays
+ // read-only text instead of a rounded number.
+ if integer, isInteger := decoded["value"].(int64); isInteger && !adapter.ExactInteger(integer) {
+ result := adapter.ReadOnly(start, end, "TOML integer is too large to represent exactly")
+ result.Value = string(raw)
+ return result, nil
+ }
+ number, ok := adapter.AsNumber(decoded["value"])
+ if !ok {
+ return adapter.ReadOnly(start, end, "TOML number is not representable"), nil
+ }
+ // Infinity and NaN have no JSON form, so they stay read-only text.
+ if math.IsInf(number, 0) || math.IsNaN(number) {
+ result := adapter.ReadOnly(start, end, "TOML infinity and NaN values are read-only")
+ result.Value = string(raw)
+ return result, nil
+ }
+ return adapter.Number(number, start, end, tomlNumberEncoder(node.Kind == unstable.Integer)), nil
+ case unstable.Array:
+ return tomlArrayNode(data, offset, node)
+ case unstable.InlineTable:
+ return tomlInlineTableNode(data, offset, node)
+ default:
+ result := adapter.ReadOnly(start, end, "TOML date and time values are read-only")
+ result.Value = string(node.Data)
+ return result, nil
+ }
+}
+
+func tomlArrayNode(data []byte, offset int, node *unstable.Node) (*adapter.Node, error) {
+ items, values := []*adapter.Node{}, []any{}
+ iterator := node.Children()
+ for iterator.Next() {
+ child := iterator.Node()
+ if child.Kind == unstable.Comment {
+ continue
+ }
+ item, err := tomlValueNode(data, offset, child)
+ if err != nil {
+ return nil, err
+ }
+ items, values = append(items, item), append(values, item.Value)
+ }
+ return &adapter.Node{
+ Value: values, Items: items, Collection: true, Capability: adapter.CapabilityReadOnly,
+ Reason: tomlStructureReason,
+ }, nil
+}
+
+func tomlInlineTableNode(data []byte, offset int, node *unstable.Node) (*adapter.Node, error) {
+ result := newTOMLTable(nil)
+ tree := newTOMLTree()
+ iterator := node.Children()
+ for iterator.Next() {
+ child := iterator.Node()
+ if child.Kind != unstable.KeyValue {
+ continue
+ }
+ if err := addTOMLValue(data, offset, result, tomlKeys(child), child.Value(), tree); err != nil {
+ return nil, err
+ }
+ }
+ return result, nil
+}
+
+// tomlNumberEncoder writes a number in the type of the value that it
+// replaces. TOML tells an integer from a float, so an integer slot takes a
+// whole number only and a float slot keeps a fraction or a trailing .0.
+func tomlNumberEncoder(integer bool) func(float64) ([]byte, error) {
+ return func(next float64) ([]byte, error) {
+ if integer {
+ if next != math.Trunc(next) || next > float64(adapter.MaxExactInteger) || next < -float64(adapter.MaxExactInteger) {
+ return nil, fmt.Errorf("TOML integer cannot hold %v", next)
+ }
+ return []byte(strconv.FormatInt(int64(next), 10)), nil
+ }
+ text := strconv.FormatFloat(next, 'g', -1, 64)
+ if !strings.ContainsAny(text, ".eE") {
+ text += ".0"
+ }
+ return []byte(text), nil
+ }
+}
+
+// encodeTOMLString writes a value in the string form of the text that it
+// replaces. A literal string has no escapes, so it cannot hold every value.
+func encodeTOMLString(value, previous string) ([]byte, error) {
+ switch {
+ case strings.HasPrefix(previous, `"""`):
+ return []byte(tomlMultilineOpening(`"""`, previous, value) + encodeTOMLBasicBody(value, true) + `"""`), nil
+ case strings.HasPrefix(previous, `'''`):
+ if strings.Contains(value, `'''`) || !tomlLiteralCanHold(value, true) {
+ return nil, fmt.Errorf("multiline literal string cannot represent the new value")
+ }
+ return []byte(tomlMultilineOpening(`'''`, previous, value) + value + `'''`), nil
+ case strings.HasPrefix(previous, `'`):
+ if strings.Contains(value, `'`) || !tomlLiteralCanHold(value, false) {
+ return nil, fmt.Errorf("literal string cannot represent the new value")
+ }
+ return []byte("'" + value + "'"), nil
+ default:
+ return []byte(`"` + encodeTOMLBasicBody(value, false) + `"`), nil
+ }
+}
+
+// tomlMultilineOpening returns the opening delimiter of a multiline string.
+// TOML trims a newline right after the delimiter, so a value that starts with
+// a newline needs one more. A source that had one keeps its layout.
+func tomlMultilineOpening(delimiter, previous, value string) string {
+ rest := strings.TrimPrefix(previous, delimiter)
+ if strings.HasPrefix(value, "\n") || strings.HasPrefix(value, "\r\n") ||
+ strings.HasPrefix(rest, "\n") || strings.HasPrefix(rest, "\r\n") {
+ return delimiter + "\n"
+ }
+ return delimiter
+}
+
+// tomlLiteralCanHold reports whether a literal string can hold a value. A
+// literal string allows a tab but no other control character. A multiline
+// literal string also allows a newline.
+func tomlLiteralCanHold(value string, multiline bool) bool {
+ for index := 0; index < len(value); index++ {
+ switch character := value[index]; {
+ case character == '\t' || character >= 0x20 && character != 0x7f:
+ case multiline && character == '\n':
+ case multiline && character == '\r' && index+1 < len(value) && value[index+1] == '\n':
+ default:
+ return false
+ }
+ }
+ return true
+}
+
+// encodeTOMLBasicBody escapes a value for a basic string. A multiline basic
+// string keeps a newline and a tab as they are.
+func encodeTOMLBasicBody(value string, multiline bool) string {
+ var encoded strings.Builder
+ for _, character := range value {
+ switch {
+ case character == '"' || character == '\\':
+ encoded.WriteByte('\\')
+ encoded.WriteRune(character)
+ case multiline && (character == '\n' || character == '\t'):
+ encoded.WriteRune(character)
+ case character == '\b':
+ encoded.WriteString(`\b`)
+ case character == '\t':
+ encoded.WriteString(`\t`)
+ case character == '\n':
+ encoded.WriteString(`\n`)
+ case character == '\f':
+ encoded.WriteString(`\f`)
+ case character == '\r':
+ encoded.WriteString(`\r`)
+ case character < 0x20 || character >= 0x7f && character <= 0x9f:
+ fmt.Fprintf(&encoded, `\u%04X`, character)
+ default:
+ encoded.WriteRune(character)
+ }
+ }
+ return encoded.String()
+}
diff --git a/internal/sync/adapter/structured/toml_test.go b/internal/sync/adapter/structured/toml_test.go
new file mode 100644
index 000000000..3b9ee1954
--- /dev/null
+++ b/internal/sync/adapter/structured/toml_test.go
@@ -0,0 +1,218 @@
+package structured
+
+import (
+ "testing"
+
+ toml "github.com/pelletier/go-toml/v2"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/launchdarkly/ldcli/internal/sync/adapter"
+)
+
+func TestTOMLArrayTablesRejectAnIncompatibleKey(t *testing.T) {
+ for name, input := range map[string]string{
+ "value": "tools = 1\n[[tools]]\nname = \"one\"\n",
+ "table": "[tools]\nname = \"one\"\n[[tools]]\nname = \"two\"\n",
+ "inline table": "tools = { name = \"one\" }\n[[tools]]\nname = \"two\"\n",
+ "plain array": "tools = []\n[[tools]]\nname = \"one\"\n",
+ } {
+ t.Run(name, func(t *testing.T) {
+ _, err := parseTOMLDocument(adapter.Document{Path: "value.toml", Data: []byte(input)})
+ require.ErrorContains(t, err, `TOML key "tools" has incompatible definitions`)
+ })
+ }
+}
+
+func TestTOMLSubtableOfAnArrayTableJoinsTheLastItem(t *testing.T) {
+ root, err := parseTOMLDocument(adapter.Document{Path: "value.toml", Data: []byte(`
+[[servers]]
+name = "one"
+[servers.limits]
+max = 1
+[[servers]]
+name = "two"
+`)})
+ require.NoError(t, err)
+
+ servers := root.Fields["servers"]
+ require.Len(t, servers.Items, 2)
+ assert.True(t, servers.Collection)
+ assert.Equal(t, 1.0, servers.Items[0].Fields["limits"].Fields["max"].Value)
+ assert.Nil(t, servers.Items[1].Fields["limits"])
+ assert.Equal(t, []any{
+ map[string]any{"name": "one", "limits": map[string]any{"max": 1.0}},
+ map[string]any{"name": "two"},
+ }, root.Value.(map[string]any)["servers"])
+}
+
+func TestTOMLStringEncodersRoundTrip(t *testing.T) {
+ values := map[string]string{
+ "escape": `path C:\new\table`,
+ "leading newline": "\nfirst\nsecond",
+ "trailing backslash": `ends with \`,
+ "quotes": `say """hi""" now"`,
+ "control": "tab\tbell\a",
+ "crlf": "one\r\ntwo",
+ "unicode": "caf\u00e9 \u2014 ok",
+ }
+ previous := []string{`"old"`, `"""old"""`, "\"\"\"\nold\"\"\"", `'old'`, `'''old'''`, "'''\nold'''"}
+ for name, value := range values {
+ for _, old := range previous {
+ t.Run(name+" after "+old, func(t *testing.T) {
+ encoded, err := encodeTOMLString(value, old)
+ if err != nil {
+ // A literal string has no escapes, so it cannot hold a
+ // control character, and a single-line one no newline.
+ require.True(t, old[0] == '\'', "basic strings hold every value")
+ return
+ }
+ var decoded struct {
+ Value string `toml:"value"`
+ }
+ require.NoError(t, toml.Unmarshal([]byte("value = "+string(encoded)+"\n"), &decoded), string(encoded))
+ assert.Equal(t, value, decoded.Value)
+ })
+ }
+ }
+}
+
+func TestTOMLHeadersDefineATableOnce(t *testing.T) {
+ for name, input := range map[string]string{
+ "table twice": "[a]\nx = 1\n[a]\ny = 2\n",
+ "table after array": "[[a]]\nx = 1\n[a]\ny = 2\n",
+ "table after inline": "a = { x = 1 }\n[a]\ny = 2\n",
+ "table after dotted key": "a.x = 1\n[a]\ny = 2\n",
+ "dotted key after table": "[a]\nx = 1\n[b]\na.y = 2\n[a]\nz = 3\n",
+ } {
+ t.Run(name, func(t *testing.T) {
+ _, err := parseTOMLDocument(adapter.Document{Path: "value.toml", Data: []byte(input)})
+ require.ErrorContains(t, err, `is defined more than once`)
+ })
+ }
+
+ root, err := parseTOMLDocument(adapter.Document{Path: "value.toml", Data: []byte(`
+[x.y.z.w]
+deep = 1
+[x]
+top = 2
+[x.y]
+mid = 3
+`)})
+ require.NoError(t, err)
+ assert.Equal(t, map[string]any{"x": map[string]any{
+ "top": 2.0, "y": map[string]any{"mid": 3.0, "z": map[string]any{"w": map[string]any{"deep": 1.0}}},
+ }}, root.Value)
+}
+
+func TestTOMLInfinityAndNaNAreReadOnly(t *testing.T) {
+ root, err := parseTOMLDocument(adapter.Document{Path: "value.toml", Data: []byte("a = inf\nb = -inf\nc = nan\nd = 1.5\n")})
+ require.NoError(t, err)
+ for key, raw := range map[string]string{"a": "inf", "b": "-inf", "c": "nan"} {
+ assert.Equal(t, adapter.CapabilityReadOnly, root.Fields[key].Capability, key)
+ assert.Equal(t, raw, root.Fields[key].Value, key)
+ }
+ assert.Equal(t, 1.5, root.Fields["d"].Value)
+ assert.Equal(t, adapter.CapabilityDirect, root.Fields["d"].Capability)
+}
+
+func TestTOMLClosedTablesCannotBeExtended(t *testing.T) {
+ for name, input := range map[string]string{
+ "dotted key into inline table": "a = { x = 1 }\na.y = 2\n",
+ "header into inline table": "a = { x = 1 }\n[a.b]\ny = 2\n",
+ "dotted key into header table": "[a.b]\nx = 1\n[a]\nb.y = 2\n",
+ "dotted key into array table": "[[a]]\nx = 1\n[b]\n[c]\n",
+ } {
+ t.Run(name, func(t *testing.T) {
+ if name == "dotted key into array table" {
+ input = "[[a]]\nx = 1\n" // The root table is closed once a header opens.
+ _, err := parseTOMLDocument(adapter.Document{Path: "value.toml", Data: []byte("a.y = 1\n[[a]]\nx = 1\n")})
+ require.ErrorContains(t, err, "incompatible definitions")
+ return
+ }
+ _, err := parseTOMLDocument(adapter.Document{Path: "value.toml", Data: []byte(input)})
+ require.ErrorContains(t, err, "cannot be extended here")
+ })
+ }
+
+ root, err := parseTOMLDocument(adapter.Document{Path: "value.toml", Data: []byte(`
+a.b.c = 1
+a.b.d = 2
+[a.e]
+f = 3
+`)})
+ require.NoError(t, err)
+ assert.Equal(t, map[string]any{"a": map[string]any{
+ "b": map[string]any{"c": 1.0, "d": 2.0}, "e": map[string]any{"f": 3.0},
+ }}, root.Value)
+}
+
+func TestTOMLSubtablesOfArrayItemsAreReadOnly(t *testing.T) {
+ root, err := parseTOMLDocument(adapter.Document{Path: "value.toml", Data: []byte("[[servers]]\nname = \"one\"\n[servers.limits]\nmax = 1\n[servers.limits.deep]\nvalue = \"x\"\n[other]\nkept = true\n")})
+ require.NoError(t, err)
+ item := root.Fields["servers"].Items[0]
+ limits := item.Fields["limits"]
+ assert.Equal(t, adapter.CapabilityReadOnly, limits.Capability)
+ assert.Equal(t, adapter.CapabilityReadOnly, limits.Fields["max"].Capability)
+ assert.Equal(t, adapter.CapabilityReadOnly, limits.Fields["deep"].Fields["value"].Capability)
+ assert.Equal(t, adapter.CapabilityDirect, root.Fields["other"].Fields["kept"].Capability)
+}
+
+func TestTOMLTablesAreStructurallyReadOnlyWithWritableValues(t *testing.T) {
+ root, err := parseTOMLDocument(adapter.Document{Path: "value.toml", Data: []byte(
+ "[model]\nname = \"gpt\"\nlimits = { max = 1 }\n[model.parameters]\ntemperature = 0.2\n",
+ )})
+ require.NoError(t, err)
+ model := root.Fields["model"]
+ for name, table := range map[string]*adapter.Node{
+ "root": root, "header table": model, "sub-table": model.Fields["parameters"], "inline table": model.Fields["limits"],
+ } {
+ assert.Equal(t, adapter.CapabilityReadOnly, table.Capability, name)
+ assert.Equal(t, tomlStructureReason, table.Reason, name)
+ assert.Nil(t, table.Encode, name)
+ }
+ assert.Equal(t, adapter.CapabilityDirect, model.Fields["name"].Capability)
+ assert.Equal(t, adapter.CapabilityDirect, model.Fields["limits"].Fields["max"].Capability)
+ assert.Equal(t, adapter.CapabilityDirect, model.Fields["parameters"].Fields["temperature"].Capability)
+}
+
+func TestTOMLNestedInlineTablesCannotBeExtended(t *testing.T) {
+ _, err := parseTOMLDocument(adapter.Document{Path: "value.toml", Data: []byte("a = { b = { x = 1 }, b.y = 2 }\n")})
+ require.ErrorContains(t, err, "cannot be extended here", "a dotted key cannot add to a nested inline table")
+
+ root, err := parseTOMLDocument(adapter.Document{Path: "value.toml", Data: []byte("a = { b.x = 1, b.y = 2 }\n")})
+ require.NoError(t, err)
+ assert.Equal(t, map[string]any{"a": map[string]any{"b": map[string]any{"x": 1.0, "y": 2.0}}}, root.Value)
+}
+
+func TestTOMLImplicitHeaderTablesAreClosedToDottedKeys(t *testing.T) {
+ _, err := parseTOMLDocument(adapter.Document{Path: "value.toml", Data: []byte("[a.b.c]\nx = 1\n[a]\nb.y = 2\n")})
+ require.ErrorContains(t, err, "cannot be extended here", "a table that a header path created cannot take a dotted key")
+
+ root, err := parseTOMLDocument(adapter.Document{Path: "value.toml", Data: []byte("[a.b.c]\nx = 1\n[a.b]\ny = 2\n")})
+ require.NoError(t, err, "a later header defines an implicit table once")
+ assert.Equal(t, map[string]any{"a": map[string]any{"b": map[string]any{"y": 2.0, "c": map[string]any{"x": 1.0}}}}, root.Value)
+}
+
+func TestTOMLNumbersKeepTheirTypeAndPrecision(t *testing.T) {
+ root, err := parseTOMLDocument(adapter.Document{Path: "value.toml", Data: []byte("big = 9007199254740993\nratio = 1.0\ncount = 3\n")})
+ require.NoError(t, err)
+ big := root.Fields["big"]
+ assert.Equal(t, adapter.CapabilityReadOnly, big.Capability, "an integer beyond 53 bits is read-only text")
+ assert.Equal(t, "9007199254740993", big.Value)
+
+ huge, err := parseTOMLDocument(adapter.Document{Path: "value.toml", Data: []byte("huge = 99999999999999999999\nname = \"ok\"\n")})
+ require.NoError(t, err, "an integer beyond int64 does not fail the document")
+ assert.Equal(t, adapter.CapabilityReadOnly, huge.Fields["huge"].Capability)
+ assert.Equal(t, "99999999999999999999", huge.Fields["huge"].Value)
+ assert.Equal(t, adapter.CapabilityDirect, huge.Fields["name"].Capability, "the other fields stay usable")
+
+ encoded, err := root.Fields["ratio"].Encode(2.0)
+ require.NoError(t, err)
+ assert.Equal(t, "2.0", string(encoded), "a float slot keeps a float form")
+ encoded, err = root.Fields["count"].Encode(4.0)
+ require.NoError(t, err)
+ assert.Equal(t, "4", string(encoded), "an integer slot writes an integer")
+ _, err = root.Fields["count"].Encode(4.5)
+ require.Error(t, err, "an integer slot does not take a fraction")
+}
diff --git a/internal/sync/adapter/structured/yaml.go b/internal/sync/adapter/structured/yaml.go
new file mode 100644
index 000000000..3f3616629
--- /dev/null
+++ b/internal/sync/adapter/structured/yaml.go
@@ -0,0 +1,75 @@
+package structured
+
+import (
+ "bytes"
+ "io"
+ "unicode/utf8"
+
+ "gopkg.in/yaml.v3"
+
+ syncdomain "github.com/launchdarkly/ldcli/internal/sync"
+ "github.com/launchdarkly/ldcli/internal/sync/adapter"
+ "github.com/launchdarkly/ldcli/internal/sync/adapter/yamltree"
+)
+
+type yamlAdapter struct{}
+
+func (yamlAdapter) Format() string { return "yaml" }
+func (yamlAdapter) Behavior() string { return "yaml/1" }
+
+func (yamlAdapter) Inspect(bundle adapter.SourceBundle) (*adapter.Node, error) {
+ return parseYAMLDocument(bundle.Primary)
+}
+
+func (yamlAdapter) Defaults(syncdomain.Kind) (string, map[string]string) { return "$", nil }
+
+func init() { adapter.MustRegister(yamlAdapter{}) }
+
+func parseYAMLDocument(document adapter.Document) (*adapter.Node, error) {
+ if !utf8.Valid(document.Data) {
+ return nil, syncdomain.NewProblem(
+ syncdomain.ErrorInvalidSource,
+ "YAML source is not valid UTF-8",
+ "Save the file as UTF-8.",
+ ).WithFile(document.Path, 0, 0)
+ }
+ data := bytes.TrimPrefix(document.Data, []byte("\ufeff"))
+ offset := len(document.Data) - len(data)
+ decoder := yaml.NewDecoder(bytes.NewReader(data))
+ var first yaml.Node
+ if err := decoder.Decode(&first); err != nil {
+ return nil, syncdomain.WrapProblem(syncdomain.NewProblem(
+ syncdomain.ErrorInvalidSource, "YAML source is invalid", "Fix the YAML syntax.",
+ ).WithFile(document.Path, 0, 0), err)
+ }
+ // The source has several documents when any later document has content.
+ // A trailing "---" decodes as an empty document, which is not content.
+ multiple := false
+ for !multiple {
+ var extra yaml.Node
+ extraErr := decoder.Decode(&extra)
+ if extraErr == io.EOF {
+ break
+ }
+ if extraErr != nil {
+ return nil, syncdomain.WrapProblem(syncdomain.NewProblem(
+ syncdomain.ErrorInvalidSource, "YAML source is invalid", "Fix the YAML syntax.",
+ ).WithFile(document.Path, 0, 0), extraErr)
+ }
+ multiple = len(extra.Content) != 0 &&
+ !(extra.Content[0].Kind == yaml.ScalarNode && extra.Content[0].Tag == "!!null" && extra.Content[0].Value == "")
+ }
+ if len(first.Content) == 0 {
+ return nil, syncdomain.NewProblem(
+ syncdomain.ErrorInvalidSource, "YAML document is empty", "Add one YAML value.",
+ ).WithFile(document.Path, 0, 0)
+ }
+ root, err := yamltree.Build(data, first.Content[0], offset, false)
+ if err != nil {
+ return nil, nameDocument(err, document.Path)
+ }
+ if multiple {
+ root.MarkReadOnly("multi-document YAML is read-only")
+ }
+ return root, nil
+}
diff --git a/internal/sync/adapter/yamltree/yamltree.go b/internal/sync/adapter/yamltree/yamltree.go
index c4df24ffd..3ac926e47 100644
--- a/internal/sync/adapter/yamltree/yamltree.go
+++ b/internal/sync/adapter/yamltree/yamltree.go
@@ -103,14 +103,12 @@ func yamlPlainScalar(text []byte, flow bool) []byte {
func quoteYAML(value, previous string) string {
if strings.HasPrefix(previous, "'") {
if strings.ContainsAny(value, "\r\n") {
- encoded, _ := json.Marshal(value)
- return string(encoded)
+ return quotedYAML(value)
}
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}
if strings.HasPrefix(previous, `"`) {
- encoded, _ := json.Marshal(value)
- return string(encoded)
+ return quotedYAML(value)
}
if value != "" && !strings.ContainsAny(value, ":\n\r#{}[],&*!|>'\"%@`") &&
value == strings.TrimSpace(value) {
@@ -121,7 +119,17 @@ func quoteYAML(value, previous string) string {
}
}
}
- encoded, _ := json.Marshal(value)
+ return quotedYAML(value)
+}
+
+// quotedYAML writes a double-quoted YAML string. JSON quoting is valid YAML,
+// and the encoder keeps <, >, and & as they are.
+func quotedYAML(value string) string {
+ encoded, err := adapter.EncodeJSON(value, "", "")
+ if err != nil {
+ fallback, _ := json.Marshal(value)
+ return string(fallback)
+ }
return string(encoded)
}
@@ -296,14 +304,44 @@ func yamlValueNode(data []byte, node *yaml.Node, offset int, flow bool) (*adapte
break
}
result = adapter.Bool(value, start, end, nil)
- case "!!int", "!!float":
+ case "!!int":
+ // An integer beyond 53 bits has no exact float64, so it stays
+ // read-only text instead of a rounded number.
+ var integer int64
+ if err := node.Decode(&integer); err != nil || !adapter.ExactInteger(integer) {
+ result = adapter.ReadOnly(start, end, "YAML integer is too large to represent exactly")
+ result.Value = node.Value
+ break
+ }
+ // An integer slot takes a whole number only, written as an integer,
+ // so that a write does not change the type of the value.
+ result = adapter.Number(float64(integer), start, end, func(next float64) ([]byte, error) {
+ if next != math.Trunc(next) || next > float64(adapter.MaxExactInteger) || next < -float64(adapter.MaxExactInteger) {
+ return nil, fmt.Errorf("YAML integer cannot hold %v", next)
+ }
+ return []byte(strconv.FormatInt(int64(next), 10)), nil
+ })
+ case "!!float":
+ // YAML tags an integer beyond int64 as a float, so an integer
+ // literal here is too large as well.
+ if !strings.ContainsAny(node.Value, ".eE") && !strings.HasPrefix(node.Value, "0x") && !strings.HasPrefix(node.Value, "0o") {
+ result = adapter.ReadOnly(start, end, "YAML integer is too large to represent exactly")
+ result.Value = node.Value
+ break
+ }
var number float64
if err := node.Decode(&number); err != nil || math.IsInf(number, 0) || math.IsNaN(number) {
result = unreadableScalar(node, start, end)
break
}
+ // A float slot keeps a fraction or an exponent, so that a whole
+ // number does not turn the slot into an integer.
result = adapter.Number(number, start, end, func(next float64) ([]byte, error) {
- return []byte(strconv.FormatFloat(next, 'g', -1, 64)), nil
+ text := strconv.FormatFloat(next, 'g', -1, 64)
+ if !strings.ContainsAny(text, ".eE") {
+ text += ".0"
+ }
+ return []byte(text), nil
})
case "!!null":
result = adapter.ReadOnly(start, end, "YAML null values are read-only")