Repository: TomWright/dasel (go)

Issue:
Go package: support file input
**Is your feature request related to a problem? Please describe.**
I'm looking into using [dasel package](https://daseldocs.tomwright.me/use-as-a-go-package) in my tool. As far as I can see you're supposed to unmarshal input file yourself and then use `rootNode := dasel.New(data)` to work with the data.

**Describe the solution you'd like**
Since `dasel` command already knows how to read all supported file types, it would be nice to have something like `dasel.NewFromFile(path, fileType)`.

**Describe alternatives you've considered**
Basically re-implementing everything in the [./internal](https://github.com/TomWright/dasel/blob/master/internal) folder.

**Additional context**
I need to modify various file formats in my tool and `dasel` fits the bill perfectly. With this addition it would be much easier to use `dasel` as a package.


Pull request description:
Add file read/write support to Go package
Add helper functions/receivers to make it easer to read/write files.

#### Functions

- `NewFromFile` function which accepts `filename` and `parser` strings.
- `NewFromReader` function which accepts `Reader` and `parser` strings.

#### Receivers

- `WriteFile` receiver which accepts `filename`, `parser` string and `compact` / `escapeHTML` options.
- `Write` receiver which accepts `Writer`, `parser` string and `compact` / `escapeHTML` options.

#### Misc

- Fix parser descriptions

Closes #187

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0c411f5..b4a6b57 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
-- Nothing yet.
+### Added
+
+- `Node.NewFromFile` func to load a root node from a file.
+- `Node.NewFromReader` func to load a root node from an `io.Reader`.
+- `Node.WriteToFile` func to write results to a file.
+- `Node.Write` func to write results to an `io.Writer`.
 
 ## [v1.23.0] - 2022-03-10
 
diff --git a/internal/storage/csv.go b/internal/storage/csv.go
index cc173c8..e957b3c 100644
--- a/internal/storage/csv.go
+++ b/internal/storage/csv.go
@@ -12,7 +12,7 @@ func init() {
 	registerWriteParser([]string{"csv"}, []string{".csv"}, &CSVParser{})
 }
 
-// CSVParser is a Parser implementation to handle yaml files.
+// CSVParser is a Parser implementation to handle csv files.
 type CSVParser struct {
 }
 
diff --git a/internal/storage/json.go b/internal/storage/json.go
index 6955f94..1a831cb 100644
--- a/internal/storage/json.go
+++ b/internal/storage/json.go
@@ -12,7 +12,7 @@ func init() {
 	registerWriteParser([]string{"json"}, []string{".json"}, &JSONParser{})
 }
 
-// JSONParser is a Parser implementation to handle yaml files.
+// JSONParser is a Parser implementation to handle json files.
 type JSONParser struct {
 }
 
diff --git a/internal/storage/plain.go b/internal/storage/plain.go
index 251d4f3..7c4a05f 100644
--- a/internal/storage/plain.go
+++ b/internal/storage/plain.go
@@ -9,7 +9,7 @@ func init() {
 	registerWriteParser([]string{"-", "plain"}, []string{}, &PlainParser{})
 }
 
-// PlainParser is a Parser implementation to handle yaml files.
+// PlainParser is a Parser implementation to handle plain files.
 type PlainParser struct {
 }
 
diff --git a/internal/storage/xml.go b/internal/storage/xml.go
index 5926cc7..7633f02 100644
--- a/internal/storage/xml.go
+++ b/internal/storage/xml.go
@@ -20,7 +20,7 @@ func init() {
 	registerWriteParser([]string{"xml"}, []string{".xml"}, &XMLParser{})
 }
 
-// XMLParser is a Parser implementation to handle yaml files.
+// XMLParser is a Parser implementation to handle xml files.
 type XMLParser struct {
 }
 
diff --git a/node.go b/node.go
index 6b9ace0..04110a5 100644
--- a/node.go
+++ b/node.go
@@ -2,9 +2,12 @@ package dasel
 
 import (
 	"fmt"
-	"github.com/tomwright/dasel/internal/storage"
+	"io"
+	"os"
 	"reflect"
 	"regexp"
+
+	"github.com/tomwright/dasel/internal/storage"
 )
 
 // Selector represents the selector for a node.
@@ -129,6 +132,70 @@ func New(value interface{}) *Node {
 	return rootNode
 }
 
+// NewFromFile returns a new root node by parsing file using specified read parser.
+func NewFromFile(filename, parser string) (*Node, error) {
+	readParser, err := storage.NewReadParserFromString(parser)
+	if err != nil {
+		return nil, err
+	}
+
+	data, err := storage.LoadFromFile(filename, readParser)
+	if err != nil {
+		return nil, err
+	}
+
+	return New(data), nil
+}
+
+// NewFromReader returns a new root node by parsing from Reader using specified read parser.
+func NewFromReader(reader io.Reader, parser string) (*Node, error) {
+	readParser, err := storage.NewReadParserFromString(parser)
+	if err != nil {
+		return nil, err
+	}
+
+	data, err := storage.Load(readParser, reader)
+	if err != nil {
+		return nil, err
+	}
+
+	return New(data), nil
+}
+
+// WriteToFile writes data to the given file with the specified options.
+func (n *Node) WriteToFile(filename, parser string, writeOptions []storage.ReadWriteOption) error {
+	f, err := os.Create(filename)
+
+	if err != nil {
+		return err
+	}
+
+	// https://www.joeshaw.org/dont-defer-close-on-writable-files/
+	if err = n.Write(f, parser, writeOptions); err != nil {
+		_ = f.Close()
+		return err
+	}
+
+	return f.Close()
+}
+
+// Write writes data to Writer using specified write parser and options.
+func (n *Node) Write(writer io.Writer, parser string, writeOptions []storage.ReadWriteOption) error {
+	writeParser, err := storage.NewWriteParserFromString(parser)
+	if err != nil {
+		return err
+	}
+
+	value := n.InterfaceValue()
+	originalValue := n.OriginalValue
+
+	if err := storage.Write(writeParser, value, originalValue, writer, writeOptions...); err != nil {
+		return err
+	}
+
+	return nil
+}
+
 func (n *Node) setValue(newValue interface{}) {
 	n.Value = reflect.ValueOf(newValue)
 	if n.Selector.Type == "ROOT" {


Test patch:
diff --git a/node_test.go b/node_test.go
index 1cd4d35..31e06cf 100644
--- a/node_test.go
+++ b/node_test.go
@@ -1,13 +1,16 @@
 package dasel_test
 
 import (
+	"bytes"
 	"encoding/json"
 	"errors"
 	"fmt"
-	"github.com/tomwright/dasel"
-	"github.com/tomwright/dasel/internal/storage"
 	"reflect"
+	"strings"
 	"testing"
+
+	"github.com/tomwright/dasel"
+	"github.com/tomwright/dasel/internal/storage"
 )
 
 // ExampleNode_ReadmeExample tests the code from the readme explanation.
@@ -1312,3 +1315,351 @@ func TestNode_DeleteMultiple_Query(t *testing.T) {
 	}), ".", []interface{}{}))
 	t.Run("RootNodeUnknown", deleteMultipleTest(dasel.New(false), ".", map[string]interface{}{}))
 }
