From 1acec4a25e468c1376440bb48286519f17e1ae45 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 26 Aug 2024 14:26:02 -0400 Subject: [PATCH] feat: adds cuetools package --- cuetools/README.md | 123 ++++++++++++++++ cuetools/go.mod | 14 ++ cuetools/go.sum | 49 +++++++ cuetools/pkg/load.go | 40 +++++ cuetools/pkg/load_test.go | 82 +++++++++++ cuetools/pkg/mutate.go | 143 ++++++++++++++++++ cuetools/pkg/mutate_test.go | 283 ++++++++++++++++++++++++++++++++++++ 7 files changed, 734 insertions(+) create mode 100644 cuetools/README.md create mode 100644 cuetools/go.mod create mode 100644 cuetools/go.sum create mode 100644 cuetools/pkg/load.go create mode 100644 cuetools/pkg/load_test.go create mode 100644 cuetools/pkg/mutate.go create mode 100644 cuetools/pkg/mutate_test.go diff --git a/cuetools/README.md b/cuetools/README.md new file mode 100644 index 00000000..f756eceb --- /dev/null +++ b/cuetools/README.md @@ -0,0 +1,123 @@ +# Cue Tools + +The `cuetools` package provides common utilities for interacting with the [CUE language](https://cuelang.org/). +The functions contained within this package are used across multiple packages within Catalyst Forge. +However, all functions are self-contained, and they may prove vaulable even outside the context of Catalyst Forge. + +## Loading and Validation + +The contents of a CUE file can be loaded with: + +```go +package pkg + +import ( + "fmt" + "log" + "os" + + "cuelang.org/go/cue/cuecontext" + cuetools "github.com/input-output-hk/catalyst-forge/cuetools/pkg" +) + +func main() { + b, err := os.ReadFile("file.cue") + if err != nil { + log.Fatal(err) + } + + ctx := cuecontext.New() + v, err := cuetools.Compile(ctx, b) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Value: %v\n", v) +} +``` + +The `Compile` function will only return an error when a syntax error is present in the given file. +It does not validate whether the file is logically valid (i.e., non-concrete values are acceptable). +To further validate the file: + +```go +func main() { + // ... + err = cuetools.Validate(v, cue.Concrete(true)) + if err != nil { + log.Fatal(err) + } +} +``` + +By default, the validation method provided by the CUE API will ellide error messages when more than one error exists. +The `Validate` method handles this by building a proper error string that includes all errors encountered while validating. +Each error is placed on a new line in order to improve readability. + +## Mutating Values + +By default, CUE is immutable and it's not possible to arbitrarily delete and/or replace fields within a CUE value. +This constraint exists at the language level and cannot be easily broken via the Go API. +While respecting language boundaries is often the best solution, in some cases it may be overwhelmingly apparent that a field needs +to be mutated and that it can be done safely. +For those cases, this package provides functions for both deleting and replacing arbitrary fields. + +To delete a field: + +```go +package main + +import ( + "fmt" + "log" + + "cuelang.org/go/cue/cuecontext" + cuetools "github.com/input-output-hk/catalyst-forge/cuetools/pkg" +) + +func main() { + ctx := cuecontext.New() + v := ctx.CompileString(`{a: 1, b: 2}`) + v, err := cuetools.Delete(ctx, v, "a") + if err != nil { + log.Fatalf("failed to delete field: %v", err) + } + + fmt.Println(v) // { b: 2 } +} +``` + +To replace a field with a new value: + +```go +func main() { + // ... + v = ctx.CompileString(`{a: 1, b: 2}`) + v, err := cuetools.Replace(ctx, v, "a", ctx.CompileString("3")) + if err != nil { + log.Fatalf("failed to delete field: %v", err) + } + + fmt.Println(v) // { a: 3, b: 2} +} +``` + +The `path` argument for both functions can be nested: + +``` +a.b.c +``` + +And can also index into lists: + +``` +a.b[0].c +``` + +## Testing + +Tests can be run with: + +``` +go test ./... +``` \ No newline at end of file diff --git a/cuetools/go.mod b/cuetools/go.mod new file mode 100644 index 00000000..7b25a38d --- /dev/null +++ b/cuetools/go.mod @@ -0,0 +1,14 @@ +module github.com/input-output-hk/catalyst-forge/cuetools + +go 1.22.3 + +require cuelang.org/go v0.10.0 + +require ( + github.com/cockroachdb/apd/v3 v3.2.1 // indirect + github.com/google/uuid v1.6.0 // indirect + golang.org/x/mod v0.20.0 // indirect + golang.org/x/net v0.28.0 // indirect + golang.org/x/text v0.17.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/cuetools/go.sum b/cuetools/go.sum new file mode 100644 index 00000000..1faf651f --- /dev/null +++ b/cuetools/go.sum @@ -0,0 +1,49 @@ +cuelabs.dev/go/oci/ociregistry v0.0.0-20240807094312-a32ad29eed79 h1:EceZITBGET3qHneD5xowSTY/YHbNybvMWGh62K2fG/M= +cuelabs.dev/go/oci/ociregistry v0.0.0-20240807094312-a32ad29eed79/go.mod h1:5A4xfTzHTXfeVJBU6RAUf+QrlfTCW+017q/QiW+sMLg= +cuelang.org/go v0.10.0 h1:Y1Pu4wwga5HkXfLFK1sWAYaSWIBdcsr5Cb5AWj2pOuE= +cuelang.org/go v0.10.0/go.mod h1:HzlaqqqInHNiqE6slTP6+UtxT9hN6DAzgJgdbNxXvX8= +github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg= +github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= +github.com/emicklei/proto v1.13.2 h1:z/etSFO3uyXeuEsVPzfl56WNgzcvIr42aQazXaQmFZY= +github.com/emicklei/proto v1.13.2/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw= +github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/protocolbuffers/txtpbfmt v0.0.0-20230328191034-3462fbc510c0 h1:sadMIsgmHpEOGbUs6VtHBXRR1OHevnj7hLx9ZcdNGW4= +github.com/protocolbuffers/txtpbfmt v0.0.0-20230328191034-3462fbc510c0/go.mod h1:jgxiZysxFPM+iWKwQwPR+y+Jvo54ARd4EisXxKYpB5c= +github.com/rogpeppe/go-internal v1.12.1-0.20240709150035-ccf4b4329d21 h1:igWZJluD8KtEtAgRyF4x6lqcxDry1ULztksMJh2mnQE= +github.com/rogpeppe/go-internal v1.12.1-0.20240709150035-ccf4b4329d21/go.mod h1:RMRJLmBOqWacUkmJHRMiPKh1S1m3PA7Zh4W80/kWPpg= +golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= +golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= +golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= +golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cuetools/pkg/load.go b/cuetools/pkg/load.go new file mode 100644 index 00000000..d607abeb --- /dev/null +++ b/cuetools/pkg/load.go @@ -0,0 +1,40 @@ +package pkg + +import ( + "fmt" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/errors" +) + +// Compile compiles the given CUE contents and returns the resulting value. +// If the contents are invalid, an error is returned. +func Compile(ctx *cue.Context, contents []byte) (cue.Value, error) { + v := ctx.CompileBytes(contents) + if v.Err() != nil { + return cue.Value{}, v.Err() + } + + return v, nil +} + +// Validate validates the given CUE value. If the value is invalid, an error +// is returned. +func Validate(c cue.Value, opts ...cue.Option) error { + if err := c.Validate(opts...); err != nil { + var errStr string + errs := errors.Errors(err) + + if len(errs) == 1 { + errStr = errs[0].Error() + } else { + errStr = "\n" + for _, e := range errs { + errStr += e.Error() + "\n" + } + } + return fmt.Errorf("failed to validate: %s", errStr) + } + + return nil +} diff --git a/cuetools/pkg/load_test.go b/cuetools/pkg/load_test.go new file mode 100644 index 00000000..71031eec --- /dev/null +++ b/cuetools/pkg/load_test.go @@ -0,0 +1,82 @@ +package pkg + +import ( + "fmt" + "testing" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/cuecontext" +) + +func TestCompile(t *testing.T) { + ctx := cuecontext.New() + tests := []struct { + name string + contents string + expectedVal cue.Value + expectedErr string + }{ + { + name: "valid contents", + contents: "{}", + expectedVal: ctx.CompileString("{}"), + expectedErr: "", + }, + { + name: "invalid contents", + contents: "{a: b}", + expectedVal: cue.Value{}, + expectedErr: "a: reference \"b\" not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v, err := Compile(ctx, []byte(tt.contents)) + if err != nil && err.Error() != tt.expectedErr { + t.Errorf("unexpected error: %v", err) + return + } else if err == nil && tt.expectedErr != "" { + t.Errorf("expected error %q but got nil", tt.expectedErr) + return + } else if err != nil && err.Error() == tt.expectedErr { + return + } + + if !v.Equals(tt.expectedVal) { + t.Errorf("expected value %v, got %v", tt.expectedVal, v) + } + }) + } +} + +func TestValidate(t *testing.T) { + ctx := cuecontext.New() + tests := []struct { + name string + v cue.Value + expectedErr string + }{ + { + name: "valid value", + v: ctx.CompileString("{}"), + expectedErr: "", + }, + { + name: "invalid value", + v: ctx.CompileString("{a: 1}").FillPath(cue.ParsePath("a"), fmt.Errorf("invalid value")), + expectedErr: "failed to validate: invalid value", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Validate(tt.v) + if err != nil && err.Error() != tt.expectedErr { + t.Errorf("unexpected error: %v", err) + } else if err == nil && tt.expectedErr != "" { + t.Errorf("expected error %q but got nil", tt.expectedErr) + } + }) + } +} diff --git a/cuetools/pkg/mutate.go b/cuetools/pkg/mutate.go new file mode 100644 index 00000000..753d81f6 --- /dev/null +++ b/cuetools/pkg/mutate.go @@ -0,0 +1,143 @@ +package pkg + +import ( + "fmt" + + "cuelang.org/go/cue" +) + +// delete deletes the field at the given path from the given value. +// The path must point to either a struct field or a list index. +func Delete(ctx *cue.Context, v cue.Value, path string) (cue.Value, error) { + // final holds the final value after the delete operation + var final cue.Value + + refPath := cue.ParsePath(path) + refSels := refPath.Selectors() + + // Make sure the target path exists + if !v.LookupPath(refPath).Exists() { + return v, fmt.Errorf("path %q does not exist", path) + } + + // Isolate the last selector in the target path, which is the field to delete + deletedSel, parentSels := refSels[len(refSels)-1], refSels[:len(refSels)-1] + parentPath := cue.MakePath(parentSels...) // Path to the parent of the field to delete + + var err error + final, err = deleteFrom(ctx, v.LookupPath(parentPath), deletedSel) + if err != nil { + return v, fmt.Errorf("failed to delete field: %v", err) + } + + // Replace the parent struct in the given value with the new struct that has the target field removed + final, err = Replace(ctx, v, parentPath.String(), final) + if err != nil { + return v, fmt.Errorf("failed to rebuild struct: %v", err) + } + + return final, nil +} + +// replace replaces the value at the given path with the given value. +// The path must point to either a struct field or a list index. +func Replace(ctx *cue.Context, v cue.Value, path string, replace cue.Value) (cue.Value, error) { + cpath := cue.ParsePath(path) + if !v.LookupPath(cpath).Exists() { + return v, fmt.Errorf("path %q does not exist", path) + } + + final := replace + sels := cpath.Selectors() + for len(sels) > 0 { + var lastSel cue.Selector + curIndex := len(sels) - 1 + lastSel, sels = sels[curIndex], sels[:curIndex] + + switch lastSel.Type() { + case cue.IndexLabel: + new := ctx.CompileString("[...]") + curList, err := v.LookupPath(cue.MakePath(sels...)).List() + if err != nil { + return cue.Value{}, fmt.Errorf("expected list at path %s: %v", path, err) + } + + for i := 0; curList.Next(); i++ { + var val cue.Value + if curList.Selector() == lastSel { + val = final + } else { + val = curList.Value() + } + + new = new.FillPath(cue.MakePath(cue.Index(i)), val) + } + + final = new + case cue.StringLabel: + new := ctx.CompileString("{}") + curFields, err := v.LookupPath(cue.MakePath(sels...)).Fields() + if err != nil { + return cue.Value{}, fmt.Errorf("expected struct at path %s: %v", path, err) + } + + for curFields.Next() { + fieldPath := cue.MakePath(curFields.Selector()) + if curFields.Selector() == lastSel { + new = new.FillPath(fieldPath, final) + } else { + new = new.FillPath(fieldPath, curFields.Value()) + } + } + + final = new + default: + return cue.Value{}, fmt.Errorf("unknown selector type %s", lastSel.Type()) + } + } + + return final, nil +} + +// deleteFrom deletes the field at the given selector from the given value. +// The value must be a struct or a list. +func deleteFrom(ctx *cue.Context, v cue.Value, targetSel cue.Selector) (cue.Value, error) { + switch targetSel.Type() { + case cue.IndexLabel: + new := ctx.CompileString("[...]") + list, err := v.List() + if err != nil { + return v, fmt.Errorf("expected list: %v", err) + } + + var i int + for list.Next() { + if list.Selector() == targetSel { + continue + } + + new = new.FillPath(cue.MakePath(cue.Index(i)), list.Value()) + i++ + } + + return new, nil + case cue.StringLabel: + new := ctx.CompileString("{}") + fields, err := v.Fields() + if err != nil { + return v, fmt.Errorf("expected struct: %v", err) + } + + for fields.Next() { + if fields.Selector() == targetSel { + continue + } + + new = new.FillPath(cue.MakePath(fields.Selector()), fields.Value()) + } + + return new, nil + default: + return v, fmt.Errorf("unsupported selector type %s", targetSel.Type().String()) + } +} diff --git a/cuetools/pkg/mutate_test.go b/cuetools/pkg/mutate_test.go new file mode 100644 index 00000000..fb56e173 --- /dev/null +++ b/cuetools/pkg/mutate_test.go @@ -0,0 +1,283 @@ +package pkg + +import ( + "fmt" + "testing" + + "cuelang.org/go/cue/cuecontext" + "cuelang.org/go/cue/format" +) + +func TestDelete(t *testing.T) { + tests := []struct { + name string + value string + path string + expected string + expectErr bool + }{ + { + name: "delete field", + value: ` +{ + a: 1 + b: 2 +}`, + path: "a", + expected: ` +{ + b: 2 +}`, + }, + { + name: "delete nested field", + value: ` +{ + a: { + b: 1 + c: 2 + } +}`, + path: "a.b", + expected: ` +{ + a: { + c: 2 + } +}`, + expectErr: false, + }, + { + name: "delete list element", + value: ` +{ + a: [1, 2, 3] +}`, + path: "a[1]", + expected: ` +{ + a: [1, ...] & [_, 3, ...] & { + [...] + } +}`, + expectErr: false, + }, + { + name: "delete nested list element", + value: ` +{ + a: [ + { + b: [1, 2, 3] + } + ] +}`, + path: "a[0].b[1]", + expected: ` +{ + a: [{ + b: [1, ...] & [_, 3, ...] & { + [...] + } + }, ...] & { + [...] + } +}`, + expectErr: false, + }, + { + name: "delete non-existent field", + value: ` +{ + a: 1 +}`, + path: "b", + expected: "", + expectErr: true, + }, + { + name: "delete non-existent index", + value: ` +{ + a: [1, 2, 3] +}`, + path: "a[3]", + expected: "", + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := cuecontext.New() + v, err := Compile(ctx, []byte(tt.value)) + if err != nil { + t.Fatalf("failed to compile value: %v", err) + } + + final, err := Delete(ctx, v, tt.path) + if err != nil { + if !tt.expectErr { + t.Fatalf("unexpected error: %v", err) + } + return + } else if tt.expectErr { + t.Fatalf("expected error, got none") + } + + src, err := format.Node(final.Syntax()) + if err != nil { + t.Fatalf("failed to format node: %v", err) + } + + if fmt.Sprintf("\n%s", src) != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, string(src)) + } + }) + } +} + +func TestReplace(t *testing.T) { + tests := []struct { + name string + value string + path string + replace string + expected string + expectErr bool + }{ + { + name: "replace field", + value: ` +{ + a: 1 + b: 2 +}`, + path: "a", + replace: "3", + expected: ` +{ + a: 3 + b: 2 +}`, + expectErr: false, + }, + { + name: "replace nested field", + value: ` +{ + a: { + b: 1 + c: 2 + } +}`, + path: "a.b", + replace: "3", + expected: ` +{ + a: { + b: 3 + c: 2 + } +}`, + expectErr: false, + }, + { + name: "replace list element", + value: ` +{ + a: [1, 2, 3] +}`, + path: "a[1]", + replace: "4", + expected: ` +{ + a: [1, ...] & [_, 4, ...] & [_, _, 3, ...] & { + [...] + } +}`, + expectErr: false, + }, + { + name: "replace nested list element", + value: ` +{ + a: [ + { + b: [1, 2, 3] + } + ] +}`, + path: "a[0].b[1]", + replace: "4", + expected: ` +{ + a: [{ + b: [1, ...] & [_, 4, ...] & [_, _, 3, ...] & { + [...] + } + }, ...] & { + [...] + } +}`, + expectErr: false, + }, + { + name: "replace non-existent field", + value: ` +{ + a: 1 +}`, + path: "b", + replace: "2", + expected: "", + expectErr: true, + }, + { + name: "replace non-existent index", + value: ` +{ + a: [1, 2, 3] +}`, + path: "a[3]", + replace: "4", + expected: "", + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := cuecontext.New() + v, err := Compile(ctx, []byte(tt.value)) + if err != nil { + t.Fatalf("failed to compile value: %v", err) + } + + replace, err := Compile(ctx, []byte(tt.replace)) + if err != nil { + t.Fatalf("failed to compile replace value: %v", err) + } + + final, err := Replace(ctx, v, tt.path, replace) + + if err != nil { + if !tt.expectErr { + t.Fatalf("unexpected error: %v", err) + } + return + } else if tt.expectErr { + t.Fatalf("expected error, got none") + } + + src, err := format.Node(final.Syntax()) + if err != nil { + t.Fatalf("failed to format node: %v", err) + } + + if fmt.Sprintf("\n%s", src) != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, string(src)) + } + }) + } +}