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");
+ });
+}