201 lines
5.1 KiB
Go
201 lines
5.1 KiB
Go
package engine
|
|
|
|
import (
|
|
"errors"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Path identifies a configuration value using YAML field names. Its textual form
|
|
// is a JSON Pointer: the root is empty, ~ escapes as ~0, and / escapes as ~1.
|
|
// Paths are semantic rather than filesystem or URL paths: empty, dot, dot-dot,
|
|
// percent-containing, and numeric keys retain their literal meaning. Parsed
|
|
// numeric tokens are resolved only when traversal knows their containing type.
|
|
//
|
|
// Paths are immutable. The zero value is the root path.
|
|
type Path struct {
|
|
segments []pathSegment
|
|
invalid bool
|
|
}
|
|
|
|
type pathSegmentKind uint8
|
|
|
|
const (
|
|
pathSegmentKey pathSegmentKind = iota
|
|
pathSegmentIndex
|
|
pathSegmentParsed
|
|
)
|
|
|
|
type pathSegment struct {
|
|
kind pathSegmentKind
|
|
key string
|
|
index int
|
|
}
|
|
|
|
// Keys constructs a path of mapping keys. Keys with no arguments returns the
|
|
// root path.
|
|
func Keys(keys ...string) Path {
|
|
p := Path{}
|
|
for _, key := range keys {
|
|
p = p.Key(key)
|
|
}
|
|
return p
|
|
}
|
|
|
|
// ParsePath parses a JSON Pointer. Numeric tokens remain unresolved until schema
|
|
// or document traversal determines whether the containing value is a mapping or
|
|
// sequence.
|
|
func ParsePath(pointer string) (Path, error) {
|
|
if pointer == "" {
|
|
return Path{}, nil
|
|
}
|
|
if pointer[0] != '/' {
|
|
return Path{}, errors.New("configkit: path must be empty or begin with slash")
|
|
}
|
|
|
|
parts := strings.Split(pointer[1:], "/")
|
|
segments := make([]pathSegment, 0, len(parts))
|
|
for _, part := range parts {
|
|
key, ok := unescapePathToken(part)
|
|
if !ok {
|
|
return Path{}, errors.New("configkit: path contains an invalid tilde escape")
|
|
}
|
|
segments = append(segments, pathSegment{kind: pathSegmentParsed, key: key})
|
|
}
|
|
return Path{segments: segments}, nil
|
|
}
|
|
|
|
// Key returns a new path with key appended as a mapping-key segment.
|
|
func (p Path) Key(key string) Path {
|
|
segments := clonePathSegments(p.segments, 1)
|
|
segments = append(segments, pathSegment{kind: pathSegmentKey, key: key})
|
|
return Path{segments: segments, invalid: p.invalid}
|
|
}
|
|
|
|
// Index returns a new path with index appended as a sequence-index segment.
|
|
// A negative index produces an invalid path that configuration operations reject.
|
|
func (p Path) Index(index int) Path {
|
|
segments := clonePathSegments(p.segments, 1)
|
|
segments = append(segments, pathSegment{kind: pathSegmentIndex, index: index})
|
|
return Path{segments: segments, invalid: p.invalid || index < 0}
|
|
}
|
|
|
|
// String returns the path's canonical JSON Pointer spelling.
|
|
func (p Path) String() string {
|
|
if len(p.segments) == 0 {
|
|
return ""
|
|
}
|
|
|
|
// Include each separator and the extra byte introduced by escaping a token.
|
|
// Use stack storage for sizing indexes without allocating another string.
|
|
size := len(p.segments)
|
|
var indexText [20]byte
|
|
for _, segment := range p.segments {
|
|
if segment.kind == pathSegmentIndex {
|
|
size += len(strconv.AppendInt(indexText[:0], int64(segment.index), 10))
|
|
} else {
|
|
size += len(segment.key) + strings.Count(segment.key, "~") + strings.Count(segment.key, "/")
|
|
}
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.Grow(size)
|
|
for _, segment := range p.segments {
|
|
b.WriteByte('/')
|
|
if segment.kind == pathSegmentIndex {
|
|
b.WriteString(strconv.Itoa(segment.index))
|
|
continue
|
|
}
|
|
b.WriteString(escapePathToken(segment.key))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func (p Path) clone() Path {
|
|
return Path{segments: clonePathSegments(p.segments, 0), invalid: p.invalid}
|
|
}
|
|
|
|
func (p Path) valid() bool {
|
|
return !p.invalid
|
|
}
|
|
|
|
func clonePathSegments(segments []pathSegment, extra int) []pathSegment {
|
|
if len(segments) == 0 && extra == 0 {
|
|
return nil
|
|
}
|
|
cloned := make([]pathSegment, len(segments), len(segments)+extra)
|
|
copy(cloned, segments)
|
|
return cloned
|
|
}
|
|
|
|
func pathTokenSpellingEqual(left, right pathSegment) bool {
|
|
switch {
|
|
case left.kind == pathSegmentIndex && right.kind == pathSegmentIndex:
|
|
return left.index == right.index
|
|
case left.kind == pathSegmentIndex:
|
|
return pathIndexSpellingEqual(left.index, right.key)
|
|
case right.kind == pathSegmentIndex:
|
|
return pathIndexSpellingEqual(right.index, left.key)
|
|
default:
|
|
return left.key == right.key
|
|
}
|
|
}
|
|
|
|
func pathIndexSpellingEqual(index int, token string) bool {
|
|
var scratch [20]byte
|
|
spelling := strconv.AppendInt(scratch[:0], int64(index), 10)
|
|
if len(spelling) != len(token) {
|
|
return false
|
|
}
|
|
for i, character := range spelling {
|
|
if character != token[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func pathSpellingEqual(left, right Path) bool {
|
|
if len(left.segments) != len(right.segments) {
|
|
return false
|
|
}
|
|
for i, segment := range left.segments {
|
|
if !pathTokenSpellingEqual(segment, right.segments[i]) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func escapePathToken(token string) string {
|
|
token = strings.ReplaceAll(token, "~", "~0")
|
|
return strings.ReplaceAll(token, "/", "~1")
|
|
}
|
|
|
|
func unescapePathToken(token string) (string, bool) {
|
|
if !strings.Contains(token, "~") {
|
|
return token, true
|
|
}
|
|
|
|
var b strings.Builder
|
|
for i := 0; i < len(token); i++ {
|
|
if token[i] != '~' {
|
|
b.WriteByte(token[i])
|
|
continue
|
|
}
|
|
if i+1 >= len(token) {
|
|
return "", false
|
|
}
|
|
i++
|
|
switch token[i] {
|
|
case '0':
|
|
b.WriteByte('~')
|
|
case '1':
|
|
b.WriteByte('/')
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
return b.String(), true
|
|
}
|