Add support for watching individual files.

Closes #17

R=rnystrom@google.com

Review URL: https://codereview.chromium.org//1187553007.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f8c54bf..4ff6cfa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,9 @@
 * Add a `Watcher` interface that encompasses watching both files and
   directories.
 
+* Add `FileWatcher` and `PollingFileWatcher` classes for watching changes to
+  individual files.
+
 * Deprecate `DirectoryWatcher.directory`. Use `DirectoryWatcher.path` instead.
 
 # 0.9.5
diff --git a/lib/src/directory_watcher.dart b/lib/src/directory_watcher.dart
index 6979536..8283785 100644
--- a/lib/src/directory_watcher.dart
+++ b/lib/src/directory_watcher.dart
@@ -25,7 +25,7 @@
   /// If a native directory watcher is available for this platform, this will
   /// use it. Otherwise, it will fall back to a [PollingDirectoryWatcher].
   ///
-  /// If [_pollingDelay] is passed, it specifies the amount of time the watcher
+  /// If [pollingDelay] is passed, it specifies the amount of time the watcher
   /// will pause between successive polls of the directory contents. Making this
   /// shorter will give more immediate feedback at the expense of doing more IO
   /// and higher CPU usage. Defaults to one second. Ignored for non-polling
diff --git a/lib/src/file_watcher.dart b/lib/src/file_watcher.dart
new file mode 100644
index 0000000..9b31537
--- /dev/null
+++ b/lib/src/file_watcher.dart
@@ -0,0 +1,43 @@
+// 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 watcher.file_watcher;
+
+import 'dart:io';
+
+import 'watch_event.dart';
+import '../watcher.dart';
+import 'file_watcher/native.dart';
+import 'file_watcher/polling.dart';
+
+/// Watches a file and emits [WatchEvent]s when the file has changed.
+///
+/// Note that since each watcher only watches a single file, it will only emit
+/// [ChangeType.MODIFY] events, except when the file is deleted at which point
+/// it will emit a single [ChangeType.REMOVE] event and then close the stream.
+///
+/// If the file is deleted and quickly replaced (when a new file is moved in its
+/// place, for example) this will emit a [ChangeTime.MODIFY] event.
+abstract class FileWatcher implements Watcher {
+  /// Creates a new [FileWatcher] monitoring [file].
+  ///
+  /// If a native file watcher is available for this platform, this will use it.
+  /// Otherwise, it will fall back to a [PollingFileWatcher]. Notably, native
+  /// file watching is *not* supported on Windows.
+  ///
+  /// If [pollingDelay] is passed, it specifies the amount of time the watcher
+  /// will pause between successive polls of the directory contents. Making this
+  /// shorter will give more immediate feedback at the expense of doing more IO
+  /// and higher CPU usage. Defaults to one second. Ignored for non-polling
+  /// watchers.
+  factory FileWatcher(String file, {Duration pollingDelay}) {
+    // [File.watch] doesn't work on Windows, but
+    // [FileSystemEntity.isWatchSupported] is still true because directory
+    // watching does work.
+    if (FileSystemEntity.isWatchSupported && !Platform.isWindows) {
+      return new NativeFileWatcher(file);
+    }
+    return new PollingFileWatcher(file, pollingDelay: pollingDelay);
+  }
+}
diff --git a/lib/src/file_watcher/native.dart b/lib/src/file_watcher/native.dart
new file mode 100644
index 0000000..f5699bb
--- /dev/null
+++ b/lib/src/file_watcher/native.dart
@@ -0,0 +1,80 @@
+// 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 watcher.file_watcher.native;
+
+import 'dart:async';
+import 'dart:io';
+
+import '../file_watcher.dart';
+import '../resubscribable.dart';
+import '../utils.dart';
+import '../watch_event.dart';
+
+/// Uses the native file system notifications to watch for filesystem events.
+///
+/// Single-file notifications are much simpler than those for multiple files, so
+/// this doesn't need to be split out into multiple OS-specific classes.
+class NativeFileWatcher extends ResubscribableWatcher implements FileWatcher {
+  NativeFileWatcher(String path)
+      : super(path, () => new _NativeFileWatcher(path));
+}
+
+class _NativeFileWatcher implements FileWatcher, ManuallyClosedWatcher {
+  final String path;
+
+  Stream<WatchEvent> get events => _eventsController.stream;
+  final _eventsController = new StreamController<WatchEvent>.broadcast();
+
+  bool get isReady => _readyCompleter.isCompleted;
+
+  Future get ready => _readyCompleter.future;
+  final _readyCompleter = new Completer();
+
+  StreamSubscription _subscription;
+
+  _NativeFileWatcher(this.path) {
+    _listen();
+
+    // We don't need to do any initial set-up, so we're ready immediately after
+    // being listened to.
+    _readyCompleter.complete();
+  }
+
+  void _listen() {
+    // Batch the events together so that we can dedup them.
+    _subscription = new File(path).watch()
+        .transform(new BatchedStreamTransformer<FileSystemEvent>())
+        .listen(_onBatch, onError: _eventsController.addError, onDone: _onDone);
+  }
+
+  void _onBatch(List<FileSystemEvent> batch) {
+    if (batch.any((event) => event.type == FileSystemEvent.DELETE)) {
+      // If the file is deleted, the underlying stream will close. We handle
+      // emitting our own REMOVE event in [_onDone].
+      return;
+    }
+
+    _eventsController.add(new WatchEvent(ChangeType.MODIFY, path));
+  }
+
+  _onDone() async {
+    // If the file exists now, it was probably removed and quickly replaced;
+    // this can happen for example when another file is moved on top of it.
+    // Re-subscribe and report a modify event.
+    if (await new File(path).exists()) {
+      _eventsController.add(new WatchEvent(ChangeType.MODIFY, path));
+      _listen();
+    } else {
+      _eventsController.add(new WatchEvent(ChangeType.REMOVE, path));
+      close();
+    }
+  }
+
+  void close() {
+    if (_subscription != null) _subscription.cancel();
+    _subscription = null;
+    _eventsController.close();
+  }
+}
diff --git a/lib/src/file_watcher/polling.dart b/lib/src/file_watcher/polling.dart
new file mode 100644
index 0000000..a44f80c
--- /dev/null
+++ b/lib/src/file_watcher/polling.dart
@@ -0,0 +1,87 @@
+// 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 watcher.file_watcher.polling;
+
+import 'dart:async';
+import 'dart:io';
+
+import '../file_watcher.dart';
+import '../resubscribable.dart';
+import '../stat.dart';
+import '../watch_event.dart';
+
+/// Periodically polls a file for changes.
+class PollingFileWatcher extends ResubscribableWatcher implements FileWatcher {
+  PollingFileWatcher(String path, {Duration pollingDelay})
+      : super(path, () {
+        return new _PollingFileWatcher(path,
+            pollingDelay != null ? pollingDelay : new Duration(seconds: 1));
+      });
+}
+
+class _PollingFileWatcher implements FileWatcher, ManuallyClosedWatcher {
+  final String path;
+
+  Stream<WatchEvent> get events => _eventsController.stream;
+  final _eventsController = new StreamController<WatchEvent>.broadcast();
+
+  bool get isReady => _readyCompleter.isCompleted;
+
+  Future get ready => _readyCompleter.future;
+  final _readyCompleter = new Completer();
+
+  /// The timer that controls polling.
+  Timer _timer;
+
+  /// The previous modification time of the file.
+  ///
+  /// Used to tell when the file was modified. This is `null` before the file's
+  /// mtime has first been checked.
+  DateTime _lastModified;
+
+  _PollingFileWatcher(this.path, Duration pollingDelay) {
+    _timer = new Timer.periodic(pollingDelay, (_) => _poll());
+    _poll();
+  }
+
+  /// Checks the mtime of the file and whether it's been removed.
+  Future _poll() async {
+    // We don't mark the file as removed if this is the first poll (indicated by
+    // [_lastModified] being null). Instead, below we forward the dart:io error
+    // that comes from trying to read the mtime below.
+    if (_lastModified != null && !await new File(path).exists()) {
+      _eventsController.add(new WatchEvent(ChangeType.REMOVE, path));
+      close();
+      return;
+    }
+
+    var modified;
+    try {
+      modified = await getModificationTime(path);
+    } on FileSystemException catch (error, stackTrace) {
+      _eventsController.addError(error, stackTrace);
+      close();
+      return;
+    }
+
+    if (_eventsController.isClosed) return;
+    if (_lastModified == modified) return;
+
+    if (_lastModified == null) {
+      // If this is the first poll, don't emit an event, just set the last mtime
+      // and complete the completer.
+      _lastModified = modified;
+      _readyCompleter.complete();
+    } else {
+      _lastModified = modified;
+      _eventsController.add(new WatchEvent(ChangeType.MODIFY, path));
+    }
+  }
+
+  void close() {
+    _timer.cancel();
+    _eventsController.close();
+  }
+}
diff --git a/lib/watcher.dart b/lib/watcher.dart
index a078058..79dcc0d 100644
--- a/lib/watcher.dart
+++ b/lib/watcher.dart
@@ -5,12 +5,17 @@
 library watcher;
 
 import 'dart:async';