+
+// TestNode_NewFromFile tests parsing from file.
+func TestNode_NewFromFile(t *testing.T) {
+	noSuchFileName := "no_such_file"
+	noSuchFileErrMsg := "could not open file: open " + noSuchFileName
+	unmarshalFailMsg := "could not unmarshal data"
+
+	tests := []struct {
+		name   string
+		file   string
+		parser string
+		err    error
+	}{
+		{
+			name:   "Existing JSON file and JSON Read parser specified",
+			file:   "./tests/assets/example.json",
+			parser: "json",
+			err:    nil,
+		},
+		{
+			name:   "Existing JSON file and YAML Read parser specified",
+			file:   "./tests/assets/example.json",
+			parser: "yaml",
+			// JSON is a subset of YAML v1.2
+			// https://stackoverflow.com/questions/21584985/what-valid-json-files-are-not-valid-yaml-1-1-files
+			err: nil,
+		},
+		{
+			name:   "Existing YAML file and YAML Read parser specified",
+			file:   "./tests/assets/example.yaml",
+			parser: "yaml",
+			err:    nil,
+		},
+		{
+			name:   "Existing YAML file and XML Read parser specified",
+			file:   "./tests/assets/example.yaml",
+			parser: "xml",
+			err:    errors.New(unmarshalFailMsg),
+		},
+		{
+			name:   "Existing XML file and XML Read parser specified",
+			file:   "./tests/assets/example.xml",
+			parser: "xml",
+			err:    nil,
+		},
+		{
+			name:   "Existing XML file and JSON Read parser specified",
+			file:   "./tests/assets/example.xml",
+			parser: "json",
+			err:    errors.New(unmarshalFailMsg),
+		},
+		{
+			name:   "Existing JSON file and invalid Read parser specified",
+			file:   "./tests/assets/example.json",
+			parser: "bad",
+			err:    storage.UnknownParserErr{Parser: "bad"},
+		},
+		{
+			name:   "File doesn't exist and JSON Read parser specified",
+			file:   noSuchFileName,
+			parser: "json",
+			err:    errors.New(noSuchFileErrMsg),
+		},
+		{
+			name:   "File doesn't exist and YAML Read parser specified",
+			file:   noSuchFileName,
+			parser: "yaml",
+			err:    errors.New(noSuchFileErrMsg),
+		},
+		{
+			name:   "File doesn't exist and YML Read parser specified",
+			file:   noSuchFileName,
+			parser: "yml",
+			err:    errors.New(noSuchFileErrMsg),
+		},
+		{
+			name:   "File doesn't exist and TOML Read parser specified",
+			file:   noSuchFileName,
+			parser: "toml",
+			err:    errors.New(noSuchFileErrMsg),
+		},
+		{
+			name:   "File doesn't exist and XML Read parser specified",
+			file:   noSuchFileName,
+			parser: "xml",
+			err:    errors.New(noSuchFileErrMsg),
+		},
+		{
+			name:   "File doesn't exist and CSV Read parser specified",
+			file:   noSuchFileName,
+			parser: "csv",
+			err:    errors.New(noSuchFileErrMsg),
+		},
+		{
+			name:   "File doesn't exist and invalid ('bad') Read parser specified",
+			file:   noSuchFileName,
+			parser: "bad",
+			err:    storage.UnknownParserErr{Parser: "bad"},
+		},
+		{
+			name:   "File doesn't exist and invalid ('-') Read parser specified",
+			file:   noSuchFileName,
+			parser: "-",
+			err:    storage.UnknownParserErr{Parser: "-"},
+		},
+	}
+
+	for _, testCase := range tests {
+		tc := testCase
+		t.Run(tc.name, func(t *testing.T) {
+			_, err := dasel.NewFromFile(tc.file, tc.parser)
+
+			if tc.err == nil && err != nil {
+				t.Errorf("expected err %v, got %v", tc.err, err)
+				return
+			}
+			if tc.err != nil && err == nil {
+				t.Errorf("expected err %v, got %v", tc.err, err)
+				return
+			}
+			if tc.err != nil && err != nil && !strings.Contains(err.Error(), tc.err.Error()) {
+				t.Errorf("expected err %v, got %v", tc.err, err)
+				return
+			}
+		})
+	}
+}
+
+// TestNode_Write tests writing to Writer.
+func TestNode_Write(t *testing.T) {
+	type testData = map[string]interface{}
+	type args struct {
+		parser     string
+		compact    bool
+		escapeHTML bool
+	}
+
+	commonData := testData{">": "&"}
+	xmlData := testData{
+		"key": testData{
+			"value": "<&>",
+		},
+	}
+	xmlEscapedData := testData{
+		"key": testData{
+			"value": "&lt;&amp;&gt;",
+		},
+	}
+
+	tests := []struct {
+		name       string
+		data       testData
+		args       args
+		wantWriter string
+		wantErr    bool
+	}{
+		{
+			name: "JSON, compact, no escape",
+			data: commonData,
+			args: args{
+				parser:     "json",
+				compact:    true,
+				escapeHTML: false,
+			},
+			wantWriter: "{\">\":\"&\"}\n",
+			wantErr:    false,
+		},
+		{
+			name: "JSON, pretty, escape",
+			data: commonData,
+			args: args{
+				parser:     "json",
+				compact:    false,
+				escapeHTML: true,
+			},
+			wantWriter: "{\n  \"\\u003e\": \"\\u0026\"\n}\n",
+			wantErr:    false,
+		},
+
+		{
+			name: "YAML, compact, no escape",
+			data: commonData,
+			args: args{
+				parser:     "yaml",
+				compact:    true,
+				escapeHTML: false,
+			},
+			wantWriter: "'>': '&'\n",
+			wantErr:    false,
+		},
+		{
+			name: "YAML, pretty, escape",
+			data: commonData,
+			args: args{
+				parser:     "yaml",
+				compact:    false,
+				escapeHTML: true,
+			},
+			wantWriter: "'>': '&'\n",
+			wantErr:    false,
+		},
+
+		{
+			name: "YML, compact, no escape",
+			data: commonData,
+			args: args{
+				parser:     "yml",
+				compact:    true,
+				escapeHTML: false,
+			},
+			wantWriter: "'>': '&'\n",
+			wantErr:    false,
+		},
+		{
+			name: "YML, pretty, escape",
+			data: commonData,
+			args: args{
+				parser:     "yml",
+				compact:    false,
+				escapeHTML: true,
+			},
+			wantWriter: "'>': '&'\n",
+			wantErr:    false,
+		},
+
+		{
+			name: "TOML, compact, no escape",
+			data: commonData,
+			args: args{
+				parser:     "toml",
+				compact:    true,
+				escapeHTML: false,
+			},
+			wantWriter: "\">\" = \"&\"\n",
+			wantErr:    false,
+		},
+		{
+			name: "TOML, pretty, escape",
+			data: commonData,
+			args: args{
+				parser:     "toml",
+				compact:    false,
+				escapeHTML: true,
+			},
+			wantWriter: "\">\" = \"&\"\n",
+			wantErr:    false,
+		},
+
+		{
+			name: "PLAIN, pretty, escape",
+			data: commonData,
+			args: args{
+				parser:     "plain",
+				compact:    false,
+				escapeHTML: true,
+			},
+			wantWriter: "map[>:&]\n",
+			wantErr:    false,
+		},
+		{
+			name: "PLAIN, pretty, no escape",
+			data: commonData,
+			args: args{
+				parser:     "plain",
+				compact:    false,
+				escapeHTML: false,
+			},
+			wantWriter: "map[>:&]\n",
+			wantErr:    false,
+		},
+
+		{
+			name: "-, pretty, escape",
+			data: commonData,
+			args: args{
+				parser:     "-",
+				compact:    false,
+				escapeHTML: true,
+			},
+			wantWriter: "map[>:&]\n",
+			wantErr:    false,
+		},
+		{
+			name: "-, pretty, no escape",
+			data: commonData,
+			args: args{
+				parser:     "-",
+				compact:    false,
+				escapeHTML: false,
+			},
+			wantWriter: "map[>:&]\n",
+			wantErr:    false,
+		},
+
+		{
+			name: "XML, pretty, no escape",
+			data: xmlData,
+			args: args{
+				parser:     "xml",
+				compact:    false,
+				escapeHTML: false,
+			},
+			// Invalid XML, but since we were asked not to escape,
+			// this is probably the best we should do
+			wantWriter: "<key>\n  <value><&></value>\n</key>\n",
+			wantErr:    false,
+		},
+		{
+			name: "Pre-escaped XML, pretty, no escape",
+			data: xmlEscapedData,
+			args: args{
+				parser:     "xml",
+				compact:    false,
+				escapeHTML: false,
+			},
+			wantWriter: "<key>\n  <value>&lt;&amp;&gt;</value>\n</key>\n",
+			wantErr:    false,
+		},
+
+		{
+			name: "Invalid Write parser: bad",
+			data: commonData,
+			args: args{
+				parser:     "bad",
+				compact:    false,
+				escapeHTML: false,
+			},
+			wantWriter: "",
+			wantErr:    true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			writer := &bytes.Buffer{}
+			node := dasel.New(tt.data)
+			if err := node.Write(writer, tt.args.parser, []storage.ReadWriteOption{
+				storage.EscapeHTMLOption(tt.args.escapeHTML),
+				storage.PrettyPrintOption(!tt.args.compact),
+			}); (err != nil) != tt.wantErr {
+				t.Errorf("Node.Write() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if gotWriter := writer.String(); gotWriter != tt.wantWriter {
+				t.Errorf("Node.Write() = %v want %v", gotWriter, tt.wantWriter)
+			}
+		})
+	}
+}