[tools] Add cnf library (Conjunctive normal form)

This will be used to parse the conditional expressions in the build
generator.

Change-Id: I95bf651f0e4b3b2d63265fee357426d7de1f2909
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/147740
Auto-Submit: Ben Clayton <bclayton@google.com>
Reviewed-by: James Price <jrprice@google.com>
Commit-Queue: James Price <jrprice@google.com>
Kokoro: Kokoro <noreply+kokoro@google.com>
diff --git a/tools/src/cnf/cnf.go b/tools/src/cnf/cnf.go
new file mode 100644
index 0000000..185b90d
--- /dev/null
+++ b/tools/src/cnf/cnf.go
@@ -0,0 +1,19 @@
+// Copyright 2023 The Tint 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 cnf provides utilities for parsing and operating on boolean
+// expressions in a Conjunctive Normal Form
+//
+// See: https://en.wikipedia.org/wiki/Conjunctive_normal_form
+package cnf
diff --git a/tools/src/cnf/decompose.go b/tools/src/cnf/decompose.go
new file mode 100644
index 0000000..3b01ce8
--- /dev/null
+++ b/tools/src/cnf/decompose.go
@@ -0,0 +1,59 @@
+// Copyright 2023 The Tint 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 cnf
+
+import (
+	"dawn.googlesource.com/dawn/tools/src/container"
+)
+
+// / Decomposed holds a decomposed expression
+// / See Decompose()
+type Decomposed struct {
+	Ands   []Ands  // The decomposed Ands
+	Ors    []Ors   // The decomposed Ors
+	Unarys []Unary // The decomposed Unarys
+}
+
+// Decompose returns the unique Ands, Ors and Unarys that make up the expression.
+// If e has two or more OR expressions AND'd together, then Decomposed.Ands will
+// hold the deduplicated AND expressions, otherwise Decomposed.Ands will be
+// empty.
+// If e has two or more Unary expressions OR'd together, then Decomposed.ORs
+// will hold the deduplicated OR expressions, otherwise Decomposed.Ands will be
+// empty.
+// Decomposed.Unarys will hold all the deduplicated Unary expressions.
+func Decompose(e Expr) Decomposed {
+	ors := container.NewMap[Key, Ors]()
+	unarys := container.NewMap[Key, Unary]()
+	for _, o := range e {
+		for _, u := range o {
+			unarys.Add(u.Key(), u)
+		}
+		if len(o) > 1 {
+			ors.Add(o.Key(), o)
+		}
+	}
+	d := Decomposed{}
+	if len(e) > 1 {
+		d.Ands = []Ands{e}
+	}
+	if len(ors) > 0 {
+		d.Ors = ors.Values()
+	}
+	if len(unarys) > 0 {
+		d.Unarys = unarys.Values()
+	}
+	return d
+}
diff --git a/tools/src/cnf/decompose_test.go b/tools/src/cnf/decompose_test.go
new file mode 100644
index 0000000..229c9ff
--- /dev/null
+++ b/tools/src/cnf/decompose_test.go
@@ -0,0 +1,95 @@
+// Copyright 2023 The Tint 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 cnf_test
+
+import (
+	"testing"
+
+	"dawn.googlesource.com/dawn/tools/src/cnf"
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestDecompose(t *testing.T) {
+
+	for _, test := range []struct {
+		in     string
+		expect cnf.Decomposed
+	}{
+		{
+			in:     ``,
+			expect: cnf.Decomposed{},
+		},
+		{
+			in: `X`,
+			expect: cnf.Decomposed{
+				Unarys: []cnf.Unary{T("X")},
+			},
+		},
+		{
+			in: `X || Y`,
+			expect: cnf.Decomposed{
+				Ors:    []cnf.Ors{{T("X"), T("Y")}},
+				Unarys: []cnf.Unary{T("X"), T("Y")},
+			},
+		},
+		{
+			in: `!X || Y`,
+			expect: cnf.Decomposed{
+				Ors:    []cnf.Ors{{F("X"), T("Y")}},
+				Unarys: []cnf.Unary{F("X"), T("Y")},
+			},
+		},
+		{
+			in: `X || !Y`,
+			expect: cnf.Decomposed{
+				Ors:    []cnf.Ors{{T("X"), F("Y")}},
+				Unarys: []cnf.Unary{T("X"), F("Y")},
+			},
+		},
+		{
+			in: `X || Y || Z`,
+			expect: cnf.Decomposed{
+				Ors:    []cnf.Ors{{T("X"), T("Y"), T("Z")}},
+				Unarys: []cnf.Unary{T("X"), T("Y"), T("Z")},
+			},
+		},
+		{
+			in: `(X || Y) && Z`,
+			expect: cnf.Decomposed{
+				Ands:   []cnf.Ands{{{T("X"), T("Y")}, {T("Z")}}},
+				Ors:    []cnf.Ors{{T("X"), T("Y")}},
+				Unarys: []cnf.Unary{T("X"), T("Y"), T("Z")},
+			},
+		},
+		{
+			in: `(X || Y) && (X || Y)`,
+			expect: cnf.Decomposed{
+				Ands:   []cnf.Ands{{{T("X"), T("Y")}, {T("X"), T("Y")}}},
+				Ors:    []cnf.Ors{{T("X"), T("Y")}},
+				Unarys: []cnf.Unary{T("X"), T("Y")},
+			},
+		},
+	} {
+		expr, err := cnf.Parse(test.in)
+		if err != nil {
+			t.Errorf(`unexpected error returned from Parse('%v'): %v`, test.in, err)
+			continue
+		}
+		got := cnf.Decompose(expr)
+		if diff := cmp.Diff(test.expect, got); diff != "" {
+			t.Errorf("Decompose('%v') returned '%v'. Diff:\n%v", test.in, got, diff)
+		}
+	}
+}
diff --git a/tools/src/cnf/expr.go b/tools/src/cnf/expr.go
new file mode 100644
index 0000000..2466901
--- /dev/null
+++ b/tools/src/cnf/expr.go
@@ -0,0 +1,40 @@
+// Copyright 2023 The Tint 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 cnf
+
+// Expr is a boolean expression, expressed in a Conjunctive Normal Form.
+// Expr is an alias to Ands, which represent all the OR expressions that are
+// AND'd together.
+type Expr = Ands
+
+// Ands is a slice of Ors
+// Ands holds a sequence of OR'd expressions that are AND'd together:
+//
+//	Ors[0] && Ors[1] && ... && Ors[n-1]
+type Ands []Ors
+
+// Ors is a slice of Unary
+// Ors holds a sequence of unary expressions that are OR'd together:
+//
+//	Unary[0] || Unary[1] || ... || Unary[n-1]
+type Ors []Unary
+
+// Unary is a negatable variable
+type Unary struct {
+	// If true, Var is negated
+	Negate bool
+	// The name of the variable
+	Var string
+}
diff --git a/tools/src/cnf/format.go b/tools/src/cnf/format.go
new file mode 100644
index 0000000..fa96f48
--- /dev/null
+++ b/tools/src/cnf/format.go
@@ -0,0 +1,63 @@
+// Copyright 2023 The Tint 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 cnf
+
+import (
+	"strings"
+
+	"dawn.googlesource.com/dawn/tools/src/transform"
+)
+
+// String returns the expression in a C-like syntax
+func (e Expr) String() string {
+	return e.Format(" && ", " || ", "!")
+}
+
+// String returns the expression in a C-like syntax
+func (o Ors) String() string {
+	return o.Format(" || ", "!")
+}
+
+// String returns the expression in a C-like syntax
+func (u Unary) String() string {
+	return u.Format("!")
+}
+
+// Format returns the expression using the provided operators
+func (e Expr) Format(and, or, not string) string {
+	parts, _ := transform.Slice(e, func(ors Ors) (string, error) {
+		if len(e) > 1 && len(ors) > 1 {
+			return "(" + ors.Format(or, not) + ")", nil
+		}
+		return ors.Format(or, not), nil
+	})
+	return strings.Join(parts, and)
+}
+
+// Format returns the expression using the provided operators
+func (o Ors) Format(or, not string) string {
+	parts, _ := transform.Slice(o, func(u Unary) (string, error) {
+		return u.Format(not), nil
+	})
+	return strings.Join(parts, or)
+}
+
+// Format returns the expression using the provided operator
+func (u Unary) Format(not string) string {
+	if u.Negate {
+		return "(" + not + u.Var + ")"
+	}
+	return u.Var
+}
diff --git a/tools/src/cnf/helpers_test.go b/tools/src/cnf/helpers_test.go
new file mode 100644
index 0000000..31af84b
--- /dev/null
+++ b/tools/src/cnf/helpers_test.go
@@ -0,0 +1,29 @@
+// Copyright 2023 The Tint 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 cnf_test
+
+import (
+	"dawn.googlesource.com/dawn/tools/src/cnf"
+)
+
+// T returns a Unary with no negation
+func T(s string) cnf.Unary {
+	return cnf.Unary{Var: s}
+}
+
+// F returns a Unary with negation
+func F(s string) cnf.Unary {
+	return cnf.Unary{Var: s, Negate: true}
+}
diff --git a/tools/src/cnf/key.go b/tools/src/cnf/key.go
new file mode 100644
index 0000000..a37db90
--- /dev/null
+++ b/tools/src/cnf/key.go
@@ -0,0 +1,48 @@
+// Copyright 2023 The Tint 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 cnf
+
+import (
+	"strings"
+
+	"dawn.googlesource.com/dawn/tools/src/transform"
+)
+
+// Key is a map key type returned by Key() methods on Ands, Ors and Unary.
+type Key string
+
+// Key returns a Key that can be used to sort the Ands
+func (e Ands) Key() Key {
+	parts, _ := transform.Slice(e, func(o Ors) (string, error) {
+		return string(o.Key()), nil
+	})
+	return Key(strings.Join(parts, "&&"))
+}
+
+// Key returns a Key that can be used to sort the Ors
+func (o Ors) Key() Key {
+	parts, _ := transform.Slice(o, func(u Unary) (string, error) {
+		return string(u.Key()), nil
+	})
+	return Key(strings.Join(parts, "||"))
+}
+
+// Key returns a Key that can be used to sort the Unary
+func (u Unary) Key() Key {
+	if u.Negate {
+		return Key(u.Var + "!")
+	}
+	return Key(u.Var)
+}
diff --git a/tools/src/cnf/ops.go b/tools/src/cnf/ops.go
new file mode 100644
index 0000000..551cd2e
--- /dev/null
+++ b/tools/src/cnf/ops.go
@@ -0,0 +1,52 @@
+// Copyright 2023 The Tint 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 cnf
+
+import "dawn.googlesource.com/dawn/tools/src/transform"
+
+// And returns a new expression that represents (lhs ∧ rhs)
+func And(lhs, rhs Expr) Expr {
+	return append(append(Expr{}, lhs...), rhs...)
+}
+
+// Or returns a new expression that represents (lhs ∨ rhs)
+func Or(lhs, rhs Expr) Expr {
+	if len(lhs) == 0 {
+		return rhs
+	}
+	if len(rhs) == 0 {
+		return lhs
+	}
+	out := Expr{}
+	for _, aOrs := range lhs {
+		for _, bOrs := range rhs {
+			out = append(out, append(append(Ors{}, aOrs...), bOrs...))
+		}
+	}
+	return out
+}
+
+// Not returns a new expression that represents (¬expr)
+func Not(expr Expr) Expr {
+	// Transform each of the OR expressions into AND expressions where each unary is negated.
+	out := Expr{}
+	for _, o := range expr {
+		ands, _ := transform.Slice(o, func(u Unary) (Ors, error) {
+			return Ors{Unary{Negate: !u.Negate, Var: u.Var}}, nil
+		})
+		out = Or(out, ands)
+	}
+	return out
+}
diff --git a/tools/src/cnf/ops_test.go b/tools/src/cnf/ops_test.go
new file mode 100644
index 0000000..bc61dfa
--- /dev/null
+++ b/tools/src/cnf/ops_test.go
@@ -0,0 +1,360 @@
+// Copyright 2023 The Tint 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 cnf_test
+
+import (
+	"testing"
+
+	"dawn.googlesource.com/dawn/tools/src/cnf"
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestAnd(t *testing.T) {
+	type E = cnf.Expr
+	for _, test := range []struct {
+		lhs, rhs cnf.Expr
+		expect   cnf.Expr
+	}{
+		{ // And('', '') => ''
+			lhs: E{}, rhs: E{},
+			expect: E{},
+		},
+		{ // And('X', '') => 'X'
+			lhs: E{{T("X")}}, rhs: E{},
+			expect: E{{T("X")}},
+		},
+		{ // And('!X', '') => '!X'
+			lhs: E{{F("X")}}, rhs: E{},
+			expect: E{{F("X")}},
+		},
+		{ // And('', 'X') => 'X'
+			lhs: E{}, rhs: E{{T("X")}},
+			expect: E{{T("X")}},
+		},
+		{ // And('', '!X') => '!X'
+			lhs: E{}, rhs: E{{F("X")}},
+			expect: E{{F("X")}},
+		},
+		{ // And('X', 'Y') => 'X && Y'
+			lhs: E{{T("X")}}, rhs: E{{T("Y")}},
+			expect: E{{T("X")}, {T("Y")}},
+		},
+		{ // And('X', '!Y') => 'X && !Y'
+			lhs: E{{T("X")}}, rhs: E{{F("Y")}},
+			expect: E{{T("X")}, {F("Y")}},
+		},
+		{ // And('Y', 'X') => 'Y && X'
+			lhs: E{{T("Y")}}, rhs: E{{T("X")}},
+			expect: E{{T("Y")}, {T("X")}},
+		},
+		{ // And('!Y', 'X') => '!Y && X'
+			lhs: E{{F("Y")}}, rhs: E{{T("X")}},
+			expect: E{{F("Y")}, {T("X")}},
+		},
+		{ // And('X && Y', 'Z') => 'X && Y && Z'
+			lhs: E{{T("X")}, {T("Y")}}, rhs: E{{T("Z")}},
+			expect: E{{T("X")}, {T("Y")}, {T("Z")}},
+		},
+		{ // And('!X && Y', 'Z') => '!X && Y && Z'
+			lhs: E{{F("X")}, {T("Y")}}, rhs: E{{T("Z")}},
+			expect: E{{F("X")}, {T("Y")}, {T("Z")}},
+		},
+		{ // And('X && !Y', 'Z') => 'X && !Y && Z'
+			lhs: E{{T("X")}, {F("Y")}}, rhs: E{{T("Z")}},
+			expect: E{{T("X")}, {F("Y")}, {T("Z")}},
+		},
+		{ // And('X && Y', '!Z') => 'X && Y && !Z'
+			lhs: E{{T("X")}, {T("Y")}}, rhs: E{{F("Z")}},
+			expect: E{{T("X")}, {T("Y")}, {F("Z")}},
+		},
+		{ // And('X', 'Y && Z') => 'X && Y && Z'
+			lhs: E{{T("X")}}, rhs: E{{T("Y")}, {T("Z")}},
+			expect: E{{T("X")}, {T("Y")}, {T("Z")}},
+		},
+		{ // And('!X', 'Y && Z') => '!X && Y && Z'
+			lhs: E{{F("X")}}, rhs: E{{T("Y")}, {T("Z")}},
+			expect: E{{F("X")}, {T("Y")}, {T("Z")}},
+		},
+		{ // And('X', '!Y && Z') => 'X && !Y && Z'
+			lhs: E{{T("X")}}, rhs: E{{F("Y")}, {T("Z")}},
+			expect: E{{T("X")}, {F("Y")}, {T("Z")}},
+		},
+		{ // And('X', 'Y && !Z') => 'X && Y && !Z'
+			lhs: E{{T("X")}}, rhs: E{{T("Y")}, {F("Z")}},
+			expect: E{{T("X")}, {T("Y")}, {F("Z")}},
+		},
+		{ // And('X && Y', 'Y && Z') => 'X && Y && Y && Z'
+			lhs: E{{T("X")}, {T("Y")}}, rhs: E{{T("Y")}, {T("Z")}},
+			expect: E{{T("X")}, {T("Y")}, {T("Y")}, {T("Z")}},
+		},
+		{ // And('!X && Y', 'Y && !Z') => '!X && Y && Y && !Z'
+			lhs: E{{F("X")}, {T("Y")}}, rhs: E{{T("Y")}, {F("Z")}},
+			expect: E{{F("X")}, {T("Y")}, {T("Y")}, {F("Z")}},
+		},
+		{ // And('X || Y', 'Z') => '(X || Y) && Z'
+			lhs: E{{T("X"), T("Y")}}, rhs: E{{T("Z")}},
+			expect: E{{T("X"), T("Y")}, {T("Z")}},
+		},
+		{ // And('!X || Y', 'Z') => '(!X || Y) && Z'
+			lhs: E{{F("X"), T("Y")}}, rhs: E{{T("Z")}},
+			expect: E{{F("X"), T("Y")}, {T("Z")}},
+		},
+		{ // And('X || !Y', 'Z') => '(X || !Y) && Z'
+			lhs: E{{T("X"), F("Y")}}, rhs: E{{T("Z")}},
+			expect: E{{T("X"), F("Y")}, {T("Z")}},
+		},
+		{ // And('X || Y', '!Z') => '(X || Y) && !Z'
+			lhs: E{{T("X"), T("Y")}}, rhs: E{{F("Z")}},
+			expect: E{{T("X"), T("Y")}, {F("Z")}},
+		},
+		{ // And('X', 'Y || Z') => 'X && (Y || Z)'
+			lhs: E{{T("X")}}, rhs: E{{T("Y"), T("Z")}},
+			expect: E{{T("X")}, {T("Y"), T("Z")}},
+		},
+		{ // And('!X', 'Y || Z') => '!X && (Y || Z)'
+			lhs: E{{F("X")}}, rhs: E{{T("Y"), T("Z")}},
+			expect: E{{F("X")}, {T("Y"), T("Z")}},
+		},
+		{ // And('X', '!Y || Z') => 'X && (!Y || Z)'
+			lhs: E{{T("X")}}, rhs: E{{F("Y"), T("Z")}},
+			expect: E{{T("X")}, {F("Y"), T("Z")}},
+		},
+		{ // And('X', 'Y || !Z') => 'X && (Y || !Z)'
+			lhs: E{{T("X")}}, rhs: E{{T("Y"), F("Z")}},
+			expect: E{{T("X")}, {T("Y"), F("Z")}},
+		},
+		{ // And('X || Y', 'Y || Z') => '(X || Y) && (Y || Z)'
+			lhs: E{{T("X"), T("Y")}}, rhs: E{{T("Y"), T("Z")}},
+			expect: E{{T("X"), T("Y")}, {T("Y"), T("Z")}},
+		},
+		{ // And('!X || Y', 'Y || !Z') => '(!X || Y) && (Y || !Z)'
+			lhs: E{{F("X"), T("Y")}}, rhs: E{{T("Y"), F("Z")}},
+			expect: E{{F("X"), T("Y")}, {T("Y"), F("Z")}},
+		},
+		{ // And('X || Y', 'Y && Z') => '(X || Y) && Y && Z'
+			lhs: E{{T("X"), T("Y")}}, rhs: E{{T("Y")}, {T("Z")}},
+			expect: E{{T("X"), T("Y")}, {T("Y")}, {T("Z")}},
+		},
+		{ // And('!X && Y', 'Y || !Z') => '!X && Y && (Y || !Z)'
+			lhs: E{{F("X")}, {T("Y")}}, rhs: E{{T("Y"), F("Z")}},
+			expect: E{{F("X")}, {T("Y")}, {T("Y"), F("Z")}},
+		},
+	} {
+		got := cnf.And(test.lhs, test.rhs)
+		if diff := cmp.Diff(test.expect, got); diff != "" {
+			t.Errorf("And('%v', '%v') returned '%v'. Diff:\n%v", test.lhs, test.rhs, got, diff)
+		}
+	}
+}
+
+func TestOr(t *testing.T) {
+	type E = cnf.Expr
+	for _, test := range []struct {
+		lhs, rhs cnf.Expr
+		expect   cnf.Expr
+	}{
+		{ // Or('', '') => ''
+			lhs: E{}, rhs: E{},
+			expect: E{},
+		},
+		{ // Or('X', '') => 'X'
+			lhs: E{{T("X")}}, rhs: E{},
+			expect: E{{T("X")}},
+		},
+		{ // Or('!X', '') => '!X'
+			lhs: E{{F("X")}}, rhs: E{},
+			expect: E{{F("X")}},
+		},
+		{ // Or('', 'X') => 'X'
+			lhs: E{}, rhs: E{{T("X")}},
+			expect: E{{T("X")}},
+		},
+		{ // Or('', '!X') => '!X'
+			lhs: E{}, rhs: E{{F("X")}},
+			expect: E{{F("X")}},
+		},
+		{ // Or('X', 'Y') => 'X || Y'
+			lhs: E{{T("X")}}, rhs: E{{T("Y")}},
+			expect: E{{T("X"), T("Y")}},
+		},
+		{ // Or('X', '!Y') => 'X || !Y'
+			lhs: E{{T("X")}}, rhs: E{{F("Y")}},
+			expect: E{{T("X"), F("Y")}},
+		},
+		{ // Or('Y', 'X') => 'Y || X'
+			lhs: E{{T("Y")}}, rhs: E{{T("X")}},
+			expect: E{{T("Y"), T("X")}},
+		},
+		{ // Or('!Y', 'X') => '!Y || X'
+			lhs: E{{F("Y")}}, rhs: E{{T("X")}},
+			expect: E{{F("Y"), T("X")}},
+		},
+		{ // Or('X && Y', 'Z') => '(X || Z) && (Y || Z)'
+			lhs: E{{T("X")}, {T("Y")}}, rhs: E{{T("Z")}},
+			expect: E{{T("X"), T("Z")}, {T("Y"), T("Z")}},
+		},
+		{ // Or('!X && Y', 'Z') => '(!X || Z) && (Y || Z)'
+			lhs: E{{F("X")}, {T("Y")}}, rhs: E{{T("Z")}},
+			expect: E{{F("X"), T("Z")}, {T("Y"), T("Z")}},
+		},
+		{ // Or('X && !Y', 'Z') => '(X || Z) && (!Y || Z)'
+			lhs: E{{T("X")}, {F("Y")}}, rhs: E{{T("Z")}},
+			expect: E{{T("X"), T("Z")}, {F("Y"), T("Z")}},
+		},
+		{ // Or('X && Y', '!Z') => '(X || !Z) && (Y || !Z)'
+			lhs: E{{T("X")}, {T("Y")}}, rhs: E{{F("Z")}},
+			expect: E{{T("X"), F("Z")}, {T("Y"), F("Z")}},
+		},
+		{ // Or('X', 'Y && Z') => '(X || Y) && (X || Z)'
+			lhs: E{{T("X")}}, rhs: E{{T("Y")}, {T("Z")}},
+			expect: E{{T("X"), T("Y")}, {T("X"), T("Z")}},
+		},
+		{ // Or('!X', 'Y && Z') => '(!X || Y) && (!X || Z)'
+			lhs: E{{F("X")}}, rhs: E{{T("Y")}, {T("Z")}},
+			expect: E{{F("X"), T("Y")}, {F("X"), T("Z")}},
+		},
+		{ // Or('X', '!Y && Z') => '(X || !Y) && (X || Z)'
+			lhs: E{{T("X")}}, rhs: E{{F("Y")}, {T("Z")}},
+			expect: E{{T("X"), F("Y")}, {T("X"), T("Z")}},
+		},
+		{ // Or('X', 'Y && !Z') => '(X || Y) && (X || !Z)'
+			lhs: E{{T("X")}}, rhs: E{{T("Y")}, {F("Z")}},
+			expect: E{{T("X"), T("Y")}, {T("X"), F("Z")}},
+		},
+		{ // Or('X && Y', 'Z && W') => '(X || Z) && (X || W) && (Y || Z) && (Y || W)'
+			lhs: E{{T("X")}, {T("Y")}}, rhs: E{{T("Z")}, {T("W")}},
+			expect: E{{T("X"), T("Z")}, {T("X"), T("W")}, {T("Y"), T("Z")}, {T("Y"), T("W")}},
+		},
+		{ // Or('!X && Y', 'Z && !W') => '(!X || Z) && (!X || !W) && (Y || Z) && (Y || !W)'
+			lhs: E{{F("X")}, {T("Y")}}, rhs: E{{T("Z")}, {F("W")}},
+			expect: E{{F("X"), T("Z")}, {F("X"), F("W")}, {T("Y"), T("Z")}, {T("Y"), F("W")}},
+		},
+		{ // Or('X || Y', 'Z') => 'X || Y || Z'
+			lhs: E{{T("X"), T("Y")}}, rhs: E{{T("Z")}},
+			expect: E{{T("X"), T("Y"), T("Z")}},
+		},
+		{ // Or('!X || Y', 'Z') => '!X || Y || Z'
+			lhs: E{{F("X"), T("Y")}}, rhs: E{{T("Z")}},
+			expect: E{{F("X"), T("Y"), T("Z")}},
+		},
+		{ // Or('X || !Y', 'Z') => 'X || !Y || Z'
+			lhs: E{{T("X"), F("Y")}}, rhs: E{{T("Z")}},
+			expect: E{{T("X"), F("Y"), T("Z")}},
+		},
+		{ // Or('X || Y', '!Z') => 'X || Y || !Z'
+			lhs: E{{T("X"), T("Y")}}, rhs: E{{F("Z")}},
+			expect: E{{T("X"), T("Y"), F("Z")}},
+		},
+		{ // Or('X', 'Y || Z') => 'X || Y || Z'
+			lhs: E{{T("X")}}, rhs: E{{T("Y"), T("Z")}},
+			expect: E{{T("X"), T("Y"), T("Z")}},
+		},
+		{ // Or('!X', 'Y || Z') => '!X || Y || Z'
+			lhs: E{{F("X")}}, rhs: E{{T("Y"), T("Z")}},
+			expect: E{{F("X"), T("Y"), T("Z")}},
+		},
+		{ // Or('X', '!Y || Z') => 'X || !Y || Z'
+			lhs: E{{T("X")}}, rhs: E{{F("Y"), T("Z")}},
+			expect: E{{T("X"), F("Y"), T("Z")}},
+		},
+		{ // Or('X', 'Y || !Z') => 'X || Y || !Z'
+			lhs: E{{T("X")}}, rhs: E{{T("Y"), F("Z")}},
+			expect: E{{T("X"), T("Y"), F("Z")}},
+		},
+		{ // Or('X || Y', 'Y || Z') => 'X || Y || Y || Z'
+			lhs: E{{T("X"), T("Y")}}, rhs: E{{T("Y"), T("Z")}},
+			expect: E{{T("X"), T("Y"), T("Y"), T("Z")}},
+		},
+		{ // Or('!X || Y', 'Y || !Z') => '!X || Y || Y || !Z'
+			lhs: E{{F("X"), T("Y")}}, rhs: E{{T("Y"), F("Z")}},
+			expect: E{{F("X"), T("Y"), T("Y"), F("Z")}},
+		},
+		{ // Or('X || Y', 'Y && Z') => '(X || Y || Y) && (X || Y || Z)'
+			lhs: E{{T("X"), T("Y")}}, rhs: E{{T("Y")}, {T("Z")}},
+			expect: E{{T("X"), T("Y"), T("Y")}, {T("X"), T("Y"), T("Z")}},
+		},
+		{ // Or('!X && Y', 'Z || !W') => '(!X || Z || !W) && (Y || Z || !W)'
+			lhs: E{{F("X")}, {T("Y")}}, rhs: E{{T("Z"), F("W")}},
+			expect: E{{F("X"), T("Z"), F("W")}, {T("Y"), T("Z"), F("W")}},
+		},
+	} {
+		got := cnf.Or(test.lhs, test.rhs)
+		if diff := cmp.Diff(test.expect, got); diff != "" {
+			t.Errorf("Or('%v', '%v') returned '%v'. Diff:\n%v", test.lhs, test.rhs, got, diff)
+		}
+	}
+}
+
+func TestNot(t *testing.T) {
+	type E = cnf.Expr
+	for _, test := range []struct {
+		expr   cnf.Expr
+		expect cnf.Expr
+	}{
+		{ // Not('') => ''
+			expr:   E{},
+			expect: E{},
+		},
+		{ // Not('X') => '!X'
+			expr:   E{{T("X")}},
+			expect: E{{F("X")}},
+		},
+		{ // Not('!X') => 'X'
+			expr:   E{{F("X")}},
+			expect: E{{T("X")}},
+		},
+		{ // Not('X || Y') => '!X && !Y'
+			expr:   E{{T("X"), T("Y")}},
+			expect: E{{F("X")}, {F("Y")}},
+		},
+		{ // Not('X || !Y') => '!X && Y'
+			expr:   E{{T("X"), F("Y")}},
+			expect: E{{F("X")}, {T("Y")}},
+		},
+		{ // Not('X && Y') => '!X || !Y'
+			expr:   E{{T("X")}, {T("Y")}},
+			expect: E{{F("X"), F("Y")}},
+		},
+		{ // Not('!X && Y') => 'X || !Y'
+			expr:   E{{F("X")}, {T("Y")}},
+			expect: E{{T("X"), F("Y")}},
+		},
+		{ // Not('(X || Y) && Z') => '(!X || !Z) && (!Y || !Z)'
+			expr:   E{{T("X"), T("Y")}, {T("Z")}},
+			expect: E{{F("X"), F("Z")}, {F("Y"), F("Z")}},
+		},
+		{ // Not('X && (Y || Z)') => '(!X || !Y) && (!X || !Z)'
+			expr:   E{{T("X")}, {T("Y"), T("Z")}},
+			expect: E{{F("X"), F("Y")}, {F("X"), F("Z")}},
+		},
+		{ // Not('X && (!Y || Z)') => '(!X || Y) && (!X || !Z)'
+			expr:   E{{T("X")}, {F("Y"), T("Z")}},
+			expect: E{{F("X"), T("Y")}, {F("X"), F("Z")}},
+		},
+		{ // Not('(X || Y) && (Z || W)') => '(!X || !Z) && (!X || !W) && (!Y || !Z) && (!Y || !W)'
+			expr:   E{{T("X"), T("Y")}, {T("Z"), T("W")}},
+			expect: E{{F("X"), F("Z")}, {F("X"), F("W")}, {F("Y"), F("Z")}, {F("Y"), F("W")}},
+		},
+		{ // Not('(X || !Y) && (!Z || W)') => '(!X || Z) && (!X || !W) && (Y || Z) && (Y || !W)'
+			expr:   E{{T("X"), F("Y")}, {F("Z"), T("W")}},
+			expect: E{{F("X"), T("Z")}, {F("X"), F("W")}, {T("Y"), T("Z")}, {T("Y"), F("W")}},
+		},
+	} {
+		got := cnf.Not(test.expr)
+		if diff := cmp.Diff(test.expect, got); diff != "" {
+			t.Errorf("Not('%v') returned '%v'. Diff:\n%v", test.expr, got, diff)
+		}
+	}
+}
diff --git a/tools/src/cnf/optimize.go b/tools/src/cnf/optimize.go
new file mode 100644
index 0000000..6f61d97
--- /dev/null
+++ b/tools/src/cnf/optimize.go
@@ -0,0 +1,40 @@
+// Copyright 2023 The Tint 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 cnf
+
+import (
+	"dawn.googlesource.com/dawn/tools/src/container"
+)
+
+// Optimize returns the expression with all duplicate unary expressions in the
+// ORs removed. The returned expression is sorted.
+func Optimize(e Expr) Expr {
+	m := container.NewMap[Key, Ors]()
+	for _, o := range e {
+		b := unique(o)
+		m.Add(b.Key(), b)
+	}
+	return m.Values()
+}
+
+// unique returns the o with all duplicate unary expressions removed
+// The returned OR expression list is sorted.
+func unique(o Ors) Ors {
+	m := container.NewMap[Key, Unary]()
+	for _, u := range o {
+		m.Add(u.Key(), u)
+	}
+	return m.Values()
+}
diff --git a/tools/src/cnf/optimize_test.go b/tools/src/cnf/optimize_test.go
new file mode 100644
index 0000000..fbb2b66
--- /dev/null
+++ b/tools/src/cnf/optimize_test.go
@@ -0,0 +1,66 @@
+// Copyright 2023 The Tint 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 cnf_test
+
+import (
+	"testing"
+
+	"dawn.googlesource.com/dawn/tools/src/cnf"
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestOptimize(t *testing.T) {
+	for _, test := range []struct {
+		in  string
+		out string
+	}{
+		// no-ops
+		{in: `X`, out: `X`},
+		{in: `X && Y`, out: `X && Y`},
+		{in: `X && Y && Z`, out: `X && Y && Z`},
+		{in: `X || Y || Z`, out: `X || Y || Z`},
+		{in: `(X || Y) && (X || Z)`, out: `(X || Y) && (X || Z)`},
+
+		// Sorting
+		{in: `Z || X || Y`, out: `X || Y || Z`},
+		{in: `!Z || X || Y`, out: `X || Y || (!Z)`},
+		{in: `X || !X || Y`, out: `X || (!X) || Y`},
+		{in: `Z && X && Y`, out: `X && Y && Z`},
+		{in: `Z && !X && Y`, out: `(!X) && Y && Z`},
+
+		// Combine common
+		{in: `X || Y || X`, out: `X || Y`},
+		{in: `X && Y && X`, out: `X && Y`},
+		{in: `X && Y && X`, out: `X && Y`},
+		{in: `(X || Y) && (X || Y)`, out: `X || Y`},
+
+		// Complex cases
+		{in: `(X || Y) || (Y || Z)`, out: `X || Y || Z`},
+		{in: `(X || Y) || (Y && Z)`, out: `(X || Y) && (X || Y || Z)`},
+		{in: `(X && Y) && (Y && Z)`, out: `X && Y && Z`},
+		{in: `!(X && !(Y || Z)) && Z`, out: `((!X) || Y || Z) && Z`},
+		{in: `Z || !(X && !(Y || Z))`, out: `(!X) || Y || Z`},
+	} {
+		expr, err := cnf.Parse(test.in)
+		if err != nil {
+			t.Errorf(`unexpected error returned from Parse('%v'): %v`, test.in, err)
+			continue
+		}
+		opt := cnf.Optimize(expr)
+		if diff := cmp.Diff(test.out, opt.String()); diff != "" {
+			t.Errorf("Optimize('%v') returned '%v'. Diff:\n%v", test.in, opt, diff)
+		}
+	}
+}
diff --git a/tools/src/cnf/parse.go b/tools/src/cnf/parse.go
new file mode 100644
index 0000000..7e40b88
--- /dev/null
+++ b/tools/src/cnf/parse.go
@@ -0,0 +1,266 @@
+// Copyright 2023 The Tint 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 cnf
+
+import (
+	"fmt"
+	"strings"
+	"unicode"
+)
+
+// Parse parses the string, returning an expression
+func Parse(s string) (Expr, error) {
+	tokens, err := lex(s)
+	if err != nil {
+		return nil, err
+	}
+	if len(tokens) == 0 {
+		return nil, nil
+	}
+	p := parser{string: s, tokens: tokens}
+	return p.parse()
+}
+
+type tokenKind string
+
+const (
+	tokEOF    = "<end of expression>"
+	tokLParen = "("
+	tokRParen = ")"
+	tokIdent  = "ident"
+	tokAnd    = "&&"
+	tokOr     = "||"
+	tokNot    = "!"
+)
+
+type token struct {
+	kind       tokenKind
+	start, end int
+}
+
+type parser struct {
+	string string
+	tokens []token
+}
+
+func (p *parser) parse() (Expr, error) {
+	return p.binary()
+}
+
+func (p *parser) binary() (Expr, error) {
+	expr, err := p.unary()
+	if err != nil {
+		return nil, err
+	}
+
+	var delimiter tokenKind
+	var op func(Expr, Expr) Expr
+
+	switch p.peek().kind {
+	case tokAnd:
+		delimiter, op = tokAnd, And
+	case tokOr:
+		delimiter, op = tokOr, Or
+	default:
+		return expr, nil
+	}
+
+	for {
+		switch p.peek().kind {
+		case delimiter:
+			p.next()
+			rhs, err := p.unary()
+			if err != nil {
+				return nil, err
+			}
+			expr = op(expr, rhs)
+		case tokRParen, tokEOF:
+			return expr, nil
+		case tokAnd, tokOr:
+			t := p.next()
+			return nil, p.err(t, "cannot mix '&&' and '||' without parentheses")
+		default:
+			t := p.next()
+			return nil, p.err(t, "expected '%v'", delimiter)
+		}
+	}
+}
+
+func (p *parser) unary() (Expr, error) {
+	if p.match(tokNot) {
+		expr, err := p.unary()
+		if err != nil {
+			return nil, err
+		}
+		return Not(expr), nil
+	}
+
+	// '(' binary ')'
+	if p.match(tokLParen) {
+		expr, err := p.binary()
+		if err != nil {
+			return nil, err
+		}
+		if _, err := p.expect(tokRParen); err != nil {
+			return nil, err
+		}
+		return expr, nil
+	}
+
+	return p.ident()
+}
+
+func (p *parser) ident() (Expr, error) {
+	identTok, err := p.expect(tokIdent)
+	if err != nil {
+		return nil, err
+	}
+	ident := p.string[identTok.start:identTok.end]
+	return Expr{{{Var: ident}}}, nil
+}
+
+func (p *parser) expect(t tokenKind) (token, error) {
+	got := p.next()
+	if t != got.kind {
+		if got.kind == tokEOF {
+			return token{}, p.err(got, "expected '%v'", t)
+		}
+		return token{}, p.err(got, "expected '%v', got '%v'", t, p.string[got.start:got.end])
+	}
+	return got, nil
+}
+
+func (p *parser) peek() token {
+	if len(p.tokens) == 0 {
+		return token{kind: tokEOF, start: len(p.string), end: len(p.string)}
+	}
+	return p.tokens[0]
+}
+
+func (p *parser) match(t tokenKind) bool {
+	if p.peek().kind == t {
+		p.next()
+		return true
+	}
+	return false
+}
+
+func (p *parser) next() token {
+	t := p.peek()
+	if len(p.tokens) > 0 {
+		p.tokens = p.tokens[1:]
+	}
+	return t
+}
+
+func (p *parser) err(t token, msg string, args ...any) error {
+	if t.end < t.start {
+		panic("token end is before start")
+	}
+	return ParseErr{
+		Start:      t.start,
+		End:        t.end,
+		Expression: p.string,
+		Message:    fmt.Sprintf(msg, args...),
+	}
+}
+
+type ParseErr struct {
+	Start, End int
+	Expression string
+	Message    string
+}
+
+func (e ParseErr) Error() string {
+	n := e.End - e.Start
+	if n == 0 {
+		n = 1
+	}
+	return fmt.Sprintf("Parse error:\n\n%v\n%v%v\n\n%v",
+		e.Expression,
+		strings.Repeat(" ", e.Start), strings.Repeat("^", n),
+		e.Message)
+}
+
+func lex(in string) ([]token, error) {
+	runes := []rune(in)
+	offset := 0
+
+	peek := func(i int) rune {
+		if offset+i < len(runes) {
+			return runes[offset+i]
+		}
+		return 0
+	}
+
+	advance := func(predicate func(r rune) bool) (start, end int) {
+		start = offset
+		for i, r := range runes[offset:] {
+			if !predicate(r) {
+				offset += i
+				return start, offset
+			}
+		}
+		offset = len(runes)
+		return start, offset
+	}
+
+	err := func(msg string, args ...any) ParseErr {
+		return ParseErr{
+			Start:      offset,
+			End:        offset + 1,
+			Expression: in,
+			Message:    fmt.Sprintf(msg, args...),
+		}
+	}
+
+	tokens := []token{}
+	for offset < len(runes) {
+		advance(unicode.IsSpace)
+
+		p0 := peek(0)
+		switch {
+		case isIdentifier(p0):
+			start, end := advance(isIdentifier)
+			tokens = append(tokens, token{kind: tokIdent, start: start, end: end})
+		case p0 == '!':
+			tokens = append(tokens, token{kind: tokNot, start: offset, end: offset + 1})
+			offset++
+		case p0 == '(':
+			tokens = append(tokens, token{kind: tokLParen, start: offset, end: offset + 1})
+			offset++
+		case p0 == ')':
+			tokens = append(tokens, token{kind: tokRParen, start: offset, end: offset + 1})
+			offset++
+		default:
+			p1 := peek(1)
+			switch {
+			case p0 == '&' && p1 == '&':
+				tokens = append(tokens, token{kind: tokAnd, start: offset, end: offset + 2})
+				offset += 2
+			case p0 == '|' && p1 == '|':
+				tokens = append(tokens, token{kind: tokOr, start: offset, end: offset + 2})
+				offset += 2
+			default:
+				return nil, err("unexpected character '%c'", p0)
+			}
+		}
+	}
+	return tokens, nil
+}
+
+func isIdentifier(r rune) bool {
+	return unicode.IsLetter(r) || r == '_'
+}
diff --git a/tools/src/cnf/parse_test.go b/tools/src/cnf/parse_test.go
new file mode 100644
index 0000000..dcb04e4
--- /dev/null
+++ b/tools/src/cnf/parse_test.go
@@ -0,0 +1,153 @@
+// Copyright 2023 The Tint 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 cnf_test
+
+import (
+	"testing"
+
+	"dawn.googlesource.com/dawn/tools/src/cnf"
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestParse(t *testing.T) {
+	for _, test := range []struct {
+		in     string
+		expect cnf.Expr
+	}{
+		{in: ``, expect: nil},
+		{in: `X`, expect: cnf.Expr{{T("X")}}},
+		{in: `(X)`, expect: cnf.Expr{{T("X")}}},
+		{in: `((X))`, expect: cnf.Expr{{T("X")}}},
+		{in: `!X`, expect: cnf.Expr{{F("X")}}},
+		{in: `!(X)`, expect: cnf.Expr{{F("X")}}},
+		{in: `!!(X)`, expect: cnf.Expr{{T("X")}}},
+		{in: `(!(X))`, expect: cnf.Expr{{F("X")}}},
+		{in: `!(!(X))`, expect: cnf.Expr{{T("X")}}},
+		{in: `X && Y`, expect: cnf.Expr{{T("X")}, {T("Y")}}},
+		{in: `X && Y && Z`, expect: cnf.Expr{{T("X")}, {T("Y")}, {T("Z")}}},
+		{in: `X && !Y && Z`, expect: cnf.Expr{{T("X")}, {F("Y")}, {T("Z")}}},
+		{in: `!X && Y && !Z`, expect: cnf.Expr{{F("X")}, {T("Y")}, {F("Z")}}},
+		{in: `X || Y`, expect: cnf.Expr{{T("X"), T("Y")}}},
+		{in: `X || Y || Z`, expect: cnf.Expr{{T("X"), T("Y"), T("Z")}}},
+		{in: `X || !Y || Z`, expect: cnf.Expr{{T("X"), F("Y"), T("Z")}}},
+		{in: `!X || Y || !Z`, expect: cnf.Expr{{F("X"), T("Y"), F("Z")}}},
+		{in: `(X || Y) && Z`, expect: cnf.Expr{{T("X"), T("Y")}, {T("Z")}}},
+		{in: `(  X || Y  ) && Z`, expect: cnf.Expr{{T("X"), T("Y")}, {T("Z")}}},
+		{in: `X || (Y && Z)`, expect: cnf.Expr{{T("X"), T("Y")}, {T("X"), T("Z")}}},
+		{in: `(X && Y) || Z`, expect: cnf.Expr{{T("X"), T("Z")}, {T("Y"), T("Z")}}},
+		{in: `X && (Y || Z)`, expect: cnf.Expr{{T("X")}, {T("Y"), T("Z")}}},
+		{in: `(!X && Y) || Z`, expect: cnf.Expr{{F("X"), T("Z")}, {T("Y"), T("Z")}}},
+		{in: `(X && !Y) || Z`, expect: cnf.Expr{{T("X"), T("Z")}, {F("Y"), T("Z")}}},
+		{in: `(X && Y) || !Z`, expect: cnf.Expr{{T("X"), F("Z")}, {T("Y"), F("Z")}}},
+		{in: `!X && (Y || Z)`, expect: cnf.Expr{{F("X")}, {T("Y"), T("Z")}}},
+		{in: `!(!X && (Y || Z))`, expect: cnf.Expr{{T("X"), F("Y")}, {T("X"), F("Z")}}},
+		{in: `X && (!Y || Z)`, expect: cnf.Expr{{T("X")}, {F("Y"), T("Z")}}},
+		{in: `!(X && (!Y || Z))`, expect: cnf.Expr{{F("X"), T("Y")}, {F("X"), F("Z")}}},
+		{in: `X && (Y || !Z)`, expect: cnf.Expr{{T("X")}, {T("Y"), F("Z")}}},
+		{in: `X && !(!Y || Z)`, expect: cnf.Expr{{T("X")}, {T("Y")}, {F("Z")}}},
+		{in: `!(X && (Y || !Z))`, expect: cnf.Expr{{F("X"), F("Y")}, {F("X"), T("Z")}}},
+		{in: `!(X && !(Y || !Z))`, expect: cnf.Expr{{F("X"), T("Y"), F("Z")}}},
+	} {
+		expr, err := cnf.Parse(test.in)
+		if err != nil {
+			t.Errorf(`unexpected error returned from Parse('%v'): %v`, test.in, err)
+			continue
+		}
+		if diff := cmp.Diff(test.expect, expr); diff != "" {
+			t.Errorf("Parse('%v') returned '%v'. Diff:\n%v", test.in, expr, diff)
+		}
+	}
+}
+
+func TestParseErr(t *testing.T) {
+	for _, test := range []struct {
+		in     string
+		expect string
+	}{
+		{
+			in: `)`,
+			expect: `Parse error:
+
+)
+^
+
+expected 'ident', got ')'`,
+		},
+		{
+			in: ` )`,
+			expect: `Parse error:
+
+ )
+ ^
+
+expected 'ident', got ')'`,
+		},
+		{
+			in: `(`,
+			expect: `Parse error:
+
+(
+ ^
+
+expected 'ident'`,
+		},
+		{
+			in: ` (`,
+			expect: `Parse error:
+
+ (
+  ^
+
+expected 'ident'`,
+		},
+		{
+			in: `(x`,
+			expect: `Parse error:
+
+(x
+  ^
+
+expected ')'`,
+		},
+		{
+			in: `((x)`,
+			expect: `Parse error:
+
+((x)
+    ^
+
+expected ')'`,
+		},
+		{
+			in: `X || Y && Z`,
+			expect: `Parse error:
+
+X || Y && Z
+       ^^
+
+cannot mix '&&' and '||' without parentheses`,
+		},
+	} {
+		_, err := cnf.Parse(test.in)
+		errStr := ""
+		if err != nil {
+			errStr = err.Error()
+		}
+		if test.expect != errStr {
+			t.Errorf(`unexpected error returned from Parse('%v'): %v`, test.in, err)
+			continue
+		}
+	}
+}