Add the content of the package.

This is mostly just taken directly from test's platform selector
infrastructure.

R=kevmoo@google.com

Review URL: https://codereview.chromium.org//1712793002 .
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3a314ce
--- /dev/null
+++ b/README.md
@@ -0,0 +1,39 @@
+The `boolean_selector` package defines a simple and flexible syntax for boolean
+expressions. It can be used for filtering based on user-defined expressions. For
+example, the [`test`][test] package uses boolean selectors to allow users to
+define what platforms their tests support.
+
+[test]: http://github.com/dart-lang/test
+
+The boolean selector syntax is based on a simplified version of Dart's
+expression syntax. Selectors can contain identifiers, parentheses, and boolean
+operators, including `||`, `&&`, `!`, and `? :`. Any valid Dart identifier is
+allowed, and identifiers may also contain hyphens. For example, `chrome`,
+`chrome || content-shell`, and `js || (vm && linux)` are all valid boolean
+selectors.
+
+A boolean selector is parsed from a string using
+[`new BooleanSelector.parse()`][parse], and evaluated against a set of variables
+using [`BooleanSelector.evaluate()`][evaluate]. The variables may be supplied as
+a list of strings, or as a function that takes a variable name and returns its
+value. For example:
+
+[parse]: https://www.dartdocs.org/documentation/boolean_selector/latest/boolean_selector/BooleanSelector/BooleanSelector.parse.html
+
+[evaluate]: https://www.dartdocs.org/documentation/boolean_selector/latest/boolean_selector/BooleanSelector/evaluate.html
+
+```dart
+import 'package:boolean_selector/boolean_selector.dart';
+
+void main(List<String> args) {
+  var selector = new BooleanSelector.parse("(x && y) || z");
+  print(selector.evaluate((variable) => args.contains(variable)));
+}
+```
+
+## Versioning
+
+If this package adds new features to the boolean selector syntax, it will
+increment its major version number. This ensures that packages that expose the
+syntax to their users will be able to update their own minor versions, so their
+users can indicate that they rely on the new syntax.
diff --git a/codereview.settings b/codereview.settings
new file mode 100644
index 0000000..3de3f59
--- /dev/null
+++ b/codereview.settings
@@ -0,0 +1,3 @@
+CODE_REVIEW_SERVER: https://codereview.chromium.org/
+VIEW_VC: https://github.com/dart-lang/boolean_selector/commit/
+CC_LIST: reviews@dartlang.org
\ No newline at end of file
diff --git a/lib/boolean_selector.dart b/lib/boolean_selector.dart
new file mode 100644
index 0000000..92eedbd
--- /dev/null
+++ b/lib/boolean_selector.dart
@@ -0,0 +1,55 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:source_span/source_span.dart';
+
+import 'src/all.dart';
+import 'src/impl.dart';
+import 'src/none.dart';
+
+/// A boolean expression that evaluates to `true` or `false` based on certain
+/// inputs.
+///
+/// The syntax is mostly Dart's expression syntax restricted to boolean
+/// operations. See [the README][] for full details.
+///
+/// [the README]: https://github.com/dart-lang/boolean_selector/blob/master/README.md
+abstract class BooleanSelector {
+  /// A selector that accepts all inputs.
+  static const all = const All();
+
+  /// A selector that accepts no inputs.
+  static const none = const None();
+
+  /// All the variables in this selector, in the order they appear.
+  Iterable<String> get variables;
+
+  /// Parses [selector].
+  ///
+  /// This will throw a [SourceSpanFormatException] if the selector is
+  /// malformed or if it uses an undefined variable.
+  factory BooleanSelector.parse(String selector) = BooleanSelectorImpl.parse;
+
+  /// Returns whether the selector matches the given [semantics].
+  ///
+  /// The [semantics] define which variables evaluate to `true` or `false`. The
+  /// parameter can be either an `Iterable<String>` containing variables that
+  /// should evaluate to `true`, or a function `bool semantics(String variable)`
+  /// that returns a variable's value.
+  bool evaluate(semantics);
+
+  /// Returns a new [BooleanSelector] that matches only inputs matched by both
+  /// [this] and [other].
+  BooleanSelector intersection(BooleanSelector other);
+
+  /// Returns a new [BooleanSelector] that matches all inputs matched by either
+  /// [this] or [other].
+  BooleanSelector union(BooleanSelector other);
+
+  /// Throws a [FormatException] if any variables are undefined.
+  ///
+  /// The [isDefined] function should return `true` for any variables that are
+  /// considered valid, and `false` for any invalid or undefined variables.
+  void validate(bool isDefined(String variable));
+}
diff --git a/lib/src/all.dart b/lib/src/all.dart
new file mode 100644
index 0000000..e15230b
--- /dev/null
+++ b/lib/src/all.dart
@@ -0,0 +1,22 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import '../boolean_selector.dart';
+
+/// A selector that matches all inputs.
+class All implements BooleanSelector {
+  final variables = const [];
+
+  const All();
+
+  bool evaluate(semantics) => true;
+
+  BooleanSelector intersection(BooleanSelector other) => other;
+
+  BooleanSelector union(BooleanSelector other) => this;
+
+  void validate(bool isDefined(String variable)) {}
+
+  String toString() => "<all>";
+}
diff --git a/lib/src/ast.dart b/lib/src/ast.dart
new file mode 100644
index 0000000..26c09bf
--- /dev/null
+++ b/lib/src/ast.dart
@@ -0,0 +1,160 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:source_span/source_span.dart';
+
+import 'visitor.dart';
+
+/// The superclass of nodes in the boolean selector abstract syntax tree.
+abstract class Node {
+  /// The span indicating where this node came from.
+  ///
+  /// This is a [FileSpan] because the nodes are parsed from a single continuous
+  /// string, but the string itself isn't actually a file. It might come from a
+  /// statically-parsed annotation or from a parameter.
+  ///
+  /// This may be `null` for nodes without source information.
+  FileSpan get span;
+
+  /// All the variables in this node, in the order they appear.
+  Iterable<String> get variables;
+
+  /// Calls the appropriate [Visitor] method on [this] and returns the result.
+  accept(Visitor visitor);
+}
+
+/// A single variable.
+class VariableNode implements Node {
+  final FileSpan span;
+
+  /// The variable name.
+  final String name;
+
+  Iterable<String> get variables => [name];
+
+  VariableNode(this.name, [this.span]);
+
+  accept(Visitor visitor) => visitor.visitVariable(this);
+
+  String toString() => name;
+}
+
+/// A negation expression.
+class NotNode implements Node {
+  final FileSpan span;
+
+  /// The expression being negated.
+  final Node child;
+
+  Iterable<String> get variables => child.variables;
+
+  NotNode(this.child, [this.span]);
+
+  accept(Visitor visitor) => visitor.visitNot(this);
+
+  String toString() => child is VariableNode || child is NotNode
+      ? "!$child"
+      : "!($child)";
+}
+
+/// An or expression.
+class OrNode implements Node {
+  FileSpan get span => _expandSafe(left.span, right.span);
+
+  /// The left-hand branch of the expression.
+  final Node left;
+
+  /// The right-hand branch of the expression.
+  final Node right;
+
+  Iterable<String> get variables sync* {
+    yield* left.variables;
+    yield* right.variables;
+  }
+
+  OrNode(this.left, this.right);
+
+  accept(Visitor visitor) => visitor.visitOr(this);
+
+  String toString() {
+    var string1 = left is AndNode || left is ConditionalNode
+        ? "($left)"
+        : left;
+    var string2 = right is AndNode || right is ConditionalNode
+        ? "($right)"
+        : right;
+
+    return "$string1 || $string2";
+  }
+}
+
+/// An and expression.
+class AndNode implements Node {
+  FileSpan get span => _expandSafe(left.span, right.span);
+
+  /// The left-hand branch of the expression.
+  final Node left;
+
+  /// The right-hand branch of the expression.
+  final Node right;
+
+  Iterable<String> get variables sync* {
+    yield* left.variables;
+    yield* right.variables;
+  }
+
+  AndNode(this.left, this.right);
+
+  accept(Visitor visitor) => visitor.visitAnd(this);
+
+  String toString() {
+    var string1 = left is OrNode || left is ConditionalNode
+        ? "($left)"
+        : left;
+    var string2 = right is OrNode || right is ConditionalNode
+        ? "($right)"
+        : right;
+
+    return "$string1 && $string2";
+  }
+}
+
+/// A ternary conditional expression.
+class ConditionalNode implements Node {
+  FileSpan get span => _expandSafe(condition.span, whenFalse.span);
+
+  /// The condition expression to check.
+  final Node condition;
+
+  /// The branch to run if the condition is true.
+  final Node whenTrue;
+
+  /// The branch to run if the condition is false.
+  final Node whenFalse;
+
+  Iterable<String> get variables sync* {
+    yield* condition.variables;
+    yield* whenTrue.variables;
+    yield* whenFalse.variables;
+  }
+
+  ConditionalNode(this.condition, this.whenTrue, this.whenFalse);
+
+  accept(Visitor visitor) => visitor.visitConditional(this);
+
+  String toString() {
+    var conditionString =
+        condition is ConditionalNode ? "($condition)" : condition;
+    var trueString = whenTrue is ConditionalNode ? "($whenTrue)" : whenTrue;
+    return "$conditionString ? $trueString : $whenFalse";
+  }
+}
+
+/// Like [FileSpan.expand], except if [start] and [end] are `null` or from
+/// different files it returns `null` rather than throwing an error.
+FileSpan _expandSafe(FileSpan start, FileSpan end) {
+  if (start == null || end == null) return null;
+  if (start.file != end.file) return null;
+  return start.expand(end);
+}
diff --git a/lib/src/evaluator.dart b/lib/src/evaluator.dart
new file mode 100644
index 0000000..9102add
--- /dev/null
+++ b/lib/src/evaluator.dart
@@ -0,0 +1,34 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'ast.dart';
+import 'visitor.dart';
+
+typedef bool _Semantics(String variable);
+
+/// A visitor for evaluating boolean selectors against a specific set of
+/// semantics.
+class Evaluator implements Visitor<bool> {
+  /// The semantics to evaluate against.
+  final _Semantics _semantics;
+
+  Evaluator(semantics)
+      : _semantics = semantics is Iterable
+            ? semantics.toSet().contains
+            : semantics;
+
+  bool visitVariable(VariableNode node) => _semantics(node.name);
+
+  bool visitNot(NotNode node) => !node.child.accept(this);
+
+  bool visitOr(OrNode node) =>
+      node.left.accept(this) || node.right.accept(this);
+
+  bool visitAnd(AndNode node) =>
+      node.left.accept(this) && node.right.accept(this);
+
+  bool visitConditional(ConditionalNode node) => node.condition.accept(this)
+      ? node.whenTrue.accept(this)
+      : node.whenFalse.accept(this);
+}
diff --git a/lib/src/impl.dart b/lib/src/impl.dart
new file mode 100644
index 0000000..6a40530
--- /dev/null
+++ b/lib/src/impl.dart
@@ -0,0 +1,56 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import '../boolean_selector.dart';
+import 'ast.dart';
+import 'evaluator.dart';
+import 'intersection_selector.dart';
+import 'parser.dart';
+import 'union_selector.dart';
+import 'validator.dart';
+
+/// The concrete implementation of a [BooleanSelector] parsed from a string.
+///
+/// This is separate from [BooleanSelector] so that [intersect] and [union] can
+/// check to see whether they're passed a [BooleanSelectorImpl] or a different
+/// class that implements [BooleanSelector].
+class BooleanSelectorImpl implements BooleanSelector {
+  /// The parsed AST.
+  final Node _selector;
+
+  /// Parses [selector].
+  ///
+  /// This will throw a [SourceSpanFormatException] if the selector is
+  /// malformed or if it uses an undefined variable.
+  BooleanSelectorImpl.parse(String selector)
+      : _selector = new Parser(selector).parse();
+
+  BooleanSelectorImpl._(this._selector);
+
+  Iterable<String> get variables => _selector.variables;
+
+  bool evaluate(semantics) => _selector.accept(new Evaluator(semantics));
+
+  BooleanSelector intersection(BooleanSelector other) {
+    if (other == BooleanSelector.all) return this;
+    if (other == BooleanSelector.none) return other;
+    return other is BooleanSelectorImpl
+        ? new BooleanSelectorImpl._(new AndNode(_selector, other._selector))
+        : new IntersectionSelector(this, other);
+  }
+
+  BooleanSelector union(BooleanSelector other) {
+    if (other == BooleanSelector.all) return other;
+    if (other == BooleanSelector.none) return this;
+    return other is BooleanSelectorImpl
+        ? new BooleanSelectorImpl._(new OrNode(_selector, other._selector))
+        : new UnionSelector(this, other);
+  }
+
+  void validate(bool isDefined(String variable)) {
+    _selector.accept(new Validator(isDefined));
+  }
+
+  String toString() => _selector.toString();
+}
diff --git a/lib/src/intersection_selector.dart b/lib/src/intersection_selector.dart
new file mode 100644
index 0000000..aa51bf9
--- /dev/null
+++ b/lib/src/intersection_selector.dart
@@ -0,0 +1,35 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import '../boolean_selector.dart';
+import 'union_selector.dart';
+
+/// A selector that matches inputs that both of its sub-selectors match.
+class IntersectionSelector implements BooleanSelector {
+  final BooleanSelector _selector1;
+  final BooleanSelector _selector2;
+
+  Iterable<String> get variables sync* {
+    yield* _selector1.variables;
+    yield* _selector2.variables;
+  }
+
+  IntersectionSelector(this._selector1, this._selector2);
+
+  bool evaluate(semantics) =>
+      _selector1.evaluate(semantics) && _selector2.evaluate(semantics);
+
+  BooleanSelector intersection(BooleanSelector other) =>
+      new IntersectionSelector(this, other);
+
+  BooleanSelector union(BooleanSelector other) =>
+      new UnionSelector(this, other);
+
+  void validate(bool isDefined(String variable)) {
+    _selector1.validate(isDefined);
+    _selector2.validate(isDefined);
+  }
+
+  String toString() => "($_selector1) && ($_selector2)";
+}
diff --git a/lib/src/none.dart b/lib/src/none.dart
new file mode 100644
index 0000000..35970b5
--- /dev/null
+++ b/lib/src/none.dart
@@ -0,0 +1,22 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import '../boolean_selector.dart';
+
+/// A selector that matches no inputs.
+class None implements BooleanSelector {
+  final variables = const [];
+
+  const None();
+
+  bool evaluate(semantics) => false;
+
+  BooleanSelector intersection(BooleanSelector other) => this;
+
+  BooleanSelector union(BooleanSelector other) => other;
+
+  void validate(bool isDefined(String variable)) {}
+
+  String toString() => "<none>";
+}
diff --git a/lib/src/parser.dart b/lib/src/parser.dart
new file mode 100644
index 0000000..f9ab470
--- /dev/null
+++ b/lib/src/parser.dart
@@ -0,0 +1,105 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:source_span/source_span.dart';
+
+import 'ast.dart';
+import 'scanner.dart';
+import 'token.dart';
+
+/// A class for parsing a boolean selector.
+///
+/// Boolean selectors use a stripped-down version of the Dart expression syntax
+/// that only contains variables, parentheses, and boolean operators. Variables
+/// may also contain dashes, contrary to Dart's syntax; this allows consistency
+/// with command-line arguments.
+class Parser {
+  /// The scanner that tokenizes the selector.
+  final Scanner _scanner;
+
+  Parser(String selector)
+      : _scanner = new Scanner(selector);
+
+  /// Parses the selector.
+  ///
+  /// This must only be called once per parser.
+  Node parse() {
+    var selector = _conditional();
+
+    if (_scanner.peek().type != TokenType.endOfFile) {
+      throw new SourceSpanFormatException(
+          "Expected end of input.", _scanner.peek().span);
+    }
+
+    return selector;
+  }
+
+  /// Parses a conditional:
+  ///
+  ///     conditionalExpression:
+  ///       logicalOrExpression ("?" conditionalExpression ":"
+  ///           conditionalExpression)?
+  Node _conditional() {
+    var condition = _or();
+    if (!_scanner.scan(TokenType.questionMark)) return condition;
+
+    var whenTrue = _conditional();
+    if (!_scanner.scan(TokenType.colon)) {
+      throw new SourceSpanFormatException(
+          'Expected ":".', _scanner.peek().span);
+    }
+
+    var whenFalse = _conditional();
+    return new ConditionalNode(condition, whenTrue, whenFalse);
+  }
+
+  /// Parses a logical or:
+  ///
+  ///     logicalOrExpression:
+  ///       logicalAndExpression ("||" logicalOrExpression)?
+  Node _or() {
+    var left = _and();
+    if (!_scanner.scan(TokenType.or)) return left;
+    return new OrNode(left, _or());
+  }
+
+  /// Parses a logical and:
+  ///
+  ///     logicalAndExpression:
+  ///       simpleExpression ("&&" logicalAndExpression)?
+  Node _and() {
+    var left = _simpleExpression();
+    if (!_scanner.scan(TokenType.and)) return left;
+    return new AndNode(left, _and());
+  }
+
+  /// Parses a simple expression:
+  ///
+  ///     simpleExpression:
+  ///       "!" simpleExpression |
+  ///           "(" conditionalExpression ")" |
+  ///           IDENTIFIER
+  Node _simpleExpression() {
+    var token = _scanner.next();
+    switch (token.type) {
+      case TokenType.not:
+        var child = _simpleExpression();
+        return new NotNode(child, token.span.expand(child.span));
+
+      case TokenType.leftParen:
+        var child = _conditional();
+        if (!_scanner.scan(TokenType.rightParen)) {
+          throw new SourceSpanFormatException(
+              'Expected ")".', _scanner.peek().span);
+        }
+        return child;
+
+      case TokenType.identifier:
+        return new VariableNode(token.name, token.span);
+
+      default:
+        throw new SourceSpanFormatException("Expected expression.", token.span);
+    }
+  }
+}
diff --git a/lib/src/scanner.dart b/lib/src/scanner.dart
new file mode 100644
index 0000000..7a2ce79
--- /dev/null
+++ b/lib/src/scanner.dart
@@ -0,0 +1,150 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:string_scanner/string_scanner.dart';
+
+import 'token.dart';
+
+/// A regular expression matching both whitespace and single-line comments.
+///
+/// This will only match if consumes at least one character.
+final _whitespaceAndSingleLineComments =
+    new RegExp(r"([ \t\n]+|//[^\n]*(\n|$))+");
+
+/// A regular expression matching the body of a multi-line comment, after `/*`
+/// but before `*/` or a nested `/*`.
+///
+/// This will only match if it consumes at least one character.
+final _multiLineCommentBody = new RegExp(r"([^/*]|/[^*]|\*[^/])+");
+
+/// A regular expression matching a hyphenated identifier.
+///
+/// This is like a standard Dart identifier, except that it can also contain
+/// hyphens.
+final _hyphenatedIdentifier = new RegExp(r"[a-zA-Z_-][a-zA-Z0-9_-]*");
+
+/// A scanner that converts a boolean selector string into a stream of tokens.
+class Scanner {
+  /// The underlying string scanner.
+  final SpanScanner _scanner;
+
+  /// The next token to emit.
+  Token _next;
+
+  /// Whether the scanner has emitted a [TokenType.endOfFile] token.
+  bool _endOfFileEmitted = false;
+
+  Scanner(String selector)
+      : _scanner = new SpanScanner(selector);
+
+  /// Returns the next token that will be returned by [next].
+  ///
+  /// Throws a [StateError] if a [TokenType.endOfFile] token has already been
+  /// consumed.
+  Token peek() {
+    if (_next == null) _next = _getNext();
+    return _next;
+  }
+
+  /// Consumes and returns the next token in the stream.
+  ///
+  /// Throws a [StateError] if a [TokenType.endOfFile] token has already been
+  /// consumed.
+  Token next() {
+    var token = _next == null ? _getNext() : _next;
+    _endOfFileEmitted = token.type == TokenType.endOfFile;
+    _next = null;
+    return token;
+  }
+
+  /// If the next token matches [type], consumes it and returns `true`;
+  /// otherwise, returns `false`.
+  ///
+  /// Throws a [StateError] if a [TokenType.endOfFile] token has already been
+  /// consumed.
+  bool scan(TokenType type) {
+    if (peek().type != type) return false;
+    next();
+    return true;
+  }
+
+  /// Scan and return the next token in the stream.
+  Token _getNext() {
+    if (_endOfFileEmitted) throw new StateError("No more tokens.");
+
+    _consumeWhitespace();
+    if (_scanner.isDone) {
+      return new Token(
+          TokenType.endOfFile, _scanner.spanFrom(_scanner.state));
+    }
+
+    switch (_scanner.peekChar()) {
+      case 0x28 /* ( */: return _scanOperator(TokenType.leftParen);
+      case 0x29 /* ) */: return _scanOperator(TokenType.rightParen);
+      case 0x3F /* ? */: return _scanOperator(TokenType.questionMark);
+      case 0x3A /* : */: return _scanOperator(TokenType.colon);
+      case 0x21 /* ! */: return _scanOperator(TokenType.not);
+      case 0x7C /* | */: return _scanOr();
+      case 0x26 /* & */: return _scanAnd();
+      default: return _scanIdentifier();
+    }
+  }
+
+  /// Scans a single-character operator and returns a token of type [type].
+  ///
+  /// This assumes that the caller has already verified that the next character
+  /// is correct for the given operator.
+  Token _scanOperator(TokenType type) {
+    var start = _scanner.state;
+    _scanner.readChar();
+    return new Token(type, _scanner.spanFrom(start));
+  }
+
+  /// Scans a `||` operator and returns the appropriate token.
+  ///
+  /// This validates that the next two characters are `||`.
+  Token _scanOr() {
+    var start = _scanner.state;
+    _scanner.expect("||");
+    return new Token(TokenType.or, _scanner.spanFrom(start));
+  }
+
+  /// Scans a `&&` operator and returns the appropriate token.
+  ///
+  /// This validates that the next two characters are `&&`.
+  Token _scanAnd() {
+    var start = _scanner.state;
+    _scanner.expect("&&");
+    return new Token(TokenType.and, _scanner.spanFrom(start));
+  }
+
+  /// Scans and returns an identifier token.
+  Token _scanIdentifier() {
+    _scanner.expect(_hyphenatedIdentifier, name: "expression");
+    return new IdentifierToken(_scanner.lastMatch[0], _scanner.lastSpan);
+  }
+
+  /// Consumes all whitespace and comments immediately following the cursor's
+  /// current position.
+  void _consumeWhitespace() {
+    while (_scanner.scan(_whitespaceAndSingleLineComments) ||
+        _multiLineComment()) {
+      // Do nothing.
+    }
+  }
+
+  /// Consumes a single multi-line comment.
+  ///
+  /// Returns whether or not a comment was consumed.
+  bool _multiLineComment() {
+    if (!_scanner.scan("/*")) return false;
+
+    while (_scanner.scan(_multiLineCommentBody) || _multiLineComment()) {
+      // Do nothing.
+    }
+    _scanner.expect("*/");
+
+    return true;
+  }
+}
diff --git a/lib/src/token.dart b/lib/src/token.dart
new file mode 100644
index 0000000..1daa0fc
--- /dev/null
+++ b/lib/src/token.dart
@@ -0,0 +1,70 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:source_span/source_span.dart';
+
+/// A token in a boolean selector.
+class Token {
+  /// The type of the token.
+  final TokenType type;
+
+  /// The span indicating where this token came from.
+  ///
+  /// This is a [FileSpan] because the tokens are parsed from a single
+  /// continuous string, but the string itself isn't actually a file. It might
+  /// come from a statically-parsed annotation or from a parameter.
+  final FileSpan span;
+
+  Token(this.type, this.span);
+}
+
+/// A token representing an identifier.
+class IdentifierToken implements Token {
+  final type = TokenType.identifier;
+  final FileSpan span;
+
+  /// The name of the identifier.
+  final String name;
+
+  IdentifierToken(this.name, this.span);
+
+  String toString() => 'identifier "$name"';
+}
+
+/// An enumeration of types of tokens.
+class TokenType {
+  /// A `(` character.
+  static const leftParen = const TokenType._("left paren");
+
+  /// A `)` character.
+  static const rightParen = const TokenType._("right paren");
+
+  /// A `||` sequence.
+  static const or = const TokenType._("or");
+
+  /// A `&&` sequence.
+  static const and = const TokenType._("and");
+
+  /// A `!` character.
+  static const not = const TokenType._("not");
+
+  /// A `?` character.
+  static const questionMark = const TokenType._("question mark");
+
+  /// A `:` character.
+  static const colon = const TokenType._("colon");
+
+  /// A named identifier.
+  static const identifier = const TokenType._("identifier");
+
+  /// The end of the selector.
+  static const endOfFile = const TokenType._("end of file");
+
+  /// The name of the token type.
+  final String name;
+
+  const TokenType._(this.name);
+
+  String toString() => name;
+}
diff --git a/lib/src/union_selector.dart b/lib/src/union_selector.dart
new file mode 100644
index 0000000..6b6cf87
--- /dev/null
+++ b/lib/src/union_selector.dart
@@ -0,0 +1,33 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import '../boolean_selector.dart';
+import 'intersection_selector.dart';
+
+/// A selector that matches inputs that either of its sub-selectors match.
+class UnionSelector implements BooleanSelector {
+  final BooleanSelector _selector1;
+  final BooleanSelector _selector2;
+
+  UnionSelector(this._selector1, this._selector2);
+
+  List<String> get variables =>
+      _selector1.variables.toList()..addAll(_selector2.variables);
+
+  bool evaluate(semantics) =>
+      _selector1.evaluate(semantics) || _selector2.evaluate(semantics);
+
+  BooleanSelector intersection(BooleanSelector other) =>
+      new IntersectionSelector(this, other);
+
+  BooleanSelector union(BooleanSelector other) =>
+      new UnionSelector(this, other);
+
+  void validate(bool isDefined(String variable)) {
+    _selector1.validate(isDefined);
+    _selector2.validate(isDefined);
+  }
+
+  String toString() => "($_selector1) && ($_selector2)";
+}
diff --git a/lib/src/validator.dart b/lib/src/validator.dart
new file mode 100644
index 0000000..f4d78cf
--- /dev/null
+++ b/lib/src/validator.dart
@@ -0,0 +1,22 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:source_span/source_span.dart';
+
+import 'ast.dart';
+import 'visitor.dart';
+
+typedef bool _IsDefined(String variable);
+
+/// An AST visitor that ensures that all variables are valid.
+class Validator extends RecursiveVisitor {
+  final _IsDefined _isDefined;
+
+  Validator(this._isDefined);
+
+  void visitVariable(VariableNode node) {
+    if (_isDefined(node.name)) return;
+    throw new SourceSpanFormatException("Undefined variable.", node.span);
+  }
+}
diff --git a/lib/src/visitor.dart b/lib/src/visitor.dart
new file mode 100644
index 0000000..4bc0436
--- /dev/null
+++ b/lib/src/visitor.dart
@@ -0,0 +1,44 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'ast.dart';
+
+/// The interface for visitors of the boolean selector AST.
+abstract class Visitor<T> {
+  T visitVariable(VariableNode node);
+  T visitNot(NotNode node);
+  T visitOr(OrNode node);
+  T visitAnd(AndNode node);
+  T visitConditional(ConditionalNode node);
+}
+
+/// An abstract superclass for side-effect-based visitors.
+///
+/// The default implementations of this visitor's methods just traverse the AST
+/// and do nothing with it.
+abstract class RecursiveVisitor implements Visitor {
+  const RecursiveVisitor();
+
+  void visitVariable(VariableNode node) {}
+
+  void visitNot(NotNode node) {
+    node.child.accept(this);
+  }
+
+  void visitOr(OrNode node) {
+    node.left.accept(this);
+    node.right.accept(this);
+  }
+
+  void visitAnd(AndNode node) {
+    node.left.accept(this);
+    node.right.accept(this);
+  }
+
+  void visitConditional(ConditionalNode node) {
+    node.condition.accept(this);
+    node.whenTrue.accept(this);
+    node.whenFalse.accept(this);
+  }
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 175db3f..2527dd5 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -7,5 +7,9 @@
 environment:
   sdk: '>=1.8.0 <2.0.0'
 
+dependencies:
+  source_span: '^1.0.0'
+  string_scanner: '^0.1.1'
+
 dev_dependencies:
   test: '^0.12.0'
diff --git a/test/evaluate_test.dart b/test/evaluate_test.dart
new file mode 100644
index 0000000..f7cc208
--- /dev/null
+++ b/test/evaluate_test.dart
@@ -0,0 +1,60 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:test/test.dart';
+
+import 'package:boolean_selector/boolean_selector.dart';
+
+void main() {
+  group("operator:", () {
+    test("conditional", () {
+      _expectEval("true ? true : false", true);
+      _expectEval("true ? false : true", false);
+      _expectEval("false ? true : false", false);
+      _expectEval("false ? false : true", true);
+    });
+
+    test("or", () {
+      _expectEval("true || true", true);
+      _expectEval("true || false", true);
+      _expectEval("false || true", true);
+      _expectEval("false || false", false);
+    });
+
+    test("and", () {
+      _expectEval("true && true", true);
+      _expectEval("true && false", false);
+      _expectEval("false && true", false);
+      _expectEval("false && false", false);
+    });
+
+    test("not", () {
+      _expectEval("!true", false);
+      _expectEval("!false", true);
+    });
+  });
+
+  test("with a semantics function", () {
+    _expectEval("foo", false, semantics: (variable) => variable.contains("a"));
+    _expectEval("bar", true, semantics: (variable) => variable.contains("a"));
+    _expectEval("baz", true, semantics: (variable) => variable.contains("a"));
+  });
+}
+
+/// Asserts that [expression] evaluates to [result] against [semantics].
+///
+/// By default, "true" is true and all other variables are "false".
+void _expectEval(String expression, bool result, {semantics}) {
+  var reason = 
+  expect(_eval(expression, semantics: semantics), equals(result),
+      reason: 'Expected "$expression" to evaluate to $result.');
+}
+
+/// Returns the result of evaluating [expression] on [semantics].
+///
+/// By default, "true" is true and all other variables are "false".
+bool _eval(String expression, {semantics}) {
+  var selector = new BooleanSelector.parse(expression);
+  return selector.evaluate(semantics ?? ["true"]);
+}
diff --git a/test/parser_test.dart b/test/parser_test.dart
new file mode 100644
index 0000000..f17b4d9
--- /dev/null
+++ b/test/parser_test.dart
@@ -0,0 +1,267 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:test/test.dart';
+
+import 'package:boolean_selector/src/ast.dart';
+import 'package:boolean_selector/src/parser.dart';
+
+/// A matcher that asserts that a value is a [ConditionalNode].
+Matcher _isConditionalNode = new isInstanceOf<ConditionalNode>();
+
+/// A matcher that asserts that a value is an [OrNode].
+Matcher _isOrNode = new isInstanceOf<OrNode>();
+
+/// A matcher that asserts that a value is an [AndNode].
+Matcher _isAndNode = new isInstanceOf<AndNode>();
+
+/// A matcher that asserts that a value is a [NotNode].
+Matcher _isNotNode = new isInstanceOf<NotNode>();
+
+void main() {
+  group("parses a conditional expression", () {
+    test("with identifiers", () {
+      var node = _parse("  a ? b : c   ");
+      expect(node.toString(), equals("a ? b : c"));
+
+      expect(node.span.text, equals("a ? b : c"));
+      expect(node.span.start.offset, equals(2));
+      expect(node.span.end.offset, equals(11));
+    });
+
+    test("with nested ors", () {
+      // Should parse as "(a || b) ? (c || d) : (e || f)".
+      // Should not parse as "a || (b ? (c || d) : (e || f))".
+      // Should not parse as "((a || b) ? (c || d) : e) || f".
+      // Should not parse as "a || (b ? (c || d) : e) || f".
+      _expectToString("a || b ? c || d : e || f",
+          "a || b ? c || d : e || f");
+    });
+
+    test("with a conditional expression as branch 1", () {
+      // Should parse as "a ? (b ? c : d) : e".
+      var node = _parse("a ? b ? c : d : e");
+      expect(node, _isConditionalNode);
+      expect(node.condition, _isVar("a"));
+      expect(node.whenFalse, _isVar("e"));
+
+      expect(node.whenTrue, _isConditionalNode);
+      expect(node.whenTrue.condition, _isVar("b"));
+      expect(node.whenTrue.whenTrue, _isVar("c"));
+      expect(node.whenTrue.whenFalse, _isVar("d"));
+    });
+
+    test("with a conditional expression as branch 2", () {
+      // Should parse as "a ? b : (c ? d : e)".
+      // Should not parse as "(a ? b : c) ? d : e".
+      var node = _parse("a ? b : c ? d : e");
+      expect(node, _isConditionalNode);
+      expect(node.condition, _isVar("a"));
+      expect(node.whenTrue, _isVar("b"));
+
+      expect(node.whenFalse, _isConditionalNode);
+      expect(node.whenFalse.condition, _isVar("c"));
+      expect(node.whenFalse.whenTrue, _isVar("d"));
+      expect(node.whenFalse.whenFalse, _isVar("e"));
+    });
+
+    group("which must have", () {
+      test("an expression after the ?", () {
+        expect(() => _parse("a ?"), throwsFormatException);
+        expect(() => _parse("a ? && b"), throwsFormatException);
+      });
+
+      test("a :", () {
+        expect(() => _parse("a ? b"), throwsFormatException);
+        expect(() => _parse("a ? b && c"), throwsFormatException);
+      });
+
+      test("an expression after the :", () {
+        expect(() => _parse("a ? b :"), throwsFormatException);
+        expect(() => _parse("a ? b : && c"), throwsFormatException);
+      });
+    });
+  });
+
+  group("parses an or expression", () {
+    test("with identifiers", () {
+      var node = _parse("  a || b   ");
+      expect(node, _isOrNode);
+      expect(node.left, _isVar("a"));
+      expect(node.right, _isVar("b"));
+
+      expect(node.span.text, equals("a || b"));
+      expect(node.span.start.offset, equals(2));
+      expect(node.span.end.offset, equals(8));
+    });
+
+    test("with nested ands", () {
+      // Should parse as "(a && b) || (c && d)".
+      // Should not parse as "a && (b || c) && d".
+      var node = _parse("a && b || c && d");
+      expect(node, _isOrNode);
+
+      expect(node.left, _isAndNode);
+      expect(node.left.left, _isVar("a"));
+      expect(node.left.right, _isVar("b"));
+
+      expect(node.right, _isAndNode);
+      expect(node.right.left, _isVar("c"));
+      expect(node.right.right, _isVar("d"));
+    });
+
+    test("with trailing ors", () {
+      // Should parse as "a || (b || (c || d))", although it doesn't affect the
+      // semantics.
+      var node = _parse("a || b || c || d");
+
+      for (var variable in ["a", "b", "c"]) {
+        expect(node, _isOrNode);
+        expect(node.left, _isVar(variable));
+        node = node.right;
+      }
+      expect(node, _isVar("d"));
+    });
+
+    test("which must have an expression after the ||", () {
+      expect(() => _parse("a ||"), throwsFormatException);
+      expect(() => _parse("a || && b"), throwsFormatException);
+    });
+  });
+
+  group("parses an and expression", () {
+    test("with identifiers", () {
+      var node = _parse("  a && b   ");
+      expect(node, _isAndNode);
+      expect(node.left, _isVar("a"));
+      expect(node.right, _isVar("b"));
+
+      expect(node.span.text, equals("a && b"));
+      expect(node.span.start.offset, equals(2));
+      expect(node.span.end.offset, equals(8));
+    });
+
+    test("with nested nots", () {
+      // Should parse as "(!a) && (!b)", obviously.
+      // Should not parse as "!(a && (!b))".
+      var node = _parse("!a && !b");
+      expect(node, _isAndNode);
+
+      expect(node.left, _isNotNode);
+      expect(node.left.child, _isVar("a"));
+
+      expect(node.right, _isNotNode);
+      expect(node.right.child, _isVar("b"));
+    });
+
+    test("with trailing ands", () {
+      // Should parse as "a && (b && (c && d))", although it doesn't affect the
+      // semantics since .
+      var node = _parse("a && b && c && d");
+
+      for (var variable in ["a", "b", "c"]) {
+        expect(node, _isAndNode);
+        expect(node.left, _isVar(variable));
+        node = node.right;
+      }
+      expect(node, _isVar("d"));
+    });
+
+    test("which must have an expression after the &&", () {
+      expect(() => _parse("a &&"), throwsFormatException);
+      expect(() => _parse("a && && b"), throwsFormatException);
+    });
+  });
+
+  group("parses a not expression", () {
+    test("with an identifier", () {
+      var node = _parse("  ! a    ");
+      expect(node, _isNotNode);
+      expect(node.child, _isVar("a"));
+
+      expect(node.span.text, equals("! a"));
+      expect(node.span.start.offset, equals(2));
+      expect(node.span.end.offset, equals(5));
+    });
+
+    test("with a parenthesized expression", () {
+      var node = _parse("!(a || b)");
+      expect(node, _isNotNode);
+
+      expect(node.child, _isOrNode);
+      expect(node.child.left, _isVar("a"));
+      expect(node.child.right, _isVar("b"));
+    });
+
+    test("with a nested not", () {
+      var node = _parse("!!a");
+      expect(node, _isNotNode);
+      expect(node.child, _isNotNode);
+      expect(node.child.child, _isVar("a"));
+    });
+
+    test("which must have an expression after the !", () {
+      expect(() => _parse("!"), throwsFormatException);
+      expect(() => _parse("! && a"), throwsFormatException);
+    });
+  });
+
+  group("parses a parenthesized expression", () {
+    test("with an identifier", () {
+      var node = _parse("(a)");
+      expect(node, _isVar("a"));
+    });
+
+    test("controls precedence", () {
+      // Without parentheses, this would parse as "(a || b) ? c : d".
+      var node = _parse("a || (b ? c : d)");
+
+      expect(node, _isOrNode);
+      expect(node.left, _isVar("a"));
+
+      expect(node.right, _isConditionalNode);
+      expect(node.right.condition, _isVar("b"));
+      expect(node.right.whenTrue, _isVar("c"));
+      expect(node.right.whenFalse, _isVar("d"));
+    });
+
+    group("which must have", () {
+      test("an expression within the ()", () {
+        expect(() => _parse("()"), throwsFormatException);
+        expect(() => _parse("( && a )"), throwsFormatException);
+      });
+
+      test("a matching )", () {
+        expect(() => _parse("( a"), throwsFormatException);
+      });
+    });
+  });
+
+  group("disallows", () {
+    test("an empty selector", () {
+      expect(() => _parse(""), throwsFormatException);
+    });
+
+    test("too many expressions", () {
+      expect(() => _parse("a b"), throwsFormatException);
+    });
+  });
+}
+
+/// Parses [selector] and returns its root node.
+Node _parse(String selector) => new Parser(selector).parse();
+
+/// A matcher that asserts that a value is a [VariableNode] with the given
+/// [name].
+Matcher _isVar(String name) => predicate(
+    (value) => value is VariableNode && value.name == name,
+    'is a variable named "$name"');
+
+void _expectToString(String selector, [String result]) {
+  if (result == null) result = selector;
+  expect(_toString(selector), equals(result),
+      reason: 'Expected toString of "$selector" to be "$result".');
+}
+
+String _toString(String selector) => new Parser(selector).parse().toString();
diff --git a/test/scanner_test.dart b/test/scanner_test.dart
new file mode 100644
index 0000000..9c7be65
--- /dev/null
+++ b/test/scanner_test.dart
@@ -0,0 +1,267 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:test/test.dart';
+
+import 'package:boolean_selector/src/scanner.dart';
+import 'package:boolean_selector/src/token.dart';
+
+void main() {
+  group("peek()", () {
+    test("returns the next token without consuming it", () {
+      var scanner = new Scanner("( )");
+      expect(scanner.peek().type, equals(TokenType.leftParen));
+      expect(scanner.peek().type, equals(TokenType.leftParen));
+      expect(scanner.peek().type, equals(TokenType.leftParen));
+    });
+
+    test("returns an end-of-file token at the end of a file", () {
+      var scanner = new Scanner("( )");
+      scanner.next();
+      scanner.next();
+
+      var token = scanner.peek();
+      expect(token.type, equals(TokenType.endOfFile));
+      expect(token.span.start.offset, equals(3));
+      expect(token.span.end.offset, equals(3));
+    });
+
+    test("throws a StateError if called after end-of-file was consumed", () {
+      var scanner = new Scanner("( )");
+      scanner.next();
+      scanner.next();
+      scanner.next();
+      expect(() => scanner.peek(), throwsStateError);
+    });
+  });
+
+  group("next()", () {
+    test("consumes and returns the next token", () {
+      var scanner = new Scanner("( )");
+      expect(scanner.next().type, equals(TokenType.leftParen));
+      expect(scanner.peek().type, equals(TokenType.rightParen));
+      expect(scanner.next().type, equals(TokenType.rightParen));
+    });
+
+    test("returns an end-of-file token at the end of a file", () {
+      var scanner = new Scanner("( )");
+      scanner.next();
+      scanner.next();
+
+      var token = scanner.next();
+      expect(token.type, equals(TokenType.endOfFile));
+      expect(token.span.start.offset, equals(3));
+      expect(token.span.end.offset, equals(3));
+    });
+
+    test("throws a StateError if called after end-of-file was consumed", () {
+      var scanner = new Scanner("( )");
+      scanner.next();
+      scanner.next();
+      scanner.next();
+      expect(() => scanner.next(), throwsStateError);
+    });
+  });
+
+  group("scan()", () {
+    test("consumes a matching token and returns true", () {
+      var scanner = new Scanner("( )");
+      expect(scanner.scan(TokenType.leftParen), isTrue);
+      expect(scanner.peek().type, equals(TokenType.rightParen));
+    });
+
+    test("doesn't consume a matching token and returns false", () {
+      var scanner = new Scanner("( )");
+      expect(scanner.scan(TokenType.questionMark), isFalse);
+      expect(scanner.peek().type, equals(TokenType.leftParen));
+    });
+
+    test("throws a StateError called after end-of-file was consumed", () {
+      var scanner = new Scanner("( )");
+      scanner.next();
+      scanner.next();
+      scanner.next();
+      expect(() => scanner.scan(TokenType.endOfFile), throwsStateError);
+    });
+  });
+
+  group("scans a simple token:", () {
+    test("left paren", () => _expectSimpleScan("(", TokenType.leftParen));
+    test("right paren", () => _expectSimpleScan(")", TokenType.rightParen));
+    test("or", () => _expectSimpleScan("||", TokenType.or));
+    test("and", () => _expectSimpleScan("&&", TokenType.and));
+    test("not", () => _expectSimpleScan("!", TokenType.not));
+    test("question mark", () => _expectSimpleScan("?", TokenType.questionMark));
+    test("colon", () => _expectSimpleScan(":", TokenType.colon));
+  });
+
+  group("scans an identifier that", () {
+    test("is simple", () {
+      var token = _scan("   foo  ");
+      expect(token.name, equals("foo"));
+      expect(token.span.text, equals("foo"));
+      expect(token.span.start.offset, equals(3));
+      expect(token.span.end.offset, equals(6));
+    });
+
+    test("is a single character", () {
+      var token = _scan("f");
+      expect(token.name, equals("f"));
+    });
+
+    test("has a leading underscore", () {
+      var token = _scan("_foo");
+      expect(token.name, equals("_foo"));
+    });
+
+    test("has a leading dash", () {
+      var token = _scan("-foo");
+      expect(token.name, equals("-foo"));
+    });
+
+    test("contains an underscore", () {
+      var token = _scan("foo_bar");
+      expect(token.name, equals("foo_bar"));
+    });
+
+    test("contains a dash", () {
+      var token = _scan("foo-bar");
+      expect(token.name, equals("foo-bar"));
+    });
+
+    test("is capitalized", () {
+      var token = _scan("FOO");
+      expect(token.name, equals("FOO"));
+    });
+
+    test("contains numbers", () {
+      var token = _scan("foo123");
+      expect(token.name, equals("foo123"));
+    });
+  });
+
+  test("scans an empty selector", () {
+    expect(_scan("").type, equals(TokenType.endOfFile));
+  });
+
+  test("scans multiple tokens", () {
+    var scanner = new Scanner("(foo && bar)");
+
+    var token = scanner.next();
+    expect(token.type, equals(TokenType.leftParen));
+    expect(token.span.start.offset, equals(0));
+    expect(token.span.end.offset, equals(1));
+
+    token = scanner.next();
+    expect(token.type, equals(TokenType.identifier));
+    expect(token.name, equals("foo"));
+    expect(token.span.start.offset, equals(1));
+    expect(token.span.end.offset, equals(4));
+
+    token = scanner.next();
+    expect(token.type, equals(TokenType.and));
+    expect(token.span.start.offset, equals(5));
+    expect(token.span.end.offset, equals(7));
+
+    token = scanner.next();
+    expect(token.type, equals(TokenType.identifier));
+    expect(token.name, equals("bar"));
+    expect(token.span.start.offset, equals(8));
+    expect(token.span.end.offset, equals(11));
+
+    token = scanner.next();
+    expect(token.type, equals(TokenType.rightParen));
+    expect(token.span.start.offset, equals(11));
+    expect(token.span.end.offset, equals(12));
+
+    token = scanner.next();
+    expect(token.type, equals(TokenType.endOfFile));
+    expect(token.span.start.offset, equals(12));
+    expect(token.span.end.offset, equals(12));
+  });
+
+  group("ignores", () {
+    test("a single-line comment", () {
+      var scanner = new Scanner("( // &&\n// ||\n)");
+      expect(scanner.next().type, equals(TokenType.leftParen));
+      expect(scanner.next().type, equals(TokenType.rightParen));
+      expect(scanner.next().type, equals(TokenType.endOfFile));
+    });
+
+    test("a single-line comment without a trailing newline", () {
+      var scanner = new Scanner("( // &&");
+      expect(scanner.next().type, equals(TokenType.leftParen));
+      expect(scanner.next().type, equals(TokenType.endOfFile));
+    });
+
+    test("a multi-line comment", () {
+      var scanner = new Scanner("( /* && * /\n|| */\n)");
+      expect(scanner.next().type, equals(TokenType.leftParen));
+      expect(scanner.next().type, equals(TokenType.rightParen));
+      expect(scanner.next().type, equals(TokenType.endOfFile));
+    });
+
+    test("a multi-line nested comment", () {
+      var scanner = new Scanner("(/* && /* ? /* || */ : */ ! */)");
+      expect(scanner.next().type, equals(TokenType.leftParen));
+      expect(scanner.next().type, equals(TokenType.rightParen));
+      expect(scanner.next().type, equals(TokenType.endOfFile));
+    });
+
+    test("Dart's notion of whitespace", () {
+      var scanner = new Scanner("( \t \n)");
+      expect(scanner.next().type, equals(TokenType.leftParen));
+      expect(scanner.next().type, equals(TokenType.rightParen));
+      expect(scanner.next().type, equals(TokenType.endOfFile));
+    });
+  });
+
+  group("disallows", () {
+    test("a single |", () {
+      expect(() => _scan("|"), throwsFormatException);
+    });
+
+    test('"| |"', () {
+      expect(() => _scan("| |"), throwsFormatException);
+    });
+
+    test("a single &", () {
+      expect(() => _scan("&"), throwsFormatException);
+    });
+
+    test('"& &"', () {
+      expect(() => _scan("& &"), throwsFormatException);
+    });
+
+    test("an unknown operator", () {
+      expect(() => _scan("=="), throwsFormatException);
+    });
+
+    test("unicode", () {
+      expect(() => _scan("öh"), throwsFormatException);
+    });
+
+    test("an unclosed multi-line comment", () {
+      expect(() => _scan("/*"), throwsFormatException);
+    });
+
+    test("an unopened multi-line comment", () {
+      expect(() => _scan("*/"), throwsFormatException);
+    });
+  });
+}
+
+/// Asserts that the first token scanned from [selector] has type [type],
+/// and that that token's span is exactly [selector].
+void _expectSimpleScan(String selector, TokenType type) {
+  // Complicate the selector to test that the span covers it correctly.
+  var token = _scan("   $selector  ");
+  expect(token.type, equals(type));
+  expect(token.span.text, equals(selector));
+  expect(token.span.start.offset, equals(3));
+  expect(token.span.end.offset, equals(3 + selector.length));
+}
+
+/// Scans a single token from [selector].
+Token _scan(String selector) => new Scanner(selector).next();
diff --git a/test/to_string_test.dart b/test/to_string_test.dart
new file mode 100644
index 0000000..85721ac
--- /dev/null
+++ b/test/to_string_test.dart
@@ -0,0 +1,84 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:test/test.dart';
+
+import 'package:boolean_selector/boolean_selector.dart';
+
+void main() {
+  group("toString() for", () {
+    test("a variable is its name", () {
+      _expectToString("foo");
+      _expectToString("a-b");
+    });
+
+    group("not", () {
+      test("doesn't parenthesize a variable", () => _expectToString("!a"));
+      test("doesn't parenthesize a nested not", () => _expectToString("!!a"));
+      test("parenthesizes an or", () => _expectToString("!(a || b)"));
+      test("parenthesizes an and", () => _expectToString("!(a && b)"));
+      test("parenthesizes a condition", () => _expectToString("!(a ? b : c)"));
+    });
+
+    group("or", () {
+      test("doesn't parenthesize variables", () => _expectToString("a || b"));
+      test("doesn't parenthesize nots", () => _expectToString("!a || !b"));
+
+      test("doesn't parenthesize ors", () {
+        _expectToString("a || b || c || d");
+        _expectToString("((a || b) || c) || d", "a || b || c || d");
+      });
+
+      test("parenthesizes ands", () =>
+          _expectToString("a && b || c && d", "(a && b) || (c && d)"));
+
+      test("parenthesizes conditions", () =>
+          _expectToString("(a ? b : c) || (e ? f : g)"));
+    });
+
+    group("and", () {
+      test("doesn't parenthesize variables", () => _expectToString("a && b"));
+      test("doesn't parenthesize nots", () => _expectToString("!a && !b"));
+
+      test("parenthesizes ors", () =>
+          _expectToString("(a || b) && (c || d)", "(a || b) && (c || d)"));
+
+      test("doesn't parenthesize ands", () {
+        _expectToString("a && b && c && d");
+        _expectToString("((a && b) && c) && d", "a && b && c && d");
+      });
+
+      test("parenthesizes conditions", () =>
+          _expectToString("(a ? b : c) && (e ? f : g)"));
+    });
+
+    group("conditional", () {
+      test("doesn't parenthesize variables", () =>
+          _expectToString("a ? b : c"));
+
+      test("doesn't parenthesize nots", () => _expectToString("!a ? !b : !c"));
+
+      test("doesn't parenthesize ors", () =>
+          _expectToString("a || b ? c || d : e || f"));
+
+      test("doesn't parenthesize ands", () =>
+          _expectToString("a && b ? c && d : e && f"));
+
+      test("parenthesizes non-trailing conditions", () {
+        _expectToString("(a ? b : c) ? (e ? f : g) : h ? i : j");
+        _expectToString("(a ? b : c) ? (e ? f : g) : (h ? i : j)",
+            "(a ? b : c) ? (e ? f : g) : h ? i : j");
+      });
+    });
+  });
+}
+
+void _expectToString(String selector, [String result]) {
+  if (result == null) result = selector;
+  expect(_toString(selector), equals(result),
+      reason: 'Expected toString of "$selector" to be "$result".');
+}
+
+String _toString(String selector) =>
+    new BooleanSelector.parse(selector).toString();
diff --git a/test/validate_test.dart b/test/validate_test.dart
new file mode 100644
index 0000000..225df8a
--- /dev/null
+++ b/test/validate_test.dart
@@ -0,0 +1,21 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:test/test.dart';
+
+import 'package:boolean_selector/boolean_selector.dart';
+
+var _selector = new BooleanSelector.parse("foo && bar && baz");
+
+void main() {
+  test("throws if any variables are undefined", () {
+    expect(() => _selector.validate((variable) => variable == "bar"),
+        throwsFormatException);
+  });
+
+  test("doesn't throw if all variables are defined", () {
+    // Should not throw.
+    _selector.validate((variable) => true);
+  });
+}
diff --git a/test/variables_test.dart b/test/variables_test.dart
new file mode 100644
index 0000000..e66f4b8
--- /dev/null
+++ b/test/variables_test.dart
@@ -0,0 +1,44 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:test/test.dart';
+
+import 'package:boolean_selector/boolean_selector.dart';
+
+void main() {
+  test("a variable reports itself", () {
+    expect(new BooleanSelector.parse("foo").variables, equals(["foo"]));
+  });
+
+  test("a negation reports its contents", () {
+    expect(new BooleanSelector.parse("!foo").variables, equals(["foo"]));
+  });
+
+  test("a parenthesized expression reports its contents", () {
+    expect(new BooleanSelector.parse("(foo)").variables, equals(["foo"]));
+  });
+
+  test("an or reports its contents", () {
+    expect(new BooleanSelector.parse("foo || bar").variables,
+        equals(["foo", "bar"]));
+  });
+
+  test("an and reports its contents", () {
+    expect(new BooleanSelector.parse("foo && bar").variables,
+        equals(["foo", "bar"]));
+  });
+
+  test("a conditional reports its contents", () {
+    expect(new BooleanSelector.parse("foo ? bar : baz").variables,
+        equals(["foo", "bar", "baz"]));
+  });
+
+  test("BooleanSelector.all reports no variables", () {
+    expect(BooleanSelector.all.variables, isEmpty);
+  });
+
+  test("BooleanSelector.none reports no variables", () {
+    expect(BooleanSelector.none.variables, isEmpty);
+  });
+}