Skip to content

Commit

Permalink
Functionality to walk a config.Value tree (#1081)
Browse files Browse the repository at this point in the history
## Changes

This change adds:
* A `config.Walk` function to walk a configuration tree
* A `config.Path` type to represent a value's path inside a tree
* Functions to create a `config.Path` from a string, or convert one to a
string

## Tests

Additional unit tests with full coverage.
  • Loading branch information
pietern authored Dec 22, 2023
1 parent ac37a59 commit a1297d7
Show file tree
Hide file tree
Showing 6 changed files with 681 additions and 0 deletions.
96 changes: 96 additions & 0 deletions libs/config/path.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package config

import (
"bytes"
"fmt"
)

type pathComponent struct {
key string
index int
}

// Path represents a path to a value in a [Value] configuration tree.
type Path []pathComponent

// EmptyPath is the empty path.
// It is defined for convenience and clarity.
var EmptyPath = Path{}

// Key returns a path component for a key.
func Key(k string) pathComponent {
return pathComponent{key: k}
}

// Index returns a path component for an index.
func Index(i int) pathComponent {
return pathComponent{index: i}
}

// NewPath returns a new path from the given components.
// The individual components may be created with [Key] or [Index].
func NewPath(cs ...pathComponent) Path {
return cs
}

// Join joins the given paths.
func (p Path) Join(qs ...Path) Path {
for _, q := range qs {
p = p.Append(q...)
}
return p
}

// Append appends the given components to the path.
func (p Path) Append(cs ...pathComponent) Path {
return append(p, cs...)
}

// Equal returns true if the paths are equal.
func (p Path) Equal(q Path) bool {
pl := len(p)
ql := len(q)
if pl != ql {
return false
}
for i := 0; i < pl; i++ {
if p[i] != q[i] {
return false
}
}
return true
}

// HasPrefix returns true if the path has the specified prefix.
// The empty path is a prefix of all paths.
func (p Path) HasPrefix(q Path) bool {
pl := len(p)
ql := len(q)
if pl < ql {
return false
}
for i := 0; i < ql; i++ {
if p[i] != q[i] {
return false
}
}
return true
}

// String returns a string representation of the path.
func (p Path) String() string {
var buf bytes.Buffer

for i, c := range p {
if i > 0 && c.key != "" {
buf.WriteRune('.')
}
if c.key != "" {
buf.WriteString(c.key)
} else {
buf.WriteString(fmt.Sprintf("[%d]", c.index))
}
}

return buf.String()
}
89 changes: 89 additions & 0 deletions libs/config/path_string.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package config

import (
"fmt"
"strconv"
"strings"
)

// MustPathFromString is like NewPathFromString but panics on error.
func MustPathFromString(input string) Path {
p, err := NewPathFromString(input)
if err != nil {
panic(err)
}
return p
}

// NewPathFromString parses a path from a string.
//
// The string must be a sequence of keys and indices separated by dots.
// Indices must be enclosed in square brackets.
// The string may include a leading dot.
//
// Examples:
// - foo.bar
// - foo[1].bar
// - foo.bar[1]
// - foo.bar[1][2]
// - .
func NewPathFromString(input string) (Path, error) {
var path Path

p := input

// Trim leading dot.
if p != "" && p[0] == '.' {
p = p[1:]
}

for p != "" {
// Every component may have a leading dot.
if p != "" && p[0] == '.' {
p = p[1:]
}

if p == "" {
return nil, fmt.Errorf("invalid path: %s", input)
}

if p[0] == '[' {
// Find next ]
i := strings.Index(p, "]")
if i < 0 {
return nil, fmt.Errorf("invalid path: %s", input)
}

// Parse index
j, err := strconv.Atoi(p[1:i])
if err != nil {
return nil, fmt.Errorf("invalid path: %s", input)
}

// Append index
path = append(path, Index(j))
p = p[i+1:]

// The next character must be a . or [
if p != "" && strings.IndexAny(p, ".[") != 0 {
return nil, fmt.Errorf("invalid path: %s", input)
}
} else {
// Find next . or [
i := strings.IndexAny(p, ".[")
if i < 0 {
i = len(p)
}

if i == 0 {
return nil, fmt.Errorf("invalid path: %s", input)
}

// Append key
path = append(path, Key(p[:i]))
p = p[i:]
}
}

return path, nil
}
100 changes: 100 additions & 0 deletions libs/config/path_string_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package config_test

import (
"fmt"
"testing"

. "github.com/databricks/cli/libs/config"
"github.com/stretchr/testify/assert"
)

func TestNewPathFromString(t *testing.T) {
for _, tc := range []struct {
input string
output Path
err error
}{
{
input: "",
output: NewPath(),
},
{
input: ".",
output: NewPath(),
},
{
input: "foo.bar",
output: NewPath(Key("foo"), Key("bar")),
},
{
input: "[1]",
output: NewPath(Index(1)),
},
{
input: "foo[1].bar",
output: NewPath(Key("foo"), Index(1), Key("bar")),
},
{
input: "foo.bar[1]",
output: NewPath(Key("foo"), Key("bar"), Index(1)),
},
{
input: "foo.bar[1][2]",
output: NewPath(Key("foo"), Key("bar"), Index(1), Index(2)),
},
{
input: "foo.bar[1][2][3]",
output: NewPath(Key("foo"), Key("bar"), Index(1), Index(2), Index(3)),
},
{
input: "foo[1234]",
output: NewPath(Key("foo"), Index(1234)),
},
{
input: "foo[123",
err: fmt.Errorf("invalid path: foo[123"),
},
{
input: "foo[123]]",
err: fmt.Errorf("invalid path: foo[123]]"),
},
{
input: "foo[[123]",
err: fmt.Errorf("invalid path: foo[[123]"),
},
{
input: "foo[[123]]",
err: fmt.Errorf("invalid path: foo[[123]]"),
},
{
input: "foo[foo]",
err: fmt.Errorf("invalid path: foo[foo]"),
},
{
input: "foo..bar",
err: fmt.Errorf("invalid path: foo..bar"),
},
{
input: "foo.bar.",
err: fmt.Errorf("invalid path: foo.bar."),
},
{
// Every component may have a leading dot.
input: ".foo.[1].bar",
output: NewPath(Key("foo"), Index(1), Key("bar")),
},
{
// But after an index there must be a dot.
input: "foo[1]bar",
err: fmt.Errorf("invalid path: foo[1]bar"),
},
} {
p, err := NewPathFromString(tc.input)
if tc.err != nil {
assert.EqualError(t, err, tc.err.Error(), tc.input)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.output, p)
}
}
}
76 changes: 76 additions & 0 deletions libs/config/path_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package config_test

