Add more set-like version constraint operations.

This adds support for union(), as well as checking if constraints are supersets
of or disjoint with one another. These are necessary for some version solver
work I'm doing in pub.

R=rnystrom@google.com

Review URL: https://codereview.chromium.org/1127783002
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1580867..32538cc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,15 @@
+# 1.2.0
+
+* Add a `VersionConstraint.union()` method and a `new
+  VersionConstraint.unionOf()` constructor. These each return a constraint that
+  matches multiple existing constraints.
+
+* Add a `VersionConstraint.allowsAll()` method, which returns whether one
+  constraint is a superset of another.
+
+* Add a `VersionConstraint.allowsAny()` method, which returns whether one
+  constraint overlaps another.
+
 # 1.1.0
 
 * Add support for the `^` operator for compatible versions according to pub's
diff --git a/lib/src/utils.dart b/lib/src/utils.dart
new file mode 100644
index 0000000..2853e5c
--- /dev/null
+++ b/lib/src/utils.dart
@@ -0,0 +1,26 @@
+// 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 pub_semver.src.utils;
+
+import 'version_range.dart';
+
+/// Returns whether [range1] is immediately next to, but not overlapping,
+/// [range2].
+bool areAdjacent(VersionRange range1, VersionRange range2) {
+  if (range1.max != range2.min) return false;
+
+  return (range1.includeMax && !range2.includeMin) ||
+      (!range1.includeMax && range2.includeMin);
+}
+
+/// A [Comparator] that compares the maximum versions of [range1] and [range2].
+int compareMax(VersionRange range1, VersionRange range2) {
+  if (range1.max < range2.max) return -1;
+  if (range1.max > range2.max) return 1;
+
+  if (!range1.includeMax && range2.includeMax) return -1;
+  if (range1.includeMax && !range2.includeMax) return 1;
+  return 0;
+}
diff --git a/lib/src/version.dart b/lib/src/version.dart
index 2bf535f..51972f7 100644
--- a/lib/src/version.dart
+++ b/lib/src/version.dart
@@ -4,7 +4,7 @@
 
 library pub_semver.src.version;
 
-import 'dart:math';
+import 'dart:math' as math;
 
 import 'package:collection/equality.dart';
 
@@ -16,7 +16,7 @@
 final _equality = const IterableEquality();
 
 /// A parsed semantic version number.