+import 'dart:io';
 
 import 'src/watch_event.dart';
+import 'src/directory_watcher.dart';
+import 'src/file_watcher.dart';
 
 export 'src/watch_event.dart';
 export 'src/directory_watcher.dart';
 export 'src/directory_watcher/polling.dart';
+export 'src/file_watcher.dart';
+export 'src/file_watcher/polling.dart';
 
 abstract class Watcher {
   /// The path to the file or directory whose contents are being monitored.
@@ -40,4 +45,25 @@
   /// If the watcher is already monitoring, this returns an already complete
   /// future.
   Future get ready;
+
+  /// Creates a new [DirectoryWatcher] or [FileWatcher] monitoring [path],
+  /// depending on whether it's a file or directory.
+  ///
+  /// If a native watcher is available for this platform, this will use it.
+  /// Otherwise, it will fall back to a polling watcher. Notably, watching
+  /// individual files is not natively supported on Windows, although watching
+  /// directories is.
+  ///
+  /// If [pollingDelay] is passed, it specifies the amount of time the watcher
+  /// will pause between successive polls of the contents of [path]. Making this
+  /// shorter will give more immediate feedback at the expense of doing more IO
+  /// and higher CPU usage. Defaults to one second. Ignored for non-polling
+  /// watchers.
+  factory Watcher(String path, {Duration pollingDelay}) {
+    if (new File(path).existsSync()) {
+      return new FileWatcher(path, pollingDelay: pollingDelay);
+    } else {
+      return new DirectoryWatcher(path, pollingDelay: pollingDelay);
+    }
+  }
 }
