tools: Add src/cts/query

Provides a type to represent the CTS query strings.

100% test coverage.

Bug: dawn:1342

Change-Id: I3769b094ba64221a7b79dd38f76daf0125ee9e28
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/85221
Reviewed-by: Austin Eng <enga@chromium.org>
Commit-Queue: Ben Clayton <bclayton@google.com>
diff --git a/tools/src/cts/query/query.go b/tools/src/cts/query/query.go
new file mode 100644
index 0000000..718dda3
--- /dev/null
+++ b/tools/src/cts/query/query.go
@@ -0,0 +1,362 @@
+// Copyright 2022 The Dawn Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package query provides helpers for parsing and mutating WebGPU CTS queries.
+//
+// The full query syntax is described at:
+// https://github.com/gpuweb/cts/blob/main/docs/terms.md#queries
+//
+// Note that this package supports a superset of the official CTS query syntax, 
+// as this package permits parsing and printing of queries that do not end in a
+// wildcard, whereas the CTS requires that all queries end in wildcards unless
+// they identify a specific test.
+// For example, the following queries are considered valid by this  package, but
+// would be rejected by the CTS: 
+// `suite`, `suite:file`, `suite:file,file`, `suite:file,file:test`.
+//
+// This relaxation is intentional as the Query type is used for constructing and
+// reducing query trees, and always requiring a wildcard adds unnecessary
+// complexity.
+package query
+
+import (
+	"fmt"
+	"strings"
+)
+
+// Query represents a WebGPU test query
+// Example queries:
+//    'suite'
+//    'suite:*'
+//    'suite:file'
+//    'suite:file,*'
+//    'suite:file,file'
+//    'suite:file,file,*'
+//    'suite:file,file,file:test'
+//    'suite:file,file,file:test:*'
+//    'suite:file,file,file:test,test:case;*'
+type Query struct {
+	Suite string
+	Files string
+	Tests string
+	Cases string
+}
+
+// Target is the target of a query, either a Suite, File, Test or Case.
+type Target int
+
+// Enumerators of Target
+const (
+	// The query targets a suite
+	Suite Target = iota
+	// The query targets one or more files
+	Files
+	// The query targets one or more tests
+	Tests
+	// The query targets one or more test cases
+	Cases
+
+	TargetCount
+)
+
+// Format writes the Target to the fmt.State
+func (l Target) Format(f fmt.State, verb rune) {
+	switch l {
+	case Suite:
+		fmt.Fprint(f, "suite")
+	case Files:
+		fmt.Fprint(f, "files")
+	case Tests:
+		fmt.Fprint(f, "tests")
+	case Cases:
+		fmt.Fprint(f, "cases")
+	default:
+		fmt.Fprint(f, "<invalid>")
+	}
+}
+
+// Delimiter constants used by the query format
+const (
+	TargetDelimiter = ":"
+	FileDelimiter   = ","
+	TestDelimiter   = ","
+	CaseDelimiter   = ";"
+)
+
+// Parse parses a query string
+func Parse(s string) (Query, error) {
+	parts := strings.Split(s, TargetDelimiter)
+	q := Query{}
+	switch len(parts) {
+	default:
+		q.Cases = strings.Join(parts[3:], TargetDelimiter)
+		fallthrough
+	case 3:
+		q.Tests = parts[2]
+		fallthrough
+	case 2:
+		q.Files = parts[1]
+		fallthrough
+	case 1:
+		q.Suite = parts[0]
+	}
+	return q, nil
+}
+
+// AppendFiles returns a new query with the strings appended to the 'files'
+func (q Query) AppendFiles(f ...string) Query {
+	if len(f) > 0 {
+		if q.Files == "" {
+			q.Files = strings.Join(f, FileDelimiter)
+		} else {
+			q.Files = q.Files + FileDelimiter + strings.Join(f, FileDelimiter)
+		}
+	}
+	return q
+}
+
+// SplitFiles returns the separated 'files' part of the query
+func (q Query) SplitFiles() []string {
+	if q.Files != "" {
+		return strings.Split(q.Files, FileDelimiter)
+	}
+	return nil
+}
+
+// AppendTests returns a new query with the strings appended to the 'tests'
+func (q Query) AppendTests(t ...string) Query {
+	if len(t) > 0 {
+		if q.Tests == "" {
+			q.Tests = strings.Join(t, TestDelimiter)
+		} else {
+			q.Tests = q.Tests + TestDelimiter + strings.Join(t, TestDelimiter)
+		}
+	}
+	return q
+}
+
+// SplitTests returns the separated 'tests' part of the query
+func (q Query) SplitTests() []string {
+	if q.Tests != "" {
+		return strings.Split(q.Tests, TestDelimiter)
+	}
+	return nil
+}
+
+// AppendCases returns a new query with the strings appended to the 'cases'
+func (q Query) AppendCases(c ...string) Query {
+	if len(c) > 0 {
+		if q.Cases == "" {
+			q.Cases = strings.Join(c, CaseDelimiter)
+		} else {
+			q.Cases = q.Cases + CaseDelimiter + strings.Join(c, CaseDelimiter)
+		}
+	}
+	return q
+}
+
+// SplitCases returns the separated 'cases' part of the query
+func (q Query) SplitCases() []string {
+	if q.Cases != "" {
+		return strings.Split(q.Cases, CaseDelimiter)
+	}
+	return nil
+}
+
+// Case parameters is a map of parameter name to parameter value
+type CaseParameters map[string]string
+
+// CaseParameters returns all the case parameters of the query
+func (q Query) CaseParameters() CaseParameters {
+	if q.Cases != "" {
+		out := CaseParameters{}
+		for _, c := range strings.Split(q.Cases, CaseDelimiter) {
+			idx := strings.IndexRune(c, '=')
+			if idx < 0 {
+				out[c] = ""
+			} else {
+				k, v := c[:idx], c[idx+1:]
+				out[k] = v
+			}
+		}
+		return out
+	}
+	return nil
+}
+
+// Append returns the query with the additional strings appended to the target
+func (q Query) Append(t Target, n ...string) Query {
+	switch t {
+	case Files:
+		return q.AppendFiles(n...)
+	case Tests:
+		return q.AppendTests(n...)
+	case Cases:
+		return q.AppendCases(n...)
+	}
+	panic("invalid target")
+}
+
+// Target returns the target of the query
+func (q Query) Target() Target {
+	if q.Files != "" {
+		if q.Tests != "" {
+			if q.Cases != "" {
+				return Cases
+			}
+			return Tests
+		}
+		return Files
+	}
+	return Suite
+}
+
+// IsWildcard returns true if the query ends with a wildcard
+func (q Query) IsWildcard() bool {
+	switch q.Target() {
+	case Suite:
+		return q.Suite == "*"
+	case Files:
+		return strings.HasSuffix(q.Files, "*")
+	case Tests:
+		return strings.HasSuffix(q.Tests, "*")
+	case Cases:
+		return strings.HasSuffix(q.Cases, "*")
+	}
+	panic("invalid target")
+}
+
+// String returns the query formatted as a string
+func (q Query) String() string {
+	sb := strings.Builder{}
+	sb.WriteString(q.Suite)
+	if q.Files != "" {
+		sb.WriteString(TargetDelimiter)
+		sb.WriteString(q.Files)
+		if q.Tests != "" {
+			sb.WriteString(TargetDelimiter)
+			sb.WriteString(q.Tests)
+			if q.Cases != "" {
+				sb.WriteString(TargetDelimiter)
+				sb.WriteString(q.Cases)
+			}
+		}
+	}
+	return sb.String()
+}
+
+// Compare compares the relative order of q and o, returning:
+//  -1 if q should come before o
+//   1 if q should come after o
+//   0 if q and o are identical
+func (q Query) Compare(o Query) int {
+	for _, cmp := range []struct{ a, b string }{
+		{q.Suite, o.Suite},
+		{q.Files, o.Files},
+		{q.Tests, o.Tests},
+		{q.Cases, o.Cases},
+	} {
+		if cmp.a < cmp.b {
+			return -1
+		}
+		if cmp.a > cmp.b {
+			return 1
+		}
+	}
+
+	return 0
+}
+
+// Contains returns true if q is a superset of o
+func (q Query) Contains(o Query) bool {
+	if q.Suite != o.Suite {
+		return false
+	}
+	{
+		a, b := q.SplitFiles(), o.SplitFiles()
+		for i, f := range a {
+			if f == "*" {
+				return true
+			}
+			if i >= len(b) || b[i] != f {
+				return false
+			}
+		}
+		if len(a) < len(b) {
+			return false
+		}
+	}
+	{
+		a, b := q.SplitTests(), o.SplitTests()
+		for i, f := range a {
+			if f == "*" {
+				return true
+			}
+			if i >= len(b) || b[i] != f {
+				return false
+			}
+		}
+		if len(a) < len(b) {
+			return false
+		}
+	}
+	{
+		a, b := q.CaseParameters(), o.CaseParameters()
+		for key, av := range a {
+			if bv, found := b[key]; found && av != bv {
+				return false
+			}
+		}
+	}
+	return true
+}
+
+// Callback function for Query.Walk()
+//   q is the query for the current segment.
+//   t is the target of the query q.
+//   n is the name of the new segment.
+type WalkCallback func(q Query, t Target, n string) error
+
+// Walk calls 'f' for each suite, file, test segment, and calls f once for all
+// cases. If f returns an error then walking is immediately terminated and the
+// error is returned.
+func (q Query) Walk(f WalkCallback) error {
+	p := Query{Suite: q.Suite}
+
+	if err := f(p, Suite, q.Suite); err != nil {
+		return err
+	}
+
+	for _, file := range q.SplitFiles() {
+		p = p.AppendFiles(file)
+		if err := f(p, Files, file); err != nil {
+			return err
+		}
+	}
+
+	for _, test := range q.SplitTests() {
+		p = p.AppendTests(test)
+		if err := f(p, Tests, test); err != nil {
+			return err
+		}
+	}
+
+	if q.Cases != "" {
+		if err := f(q, Cases, q.Cases); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
diff --git a/tools/src/cts/query/query_test.go b/tools/src/cts/query/query_test.go
new file mode 100644
index 0000000..c42f8c7
--- /dev/null
+++ b/tools/src/cts/query/query_test.go
@@ -0,0 +1,865 @@
+// Copyright 2022 The Dawn Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package query_test
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+
+	"dawn.googlesource.com/dawn/tools/src/cts/query"
+	"github.com/google/go-cmp/cmp"
+)
+
+func Q(name string) query.Query {
+	q, err := query.Parse(name)
+	if err != nil {
+		panic(err)
+	}
+	return q
+}
+
+func TestTargetFormat(t *testing.T) {
+	type Test struct {
+		target query.Target
+		expect string
+	}
+
+	for _, test := range []Test{
+		{query.Suite, "suite"},
+		{query.Files, "files"},
+		{query.Tests, "tests"},
+		{query.Cases, "cases"},
+		{query.Target(-1), "<invalid>"},
+	} {
+		s := strings.Builder{}
+		_, err := fmt.Fprint(&s, test.target)
+		if err != nil {
+			t.Errorf("Fprint() returned %v", err)
+			continue
+		}
+		if diff := cmp.Diff(s.String(), test.expect); diff != "" {
+			t.Errorf("Fprint('%v')\n%v", test.target, diff)
+		}
+	}
+}
+
+func TestAppendFiles(t *testing.T) {
+	type Test struct {
+		base   query.Query
+		files  []string
+		expect query.Query
+	}
+
+	for _, test := range []Test{
+		{Q("suite"), []string{}, Q("suite")},
+		{Q("suite"), []string{"x"}, Q("suite:x")},
+		{Q("suite"), []string{"x", "y"}, Q("suite:x,y")},
+		{Q("suite:a"), []string{}, Q("suite:a")},
+		{Q("suite:a"), []string{"x"}, Q("suite:a,x")},
+		{Q("suite:a"), []string{"x", "y"}, Q("suite:a,x,y")},
+		{Q("suite:a,b"), []string{}, Q("suite:a,b")},
+		{Q("suite:a,b"), []string{"x"}, Q("suite:a,b,x")},
+		{Q("suite:a,b"), []string{"x", "y"}, Q("suite:a,b,x,y")},
+		{Q("suite:a,b:c"), []string{}, Q("suite:a,b:c")},
+		{Q("suite:a,b:c"), []string{"x"}, Q("suite:a,b,x:c")},
+		{Q("suite:a,b:c"), []string{"x", "y"}, Q("suite:a,b,x,y:c")},
+		{Q("suite:a,b:c,d"), []string{}, Q("suite:a,b:c,d")},
+		{Q("suite:a,b:c,d"), []string{"x"}, Q("suite:a,b,x:c,d")},
+		{Q("suite:a,b:c,d"), []string{"x", "y"}, Q("suite:a,b,x,y:c,d")},
+		{Q("suite:a,b:c,d:e"), []string{}, Q("suite:a,b:c,d:e")},
+		{Q("suite:a,b:c,d:e"), []string{"x"}, Q("suite:a,b,x:c,d:e")},
+		{Q("suite:a,b:c,d:e"), []string{"x", "y"}, Q("suite:a,b,x,y:c,d:e")},
+		{Q("suite:a,b:c,d:e;f"), []string{}, Q("suite:a,b:c,d:e;f")},
+		{Q("suite:a,b:c,d:e;f"), []string{"x"}, Q("suite:a,b,x:c,d:e;f")},
+		{Q("suite:a,b:c,d:e;f"), []string{"x", "y"}, Q("suite:a,b,x,y:c,d:e;f")},
+	} {
+		got := test.base.AppendFiles(test.files...)
+		if diff := cmp.Diff(got, test.expect); diff != "" {
+			t.Errorf("'%v'.AppendFiles(%v)\n%v", test.base, test.files, diff)
+		}
+	}
+}
+
+func TestSplitFiles(t *testing.T) {
+	type Test struct {
+		query  query.Query
+		expect []string
+	}
+
+	for _, test := range []Test{
+		{Q("suite"), nil},
+		{Q("suite:a"), []string{"a"}},
+		{Q("suite:a,b"), []string{"a", "b"}},
+		{Q("suite:a,b:c"), []string{"a", "b"}},
+		{Q("suite:a,b:c,d"), []string{"a", "b"}},
+		{Q("suite:a,b:c,d:e"), []string{"a", "b"}},
+		{Q("suite:a,b:c,d:e;f"), []string{"a", "b"}},
+	} {
+		got := test.query.SplitFiles()
+		if diff := cmp.Diff(got, test.expect); diff != "" {
+			t.Errorf("'%v'.SplitFiles()\n%v", test.query, diff)
+		}
+	}
+}
+
+func TestAppendTests(t *testing.T) {
+	type Test struct {
+		base   query.Query
+		files  []string
+		expect query.Query
+	}
+
+	for _, test := range []Test{
+		{Q("suite"), []string{}, Q("suite")},
+		{Q("suite"), []string{"x"}, Q("suite::x")},
+		{Q("suite"), []string{"x", "y"}, Q("suite::x,y")},
+		{Q("suite:a"), []string{}, Q("suite:a")},
+		{Q("suite:a"), []string{"x"}, Q("suite:a:x")},
+		{Q("suite:a"), []string{"x", "y"}, Q("suite:a:x,y")},
+		{Q("suite:a,b"), []string{}, Q("suite:a,b")},
+		{Q("suite:a,b"), []string{"x"}, Q("suite:a,b:x")},
+		{Q("suite:a,b"), []string{"x", "y"}, Q("suite:a,b:x,y")},
+		{Q("suite:a,b:c"), []string{}, Q("suite:a,b:c")},
+		{Q("suite:a,b:c"), []string{"x"}, Q("suite:a,b:c,x")},
+		{Q("suite:a,b:c"), []string{"x", "y"}, Q("suite:a,b:c,x,y")},
+		{Q("suite:a,b:c,d"), []string{}, Q("suite:a,b:c,d")},
+		{Q("suite:a,b:c,d"), []string{"x"}, Q("suite:a,b:c,d,x")},
+		{Q("suite:a,b:c,d"), []string{"x", "y"}, Q("suite:a,b:c,d,x,y")},
+		{Q("suite:a,b:c,d:e"), []string{}, Q("suite:a,b:c,d:e")},
+		{Q("suite:a,b:c,d:e"), []string{"x"}, Q("suite:a,b:c,d,x:e")},
+		{Q("suite:a,b:c,d:e"), []string{"x", "y"}, Q("suite:a,b:c,d,x,y:e")},
+		{Q("suite:a,b:c,d:e;f"), []string{}, Q("suite:a,b:c,d:e;f")},
+		{Q("suite:a,b:c,d:e;f"), []string{"x"}, Q("suite:a,b:c,d,x:e;f")},
+		{Q("suite:a,b:c,d:e;f"), []string{"x", "y"}, Q("suite:a,b:c,d,x,y:e;f")},
+	} {
+		got := test.base.AppendTests(test.files...)
+		if diff := cmp.Diff(got, test.expect); diff != "" {
+			t.Errorf("'%v'.AppendTests(%v)\n%v", test.base, test.files, diff)
+		}
+	}
+}
+
+func TestSplitTests(t *testing.T) {
+	type Test struct {
+		query query.Query
+		tests []string
+	}
+
+	for _, test := range []Test{
+		{Q("suite"), nil},
+		{Q("suite:a"), nil},
+		{Q("suite:a,b"), nil},
+		{Q("suite:a,b:c"), []string{"c"}},
+		{Q("suite:a,b:c,d"), []string{"c", "d"}},
+		{Q("suite:a,b:c,d:e"), []string{"c", "d"}},
+		{Q("suite:a,b:c,d:e;f"), []string{"c", "d"}},
+	} {
+		got := test.query.SplitTests()
+		if diff := cmp.Diff(got, test.tests); diff != "" {
+			t.Errorf("'%v'.SplitTests()\n%v", test.query, diff)
+		}
+	}
+}
+
+func TestAppendCases(t *testing.T) {
+	type Test struct {
+		base   query.Query
+		cases  []string
+		expect query.Query
+	}
+
+	for _, test := range []Test{
+		{Q("suite"), []string{}, Q("suite")},
+		{Q("suite"), []string{"x"}, Q("suite:::x")},
+		{Q("suite"), []string{"x", "y"}, Q("suite:::x;y")},
+		{Q("suite:a"), []string{}, Q("suite:a")},
+		{Q("suite:a"), []string{"x"}, Q("suite:a::x")},
+		{Q("suite:a"), []string{"x", "y"}, Q("suite:a::x;y")},
+		{Q("suite:a,b"), []string{}, Q("suite:a,b")},
+		{Q("suite:a,b"), []string{"x"}, Q("suite:a,b::x")},
+		{Q("suite:a,b"), []string{"x", "y"}, Q("suite:a,b::x;y")},
+		{Q("suite:a,b:c"), []string{}, Q("suite:a,b:c")},
+		{Q("suite:a,b:c"), []string{"x"}, Q("suite:a,b:c:x")},
+		{Q("suite:a,b:c"), []string{"x", "y"}, Q("suite:a,b:c:x;y")},
+		{Q("suite:a,b:c,d"), []string{}, Q("suite:a,b:c,d")},
+		{Q("suite:a,b:c,d"), []string{"x"}, Q("suite:a,b:c,d:x")},
+		{Q("suite:a,b:c,d"), []string{"x", "y"}, Q("suite:a,b:c,d:x;y")},
+		{Q("suite:a,b:c,d:e"), []string{}, Q("suite:a,b:c,d:e")},
+		{Q("suite:a,b:c,d:e"), []string{"x"}, Q("suite:a,b:c,d:e;x")},
+		{Q("suite:a,b:c,d:e"), []string{"x", "y"}, Q("suite:a,b:c,d:e;x;y")},
+		{Q("suite:a,b:c,d:e;f"), []string{}, Q("suite:a,b:c,d:e;f")},
+		{Q("suite:a,b:c,d:e;f"), []string{"x"}, Q("suite:a,b:c,d:e;f;x")},
+		{Q("suite:a,b:c,d:e;f"), []string{"x", "y"}, Q("suite:a,b:c,d:e;f;x;y")},
+	} {
+		got := test.base.AppendCases(test.cases...)
+		if diff := cmp.Diff(got, test.expect); diff != "" {
+			t.Errorf("'%v'.AppendCases(%v)\n%v", test.base, test.cases, diff)
+		}
+	}
+}
+
+func TestAppend(t *testing.T) {
+	type Subtest struct {
+		target query.Target
+		expect query.Query
+	}
+	type Test struct {
+		base    query.Query
+		strings []string
+		subtest []Subtest
+	}
+	for _, test := range []Test{
+		{
+			Q("suite"), []string{}, []Subtest{
+				{query.Files, Q("suite")},
+				{query.Tests, Q("suite")},
+				{query.Cases, Q("suite")},
+			},
+		}, {
+			Q("suite"), []string{"x"}, []Subtest{
+				{query.Files, Q("suite:x")},
+				{query.Tests, Q("suite::x")},
+				{query.Cases, Q("suite:::x")},
+			},
+		}, {
+			Q("suite"), []string{"x", "y"}, []Subtest{
+				{query.Files, Q("suite:x,y")},
+				{query.Tests, Q("suite::x,y")},
+				{query.Cases, Q("suite:::x;y")},
+			},
+		}, {
+			Q("suite:a"), []string{}, []Subtest{
+				{query.Files, Q("suite:a")},
+				{query.Tests, Q("suite:a")},
+				{query.Cases, Q("suite:a")},
+			},
+		}, {
+			Q("suite:a"), []string{"x"}, []Subtest{
+				{query.Files, Q("suite:a,x")},
+				{query.Tests, Q("suite:a:x")},
+				{query.Cases, Q("suite:a::x")},
+			},
+		}, {
+			Q("suite:a"), []string{"x", "y"}, []Subtest{
+				{query.Files, Q("suite:a,x,y")},
+				{query.Tests, Q("suite:a:x,y")},
+				{query.Cases, Q("suite:a::x;y")},
+			},
+		}, {
+			Q("suite:a,b"), []string{}, []Subtest{
+				{query.Files, Q("suite:a,b")},
+				{query.Tests, Q("suite:a,b")},
+				{query.Cases, Q("suite:a,b")},
+			},
+		}, {
+			Q("suite:a,b"), []string{"x"}, []Subtest{
+				{query.Files, Q("suite:a,b,x")},
+				{query.Tests, Q("suite:a,b:x")},
+				{query.Cases, Q("suite:a,b::x")},
+			},
+		}, {
+			Q("suite:a,b"), []string{"x", "y"}, []Subtest{
+				{query.Files, Q("suite:a,b,x,y")},
+				{query.Tests, Q("suite:a,b:x,y")},
+				{query.Cases, Q("suite:a,b::x;y")},
+			},
+		}, {
+			Q("suite:a,b:c"), []string{}, []Subtest{
+				{query.Files, Q("suite:a,b:c")},
+				{query.Tests, Q("suite:a,b:c")},
+				{query.Cases, Q("suite:a,b:c")},
+			},
+		}, {
+			Q("suite:a,b:c"), []string{"x"}, []Subtest{
+				{query.Files, Q("suite:a,b,x:c")},
+				{query.Tests, Q("suite:a,b:c,x")},
+				{query.Cases, Q("suite:a,b:c:x")},
+			},
+		}, {
+			Q("suite:a,b:c"), []string{"x", "y"}, []Subtest{
+				{query.Files, Q("suite:a,b,x,y:c")},
+				{query.Tests, Q("suite:a,b:c,x,y")},
+				{query.Cases, Q("suite:a,b:c:x;y")},
+			},
+		}, {
+			Q("suite:a,b:c,d"), []string{}, []Subtest{
+				{query.Files, Q("suite:a,b:c,d")},
+				{query.Tests, Q("suite:a,b:c,d")},
+				{query.Cases, Q("suite:a,b:c,d")},
+			},
+		}, {
+			Q("suite:a,b:c,d"), []string{"x"}, []Subtest{
+				{query.Files, Q("suite:a,b,x:c,d")},
+				{query.Tests, Q("suite:a,b:c,d,x")},
+				{query.Cases, Q("suite:a,b:c,d:x")},
+			},
+		}, {
+			Q("suite:a,b:c,d"), []string{"x", "y"}, []Subtest{
+				{query.Files, Q("suite:a,b,x,y:c,d")},
+				{query.Tests, Q("suite:a,b:c,d,x,y")},
+				{query.Cases, Q("suite:a,b:c,d:x;y")},
+			},
+		}, {
+			Q("suite:a,b:c,d:e"), []string{}, []Subtest{
+				{query.Files, Q("suite:a,b:c,d:e")},
+				{query.Tests, Q("suite:a,b:c,d:e")},
+				{query.Cases, Q("suite:a,b:c,d:e")},
+			},
+		}, {
+			Q("suite:a,b:c,d:e"), []string{"x"}, []Subtest{
+				{query.Files, Q("suite:a,b,x:c,d:e")},
+				{query.Tests, Q("suite:a,b:c,d,x:e")},
+				{query.Cases, Q("suite:a,b:c,d:e;x")},
+			},
+		}, {
+			Q("suite:a,b:c,d:e"), []string{"x", "y"}, []Subtest{
+				{query.Files, Q("suite:a,b,x,y:c,d:e")},
+				{query.Tests, Q("suite:a,b:c,d,x,y:e")},
+				{query.Cases, Q("suite:a,b:c,d:e;x;y")},
+			},
+		}, {
+			Q("suite:a,b:c,d:e;f"), []string{}, []Subtest{
+				{query.Files, Q("suite:a,b:c,d:e;f")},
+				{query.Tests, Q("suite:a,b:c,d:e;f")},
+				{query.Cases, Q("suite:a,b:c,d:e;f")},
+			},
+		}, {
+			Q("suite:a,b:c,d:e;f"), []string{"x"}, []Subtest{
+				{query.Files, Q("suite:a,b,x:c,d:e;f")},
+				{query.Tests, Q("suite:a,b:c,d,x:e;f")},
+				{query.Cases, Q("suite:a,b:c,d:e;f;x")},
+			},
+		}, {
+			Q("suite:a,b:c,d:e;f"), []string{"x", "y"}, []Subtest{
+				{query.Files, Q("suite:a,b,x,y:c,d:e;f")},
+				{query.Tests, Q("suite:a,b:c,d,x,y:e;f")},
+				{query.Cases, Q("suite:a,b:c,d:e;f;x;y")},
+			},
+		},
+	} {
+		for _, subtest := range test.subtest {
+			got := test.base.Append(subtest.target, test.strings...)
+			if diff := cmp.Diff(got, subtest.expect); diff != "" {
+				t.Errorf("'%v'.Append(%v, %v)\n%v", test.base, subtest.target, test.base.Files, diff)
+			}
+		}
+	}
+}
+
+func TestSplitCases(t *testing.T) {
+	type Test struct {
+		query  query.Query
+		expect []string
+	}
+
+	for _, test := range []Test{
+		{Q("suite"), nil},
+		{Q("suite:a"), nil},
+		{Q("suite:a,b"), nil},
+		{Q("suite:a,b:c"), nil},
+		{Q("suite:a,b:c,d"), nil},
+		{Q("suite:a,b:c,d:e"), []string{"e"}},
+		{Q("suite:a,b:c,d:e;f"), []string{"e", "f"}},
+	} {
+		got := test.query.SplitCases()
+		if diff := cmp.Diff(got, test.expect); diff != "" {
+			t.Errorf("'%v'.SplitCases()\n%v", test.query, diff)
+		}
+	}
+}
+
+func TestCaseParameters(t *testing.T) {
+	type Test struct {
+		query  query.Query
+		expect query.CaseParameters
+	}
+
+	for _, test := range []Test{
+		{Q("suite"), nil},
+		{Q("suite:a"), nil},
+		{Q("suite:a,b"), nil},
+		{Q("suite:a,b:c"), nil},
+		{Q("suite:a,b:c,d"), nil},
+		{Q("suite:a,b:c,d:e"), query.CaseParameters{"e": ""}},
+		{Q("suite:a,b:c,d:e;f"), query.CaseParameters{"e": "", "f": ""}},
+		{Q("suite:a,b:c,d:e=f;g=h"), query.CaseParameters{"e": "f", "g": "h"}},
+	} {
+		got := test.query.CaseParameters()
+		if diff := cmp.Diff(got, test.expect); diff != "" {
+			t.Errorf("'%v'.CaseParameters()\n%v", test.query, diff)
+		}
+	}
+}
+
+func TestTarget(t *testing.T) {
+	type Test struct {
+		query  query.Query
+		expect query.Target
+	}
+
+	for _, test := range []Test{
+		{Q("suite"), query.Suite},
+		{Q("suite:*"), query.Files},
+		{Q("suite:a"), query.Files},
+		{Q("suite:a,*"), query.Files},
+		{Q("suite:a,b"), query.Files},
+		{Q("suite:a,b:*"), query.Tests},
+		{Q("suite:a,b:c"), query.Tests},
+		{Q("suite:a,b:c,*"), query.Tests},
+		{Q("suite:a,b:c,d"), query.Tests},
+		{Q("suite:a,b:c,d:*"), query.Cases},
+		{Q("suite:a,b:c,d:e"), query.Cases},
+		{Q("suite:a,b:c,d:e;*"), query.Cases},
+		{Q("suite:a,b:c,d:e;f"), query.Cases},
+		{Q("suite:a,b:c,d:e;f;*"), query.Cases},
+	} {
+		got := test.query.Target()
+		if diff := cmp.Diff(got, test.expect); diff != "" {
+			t.Errorf("'%v'.Target()\n%v", test.query, diff)
+		}
+	}
+}
+
+func TestIsWildcard(t *testing.T) {
+	type Test struct {
+		query  query.Query
+		expect bool
+	}
+
+	for _, test := range []Test{
+		{Q("suite"), false},
+		{Q("suite:*"), true},
+		{Q("suite:a"), false},
+		{Q("suite:a,*"), true},
+		{Q("suite:a,b"), false},
+		{Q("suite:a,b:*"), true},
+		{Q("suite:a,b:c"), false},
+		{Q("suite:a,b:c,*"), true},
+		{Q("suite:a,b:c,d"), false},
+		{Q("suite:a,b:c,d:*"), true},
+		{Q("suite:a,b:c,d:e"), false},
+		{Q("suite:a,b:c,d:e;*"), true},
+		{Q("suite:a,b:c,d:e;f"), false},
+		{Q("suite:a,b:c,d:e;f;*"), true},
+	} {
+		got := test.query.IsWildcard()
+		if diff := cmp.Diff(got, test.expect); diff != "" {
+			t.Errorf("'%v'.IsWildcard()\n%v", test.query, diff)
+		}
+	}
+}
+
+func TestParsePrint(t *testing.T) {
+	type Test struct {
+		in     string
+		expect query.Query
+	}
+
+	for _, test := range []Test{
+		{
+			"a",
+			query.Query{
+				Suite: "a",
+			},
+		}, {
+			"a:*",
+			query.Query{
+				Suite: "a",
+				Files: "*",
+			},
+		}, {
+			"a:b",
+			query.Query{
+				Suite: "a",
+				Files: "b",
+			},
+		}, {
+			"a:b,*",
+			query.Query{
+				Suite: "a",
+				Files: "b,*",
+			},
+		}, {
+			"a:b:*",
+			query.Query{
+				Suite: "a",
+				Files: "b",
+				Tests: "*",
+			},
+		}, {
+			"a:b,c",
+			query.Query{
+				Suite: "a",
+				Files: "b,c",
+			},
+		}, {
+			"a:b,c:*",
+			query.Query{
+				Suite: "a",
+				Files: "b,c",
+				Tests: "*",
+			},
+		}, {
+			"a:b,c:d",
+			query.Query{
+				Suite: "a",
+				Files: "b,c",
+				Tests: "d",
+			},
+		}, {
+			"a:b,c:d,*",
+			query.Query{
+				Suite: "a",
+				Files: "b,c",
+				Tests: "d,*",
+			},
+		}, {
+			"a:b,c:d,e",
+			query.Query{
+				Suite: "a",
+				Files: "b,c",
+				Tests: "d,e",
+			},
+		}, {
+			"a:b,c:d,e,*",
+			query.Query{
+				Suite: "a",
+				Files: "b,c",
+				Tests: "d,e,*",
+			},
+		}, {
+			"a:b,c:d,e:*",
+			query.Query{
+				Suite: "a",
+				Files: "b,c",
+				Tests: "d,e",
+				Cases: "*",
+			},
+		}, {
+			"a:b,c:d,e:f=g",
+			query.Query{
+				Suite: "a",
+				Files: "b,c",
+				Tests: "d,e",
+				Cases: "f=g",
+			},
+		}, {
+			"a:b,c:d,e:f=g;*",
+			query.Query{
+				Suite: "a",
+				Files: "b,c",
+				Tests: "d,e",
+				Cases: "f=g;*",
+			},
+		}, {
+			"a:b,c:d,e:f=g;h=i",
+			query.Query{
+				Suite: "a",
+				Files: "b,c",
+				Tests: "d,e",
+				Cases: "f=g;h=i",
+			},
+		}, {
+			"a:b,c:d,e:f=g;h=i;*",
+			query.Query{
+				Suite: "a",
+				Files: "b,c",
+				Tests: "d,e",
+				Cases: "f=g;h=i;*",
+			},
+		}, {
+			`a:b,c:d,e:f={"x": 1, "y": 2}`,
+			query.Query{
+				Suite: "a",
+				Files: "b,c",
+				Tests: "d,e",
+				Cases: `f={"x": 1, "y": 2}`,
+			},
+		},
+	} {
+		parsed, err := query.Parse(test.in)
+		if err != nil {
+			t.Errorf("query.Parse('%v') returned %v", test.in, err)
+			continue
+		}
+		if diff := cmp.Diff(test.expect, parsed); diff != "" {
+			t.Errorf("query.Parse('%v')\n%v", test.in, diff)
+		}
+		str := test.expect.String()
+		if diff := cmp.Diff(test.in, str); diff != "" {
+			t.Errorf("query.String('%v')\n%v", test.in, diff)
+		}
+	}
+}
+
+func TestCompare(t *testing.T) {
+	type Test struct {
+		a, b   query.Query
+		expect int
+	}
+
+	for _, test := range []Test{
+		{Q("a"), Q("a"), 0},
+		{Q("a:*"), Q("a"), 1},
+		{Q("a:*"), Q("a:*"), 0},
+		{Q("a:*"), Q("b:*"), -1},
+		{Q("a:*"), Q("a:b,*"), -1},
+		{Q("a:b,*"), Q("a:b"), 1},
+		{Q("a:b,*"), Q("a:b,*"), 0},
+		{Q("a:b,*"), Q("a:c,*"), -1},
+		{Q("a:b,c,*"), Q("a:b,*"), 1},
+		{Q("a:b,c,*"), Q("a:b,c,*"), 0},
+		{Q("a:b,c,d,*"), Q("a:b,c,*"), 1},
+		{Q("a:b,c,*"), Q("a:b,c:d,*"), 1},
+		{Q("a:b,c:*"), Q("a:b,c,d,*"), -1},
+		{Q("a:b,c:d,*"), Q("a:b,c:d,*"), 0},
+		{Q("a:b,c:d,e,*"), Q("a:b,c:d,*"), 1},
+		{Q("a:b,c:d,e,*"), Q("a:b,c:d,e,*"), 0},
+		{Q("a:b,c:d,e,*"), Q("a:b,c:e,f,*"), -1},
+		{Q("a:b:c:d;*"), Q("a:b:c:d;*"), 0},
+		{Q("a:b:c:d;e=1;*"), Q("a:b:c:d;*"), 1},
+		{Q("a:b:c:d;e=2;*"), Q("a:b:c:d;e=1;*"), 1},
+		{Q("a:b:c:d;e=1;f=2;*"), Q("a:b:c:d;*"), 1},
+	} {
+		if got, expect := test.a.Compare(test.b), test.expect; got != expect {
+			t.Errorf("('%v').Compare('%v')\nexpect: %+v\ngot:    %+v", test.a, test.b, expect, got)
+		}
+		// Check opposite order
+		if got, expect := test.b.Compare(test.a), -test.expect; got != expect {
+			t.Errorf("('%v').Compare('%v')\nexpect: %+v\ngot:    %+v", test.b, test.a, expect, got)
+		}
+	}
+}
+
+func TestContains(t *testing.T) {
+	type Test struct {
+		a, b   query.Query
+		expect bool
+	}
+
+	for _, test := range []Test{
+		{Q("a"), Q("a"), true},
+		{Q("a"), Q("b"), false},
+		{Q("a:*"), Q("a:*"), true},
+		{Q("a:*"), Q("a:b"), true},
+		{Q("a:*"), Q("b"), false},
+		{Q("a:*"), Q("b:c"), false},
+		{Q("a:*"), Q("b:*"), false},
+		{Q("a:*"), Q("a:b,*"), true},
+		{Q("a:b,*"), Q("a:*"), false},
+		{Q("a:b,*"), Q("a:b"), true},
+		{Q("a:b,*"), Q("a:c"), false},
+		{Q("a:b,*"), Q("a:b,*"), true},
+		{Q("a:b,*"), Q("a:c,*"), false},
+		{Q("a:b,c"), Q("a:b,c,d"), false},
+		{Q("a:b,c"), Q("a:b,c:d"), false},
+		{Q("a:b,c,*"), Q("a:b,*"), false},
+		{Q("a:b,c,*"), Q("a:b,c"), true},
+		{Q("a:b,c,*"), Q("a:b,d"), false},
+		{Q("a:b,c,*"), Q("a:b,c,*"), true},
+		{Q("a:b,c,*"), Q("a:b,c,d,*"), true},
+		{Q("a:b,c,*"), Q("a:b,c:d,*"), true},
+		{Q("a:b,c:*"), Q("a:b,c,d,*"), false},
+		{Q("a:b,c:d"), Q("a:b,c:d,e"), false},
+		{Q("a:b,c:d,*"), Q("a:b,c:d"), true},
+		{Q("a:b,c:d,*"), Q("a:b,c:e"), false},
+		{Q("a:b,c:d,*"), Q("a:b,c:d,*"), true},
+		{Q("a:b,c:d,*"), Q("a:b,c:d,e,*"), true},
+		{Q("a:b,c:d,e,*"), Q("a:b,c:d,e"), true},
+		{Q("a:b,c:d,e,*"), Q("a:b,c:e,e"), false},
+		{Q("a:b,c:d,e,*"), Q("a:b,c:d,f"), false},
+		{Q("a:b,c:d,e,*"), Q("a:b,c:d,e,*"), true},
+		{Q("a:b,c:d,e,*"), Q("a:b,c:e,f,*"), false},
+		{Q("a:b,c:d,e,*"), Q("a:b,c:d,*"), false},
+		{Q("a:b:c:d;*"), Q("a:b:c:d;*"), true},
+		{Q("a:b:c:d;*"), Q("a:b:c:d,e;*"), true},
+		{Q("a:b:c:d;*"), Q("a:b:c:d;e=1;*"), true},
+		{Q("a:b:c:d;*"), Q("a:b:c:d;e=1;*"), true},
+		{Q("a:b:c:d;*"), Q("a:b:c:d;e=1;f=2;*"), true},
+		{Q("a:b:c:d;e=1;*"), Q("a:b:c:d;*"), true},
+		{Q("a:b:c:d;e=1;f=2;*"), Q("a:b:c:d;*"), true},
+		{Q("a:b:c:d;e=1;*"), Q("a:b:c:d;e=2;*"), false},
+		{Q("a:b:c:d;e=2;*"), Q("a:b:c:d;e=1;*"), false},
+		{Q("a:b:c:d;e;*"), Q("a:b:c:d;e=1;*"), false},
+	} {
+		if got := test.a.Contains(test.b); got != test.expect {
+			t.Errorf("('%v').Contains('%v')\nexpect: %+v\ngot:    %+v", test.a, test.b, test.expect, got)
+		}
+	}
+}
+
+func TestWalk(t *testing.T) {
+	type Segment struct {
+		Query  query.Query
+		Target query.Target
+		Name   string
+	}
+	type Test struct {
+		query  query.Query
+		expect []Segment
+	}
+
+	for _, test := range []Test{
+		{
+			Q("suite"), []Segment{
+				{Q("suite"), query.Suite, "suite"},
+			}},
+		{
+			Q("suite:*"), []Segment{
+				{Q("suite"), query.Suite, "suite"},
+				{Q("suite:*"), query.Files, "*"},
+			}},
+		{
+			Q("suite:a"), []Segment{
+				{Q("suite"), query.Suite, "suite"},
+				{Q("suite:a"), query.Files, "a"},
+			}},
+		{
+			Q("suite:a,*"), []Segment{
+				{Q("suite"), query.Suite, "suite"},
+				{Q("suite:a"), query.Files, "a"},
+				{Q("suite:a,*"), query.Files, "*"},
+			}},
+		{
+			Q("suite:a,b"), []Segment{
+				{Q("suite"), query.Suite, "suite"},
+				{Q("suite:a"), query.Files, "a"},
+				{Q("suite:a,b"), query.Files, "b"},
+			}},
+		{
+			Q("suite:a,b:*"), []Segment{
+				{Q("suite"), query.Suite, "suite"},
+				{Q("suite:a"), query.Files, "a"},
+				{Q("suite:a,b"), query.Files, "b"},
+				{Q("suite:a,b:*"), query.Tests, "*"},
+			}},
+		{
+			Q("suite:a,b:c"), []Segment{
+				{Q("suite"), query.Suite, "suite"},
+				{Q("suite:a"), query.Files, "a"},
+				{Q("suite:a,b"), query.Files, "b"},
+				{Q("suite:a,b:c"), query.Tests, "c"},
+			}},
+		{
+			Q("suite:a,b:c,*"), []Segment{
+				{Q("suite"), query.Suite, "suite"},
+				{Q("suite:a"), query.Files, "a"},
+				{Q("suite:a,b"), query.Files, "b"},
+				{Q("suite:a,b:c"), query.Tests, "c"},
+				{Q("suite:a,b:c,*"), query.Tests, "*"},
+			}},
+		{
+			Q("suite:a,b:c,d"), []Segment{
+				{Q("suite"), query.Suite, "suite"},
+				{Q("suite:a"), query.Files, "a"},
+				{Q("suite:a,b"), query.Files, "b"},
+				{Q("suite:a,b:c"), query.Tests, "c"},
+				{Q("suite:a,b:c,d"), query.Tests, "d"},
+			}},
+		{
+			Q("suite:a,b:c,d:*"), []Segment{
+				{Q("suite"), query.Suite, "suite"},
+				{Q("suite:a"), query.Files, "a"},
+				{Q("suite:a,b"), query.Files, "b"},
+				{Q("suite:a,b:c"), query.Tests, "c"},
+				{Q("suite:a,b:c,d"), query.Tests, "d"},
+				{Q("suite:a,b:c,d:*"), query.Cases, "*"},
+			}},
+		{
+			Q("suite:a,b:c,d:e"), []Segment{
+				{Q("suite"), query.Suite, "suite"},
+				{Q("suite:a"), query.Files, "a"},
+				{Q("suite:a,b"), query.Files, "b"},
+				{Q("suite:a,b:c"), query.Tests, "c"},
+				{Q("suite:a,b:c,d"), query.Tests, "d"},
+				{Q("suite:a,b:c,d:e"), query.Cases, "e"},
+			}},
+		{
+			Q("suite:a,b:c,d:e;*"), []Segment{
+				{Q("suite"), query.Suite, "suite"},
+				{Q("suite:a"), query.Files, "a"},
+				{Q("suite:a,b"), query.Files, "b"},
+				{Q("suite:a,b:c"), query.Tests, "c"},
+				{Q("suite:a,b:c,d"), query.Tests, "d"},
+				{Q("suite:a,b:c,d:e;*"), query.Cases, "e;*"},
+			}},
+		{
+			Q("suite:a,b:c,d:e;f"), []Segment{
+				{Q("suite"), query.Suite, "suite"},
+				{Q("suite:a"), query.Files, "a"},
+				{Q("suite:a,b"), query.Files, "b"},
+				{Q("suite:a,b:c"), query.Tests, "c"},
+				{Q("suite:a,b:c,d"), query.Tests, "d"},
+				{Q("suite:a,b:c,d:e;f"), query.Cases, "e;f"},
+			}},
+		{
+			Q("suite:a,b:c,d:e;f;*"), []Segment{
+				{Q("suite"), query.Suite, "suite"},
+				{Q("suite:a"), query.Files, "a"},
+				{Q("suite:a,b"), query.Files, "b"},
+				{Q("suite:a,b:c"), query.Tests, "c"},
+				{Q("suite:a,b:c,d"), query.Tests, "d"},
+				{Q("suite:a,b:c,d:e;f;*"), query.Cases, "e;f;*"},
+			}},
+	} {
+		got := []Segment{}
+		err := test.query.Walk(func(q query.Query, t query.Target, n string) error {
+			got = append(got, Segment{q, t, n})
+			return nil
+		})
+		if err != nil {
+			t.Errorf("'%v'.Walk() returned %v", test.query, err)
+			continue
+		}
+		if diff := cmp.Diff(got, test.expect); diff != "" {
+			t.Errorf("'%v'.Walk()\n%v", test.query, diff)
+		}
+	}
+}
+
+type TestError struct{}
+
+func (TestError) Error() string { return "test error" }
+
+func TestWalkErrors(t *testing.T) {
+	for _, fq := range []query.Query{
+		Q("suite"),
+		Q("suite:*"),
+		Q("suite:a"),
+		Q("suite:a,*"),
+		Q("suite:a,b"),
+		Q("suite:a,b:*"),
+		Q("suite:a,b:c"),
+		Q("suite:a,b:c,*"),
+		Q("suite:a,b:c,d"),
+		Q("suite:a,b:c,d:*"),
+		Q("suite:a,b:c,d:e"),
+		Q("suite:a,b:c,d:e;*"),
+		Q("suite:a,b:c,d:e;f"),
+		Q("suite:a,b:c,d:e;f;*"),
+	} {
+		expect := TestError{}
+		got := fq.Walk(func(q query.Query, t query.Target, n string) error {
+			if q == fq {
+				return expect
+			}
+			return nil
+		})
+		if diff := cmp.Diff(got, expect); diff != "" {
+			t.Errorf("'%v'.Walk()\n%v", fq, diff)
+		}
+	}
+}