diff --git a/lib/src/backend/platform_selector/ast.dart b/lib/src/backend/platform_selector/ast.dart
new file mode 100644
index 0000000000000000000000000000000000000000..d1eb0aac16f50b30f33a9256e171cb48dd405313
--- /dev/null
+++ b/lib/src/backend/platform_selector/ast.dart
@@ -0,0 +1,124 @@
+// Copyright (c) 2015, 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.
+
+library unittest.backend.platform_selector.ast;
+
+import 'package:source_span/source_span.dart';
+
+/// The superclass of nodes in the platform 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;
+}
+
+/// A single variable.
+class VariableNode implements Node {
+  final FileSpan span;
+
+  /// The variable name.
+  final String name;
+
+  VariableNode(this.name, [this.span]);
+
+  String toString() => name;
+}
+
+/// A negation expression.
+class NotNode implements Node {
+  final FileSpan span;
+
+  /// The expression being negated.
+  final Node child;
+
+  NotNode(this.child, [this.span]);
+
+  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;
+
+  OrNode(this.left, this.right);
+
+  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;
+
+  AndNode(this.left, this.right);
+
+  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;
+
+  ConditionalNode(this.condition, this.whenTrue, this.whenFalse);
+
+  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/backend/platform_selector/parser.dart b/lib/src/backend/platform_selector/parser.dart
new file mode 100644
index 0000000000000000000000000000000000000000..2761e8c0b2804207aee7682938d94a8e22a807b1
--- /dev/null
+++ b/lib/src/backend/platform_selector/parser.dart
@@ -0,0 +1,107 @@
+// Copyright (c) 2015, 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.
+
+library unittest.backend.platform_selector.parser;
+
+import 'package:source_span/source_span.dart';
+
+import 'ast.dart';
+import 'scanner.dart';
+import 'token.dart';
+
+/// A class for parsing a platform selector.
+///
+/// Platform 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/backend/platform_selector/scanner.dart b/lib/src/backend/platform_selector/scanner.dart
new file mode 100644
index 0000000000000000000000000000000000000000..3c2763c9b8a45393ce8ebd6bba05e198d49ee123
--- /dev/null
+++ b/lib/src/backend/platform_selector/scanner.dart
@@ -0,0 +1,153 @@
+// Copyright (c) 2015, 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.
+
+library unittest.backend.platform_selector.scanner;
+
+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 an identifier.
+///
+/// Unlike standard Dart identifiers, platform selector identifiers may
+/// contain dashes for consistency with command-line arguments.
+final _identifier = new RegExp(r"[a-zA-Z_-][a-zA-Z0-9_-]*");
+
+/// A scanner that converts a platform 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(_identifier, 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/backend/platform_selector/token.dart b/lib/src/backend/platform_selector/token.dart
new file mode 100644
index 0000000000000000000000000000000000000000..fc6828a76cb48ef5e50e50bd1dfecc3a44bface8
--- /dev/null
+++ b/lib/src/backend/platform_selector/token.dart
@@ -0,0 +1,72 @@
+// Copyright (c) 2015, 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.
+
+library unittest.backend.platform_selector.token;
+
+import 'package:source_span/source_span.dart';
+
+/// A token in a platform 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/test/backend/platform_selector/ast_test.dart b/test/backend/platform_selector/ast_test.dart
new file mode 100644
index 0000000000000000000000000000000000000000..1362141dd5da0869513c38ed11c9e344187554ce
--- /dev/null
+++ b/test/backend/platform_selector/ast_test.dart
@@ -0,0 +1,83 @@
+// Copyright (c) 2015, 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:unittest/unittest.dart';
+import 'package:unittest/src/backend/platform_selector/ast.dart';
+import 'package:unittest/src/backend/platform_selector/parser.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 Parser(selector).parse().toString();
diff --git a/test/backend/platform_selector/parser_test.dart b/test/backend/platform_selector/parser_test.dart
new file mode 100644
index 0000000000000000000000000000000000000000..d53020b923fdae7a7387682d9cb252fcde62232d
--- /dev/null
+++ b/test/backend/platform_selector/parser_test.dart
@@ -0,0 +1,267 @@
+// Copyright (c) 2015, 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:unittest/unittest.dart';
+import 'package:unittest/src/backend/platform_selector/ast.dart';
+import 'package:unittest/src/backend/platform_selector/parser.dart';
+
+/// A matcher that asserts that a value is a [ConditionalNode].
+Matcher _isConditionalNode =
+    new isInstanceOf<ConditionalNode>("ConditionalNode");
+
+/// A matcher that asserts that a value is an [OrNode].
+Matcher _isOrNode = new isInstanceOf<OrNode>("OrNode");
+
+/// A matcher that asserts that a value is an [AndNode].
+Matcher _isAndNode = new isInstanceOf<AndNode>("AndNode");
+
+/// A matcher that asserts that a value is a [NotNode].
+Matcher _isNotNode = new isInstanceOf<NotNode>("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/backend/platform_selector/scanner_test.dart b/test/backend/platform_selector/scanner_test.dart
new file mode 100644
index 0000000000000000000000000000000000000000..e2ecdda6d67b1b452768444d8f1eac54bb6e39cb
--- /dev/null
+++ b/test/backend/platform_selector/scanner_test.dart
@@ -0,0 +1,266 @@
+// Copyright (c) 2015, 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:unittest/unittest.dart';
+import 'package:unittest/src/backend/platform_selector/scanner.dart';
+import 'package:unittest/src/backend/platform_selector/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();