Add support for watching individual files. Closes dart-lang/watcher#17 R=rnystrom@google.com Review URL: https://codereview.chromium.org//1187553007.
diff --git a/pkgs/watcher/CHANGELOG.md b/pkgs/watcher/CHANGELOG.md index f8c54bf..4ff6cfa 100644 --- a/pkgs/watcher/CHANGELOG.md +++ b/pkgs/watcher/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/pkgs/watcher/lib/src/directory_watcher.dart b/pkgs/watcher/lib/src/directory_watcher.dart index 6979536..8283785 100644 --- a/pkgs/watcher/lib/src/directory_watcher.dart +++ b/pkgs/watcher/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/pkgs/watcher/lib/src/file_watcher.dart b/pkgs/watcher/lib/src/file_watcher.dart new file mode 100644 index 0000000..9b31537 --- /dev/null +++ b/pkgs/watcher/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/pkgs/watcher/lib/src/file_watcher/native.dart b/pkgs/watcher/lib/src/file_watcher/native.dart new file mode 100644 index 0000000..f5699bb --- /dev/null +++ b/pkgs/watcher/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/pkgs/watcher/lib/src/file_watcher/polling.dart b/pkgs/watcher/lib/src/file_watcher/polling.dart new file mode 100644 index 0000000..a44f80c --- /dev/null +++ b/pkgs/watcher/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/pkgs/watcher/lib/watcher.dart b/pkgs/watcher/lib/watcher.dart index a078058..79dcc0d 100644 --- a/pkgs/watcher/lib/watcher.dart +++ b/pkgs/watcher/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/pkgs/watcher/pubspec.yaml b/pkgs/watcher/pubspec.yaml index 8853995..f62248d 100644 --- a/pkgs/watcher/pubspec.yaml +++ b/pkgs/watcher/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/pkgs/watcher/test/file_watcher/native_test.dart b/pkgs/watcher/test/file_watcher/native_test.dart new file mode 100644 index 0000000..30d0eca --- /dev/null +++ b/pkgs/watcher/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/pkgs/watcher/test/file_watcher/polling_test.dart b/pkgs/watcher/test/file_watcher/polling_test.dart new file mode 100644 index 0000000..e502544 --- /dev/null +++ b/pkgs/watcher/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/pkgs/watcher/test/file_watcher/shared.dart b/pkgs/watcher/test/file_watcher/shared.dart new file mode 100644 index 0000000..2931d80 --- /dev/null +++ b/pkgs/watcher/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"); + }); +}