diff --git a/pubspec.yaml b/pubspec.yaml
index 8853995..f62248d 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,12 +1,12 @@
 name: watcher
-version: 0.9.6-dev
+version: 0.9.6
 author: Dart Team <misc@dartlang.org>
 homepage: http://github.com/dart-lang/watcher
 description: >
   A file system watcher. It monitors changes to contents of directories and
   sends notifications when files have been added, removed, or modified.
 environment:
-  sdk: '>=1.8.0 <2.0.0'
+  sdk: '>=1.9.0 <2.0.0'
 dependencies:
   path: '>=0.9.0 <2.0.0'
 dev_dependencies:
diff --git a/test/file_watcher/native_test.dart b/test/file_watcher/native_test.dart
new file mode 100644
index 0000000..30d0eca
--- /dev/null
+++ b/test/file_watcher/native_test.dart
@@ -0,0 +1,23 @@
+// 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.
+
+@TestOn('linux || mac-os')
+
+import 'package:scheduled_test/scheduled_test.dart';
+import 'package:watcher/src/file_watcher/native.dart';
+import 'package:watcher/watcher.dart';
+
+import 'shared.dart';
+import '../utils.dart';
+
+void main() {
+  watcherFactory = (file) => new NativeFileWatcher(file);
+
+  setUp(() {
+    createSandbox();
+    writeFile("file.txt");
+  });
+
+  sharedTests();
+}
diff --git a/test/file_watcher/polling_test.dart b/test/file_watcher/polling_test.dart
new file mode 100644
index 0000000..e502544
--- /dev/null
+++ b/test/file_watcher/polling_test.dart
@@ -0,0 +1,23 @@
+// 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.
+
+@TestOn('linux || mac-os')
+
+import 'package:scheduled_test/scheduled_test.dart';
+import 'package:watcher/watcher.dart';
+
+import 'shared.dart';
+import '../utils.dart';
+
+void main() {
+  watcherFactory = (file) => new PollingFileWatcher(file,
+      pollingDelay: new Duration(milliseconds: 100));
+
+  setUp(() {
+    createSandbox();
+    writeFile("file.txt");
+  });
+
+  sharedTests();
+}
diff --git a/test/file_watcher/shared.dart b/test/file_watcher/shared.dart
new file mode 100644
index 0000000..2931d80
--- /dev/null
+++ b/test/file_watcher/shared.dart
@@ -0,0 +1,58 @@
+// 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:scheduled_test/scheduled_test.dart';
+import 'package:watcher/src/utils.dart';
+
+import '../utils.dart';
+
+void sharedTests() {
+  test("doesn't notify if the file isn't modified", () {
+    startWatcher(path: "file.txt");
+    // Give the watcher time to fire events if it's going to.
+    schedule(() => pumpEventQueue());
+    deleteFile("file.txt");
+    expectRemoveEvent("file.txt");
+  });
+
+  test("notifies when a file is modified", () {
+    startWatcher(path: "file.txt");
+    writeFile("file.txt", contents: "modified");
+    expectModifyEvent("file.txt");
+  });
+
+  test("notifies when a file is removed", () {
+    startWatcher(path: "file.txt");
+    deleteFile("file.txt");
+    expectRemoveEvent("file.txt");
+  });
+
+  test("notifies when a file is modified multiple times", () {
+    startWatcher(path: "file.txt");
+    writeFile("file.txt", contents: "modified");
+    expectModifyEvent("file.txt");
+    writeFile("file.txt", contents: "modified again");
+    expectModifyEvent("file.txt");
+  });
+
+  test("notifies even if the file contents are unchanged", () {
+    startWatcher(path: "file.txt");
+    writeFile("file.txt");
+    expectModifyEvent("file.txt");
+  });
+
+  test("emits a remove event when the watched file is moved away", () {
+    startWatcher(path: "file.txt");
+    renameFile("file.txt", "new.txt");
+    expectRemoveEvent("file.txt");
+  });
+
+  test("emits a modify event when another file is moved on top of the watched "
+      "file", () {
+    writeFile("old.txt");
+    startWatcher(path: "file.txt");
+    renameFile("old.txt", "file.txt");
+    expectModifyEvent("file.txt");
+  });
+}