Repository: goccy/go-yaml
Pull request #251 (2021-09-02 12:51:44)

Allow reserved characters for key name in YAMLPath
resolve https://github.com/goccy/go-yaml/issues/240

If you want to use reserved characters such as `.` and `*` as a key name,
enclose them in single quotation as follows ( $.foo.'bar.baz-*'.hoge ).
If you want to use a single quote with reserved characters, escape it with `\` ( $.foo.'bar.baz\'s value'.hoge ).



Patch:
diff --git a/path.go b/path.go
index 025a5e2..b83f31b 100644
--- a/path.go
+++ b/path.go
@@ -20,6 +20,10 @@ import (
 // ..    : recursive descent
 // [num] : object/element of array by number
 // [*]   : all objects/elements for array.
+//
+// If you want to use reserved characters such as `.` and `*` as a key name,
+// enclose them in single quotation as follows ( $.foo.'bar.baz-*'.hoge ).
+// If you want to use a single quote with reserved characters, escape it with `\` ( $.foo.'bar.baz\'s value'.hoge ).
 func PathString(s string) (*Path, error) {
 	buf := []rune(s)
 	length := len(buf)
@@ -32,17 +36,19 @@ func PathString(s string) (*Path, error) {
 			builder = builder.Root()
 			cursor++
 		case '.':
-			b, c, err := parsePathDot(builder, buf, cursor)
+			b, buf, c, err := parsePathDot(builder, buf, cursor)
 			if err != nil {
 				return nil, errors.Wrapf(err, "failed to parse path of dot")
 			}
+			length = len(buf)
 			builder = b
 			cursor = c
 		case '[':
-			b, c, err := parsePathIndex(builder, buf, cursor)
+			b, buf, c, err := parsePathIndex(builder, buf, cursor)
 			if err != nil {
 				return nil, errors.Wrapf(err, "failed to parse path of index")
 			}
+			length = len(buf)
 			builder = b
 			cursor = c
 		default:
@@ -52,7 +58,7 @@ func PathString(s string) (*Path, error) {
 	return builder.Build(), nil
 }
 
-func parsePathRecursive(b *PathBuilder, buf []rune, cursor int) (*PathBuilder, int, error) {
+func parsePathRecursive(b *PathBuilder, buf []rune, cursor int) (*PathBuilder, []rune, int, error) {
 	length := len(buf)
 	cursor += 2 // skip .. characters
 	start := cursor
@@ -60,58 +66,100 @@ func parsePathRecursive(b *PathBuilder, buf []rune, cursor int) (*PathBuilder, i
 		c := buf[cursor]
 		switch c {
 		case '$':
-			return nil, 0, errors.Wrapf(ErrInvalidPathString, "specified '$' after '..' character")
+			return nil, nil, 0, errors.Wrapf(ErrInvalidPathString, "specified '$' after '..' character")
 		case '*':
-			return nil, 0, errors.Wrapf(ErrInvalidPathString, "specified '*' after '..' character")
+			return nil, nil, 0, errors.Wrapf(ErrInvalidPathString, "specified '*' after '..' character")
 		case '.', '[':
 			goto end
 		case ']':
-			return nil, 0, errors.Wrapf(ErrInvalidPathString, "specified ']' after '..' character")
+			return nil, nil, 0, errors.Wrapf(ErrInvalidPathString, "specified ']' after '..' character")
 		}
 	}
 end:
 	if start == cursor {
-		return nil, 0, errors.Wrapf(ErrInvalidPathString, "not found recursive selector")
+		return nil, nil, 0, errors.Wrapf(ErrInvalidPathString, "not found recursive selector")
 	}
-	return b.Recursive(string(buf[start:cursor])), cursor, nil
+	return b.Recursive(string(buf[start:cursor])), buf, cursor, nil
 }
 
-func parsePathDot(b *PathBuilder, buf []rune, cursor int) (*PathBuilder, int, error) {
+func parsePathDot(b *PathBuilder, buf []rune, cursor int) (*PathBuilder, []rune, int, error) {
 	length := len(buf)
 	if cursor+1 < length && buf[cursor+1] == '.' {
-		b, c, err := parsePathRecursive(b, buf, cursor)
+		b, buf, c, err := parsePathRecursive(b, buf, cursor)
 		if err != nil {
-			return nil, 0, errors.Wrapf(err, "failed to parse path of recursive")
+			return nil, nil, 0, errors.Wrapf(err, "failed to parse path of recursive")
 		}
-		return b, c, nil
+		return b, buf, c, nil
 	}
 	cursor++ // skip . character
 	start := cursor
+
+	// if started single quote, looking for end single quote char
+	if cursor < length && buf[cursor] == '\'' {
+		return parseQuotedKey(b, buf, cursor)
+	}
 	for ; cursor < length; cursor++ {
 		c := buf[cursor]
 		switch c {
 		case '$':
-			return nil, 0, errors.Wrapf(ErrInvalidPathString, "specified '$' after '.' character")
+			return nil, nil, 0, errors.Wrapf(ErrInvalidPathString, "specified '$' after '.' character")
 		case '*':
-			return nil, 0, errors.Wrapf(ErrInvalidPathString, "specified '*' after '.' character")
+			return nil, nil, 0, errors.Wrapf(ErrInvalidPathString, "specified '*' after '.' character")
 		case '.', '[':
 			goto end
 		case ']':
-			return nil, 0, errors.Wrapf(ErrInvalidPathString, "specified ']' after '.' character")
+			return nil, nil, 0, errors.Wrapf(ErrInvalidPathString, "specified ']' after '.' character")
+		}
+	}
+end:
+	if start == cursor {
+		return nil, nil, 0, errors.Wrapf(ErrInvalidPathString, "cloud not find by empty key")
+	}
+	return b.Child(string(buf[start:cursor])), buf, cursor, nil
+}
+
+func parseQuotedKey(b *PathBuilder, buf []rune, cursor int) (*PathBuilder, []rune, int, error) {
+	cursor++ // skip single quote
+	start := cursor
+	length := len(buf)
+	var foundEndDelim bool
+	for ; cursor < length; cursor++ {
+		switch buf[cursor] {
+		case '\\':
+			buf = append(append([]rune{}, buf[:cursor]...), buf[cursor+1:]...)
+			length = len(buf)
+		case '\'':
+			foundEndDelim = true
+			goto end
 		}
 	}
 end:
+	if !foundEndDelim {
+		return nil, nil, 0, errors.Wrapf(ErrInvalidPathString, "could not find end delimiter for key")
+	}
 	if start == cursor {
-		return nil, 0, errors.Wrapf(ErrInvalidPathString, "not found child selector")
+		return nil, nil, 0, errors.Wrapf(ErrInvalidPathString, "could not find by empty key")
+	}
+	selector := buf[start:cursor]
+	cursor++
+	if cursor < length {
+		switch buf[cursor] {
+		case '$':
+			return nil, nil, 0, errors.Wrapf(ErrInvalidPathString, "specified '$' after '.' character")
+		case '*':
+			return nil, nil, 0, errors.Wrapf(ErrInvalidPathString, "specified '*' after '.' character")
+		case ']':
+			return nil, nil, 0, errors.Wrapf(ErrInvalidPathString, "specified ']' after '.' character")
+		}
 	}
-	return b.Child(string(buf[start:cursor])), cursor, nil
+	return b.Child(string(selector)), buf, cursor, nil
 }
 
-func parsePathIndex(b *PathBuilder, buf []rune, cursor int) (*PathBuilder, int, error) {
+func parsePathIndex(b *PathBuilder, buf []rune, cursor int) (*PathBuilder, []rune, int, error) {
 	length := len(buf)
 	cursor++ // skip '[' character
 	if length <= cursor {
-		return nil, 0, errors.Wrapf(ErrInvalidPathString, "unexpected end of YAML Path")
+		return nil, nil, 0, errors.Wrapf(ErrInvalidPathString, "unexpected end of YAML Path")
 	}
 	c := buf[cursor]
 	switch c {
@@ -127,19 +175,19 @@ func parsePathIndex(b *PathBuilder, buf []rune, cursor int) (*PathBuilder, int,
 			break
 		}
 		if buf[cursor] != ']' {
-			return nil, 0, errors.Wrapf(ErrInvalidPathString, "invalid character %s at %d", string(buf[cursor]), cursor)
+			return nil, nil, 0, errors.Wrapf(ErrInvalidPathString, "invalid character %s at %d", string(buf[cursor]), cursor)
 		}
 		numOrAll := string(buf[start:cursor])
 		if numOrAll == "*" {
-			return b.IndexAll(), cursor + 1, nil
+			return b.IndexAll(), buf, cursor + 1, nil
 		}
 		num, err := strconv.ParseInt(numOrAll, 10, 64)
 		if err != nil {
-			return nil, 0, errors.Wrapf(err, "failed to parse number")
+			return nil, nil, 0, errors.Wrapf(err, "failed to parse number")
 		}
-		return b.Index(uint(num)), cursor + 1, nil
+		return b.Index(uint(num)), buf, cursor + 1, nil
 	}
-	return nil, 0, errors.Wrapf(ErrInvalidPathString, "invalid character %s at %d", c, cursor)
+	return nil, nil, 0, errors.Wrapf(ErrInvalidPathString, "invalid character %s at %d", c, cursor)
 }
 
 // Path represent YAMLPath ( like a JSONPath ).


Test patch:
diff --git a/path_test.go b/path_test.go
index d1d398c..d99e9c4 100644
--- a/path_test.go
+++ b/path_test.go
@@ -115,6 +115,82 @@ store:
 	})
 }
 
+func TestPath_ReservedKeyword(t *testing.T) {
+	tests := []struct {
+		name     string
+		path     string
+		src      string
+		expected interface{}
+		failure  bool
+	}{
+		{
+			name: "quoted path",
+			path: `$.'a.b.c'.foo`,
+			src: `
+a.b.c:
+  foo: bar
+`,
+			expected: "bar",
+		},
+		{
+			name:     "contains quote key",
+			path:     `$.a'b`,
+			src:      `a'b: 10`,
+			expected: uint64(10),
+		},
+		{
+			name:     "escaped quote",
+			path:     `$.'alice\'s age'`,
+			src:      `alice's age: 10`,
+			expected: uint64(10),
+		},
+		{
+			name:     "directly use white space",
+			path:     `$.a  b`,
+			src:      `a  b: 10`,
+			expected: uint64(10),
+		},
+		{
+			name:    "empty quoted key",
+			path:    `$.''`,
+			src:     `a: 10`,
+			failure: true,
+		},
+		{
+			name:    "unterminated quote",
+			path:    `$.'abcd`,
+			src:     `abcd: 10`,
+			failure: true,
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			path, err := yaml.PathString(test.path)
+			if test.failure {
+				if err == nil {
+					t.Fatal("expected error")
+				}
+				return
+			} else {
+				if err != nil {
+					t.Fatalf("%+v", err)
+				}
+			}
+			file, err := parser.ParseBytes([]byte(test.src), 0)
+			if err != nil {
+				t.Fatal(err)
+			}
+			var v interface{}
+			if err := path.Read(file, &v); err != nil {
+				t.Fatalf("%+v", err)
+			}
+			if v != test.expected {
+				t.Fatalf("failed to get value. expected:[%v] but got:[%v]", test.expected, v)
+			}
+		})
+	}
+}
+
 func TestPath_Invalid(t *testing.T) {
 	tests := []struct {
 		path string