diff --git a/encoding/yaml/yaml.go b/encoding/yaml/yaml.go index ce2c08b1b..c0a5da319 100644 --- a/encoding/yaml/yaml.go +++ b/encoding/yaml/yaml.go @@ -96,6 +96,40 @@ func EncodeStream(iter cue.Iterator) ([]byte, error) { return buf.Bytes(), nil } +// NewDecoder configures a YAML decoder. The path is used to associate position +// information with each node. +// +// Use the Decoder's Extract method to extract YAML values one at a time. +// For YAML streams with multiple documents separated by `---`, each call to +// Extract will return the next document. +func NewDecoder(path string, src io.Reader) *Decoder { + b, err := source.ReadAll(path, src) + return &Decoder{ + path: path, + dec: cueyaml.NewDecoder(path, b), + readAllErr: err, + } +} + +// A Decoder converts YAML values to CUE. +type Decoder struct { + path string + dec cueyaml.Decoder + readAllErr error +} + +// Extract converts the current YAML value to a CUE ast. It returns io.EOF +// if the input has been exhausted. +// +// For YAML streams with multiple documents separated by `---`, each call to +// Extract will return the next document as a separate CUE expression. +func (d *Decoder) Extract() (ast.Expr, error) { + if d.readAllErr != nil { + return nil, d.readAllErr + } + return d.dec.Decode() +} + // Validate validates the YAML and confirms it matches the constraints // specified by v. For YAML streams, all values must match v. func Validate(b []byte, v cue.Value) error { diff --git a/encoding/yaml/yaml_test.go b/encoding/yaml/yaml_test.go index d6554ca15..6bbecc200 100644 --- a/encoding/yaml/yaml_test.go +++ b/encoding/yaml/yaml_test.go @@ -15,6 +15,7 @@ package yaml_test import ( + "io" "strings" "testing" @@ -146,6 +147,192 @@ null } } +func TestDecoder(t *testing.T) { + testCases := []struct { + name string + yaml string + want []string + wantErr bool + }{{ + name: "empty document", + yaml: ``, + want: []string{`*null | _`}, + }, { + name: "single struct", + yaml: `a: foo +b: bar`, + want: []string{`{ + a: "foo" + b: "bar" +}`}, + }, { + name: "single struct - inline", + yaml: `a: foo`, + want: []string{`{ + a: "foo" +}`}, + }, { + name: "single list", + yaml: `[1, 2, 3]`, + want: []string{`[1, 2, 3]`}, + }, { + name: "single object", + yaml: `{"key": "value"}`, + want: []string{`{ + key: "value" +}`}, + }, { + name: "single string", + yaml: `simple string`, + want: []string{`"simple string"`}, + }, { + name: "single number", + yaml: `42`, + want: []string{`42`}, + }, { + name: "single boolean", + yaml: `true`, + want: []string{`true`}, + }, { + name: "single null", + yaml: `null`, + want: []string{`null`}, + }, { + name: "multiple documents with separator", + yaml: `a: foo +--- +b: bar +c: baz`, + want: []string{ + `{ + a: "foo" +}`, + `{ + b: "bar" + c: "baz" +}`, + }, + }, { + name: "three documents", + yaml: `name: first +--- +name: second +--- +name: third`, + want: []string{ + `{ + name: "first" +}`, + `{ + + name: "second" +}`, + `{ + + name: "third" +}`, + }, + }, { + name: "documents with lists", + yaml: `- one +- two +--- +- three +- four`, + want: []string{ + `[ + "one", + "two", +]`, + `[ + "three", + "four", +]`, + }, + }, { + name: "document with null", + yaml: `--- +null +--- +a: value`, + want: []string{ + `null`, + `{ + + a: "value" +}`, + }, + }, { + name: "mixed types", + yaml: `string: text +number: 42 +bool: true +--- +list: + - item1 + - item2`, + want: []string{ + `{ + string: "text" + number: 42 + bool: true +}`, + `{ + list: [ + "item1", + "item2", + ] +}`, + }, + }} + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + d := yaml.NewDecoder(tc.name, strings.NewReader(tc.yaml)) + + var results []string + for { + expr, err := d.Extract() + if err == io.EOF { + break + } + if tc.wantErr { + if err == nil { + t.Fatal("expected error but got none") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + b, err := format.Node(expr) + if err != nil { + t.Fatalf("format error: %v", err) + } + results = append(results, strings.TrimSpace(string(b))) + } + + if len(results) != len(tc.want) { + t.Fatalf("got %d documents, want %d\nresults: %v\nwant: %v", + len(results), len(tc.want), results, tc.want) + } + + for i, got := range results { + if got != tc.want[i] { + t.Errorf("document %d:\ngot %q\nwant %q", i, got, tc.want[i]) + } + } + + // Verify that calling Extract again returns EOF + _, err := d.Extract() + if err != io.EOF { + t.Errorf("expected io.EOF on subsequent Extract, got %v", err) + } + }) + } +} + func TestYAMLValues(t *testing.T) { testCases := []struct { cue string