Add an ObservableSet to compliment List and Map (#20)

* Add an ObservableSet implementation

* Add tests, cleanup.

* Cleanup licensing.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3549221..dc5e443 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.20.0
+
+* Add `ObservableSet`, `SetChangeRecord`, and `SetDiffer`
+
 ## 0.19.0
 
 * Refactor and deprecate `ObservableMap`-specific API
diff --git a/lib/observable.dart b/lib/observable.dart
index 641fa7b..be4fe34 100644
--- a/lib/observable.dart
+++ b/lib/observable.dart
@@ -5,9 +5,15 @@
 library observable;
 
 export 'src/change_notifier.dart' show ChangeNotifier, PropertyChangeNotifier;
-export 'src/collections.dart' show ObservableList, ObservableMap;
-export 'src/differs.dart' show Differ, EqualityDiffer, ListDiffer, MapDiffer;
+export 'src/collections.dart' show ObservableList, ObservableMap, ObservableSet;
+export 'src/differs.dart'
+    show Differ, EqualityDiffer, ListDiffer, MapDiffer, SetDiffer;
 export 'src/records.dart'
-    show ChangeRecord, ListChangeRecord, MapChangeRecord, PropertyChangeRecord;
+    show
+        ChangeRecord,
+        ListChangeRecord,
+        MapChangeRecord,
+        PropertyChangeRecord,
+        SetChangeRecord;
 export 'src/observable.dart';
 export 'src/to_observable.dart';
diff --git a/lib/src/change_notifier.dart b/lib/src/change_notifier.dart
index 775df10..f5ca601 100644
--- a/lib/src/change_notifier.dart
+++ b/lib/src/change_notifier.dart
@@ -1,3 +1,7 @@
+// 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 'dart:async';
 
 import 'package:meta/meta.dart';
diff --git a/lib/src/collections.dart b/lib/src/collections.dart
index 363e925..7441831 100644
--- a/lib/src/collections.dart
+++ b/lib/src/collections.dart
@@ -1,2 +1,7 @@
+// 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.
+
 export 'collections/observable_list.dart';
 export 'collections/observable_map.dart';
+export 'collections/observable_set.dart';
diff --git a/lib/src/collections/observable_list.dart b/lib/src/collections/observable_list.dart
index 1052df2..c8266c7 100644
--- a/lib/src/collections/observable_list.dart
+++ b/lib/src/collections/observable_list.dart
@@ -1,3 +1,7 @@
+// 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 'dart:async';
 
 import 'package:collection/collection.dart';
diff --git a/lib/src/collections/observable_map.dart b/lib/src/collections/observable_map.dart
index 50fac80..3c2ed48 100644
--- a/lib/src/collections/observable_map.dart
+++ b/lib/src/collections/observable_map.dart
@@ -1,3 +1,7 @@
+// 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 'dart:async';
 import 'dart:collection';
 
@@ -13,7 +17,7 @@
 ///     ```
 ///     set grades(Map<String, int> grades) {
 ///       buildBook(grades);
-///       if (names is ObservableMap<String>, int) {
+///       if (names is ObservableMap<String, int>) {
 ///         grades.changes.listen(updateBook);
 ///       }
 ///     }
@@ -46,7 +50,7 @@
 
   /// Creates a new observable map that contains all entries in [other].
   ///
-  /// It will attempt to use the same backing map  type if the other map is
+  /// It will attempt to use the same backing map type if the other map is
   /// either a [LinkedHashMap], [SplayTreeMap], or [HashMap]. Otherwise it will
   /// fall back to using a [HashMap].
   factory ObservableMap.from(Map<K, V> other) {
diff --git a/lib/src/collections/observable_set.dart b/lib/src/collections/observable_set.dart
new file mode 100644
index 0000000..762a782
--- /dev/null
+++ b/lib/src/collections/observable_set.dart
@@ -0,0 +1,118 @@
+// 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 'dart:collection';
+
+import 'package:collection/collection.dart';
+import 'package:observable/observable.dart';
+
+/// A [Set] that broadcasts [changes] to subscribers for efficient mutations.
+///
+/// When client code expects a read heavy/write light workload, it is often more
+/// efficient to notify _when_ something has changed, instead of constantly
+/// diffing lists to find a single change (like an inserted element). You may
+/// accept an observable set to be notified of mutations:
+/// ```
+/// set emails(Set<String> emails) {
+///   emailUsers(emails);
+///   if (names is ObservableSet<String>) {
+///     emails.changes.listen(updateEmailList);
+///   }
+/// }
+/// ```
+///
+/// *See [SetDiffer] to manually diff two lists instead*
+abstract class ObservableSet<E>
+    implements Observable<SetChangeRecord<E>>, Set<E> {
+  /// Create a new empty observable set.
+  factory ObservableSet() => new _DelegatingObservableSet<E>(new HashSet<E>());
+
+  /// Like [ObservableSet.from], but creates a new empty set.
+  factory ObservableSet.createFromType(Iterable<E> other) {
+    ObservableSet<E> result;
+    if (other is LinkedHashSet) {
+      result = new _DelegatingObservableSet<E>(new LinkedHashSet<E>());
+    } else if (result is SplayTreeSet) {
+      result = new _DelegatingObservableSet<E>(new SplayTreeSet<E>());
+    } else {
+      result = new _DelegatingObservableSet<E>(new HashSet<E>());
+    }
+    return result;
+  }
+
+  /// Create a new observable set using [set] as a backing store.
+  factory ObservableSet.delegate(Set<E> set) = _DelegatingObservableSet<E>;
+
+  /// Creates a new observable set that contains all elements in [other].
+  ///
+  /// It will attempt to use the same backing set type if the other set is
+  /// either a [LinkedHashSet], [SplayTreeSet], or [HashSet]. Otherwise it will
+  /// fall back to using a [HashSet].
+  factory ObservableSet.from(Iterable<E> other) {
+    return new ObservableSet<E>.createFromType(other)..addAll(other);
+  }
+
+  /// Creates a new observable map using a [LinkedHashSet].
+  factory ObservableSet.linked() {
+    return new _DelegatingObservableSet<E>(new LinkedHashSet<E>());
+  }
+
+  /// Creates a new observable map using a [SplayTreeSet].
+  factory ObservableSet.sorted() {
+    return new _DelegatingObservableSet<E>(new SplayTreeSet<E>());
+  }
+}
+
+class _DelegatingObservableSet<E> extends DelegatingSet<E>
+    with ChangeNotifier<SetChangeRecord<E>>
+    implements ObservableSet<E> {
+  _DelegatingObservableSet(Set<E> set) : super(set);
+
+  @override
+  bool add(E value) {
+    if (super.add(value)) {
+      if (hasObservers) {
+        notifyChange(new SetChangeRecord<E>.add(value));
+      }
+      return true;
+    }
+    return false;
+  }
+
+  @override
+  void addAll(Iterable<E> values) {
+    values.forEach(add);
+  }
+
+  @override
+  bool remove(Object value) {
+    if (super.remove(value)) {
+      if (hasObservers) {
+        notifyChange(new SetChangeRecord<E>.remove(value as E));
+      }
+      return true;
+    }
+    return false;
+  }
+
+  @override
+  void removeAll(Iterable<Object> values) {
+    values.toList().forEach(remove);
+  }
+
+  @override
+  void removeWhere(bool test(E value)) {
+    removeAll(super.where(test));
+  }
+
+  @override
+  void retainAll(Iterable<Object> elements) {
+    retainWhere(elements.toSet().contains);
+  }
+
+  @override
+  void retainWhere(bool test(E element)) {
+    removeWhere((e) => !test(e));
+  }
+}
diff --git a/lib/src/differs.dart b/lib/src/differs.dart
index 40f005e..63d5638 100644
--- a/lib/src/differs.dart
+++ b/lib/src/differs.dart
@@ -9,11 +9,11 @@
 import 'package:collection/collection.dart';
 
 import 'records.dart';
-
 import 'internal.dart';
 
 part 'differs/list_differ.dart';
 part 'differs/map_differ.dart';
+part 'differs/set_differ.dart';
 
 /// Generic comparisons between two comparable objects.
 abstract class Differ<E> {
diff --git a/lib/src/differs/set_differ.dart b/lib/src/differs/set_differ.dart
new file mode 100644
index 0000000..31d93cb
--- /dev/null
+++ b/lib/src/differs/set_differ.dart
@@ -0,0 +1,31 @@
+// 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.
+
+part of observable.src.differs;
+
+/// Determines differences between two maps, returning [SetChangeRecord]s.
+///
+/// While [SetChangeRecord] has more information and can be replayed they carry
+/// a more significant cost to calculate and create and should only be used when
+/// the details in the record will actually be used.
+///
+/// See also [EqualityDiffer] for a simpler comparison.
+class SetDiffer<E> implements Differ<Set<E>> {
+  const SetDiffer();
+
+  @override
+  List<SetChangeRecord<E>> diff(Set<E> oldValue, Set<E> newValue) {
+    if (identical(oldValue, newValue)) {
+      return ChangeRecord.NONE;
+    }
+    final changes = <SetChangeRecord<E>>[];
+    for (final added in newValue.difference(oldValue)) {
+      changes.add(new SetChangeRecord<E>.add(added));
+    }
+    for (final removed in oldValue.difference(newValue)) {
+      changes.add(new SetChangeRecord<E>.remove(removed));
+    }
+    return changes;
+  }
+}
diff --git a/lib/src/records.dart b/lib/src/records.dart
index 87d7d4f..40a8d4e 100644
--- a/lib/src/records.dart
+++ b/lib/src/records.dart
@@ -12,6 +12,7 @@
 part 'records/list_change_record.dart';
 part 'records/map_change_record.dart';
 part 'records/property_change_record.dart';
+part 'records/set_change_record.dart';
 
 /// Result of a change to an observed object.
 class ChangeRecord {
diff --git a/lib/src/records/map_change_record.dart b/lib/src/records/map_change_record.dart
index cd37fc9..dc252d2 100644
--- a/lib/src/records/map_change_record.dart
+++ b/lib/src/records/map_change_record.dart
@@ -77,6 +77,6 @@
   @override
   String toString() {
     final kind = isInsert ? 'insert' : isRemove ? 'remove' : 'set';
-    return '#<MapChangeRecord $kind $key from $oldValue to $newValue';
+    return '#<MapChangeRecord $kind $key from $oldValue to $newValue>';
   }
 }
diff --git a/lib/src/records/set_change_record.dart b/lib/src/records/set_change_record.dart
new file mode 100644
index 0000000..2846e31
--- /dev/null
+++ b/lib/src/records/set_change_record.dart
@@ -0,0 +1,41 @@
+// 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.
+
+part of observable.src.records;
+
+/// A [ChangeRecord] that denotes adding or removing values from a [Set].
+class SetChangeRecord<E> implements ChangeRecord {
+  /// Whether this is a removal operation.
+  final bool isRemove;
+
+  /// Element added or removed in the operation.
+  final E element;
+
+  const SetChangeRecord.add(this.element) : isRemove = false;
+  const SetChangeRecord.remove(this.element) : isRemove = true;
+
+  /// Whether this is an add operation.
+  bool get isAdd => !isRemove;
+
+  /// Apply the change operation to [set].
+  void apply(Set<E> set) {
+    if (isRemove) {
+      set.remove(element);
+    } else {
+      set.add(element);
+    }
+  }
+
+  @override
+  bool operator ==(Object o) =>
+      o is SetChangeRecord<E> && element == o.element && isRemove == o.isRemove;
+
+  @override
+  int get hashCode => hash2(element, isRemove);
+
+  @override
+  String toString() {
+    return '#<SetChangeRecord ${isRemove ? 'remove' : 'add'} $element>';
+  }
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 8065fa9..84d9382 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: observable
-version: 0.19.0
+version: 0.20.0
 author: Dart Team <misc@dartlang.org>
 description: Support for marking objects as observable
 homepage: https://github.com/dart-lang/observable
diff --git a/test/collections/observable_list_test.dart b/test/collections/observable_list_test.dart
index b8e50d0..2544677 100644
--- a/test/collections/observable_list_test.dart
+++ b/test/collections/observable_list_test.dart
@@ -1,3 +1,7 @@
+// 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 'dart:async';
 
 import 'package:observable/observable.dart';
diff --git a/test/collections/observable_set_test.dart b/test/collections/observable_set_test.dart
new file mode 100644
index 0000000..852fd17
--- /dev/null
+++ b/test/collections/observable_set_test.dart
@@ -0,0 +1,163 @@
+// 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 'dart:async';
+
+import 'package:observable/observable.dart';
+import 'package:test/test.dart';
+
+main() {
+  group('$ObservableSet', () {
+    group('set api', _runSetTests);
+    _runObservableSetTests();
+  });
+}
+
+_runSetTests() {
+  // TODO(matanl): Can we run the Set-API tests from the SDK?
+  // Any methods actually implemented by ObservableSet are below, otherwise I am
+  // relying on the test suite for DelegatingSet.
+  test('add', () {
+    final set = new ObservableSet<String>();
+    expect(set.add('item'), isTrue);
+    expect(set, ['item']);
+    expect(set.add('item'), isFalse);
+    expect(set, ['item']);
+  });
+
+  test('addAll', () {
+    final set = new ObservableSet<String>.linked();
+    set.addAll(['1', '2', '3']);
+    expect(set, ['1', '2', '3']);
+    set.addAll(['3', '4']);
+    expect(set, ['1', '2', '3', '4']);
+  });
+
+  test('remove', () {
+    final set = new ObservableSet<String>();
+    expect(set.remove('item'), isFalse);
+    expect(set, isEmpty);
+    set.add('item');
+    expect(set, isNotEmpty);
+    expect(set.remove('item'), isTrue);
+    expect(set, isEmpty);
+  });
+
+  test('removeAll', () {
+    final set = new ObservableSet<String>.from(['1', '2', '3']);
+    set.removeAll(['1', '3']);
+    expect(set, ['2']);
+  });
+
+  test('removeWhere', () {
+    final set = new ObservableSet<String>.from(['1', '2', '3']);
+    set.removeWhere((s) => s != '2');
+    expect(set, ['2']);
+  });
+
+  test('retainAll', () {
+    final set = new ObservableSet<String>.from(['1', '2', '3']);
+    set.retainAll(['2']);
+    expect(set, ['2']);
+  });
+
+  test('retainWhere', () {
+    final set = new ObservableSet<String>.from(['1', '2', '3']);
+    set.retainWhere((s) => s == '2');
+    expect(set, ['2']);
+  });
+}
+
+_runObservableSetTests() {
+  group('observable changes', () {
+    Completer<List<SetChangeRecord>> completer;
+    Set<String> previousState;
+
+    ObservableSet<String> set;
+    StreamSubscription sub;
+
+    Future next() {
+      completer = new Completer<List<SetChangeRecord>>.sync();
+      return completer.future;
+    }
+
+    Future<Null> expectChanges(List<SetChangeRecord> changes) {
+      // Applying these change records in order should make the new list.
+      for (final change in changes) {
+        change.apply(previousState);
+      }
+
+      expect(set, previousState);
+
+      // If these fail, it might be safe to update if optimized/changed.
+      return next().then((actualChanges) {
+        for (final change in changes) {
+          expect(actualChanges, contains(change));
+        }
+      });
+    }
+
+    setUp(() {
+      set = new ObservableSet.from(['a', 'b', 'c']);
+      previousState = set.toSet();
+      sub = set.changes.listen((c) {
+        if (completer?.isCompleted == false) {
+          completer.complete(c);
+        }
+        previousState = set.toSet();
+      });
+    });
+
+    tearDown(() => sub.cancel());
+
+    test('add', () async {
+      set.add('value');
+      await expectChanges([
+        new SetChangeRecord.add('value'),
+      ]);
+    });
+
+    test('addAll', () async {
+      set.addAll(['1', '2']);
+      await expectChanges([
+        new SetChangeRecord.add('1'),
+        new SetChangeRecord.add('2'),
+      ]);
+    });
+
+    test('remove', () async {
+      set.remove('a');
+      await expectChanges([
+        new SetChangeRecord.remove('a'),
+      ]);
+    });
+
+    Future expectOnlyItem() {
+      return expectChanges([
+        new SetChangeRecord.remove('a'),
+        new SetChangeRecord.remove('c'),
+      ]);
+    }
+
+    test('removeAll', () async {
+      set.removeAll(['a', 'c']);
+      await expectOnlyItem();
+    });
+
+    test('removeWhere', () async {
+      set.removeWhere((s) => s != 'b');
+      await expectOnlyItem();
+    });
+
+    test('retainAll', () async {
+      set.retainAll(['b']);
+      await expectOnlyItem();
+    });
+
+    test('retainWhere', () async {
+      set.retainWhere((s) => s == 'b');
+      await expectOnlyItem();
+    });
+  });
+}
diff --git a/test/map_differ_test.dart b/test/differs/map_differ_test.dart
similarity index 91%
rename from test/map_differ_test.dart
rename to test/differs/map_differ_test.dart
index b6a11c8..b6ebb53 100644
--- a/test/map_differ_test.dart
+++ b/test/differs/map_differ_test.dart
@@ -1,3 +1,7 @@
+// 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:observable/observable.dart';
 import 'package:test/test.dart';
 
@@ -71,7 +75,7 @@
   });
 
   group('$MapChangeRecord', () {
-    test('should reply an insertion', () {
+    test('should replay an insertion', () {
       final map1 = {
         'key-a': 'value-a',
         'key-b': 'value-b',
diff --git a/test/differs/set_differ_test.dart b/test/differs/set_differ_test.dart
new file mode 100644
index 0000000..b6fbf04
--- /dev/null
+++ b/test/differs/set_differ_test.dart
@@ -0,0 +1,69 @@
+// 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:observable/observable.dart';
+import 'package:test/test.dart';
+
+main() {
+  group('$SetDiffer', () {
+    final diff = const SetDiffer<String>().diff;
+
+    test('should emit no changes for identical maps', () {
+      final set = new Set<String>.from(
+        new Iterable.generate(10, (i) => '$i'),
+      );
+      expect(diff(set, set), ChangeRecord.NONE);
+    });
+
+    test('should emit no changes for maps with identical content', () {
+      final set1 = new Set<String>.from(
+        new Iterable.generate(10, (i) => '$i'),
+      );
+      final set2 = new Set<String>.from(
+        new Iterable.generate(10, (i) => '$i'),
+      );
+      expect(diff(set1, set2), ChangeRecord.NONE);
+    });
+
+    test('should detect insertions', () {
+      expect(
+        diff(
+          new Set<String>.from(['a']),
+          new Set<String>.from(['a', 'b']),
+        ),
+        [
+          new SetChangeRecord.add('b'),
+        ],
+      );
+    });
+
+    test('should detect removals', () {
+      expect(
+        diff(
+          new Set<String>.from(['a', 'b']),
+          new Set<String>.from(['a']),
+        ),
+        [
+          new SetChangeRecord.remove('b'),
+        ],
+      );
+    });
+  });
+
+  group('$SetChangeRecord', () {
+    test('should reply an insertion', () {
+      final set1 = new Set<String>.from(['a', 'b']);
+      final set2 = new Set<String>.from(['a', 'b', 'c']);
+      new SetChangeRecord.add('c').apply(set1);
+      expect(set1, set2);
+    });
+
+    test('should replay a removal', () {
+      final set1 = new Set<String>.from(['a', 'b', 'c']);
+      final set2 = new Set<String>.from(['a', 'b']);
+      new SetChangeRecord.remove('c').apply(set1);
+      expect(set1, set2);
+    });
+  });
+}
diff --git a/test/observable_map_test.dart b/test/observable_map_test.dart
index 5e369d5..795328b 100644
--- a/test/observable_map_test.dart
+++ b/test/observable_map_test.dart
@@ -305,5 +305,3 @@
 _insertKey(key, newValue) => new MapChangeRecord.insert(key, newValue);
 
 _removeKey(key, oldValue) => new MapChangeRecord.remove(key, oldValue);
-
-_propChange(map, prop) => new PropertyChangeRecord(map, prop, null, null);