diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3bcf297..cb70427 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ jobs: strategy: matrix: os: [[🐧, Ubuntu], [🍎, macOS], [🪟, Windows]] - go: ["1.23", "1.22"] + go: ["1.23"] name: ${{ matrix.os[0] }} Test Go ${{ matrix.go }} on ${{ matrix.os[1] }} runs-on: ${{ matrix.os[1] }}-latest steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index f7d984b..e334014 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,18 +7,33 @@ All notable changes to this project will be documented in this file. It uses the [Semantic Versioning]: https://semver.org/spec/v2.0.0.html "Semantic Versioning 2.0.0" -## [v0.3.0 ] — Unreleased +## [v0.3.0] — Unreleased ### ⚡ Improvements -* Added `SelectLocated`. It works just like `Select`, but returns a slice of +* Added `SelectLocated`. It works just like `Select`, but returns `LocatedNode`s that pair the selected nodes with [RFC 9535-defined] `NormalizedPath`s that uniquely identify their locations within the JSON query argument. +* Added `LocatedNodeList`, the return value from `SelectLocated`. It + contains methods for working with the selected nodes, including iterators + for its nodes & `NormalizedPath`s, deduplication, sorting, and cloning. +* Added `Compare` to `NormalizedPath`, which enables the sorting of + `LocatedNodeList`s. + +### 📔 Notes + +* Requires Go 1.23 to take advantage of its iterator support. +* Changed the return value of `Select` from `[]any` to `NodeList`, which is + an alias for `[]any`. Done to pair with `LocatedNodeList`, the return + value of `SelectLocated`. Features an `All` method, which returns an + iterator over all the nodes in the list. It may gain additional methods in + the future. ### 📚 Documentation -* Added `Select` and `SelectLocated` examples to the Go docs. +* Added `Select`, `SelectLocated`, `NodeList`, and `LocatedNodeList` + examples to the Go docs. [v0.3.0]: https://github.com/theory/jsonpath/compare/v0.2.1...v0.3.0 [RFC 9535-defined]: https://www.rfc-editor.org/rfc/rfc9535#section-2.7 diff --git a/go.mod b/go.mod index c1907e5..f6a5912 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/theory/jsonpath -go 1.22 +go 1.23 require github.com/stretchr/testify v1.10.0 diff --git a/path.go b/path.go index 4ff5980..4f573d9 100644 --- a/path.go +++ b/path.go @@ -2,6 +2,9 @@ package jsonpath import ( + "iter" + "slices" + "github.com/theory/jsonpath/parser" "github.com/theory/jsonpath/registry" "github.com/theory/jsonpath/spec" @@ -45,7 +48,7 @@ func (p *Path) Query() *spec.PathQuery { } // Select returns the values that JSONPath query p selects from input. -func (p *Path) Select(input any) []any { +func (p *Path) Select(input any) NodeList { return p.q.Select(nil, input) } @@ -55,7 +58,7 @@ func (p *Path) Select(input any) []any { // normalized path for each value, you probably want to use [Path.Select]. // // [normalized paths]: https://www.rfc-editor.org/rfc/rfc9535#section-2.7 -func (p *Path) SelectLocated(input any) []*spec.LocatedNode { +func (p *Path) SelectLocated(input any) LocatedNodeList { return p.q.SelectLocated(nil, input, spec.NormalizedPath{}) } @@ -108,3 +111,95 @@ func (c *Parser) MustParse(path string) *Path { } return New(q) } + +// NodeList is a list of nodes selected by a JSONPath query. Each node +// represents a single JSON value selected from the JSON query argument. +// Returned by [Path.Select]. +type NodeList []any + +// All returns an iterator over all the nodes in list. +// +// Range over list itself to get indexes and node values. +func (list NodeList) All() iter.Seq[any] { + return func(yield func(any) bool) { + for _, v := range list { + if !yield(v) { + return + } + } + } +} + +// LocatedNodeList is a list of nodes selected by a JSONPath query, along with +// their locations. Returned by [Path.SelectLocated]. +type LocatedNodeList []*spec.LocatedNode + +// All returns an iterator over all the nodes in list. +// +// Range over list itself to get indexes and node values. +func (list LocatedNodeList) All() iter.Seq[*spec.LocatedNode] { + return func(yield func(*spec.LocatedNode) bool) { + for _, v := range list { + if !yield(v) { + return + } + } + } +} + +// Nodes returns an iterator over all the nodes in list. This is effectively +// the same data a returned by [Path.Select]. +func (list LocatedNodeList) Nodes() iter.Seq[any] { + return func(yield func(any) bool) { + for _, v := range list { + if !yield(v.Node) { + return + } + } + } +} + +// Paths returns an iterator over all the normalized paths in list. +func (list LocatedNodeList) Paths() iter.Seq[spec.NormalizedPath] { + return func(yield func(spec.NormalizedPath) bool) { + for _, v := range list { + if !yield(v.Path) { + return + } + } + } +} + +// Deduplicate deduplicates the nodes in list based on their normalized paths, +// modifying the contents of list. It returns the modified list, which may +// have a smaller length, and zeroes the elements between the new length and +// the original length. +func (list LocatedNodeList) Deduplicate() LocatedNodeList { + if len(list) <= 1 { + return list + } + + seen := map[string]struct{}{} + uniq := list[:0] + for _, n := range list { + p := n.Path.String() + if _, x := seen[p]; !x { + seen[p] = struct{}{} + uniq = append(uniq, n) + } + } + clear(list[len(uniq):]) // zero/nil out the obsolete elements, for GC + return slices.Clip(uniq) +} + +// Sort sorts list by the normalized path of each node. +func (list LocatedNodeList) Sort() { + slices.SortFunc(list, func(a, b *spec.LocatedNode) int { + return a.Path.Compare(b.Path) + }) +} + +// Clone returns a shallow copy of list. +func (list LocatedNodeList) Clone() LocatedNodeList { + return append(make(LocatedNodeList, 0, len(list)), list...) +} diff --git a/path_example_test.go b/path_example_test.go index 7d6aa43..84ea35f 100644 --- a/path_example_test.go +++ b/path_example_test.go @@ -21,10 +21,10 @@ func Example() { // Select values from unmarshaled JSON input. store := bookstore() - result := p.Select(store) + nodes := p.Select(store) - // Show the result. - items, err := json.Marshal(result) + // Show the selected values. + items, err := json.Marshal(nodes) if err != nil { log.Fatal(err) } @@ -44,11 +44,11 @@ func ExamplePath_Select() { // Parse a JSONPath and select from the input. p := jsonpath.MustParse("$.apps.*") - result := p.Select(menu) + nodes := p.Select(menu) - // Show the result. - for _, val := range result { - fmt.Printf("%v\n", val) + // Show the selected values. + for node := range nodes.All() { + fmt.Printf("%v\n", node) } // Unordered output: // 19.99 @@ -66,10 +66,10 @@ func ExamplePath_SelectLocated() { // Parse a JSONPath and select from the input. p := jsonpath.MustParse("$.apps.*") - result := p.SelectLocated(menu) + nodes := p.SelectLocated(menu) - // Show the result. - for _, node := range result { + // Show the selected nodes. + for node := range nodes.All() { fmt.Printf("%v: %v\n", node.Path, node.Node) } @@ -78,6 +78,112 @@ func ExamplePath_SelectLocated() { // $['apps']['salsa']: 5.99 } +func ExampleLocatedNodeList() { + // Load some JSON. + menu := map[string]any{ + "apps": map[string]any{ + "guacamole": 19.99, + "salsa": 5.99, + }, + } + + // Parse a JSONPath and select from the input. + p := jsonpath.MustParse(`$.apps["salsa", "guacamole"]`) + nodes := p.SelectLocated(menu) + + // Show the nodes. + fmt.Println("Nodes:") + for n := range nodes.Nodes() { + fmt.Printf(" %v\n", n) + } + + // Show the paths. + fmt.Println("\nPaths:") + for p := range nodes.Paths() { + fmt.Printf(" %v\n", p) + } + + // Output: + // Nodes: + // 5.99 + // 19.99 + // + // Paths: + // $['apps']['salsa'] + // $['apps']['guacamole'] +} + +func ExampleLocatedNodeList_Deduplicate() { + // Load some JSON. + pallet := map[string]any{"colors": []any{"red", "blue"}} + + // Parse a JSONPath and select from the input. + p := jsonpath.MustParse("$.colors[0, 1, 1, 0]") + nodes := p.SelectLocated(pallet) + fmt.Printf("Items: %v\n", len(nodes)) + + // Deduplicate + nodes = nodes.Deduplicate() + fmt.Printf("Items: %v\n", len(nodes)) + + // Output: + // Items: 4 + // Items: 2 +} + +func ExampleLocatedNodeList_Sort() { + // Load some JSON. + pallet := map[string]any{"colors": []any{"red", "blue", "green"}} + + // Parse a JSONPath and select from the input. + p := jsonpath.MustParse("$.colors[2, 0, 1]") + nodes := p.SelectLocated(pallet) + + // Show selected. + fmt.Println("Selected:") + for _, node := range nodes { + fmt.Printf(" %v: %v\n", node.Path, node.Node) + } + + // Sort by normalized paths and show selected again. + nodes.Sort() + fmt.Println("\nSorted:") + for _, node := range nodes { + fmt.Printf(" %v: %v\n", node.Path, node.Node) + } + + // Output: + // Selected: + // $['colors'][2]: green + // $['colors'][0]: red + // $['colors'][1]: blue + // + // Sorted: + // $['colors'][0]: red + // $['colors'][1]: blue + // $['colors'][2]: green +} + +func ExampleLocatedNodeList_Clone() { + // Load some JSON. + items := []any{1, 2, 3, 4, 5} + + // Parse a JSONPath and select from the input. + p := jsonpath.MustParse("$[2, 0, 1, 0, 1]") + nodes := p.SelectLocated(items) + + // Clone the selected nodes then deduplicate. + orig := nodes.Clone() + nodes = nodes.Deduplicate() + + // Cloned nodes have the original count. + fmt.Printf("Unique Count: %v\nOriginal Count: %v\n", len(nodes), len(orig)) + + // Output: + // Unique Count: 3 + // Original Count: 5 +} + // Use the Parser to parse a collection of paths. func ExampleParser() { // Create a new parser using the default function registry. @@ -156,8 +262,8 @@ func ExampleParser_functionExtension() { []any{6, 7, 8, 9}, []any{4, 8, 12}, } - result := path.Select(input) - fmt.Printf("%v\n", result) + nodes := path.Select(input) + fmt.Printf("%v\n", nodes) // Output: [[6 7 8 9]] } diff --git a/path_test.go b/path_test.go index 0545737..02034fd 100644 --- a/path_test.go +++ b/path_test.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "testing" "github.com/stretchr/testify/assert" @@ -27,16 +28,16 @@ func TestParseSpecExamples(t *testing.T) { for _, tc := range []struct { name string path string - exp []any - loc []*spec.LocatedNode + exp NodeList + loc LocatedNodeList size int rand bool }{ { name: "example_1", path: `$.store.book[*].author`, - exp: []any{"Nigel Rees", "Evelyn Waugh", "Herman Melville", "J. R. R. Tolkien"}, - loc: []*spec.LocatedNode{ + exp: NodeList{"Nigel Rees", "Evelyn Waugh", "Herman Melville", "J. R. R. Tolkien"}, + loc: LocatedNodeList{ {Path: append(book(0), spec.Name("author")), Node: "Nigel Rees"}, {Path: append(book(1), spec.Name("author")), Node: "Evelyn Waugh"}, {Path: append(book(2), spec.Name("author")), Node: "Herman Melville"}, @@ -46,8 +47,8 @@ func TestParseSpecExamples(t *testing.T) { { name: "example_2", path: `$..author`, - exp: []any{"Nigel Rees", "Evelyn Waugh", "Herman Melville", "J. R. R. Tolkien"}, - loc: []*spec.LocatedNode{ + exp: NodeList{"Nigel Rees", "Evelyn Waugh", "Herman Melville", "J. R. R. Tolkien"}, + loc: LocatedNodeList{ {Path: append(book(0), spec.Name("author")), Node: "Nigel Rees"}, {Path: append(book(1), spec.Name("author")), Node: "Evelyn Waugh"}, {Path: append(book(2), spec.Name("author")), Node: "Herman Melville"}, @@ -57,19 +58,19 @@ func TestParseSpecExamples(t *testing.T) { { name: "example_3", path: `$.store.*`, - exp: []any{store["book"], store["bicycle"]}, - loc: []*spec.LocatedNode{ - {Path: spec.NormalizedPath{spec.Name("store"), spec.Name("book")}, Node: store["book"]}, - {Path: spec.NormalizedPath{spec.Name("store"), spec.Name("bicycle")}, Node: store["bicycle"]}, + exp: NodeList{store["book"], store["bicycle"]}, + loc: LocatedNodeList{ + {Path: norm("store", "book"), Node: store["book"]}, + {Path: norm("store", "bicycle"), Node: store["bicycle"]}, }, rand: true, }, { name: "example_4", path: `$.store..price`, - exp: []any{399., 8.95, 12.99, 8.99, 22.99}, - loc: []*spec.LocatedNode{ - {Path: spec.NormalizedPath{spec.Name("store"), spec.Name("bicycle"), spec.Name("price")}, Node: 399.}, + exp: NodeList{399., 8.95, 12.99, 8.99, 22.99}, + loc: LocatedNodeList{ + {Path: norm("store", "bicycle", "price"), Node: 399.}, {Path: append(book(0), spec.Name("price")), Node: 8.95}, {Path: append(book(1), spec.Name("price")), Node: 12.99}, {Path: append(book(2), spec.Name("price")), Node: 8.99}, @@ -80,20 +81,20 @@ func TestParseSpecExamples(t *testing.T) { { name: "example_5", path: `$..book[2]`, - exp: []any{books[2]}, + exp: NodeList{books[2]}, loc: []*spec.LocatedNode{{Path: book(2), Node: books[2]}}, }, { name: "example_6", path: `$..book[-1]`, - exp: []any{books[3]}, + exp: NodeList{books[3]}, loc: []*spec.LocatedNode{{Path: book(3), Node: books[3]}}, }, { name: "example_7", path: `$..book[0,1]`, - exp: []any{books[0], books[1]}, - loc: []*spec.LocatedNode{ + exp: NodeList{books[0], books[1]}, + loc: LocatedNodeList{ {Path: book(0), Node: books[0]}, {Path: book(1), Node: books[1]}, }, @@ -101,8 +102,8 @@ func TestParseSpecExamples(t *testing.T) { { name: "example_8", path: `$..book[?(@.isbn)]`, - exp: []any{books[2], books[3]}, - loc: []*spec.LocatedNode{ + exp: NodeList{books[2], books[3]}, + loc: LocatedNodeList{ {Path: book(2), Node: books[2]}, {Path: book(3), Node: books[3]}, }, @@ -110,8 +111,8 @@ func TestParseSpecExamples(t *testing.T) { { name: "example_9", path: `$..book[?(@.price<10)]`, - exp: []any{books[0], books[2]}, - loc: []*spec.LocatedNode{ + exp: NodeList{books[0], books[2]}, + loc: LocatedNodeList{ {Path: book(0), Node: books[0]}, {Path: book(2), Node: books[2]}, }, @@ -205,8 +206,8 @@ func TestParseCompliance(t *testing.T) { Name string Selector string Document any - Result []any - Results [][]any + Result NodeList + Results []NodeList InvalidSelector bool `json:"invalid_selector"` } @@ -315,3 +316,283 @@ func TestParser(t *testing.T) { }) } } + +func norm(sel ...any) spec.NormalizedPath { + path := make(spec.NormalizedPath, len(sel)) + for i, s := range sel { + switch s := s.(type) { + case string: + path[i] = spec.Name(s) + case int: + path[i] = spec.Index(s) + default: + panic(fmt.Sprintf("Invalid normalized path selector %T", s)) + } + } + return path +} + +func TestNodeList(t *testing.T) { + t.Parallel() + a := assert.New(t) + + for _, tc := range []struct { + name string + list NodeList + }{ + { + name: "empty", + list: NodeList{}, + }, + { + name: "one_node", + list: NodeList{true}, + }, + { + name: "two_nodes", + list: NodeList{true, "hi"}, + }, + { + name: "dupe_nodes", + list: NodeList{"hi", "hi"}, + }, + { + name: "many_nodes", + list: NodeList{"hi", true, nil, "hi", 42, 98.6, []any{99}, map[string]any{"hi": "go"}}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + // Test iterators. + if len(tc.list) == 0 { + a.Nil(slices.Collect(tc.list.All())) + } else { + a.Equal([]any(tc.list), slices.Collect(tc.list.All())) + } + }) + } +} + +func TestLocatedNodeList(t *testing.T) { + t.Parallel() + a := assert.New(t) + + for _, tc := range []struct { + name string + list LocatedNodeList + nodes []any + paths []spec.NormalizedPath + uniq LocatedNodeList + sort LocatedNodeList + }{ + { + name: "empty", + list: LocatedNodeList{}, + uniq: LocatedNodeList{}, + sort: LocatedNodeList{}, + }, + { + name: "one_node", + list: LocatedNodeList{{Path: norm("foo"), Node: 42}}, + nodes: []any{42}, + paths: []spec.NormalizedPath{norm("foo")}, + uniq: LocatedNodeList{{Path: norm("foo"), Node: 42}}, + sort: LocatedNodeList{{Path: norm("foo"), Node: 42}}, + }, + { + name: "two_names", + list: LocatedNodeList{ + {Path: norm("foo", "bar"), Node: true}, + }, + nodes: []any{true}, + paths: []spec.NormalizedPath{norm("foo", "bar")}, + uniq: LocatedNodeList{ + {Path: norm("foo", "bar"), Node: true}, + }, + sort: LocatedNodeList{ + {Path: norm("foo", "bar"), Node: true}, + }, + }, + { + name: "two_nodes", + list: LocatedNodeList{ + {Path: norm("foo"), Node: 42}, + {Path: norm("bar"), Node: true}, + }, + nodes: []any{42, true}, + paths: []spec.NormalizedPath{norm("foo"), norm("bar")}, + uniq: LocatedNodeList{ + {Path: norm("foo"), Node: 42}, + {Path: norm("bar"), Node: true}, + }, + // Sort strings. + sort: LocatedNodeList{ + {Path: norm("bar"), Node: true}, + {Path: norm("foo"), Node: 42}, + }, + }, + { + name: "three_nodes", + list: LocatedNodeList{ + {Path: norm("foo"), Node: 42}, + {Path: norm("bar"), Node: true}, + {Path: norm(42), Node: "hi"}, + }, + nodes: []any{42, true, "hi"}, + paths: []spec.NormalizedPath{norm("foo"), norm("bar"), norm(42)}, + uniq: LocatedNodeList{ + {Path: norm("foo"), Node: 42}, + {Path: norm("bar"), Node: true}, + {Path: norm(42), Node: "hi"}, + }, + // Indexes before strings. + sort: LocatedNodeList{ + {Path: norm(42), Node: "hi"}, + {Path: norm("bar"), Node: true}, + {Path: norm("foo"), Node: 42}, + }, + }, + { + name: "two_nodes_diff_lengths", + list: LocatedNodeList{ + {Path: norm("foo"), Node: 42}, + {Path: norm("bar", "baz"), Node: true}, + }, + nodes: []any{42, true}, + paths: []spec.NormalizedPath{norm("foo"), norm("bar", "baz")}, + uniq: LocatedNodeList{ + {Path: norm("foo"), Node: 42}, + {Path: norm("bar", "baz"), Node: true}, + }, + // Sort strings. + sort: LocatedNodeList{ + {Path: norm("bar", "baz"), Node: true}, + {Path: norm("foo"), Node: 42}, + }, + }, + { + name: "two_nodes_diff_lengths_reverse", + list: LocatedNodeList{ + {Path: norm("foo", "baz"), Node: 42}, + {Path: norm("bar"), Node: true}, + }, + nodes: []any{42, true}, + paths: []spec.NormalizedPath{norm("foo", "baz"), norm("bar")}, + uniq: LocatedNodeList{ + {Path: norm("foo", "baz"), Node: 42}, + {Path: norm("bar"), Node: true}, + }, + // Sort strings. + sort: LocatedNodeList{ + {Path: norm("bar"), Node: true}, + {Path: norm("foo", "baz"), Node: 42}, + }, + }, + { + name: "dupe_nodes", + list: LocatedNodeList{ + {Path: norm("foo"), Node: 42}, + {Path: norm("bar"), Node: true}, + {Path: norm("foo"), Node: 42}, + }, + nodes: []any{42, true, 42}, + paths: []spec.NormalizedPath{norm("foo"), norm("bar"), norm("foo")}, + uniq: LocatedNodeList{ + {Path: norm("foo"), Node: 42}, + {Path: norm("bar"), Node: true}, + }, + sort: LocatedNodeList{ + {Path: norm("bar"), Node: true}, + {Path: norm("foo"), Node: 42}, + {Path: norm("foo"), Node: 42}, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Test iterators. + if len(tc.list) == 0 { + a.Nil(slices.Collect(tc.list.All())) + } else { + a.Equal([]*spec.LocatedNode(tc.list), slices.Collect(tc.list.All())) + } + a.Equal(tc.nodes, slices.Collect(tc.list.Nodes())) + a.Equal(tc.paths, slices.Collect(tc.list.Paths())) + + // Test Clone. + list := tc.list.Clone() + a.Equal(tc.list, list) + + // Test Deduplicate + uniq := list.Deduplicate() + a.Equal(tc.uniq, uniq) + // Additional capacity in list should be zero + for i := len(uniq); i < len(list); i++ { + a.Zero(list[i]) + } + + // Test Sort + list = tc.list.Clone() + list.Sort() + a.Equal(tc.sort, list) + }) + } +} + +func TestNodeListIterators(t *testing.T) { + t.Parallel() + a := assert.New(t) + + list := NodeList{true, 42, "hi"} + + // Fetch a single node. + for node := range list.All() { + a.Equal(true, node) + break + } + + // Should be able to fetch them all after break. + a.Equal([]any{true, 42, "hi"}, slices.Collect(list.All())) +} + +func TestLocatedNodeListIterators(t *testing.T) { + t.Parallel() + a := assert.New(t) + + list := LocatedNodeList{ + {Path: norm("bar"), Node: true}, + {Path: norm("foo", "baz"), Node: 42}, + {Path: norm(1, 2), Node: "hi"}, + } + + // Fetch a single node. + for node := range list.All() { + a.Equal(true, node.Node) + break + } + + // Should be able to fetch them all after break. + a.Equal([]*spec.LocatedNode(list), slices.Collect(list.All())) + + // Fetch a single node. + for node := range list.Nodes() { + a.Equal(true, node) + break + } + + // Should be able to fetch them all after break. + a.Equal([]any{true, 42, "hi"}, slices.Collect(list.Nodes())) + + // Fetch a single path. + for path := range list.Paths() { + a.Equal(path, norm("bar")) + break + } + + // Should be able to fetch them all after break. + a.Equal( + []spec.NormalizedPath{norm("bar"), norm("foo", "baz"), norm(1, 2)}, + slices.Collect(list.Paths()), + ) +} diff --git a/spec/normalized.go b/spec/normalized.go index 6e9e2a3..bab2ba3 100644 --- a/spec/normalized.go +++ b/spec/normalized.go @@ -1,6 +1,7 @@ package spec import ( + "cmp" "strings" ) @@ -29,6 +30,42 @@ func (np NormalizedPath) String() string { return buf.String() } +// Compare compares np to np2 and returns -1 if np is less than np2, 1 if it's +// greater than np2, and 0 if they're equal. Indexes are always considered +// less than names. +func (np NormalizedPath) Compare(np2 NormalizedPath) int { + for i := range np { + if i >= len(np2) { + return 1 + } + switch v1 := np[i].(type) { + case Name: + switch v2 := np2[i].(type) { + case Name: + if x := cmp.Compare(v1, v2); x != 0 { + return x + } + case Index: + return 1 + } + case Index: + switch v2 := np2[i].(type) { + case Index: + if x := cmp.Compare(v1, v2); x != 0 { + return x + } + case Name: + return -1 + } + } + } + + if len(np2) > len(np) { + return -1 + } + return 0 +} + // MarshalText marshals np into text. It implements [encoding.TextMarshaler]. func (np NormalizedPath) MarshalText() ([]byte, error) { return []byte(np.String()), nil diff --git a/spec/normalized_test.go b/spec/normalized_test.go index 2f63011..fedcacc 100644 --- a/spec/normalized_test.go +++ b/spec/normalized_test.go @@ -110,6 +110,118 @@ func TestNormalizedPath(t *testing.T) { } } +func TestNormalizedPathCompare(t *testing.T) { + t.Parallel() + a := assert.New(t) + + for _, tc := range []struct { + name string + p1 NormalizedPath + p2 NormalizedPath + exp int + }{ + { + name: "empty_paths", + exp: 0, + }, + { + name: "same_name", + p1: NormalizedPath{Name("a")}, + p2: NormalizedPath{Name("a")}, + exp: 0, + }, + { + name: "diff_names", + p1: NormalizedPath{Name("a")}, + p2: NormalizedPath{Name("b")}, + exp: -1, + }, + { + name: "diff_names_rev", + p1: NormalizedPath{Name("b")}, + p2: NormalizedPath{Name("a")}, + exp: 1, + }, + { + name: "same_name_diff_lengths", + p1: NormalizedPath{Name("a"), Name("b")}, + p2: NormalizedPath{Name("a")}, + exp: 1, + }, + { + name: "same_name_diff_lengths_rev", + p1: NormalizedPath{Name("a")}, + p2: NormalizedPath{Name("a"), Name("b")}, + exp: -1, + }, + { + name: "same_multi_names", + p1: NormalizedPath{Name("a"), Name("b")}, + p2: NormalizedPath{Name("a"), Name("b")}, + exp: 0, + }, + { + name: "diff_nested_names", + p1: NormalizedPath{Name("a"), Name("a")}, + p2: NormalizedPath{Name("a"), Name("b")}, + exp: -1, + }, + { + name: "diff_nested_names_rev", + p1: NormalizedPath{Name("a"), Name("b")}, + p2: NormalizedPath{Name("a"), Name("a")}, + exp: 1, + }, + { + name: "name_vs_index", + p1: NormalizedPath{Name("a")}, + p2: NormalizedPath{Index(0)}, + exp: 1, + }, + { + name: "name_vs_index_rev", + p1: NormalizedPath{Index(0)}, + p2: NormalizedPath{Name("a")}, + exp: -1, + }, + { + name: "diff_nested_types", + p1: NormalizedPath{Name("a"), Index(1024)}, + p2: NormalizedPath{Name("a"), Name("b")}, + exp: -1, + }, + { + name: "diff_nested_types_rev", + p1: NormalizedPath{Name("a"), Name("b")}, + p2: NormalizedPath{Name("a"), Index(1024)}, + exp: 1, + }, + { + name: "same_index", + p1: NormalizedPath{Index(42)}, + p2: NormalizedPath{Index(42)}, + exp: 0, + }, + { + name: "diff_indexes", + p1: NormalizedPath{Index(42)}, + p2: NormalizedPath{Index(99)}, + exp: -1, + }, + { + name: "diff_indexes_rev", + p1: NormalizedPath{Index(99)}, + p2: NormalizedPath{Index(42)}, + exp: 1, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + a.Equal(tc.exp, tc.p1.Compare(tc.p2)) + }) + } +} + func TestLocatedNode(t *testing.T) { t.Parallel() a := assert.New(t)