import (
"testing"

"github.com/databricks/cli/libs/config"
"github.com/stretchr/testify/assert"
)

func TestPathAppend(t *testing.T) {
p := config.NewPath(config.Key("foo"))

// Single arg.
p1 := p.Append(config.Key("bar"))
assert.True(t, p1.Equal(config.NewPath(config.Key("foo"), config.Key("bar"))))

// Multiple args.
p2 := p.Append(config.Key("bar"), config.Index(1))
assert.True(t, p2.Equal(config.NewPath(config.Key("foo"), config.Key("bar"), config.Index(1))))
}

func TestPathJoin(t *testing.T) {
p := config.NewPath(config.Key("foo"))

// Single arg.
p1 := p.Join(config.NewPath(config.Key("bar")))
assert.True(t, p1.Equal(config.NewPath(config.Key("foo"), config.Key("bar"))))

// Multiple args.
p2 := p.Join(config.NewPath(config.Key("bar")), config.NewPath(config.Index(1)))
assert.True(t, p2.Equal(config.NewPath(config.Key("foo"), config.Key("bar"), config.Index(1))))
}

func TestPathEqualEmpty(t *testing.T) {
assert.True(t, config.EmptyPath.Equal(config.EmptyPath))
}

func TestPathEqual(t *testing.T) {
p1 := config.NewPath(config.Key("foo"), config.Index(1))
p2 := config.NewPath(config.Key("bar"), config.Index(2))
assert.False(t, p1.Equal(p2), "expected %q to not equal %q", p1, p2)

p3 := config.NewPath(config.Key("foo"), config.Index(1))
assert.True(t, p1.Equal(p3), "expected %q to equal %q", p1, p3)

p4 := config.NewPath(config.Key("foo"), config.Index(1), config.Key("bar"), config.Index(2))
assert.False(t, p1.Equal(p4), "expected %q to not equal %q", p1, p4)
}

func TestPathHasPrefixEmpty(t *testing.T) {
empty := config.EmptyPath
nonEmpty := config.NewPath(config.Key("foo"))
assert.True(t, empty.HasPrefix(empty))
assert.True(t, nonEmpty.HasPrefix(empty))
assert.False(t, empty.HasPrefix(nonEmpty))
}

func TestPathHasPrefix(t *testing.T) {
p1 := config.NewPath(config.Key("foo"), config.Index(1))
p2 := config.NewPath(config.Key("bar"), config.Index(2))
assert.False(t, p1.HasPrefix(p2), "expected %q to not have prefix %q", p1, p2)

p3 := config.NewPath(config.Key("foo"))
assert.True(t, p1.HasPrefix(p3), "expected %q to have prefix %q", p1, p3)
}

func TestPathString(t *testing.T) {
p1 := config.NewPath(config.Key("foo"), config.Index(1))
assert.Equal(t, "foo[1]", p1.String())

p2 := config.NewPath(config.Key("bar"), config.Index(2), config.Key("baz"))
assert.Equal(t, "bar[2].baz", p2.String())

p3 := config.NewPath(config.Key("foo"), config.Index(1), config.Key("bar"), config.Index(2), config.Key("baz"))
assert.Equal(t, "foo[1].bar[2].baz", p3.String())
}
Loading

0 comments on commit a1297d7

Please sign in to comment.