-class Version implements Comparable<Version>, VersionConstraint {
+class Version implements Comparable<Version>, VersionConstraint, VersionRange {
   /// No released version: i.e. "0.0.0".
   static Version get none => new Version(0, 0, 0);
 
@@ -85,6 +85,11 @@
   /// of the parsed version.
   final String _text;
 
+  Version get min => this;
+  Version get max => this;
+  bool get includeMin => true;
+  bool get includeMax => true;
+
   Version._(this.major, this.minor, this.patch, String preRelease, String build,
             this._text)
       : preRelease = preRelease == null ? [] : _splitParts(preRelease),
@@ -237,19 +242,31 @@
   /// Tests if [other] matches this version exactly.
   bool allows(Version other) => this == other;
 
-  VersionConstraint intersect(VersionConstraint other) {
-    if (other.isEmpty) return other;
+  bool allowsAll(VersionConstraint other) => other.isEmpty || other == this;
 
-    // Intersect a version and a range.
-    if (other is VersionRange) return other.intersect(this);
+  bool allowsAny(VersionConstraint other) => other.allows(this);
 
-    // Intersecting two versions only works if they are the same.
-    if (other is Version) {
-      return this == other ? this : VersionConstraint.empty;
+  VersionConstraint intersect(VersionConstraint other) =>
+      other.allows(this) ? this : VersionConstraint.empty;
+
+  VersionConstraint union(VersionConstraint other) {
+    if (other.allows(this)) return other;
+
+    if (other is VersionRange) {
+      if (other.min == this) {
+        return new VersionRange(
+            min: other.min, max: other.max,
+            includeMin: true, includeMax: other.includeMax);
+      }
+
+      if (other.max == this) {
+        return new VersionRange(
+            min: other.min, max: other.max,
+            includeMin: other.includeMin, includeMax: true);
+      }
     }
 
-    throw new ArgumentError(
-        'Unknown VersionConstraint type $other.');
+    return new VersionConstraint.unionOf([this, other]);
   }
 
   int compareTo(Version other) {
@@ -277,7 +294,7 @@
   /// This is used for the pre-release and build version parts. This follows
   /// Rule 12 of the Semantic Versioning spec (v2.0.0-rc.1).
   int _compareLists(List a, List b) {
-    for (var i = 0; i < max(a.length, b.length); i++) {
+    for (var i = 0; i < math.max(a.length, b.length); i++) {
       var aPart = (i < a.length) ? a[i] : null;
       var bPart = (i < b.length) ? b[i] : null;
 
diff --git a/lib/src/version_constraint.dart b/lib/src/version_constraint.dart
index cb1c64c..3f4d5b8 100644
--- a/lib/src/version_constraint.dart
+++ b/lib/src/version_constraint.dart
@@ -7,6 +7,7 @@
 import 'patterns.dart';
 import 'version.dart';
 import 'version_range.dart';
+import 'version_union.dart';
 
 /// A [VersionConstraint] is a predicate that can determine whether a given
 /// version is valid or not.
@@ -168,6 +169,14 @@
     return constraint;
   }
 
+  /// Creates a new version constraint that is the union of [constraints].
+  ///
+  /// It allows any versions that any of those constraints allows. If
+  /// [constraints] is empty, this returns a constraint that allows no versions.
+  factory VersionConstraint.unionOf(
+          Iterable<VersionConstraint> constraints) =>
+      VersionUnion.create(constraints);
+
   /// Returns `true` if this constraint allows no versions.
   bool get isEmpty;
 
@@ -177,9 +186,21 @@
   /// Returns `true` if this constraint allows [version].
   bool allows(Version version);
 
+  /// Returns `true` if this constraint allows all the versions that [other]
+  /// allows.
+  bool allowsAll(VersionConstraint other);
+
+  /// Returns `true` if this constraint allows any of the versions that [other]
+  /// allows.
+  bool allowsAny(VersionConstraint other);
+
   /// Creates a new [VersionConstraint] that only allows [Version]s allowed by
   /// both this and [other].
   VersionConstraint intersect(VersionConstraint other);
+
+  /// Creates a new [VersionConstraint] that allows [Versions]s allowed by
+  /// either this or [other].
+  VersionConstraint union(VersionConstraint other);
 }
 
 class _EmptyVersion implements VersionConstraint {
@@ -188,7 +209,10 @@
   bool get isEmpty => true;
   bool get isAny => false;
   bool allows(Version other) => false;
+  bool allowsAll(Version other) => other.isEmpty;
+  bool allowsAny(Version other) => false;
   VersionConstraint intersect(VersionConstraint other) => this;
+  VersionConstraint union(VersionConstraint other) => other;
   String toString() => '<empty>';
 }
 
diff --git a/lib/src/version_range.dart b/lib/src/version_range.dart
index faffb47..7a18edf 100644
--- a/lib/src/version_range.dart
+++ b/lib/src/version_range.dart
@@ -6,6 +6,7 @@
 
 import 'version.dart';
 import 'version_constraint.dart';
+import 'version_union.dart';
 
 /// Constrains versions to a fall within a given range.
 ///
@@ -99,8 +100,81 @@
     return true;
   }
 
+  bool allowsAll(VersionConstraint other) {
+    if (other.isEmpty) return true;
+    if (other is Version) return allows(other);
+
+    if (other is VersionUnion) {
+      return other.constraints.every((constraint) => allowsAll(constraint));
+    }
+
+    if (other is VersionRange) {
+      if (min != null) {
+        if (other.min == null) return false;
+        if (min > other.min) return false;
+        if (min == other.min && !includeMin && other.includeMin) return false;
+      }
+
+      if (max != null) {
+        if (other.max == null) return false;
+        if (max < other.max) return false;
+        if (max == other.max && !includeMax && other.includeMax) return false;
+      }
+
+      return true;
+    }
+
+    throw new ArgumentError('Unknown VersionConstraint type $other.');
+  }
+
+  bool allowsAny(VersionConstraint other) {
+    if (other.isEmpty) return false;
+    if (other is Version) return allows(other);
+
+    if (other is VersionUnion) {
+      return other.constraints.any((constraint) => allowsAny(constraint));
+    }
+
+    if (other is VersionRange) {
+      // If neither range has a minimum, they'll overlap at some point.
+      //
+      //     ...     this     ]
+      //     ...     other         ]
+      if (min == null && other.min == null) return true;
+
+      // If this range has a lower minimum than the other range, it overlaps as
+      // long as its maximum is higher than or the same as the other range's
+      // minimum.
+      //
+      //     [  this  ]            [  this  ]
+      //        [  other  ]                 [  other  ]
+      if (min == null || (other.min != null && min < other.min)) {
+        if (max == null) return true;
+        if (max > other.min) return true;
+        if (max < other.min) return false;
+        assert(max == other.min);
+        return includeMax && other.includeMin;
+      }
+
+      // If this range has a higher minimum than the other range, it overlaps as
+      // long as its minimum is lower than or the same as the other range's
+      // maximum.
+      //
+      //         [  this  ]               [  this  ]
+      //     [  other  ]        [  other  ]
+      if (other.max == null) return true;
+      if (min < other.max) return true;
+      if (min > other.max) return false;
+      assert(min == other.max);
+      return includeMin && other.includeMax;
+    }
+
+    throw new ArgumentError('Unknown VersionConstraint type $other.');
+  }
+
   VersionConstraint intersect(VersionConstraint other) {
     if (other.isEmpty) return other;
+    if (other is VersionUnion) return other.intersect(this);
 
     // A range and a Version just yields the version if it's in the range.
     if (other is Version) {
@@ -162,6 +236,66 @@
     throw new ArgumentError('Unknown VersionConstraint type $other.');
   }
 
+  VersionConstraint union(VersionConstraint other) {
+    if (other is Version) {
+      if (allows(other)) return this;
+
+      if (other == min) {
+        return new VersionRange(
+            min: this.min, max: this.max,
+            includeMin: true, includeMax: this.includeMax);
+      }
+
+      if (other == max) {
+        return new VersionRange(
+            min: this.min, max: this.max,
+            includeMin: this.includeMin, includeMax: true);
+      }
+
+      return new VersionConstraint.unionOf([this, other]);
+    }
+
+    if (other is VersionRange) {
+      // If the two ranges don't overlap, we won't be able to create a single
+      // VersionRange for both of them.
+      var edgesTouch = (max == other.min && (includeMax || other.includeMin)) ||
+          (min == other.max && (includeMin || other.includeMax));
+      if (!edgesTouch && !allowsAny(other)) {
+        return new VersionConstraint.unionOf([this, other]);
+      }
+
+      var unionMin = min;
+      var unionIncludeMin = includeMin;
+      var unionMax = max;
+      var unionIncludeMax = includeMax;
+
+      if (unionMin == null) {
+        // Do nothing.
+      } else if (other.min == null || other.min < min) {
+        unionMin = other.min;
+        unionIncludeMin = other.includeMin;
+      } else if (min == other.min && other.includeMin) {
+        // If the edges are the same but one is inclusive, make it inclusive.
+        unionIncludeMin = true;
+      }
+
+      if (unionMax == null) {
+        // Do nothing.
+      } else if (other.max == null || other.max > max) {
+        unionMax = other.max;
+        unionIncludeMax = other.includeMax;
+      } else if (max == other.max && other.includeMax) {
+        // If the edges are the same but one is inclusive, make it inclusive.
+        unionIncludeMax = true;
+      }
+
+      return new VersionRange(min: unionMin, max: unionMax,
+          includeMin: unionIncludeMin, includeMax: unionIncludeMax);
+    }
+
+    return new VersionConstraint.unionOf([this, other]);
+  }
+
   String toString() {
     var buffer = new StringBuffer();
 
diff --git a/lib/src/version_union.dart b/lib/src/version_union.dart
new file mode 100644
index 0000000..ee9f657
--- /dev/null
+++ b/lib/src/version_union.dart
@@ -0,0 +1,182 @@
+// 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 pub_semver.src.version_union;
+
+import 'package:collection/collection.dart';
+
+import 'utils.dart';
+import 'version.dart';
+import 'version_constraint.dart';
+import 'version_range.dart';
+
+/// A (package-private) version constraint representing a union of multiple
+/// disjoint version constraints.
+///
+/// An instance of this will only be created if the version can't be represented
+/// as a non-compound value.
+class VersionUnion implements VersionConstraint {
+  /// The constraints that compose this union.
+  ///
+  /// This list has two invariants:
+  ///
+  /// * Its contents are sorted from lowest to highest matched versions.
+  /// * Its contents are disjoint and non-adjacent. In other words, for any two
+  ///   constraints next to each other in the list, there's some version between
+  ///   those constraints that they don't match.
+  final List<VersionRange> constraints;
+
+  bool get isEmpty => false;
+
+  bool get isAny => false;
+
+  /// Returns the union of [constraints].
+  ///
+  /// This ensures that an actual [VersionUnion] is only returned if necessary.
+  /// It also takes care of sorting and merging the constraints to ensure that
+  /// they're disjoint.
+  static VersionConstraint create(Iterable<VersionConstraint> constraints) {
+    var flattened = constraints.expand((constraint) {
+      if (constraint.isEmpty) return [];
+      if (constraint is VersionUnion) return constraint.constraints;
+      return [constraint];
+    }).toList();
+
+    if (flattened.isEmpty) return VersionConstraint.empty;
+
+    if (flattened.any((constraint) => constraint.isAny)) {
+      return VersionConstraint.any;
+    }
+
+    // Only allow Versions and VersionRanges here so we can more easily reason
+    // about everything in [flattened]. _EmptyVersions and VersionUnions are
+    // filtered out above.
+    for (var constraint in flattened) {
+      if (constraint is VersionRange) continue;
+      throw new ArgumentError('Unknown VersionConstraint type $constraint.');
+    }
+
+    (flattened as List).sort(compareMax);
+
+    var merged = [];
+    for (var constraint in flattened) {
+      // Merge this constraint with the previous one, but only if they touch.
+      if (merged.isEmpty ||
+          (!merged.last.allowsAny(constraint) &&
+              !areAdjacent(merged.last, constraint))) {
+        merged.add(constraint);
+      } else {
+        merged[merged.length - 1] = merged.last.union(constraint);
+      }
+    }
+
+    if (merged.length == 1) return merged.single;
+    return new VersionUnion._(merged);
+  }
+
+  VersionUnion._(this.constraints);
+
+  bool allows(Version version) =>
+      constraints.any((constraint) => constraint.allows(version));
+
+  bool allowsAll(VersionConstraint other) {
+    var ourConstraints = constraints.iterator;
+    var theirConstraints = _constraintsFor(other).iterator;
+
+    // Because both lists of constraints are ordered by minimum version, we can
+    // safely move through them linearly here.
+    ourConstraints.moveNext();
+    theirConstraints.moveNext();
+    while (ourConstraints.current != null && theirConstraints.current != null) {
+      if (ourConstraints.current.allowsAll(theirConstraints.current)) {
+        theirConstraints.moveNext();
+      } else {
+        ourConstraints.moveNext();
+      }
+    }
+
+    // If our constraints have allowed all of their constraints, we'll have
+    // consumed all of them.
+    return theirConstraints.current == null;
+  }
+
+  bool allowsAny(VersionConstraint other) {
+    var ourConstraints = constraints.iterator;
+    var theirConstraints = _constraintsFor(other).iterator;
+
+    // Because both lists of constraints are ordered by minimum version, we can
+    // safely move through them linearly here.
+    ourConstraints.moveNext();
+    theirConstraints.moveNext();
+    while (ourConstraints.current != null && theirConstraints.current != null) {
+      if (ourConstraints.current.allowsAny(theirConstraints.current)) {
+        return true;
+      }
+
+      // Move the constraint with the higher max value forward. This ensures
+      // that we keep both lists in sync as much as possible.
+      if (compareMax(ourConstraints.current, theirConstraints.current) < 0) {
+        ourConstraints.moveNext();
+      } else {
+        theirConstraints.moveNext();
+      }
+    }
+
+    return false;
+  }
+
+  VersionConstraint intersect(VersionConstraint other) {
+    var ourConstraints = constraints.iterator;
+    var theirConstraints = _constraintsFor(other).iterator;
+
+    // Because both lists of constraints are ordered by minimum version, we can
+    // safely move through them linearly here.
+    var newConstraints = [];
+    ourConstraints.moveNext();
+    theirConstraints.moveNext();
+    while (ourConstraints.current != null && theirConstraints.current != null) {
+      var intersection = ourConstraints.current
+          .intersect(theirConstraints.current);
+
+      if (!intersection.isEmpty) newConstraints.add(intersection);
+
+      // Move the constraint with the higher max value forward. This ensures
+      // that we keep both lists in sync as much as possible, and that large
+      // constraints have a chance to match multiple small constraints that they
+      // contain.
+      if (compareMax(ourConstraints.current, theirConstraints.current) < 0) {
+        ourConstraints.moveNext();
+      } else {
+        theirConstraints.moveNext();
+      }
+    }
+
+    if (newConstraints.isEmpty) return VersionConstraint.empty;
+    if (newConstraints.length == 1) return newConstraints.single;
+
+    return new VersionUnion._(newConstraints);
+  }
+
+  /// Returns [constraint] as a list of constraints.
+  ///
+  /// This is used to normalize constraints of various types.
+  List<VersionRange> _constraintsFor(VersionConstraint constraint) {
+    if (constraint.isEmpty) return [];
+    if (constraint is VersionUnion) return constraint.constraints;
+    if (constraint is VersionRange) return [constraint];
+    throw new ArgumentError('Unknown VersionConstraint type $constraint.');
+  }
+
+  VersionConstraint union(VersionConstraint other) =>
+      new VersionConstraint.unionOf([this, other]);
+
+  bool operator ==(other) {
+    if (other is! VersionUnion) return false;
+    return const ListEquality().equals(constraints, other.constraints);
+  }
+
+  int get hashCode => const ListEquality().hash(constraints);
+
+  String toString() => constraints.join(" or ");
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index bfb104c..26be280 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: pub_semver
-version: 1.1.1-dev
+version: 1.2.0
 author: Dart Team <misc@dartlang.org>
 description: >
  Versions and version constraints implementing pub's versioning policy. This
diff --git a/test/version_range_test.dart b/test/version_range_test.dart
index e15539b..ef35d80 100644
--- a/test/version_range_test.dart
+++ b/test/version_range_test.dart
@@ -151,6 +151,167 @@
     });
   });
 
+  group('allowsAll()', () {
+    test('allows an empty constraint', () {
+      expect(
+          new VersionRange(min: v123, max: v250)
+              .allowsAll(VersionConstraint.empty),
+          isTrue);
+    });
+
+    test('allows allowed versions', () {
+      var range = new VersionRange(min: v123, max: v250, includeMax: true);
+      expect(range.allowsAll(v123), isFalse);
+      expect(range.allowsAll(v124), isTrue);
+      expect(range.allowsAll(v250), isTrue);
+      expect(range.allowsAll(v300), isFalse);
+    });
+
+    test('with no min', () {
+      var range = new VersionRange(max: v250);
+      expect(range.allowsAll(new VersionRange(min: v080, max: v140)), isTrue);
+      expect(range.allowsAll(new VersionRange(min: v080, max: v300)), isFalse);
+      expect(range.allowsAll(new VersionRange(max: v140)), isTrue);
+      expect(range.allowsAll(new VersionRange(max: v300)), isFalse);
+      expect(range.allowsAll(range), isTrue);
+      expect(range.allowsAll(VersionConstraint.any), isFalse);
+    });
+
+    test('with no max', () {
+      var range = new VersionRange(min: v010);
+      expect(range.allowsAll(new VersionRange(min: v080, max: v140)), isTrue);
+      expect(range.allowsAll(new VersionRange(min: v003, max: v140)), isFalse);
+      expect(range.allowsAll(new VersionRange(min: v080)), isTrue);
+      expect(range.allowsAll(new VersionRange(min: v003)), isFalse);
+      expect(range.allowsAll(range), isTrue);
+      expect(range.allowsAll(VersionConstraint.any), isFalse);
+    });
+
+    test('with a min and max', () {
+      var range = new VersionRange(min: v010, max: v250);
+      expect(range.allowsAll(new VersionRange(min: v080, max: v140)), isTrue);
+      expect(range.allowsAll(new VersionRange(min: v080, max: v300)), isFalse);
+      expect(range.allowsAll(new VersionRange(min: v003, max: v140)), isFalse);
+      expect(range.allowsAll(new VersionRange(min: v080)), isFalse);
+      expect(range.allowsAll(new VersionRange(max: v140)), isFalse);
+      expect(range.allowsAll(range), isTrue);
+    });
+
+    test("allows a bordering range that's not more inclusive", () {
+      var exclusive = new VersionRange(min: v010, max: v250);
+      var inclusive = new VersionRange(
+          min: v010, includeMin: true, max: v250, includeMax: true);
+      expect(inclusive.allowsAll(exclusive), isTrue);
+      expect(inclusive.allowsAll(inclusive), isTrue);
+      expect(exclusive.allowsAll(inclusive), isFalse);
+      expect(exclusive.allowsAll(exclusive), isTrue);
+    });
+
+    test('allows unions that are completely contained', () {
+      var range = new VersionRange(min: v114, max: v200);
+      expect(
+          range.allowsAll(new VersionRange(min: v123, max: v124).union(v140)),
+          isTrue);
+      expect(
+          range.allowsAll(new VersionRange(min: v010, max: v124).union(v140)),
+          isFalse);
+      expect(
+          range.allowsAll(new VersionRange(min: v123, max: v234).union(v140)),
+          isFalse);
+    });
+  });
+
+  group('allowsAny()', () {
+    test('disallows an empty constraint', () {
+      expect(
+          new VersionRange(min: v123, max: v250)
+              .allowsAny(VersionConstraint.empty),
+          isFalse);
+    });
+
+    test('allows allowed versions', () {
+      var range = new VersionRange(min: v123, max: v250, includeMax: true);
+      expect(range.allowsAny(v123), isFalse);
+      expect(range.allowsAny(v124), isTrue);
+      expect(range.allowsAny(v250), isTrue);
+      expect(range.allowsAny(v300), isFalse);
+    });
+
+    test('with no min', () {
+      var range = new VersionRange(max: v200);
+      expect(range.allowsAny(new VersionRange(min: v140, max: v300)), isTrue);
+      expect(range.allowsAny(new VersionRange(min: v234, max: v300)), isFalse);
+      expect(range.allowsAny(new VersionRange(min: v140)), isTrue);
+      expect(range.allowsAny(new VersionRange(min: v234)), isFalse);
+      expect(range.allowsAny(range), isTrue);
+    });
+
+    test('with no max', () {
+      var range = new VersionRange(min: v072);
+      expect(range.allowsAny(new VersionRange(min: v003, max: v140)), isTrue);
+      expect(range.allowsAny(new VersionRange(min: v003, max: v010)), isFalse);
+      expect(range.allowsAny(new VersionRange(max: v080)), isTrue);
+      expect(range.allowsAny(new VersionRange(max: v003)), isFalse);
+      expect(range.allowsAny(range), isTrue);
+    });
+
+    test('with a min and max', () {
+      var range = new VersionRange(min: v072, max: v200);
+      expect(range.allowsAny(new VersionRange(min: v003, max: v140)), isTrue);
+      expect(range.allowsAny(new VersionRange(min: v140, max: v300)), isTrue);
+      expect(range.allowsAny(new VersionRange(min: v003, max: v010)), isFalse);
+      expect(range.allowsAny(new VersionRange(min: v234, max: v300)), isFalse);
+      expect(range.allowsAny(new VersionRange(max: v010)), isFalse);
+      expect(range.allowsAny(new VersionRange(min: v234)), isFalse);
+      expect(range.allowsAny(range), isTrue);
+    });
+
+    test('allows a bordering range when both are inclusive', () {
+      expect(new VersionRange(max: v250).allowsAny(new VersionRange(min: v250)),
+          isFalse);
+
+      expect(new VersionRange(max: v250, includeMax: true)
+              .allowsAny(new VersionRange(min: v250)),
+          isFalse);
+
+      expect(new VersionRange(max: v250)
+              .allowsAny(new VersionRange(min: v250, includeMin: true)),
+          isFalse);
+
+      expect(new VersionRange(max: v250, includeMax: true)
+              .allowsAny(new VersionRange(min: v250, includeMin: true)),
+          isTrue);
+
+      expect(new VersionRange(min: v250).allowsAny(new VersionRange(max: v250)),
+          isFalse);
+
+      expect(new VersionRange(min: v250, includeMin: true)
+              .allowsAny(new VersionRange(max: v250)),
+          isFalse);
+
+      expect(new VersionRange(min: v250)
+              .allowsAny(new VersionRange(max: v250, includeMax: true)),
+          isFalse);
+
+      expect(new VersionRange(min: v250, includeMin: true)
+              .allowsAny(new VersionRange(max: v250, includeMax: true)),
+          isTrue);
+    });
+
+    test('allows unions that are partially contained', () {
+      var range = new VersionRange(min: v114, max: v200);
+      expect(
+          range.allowsAny(new VersionRange(min: v010, max: v080).union(v140)),
+          isTrue);
+      expect(
+          range.allowsAny(new VersionRange(min: v123, max: v234).union(v300)),
+          isTrue);
+      expect(
+          range.allowsAny(new VersionRange(min: v234, max: v300).union(v010)),
+          isFalse);
+    });
+  });
+
   group('intersect()', () {
     test('two overlapping ranges', () {
       var a = new VersionRange(min: v123, max: v250);
@@ -195,6 +356,81 @@
     });
   });
 
+  group('union()', () {
+    test("with a version returns the range if it contains the version", () {
+      var range = new VersionRange(min: v114, max: v124);
+      expect(range.union(v123), equals(range));
+    });
+
+    test("with a version on the edge of the range, expands the range", () {
+      expect(new VersionRange(min: v114, max: v124).union(v124),
+          equals(new VersionRange(min: v114, max: v124, includeMax: true)));
+      expect(new VersionRange(min: v114, max: v124).union(v114),
+          equals(new VersionRange(min: v114, max: v124, includeMin: true)));
+    });
+
+    test("with a version allows both the range and the version if the range "
+        "doesn't contain the version", () {
+      var result = new VersionRange(min: v003, max: v114).union(v124);
+      expect(result, allows(v010));
+      expect(result, doesNotAllow(v123));
+      expect(result, allows(v124));
+    });
+
+    test("returns a VersionUnion for a disjoint range", () {
+      var result = new VersionRange(min: v003, max: v114)
+          .union(new VersionRange(min: v130, max: v200));
+      expect(result, allows(v080));
+      expect(result, doesNotAllow(v123));
+      expect(result, allows(v140));
+    });
+
+    test("considers open ranges disjoint", () {
+      var result = new VersionRange(min: v003, max: v114)
+          .union(new VersionRange(min: v114, max: v200));
+      expect(result, allows(v080));
+      expect(result, doesNotAllow(v114));
+      expect(result, allows(v140));
+
+      result = new VersionRange(min: v114, max: v200)
+          .union(new VersionRange(min: v003, max: v114));
+      expect(result, allows(v080));
+      expect(result, doesNotAllow(v114));
+      expect(result, allows(v140));
+    });
+
+    test("returns a merged range for an overlapping range", () {
+      var result = new VersionRange(min: v003, max: v114)
+          .union(new VersionRange(min: v080, max: v200));
+      expect(result, equals(new VersionRange(min: v003, max: v200)));
+    });
+
+    test("considers closed ranges overlapping", () {
+      var result = new VersionRange(min: v003, max: v114, includeMax: true)
+          .union(new VersionRange(min: v114, max: v200));
+      expect(result, equals(new VersionRange(min: v003, max: v200)));
+
+      result = new VersionRange(min: v003, max: v114)
+          .union(new VersionRange(min: v114, max: v200, includeMin: true));
+      expect(result, equals(new VersionRange(min: v003, max: v200)));
+
+      result = new VersionRange(min: v114, max: v200)
+          .union(new VersionRange(min: v003, max: v114, includeMax: true));
+      expect(result, equals(new VersionRange(min: v003, max: v200)));
+
+      result = new VersionRange(min: v114, max: v200, includeMin: true)
+          .union(new VersionRange(min: v003, max: v114));
+      expect(result, equals(new VersionRange(min: v003, max: v200)));
+    });
+
+    test("includes edges if either range does", () {
+      var result = new VersionRange(min: v003, max: v114, includeMin: true)
+          .union(new VersionRange(min: v003, max: v114, includeMax: true));
+      expect(result, equals(new VersionRange(
+          min: v003, max: v114, includeMin: true, includeMax: true)));
+    });
+  });
+
   test('isEmpty', () {
     expect(new VersionRange().isEmpty, isFalse);
     expect(new VersionRange(min: v123, max: v124).isEmpty, isFalse);
diff --git a/test/version_test.dart b/test/version_test.dart
index 8745006..e3d1b49 100644
--- a/test/version_test.dart
+++ b/test/version_test.dart
@@ -138,6 +138,22 @@
         new Version.parse('1.2.3+build')));
   });
 
+  test('allowsAll()', () {
+    expect(v123.allowsAll(v123), isTrue);
+    expect(v123.allowsAll(v003), isFalse);
+    expect(v123.allowsAll(new VersionRange(min: v114, max: v124)), isFalse);
+    expect(v123.allowsAll(VersionConstraint.any), isFalse);
+    expect(v123.allowsAll(VersionConstraint.empty), isTrue);
+  });
+
+  test('allowsAny()', () {
+    expect(v123.allowsAny(v123), isTrue);
+    expect(v123.allowsAny(v003), isFalse);
+    expect(v123.allowsAny(new VersionRange(min: v114, max: v124)), isTrue);
+    expect(v123.allowsAny(VersionConstraint.any), isTrue);
+    expect(v123.allowsAny(VersionConstraint.empty), isFalse);
+  });
+
   test('intersect()', () {
     // Intersecting the same version returns the version.
     expect(v123.intersect(v123), equals(v123));
@@ -154,6 +170,40 @@
         isTrue);
   });
 
+  group('union()', () {
+    test("with the same version returns the version", () {
+      expect(v123.union(v123), equals(v123));
+    });
+
+    test("with a different version returns a version that matches both", () {
+      var result = v123.union(v080);
+      expect(result, allows(v123));
+      expect(result, allows(v080));
+
+      // Nothing in between should match.
+      expect(result, doesNotAllow(v114));
+    });
+
+    test("with a range returns the range if it contains the version", () {
+      var range = new VersionRange(min: v114, max: v124);
+      expect(v123.union(range), equals(range));
+    });
+
+    test("with a range with the version on the edge, expands the range", () {
+      expect(v124.union(new VersionRange(min: v114, max: v124)),
+          equals(new VersionRange(min: v114, max: v124, includeMax: true)));
+      expect(v114.union(new VersionRange(min: v114, max: v124)),
+          equals(new VersionRange(min: v114, max: v124, includeMin: true)));
+    });
+
+    test("with a range allows both the range and the version if the range "
+        "doesn't contain the version", () {
+      var result = v123.union(new VersionRange(min: v003, max: v114));
+      expect(result, allows(v123));
+      expect(result, allows(v010));
+    });
+  });
+
   test('isEmpty', () {
     expect(v123.isEmpty, isFalse);
   });
diff --git a/test/version_union_test.dart b/test/version_union_test.dart
new file mode 100644
index 0000000..f3518a0
--- /dev/null
+++ b/test/version_union_test.dart
@@ -0,0 +1,352 @@
+// 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:test/test.dart';
+
+import 'package:pub_semver/pub_semver.dart';
+
+import 'utils.dart';
+
+main() {
+  group('factory', () {
+    test('ignores empty constraints', () {
+      expect(new VersionConstraint.unionOf([
+        VersionConstraint.empty,
+        VersionConstraint.empty,
+        v123,
+        VersionConstraint.empty
+      ]), equals(v123));
+
+      expect(new VersionConstraint.unionOf([
+        VersionConstraint.empty,
+        VersionConstraint.empty
+      ]), isEmpty);
+    });
+
+    test('returns an empty constraint for an empty list', () {
+      expect(new VersionConstraint.unionOf([]), isEmpty);
+    });
+
+    test('any constraints override everything', () {
+      expect(new VersionConstraint.unionOf([
+        v123,
+        VersionConstraint.any,
+        v200,
+        new VersionRange(min: v234, max: v250)
+      ]), equals(VersionConstraint.any));
+    });
+
+    test('flattens other unions', () {
+      expect(new VersionConstraint.unionOf([
+        v072,
+        new VersionConstraint.unionOf([v123, v124]),
+        v250
+      ]), equals(new VersionConstraint.unionOf([v072, v123, v124, v250])));
+    });
+
+    test('returns a single merged range as-is', () {
+      expect(new VersionConstraint.unionOf([
+        new VersionRange(min: v080, max: v140),
+        new VersionRange(min: v123, max: v200)
+      ]), equals(new VersionRange(min: v080, max: v200)));
+    });
+  });
+
+  group('equality', () {
+    test("doesn't depend on original order", () {
+      expect(new VersionConstraint.unionOf([
+        v250,
+        new VersionRange(min: v201, max: v234),
+        v124,
+        v072,
+        new VersionRange(min: v080, max: v114),
+        v123
+      ]), equals(new VersionConstraint.unionOf([
+        v072,
+        new VersionRange(min: v080, max: v114),
+        v123,
+        v124,
+        new VersionRange(min: v201, max: v234),
+        v250
+      ])));
+    });
+
+    test("merges overlapping ranges", () {
+      expect(new VersionConstraint.unionOf([
+        new VersionRange(min: v003, max: v072),
+        new VersionRange(min: v010, max: v080),
+        new VersionRange(min: v114, max: v124),
+        new VersionRange(min: v123, max: v130)
+      ]), equals(new VersionConstraint.unionOf([
+        new VersionRange(min: v003, max: v080),
+        new VersionRange(min: v114, max: v130)
+      ])));
+    });
+
+    test("merges adjacent ranges", () {
+      expect(new VersionConstraint.unionOf([
+        new VersionRange(min: v003, max: v072, includeMax: true),
+        new VersionRange(min: v072, max: v080),
+        new VersionRange(min: v114, max: v124),
+        new VersionRange(min: v124, max: v130, includeMin: true)
+      ]), equals(new VersionConstraint.unionOf([
+        new VersionRange(min: v003, max: v080),
+        new VersionRange(min: v114, max: v130)
+      ])));
+    });
+
+    test("doesn't merge not-quite-adjacent ranges", () {
+      expect(new VersionConstraint.unionOf([
+        new VersionRange(min: v003, max: v072),
+        new VersionRange(min: v072, max: v080)
+      ]), equals(new VersionConstraint.unionOf([
+        new VersionRange(min: v003, max: v072),
+        new VersionRange(min: v072, max: v080)
+      ])));
+    });
+ 
+    test("merges version numbers into ranges", () {
+      expect(new VersionConstraint.unionOf([
+        new VersionRange(min: v003, max: v072),
+        v010,
+        new VersionRange(min: v114, max: v124),
+        v123
+      ]), equals(new VersionConstraint.unionOf([
+        new VersionRange(min: v003, max: v072),
+        new VersionRange(min: v114, max: v124)
+      ])));
+    });
+ 
+    test("merges adjacent version numbers into ranges", () {
+      expect(new VersionConstraint.unionOf([
+        new VersionRange(min: v003, max: v072),
+        v072,
+        v114,
+        new VersionRange(min: v114, max: v124)
+      ]), equals(new VersionConstraint.unionOf([
+        new VersionRange(min: v003, max: v072, includeMax: true),
+        new VersionRange(min: v114, max: v124, includeMin: true)
+      ])));
+    });
+  });
+
+  test('isEmpty returns false', () {
+    expect(new VersionConstraint.unionOf([
+      new VersionRange(min: v003, max: v080),
+      new VersionRange(min: v123, max: v130),
+    ]), isNot(isEmpty));
+  });
+
+  test('isAny returns false', () {
+    expect(new VersionConstraint.unionOf([
+      new VersionRange(min: v003, max: v080),
+      new VersionRange(min: v123, max: v130),
+    ]).isAny, isFalse);
+  });
+
+  test('allows() allows anything the components allow', () {
+    var union = new VersionConstraint.unionOf([
+      new VersionRange(min: v003, max: v080),
+      new VersionRange(min: v123, max: v130),
+      v200
+    ]);
+
+    expect(union, allows(v010));
+    expect(union, doesNotAllow(v080));
+    expect(union, allows(v124));
+    expect(union, doesNotAllow(v140));
+    expect(union, allows(v200));
+  });
+
+  group('allowsAll()', () {
+    test('for a version, returns true if any component allows the version', () {
+      var union = new VersionConstraint.unionOf([
+        new VersionRange(min: v003, max: v080),
+        new VersionRange(min: v123, max: v130),
+        v200
+      ]);
+
+      expect(union.allowsAll(v010), isTrue);
+      expect(union.allowsAll(v080), isFalse);
+      expect(union.allowsAll(v124), isTrue);
+      expect(union.allowsAll(v140), isFalse);
+      expect(union.allowsAll(v200), isTrue);
+    });
+
+    test('for a version range, returns true if any component allows the whole '
+        'range', () {
+      var union = new VersionConstraint.unionOf([
+        new VersionRange(min: v003, max: v080),
+        new VersionRange(min: v123, max: v130)
+      ]);
+
+      expect(union.allowsAll(new VersionRange(min: v003, max: v080)), isTrue);
+      expect(union.allowsAll(new VersionRange(min: v010, max: v072)), isTrue);
+      expect(union.allowsAll(new VersionRange(min: v010, max: v124)), isFalse);
+    });
+
+    group('for a union,', () {
+      var union = new VersionConstraint.unionOf([
+        new VersionRange(min: v003, max: v080),
+        new VersionRange(min: v123, max: v130)
+      ]);
+
+      test('returns true if every constraint matches a different constraint',
+          () {
+        expect(union.allowsAll(new VersionConstraint.unionOf([
+          new VersionRange(min: v010, max: v072),
+          new VersionRange(min: v124, max: v130)
+        ])), isTrue);
+      });
+
+      test('returns true if every constraint matches the same constraint', () {
+        expect(union.allowsAll(new VersionConstraint.unionOf([
+          new VersionRange(min: v003, max: v010),
+          new VersionRange(min: v072, max: v080)
+        ])), isTrue);
+      });
+ 
+      test("returns false if there's an unmatched constraint", () {
+        expect(union.allowsAll(new VersionConstraint.unionOf([
+          new VersionRange(min: v010, max: v072),
+          new VersionRange(min: v124, max: v130),
+          new VersionRange(min: v140, max: v200)
+        ])), isFalse);
+      });
+
+      test("returns false if a constraint isn't fully matched", () {
+        expect(union.allowsAll(new VersionConstraint.unionOf([
+          new VersionRange(min: v010, max: v114),
+          new VersionRange(min: v124, max: v130)
+        ])), isFalse);
+      });
+    });
+  });
+
+  group('allowsAny()', () {
+    test('for a version, returns true if any component allows the version', () {
+      var union = new VersionConstraint.unionOf([
+        new VersionRange(min: v003, max: v080),
+        new VersionRange(min: v123, max: v130),
+        v200
+      ]);
+
+      expect(union.allowsAny(v010), isTrue);
+      expect(union.allowsAny(v080), isFalse);
+      expect(union.allowsAny(v124), isTrue);
+      expect(union.allowsAny(v140), isFalse);
+      expect(union.allowsAny(v200), isTrue);
+    });
+
+    test('for a version range, returns true if any component allows part of '
+        'the range', () {
+      var union = new VersionConstraint.unionOf([
+        new VersionRange(min: v003, max: v080),
+        v123
+      ]);
+
+      expect(union.allowsAny(new VersionRange(min: v010, max: v114)), isTrue);
+      expect(union.allowsAny(new VersionRange(min: v114, max: v124)), isTrue);
+      expect(union.allowsAny(new VersionRange(min: v124, max: v130)), isFalse);
+    });
+
+    group('for a union,', () {
+      var union = new VersionConstraint.unionOf([
+        new VersionRange(min: v010, max: v080),
+        new VersionRange(min: v123, max: v130)
+      ]);
+
+      test('returns true if any constraint matches', () {
+        expect(union.allowsAny(new VersionConstraint.unionOf([
+          v072,
+          new VersionRange(min: v200, max: v300)
+        ])), isTrue);
+
+        expect(union.allowsAny(new VersionConstraint.unionOf([
+          v003,
+          new VersionRange(min: v124, max: v300)
+        ])), isTrue);
+      });
+ 
+      test("returns false if no constraint matches", () {
+        expect(union.allowsAny(new VersionConstraint.unionOf([
+          v003,
+          new VersionRange(min: v130, max: v140),
+          new VersionRange(min: v140, max: v200)
+        ])), isFalse);
+      });
+    });
+  });
+
+  group("intersect()", () {
+    test("with an overlapping version, returns that version", () {
+      expect(new VersionConstraint.unionOf([
+        new VersionRange(min: v010, max: v080),
+        new VersionRange(min: v123, max: v140)
+      ]).intersect(v072), equals(v072));
+    });
+
+    test("with a non-overlapping version, returns an empty constraint", () {
+      expect(new VersionConstraint.unionOf([
+        new VersionRange(min: v010, max: v080),
+        new VersionRange(min: v123, max: v140)
+      ]).intersect(v300), isEmpty);
+    });
+
+    test("with an overlapping range, returns that range", () {
+      var range = new VersionRange(min: v072, max: v080);
+      expect(new VersionConstraint.unionOf([
+        new VersionRange(min: v010, max: v080),
+        new VersionRange(min: v123, max: v140)
+      ]).intersect(range), equals(range));
+    });
+
+    test("with a non-overlapping range, returns an empty constraint", () {
+      expect(new VersionConstraint.unionOf([
+        new VersionRange(min: v010, max: v080),
+        new VersionRange(min: v123, max: v140)
+      ]).intersect(new VersionRange(min: v080, max: v123)), isEmpty);
+    });
+
+    test("with a parially-overlapping range, returns the overlapping parts",
+        () {
+      expect(new VersionConstraint.unionOf([
+        new VersionRange(min: v010, max: v080),
+        new VersionRange(min: v123, max: v140)
+      ]).intersect(new VersionRange(min: v072, max: v130)),
+          equals(new VersionConstraint.unionOf([
+        new VersionRange(min: v072, max: v080),
+        new VersionRange(min: v123, max: v130)
+      ])));
+    });
+
+    group("for a union,", () {
+      var union = new VersionConstraint.unionOf([
+        new VersionRange(min: v003, max: v080),
+        new VersionRange(min: v123, max: v130)
+      ]);
+
+      test("returns the overlapping parts", () {
+        expect(union.intersect(new VersionConstraint.unionOf([
+          v010,
+          new VersionRange(min: v072, max: v124),
+          new VersionRange(min: v124, max: v130)
+        ])), equals(new VersionConstraint.unionOf([
+          v010,
+          new VersionRange(min: v072, max: v080),
+          new VersionRange(min: v123, max: v124),
+          new VersionRange(min: v124, max: v130)
+        ])));
+      });
+
+      test("drops parts that don't match", () {
+        expect(union.intersect(new VersionConstraint.unionOf([
+          v003,
+          new VersionRange(min: v072, max: v080),
+          new VersionRange(min: v080, max: v123)
+        ])), equals(new VersionRange(min: v072, max: v080)));
+      });
+    });
+  });
+}
\ No newline at end of file