Merge package:watcher into the tools monorepo
diff --git a/pkgs/watcher/.github/dependabot.yaml b/pkgs/watcher/.github/dependabot.yaml
new file mode 100644
index 0000000..bf6b38a
--- /dev/null
+++ b/pkgs/watcher/.github/dependabot.yaml
@@ -0,0 +1,14 @@
+# Dependabot configuration file.
+version: 2
+
+updates:
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: monthly
+ labels:
+ - autosubmit
+ groups:
+ github-actions:
+ patterns:
+ - "*"
diff --git a/pkgs/watcher/.github/workflows/no-response.yml b/pkgs/watcher/.github/workflows/no-response.yml
new file mode 100644
index 0000000..ab1ac49
--- /dev/null
+++ b/pkgs/watcher/.github/workflows/no-response.yml
@@ -0,0 +1,37 @@
+# A workflow to close issues where the author hasn't responded to a request for
+# more information; see https://github.com/actions/stale.
+
+name: No Response
+
+# Run as a daily cron.
+on:
+ schedule:
+ # Every day at 8am
+ - cron: '0 8 * * *'
+
+# All permissions not specified are set to 'none'.
+permissions:
+ issues: write
+ pull-requests: write
+
+jobs:
+ no-response:
+ runs-on: ubuntu-latest
+ if: ${{ github.repository_owner == 'dart-lang' }}
+ steps:
+ - uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e
+ with:
+ # Don't automatically mark inactive issues+PRs as stale.
+ days-before-stale: -1
+ # Close needs-info issues and PRs after 14 days of inactivity.
+ days-before-close: 14
+ stale-issue-label: "needs-info"
+ close-issue-message: >
+ Without additional information we're not able to resolve this issue.
+ Feel free to add more info or respond to any questions above and we
+ can reopen the case. Thanks for your contribution!
+ stale-pr-label: "needs-info"
+ close-pr-message: >
+ Without additional information we're not able to resolve this PR.
+ Feel free to add more info or respond to any questions above.
+ Thanks for your contribution!
diff --git a/pkgs/watcher/.github/workflows/publish.yaml b/pkgs/watcher/.github/workflows/publish.yaml
new file mode 100644
index 0000000..2239b63
--- /dev/null
+++ b/pkgs/watcher/.github/workflows/publish.yaml
@@ -0,0 +1,14 @@
+# A CI configuration to auto-publish pub packages.
+
+name: Publish
+
+on:
+ pull_request:
+ branches: [ master ]
+ push:
+ tags: [ 'v[0-9]+.[0-9]+.[0-9]+' ]
+
+jobs:
+ publish:
+ if: ${{ github.repository_owner == 'dart-lang' }}
+ uses: dart-lang/ecosystem/.github/workflows/publish.yaml@main
diff --git a/pkgs/watcher/.github/workflows/test-package.yml b/pkgs/watcher/.github/workflows/test-package.yml
new file mode 100644
index 0000000..b501c98
--- /dev/null
+++ b/pkgs/watcher/.github/workflows/test-package.yml
@@ -0,0 +1,60 @@
+name: Dart CI
+
+on:
+ # Run on PRs and pushes to the default branch.
+ push:
+ branches: [ master ]
+ pull_request:
+ branches: [ master ]
+ schedule:
+ - cron: "0 0 * * 0"
+
+env:
+ PUB_ENVIRONMENT: bot.github
+
+jobs:
+ # Check code formatting and static analysis on a single OS (linux)
+ # against Dart dev.
+ analyze:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ sdk: [dev]
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+ - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94
+ with:
+ sdk: ${{ matrix.sdk }}
+ - id: install
+ name: Install dependencies
+ run: dart pub get
+ - name: Check formatting
+ run: dart format --output=none --set-exit-if-changed .
+ if: always() && steps.install.outcome == 'success'
+ - name: Analyze code
+ run: dart analyze --fatal-infos
+ if: always() && steps.install.outcome == 'success'
+
+ # Run tests on a matrix consisting of two dimensions:
+ # 1. OS: ubuntu-latest, macos-latest, windows-latest
+ # 2. release channel: dev
+ test:
+ needs: analyze
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ sdk: [3.1, dev]
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+ - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94
+ with:
+ sdk: ${{ matrix.sdk }}
+ - id: install
+ name: Install dependencies
+ run: dart pub get
+ - name: Run VM tests
+ run: dart test --platform vm
+ if: always() && steps.install.outcome == 'success'
diff --git a/pkgs/watcher/.gitignore b/pkgs/watcher/.gitignore
new file mode 100644
index 0000000..ac98e87
--- /dev/null
+++ b/pkgs/watcher/.gitignore
@@ -0,0 +1,4 @@
+# Don’t commit the following directories created by pub.
+.dart_tool
+.packages
+pubspec.lock
diff --git a/pkgs/watcher/.test_config b/pkgs/watcher/.test_config
new file mode 100644
index 0000000..531426a
--- /dev/null
+++ b/pkgs/watcher/.test_config
@@ -0,0 +1,5 @@
+{
+ "test_package": {
+ "platforms": ["vm"]
+ }
+}
\ No newline at end of file
diff --git a/pkgs/watcher/CHANGELOG.md b/pkgs/watcher/CHANGELOG.md
new file mode 100644
index 0000000..8f4d289
--- /dev/null
+++ b/pkgs/watcher/CHANGELOG.md
@@ -0,0 +1,129 @@
+## 1.1.1-wip
+
+- Ensure `PollingFileWatcher.ready` completes for files that do not exist.
+- Require Dart SDK `^3.1.0`
+
+## 1.1.0
+
+- Require Dart SDK >= 3.0.0
+- Remove usage of redundant ConstructableFileSystemEvent classes.
+
+## 1.0.3-dev
+
+- Require Dart SDK >= 2.19
+
+## 1.0.2
+
+- Require Dart SDK >= 2.14
+- Ensure `DirectoryWatcher.ready` completes even when errors occur that close the watcher.
+- Add markdown badges to the readme.
+
+## 1.0.1
+
+* Drop package:pedantic and use package:lints instead.
+
+## 1.0.0
+
+* Require Dart SDK >= 2.12
+* Add the ability to create custom Watcher types for specific file paths.
+
+## 0.9.7+15
+
+* Fix a bug on Mac where modifying a directory with a path exactly matching a
+ prefix of a modified file would suppress change events for that file.
+
+## 0.9.7+14
+
+* Prepare for breaking change in SDK where modified times for not found files
+ becomes meaningless instead of null.
+
+## 0.9.7+13
+
+* Catch & forward `FileSystemException` from unexpectedly closed file watchers
+ on windows; the watcher will also be automatically restarted when this occurs.
+
+## 0.9.7+12
+
+* Catch `FileSystemException` during `existsSync()` on Windows.
+* Internal cleanup.
+
+## 0.9.7+11
+
+* Fix an analysis hint.
+
+## 0.9.7+10
+
+* Set max SDK version to `<3.0.0`, and adjust other dependencies.
+
+## 0.9.7+9
+
+* Internal changes only.
+
+## 0.9.7+8
+
+* Fix Dart 2.0 type issues on Mac and Windows.
+
+## 0.9.7+7
+
+* Updates to support Dart 2.0 core library changes (wave 2.2).
+ See [issue 31847][sdk#31847] for details.
+
+ [sdk#31847]: https://github.com/dart-lang/sdk/issues/31847
+
+
+## 0.9.7+6
+
+* Internal changes only, namely removing dep on scheduled test.
+
+## 0.9.7+5
+
+* Fix an analysis warning.
+
+## 0.9.7+4
+
+* Declare support for `async` 2.0.0.
+
+## 0.9.7+3
+
+* Fix a crashing bug on Linux.
+
+## 0.9.7+2
+
+* Narrow the constraint on `async` to reflect the APIs this package is actually
+ using.
+
+## 0.9.7+1
+
+* Fix all strong-mode warnings.
+
+## 0.9.7
+
+* Fix a bug in `FileWatcher` where events could be added after watchers were
+ closed.
+
+## 0.9.6
+
+* 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
+
+* Fix bugs where events could be added after watchers were closed.
+
+## 0.9.4
+
+* Treat add events for known files as modifications instead of discarding them
+ on Mac OS.
+
+## 0.9.3
+
+* Improved support for Windows via `WindowsDirectoryWatcher`.
+
+* Simplified `PollingDirectoryWatcher`.
+
+* Fixed bugs in `MacOSDirectoryWatcher`
diff --git a/pkgs/watcher/LICENSE b/pkgs/watcher/LICENSE
new file mode 100644
index 0000000..000cd7b
--- /dev/null
+++ b/pkgs/watcher/LICENSE
@@ -0,0 +1,27 @@
+Copyright 2014, the Dart project authors.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+ copyright notice, this list of conditions and the following
+ disclaimer in the documentation and/or other materials provided
+ with the distribution.
+ * Neither the name of Google LLC nor the names of its
+ contributors may be used to endorse or promote products derived
+ from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/pkgs/watcher/README.md b/pkgs/watcher/README.md
new file mode 100644
index 0000000..677ca35
--- /dev/null
+++ b/pkgs/watcher/README.md
@@ -0,0 +1,10 @@
+[](https://github.com/dart-lang/watcher/actions/workflows/test-package.yml)
+[](https://pub.dev/packages/watcher)
+[](https://pub.dev/packages/watcher/publisher)
+
+A file system watcher.
+
+## What's this?
+
+`package:watcher` monitors changes to contents of directories and sends
+notifications when files have been added, removed, or modified.
diff --git a/pkgs/watcher/analysis_options.yaml b/pkgs/watcher/analysis_options.yaml
new file mode 100644
index 0000000..d978f81
--- /dev/null
+++ b/pkgs/watcher/analysis_options.yaml
@@ -0,0 +1 @@
+include: package:dart_flutter_team_lints/analysis_options.yaml
diff --git a/pkgs/watcher/benchmark/path_set.dart b/pkgs/watcher/benchmark/path_set.dart
new file mode 100644
index 0000000..e7929d8
--- /dev/null
+++ b/pkgs/watcher/benchmark/path_set.dart
@@ -0,0 +1,158 @@
+// 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.
+
+/// Benchmarks for the PathSet class.
+library;
+
+import 'dart:io';
+import 'dart:math' as math;
+
+import 'package:benchmark_harness/benchmark_harness.dart';
+import 'package:path/path.dart' as p;
+import 'package:watcher/src/path_set.dart';
+
+final String root = Platform.isWindows ? r'C:\root' : '/root';
+
+/// Base class for benchmarks on [PathSet].
+abstract class PathSetBenchmark extends BenchmarkBase {
+ PathSetBenchmark(String method) : super('PathSet.$method');
+
+ final PathSet pathSet = PathSet(root);
+
+ /// Use a fixed [math.Random] with a constant seed to ensure the tests are
+ /// deterministic.
+ final math.Random random = math.Random(1234);
+
+ /// Walks over a virtual directory [depth] levels deep invoking [callback]
+ /// for each "file".
+ ///
+ /// Each virtual directory contains ten entries: either subdirectories or
+ /// files.
+ void walkTree(int depth, void Function(String) callback) {
+ void recurse(String path, int remainingDepth) {
+ for (var i = 0; i < 10; i++) {
+ var padded = i.toString().padLeft(2, '0');
+ if (remainingDepth == 0) {
+ callback(p.join(path, 'file_$padded.txt'));
+ } else {
+ var subdir = p.join(path, 'subdirectory_$padded');
+ recurse(subdir, remainingDepth - 1);
+ }
+ }
+ }
+
+ recurse(root, depth);
+ }
+}
+
+class AddBenchmark extends PathSetBenchmark {
+ AddBenchmark() : super('add()');
+
+ final List<String> paths = [];
+
+ @override
+ void setup() {
+ // Make a bunch of paths in about the same order we expect to get them from
+ // Directory.list().
+ walkTree(3, paths.add);
+ }
+
+ @override
+ void run() {
+ for (var path in paths) {
+ pathSet.add(path);
+ }
+ }
+}
+
+class ContainsBenchmark extends PathSetBenchmark {
+ ContainsBenchmark() : super('contains()');
+
+ final List<String> paths = [];
+
+ @override
+ void setup() {
+ // Add a bunch of paths to the set.
+ walkTree(3, (path) {
+ pathSet.add(path);
+ paths.add(path);
+ });
+
+ // Add some non-existent paths to test the false case.
+ for (var i = 0; i < 100; i++) {
+ paths.addAll([
+ '/nope',
+ '/root/nope',
+ '/root/subdirectory_04/nope',
+ '/root/subdirectory_04/subdirectory_04/nope',
+ '/root/subdirectory_04/subdirectory_04/subdirectory_04/nope',
+ '/root/subdirectory_04/subdirectory_04/subdirectory_04/nope/file_04.txt',
+ ]);
+ }
+ }
+
+ @override
+ void run() {
+ var contained = 0;
+ for (var path in paths) {
+ if (pathSet.contains(path)) contained++;
+ }
+
+ if (contained != 10000) throw StateError('Wrong result: $contained');
+ }
+}
+
+class PathsBenchmark extends PathSetBenchmark {
+ PathsBenchmark() : super('toSet()');
+
+ @override
+ void setup() {
+ walkTree(3, pathSet.add);
+ }
+
+ @override
+ void run() {
+ var count = 0;
+ for (var _ in pathSet.paths) {
+ count++;
+ }
+
+ if (count != 10000) throw StateError('Wrong result: $count');
+ }
+}
+
+class RemoveBenchmark extends PathSetBenchmark {
+ RemoveBenchmark() : super('remove()');
+
+ final List<String> paths = [];
+
+ @override
+ void setup() {
+ // Make a bunch of paths. Do this here so that we don't spend benchmarked
+ // time synthesizing paths.
+ walkTree(3, (path) {
+ pathSet.add(path);
+ paths.add(path);
+ });
+
+ // Shuffle the paths so that we delete them in a random order that
+ // hopefully mimics real-world file system usage. Do the shuffling here so
+ // that we don't spend benchmarked time shuffling.
+ paths.shuffle(random);
+ }
+
+ @override
+ void run() {
+ for (var path in paths) {
+ pathSet.remove(path);
+ }
+ }
+}
+
+void main() {
+ AddBenchmark().report();
+ ContainsBenchmark().report();
+ PathsBenchmark().report();
+ RemoveBenchmark().report();
+}
diff --git a/pkgs/watcher/example/watch.dart b/pkgs/watcher/example/watch.dart
new file mode 100644
index 0000000..37931d3
--- /dev/null
+++ b/pkgs/watcher/example/watch.dart
@@ -0,0 +1,19 @@
+// Copyright (c) 2013, 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.
+
+/// Watches the given directory and prints each modification to it.
+library;
+
+import 'package:path/path.dart' as p;
+import 'package:watcher/watcher.dart';
+
+void main(List<String> arguments) {
+ if (arguments.length != 1) {
+ print('Usage: watch <directory path>');
+ return;
+ }
+
+ var watcher = DirectoryWatcher(p.absolute(arguments[0]));
+ watcher.events.listen(print);
+}
diff --git a/pkgs/watcher/lib/src/async_queue.dart b/pkgs/watcher/lib/src/async_queue.dart
new file mode 100644
index 0000000..f6c76a9
--- /dev/null
+++ b/pkgs/watcher/lib/src/async_queue.dart
@@ -0,0 +1,70 @@
+// Copyright (c) 2013, 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';
+
+typedef ItemProcessor<T> = Future<void> Function(T item);
+
+/// A queue of items that are sequentially, asynchronously processed.
+///
+/// Unlike [Stream.map] or [Stream.forEach], the callback used to process each
+/// item returns a [Future], and it will not advance to the next item until the
+/// current item is finished processing.
+///
+/// Items can be added at any point in time and processing will be started as
+/// needed. When all items are processed, it stops processing until more items
+/// are added.
+class AsyncQueue<T> {
+ final _items = Queue<T>();
+
+ /// Whether or not the queue is currently waiting on a processing future to
+ /// complete.
+ bool _isProcessing = false;
+
+ /// The callback to invoke on each queued item.
+ ///
+ /// The next item in the queue will not be processed until the [Future]
+ /// returned by this completes.
+ final ItemProcessor<T> _processor;
+
+ /// The handler for errors thrown during processing.
+ ///
+ /// Used to avoid top-leveling asynchronous errors.
+ final void Function(Object, StackTrace) _errorHandler;
+
+ AsyncQueue(this._processor,
+ {required void Function(Object, StackTrace) onError})
+ : _errorHandler = onError;
+
+ /// Enqueues [item] to be processed and starts asynchronously processing it
+ /// if a process isn't already running.
+ void add(T item) {
+ _items.add(item);
+
+ // Start up the asynchronous processing if not already running.
+ if (_isProcessing) return;
+ _isProcessing = true;
+
+ _processNextItem().catchError(_errorHandler);
+ }
+
+ /// Removes all remaining items to be processed.
+ void clear() {
+ _items.clear();
+ }
+
+ /// Pulls the next item off [_items] and processes it.
+ ///
+ /// When complete, recursively calls itself to continue processing unless
+ /// the process was cancelled.
+ Future<void> _processNextItem() async {
+ var item = _items.removeFirst();
+ await _processor(item);
+ if (_items.isNotEmpty) return _processNextItem();
+
+ // We have drained the queue, stop processing and wait until something
+ // has been enqueued.
+ _isProcessing = false;
+ }
+}
diff --git a/pkgs/watcher/lib/src/custom_watcher_factory.dart b/pkgs/watcher/lib/src/custom_watcher_factory.dart
new file mode 100644
index 0000000..fc4e3fb
--- /dev/null
+++ b/pkgs/watcher/lib/src/custom_watcher_factory.dart
@@ -0,0 +1,88 @@
+// Copyright (c) 2020, 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 '../watcher.dart';
+
+/// A factory to produce custom watchers for specific file paths.
+class _CustomWatcherFactory {
+ final String id;
+ final DirectoryWatcher? Function(String path, {Duration? pollingDelay})
+ createDirectoryWatcher;
+ final FileWatcher? Function(String path, {Duration? pollingDelay})
+ createFileWatcher;
+
+ _CustomWatcherFactory(
+ this.id, this.createDirectoryWatcher, this.createFileWatcher);
+}
+
+/// Registers a custom watcher.
+///
+/// Each custom watcher must have a unique [id] and the same watcher may not be
+/// registered more than once.
+/// [createDirectoryWatcher] and [createFileWatcher] should return watchers for
+/// the file paths they are able to handle. If the custom watcher is not able to
+/// handle the path it should return null.
+/// The paths handled by each custom watch may not overlap, at most one custom
+/// matcher may return a non-null watcher for a given path.
+///
+/// When a file or directory watcher is created the path is checked against each
+/// registered custom watcher, and if exactly one custom watcher is available it
+/// will be used instead of the default.
+void registerCustomWatcher(
+ String id,
+ DirectoryWatcher? Function(String path, {Duration? pollingDelay})?
+ createDirectoryWatcher,
+ FileWatcher? Function(String path, {Duration? pollingDelay})?
+ createFileWatcher,
+) {
+ if (_customWatcherFactories.containsKey(id)) {
+ throw ArgumentError('A custom watcher with id `$id` '
+ 'has already been registered');
+ }
+ _customWatcherFactories[id] = _CustomWatcherFactory(
+ id,
+ createDirectoryWatcher ?? (_, {pollingDelay}) => null,
+ createFileWatcher ?? (_, {pollingDelay}) => null);
+}
+
+/// Tries to create a custom [DirectoryWatcher] and returns it.
+///
+/// Returns `null` if no custom watcher was applicable and throws a [StateError]
+/// if more than one was.
+DirectoryWatcher? createCustomDirectoryWatcher(String path,
+ {Duration? pollingDelay}) {
+ DirectoryWatcher? customWatcher;
+ String? customFactoryId;
+ for (var watcherFactory in _customWatcherFactories.values) {
+ if (customWatcher != null) {
+ throw StateError('Two `CustomWatcherFactory`s applicable: '
+ '`$customFactoryId` and `${watcherFactory.id}` for `$path`');
+ }
+ customWatcher =
+ watcherFactory.createDirectoryWatcher(path, pollingDelay: pollingDelay);
+ customFactoryId = watcherFactory.id;
+ }
+ return customWatcher;
+}
+
+/// Tries to create a custom [FileWatcher] and returns it.
+///
+/// Returns `null` if no custom watcher was applicable and throws a [StateError]
+/// if more than one was.
+FileWatcher? createCustomFileWatcher(String path, {Duration? pollingDelay}) {
+ FileWatcher? customWatcher;
+ String? customFactoryId;
+ for (var watcherFactory in _customWatcherFactories.values) {
+ if (customWatcher != null) {
+ throw StateError('Two `CustomWatcherFactory`s applicable: '
+ '`$customFactoryId` and `${watcherFactory.id}` for `$path`');
+ }
+ customWatcher =
+ watcherFactory.createFileWatcher(path, pollingDelay: pollingDelay);
+ customFactoryId = watcherFactory.id;
+ }
+ return customWatcher;
+}
+
+final _customWatcherFactories = <String, _CustomWatcherFactory>{};
diff --git a/pkgs/watcher/lib/src/directory_watcher.dart b/pkgs/watcher/lib/src/directory_watcher.dart
new file mode 100644
index 0000000..158b86b
--- /dev/null
+++ b/pkgs/watcher/lib/src/directory_watcher.dart
@@ -0,0 +1,41 @@
+// Copyright (c) 2013, 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:io';
+
+import '../watcher.dart';
+import 'custom_watcher_factory.dart';
+import 'directory_watcher/linux.dart';
+import 'directory_watcher/mac_os.dart';
+import 'directory_watcher/windows.dart';
+
+/// Watches the contents of a directory and emits [WatchEvent]s when something
+/// in the directory has changed.
+abstract class DirectoryWatcher implements Watcher {
+ /// The directory whose contents are being monitored.
+ @Deprecated('Expires in 1.0.0. Use DirectoryWatcher.path instead.')
+ String get directory;
+
+ /// Creates a new [DirectoryWatcher] monitoring [directory].
+ ///
+ /// 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
+ /// 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 DirectoryWatcher(String directory, {Duration? pollingDelay}) {
+ if (FileSystemEntity.isWatchSupported) {
+ var customWatcher =
+ createCustomDirectoryWatcher(directory, pollingDelay: pollingDelay);
+ if (customWatcher != null) return customWatcher;
+ if (Platform.isLinux) return LinuxDirectoryWatcher(directory);
+ if (Platform.isMacOS) return MacOSDirectoryWatcher(directory);
+ if (Platform.isWindows) return WindowsDirectoryWatcher(directory);
+ }
+ return PollingDirectoryWatcher(directory, pollingDelay: pollingDelay);
+ }
+}
diff --git a/pkgs/watcher/lib/src/directory_watcher/linux.dart b/pkgs/watcher/lib/src/directory_watcher/linux.dart
new file mode 100644
index 0000000..cb1d077
--- /dev/null
+++ b/pkgs/watcher/lib/src/directory_watcher/linux.dart
@@ -0,0 +1,294 @@
+// Copyright (c) 2013, 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:io';
+
+import 'package:async/async.dart';
+
+import '../directory_watcher.dart';
+import '../path_set.dart';
+import '../resubscribable.dart';
+import '../utils.dart';
+import '../watch_event.dart';
+
+/// Uses the inotify subsystem to watch for filesystem events.
+///
+/// Inotify doesn't suport recursively watching subdirectories, nor does
+/// [Directory.watch] polyfill that functionality. This class polyfills it
+/// instead.
+///
+/// This class also compensates for the non-inotify-specific issues of
+/// [Directory.watch] producing multiple events for a single logical action
+/// (issue 14372) and providing insufficient information about move events
+/// (issue 14424).
+class LinuxDirectoryWatcher extends ResubscribableWatcher
+ implements DirectoryWatcher {
+ @override
+ String get directory => path;
+
+ LinuxDirectoryWatcher(String directory)
+ : super(directory, () => _LinuxDirectoryWatcher(directory));
+}
+
+class _LinuxDirectoryWatcher
+ implements DirectoryWatcher, ManuallyClosedWatcher {
+ @override
+ String get directory => _files.root;
+ @override
+ String get path => _files.root;
+
+ @override
+ Stream<WatchEvent> get events => _eventsController.stream;
+ final _eventsController = StreamController<WatchEvent>.broadcast();
+
+ @override
+ bool get isReady => _readyCompleter.isCompleted;
+
+ @override
+ Future<void> get ready => _readyCompleter.future;
+ final _readyCompleter = Completer<void>();
+
+ /// A stream group for the [Directory.watch] events of [path] and all its
+ /// subdirectories.
+ final _nativeEvents = StreamGroup<FileSystemEvent>();
+
+ /// All known files recursively within [path].
+ final PathSet _files;
+
+ /// [Directory.watch] streams for [path]'s subdirectories, indexed by name.
+ ///
+ /// A stream is in this map if and only if it's also in [_nativeEvents].
+ final _subdirStreams = <String, Stream<FileSystemEvent>>{};
+
+ /// A set of all subscriptions that this watcher subscribes to.
+ ///
+ /// These are gathered together so that they may all be canceled when the
+ /// watcher is closed.
+ final _subscriptions = <StreamSubscription>{};
+
+ _LinuxDirectoryWatcher(String path) : _files = PathSet(path) {
+ _nativeEvents.add(Directory(path)
+ .watch()
+ .transform(StreamTransformer.fromHandlers(handleDone: (sink) {
+ // Handle the done event here rather than in the call to [_listen] because
+ // [innerStream] won't close until we close the [StreamGroup]. However, if
+ // we close the [StreamGroup] here, we run the risk of new-directory
+ // events being fired after the group is closed, since batching delays
+ // those events. See b/30768513.
+ _onDone();
+ })));
+
+ // Batch the inotify changes together so that we can dedup events.
+ var innerStream = _nativeEvents.stream.batchEvents();
+ _listen(innerStream, _onBatch,
+ onError: (Object error, StackTrace stackTrace) {
+ // Guarantee that ready always completes.
+ if (!isReady) {
+ _readyCompleter.complete();
+ }
+ _eventsController.addError(error, stackTrace);
+ });
+
+ _listen(
+ Directory(path).list(recursive: true),
+ (FileSystemEntity entity) {
+ if (entity is Directory) {
+ _watchSubdir(entity.path);
+ } else {
+ _files.add(entity.path);
+ }
+ },
+ onError: _emitError,
+ onDone: () {
+ if (!isReady) {
+ _readyCompleter.complete();
+ }
+ },
+ cancelOnError: true,
+ );
+ }
+
+ @override
+ void close() {
+ for (var subscription in _subscriptions) {
+ subscription.cancel();
+ }
+
+ _subscriptions.clear();
+ _subdirStreams.clear();
+ _files.clear();
+ _nativeEvents.close();
+ _eventsController.close();
+ }
+
+ /// Watch a subdirectory of [directory] for changes.
+ void _watchSubdir(String path) {
+ // TODO(nweiz): Right now it's possible for the watcher to emit an event for
+ // a file before the directory list is complete. This could lead to the user
+ // seeing a MODIFY or REMOVE event for a file before they see an ADD event,
+ // which is bad. We should handle that.
+ //
+ // One possibility is to provide a general means (e.g.
+ // `DirectoryWatcher.eventsAndExistingFiles`) to tell a watcher to emit
+ // events for all the files that already exist. This would be useful for
+ // top-level clients such as barback as well, and could be implemented with
+ // a wrapper similar to how listening/canceling works now.
+
+ // TODO(nweiz): Catch any errors here that indicate that the directory in
+ // question doesn't exist and silently stop watching it instead of
+ // propagating the errors.
+ var stream = Directory(path).watch();
+ _subdirStreams[path] = stream;
+ _nativeEvents.add(stream);
+ }
+
+ /// The callback that's run when a batch of changes comes in.
+ void _onBatch(List<FileSystemEvent> batch) {
+ var files = <String>{};
+ var dirs = <String>{};
+ var changed = <String>{};
+
+ // inotify event batches are ordered by occurrence, so we treat them as a
+ // log of what happened to a file. We only emit events based on the
+ // difference between the state before the batch and the state after it, not
+ // the intermediate state.
+ for (var event in batch) {
+ // If the watched directory is deleted or moved, we'll get a deletion
+ // event for it. Ignore it; we handle closing [this] when the underlying
+ // stream is closed.
+ if (event.path == path) continue;
+
+ changed.add(event.path);
+
+ if (event is FileSystemMoveEvent) {
+ files.remove(event.path);
+ dirs.remove(event.path);
+
+ var destination = event.destination;
+ if (destination == null) continue;
+
+ changed.add(destination);
+ if (event.isDirectory) {
+ files.remove(destination);
+ dirs.add(destination);
+ } else {
+ files.add(destination);
+ dirs.remove(destination);
+ }
+ } else if (event is FileSystemDeleteEvent) {
+ files.remove(event.path);
+ dirs.remove(event.path);
+ } else if (event.isDirectory) {
+ files.remove(event.path);
+ dirs.add(event.path);
+ } else {
+ files.add(event.path);
+ dirs.remove(event.path);
+ }
+ }
+
+ _applyChanges(files, dirs, changed);
+ }
+
+ /// Applies the net changes computed for a batch.
+ ///
+ /// The [files] and [dirs] sets contain the files and directories that now
+ /// exist, respectively. The [changed] set contains all files and directories
+ /// that have changed (including being removed), and so is a superset of
+ /// [files] and [dirs].
+ void _applyChanges(Set<String> files, Set<String> dirs, Set<String> changed) {
+ for (var path in changed) {
+ var stream = _subdirStreams.remove(path);
+ if (stream != null) _nativeEvents.add(stream);
+
+ // Unless [path] was a file and still is, emit REMOVE events for it or its
+ // contents,
+ if (files.contains(path) && _files.contains(path)) continue;
+ for (var file in _files.remove(path)) {
+ _emitEvent(ChangeType.REMOVE, file);
+ }
+ }
+
+ for (var file in files) {
+ if (_files.contains(file)) {
+ _emitEvent(ChangeType.MODIFY, file);
+ } else {
+ _emitEvent(ChangeType.ADD, file);
+ _files.add(file);
+ }
+ }
+
+ for (var dir in dirs) {
+ _watchSubdir(dir);
+ _addSubdir(dir);
+ }
+ }
+
+ /// Emits [ChangeType.ADD] events for the recursive contents of [path].
+ void _addSubdir(String path) {
+ _listen(Directory(path).list(recursive: true), (FileSystemEntity entity) {
+ if (entity is Directory) {
+ _watchSubdir(entity.path);
+ } else {
+ _files.add(entity.path);
+ _emitEvent(ChangeType.ADD, entity.path);
+ }
+ }, onError: (Object error, StackTrace stackTrace) {
+ // Ignore an exception caused by the dir not existing. It's fine if it
+ // was added and then quickly removed.
+ if (error is FileSystemException) return;
+
+ _emitError(error, stackTrace);
+ }, cancelOnError: true);
+ }
+
+ /// Handles the underlying event stream closing, indicating that the directory
+ /// being watched was removed.
+ void _onDone() {
+ // Most of the time when a directory is removed, its contents will get
+ // individual REMOVE events before the watch stream is closed -- in that
+ // case, [_files] will be empty here. However, if the directory's removal is
+ // caused by a MOVE, we need to manually emit events.
+ if (isReady) {
+ for (var file in _files.paths) {
+ _emitEvent(ChangeType.REMOVE, file);
+ }
+ }
+
+ close();
+ }
+
+ /// Emits a [WatchEvent] with [type] and [path] if this watcher is in a state
+ /// to emit events.
+ void _emitEvent(ChangeType type, String path) {
+ if (!isReady) return;
+ if (_eventsController.isClosed) return;
+ _eventsController.add(WatchEvent(type, path));
+ }
+
+ /// Emit an error, then close the watcher.
+ void _emitError(Object error, StackTrace stackTrace) {
+ // Guarantee that ready always completes.
+ if (!isReady) {
+ _readyCompleter.complete();
+ }
+ _eventsController.addError(error, stackTrace);
+ close();
+ }
+
+ /// Like [Stream.listen], but automatically adds the subscription to
+ /// [_subscriptions] so that it can be canceled when [close] is called.
+ void _listen<T>(Stream<T> stream, void Function(T) onData,
+ {Function? onError,
+ void Function()? onDone,
+ bool cancelOnError = false}) {
+ late StreamSubscription<T> subscription;
+ subscription = stream.listen(onData, onError: onError, onDone: () {
+ _subscriptions.remove(subscription);
+ onDone?.call();
+ }, cancelOnError: cancelOnError);
+ _subscriptions.add(subscription);
+ }
+}
diff --git a/pkgs/watcher/lib/src/directory_watcher/mac_os.dart b/pkgs/watcher/lib/src/directory_watcher/mac_os.dart
new file mode 100644
index 0000000..b461383
--- /dev/null
+++ b/pkgs/watcher/lib/src/directory_watcher/mac_os.dart
@@ -0,0 +1,410 @@
+// Copyright (c) 2013, 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:io';
+
+import 'package:path/path.dart' as p;
+
+import '../directory_watcher.dart';
+import '../path_set.dart';
+import '../resubscribable.dart';
+import '../utils.dart';
+import '../watch_event.dart';
+
+/// Uses the FSEvents subsystem to watch for filesystem events.
+///
+/// FSEvents has two main idiosyncrasies that this class works around. First, it
+/// will occasionally report events that occurred before the filesystem watch
+/// was initiated. Second, if multiple events happen to the same file in close
+/// succession, it won't report them in the order they occurred. See issue
+/// 14373.
+///
+/// This also works around issues 16003 and 14849 in the implementation of
+/// [Directory.watch].
+class MacOSDirectoryWatcher extends ResubscribableWatcher
+ implements DirectoryWatcher {
+ @override
+ String get directory => path;
+
+ MacOSDirectoryWatcher(String directory)
+ : super(directory, () => _MacOSDirectoryWatcher(directory));
+}
+
+class _MacOSDirectoryWatcher
+ implements DirectoryWatcher, ManuallyClosedWatcher {
+ @override
+ String get directory => path;
+ @override
+ final String path;
+
+ @override
+ Stream<WatchEvent> get events => _eventsController.stream;
+ final _eventsController = StreamController<WatchEvent>.broadcast();
+
+ @override
+ bool get isReady => _readyCompleter.isCompleted;
+
+ @override
+ Future<void> get ready => _readyCompleter.future;
+ final _readyCompleter = Completer<void>();
+
+ /// The set of files that are known to exist recursively within the watched
+ /// directory.
+ ///
+ /// The state of files on the filesystem is compared against this to determine
+ /// the real change that occurred when working around issue 14373. This is
+ /// also used to emit REMOVE events when subdirectories are moved out of the
+ /// watched directory.
+ final PathSet _files;
+
+ /// The subscription to the stream returned by [Directory.watch].
+ ///
+ /// This is separate from [_listSubscriptions] because this stream
+ /// occasionally needs to be resubscribed in order to work around issue 14849.
+ StreamSubscription<List<FileSystemEvent>>? _watchSubscription;
+
+ /// The subscription to the [Directory.list] call for the initial listing of
+ /// the directory to determine its initial state.
+ StreamSubscription<FileSystemEntity>? _initialListSubscription;
+
+ /// The subscriptions to [Directory.list] calls for listing the contents of a
+ /// subdirectory that was moved into the watched directory.
+ final _listSubscriptions = <StreamSubscription<FileSystemEntity>>{};
+
+ /// The timer for tracking how long we wait for an initial batch of bogus
+ /// events (see issue 14373).
+ late Timer _bogusEventTimer;
+
+ _MacOSDirectoryWatcher(this.path) : _files = PathSet(path) {
+ _startWatch();
+
+ // Before we're ready to emit events, wait for [_listDir] to complete and
+ // for enough time to elapse that if bogus events (issue 14373) would be
+ // emitted, they will be.
+ //
+ // If we do receive a batch of events, [_onBatch] will ensure that these
+ // futures don't fire and that the directory is re-listed.
+ Future.wait([_listDir(), _waitForBogusEvents()]).then((_) {
+ if (!isReady) {
+ _readyCompleter.complete();
+ }
+ });
+ }
+
+ @override
+ void close() {
+ _watchSubscription?.cancel();
+ _initialListSubscription?.cancel();
+ _watchSubscription = null;
+ _initialListSubscription = null;
+
+ for (var subscription in _listSubscriptions) {
+ subscription.cancel();
+ }
+ _listSubscriptions.clear();
+
+ _eventsController.close();
+ }
+
+ /// The callback that's run when [Directory.watch] emits a batch of events.
+ void _onBatch(List<FileSystemEvent> batch) {
+ // If we get a batch of events before we're ready to begin emitting events,
+ // it's probable that it's a batch of pre-watcher events (see issue 14373).
+ // Ignore those events and re-list the directory.
+ if (!isReady) {
+ // Cancel the timer because bogus events only occur in the first batch, so
+ // we can fire [ready] as soon as we're done listing the directory.
+ _bogusEventTimer.cancel();
+ _listDir().then((_) {
+ if (!isReady) {
+ _readyCompleter.complete();
+ }
+ });
+ return;
+ }
+
+ _sortEvents(batch).forEach((path, eventSet) {
+ var canonicalEvent = _canonicalEvent(eventSet);
+ var events = canonicalEvent == null
+ ? _eventsBasedOnFileSystem(path)
+ : [canonicalEvent];
+
+ for (var event in events) {
+ if (event is FileSystemCreateEvent) {
+ if (!event.isDirectory) {
+ // If we already know about the file, treat it like a modification.
+ // This can happen if a file is copied on top of an existing one.
+ // We'll see an ADD event for the latter file when from the user's
+ // perspective, the file's contents just changed.
+ var type =
+ _files.contains(path) ? ChangeType.MODIFY : ChangeType.ADD;
+
+ _emitEvent(type, path);
+ _files.add(path);
+ continue;
+ }
+
+ if (_files.containsDir(path)) continue;
+
+ var stream = Directory(path).list(recursive: true);
+ var subscription = stream.listen((entity) {
+ if (entity is Directory) return;
+ if (_files.contains(path)) return;
+
+ _emitEvent(ChangeType.ADD, entity.path);
+ _files.add(entity.path);
+ }, cancelOnError: true);
+ subscription.onDone(() {
+ _listSubscriptions.remove(subscription);
+ });
+ subscription.onError(_emitError);
+ _listSubscriptions.add(subscription);
+ } else if (event is FileSystemModifyEvent) {
+ assert(!event.isDirectory);
+ _emitEvent(ChangeType.MODIFY, path);
+ } else {
+ assert(event is FileSystemDeleteEvent);
+ for (var removedPath in _files.remove(path)) {
+ _emitEvent(ChangeType.REMOVE, removedPath);
+ }
+ }
+ }
+ });
+ }
+
+ /// Sort all the events in a batch into sets based on their path.
+ ///
+ /// A single input event may result in multiple events in the returned map;
+ /// for example, a MOVE event becomes a DELETE event for the source and a
+ /// CREATE event for the destination.
+ ///
+ /// The returned events won't contain any [FileSystemMoveEvent]s, nor will it
+ /// contain any events relating to [path].
+ Map<String, Set<FileSystemEvent>> _sortEvents(List<FileSystemEvent> batch) {
+ var eventsForPaths = <String, Set<FileSystemEvent>>{};
+
+ // FSEvents can report past events, including events on the root directory
+ // such as it being created. We want to ignore these. If the directory is
+ // really deleted, that's handled by [_onDone].
+ batch = batch.where((event) => event.path != path).toList();
+
+ // Events within directories that already have events are superfluous; the
+ // directory's full contents will be examined anyway, so we ignore such
+ // events. Emitting them could cause useless or out-of-order events.
+ var directories = unionAll(batch.map((event) {
+ if (!event.isDirectory) return <String>{};
+ if (event is FileSystemMoveEvent) {
+ var destination = event.destination;
+ if (destination != null) {
+ return {event.path, destination};
+ }
+ }
+ return {event.path};
+ }));
+
+ bool isInModifiedDirectory(String path) =>
+ directories.any((dir) => path != dir && p.isWithin(dir, path));
+
+ void addEvent(String path, FileSystemEvent event) {
+ if (isInModifiedDirectory(path)) return;
+ eventsForPaths.putIfAbsent(path, () => <FileSystemEvent>{}).add(event);
+ }
+
+ for (var event in batch) {
+ // The Mac OS watcher doesn't emit move events. See issue 14806.
+ assert(event is! FileSystemMoveEvent);
+ addEvent(event.path, event);
+ }
+
+ return eventsForPaths;
+ }
+
+ /// Returns the canonical event from a batch of events on the same path, if
+ /// one exists.
+ ///
+ /// If [batch] doesn't contain any contradictory events (e.g. DELETE and
+ /// CREATE, or events with different values for `isDirectory`), this returns a
+ /// single event that describes what happened to the path in question.
+ ///
+ /// If [batch] does contain contradictory events, this returns `null` to
+ /// indicate that the state of the path on the filesystem should be checked to
+ /// determine what occurred.
+ FileSystemEvent? _canonicalEvent(Set<FileSystemEvent> batch) {
+ // An empty batch indicates that we've learned earlier that the batch is
+ // contradictory (e.g. because of a move).
+ if (batch.isEmpty) return null;
+
+ var type = batch.first.type;
+ var isDir = batch.first.isDirectory;
+ var hadModifyEvent = false;
+
+ for (var event in batch.skip(1)) {
+ // If one event reports that the file is a directory and another event
+ // doesn't, that's a contradiction.
+ if (isDir != event.isDirectory) return null;
+
+ // Modify events don't contradict either CREATE or REMOVE events. We can
+ // safely assume the file was modified after a CREATE or before the
+ // REMOVE; otherwise there will also be a REMOVE or CREATE event
+ // (respectively) that will be contradictory.
+ if (event is FileSystemModifyEvent) {
+ hadModifyEvent = true;
+ continue;
+ }
+ assert(event is FileSystemCreateEvent || event is FileSystemDeleteEvent);
+
+ // If we previously thought this was a MODIFY, we now consider it to be a
+ // CREATE or REMOVE event. This is safe for the same reason as above.
+ if (type == FileSystemEvent.modify) {
+ type = event.type;
+ continue;
+ }
+
+ // A CREATE event contradicts a REMOVE event and vice versa.
+ assert(type == FileSystemEvent.create || type == FileSystemEvent.delete);
+ if (type != event.type) return null;
+ }
+
+ // If we got a CREATE event for a file we already knew about, that comes
+ // from FSEvents reporting an add that happened prior to the watch
+ // beginning. If we also received a MODIFY event, we want to report that,
+ // but not the CREATE.
+ if (type == FileSystemEvent.create &&
+ hadModifyEvent &&
+ _files.contains(batch.first.path)) {
+ type = FileSystemEvent.modify;
+ }
+
+ switch (type) {
+ case FileSystemEvent.create:
+ // Issue 16003 means that a CREATE event for a directory can indicate
+ // that the directory was moved and then re-created.
+ // [_eventsBasedOnFileSystem] will handle this correctly by producing a
+ // DELETE event followed by a CREATE event if the directory exists.
+ if (isDir) return null;
+ return FileSystemCreateEvent(batch.first.path, false);
+ case FileSystemEvent.delete:
+ return FileSystemDeleteEvent(batch.first.path, isDir);
+ case FileSystemEvent.modify:
+ return FileSystemModifyEvent(batch.first.path, isDir, false);
+ default:
+ throw StateError('unreachable');
+ }
+ }
+
+ /// Returns one or more events that describe the change between the last known
+ /// state of [path] and its current state on the filesystem.
+ ///
+ /// This returns a list whose order should be reflected in the events emitted
+ /// to the user, unlike the batched events from [Directory.watch]. The
+ /// returned list may be empty, indicating that no changes occurred to [path]
+ /// (probably indicating that it was created and then immediately deleted).
+ List<FileSystemEvent> _eventsBasedOnFileSystem(String path) {
+ var fileExisted = _files.contains(path);
+ var dirExisted = _files.containsDir(path);
+ var fileExists = File(path).existsSync();
+ var dirExists = Directory(path).existsSync();
+
+ var events = <FileSystemEvent>[];
+ if (fileExisted) {
+ if (fileExists) {
+ events.add(FileSystemModifyEvent(path, false, false));
+ } else {
+ events.add(FileSystemDeleteEvent(path, false));
+ }
+ } else if (dirExisted) {
+ if (dirExists) {
+ // If we got contradictory events for a directory that used to exist and
+ // still exists, we need to rescan the whole thing in case it was
+ // replaced with a different directory.
+ events.add(FileSystemDeleteEvent(path, true));
+ events.add(FileSystemCreateEvent(path, true));
+ } else {
+ events.add(FileSystemDeleteEvent(path, true));
+ }
+ }
+
+ if (!fileExisted && fileExists) {
+ events.add(FileSystemCreateEvent(path, false));
+ } else if (!dirExisted && dirExists) {
+ events.add(FileSystemCreateEvent(path, true));
+ }
+
+ return events;
+ }
+
+ /// The callback that's run when the [Directory.watch] stream is closed.
+ void _onDone() {
+ _watchSubscription = null;
+
+ // If the directory still exists and we're still expecting bogus events,
+ // this is probably issue 14849 rather than a real close event. We should
+ // just restart the watcher.
+ if (!isReady && Directory(path).existsSync()) {
+ _startWatch();
+ return;
+ }
+
+ // FSEvents can fail to report the contents of the directory being removed
+ // when the directory itself is removed, so we need to manually mark the
+ // files as removed.
+ for (var file in _files.paths) {
+ _emitEvent(ChangeType.REMOVE, file);
+ }
+ _files.clear();
+ close();
+ }
+
+ /// Start or restart the underlying [Directory.watch] stream.
+ void _startWatch() {
+ // Batch the FSEvent changes together so that we can dedup events.
+ var innerStream = Directory(path).watch(recursive: true).batchEvents();
+ _watchSubscription = innerStream.listen(_onBatch,
+ onError: _eventsController.addError, onDone: _onDone);
+ }
+
+ /// Starts or restarts listing the watched directory to get an initial picture
+ /// of its state.
+ Future<void> _listDir() {
+ assert(!isReady);
+ _initialListSubscription?.cancel();
+
+ _files.clear();
+ var completer = Completer<void>();
+ var stream = Directory(path).list(recursive: true);
+ _initialListSubscription = stream.listen((entity) {
+ if (entity is! Directory) _files.add(entity.path);
+ }, onError: _emitError, onDone: completer.complete, cancelOnError: true);
+ return completer.future;
+ }
+
+ /// Wait 200ms for a batch of bogus events (issue 14373) to come in.
+ ///
+ /// 200ms is short in terms of human interaction, but longer than any Mac OS
+ /// watcher tests take on the bots, so it should be safe to assume that any
+ /// bogus events will be signaled in that time frame.
+ Future<void> _waitForBogusEvents() {
+ var completer = Completer<void>();
+ _bogusEventTimer =
+ Timer(const Duration(milliseconds: 200), completer.complete);
+ return completer.future;
+ }
+
+ /// Emit an event with the given [type] and [path].
+ void _emitEvent(ChangeType type, String path) {
+ if (!isReady) return;
+ _eventsController.add(WatchEvent(type, path));
+ }
+
+ /// Emit an error, then close the watcher.
+ void _emitError(Object error, StackTrace stackTrace) {
+ // Guarantee that ready always completes.
+ if (!isReady) {
+ _readyCompleter.complete();
+ }
+ _eventsController.addError(error, stackTrace);
+ close();
+ }
+}
diff --git a/pkgs/watcher/lib/src/directory_watcher/polling.dart b/pkgs/watcher/lib/src/directory_watcher/polling.dart
new file mode 100644
index 0000000..207679b
--- /dev/null
+++ b/pkgs/watcher/lib/src/directory_watcher/polling.dart
@@ -0,0 +1,191 @@
+// Copyright (c) 2013, 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:io';
+
+import '../async_queue.dart';
+import '../directory_watcher.dart';
+import '../resubscribable.dart';
+import '../stat.dart';
+import '../utils.dart';
+import '../watch_event.dart';
+
+/// Periodically polls a directory for changes.
+class PollingDirectoryWatcher extends ResubscribableWatcher
+ implements DirectoryWatcher {
+ @override
+ String get directory => path;
+
+ /// Creates a new polling watcher monitoring [directory].
+ ///
+ /// 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.
+ PollingDirectoryWatcher(String directory, {Duration? pollingDelay})
+ : super(directory, () {
+ return _PollingDirectoryWatcher(
+ directory, pollingDelay ?? const Duration(seconds: 1));
+ });
+}
+
+class _PollingDirectoryWatcher
+ implements DirectoryWatcher, ManuallyClosedWatcher {
+ @override
+ String get directory => path;
+ @override
+ final String path;
+
+ @override
+ Stream<WatchEvent> get events => _events.stream;
+ final _events = StreamController<WatchEvent>.broadcast();
+
+ @override
+ bool get isReady => _readyCompleter.isCompleted;
+
+ @override
+ Future<void> get ready => _readyCompleter.future;
+ final _readyCompleter = Completer<void>();
+
+ /// The amount of time the watcher pauses between successive polls of the
+ /// directory contents.
+ final Duration _pollingDelay;
+
+ /// The previous modification times of the files in the directory.
+ ///
+ /// Used to tell which files have been modified.
+ final _lastModifieds = <String, DateTime?>{};
+
+ /// The subscription used while [directory] is being listed.
+ ///
+ /// Will be `null` if a list is not currently happening.
+ StreamSubscription<FileSystemEntity>? _listSubscription;
+
+ /// The queue of files waiting to be processed to see if they have been
+ /// modified.
+ ///
+ /// Processing a file is asynchronous, as is listing the directory, so the
+ /// queue exists to let each of those proceed at their own rate. The lister
+ /// will enqueue files as quickly as it can. Meanwhile, files are dequeued
+ /// and processed sequentially.
+ late final AsyncQueue<String?> _filesToProcess =
+ AsyncQueue<String?>(_processFile, onError: (error, stackTrace) {
+ if (!_events.isClosed) _events.addError(error, stackTrace);
+ });
+
+ /// The set of files that have been seen in the current directory listing.
+ ///
+ /// Used to tell which files have been removed: files that are in
+ /// [_lastModifieds] but not in here when a poll completes have been removed.
+ final _polledFiles = <String>{};
+
+ _PollingDirectoryWatcher(this.path, this._pollingDelay) {
+ _poll();
+ }
+
+ @override
+ void close() {
+ _events.close();
+
+ // If we're in the middle of listing the directory, stop.
+ _listSubscription?.cancel();
+
+ // Don't process any remaining files.
+ _filesToProcess.clear();
+ _polledFiles.clear();
+ _lastModifieds.clear();
+ }
+
+ /// Scans the contents of the directory once to see which files have been
+ /// added, removed, and modified.
+ void _poll() {
+ _filesToProcess.clear();
+ _polledFiles.clear();
+
+ void endListing() {
+ assert(!_events.isClosed);
+ _listSubscription = null;
+
+ // Null tells the queue consumer that we're done listing.
+ _filesToProcess.add(null);
+ }
+
+ var stream = Directory(path).list(recursive: true);
+ _listSubscription = stream.listen((entity) {
+ assert(!_events.isClosed);
+
+ if (entity is! File) return;
+ _filesToProcess.add(entity.path);
+ }, onError: (Object error, StackTrace stackTrace) {
+ // Guarantee that ready always completes.
+ if (!isReady) {
+ _readyCompleter.complete();
+ }
+ if (!isDirectoryNotFoundException(error)) {
+ // It's some unknown error. Pipe it over to the event stream so the
+ // user can see it.
+ _events.addError(error, stackTrace);
+ }
+
+ // When an error occurs, we end the listing normally, which has the
+ // desired effect of marking all files that were in the directory as
+ // being removed.
+ endListing();
+ }, onDone: endListing, cancelOnError: true);
+ }
+
+ /// Processes [file] to determine if it has been modified since the last
+ /// time it was scanned.
+ Future<void> _processFile(String? file) async {
+ // `null` is the sentinel which means the directory listing is complete.
+ if (file == null) {
+ await _completePoll();
+ return;
+ }
+
+ final modified = await modificationTime(file);
+
+ if (_events.isClosed) return;
+
+ var lastModified = _lastModifieds[file];
+
+ // If its modification time hasn't changed, assume the file is unchanged.
+ if (lastModified != null && lastModified == modified) {
+ // The file is still here.
+ _polledFiles.add(file);
+ return;
+ }
+
+ if (_events.isClosed) return;
+
+ _lastModifieds[file] = modified;
+ _polledFiles.add(file);
+
+ // Only notify if we're ready to emit events.
+ if (!isReady) return;
+
+ var type = lastModified == null ? ChangeType.ADD : ChangeType.MODIFY;
+ _events.add(WatchEvent(type, file));
+ }
+
+ /// After the directory listing is complete, this determines which files were
+ /// removed and then restarts the next poll.
+ Future<void> _completePoll() async {
+ // Any files that were not seen in the last poll but that we have a
+ // status for must have been removed.
+ var removedFiles = _lastModifieds.keys.toSet().difference(_polledFiles);
+ for (var removed in removedFiles) {
+ if (isReady) _events.add(WatchEvent(ChangeType.REMOVE, removed));
+ _lastModifieds.remove(removed);
+ }
+
+ if (!isReady) _readyCompleter.complete();
+
+ // Wait and then poll again.
+ await Future<void>.delayed(_pollingDelay);
+ if (_events.isClosed) return;
+ _poll();
+ }
+}
diff --git a/pkgs/watcher/lib/src/directory_watcher/windows.dart b/pkgs/watcher/lib/src/directory_watcher/windows.dart
new file mode 100644
index 0000000..d1c98be
--- /dev/null
+++ b/pkgs/watcher/lib/src/directory_watcher/windows.dart
@@ -0,0 +1,437 @@
+// Copyright (c) 2014, 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.
+// TODO(rnystrom): Merge with mac_os version.
+
+import 'dart:async';
+import 'dart:collection';
+import 'dart:io';
+
+import 'package:path/path.dart' as p;
+
+import '../directory_watcher.dart';
+import '../path_set.dart';
+import '../resubscribable.dart';
+import '../utils.dart';
+import '../watch_event.dart';
+
+class WindowsDirectoryWatcher extends ResubscribableWatcher
+ implements DirectoryWatcher {
+ @override
+ String get directory => path;
+
+ WindowsDirectoryWatcher(String directory)
+ : super(directory, () => _WindowsDirectoryWatcher(directory));
+}
+
+class _EventBatcher {
+ static const Duration _batchDelay = Duration(milliseconds: 100);
+ final List<FileSystemEvent> events = [];
+ Timer? timer;
+
+ void addEvent(FileSystemEvent event, void Function() callback) {
+ events.add(event);
+ timer?.cancel();
+ timer = Timer(_batchDelay, callback);
+ }
+
+ void cancelTimer() {
+ timer?.cancel();
+ }
+}
+
+class _WindowsDirectoryWatcher
+ implements DirectoryWatcher, ManuallyClosedWatcher {
+ @override
+ String get directory => path;
+ @override
+ final String path;
+
+ @override
+ Stream<WatchEvent> get events => _eventsController.stream;
+ final _eventsController = StreamController<WatchEvent>.broadcast();
+
+ @override
+ bool get isReady => _readyCompleter.isCompleted;
+
+ @override
+ Future<void> get ready => _readyCompleter.future;
+ final _readyCompleter = Completer<void>();
+
+ final Map<String, _EventBatcher> _eventBatchers =
+ HashMap<String, _EventBatcher>();
+
+ /// The set of files that are known to exist recursively within the watched
+ /// directory.
+ ///
+ /// The state of files on the filesystem is compared against this to determine
+ /// the real change that occurred. This is also used to emit REMOVE events
+ /// when subdirectories are moved out of the watched directory.
+ final PathSet _files;
+
+ /// The subscription to the stream returned by [Directory.watch].
+ StreamSubscription<FileSystemEvent>? _watchSubscription;
+
+ /// The subscription to the stream returned by [Directory.watch] of the
+ /// parent directory to [directory]. This is needed to detect changes to
+ /// [directory], as they are not included on Windows.
+ StreamSubscription<FileSystemEvent>? _parentWatchSubscription;
+
+ /// The subscription to the [Directory.list] call for the initial listing of
+ /// the directory to determine its initial state.
+ StreamSubscription<FileSystemEntity>? _initialListSubscription;
+
+ /// The subscriptions to the [Directory.list] calls for listing the contents
+ /// of subdirectories that were moved into the watched directory.
+ final Set<StreamSubscription<FileSystemEntity>> _listSubscriptions =
+ HashSet<StreamSubscription<FileSystemEntity>>();
+
+ _WindowsDirectoryWatcher(this.path) : _files = PathSet(path) {
+ // Before we're ready to emit events, wait for [_listDir] to complete.
+ _listDir().then((_) {
+ _startWatch();
+ _startParentWatcher();
+ if (!isReady) {
+ _readyCompleter.complete();
+ }
+ });
+ }
+
+ @override
+ void close() {
+ _watchSubscription?.cancel();
+ _parentWatchSubscription?.cancel();
+ _initialListSubscription?.cancel();
+ for (var sub in _listSubscriptions) {
+ sub.cancel();
+ }
+ _listSubscriptions.clear();
+ for (var batcher in _eventBatchers.values) {
+ batcher.cancelTimer();
+ }
+ _eventBatchers.clear();
+ _watchSubscription = null;
+ _parentWatchSubscription = null;
+ _initialListSubscription = null;
+ _eventsController.close();
+ }
+
+ /// On Windows, if [directory] is deleted, we will not receive any event.
+ ///
+ /// Instead, we add a watcher on the parent folder (if any), that can notify
+ /// us about [path]. This also includes events such as moves.
+ void _startParentWatcher() {
+ var absoluteDir = p.absolute(path);
+ var parent = p.dirname(absoluteDir);
+ // Check if [path] is already the root directory.
+ if (FileSystemEntity.identicalSync(parent, path)) return;
+ var parentStream = Directory(parent).watch(recursive: false);
+ _parentWatchSubscription = parentStream.listen((event) {
+ // Only look at events for 'directory'.
+ if (p.basename(event.path) != p.basename(absoluteDir)) return;
+ // Test if the directory is removed. FileSystemEntity.typeSync will
+ // return NOT_FOUND if it's unable to decide upon the type, including
+ // access denied issues, which may happen when the directory is deleted.
+ // FileSystemMoveEvent and FileSystemDeleteEvent events will always mean
+ // the directory is now gone.
+ if (event is FileSystemMoveEvent ||
+ event is FileSystemDeleteEvent ||
+ (FileSystemEntity.typeSync(path) == FileSystemEntityType.notFound)) {
+ for (var path in _files.paths) {
+ _emitEvent(ChangeType.REMOVE, path);
+ }
+ _files.clear();
+ close();
+ }
+ }, onError: (error) {
+ // Ignore errors, simply close the stream. The user listens on
+ // [directory], and while it can fail to listen on the parent, we may
+ // still be able to listen on the path requested.
+ _parentWatchSubscription?.cancel();
+ _parentWatchSubscription = null;
+ });
+ }
+
+ void _onEvent(FileSystemEvent event) {
+ assert(isReady);
+ final batcher = _eventBatchers.putIfAbsent(event.path, _EventBatcher.new);
+ batcher.addEvent(event, () {
+ _eventBatchers.remove(event.path);
+ _onBatch(batcher.events);
+ });
+ }
+
+ /// The callback that's run when [Directory.watch] emits a batch of events.
+ void _onBatch(List<FileSystemEvent> batch) {
+ _sortEvents(batch).forEach((path, eventSet) {
+ var canonicalEvent = _canonicalEvent(eventSet);
+ var events = canonicalEvent == null
+ ? _eventsBasedOnFileSystem(path)
+ : [canonicalEvent];
+
+ for (var event in events) {
+ if (event is FileSystemCreateEvent) {
+ if (!event.isDirectory) {
+ if (_files.contains(path)) continue;
+
+ _emitEvent(ChangeType.ADD, path);
+ _files.add(path);
+ continue;
+ }
+
+ if (_files.containsDir(path)) continue;
+
+ var stream = Directory(path).list(recursive: true);
+ var subscription = stream.listen((entity) {
+ if (entity is Directory) return;
+ if (_files.contains(path)) return;
+
+ _emitEvent(ChangeType.ADD, entity.path);
+ _files.add(entity.path);
+ }, cancelOnError: true);
+ subscription.onDone(() {
+ _listSubscriptions.remove(subscription);
+ });
+ subscription.onError((Object e, StackTrace stackTrace) {
+ _listSubscriptions.remove(subscription);
+ _emitError(e, stackTrace);
+ });
+ _listSubscriptions.add(subscription);
+ } else if (event is FileSystemModifyEvent) {
+ if (!event.isDirectory) {
+ _emitEvent(ChangeType.MODIFY, path);
+ }
+ } else {
+ assert(event is FileSystemDeleteEvent);
+ for (var removedPath in _files.remove(path)) {
+ _emitEvent(ChangeType.REMOVE, removedPath);
+ }
+ }
+ }
+ });
+ }
+
+ /// Sort all the events in a batch into sets based on their path.
+ ///
+ /// A single input event may result in multiple events in the returned map;
+ /// for example, a MOVE event becomes a DELETE event for the source and a
+ /// CREATE event for the destination.
+ ///
+ /// The returned events won't contain any [FileSystemMoveEvent]s, nor will it
+ /// contain any events relating to [path].
+ Map<String, Set<FileSystemEvent>> _sortEvents(List<FileSystemEvent> batch) {
+ var eventsForPaths = <String, Set<FileSystemEvent>>{};
+
+ // Events within directories that already have events are superfluous; the
+ // directory's full contents will be examined anyway, so we ignore such
+ // events. Emitting them could cause useless or out-of-order events.
+ var directories = unionAll(batch.map((event) {
+ if (!event.isDirectory) return <String>{};
+ if (event is FileSystemMoveEvent) {
+ var destination = event.destination;
+ if (destination != null) {
+ return {event.path, destination};
+ }
+ }
+ return {event.path};
+ }));
+
+ bool isInModifiedDirectory(String path) =>
+ directories.any((dir) => path != dir && p.isWithin(dir, path));
+
+ void addEvent(String path, FileSystemEvent event) {
+ if (isInModifiedDirectory(path)) return;
+ eventsForPaths.putIfAbsent(path, () => <FileSystemEvent>{}).add(event);
+ }
+
+ for (var event in batch) {
+ if (event is FileSystemMoveEvent) {
+ var destination = event.destination;
+ if (destination != null) {
+ addEvent(destination, event);
+ }
+ }
+ addEvent(event.path, event);
+ }
+
+ return eventsForPaths;
+ }
+
+ /// Returns the canonical event from a batch of events on the same path, if
+ /// one exists.
+ ///
+ /// If [batch] doesn't contain any contradictory events (e.g. DELETE and
+ /// CREATE, or events with different values for `isDirectory`), this returns a
+ /// single event that describes what happened to the path in question.
+ ///
+ /// If [batch] does contain contradictory events, this returns `null` to
+ /// indicate that the state of the path on the filesystem should be checked to
+ /// determine what occurred.
+ FileSystemEvent? _canonicalEvent(Set<FileSystemEvent> batch) {
+ // An empty batch indicates that we've learned earlier that the batch is
+ // contradictory (e.g. because of a move).
+ if (batch.isEmpty) return null;
+
+ var type = batch.first.type;
+ var isDir = batch.first.isDirectory;
+
+ for (var event in batch.skip(1)) {
+ // If one event reports that the file is a directory and another event
+ // doesn't, that's a contradiction.
+ if (isDir != event.isDirectory) return null;
+
+ // Modify events don't contradict either CREATE or REMOVE events. We can
+ // safely assume the file was modified after a CREATE or before the
+ // REMOVE; otherwise there will also be a REMOVE or CREATE event
+ // (respectively) that will be contradictory.
+ if (event is FileSystemModifyEvent) continue;
+ assert(event is FileSystemCreateEvent ||
+ event is FileSystemDeleteEvent ||
+ event is FileSystemMoveEvent);
+
+ // If we previously thought this was a MODIFY, we now consider it to be a
+ // CREATE or REMOVE event. This is safe for the same reason as above.
+ if (type == FileSystemEvent.modify) {
+ type = event.type;
+ continue;
+ }
+
+ // A CREATE event contradicts a REMOVE event and vice versa.
+ assert(type == FileSystemEvent.create ||
+ type == FileSystemEvent.delete ||
+ type == FileSystemEvent.move);
+ if (type != event.type) return null;
+ }
+
+ switch (type) {
+ case FileSystemEvent.create:
+ return FileSystemCreateEvent(batch.first.path, isDir);
+ case FileSystemEvent.delete:
+ return FileSystemDeleteEvent(batch.first.path, isDir);
+ case FileSystemEvent.modify:
+ return FileSystemModifyEvent(batch.first.path, isDir, false);
+ case FileSystemEvent.move:
+ return null;
+ default:
+ throw StateError('unreachable');
+ }
+ }
+
+ /// Returns zero or more events that describe the change between the last
+ /// known state of [path] and its current state on the filesystem.
+ ///
+ /// This returns a list whose order should be reflected in the events emitted
+ /// to the user, unlike the batched events from [Directory.watch]. The
+ /// returned list may be empty, indicating that no changes occurred to [path]
+ /// (probably indicating that it was created and then immediately deleted).
+ List<FileSystemEvent> _eventsBasedOnFileSystem(String path) {
+ var fileExisted = _files.contains(path);
+ var dirExisted = _files.containsDir(path);
+
+ bool fileExists;
+ bool dirExists;
+ try {
+ fileExists = File(path).existsSync();
+ dirExists = Directory(path).existsSync();
+ } on FileSystemException {
+ return const <FileSystemEvent>[];
+ }
+
+ var events = <FileSystemEvent>[];
+ if (fileExisted) {
+ if (fileExists) {
+ events.add(FileSystemModifyEvent(path, false, false));
+ } else {
+ events.add(FileSystemDeleteEvent(path, false));
+ }
+ } else if (dirExisted) {
+ if (dirExists) {
+ // If we got contradictory events for a directory that used to exist and
+ // still exists, we need to rescan the whole thing in case it was
+ // replaced with a different directory.
+ events.add(FileSystemDeleteEvent(path, true));
+ events.add(FileSystemCreateEvent(path, true));
+ } else {
+ events.add(FileSystemDeleteEvent(path, true));
+ }
+ }
+
+ if (!fileExisted && fileExists) {
+ events.add(FileSystemCreateEvent(path, false));
+ } else if (!dirExisted && dirExists) {
+ events.add(FileSystemCreateEvent(path, true));
+ }
+
+ return events;
+ }
+
+ /// The callback that's run when the [Directory.watch] stream is closed.
+ /// Note that this is unlikely to happen on Windows, unless the system itself
+ /// closes the handle.
+ void _onDone() {
+ _watchSubscription = null;
+
+ // Emit remove events for any remaining files.
+ for (var file in _files.paths) {
+ _emitEvent(ChangeType.REMOVE, file);
+ }
+ _files.clear();
+ close();
+ }
+
+ /// Start or restart the underlying [Directory.watch] stream.
+ void _startWatch() {
+ // Note: "watcher closed" exceptions do not get sent over the stream
+ // returned by watch, and must be caught via a zone handler.
+ runZonedGuarded(() {
+ var innerStream = Directory(path).watch(recursive: true);
+ _watchSubscription = innerStream.listen(_onEvent,
+ onError: _eventsController.addError, onDone: _onDone);
+ }, (error, stackTrace) {
+ if (error is FileSystemException &&
+ error.message.startsWith('Directory watcher closed unexpectedly')) {
+ _watchSubscription?.cancel();
+ _eventsController.addError(error, stackTrace);
+ _startWatch();
+ } else {
+ // ignore: only_throw_errors
+ throw error;
+ }
+ });
+ }
+
+ /// Starts or restarts listing the watched directory to get an initial picture
+ /// of its state.
+ Future<void> _listDir() {
+ assert(!isReady);
+ _initialListSubscription?.cancel();
+
+ _files.clear();
+ var completer = Completer<void>();
+ var stream = Directory(path).list(recursive: true);
+ void handleEntity(FileSystemEntity entity) {
+ if (entity is! Directory) _files.add(entity.path);
+ }
+
+ _initialListSubscription = stream.listen(handleEntity,
+ onError: _emitError, onDone: completer.complete, cancelOnError: true);
+ return completer.future;
+ }
+
+ /// Emit an event with the given [type] and [path].
+ void _emitEvent(ChangeType type, String path) {
+ if (!isReady) return;
+
+ _eventsController.add(WatchEvent(type, path));
+ }
+
+ /// Emit an error, then close the watcher.
+ void _emitError(Object error, StackTrace stackTrace) {
+ // Guarantee that ready always completes.
+ if (!isReady) {
+ _readyCompleter.complete();
+ }
+ _eventsController.addError(error, stackTrace);
+ close();
+ }
+}
diff --git a/pkgs/watcher/lib/src/file_watcher.dart b/pkgs/watcher/lib/src/file_watcher.dart
new file mode 100644
index 0000000..143aa31
--- /dev/null
+++ b/pkgs/watcher/lib/src/file_watcher.dart
@@ -0,0 +1,44 @@
+// 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 'dart:io';
+
+import '../watcher.dart';
+import 'custom_watcher_factory.dart';
+import 'file_watcher/native.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 [ChangeType.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}) {
+ var customWatcher =
+ createCustomFileWatcher(file, pollingDelay: pollingDelay);
+ if (customWatcher != null) return customWatcher;
+
+ // [File.watch] doesn't work on Windows, but
+ // [FileSystemEntity.isWatchSupported] is still true because directory
+ // watching does work.
+ if (FileSystemEntity.isWatchSupported && !Platform.isWindows) {
+ return NativeFileWatcher(file);
+ }
+ return 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..502aa10
--- /dev/null
+++ b/pkgs/watcher/lib/src/file_watcher/native.dart
@@ -0,0 +1,90 @@
+// 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 '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, () => _NativeFileWatcher(path));
+}
+
+class _NativeFileWatcher implements FileWatcher, ManuallyClosedWatcher {
+ @override
+ final String path;
+
+ @override
+ Stream<WatchEvent> get events => _eventsController.stream;
+ final _eventsController = StreamController<WatchEvent>.broadcast();
+
+ @override
+ bool get isReady => _readyCompleter.isCompleted;
+
+ @override
+ Future<void> get ready => _readyCompleter.future;
+ final _readyCompleter = Completer<void>();
+
+ StreamSubscription<List<FileSystemEvent>>? _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 = File(path)
+ .watch()
+ .batchEvents()
+ .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(WatchEvent(ChangeType.MODIFY, path));
+ }
+
+ void _onDone() async {
+ var fileExists = await File(path).exists();
+
+ // Check for this after checking whether the file exists because it's
+ // possible that [close] was called between [File.exists] being called and
+ // it completing.
+ if (_eventsController.isClosed) return;
+
+ if (fileExists) {
+ // 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.
+ _eventsController.add(WatchEvent(ChangeType.MODIFY, path));
+ _listen();
+ } else {
+ _eventsController.add(WatchEvent(ChangeType.REMOVE, path));
+ close();
+ }
+ }
+
+ @override
+ void close() {
+ _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..15ff9ab
--- /dev/null
+++ b/pkgs/watcher/lib/src/file_watcher/polling.dart
@@ -0,0 +1,106 @@
+// 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 '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 _PollingFileWatcher(
+ path, pollingDelay ?? const Duration(seconds: 1));
+ });
+}
+
+class _PollingFileWatcher implements FileWatcher, ManuallyClosedWatcher {
+ @override
+ final String path;
+
+ @override
+ Stream<WatchEvent> get events => _eventsController.stream;
+ final _eventsController = StreamController<WatchEvent>.broadcast();
+
+ @override
+ bool get isReady => _readyCompleter.isCompleted;
+
+ @override
+ Future<void> get ready => _readyCompleter.future;
+ final _readyCompleter = Completer<void>();
+
+ /// The timer that controls polling.
+ late final Timer _timer;
+
+ /// The previous modification time of the file.
+ ///
+ /// `null` indicates the file does not (or did not on the last poll) exist.
+ DateTime? _lastModified;
+
+ _PollingFileWatcher(this.path, Duration pollingDelay) {
+ _timer = Timer.periodic(pollingDelay, (_) => _poll());
+ _poll();
+ }
+
+ /// Checks the mtime of the file and whether it's been removed.
+ Future<void> _poll() async {
+ // We don't mark the file as removed if this is the first poll. Instead,
+ // below we forward the dart:io error that comes from trying to read the
+ // mtime below.
+ var pathExists = await File(path).exists();
+ if (_eventsController.isClosed) return;
+
+ if (_lastModified != null && !pathExists) {
+ _flagReady();
+ _eventsController.add(WatchEvent(ChangeType.REMOVE, path));
+ unawaited(close());
+ return;
+ }
+
+ DateTime? modified;
+ try {
+ modified = await modificationTime(path);
+ } on FileSystemException catch (error, stackTrace) {
+ if (!_eventsController.isClosed) {
+ _flagReady();
+ _eventsController.addError(error, stackTrace);
+ await close();
+ }
+ }
+ if (_eventsController.isClosed) {
+ _flagReady();
+ return;
+ }
+
+ if (!isReady) {
+ // If this is the first poll, don't emit an event, just set the last mtime
+ // and complete the completer.
+ _lastModified = modified;
+ _flagReady();
+ return;
+ }
+
+ if (_lastModified == modified) return;
+
+ _lastModified = modified;
+ _eventsController.add(WatchEvent(ChangeType.MODIFY, path));
+ }
+
+ /// Flags this watcher as ready if it has not already been done.
+ void _flagReady() {
+ if (!isReady) {
+ _readyCompleter.complete();
+ }
+ }
+
+ @override
+ Future<void> close() async {
+ _timer.cancel();
+ await _eventsController.close();
+ }
+}
diff --git a/pkgs/watcher/lib/src/path_set.dart b/pkgs/watcher/lib/src/path_set.dart
new file mode 100644
index 0000000..4f41cf9
--- /dev/null
+++ b/pkgs/watcher/lib/src/path_set.dart
@@ -0,0 +1,190 @@
+// Copyright (c) 2013, 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:path/path.dart' as p;
+
+/// A set of paths, organized into a directory hierarchy.
+///
+/// When a path is [add]ed, it creates an implicit directory structure above
+/// that path. Directories can be inspected using [containsDir] and removed
+/// using [remove]. If they're removed, their contents are removed as well.
+///
+/// The paths in the set are normalized so that they all begin with [root].
+class PathSet {
+ /// The root path, which all paths in the set must be under.
+ final String root;
+
+ /// The path set's directory hierarchy.
+ ///
+ /// Each entry represents a directory or file. It may be a file or directory
+ /// that was explicitly added, or a parent directory that was implicitly
+ /// added in order to add a child.
+ final _Entry _entries = _Entry();
+
+ PathSet(this.root);
+
+ /// Adds [path] to the set.
+ void add(String path) {
+ path = _normalize(path);
+
+ var parts = p.split(path);
+ var entry = _entries;
+ for (var part in parts) {
+ entry = entry.contents.putIfAbsent(part, _Entry.new);
+ }
+
+ entry.isExplicit = true;
+ }
+
+ /// Removes [path] and any paths beneath it from the set and returns the
+ /// removed paths.
+ ///
+ /// Even if [path] itself isn't in the set, if it's a directory containing
+ /// paths that are in the set those paths will be removed and returned.
+ ///
+ /// If neither [path] nor any paths beneath it are in the set, returns an
+ /// empty set.
+ Set<String> remove(String path) {
+ path = _normalize(path);
+ var parts = Queue.of(p.split(path));
+
+ // Remove the children of [dir], as well as [dir] itself if necessary.
+ //
+ // [partialPath] is the path to [dir], and a prefix of [path]; the remaining
+ // components of [path] are in [parts].
+ Set<String> recurse(_Entry dir, String partialPath) {
+ if (parts.length > 1) {
+ // If there's more than one component left in [path], recurse down to
+ // the next level.
+ var part = parts.removeFirst();
+ var entry = dir.contents[part];
+ if (entry == null || entry.contents.isEmpty) return <String>{};
+
+ partialPath = p.join(partialPath, part);
+ var paths = recurse(entry, partialPath);
+ // After removing this entry's children, if it has no more children and
+ // it's not in the set in its own right, remove it as well.
+ if (entry.contents.isEmpty && !entry.isExplicit) {
+ dir.contents.remove(part);
+ }
+ return paths;
+ }
+
+ // If there's only one component left in [path], we should remove it.
+ var entry = dir.contents.remove(parts.first);
+ if (entry == null) return <String>{};
+
+ if (entry.contents.isEmpty) {
+ return {p.join(root, path)};
+ }
+
+ var set = _explicitPathsWithin(entry, path);
+ if (entry.isExplicit) {
+ set.add(p.join(root, path));
+ }
+
+ return set;
+ }
+
+ return recurse(_entries, root);
+ }
+
+ /// Recursively lists all of the explicit paths within [dir].
+ ///
+ /// [dirPath] should be the path to [dir].
+ Set<String> _explicitPathsWithin(_Entry dir, String dirPath) {
+ var paths = <String>{};
+ void recurse(_Entry dir, String path) {
+ dir.contents.forEach((name, entry) {
+ var entryPath = p.join(path, name);
+ if (entry.isExplicit) paths.add(p.join(root, entryPath));
+
+ recurse(entry, entryPath);
+ });
+ }
+
+ recurse(dir, dirPath);
+ return paths;
+ }
+
+ /// Returns whether this set contains [path].
+ ///
+ /// This only returns true for paths explicitly added to this set.
+ /// Implicitly-added directories can be inspected using [containsDir].
+ bool contains(String path) {
+ path = _normalize(path);
+ var entry = _entries;
+
+ for (var part in p.split(path)) {
+ var child = entry.contents[part];
+ if (child == null) return false;
+ entry = child;
+ }
+
+ return entry.isExplicit;
+ }
+
+ /// Returns whether this set contains paths beneath [path].
+ bool containsDir(String path) {
+ path = _normalize(path);
+ var entry = _entries;
+
+ for (var part in p.split(path)) {
+ var child = entry.contents[part];
+ if (child == null) return false;
+ entry = child;
+ }
+
+ return entry.contents.isNotEmpty;
+ }
+
+ /// All of the paths explicitly added to this set.
+ List<String> get paths {
+ var result = <String>[];
+
+ void recurse(_Entry dir, String path) {
+ for (var mapEntry in dir.contents.entries) {
+ var entry = mapEntry.value;
+ var entryPath = p.join(path, mapEntry.key);
+ if (entry.isExplicit) result.add(entryPath);
+ recurse(entry, entryPath);
+ }
+ }
+
+ recurse(_entries, root);
+ return result;
+ }
+
+ /// Removes all paths from this set.
+ void clear() {
+ _entries.contents.clear();
+ }
+
+ /// Returns a normalized version of [path].
+ ///
+ /// This removes any extra ".." or "."s and ensure that the returned path
+ /// begins with [root]. It's an error if [path] isn't within [root].
+ String _normalize(String path) {
+ assert(p.isWithin(root, path));
+
+ return p.relative(p.normalize(path), from: root);
+ }
+}
+
+/// A virtual file system entity tracked by the [PathSet].
+///
+/// It may have child entries in [contents], which implies it's a directory.
+class _Entry {
+ /// The child entries contained in this directory.
+ final Map<String, _Entry> contents = {};
+
+ /// If this entry was explicitly added as a leaf file system entity, this
+ /// will be true.
+ ///
+ /// Otherwise, it represents a parent directory that was implicitly added
+ /// when added some child of it.
+ bool isExplicit = false;
+}
diff --git a/pkgs/watcher/lib/src/resubscribable.dart b/pkgs/watcher/lib/src/resubscribable.dart
new file mode 100644
index 0000000..b99e9d7
--- /dev/null
+++ b/pkgs/watcher/lib/src/resubscribable.dart
@@ -0,0 +1,79 @@
+// Copyright (c) 2013, 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 '../watcher.dart';
+
+/// A wrapper for [ManuallyClosedWatcher] that encapsulates support for closing
+/// the watcher when it has no subscribers and re-opening it when it's
+/// re-subscribed.
+///
+/// It's simpler to implement watchers without worrying about this behavior.
+/// This class wraps a watcher class which can be written with the simplifying
+/// assumption that it can continue emitting events until an explicit `close`
+/// method is called, at which point it will cease emitting events entirely. The
+/// [ManuallyClosedWatcher] interface is used for these watchers.
+///
+/// This would be more cleanly implemented as a function that takes a class and
+/// emits a new class, but Dart doesn't support that sort of thing. Instead it
+/// takes a factory function that produces instances of the inner class.
+abstract class ResubscribableWatcher implements Watcher {
+ /// The factory function that produces instances of the inner class.
+ final ManuallyClosedWatcher Function() _factory;
+
+ @override
+ final String path;
+
+ @override
+ Stream<WatchEvent> get events => _eventsController.stream;
+ late StreamController<WatchEvent> _eventsController;
+
+ @override
+ bool get isReady => _readyCompleter.isCompleted;
+
+ @override
+ Future<void> get ready => _readyCompleter.future;
+ var _readyCompleter = Completer<void>();
+
+ /// Creates a new [ResubscribableWatcher] wrapping the watchers
+ /// emitted by [_factory].
+ ResubscribableWatcher(this.path, this._factory) {
+ late ManuallyClosedWatcher watcher;
+ late StreamSubscription<WatchEvent> subscription;
+
+ _eventsController = StreamController<WatchEvent>.broadcast(
+ onListen: () async {
+ watcher = _factory();
+ subscription = watcher.events.listen(_eventsController.add,
+ onError: _eventsController.addError,
+ onDone: _eventsController.close);
+
+ // It's important that we complete the value of [_readyCompleter] at
+ // the time [onListen] is called, as opposed to the value when
+ // [watcher.ready] fires. A new completer may be created by that time.
+ await watcher.ready;
+ _readyCompleter.complete();
+ },
+ onCancel: () {
+ // Cancel the subscription before closing the watcher so that the
+ // watcher's `onDone` event doesn't close [events].
+ subscription.cancel();
+ watcher.close();
+ _readyCompleter = Completer();
+ },
+ sync: true);
+ }
+}
+
+/// An interface for watchers with an explicit, manual [close] method.
+///
+/// See [ResubscribableWatcher].
+abstract class ManuallyClosedWatcher implements Watcher {
+ /// Closes the watcher.
+ ///
+ /// Subclasses should close their [events] stream and release any internal
+ /// resources.
+ void close();
+}
diff --git a/pkgs/watcher/lib/src/stat.dart b/pkgs/watcher/lib/src/stat.dart
new file mode 100644
index 0000000..fe0f155
--- /dev/null
+++ b/pkgs/watcher/lib/src/stat.dart
@@ -0,0 +1,34 @@
+// Copyright (c) 2013, 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:io';
+
+/// A function that takes a file path and returns the last modified time for
+/// the file at that path.
+typedef MockTimeCallback = DateTime? Function(String path);
+
+MockTimeCallback? _mockTimeCallback;
+
+/// Overrides the default behavior for accessing a file's modification time
+/// with [callback].
+///
+/// The OS file modification time has pretty rough granularity (like a few
+/// seconds) which can make for slow tests that rely on modtime. This lets you
+/// replace it with something you control.
+void mockGetModificationTime(MockTimeCallback callback) {
+ _mockTimeCallback = callback;
+}
+
+/// Gets the modification time for the file at [path].
+/// Completes with `null` if the file does not exist.
+Future<DateTime?> modificationTime(String path) async {
+ var mockTimeCallback = _mockTimeCallback;
+ if (mockTimeCallback != null) {
+ return mockTimeCallback(path);
+ }
+
+ final stat = await FileStat.stat(path);
+ if (stat.type == FileSystemEntityType.notFound) return null;
+ return stat.modified;
+}
diff --git a/pkgs/watcher/lib/src/utils.dart b/pkgs/watcher/lib/src/utils.dart
new file mode 100644
index 0000000..c2e71b3
--- /dev/null
+++ b/pkgs/watcher/lib/src/utils.dart
@@ -0,0 +1,52 @@
+// Copyright (c) 2013, 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';
+import 'dart:io';
+
+/// Returns `true` if [error] is a [FileSystemException] for a missing
+/// directory.
+bool isDirectoryNotFoundException(Object error) {
+ if (error is! FileSystemException) return false;
+
+ // See dartbug.com/12461 and tests/standalone/io/directory_error_test.dart.
+ var notFoundCode = Platform.operatingSystem == 'windows' ? 3 : 2;
+ return error.osError?.errorCode == notFoundCode;
+}
+
+/// Returns the union of all elements in each set in [sets].
+Set<T> unionAll<T>(Iterable<Set<T>> sets) =>
+ sets.fold(<T>{}, (union, set) => union.union(set));
+
+extension BatchEvents<T> on Stream<T> {
+ /// Batches all events that are sent at the same time.
+ ///
+ /// When multiple events are synchronously added to a stream controller, the
+ /// [StreamController] implementation uses [scheduleMicrotask] to schedule the
+ /// asynchronous firing of each event. In order to recreate the synchronous
+ /// batches, this collates all the events that are received in "nearby"
+ /// microtasks.
+ Stream<List<T>> batchEvents() {
+ var batch = Queue<T>();
+ return StreamTransformer<T, List<T>>.fromHandlers(
+ handleData: (event, sink) {
+ batch.add(event);
+
+ // [Timer.run] schedules an event that runs after any microtasks that have
+ // been scheduled.
+ Timer.run(() {
+ if (batch.isEmpty) return;
+ sink.add(batch.toList());
+ batch.clear();
+ });
+ }, handleDone: (sink) {
+ if (batch.isNotEmpty) {
+ sink.add(batch.toList());
+ batch.clear();
+ }
+ sink.close();
+ }).bind(this);
+ }
+}
diff --git a/pkgs/watcher/lib/src/watch_event.dart b/pkgs/watcher/lib/src/watch_event.dart
new file mode 100644
index 0000000..b65afc2
--- /dev/null
+++ b/pkgs/watcher/lib/src/watch_event.dart
@@ -0,0 +1,38 @@
+// Copyright (c) 2013, 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.
+
+/// An event describing a single change to the file system.
+class WatchEvent {
+ /// The manner in which the file at [path] has changed.
+ final ChangeType type;
+
+ /// The path of the file that changed.
+ final String path;
+
+ WatchEvent(this.type, this.path);
+
+ @override
+ String toString() => '$type $path';
+}
+
+/// Enum for what kind of change has happened to a file.
+class ChangeType {
+ /// A new file has been added.
+ // ignore: constant_identifier_names
+ static const ADD = ChangeType('add');
+
+ /// A file has been removed.
+ // ignore: constant_identifier_names
+ static const REMOVE = ChangeType('remove');
+
+ /// The contents of a file have changed.
+ // ignore: constant_identifier_names
+ static const MODIFY = ChangeType('modify');
+
+ final String _name;
+ const ChangeType(this._name);
+
+ @override
+ String toString() => _name;
+}
diff --git a/pkgs/watcher/lib/watcher.dart b/pkgs/watcher/lib/watcher.dart
new file mode 100644
index 0000000..12a5369
--- /dev/null
+++ b/pkgs/watcher/lib/watcher.dart
@@ -0,0 +1,70 @@
+// Copyright (c) 2013, 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:io';
+
+import 'src/directory_watcher.dart';
+import 'src/file_watcher.dart';
+import 'src/watch_event.dart';
+
+export 'src/custom_watcher_factory.dart' show registerCustomWatcher;
+export 'src/directory_watcher.dart';
+export 'src/directory_watcher/polling.dart';
+export 'src/file_watcher.dart';
+export 'src/file_watcher/polling.dart';
+export 'src/watch_event.dart';
+
+abstract class Watcher {
+ /// The path to the file or directory whose contents are being monitored.
+ String get path;
+
+ /// The broadcast [Stream] of events that have occurred to the watched file or
+ /// files in the watched directory.
+ ///
+ /// Changes will only be monitored while this stream has subscribers. Any
+ /// changes that occur during periods when there are no subscribers will not
+ /// be reported the next time a subscriber is added.
+ Stream<WatchEvent> get events;
+
+ /// Whether the watcher is initialized and watching for changes.
+ ///
+ /// This is true if and only if [ready] is complete.
+ bool get isReady;
+
+ /// A [Future] that completes when the watcher is initialized and watching for
+ /// changes.
+ ///
+ /// If the watcher is not currently monitoring the file or directory (because
+ /// there are no subscribers to [events]), this returns a future that isn't
+ /// complete yet. It will complete when a subscriber starts listening and the
+ /// watcher finishes any initialization work it needs to do.
+ ///
+ /// If the watcher is already monitoring, this returns an already complete
+ /// future.
+ ///
+ /// This future always completes successfully as errors are provided through
+ /// the [events] stream.
+ 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 (File(path).existsSync()) {
+ return FileWatcher(path, pollingDelay: pollingDelay);
+ } else {
+ return DirectoryWatcher(path, pollingDelay: pollingDelay);
+ }
+ }
+}
diff --git a/pkgs/watcher/pubspec.yaml b/pkgs/watcher/pubspec.yaml
new file mode 100644
index 0000000..108be66
--- /dev/null
+++ b/pkgs/watcher/pubspec.yaml
@@ -0,0 +1,19 @@
+name: watcher
+version: 1.1.1-wip
+description: >-
+ A file system watcher. It monitors changes to contents of directories and
+ sends notifications when files have been added, removed, or modified.
+repository: https://github.com/dart-lang/watcher
+
+environment:
+ sdk: ^3.1.0
+
+dependencies:
+ async: ^2.5.0
+ path: ^1.8.0
+
+dev_dependencies:
+ benchmark_harness: ^2.0.0
+ dart_flutter_team_lints: ^3.0.0
+ test: ^1.16.6
+ test_descriptor: ^2.0.0
diff --git a/pkgs/watcher/test/custom_watcher_factory_test.dart b/pkgs/watcher/test/custom_watcher_factory_test.dart
new file mode 100644
index 0000000..e9d65bb
--- /dev/null
+++ b/pkgs/watcher/test/custom_watcher_factory_test.dart
@@ -0,0 +1,142 @@
+import 'dart:async';
+
+import 'package:test/test.dart';
+import 'package:watcher/watcher.dart';
+
+void main() {
+ late _MemFs memFs;
+ final memFsFactoryId = 'MemFs';
+ final noOpFactoryId = 'NoOp';
+
+ setUpAll(() {
+ memFs = _MemFs();
+ var memFsWatcherFactory = _MemFsWatcherFactory(memFs);
+ var noOpWatcherFactory = _NoOpWatcherFactory();
+ registerCustomWatcher(
+ noOpFactoryId,
+ noOpWatcherFactory.createDirectoryWatcher,
+ noOpWatcherFactory.createFileWatcher);
+ registerCustomWatcher(
+ memFsFactoryId,
+ memFsWatcherFactory.createDirectoryWatcher,
+ memFsWatcherFactory.createFileWatcher);
+ });
+
+ test('notifies for files', () async {
+ var watcher = FileWatcher('file.txt');
+
+ var completer = Completer<WatchEvent>();
+ watcher.events.listen((event) => completer.complete(event));
+ await watcher.ready;
+ memFs.add('file.txt');
+ var event = await completer.future;
+
+ expect(event.type, ChangeType.ADD);
+ expect(event.path, 'file.txt');
+ });
+
+ test('notifies for directories', () async {
+ var watcher = DirectoryWatcher('dir');
+
+ var completer = Completer<WatchEvent>();
+ watcher.events.listen((event) => completer.complete(event));
+ await watcher.ready;
+ memFs.add('dir');
+ var event = await completer.future;
+
+ expect(event.type, ChangeType.ADD);
+ expect(event.path, 'dir');
+ });
+
+ test('registering twice throws', () async {
+ expect(
+ () => registerCustomWatcher(
+ memFsFactoryId,
+ (_, {pollingDelay}) => throw UnimplementedError(),
+ (_, {pollingDelay}) => throw UnimplementedError()),
+ throwsA(isA<ArgumentError>()),
+ );
+ });
+
+ test('finding two applicable factories throws', () async {
+ // Note that _MemFsWatcherFactory always returns a watcher, so having two
+ // will always produce a conflict.
+ var watcherFactory = _MemFsWatcherFactory(memFs);
+ registerCustomWatcher('Different id', watcherFactory.createDirectoryWatcher,
+ watcherFactory.createFileWatcher);
+ expect(() => FileWatcher('file.txt'), throwsA(isA<StateError>()));
+ expect(() => DirectoryWatcher('dir'), throwsA(isA<StateError>()));
+ });
+}
+
+class _MemFs {
+ final _streams = <String, Set<StreamController<WatchEvent>>>{};
+
+ StreamController<WatchEvent> watchStream(String path) {
+ var controller = StreamController<WatchEvent>();
+ _streams
+ .putIfAbsent(path, () => <StreamController<WatchEvent>>{})
+ .add(controller);
+ return controller;
+ }
+
+ void add(String path) {
+ var controllers = _streams[path];
+ if (controllers != null) {
+ for (var controller in controllers) {
+ controller.add(WatchEvent(ChangeType.ADD, path));
+ }
+ }
+ }
+
+ void remove(String path) {
+ var controllers = _streams[path];
+ if (controllers != null) {
+ for (var controller in controllers) {
+ controller.add(WatchEvent(ChangeType.REMOVE, path));
+ }
+ }
+ }
+}
+
+class _MemFsWatcher implements FileWatcher, DirectoryWatcher, Watcher {
+ final String _path;
+ final StreamController<WatchEvent> _controller;
+
+ _MemFsWatcher(this._path, this._controller);
+
+ @override
+ String get path => _path;
+
+ @override
+ String get directory => throw UnsupportedError('directory is not supported');
+
+ @override
+ Stream<WatchEvent> get events => _controller.stream;
+
+ @override
+ bool get isReady => true;
+
+ @override
+ Future<void> get ready async {}
+}
+
+class _MemFsWatcherFactory {
+ final _MemFs _memFs;
+ _MemFsWatcherFactory(this._memFs);
+
+ DirectoryWatcher? createDirectoryWatcher(String path,
+ {Duration? pollingDelay}) =>
+ _MemFsWatcher(path, _memFs.watchStream(path));
+
+ FileWatcher? createFileWatcher(String path, {Duration? pollingDelay}) =>
+ _MemFsWatcher(path, _memFs.watchStream(path));
+}
+
+class _NoOpWatcherFactory {
+ DirectoryWatcher? createDirectoryWatcher(String path,
+ {Duration? pollingDelay}) =>
+ null;
+
+ FileWatcher? createFileWatcher(String path, {Duration? pollingDelay}) => null;
+}
diff --git a/pkgs/watcher/test/directory_watcher/linux_test.dart b/pkgs/watcher/test/directory_watcher/linux_test.dart
new file mode 100644
index 0000000..a10a72c
--- /dev/null
+++ b/pkgs/watcher/test/directory_watcher/linux_test.dart
@@ -0,0 +1,44 @@
+// Copyright (c) 2013, 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')
+library;
+
+import 'package:test/test.dart';
+import 'package:watcher/src/directory_watcher/linux.dart';
+import 'package:watcher/watcher.dart';
+
+import '../utils.dart';
+import 'shared.dart';
+
+void main() {
+ watcherFactory = LinuxDirectoryWatcher.new;
+
+ sharedTests();
+
+ test('DirectoryWatcher creates a LinuxDirectoryWatcher on Linux', () {
+ expect(DirectoryWatcher('.'), const TypeMatcher<LinuxDirectoryWatcher>());
+ });
+
+ test('emits events for many nested files moved out then immediately back in',
+ () async {
+ withPermutations(
+ (i, j, k) => writeFile('dir/sub/sub-$i/sub-$j/file-$k.txt'));
+ await startWatcher(path: 'dir');
+
+ renameDir('dir/sub', 'sub');
+ renameDir('sub', 'dir/sub');
+
+ await allowEither(() {
+ inAnyOrder(withPermutations(
+ (i, j, k) => isRemoveEvent('dir/sub/sub-$i/sub-$j/file-$k.txt')));
+
+ inAnyOrder(withPermutations(
+ (i, j, k) => isAddEvent('dir/sub/sub-$i/sub-$j/file-$k.txt')));
+ }, () {
+ inAnyOrder(withPermutations(
+ (i, j, k) => isModifyEvent('dir/sub/sub-$i/sub-$j/file-$k.txt')));
+ });
+ });
+}
diff --git a/pkgs/watcher/test/directory_watcher/mac_os_test.dart b/pkgs/watcher/test/directory_watcher/mac_os_test.dart
new file mode 100644
index 0000000..3376626
--- /dev/null
+++ b/pkgs/watcher/test/directory_watcher/mac_os_test.dart
@@ -0,0 +1,69 @@
+// Copyright (c) 2013, 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('mac-os')
+library;
+
+import 'package:test/test.dart';
+import 'package:watcher/src/directory_watcher/mac_os.dart';
+import 'package:watcher/watcher.dart';
+
+import '../utils.dart';
+import 'shared.dart';
+
+void main() {
+ watcherFactory = MacOSDirectoryWatcher.new;
+
+ sharedTests();
+
+ test('DirectoryWatcher creates a MacOSDirectoryWatcher on Mac OS', () {
+ expect(DirectoryWatcher('.'), const TypeMatcher<MacOSDirectoryWatcher>());
+ });
+
+ test(
+ 'does not notify about the watched directory being deleted and '
+ 'recreated immediately before watching', () async {
+ createDir('dir');
+ writeFile('dir/old.txt');
+ deleteDir('dir');
+ createDir('dir');
+
+ await startWatcher(path: 'dir');
+ writeFile('dir/newer.txt');
+ await expectAddEvent('dir/newer.txt');
+ });
+
+ test('emits events for many nested files moved out then immediately back in',
+ () async {
+ withPermutations(
+ (i, j, k) => writeFile('dir/sub/sub-$i/sub-$j/file-$k.txt'));
+
+ await startWatcher(path: 'dir');
+
+ renameDir('dir/sub', 'sub');
+ renameDir('sub', 'dir/sub');
+
+ await allowEither(() {
+ inAnyOrder(withPermutations(
+ (i, j, k) => isRemoveEvent('dir/sub/sub-$i/sub-$j/file-$k.txt')));
+
+ inAnyOrder(withPermutations(
+ (i, j, k) => isAddEvent('dir/sub/sub-$i/sub-$j/file-$k.txt')));
+ }, () {
+ inAnyOrder(withPermutations(
+ (i, j, k) => isModifyEvent('dir/sub/sub-$i/sub-$j/file-$k.txt')));
+ });
+ });
+ test('does not suppress files with the same prefix as a directory', () async {
+ // Regression test for https://github.com/dart-lang/watcher/issues/83
+ writeFile('some_name.txt');
+
+ await startWatcher();
+
+ writeFile('some_name/some_name.txt');
+ deleteFile('some_name.txt');
+
+ await expectRemoveEvent('some_name.txt');
+ });
+}
diff --git a/pkgs/watcher/test/directory_watcher/polling_test.dart b/pkgs/watcher/test/directory_watcher/polling_test.dart
new file mode 100644
index 0000000..f4ec8f4
--- /dev/null
+++ b/pkgs/watcher/test/directory_watcher/polling_test.dart
@@ -0,0 +1,26 @@
+// Copyright (c) 2013, 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:watcher/watcher.dart';
+
+import '../utils.dart';
+import 'shared.dart';
+
+void main() {
+ // Use a short delay to make the tests run quickly.
+ watcherFactory = (dir) => PollingDirectoryWatcher(dir,
+ pollingDelay: const Duration(milliseconds: 100));
+
+ sharedTests();
+
+ test('does not notify if the modification time did not change', () async {
+ writeFile('a.txt', contents: 'before');
+ writeFile('b.txt', contents: 'before');
+ await startWatcher();
+ writeFile('a.txt', contents: 'after', updateModified: false);
+ writeFile('b.txt', contents: 'after');
+ await expectModifyEvent('b.txt');
+ });
+}
diff --git a/pkgs/watcher/test/directory_watcher/shared.dart b/pkgs/watcher/test/directory_watcher/shared.dart
new file mode 100644
index 0000000..1ebc78d
--- /dev/null
+++ b/pkgs/watcher/test/directory_watcher/shared.dart
@@ -0,0 +1,344 @@
+// Copyright (c) 2012, 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:watcher/src/utils.dart';
+
+import '../utils.dart';
+
+void sharedTests() {
+ test('does not notify for files that already exist when started', () async {
+ // Make some pre-existing files.
+ writeFile('a.txt');
+ writeFile('b.txt');
+
+ await startWatcher();
+
+ // Change one after the watcher is running.
+ writeFile('b.txt', contents: 'modified');
+
+ // We should get a modify event for the changed file, but no add events
+ // for them before this.
+ await expectModifyEvent('b.txt');
+ });
+
+ test('notifies when a file is added', () async {
+ await startWatcher();
+ writeFile('file.txt');
+ await expectAddEvent('file.txt');
+ });
+
+ test('notifies when a file is modified', () async {
+ writeFile('file.txt');
+ await startWatcher();
+ writeFile('file.txt', contents: 'modified');
+ await expectModifyEvent('file.txt');
+ });
+
+ test('notifies when a file is removed', () async {
+ writeFile('file.txt');
+ await startWatcher();
+ deleteFile('file.txt');
+ await expectRemoveEvent('file.txt');
+ });
+
+ test('notifies when a file is modified multiple times', () async {
+ writeFile('file.txt');
+ await startWatcher();
+ writeFile('file.txt', contents: 'modified');
+ await expectModifyEvent('file.txt');
+ writeFile('file.txt', contents: 'modified again');
+ await expectModifyEvent('file.txt');
+ });
+
+ test('notifies even if the file contents are unchanged', () async {
+ writeFile('a.txt', contents: 'same');
+ writeFile('b.txt', contents: 'before');
+ await startWatcher();
+
+ writeFile('a.txt', contents: 'same');
+ writeFile('b.txt', contents: 'after');
+ await inAnyOrder([isModifyEvent('a.txt'), isModifyEvent('b.txt')]);
+ });
+
+ test('when the watched directory is deleted, removes all files', () async {
+ writeFile('dir/a.txt');
+ writeFile('dir/b.txt');
+
+ await startWatcher(path: 'dir');
+
+ deleteDir('dir');
+ await inAnyOrder([isRemoveEvent('dir/a.txt'), isRemoveEvent('dir/b.txt')]);
+ });
+
+ test('when the watched directory is moved, removes all files', () async {
+ writeFile('dir/a.txt');
+ writeFile('dir/b.txt');
+
+ await startWatcher(path: 'dir');
+
+ renameDir('dir', 'moved_dir');
+ createDir('dir');
+ await inAnyOrder([isRemoveEvent('dir/a.txt'), isRemoveEvent('dir/b.txt')]);
+ });
+
+ // Regression test for b/30768513.
+ test(
+ "doesn't crash when the directory is moved immediately after a subdir "
+ 'is added', () async {
+ writeFile('dir/a.txt');
+ writeFile('dir/b.txt');
+
+ await startWatcher(path: 'dir');
+
+ createDir('dir/subdir');
+ renameDir('dir', 'moved_dir');
+ createDir('dir');
+ await inAnyOrder([isRemoveEvent('dir/a.txt'), isRemoveEvent('dir/b.txt')]);
+ });
+
+ group('moves', () {
+ test('notifies when a file is moved within the watched directory',
+ () async {
+ writeFile('old.txt');
+ await startWatcher();
+ renameFile('old.txt', 'new.txt');
+
+ await inAnyOrder([isAddEvent('new.txt'), isRemoveEvent('old.txt')]);
+ });
+
+ test('notifies when a file is moved from outside the watched directory',
+ () async {
+ writeFile('old.txt');
+ createDir('dir');
+ await startWatcher(path: 'dir');
+
+ renameFile('old.txt', 'dir/new.txt');
+ await expectAddEvent('dir/new.txt');
+ });
+
+ test('notifies when a file is moved outside the watched directory',
+ () async {
+ writeFile('dir/old.txt');
+ await startWatcher(path: 'dir');
+
+ renameFile('dir/old.txt', 'new.txt');
+ await expectRemoveEvent('dir/old.txt');
+ });
+
+ test('notifies when a file is moved onto an existing one', () async {
+ writeFile('from.txt');
+ writeFile('to.txt');
+ await startWatcher();
+
+ renameFile('from.txt', 'to.txt');
+ await inAnyOrder([isRemoveEvent('from.txt'), isModifyEvent('to.txt')]);
+ }, onPlatform: {
+ 'windows': const Skip('https://github.com/dart-lang/watcher/issues/125')
+ });
+ });
+
+ // Most of the time, when multiple filesystem actions happen in sequence,
+ // they'll be batched together and the watcher will see them all at once.
+ // These tests verify that the watcher normalizes and combine these events
+ // properly. However, very occasionally the events will be reported in
+ // separate batches, and the watcher will report them as though they occurred
+ // far apart in time, so each of these tests has a "backup case" to allow for
+ // that as well.
+ group('clustered changes', () {
+ test("doesn't notify when a file is created and then immediately removed",
+ () async {
+ writeFile('test.txt');
+ await startWatcher();
+ writeFile('file.txt');
+ deleteFile('file.txt');
+
+ // Backup case.
+ startClosingEventStream();
+ await allowEvents(() {
+ expectAddEvent('file.txt');
+ expectRemoveEvent('file.txt');
+ });
+ });
+
+ test(
+ 'reports a modification when a file is deleted and then immediately '
+ 'recreated', () async {
+ writeFile('file.txt');
+ await startWatcher();
+
+ deleteFile('file.txt');
+ writeFile('file.txt', contents: 're-created');
+
+ await allowEither(() {
+ expectModifyEvent('file.txt');
+ }, () {
+ // Backup case.
+ expectRemoveEvent('file.txt');
+ expectAddEvent('file.txt');
+ });
+ });
+
+ test(
+ 'reports a modification when a file is moved and then immediately '
+ 'recreated', () async {
+ writeFile('old.txt');
+ await startWatcher();
+
+ renameFile('old.txt', 'new.txt');
+ writeFile('old.txt', contents: 're-created');
+
+ await allowEither(() {
+ inAnyOrder([isModifyEvent('old.txt'), isAddEvent('new.txt')]);
+ }, () {
+ // Backup case.
+ expectRemoveEvent('old.txt');
+ expectAddEvent('new.txt');
+ expectAddEvent('old.txt');
+ });
+ });
+
+ test(
+ 'reports a removal when a file is modified and then immediately '
+ 'removed', () async {
+ writeFile('file.txt');
+ await startWatcher();
+
+ writeFile('file.txt', contents: 'modified');
+ deleteFile('file.txt');
+
+ // Backup case.
+ await allowModifyEvent('file.txt');
+
+ await expectRemoveEvent('file.txt');
+ });
+
+ test('reports an add when a file is added and then immediately modified',
+ () async {
+ await startWatcher();
+
+ writeFile('file.txt');
+ writeFile('file.txt', contents: 'modified');
+
+ await expectAddEvent('file.txt');
+
+ // Backup case.
+ startClosingEventStream();
+ await allowModifyEvent('file.txt');
+ });
+ });
+
+ group('subdirectories', () {
+ test('watches files in subdirectories', () async {
+ await startWatcher();
+ writeFile('a/b/c/d/file.txt');
+ await expectAddEvent('a/b/c/d/file.txt');
+ });
+
+ test(
+ 'notifies when a subdirectory is moved within the watched directory '
+ 'and then its contents are modified', () async {
+ writeFile('old/file.txt');
+ await startWatcher();
+
+ renameDir('old', 'new');
+ await inAnyOrder(
+ [isRemoveEvent('old/file.txt'), isAddEvent('new/file.txt')]);
+
+ writeFile('new/file.txt', contents: 'modified');
+ await expectModifyEvent('new/file.txt');
+ });
+
+ test('notifies when a file is replaced by a subdirectory', () async {
+ writeFile('new');
+ writeFile('old/file.txt');
+ await startWatcher();
+
+ deleteFile('new');
+ renameDir('old', 'new');
+ await inAnyOrder([
+ isRemoveEvent('new'),
+ isRemoveEvent('old/file.txt'),
+ isAddEvent('new/file.txt')
+ ]);
+ });
+
+ test('notifies when a subdirectory is replaced by a file', () async {
+ writeFile('old');
+ writeFile('new/file.txt');
+ await startWatcher();
+
+ renameDir('new', 'newer');
+ renameFile('old', 'new');
+ await inAnyOrder([
+ isRemoveEvent('new/file.txt'),
+ isAddEvent('newer/file.txt'),
+ isRemoveEvent('old'),
+ isAddEvent('new')
+ ]);
+ }, onPlatform: {
+ 'windows': const Skip('https://github.com/dart-lang/watcher/issues/21')
+ });
+
+ test('emits events for many nested files added at once', () async {
+ withPermutations((i, j, k) => writeFile('sub/sub-$i/sub-$j/file-$k.txt'));
+
+ createDir('dir');
+ await startWatcher(path: 'dir');
+ renameDir('sub', 'dir/sub');
+
+ await inAnyOrder(withPermutations(
+ (i, j, k) => isAddEvent('dir/sub/sub-$i/sub-$j/file-$k.txt')));
+ });
+
+ test('emits events for many nested files removed at once', () async {
+ withPermutations(
+ (i, j, k) => writeFile('dir/sub/sub-$i/sub-$j/file-$k.txt'));
+
+ createDir('dir');
+ await startWatcher(path: 'dir');
+
+ // Rename the directory rather than deleting it because native watchers
+ // report a rename as a single DELETE event for the directory, whereas
+ // they report recursive deletion with DELETE events for every file in the
+ // directory.
+ renameDir('dir/sub', 'sub');
+
+ await inAnyOrder(withPermutations(
+ (i, j, k) => isRemoveEvent('dir/sub/sub-$i/sub-$j/file-$k.txt')));
+ });
+
+ test('emits events for many nested files moved at once', () async {
+ withPermutations(
+ (i, j, k) => writeFile('dir/old/sub-$i/sub-$j/file-$k.txt'));
+
+ createDir('dir');
+ await startWatcher(path: 'dir');
+ renameDir('dir/old', 'dir/new');
+
+ await inAnyOrder(unionAll(withPermutations((i, j, k) {
+ return {
+ isRemoveEvent('dir/old/sub-$i/sub-$j/file-$k.txt'),
+ isAddEvent('dir/new/sub-$i/sub-$j/file-$k.txt')
+ };
+ })));
+ });
+
+ test(
+ 'emits events for many files added at once in a subdirectory with the '
+ 'same name as a removed file', () async {
+ writeFile('dir/sub');
+ withPermutations((i, j, k) => writeFile('old/sub-$i/sub-$j/file-$k.txt'));
+ await startWatcher(path: 'dir');
+
+ deleteFile('dir/sub');
+ renameDir('old', 'dir/sub');
+
+ var events = withPermutations(
+ (i, j, k) => isAddEvent('dir/sub/sub-$i/sub-$j/file-$k.txt'));
+ events.add(isRemoveEvent('dir/sub'));
+ await inAnyOrder(events);
+ });
+ });
+}
diff --git a/pkgs/watcher/test/directory_watcher/windows_test.dart b/pkgs/watcher/test/directory_watcher/windows_test.dart
new file mode 100644
index 0000000..499e7fb
--- /dev/null
+++ b/pkgs/watcher/test/directory_watcher/windows_test.dart
@@ -0,0 +1,23 @@
+// Copyright (c) 2014, 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('windows')
+library;
+
+import 'package:test/test.dart';
+import 'package:watcher/src/directory_watcher/windows.dart';
+import 'package:watcher/watcher.dart';
+
+import '../utils.dart';
+import 'shared.dart';
+
+void main() {
+ watcherFactory = WindowsDirectoryWatcher.new;
+
+ group('Shared Tests:', sharedTests);
+
+ test('DirectoryWatcher creates a WindowsDirectoryWatcher on Windows', () {
+ expect(DirectoryWatcher('.'), const TypeMatcher<WindowsDirectoryWatcher>());
+ });
+}
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..0d4ad63
--- /dev/null
+++ b/pkgs/watcher/test/file_watcher/native_test.dart
@@ -0,0 +1,22 @@
+// 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')
+library;
+
+import 'package:test/test.dart';
+import 'package:watcher/src/file_watcher/native.dart';
+
+import '../utils.dart';
+import 'shared.dart';
+
+void main() {
+ watcherFactory = NativeFileWatcher.new;
+
+ setUp(() {
+ 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..861fcb2
--- /dev/null
+++ b/pkgs/watcher/test/file_watcher/polling_test.dart
@@ -0,0 +1,20 @@
+// 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:watcher/watcher.dart';
+
+import '../utils.dart';
+import 'shared.dart';
+
+void main() {
+ watcherFactory = (file) =>
+ PollingFileWatcher(file, pollingDelay: const Duration(milliseconds: 100));
+
+ setUp(() {
+ 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..081b92e
--- /dev/null
+++ b/pkgs/watcher/test/file_watcher/shared.dart
@@ -0,0 +1,73 @@
+// 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 '../utils.dart';
+
+void sharedTests() {
+ test("doesn't notify if the file isn't modified", () async {
+ await startWatcher(path: 'file.txt');
+ await pumpEventQueue();
+ deleteFile('file.txt');
+ await expectRemoveEvent('file.txt');
+ });
+
+ test('notifies when a file is modified', () async {
+ await startWatcher(path: 'file.txt');
+ writeFile('file.txt', contents: 'modified');
+ await expectModifyEvent('file.txt');
+ });
+
+ test('notifies when a file is removed', () async {
+ await startWatcher(path: 'file.txt');
+ deleteFile('file.txt');
+ await expectRemoveEvent('file.txt');
+ });
+
+ test('notifies when a file is modified multiple times', () async {
+ await startWatcher(path: 'file.txt');
+ writeFile('file.txt', contents: 'modified');
+ await expectModifyEvent('file.txt');
+ writeFile('file.txt', contents: 'modified again');
+ await expectModifyEvent('file.txt');
+ });
+
+ test('notifies even if the file contents are unchanged', () async {
+ await startWatcher(path: 'file.txt');
+ writeFile('file.txt');
+ await expectModifyEvent('file.txt');
+ });
+
+ test('emits a remove event when the watched file is moved away', () async {
+ await startWatcher(path: 'file.txt');
+ renameFile('file.txt', 'new.txt');
+ await expectRemoveEvent('file.txt');
+ });
+
+ test(
+ 'emits a modify event when another file is moved on top of the watched '
+ 'file', () async {
+ writeFile('old.txt');
+ await startWatcher(path: 'file.txt');
+ renameFile('old.txt', 'file.txt');
+ await expectModifyEvent('file.txt');
+ });
+
+ // Regression test for a race condition.
+ test('closes the watcher immediately after deleting the file', () async {
+ writeFile('old.txt');
+ var watcher = createWatcher(path: 'file.txt');
+ var sub = watcher.events.listen(null);
+
+ deleteFile('file.txt');
+ await Future<void>.delayed(const Duration(milliseconds: 10));
+ await sub.cancel();
+ });
+
+ test('ready completes even if file does not exist', () async {
+ // startWatcher awaits 'ready'
+ await startWatcher(path: 'foo/bar/baz');
+ });
+}
diff --git a/pkgs/watcher/test/no_subscription/linux_test.dart b/pkgs/watcher/test/no_subscription/linux_test.dart
new file mode 100644
index 0000000..aac0810
--- /dev/null
+++ b/pkgs/watcher/test/no_subscription/linux_test.dart
@@ -0,0 +1,18 @@
+// Copyright (c) 2013, 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')
+library;
+
+import 'package:test/test.dart';
+import 'package:watcher/src/directory_watcher/linux.dart';
+
+import '../utils.dart';
+import 'shared.dart';
+
+void main() {
+ watcherFactory = LinuxDirectoryWatcher.new;
+
+ sharedTests();
+}
diff --git a/pkgs/watcher/test/no_subscription/mac_os_test.dart b/pkgs/watcher/test/no_subscription/mac_os_test.dart
new file mode 100644
index 0000000..55a8308
--- /dev/null
+++ b/pkgs/watcher/test/no_subscription/mac_os_test.dart
@@ -0,0 +1,18 @@
+// Copyright (c) 2013, 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('mac-os')
+library;
+
+import 'package:test/test.dart';
+import 'package:watcher/src/directory_watcher/mac_os.dart';
+
+import '../utils.dart';
+import 'shared.dart';
+
+void main() {
+ watcherFactory = MacOSDirectoryWatcher.new;
+
+ sharedTests();
+}
diff --git a/pkgs/watcher/test/no_subscription/polling_test.dart b/pkgs/watcher/test/no_subscription/polling_test.dart
new file mode 100644
index 0000000..bfd2958
--- /dev/null
+++ b/pkgs/watcher/test/no_subscription/polling_test.dart
@@ -0,0 +1,14 @@
+// Copyright (c) 2013, 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:watcher/watcher.dart';
+
+import '../utils.dart';
+import 'shared.dart';
+
+void main() {
+ watcherFactory = PollingDirectoryWatcher.new;
+
+ sharedTests();
+}
diff --git a/pkgs/watcher/test/no_subscription/shared.dart b/pkgs/watcher/test/no_subscription/shared.dart
new file mode 100644
index 0000000..e7a6144
--- /dev/null
+++ b/pkgs/watcher/test/no_subscription/shared.dart
@@ -0,0 +1,54 @@
+// Copyright (c) 2012, 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:async/async.dart';
+import 'package:test/test.dart';
+import 'package:watcher/watcher.dart';
+
+import '../utils.dart';
+
+void sharedTests() {
+ test('does not notify for changes when there are no subscribers', () async {
+ // Note that this test doesn't rely as heavily on the test functions in
+ // utils.dart because it needs to be very explicit about when the event
+ // stream is and is not subscribed.
+ var watcher = createWatcher();
+ var queue = StreamQueue(watcher.events);
+ unawaited(queue.hasNext);
+
+ var future =
+ expectLater(queue, emits(isWatchEvent(ChangeType.ADD, 'file.txt')));
+ expect(queue, neverEmits(anything));
+
+ await watcher.ready;
+
+ writeFile('file.txt');
+
+ await future;
+
+ // Unsubscribe.
+ await queue.cancel(immediate: true);
+
+ // Now write a file while we aren't listening.
+ writeFile('unwatched.txt');
+
+ queue = StreamQueue(watcher.events);
+ future =
+ expectLater(queue, emits(isWatchEvent(ChangeType.ADD, 'added.txt')));
+ expect(queue, neverEmits(isWatchEvent(ChangeType.ADD, 'unwatched.txt')));
+
+ // Wait until the watcher is ready to dispatch events again.
+ await watcher.ready;
+
+ // And add a third file.
+ writeFile('added.txt');
+
+ // Wait until we get an event for the third file.
+ await future;
+
+ await queue.cancel(immediate: true);
+ });
+}
diff --git a/pkgs/watcher/test/no_subscription/windows_test.dart b/pkgs/watcher/test/no_subscription/windows_test.dart
new file mode 100644
index 0000000..9f9e5a9
--- /dev/null
+++ b/pkgs/watcher/test/no_subscription/windows_test.dart
@@ -0,0 +1,18 @@
+// Copyright (c) 2022, 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('windows')
+library;
+
+import 'package:test/test.dart';
+import 'package:watcher/src/directory_watcher/windows.dart';
+
+import '../utils.dart';
+import 'shared.dart';
+
+void main() {
+ watcherFactory = WindowsDirectoryWatcher.new;
+
+ sharedTests();
+}
diff --git a/pkgs/watcher/test/path_set_test.dart b/pkgs/watcher/test/path_set_test.dart
new file mode 100644
index 0000000..61ab2cd
--- /dev/null
+++ b/pkgs/watcher/test/path_set_test.dart
@@ -0,0 +1,228 @@
+// Copyright (c) 2013, 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:path/path.dart' as p;
+import 'package:test/test.dart';
+import 'package:watcher/src/path_set.dart';
+
+Matcher containsPath(String path) => predicate(
+ (paths) => paths is PathSet && paths.contains(path),
+ 'set contains "$path"');
+
+Matcher containsDir(String path) => predicate(
+ (paths) => paths is PathSet && paths.containsDir(path),
+ 'set contains directory "$path"');
+
+void main() {
+ late PathSet paths;
+ setUp(() => paths = PathSet('root'));
+
+ group('adding a path', () {
+ test('stores the path in the set', () {
+ paths.add('root/path/to/file');
+ expect(paths, containsPath('root/path/to/file'));
+ });
+
+ test("that's a subdir of another path keeps both in the set", () {
+ paths.add('root/path');
+ paths.add('root/path/to/file');
+ expect(paths, containsPath('root/path'));
+ expect(paths, containsPath('root/path/to/file'));
+ });
+
+ test("that's not normalized normalizes the path before storing it", () {
+ paths.add('root/../root/path/to/../to/././file');
+ expect(paths, containsPath('root/path/to/file'));
+ });
+
+ test("that's absolute normalizes the path before storing it", () {
+ paths.add(p.absolute('root/path/to/file'));
+ expect(paths, containsPath('root/path/to/file'));
+ });
+ });
+
+ group('removing a path', () {
+ test("that's in the set removes and returns that path", () {
+ paths.add('root/path/to/file');
+ expect(paths.remove('root/path/to/file'),
+ unorderedEquals([p.normalize('root/path/to/file')]));
+ expect(paths, isNot(containsPath('root/path/to/file')));
+ });
+
+ test("that's not in the set returns an empty set", () {
+ paths.add('root/path/to/file');
+ expect(paths.remove('root/path/to/nothing'), isEmpty);
+ });
+
+ test("that's a directory removes and returns all files beneath it", () {
+ paths.add('root/outside');
+ paths.add('root/path/to/one');
+ paths.add('root/path/to/two');
+ paths.add('root/path/to/sub/three');
+
+ expect(
+ paths.remove('root/path'),
+ unorderedEquals([
+ 'root/path/to/one',
+ 'root/path/to/two',
+ 'root/path/to/sub/three'
+ ].map(p.normalize)));
+
+ expect(paths, containsPath('root/outside'));
+ expect(paths, isNot(containsPath('root/path/to/one')));
+ expect(paths, isNot(containsPath('root/path/to/two')));
+ expect(paths, isNot(containsPath('root/path/to/sub/three')));
+ });
+
+ test(
+ "that's a directory in the set removes and returns it and all files "
+ 'beneath it', () {
+ paths.add('root/path');
+ paths.add('root/path/to/one');
+ paths.add('root/path/to/two');
+ paths.add('root/path/to/sub/three');
+
+ expect(
+ paths.remove('root/path'),
+ unorderedEquals([
+ 'root/path',
+ 'root/path/to/one',
+ 'root/path/to/two',
+ 'root/path/to/sub/three'
+ ].map(p.normalize)));
+
+ expect(paths, isNot(containsPath('root/path')));
+ expect(paths, isNot(containsPath('root/path/to/one')));
+ expect(paths, isNot(containsPath('root/path/to/two')));
+ expect(paths, isNot(containsPath('root/path/to/sub/three')));
+ });
+
+ test("that's not normalized removes and returns the normalized path", () {
+ paths.add('root/path/to/file');
+ expect(paths.remove('root/../root/path/to/../to/./file'),
+ unorderedEquals([p.normalize('root/path/to/file')]));
+ });
+
+ test("that's absolute removes and returns the normalized path", () {
+ paths.add('root/path/to/file');
+ expect(paths.remove(p.absolute('root/path/to/file')),
+ unorderedEquals([p.normalize('root/path/to/file')]));
+ });
+ });
+
+ group('containsPath()', () {
+ test('returns false for a non-existent path', () {
+ paths.add('root/path/to/file');
+ expect(paths, isNot(containsPath('root/path/to/nothing')));
+ });
+
+ test("returns false for a directory that wasn't added explicitly", () {
+ paths.add('root/path/to/file');
+ expect(paths, isNot(containsPath('root/path')));
+ });
+
+ test('returns true for a directory that was added explicitly', () {
+ paths.add('root/path');
+ paths.add('root/path/to/file');
+ expect(paths, containsPath('root/path'));
+ });
+
+ test('with a non-normalized path normalizes the path before looking it up',
+ () {
+ paths.add('root/path/to/file');
+ expect(paths, containsPath('root/../root/path/to/../to/././file'));
+ });
+
+ test('with an absolute path normalizes the path before looking it up', () {
+ paths.add('root/path/to/file');
+ expect(paths, containsPath(p.absolute('root/path/to/file')));
+ });
+ });
+
+ group('containsDir()', () {
+ test('returns true for a directory that was added implicitly', () {
+ paths.add('root/path/to/file');
+ expect(paths, containsDir('root/path'));
+ expect(paths, containsDir('root/path/to'));
+ });
+
+ test('returns true for a directory that was added explicitly', () {
+ paths.add('root/path');
+ paths.add('root/path/to/file');
+ expect(paths, containsDir('root/path'));
+ });
+
+ test("returns false for a directory that wasn't added", () {
+ expect(paths, isNot(containsDir('root/nothing')));
+ });
+
+ test('returns false for a non-directory path that was added', () {
+ paths.add('root/path/to/file');
+ expect(paths, isNot(containsDir('root/path/to/file')));
+ });
+
+ test(
+ 'returns false for a directory that was added implicitly and then '
+ 'removed implicitly', () {
+ paths.add('root/path/to/file');
+ paths.remove('root/path/to/file');
+ expect(paths, isNot(containsDir('root/path')));
+ });
+
+ test(
+ 'returns false for a directory that was added explicitly whose '
+ 'children were then removed', () {
+ paths.add('root/path');
+ paths.add('root/path/to/file');
+ paths.remove('root/path/to/file');
+ expect(paths, isNot(containsDir('root/path')));
+ });
+
+ test('with a non-normalized path normalizes the path before looking it up',
+ () {
+ paths.add('root/path/to/file');
+ expect(paths, containsDir('root/../root/path/to/../to/.'));
+ });
+
+ test('with an absolute path normalizes the path before looking it up', () {
+ paths.add('root/path/to/file');
+ expect(paths, containsDir(p.absolute('root/path')));
+ });
+ });
+
+ group('paths', () {
+ test('returns paths added to the set', () {
+ paths.add('root/path');
+ paths.add('root/path/to/one');
+ paths.add('root/path/to/two');
+
+ expect(
+ paths.paths,
+ unorderedEquals([
+ 'root/path',
+ 'root/path/to/one',
+ 'root/path/to/two',
+ ].map(p.normalize)));
+ });
+
+ test("doesn't return paths removed from the set", () {
+ paths.add('root/path/to/one');
+ paths.add('root/path/to/two');
+ paths.remove('root/path/to/two');
+
+ expect(paths.paths, unorderedEquals([p.normalize('root/path/to/one')]));
+ });
+ });
+
+ group('clear', () {
+ test('removes all paths from the set', () {
+ paths.add('root/path');
+ paths.add('root/path/to/one');
+ paths.add('root/path/to/two');
+
+ paths.clear();
+ expect(paths.paths, isEmpty);
+ });
+ });
+}
diff --git a/pkgs/watcher/test/ready/linux_test.dart b/pkgs/watcher/test/ready/linux_test.dart
new file mode 100644
index 0000000..aac0810
--- /dev/null
+++ b/pkgs/watcher/test/ready/linux_test.dart
@@ -0,0 +1,18 @@
+// Copyright (c) 2013, 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')
+library;
+
+import 'package:test/test.dart';
+import 'package:watcher/src/directory_watcher/linux.dart';
+
+import '../utils.dart';
+import 'shared.dart';
+
+void main() {
+ watcherFactory = LinuxDirectoryWatcher.new;
+
+ sharedTests();
+}
diff --git a/pkgs/watcher/test/ready/mac_os_test.dart b/pkgs/watcher/test/ready/mac_os_test.dart
new file mode 100644
index 0000000..55a8308
--- /dev/null
+++ b/pkgs/watcher/test/ready/mac_os_test.dart
@@ -0,0 +1,18 @@
+// Copyright (c) 2013, 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('mac-os')
+library;
+
+import 'package:test/test.dart';
+import 'package:watcher/src/directory_watcher/mac_os.dart';
+
+import '../utils.dart';
+import 'shared.dart';
+
+void main() {
+ watcherFactory = MacOSDirectoryWatcher.new;
+
+ sharedTests();
+}
diff --git a/pkgs/watcher/test/ready/polling_test.dart b/pkgs/watcher/test/ready/polling_test.dart
new file mode 100644
index 0000000..bfd2958
--- /dev/null
+++ b/pkgs/watcher/test/ready/polling_test.dart
@@ -0,0 +1,14 @@
+// Copyright (c) 2013, 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:watcher/watcher.dart';
+
+import '../utils.dart';
+import 'shared.dart';
+
+void main() {
+ watcherFactory = PollingDirectoryWatcher.new;
+
+ sharedTests();
+}
diff --git a/pkgs/watcher/test/ready/shared.dart b/pkgs/watcher/test/ready/shared.dart
new file mode 100644
index 0000000..ab2c3e1
--- /dev/null
+++ b/pkgs/watcher/test/ready/shared.dart
@@ -0,0 +1,84 @@
+// Copyright (c) 2012, 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:test/test.dart';
+
+import '../utils.dart';
+
+void sharedTests() {
+ test('ready does not complete until after subscription', () async {
+ var watcher = createWatcher();
+
+ var ready = false;
+ unawaited(watcher.ready.then((_) {
+ ready = true;
+ }));
+ await pumpEventQueue();
+
+ expect(ready, isFalse);
+
+ // Subscribe to the events.
+ var subscription = watcher.events.listen((event) {});
+
+ await watcher.ready;
+
+ // Should eventually be ready.
+ expect(watcher.isReady, isTrue);
+
+ await subscription.cancel();
+ });
+
+ test('ready completes immediately when already ready', () async {
+ var watcher = createWatcher();
+
+ // Subscribe to the events.
+ var subscription = watcher.events.listen((event) {});
+
+ // Allow watcher to become ready
+ await watcher.ready;
+
+ // Ensure ready completes immediately
+ expect(
+ watcher.ready.timeout(
+ const Duration(milliseconds: 0),
+ onTimeout: () => throw StateError('Does not complete immediately'),
+ ),
+ completes,
+ );
+
+ await subscription.cancel();
+ });
+
+ test('ready returns a future that does not complete after unsubscribing',
+ () async {
+ var watcher = createWatcher();
+
+ // Subscribe to the events.
+ var subscription = watcher.events.listen((event) {});
+
+ // Wait until ready.
+ await watcher.ready;
+
+ // Now unsubscribe.
+ await subscription.cancel();
+
+ // Should be back to not ready.
+ expect(watcher.ready, doesNotComplete);
+ });
+
+ test('ready completes even if directory does not exist', () async {
+ var watcher = createWatcher(path: 'does/not/exist');
+
+ // Subscribe to the events (else ready will never fire).
+ var subscription = watcher.events.listen((event) {}, onError: (error) {});
+
+ // Expect ready still completes.
+ await watcher.ready;
+
+ // Now unsubscribe.
+ await subscription.cancel();
+ });
+}
diff --git a/pkgs/watcher/test/ready/windows_test.dart b/pkgs/watcher/test/ready/windows_test.dart
new file mode 100644
index 0000000..9f9e5a9
--- /dev/null
+++ b/pkgs/watcher/test/ready/windows_test.dart
@@ -0,0 +1,18 @@
+// Copyright (c) 2022, 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('windows')
+library;
+
+import 'package:test/test.dart';
+import 'package:watcher/src/directory_watcher/windows.dart';
+
+import '../utils.dart';
+import 'shared.dart';
+
+void main() {
+ watcherFactory = WindowsDirectoryWatcher.new;
+
+ sharedTests();
+}
diff --git a/pkgs/watcher/test/utils.dart b/pkgs/watcher/test/utils.dart
new file mode 100644
index 0000000..7867b9f
--- /dev/null
+++ b/pkgs/watcher/test/utils.dart
@@ -0,0 +1,288 @@
+// Copyright (c) 2012, 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:io';
+
+import 'package:async/async.dart';
+import 'package:path/path.dart' as p;
+import 'package:test/test.dart';
+import 'package:test_descriptor/test_descriptor.dart' as d;
+import 'package:watcher/src/stat.dart';
+import 'package:watcher/watcher.dart';
+
+typedef WatcherFactory = Watcher Function(String directory);
+
+/// Sets the function used to create the watcher.
+set watcherFactory(WatcherFactory factory) {
+ _watcherFactory = factory;
+}
+
+/// The mock modification times (in milliseconds since epoch) for each file.
+///
+/// The actual file system has pretty coarse granularity for file modification
+/// times. This means using the real file system requires us to put delays in
+/// the tests to ensure we wait long enough between operations for the mod time
+/// to be different.
+///
+/// Instead, we'll just mock that out. Each time a file is written, we manually
+/// increment the mod time for that file instantly.
+final _mockFileModificationTimes = <String, int>{};
+
+late WatcherFactory _watcherFactory;
+
+/// Creates a new [Watcher] that watches a temporary file or directory.
+///
+/// If [path] is provided, watches a subdirectory in the sandbox with that name.
+Watcher createWatcher({String? path}) {
+ if (path == null) {
+ path = d.sandbox;
+ } else {
+ path = p.join(d.sandbox, path);
+ }
+
+ return _watcherFactory(path);
+}
+
+/// The stream of events from the watcher started with [startWatcher].
+late StreamQueue<WatchEvent> _watcherEvents;
+
+/// Whether the event stream has been closed.
+///
+/// If this is not done by a test (by calling [startClosingEventStream]) it will
+/// be done automatically via [addTearDown] in [startWatcher].
+var _hasClosedStream = true;
+
+/// Creates a new [Watcher] that watches a temporary file or directory and
+/// starts monitoring it for events.
+///
+/// If [path] is provided, watches a path in the sandbox with that name.
+Future<void> startWatcher({String? path}) async {
+ mockGetModificationTime((path) {
+ final normalized = p.normalize(p.relative(path, from: d.sandbox));
+
+ // Make sure we got a path in the sandbox.
+ assert(p.isRelative(normalized) && !normalized.startsWith('..'),
+ 'Path is not in the sandbox: $path not in ${d.sandbox}');
+
+ var mtime = _mockFileModificationTimes[normalized];
+ return mtime != null ? DateTime.fromMillisecondsSinceEpoch(mtime) : null;
+ });
+
+ // We want to wait until we're ready *after* we subscribe to the watcher's
+ // events.
+ var watcher = createWatcher(path: path);
+ _watcherEvents = StreamQueue(watcher.events);
+ // Forces a subscription to the underlying stream.
+ unawaited(_watcherEvents.hasNext);
+
+ _hasClosedStream = false;
+ addTearDown(startClosingEventStream);
+
+ await watcher.ready;
+}
+
+/// Schedule closing the watcher stream after the event queue has been pumped.
+///
+/// This is necessary when events are allowed to occur, but don't have to occur,
+/// at the end of a test. Otherwise, if they don't occur, the test will wait
+/// indefinitely because they might in the future and because the watcher is
+/// normally only closed after the test completes.
+void startClosingEventStream() async {
+ if (_hasClosedStream) return;
+ _hasClosedStream = true;
+ await pumpEventQueue();
+ await _watcherEvents.cancel(immediate: true);
+}
+
+/// A list of [StreamMatcher]s that have been collected using
+/// [_collectStreamMatcher].
+List<StreamMatcher>? _collectedStreamMatchers;
+
+/// Collects all stream matchers that are registered within [block] into a
+/// single stream matcher.
+///
+/// The returned matcher will match each of the collected matchers in order.
+StreamMatcher _collectStreamMatcher(void Function() block) {
+ var oldStreamMatchers = _collectedStreamMatchers;
+ var collectedStreamMatchers = _collectedStreamMatchers = <StreamMatcher>[];
+ try {
+ block();
+ return emitsInOrder(collectedStreamMatchers);
+ } finally {
+ _collectedStreamMatchers = oldStreamMatchers;
+ }
+}
+
+/// Either add [streamMatcher] as an expectation to [_watcherEvents], or collect
+/// it with [_collectStreamMatcher].
+///
+/// [streamMatcher] can be a [StreamMatcher], a [Matcher], or a value.
+Future _expectOrCollect(Matcher streamMatcher) {
+ var collectedStreamMatchers = _collectedStreamMatchers;
+ if (collectedStreamMatchers != null) {
+ collectedStreamMatchers.add(emits(streamMatcher));
+ return Future.sync(() {});
+ } else {
+ return expectLater(_watcherEvents, emits(streamMatcher));
+ }
+}
+
+/// Expects that [matchers] will match emitted events in any order.
+///
+/// [matchers] may be [Matcher]s or values, but not [StreamMatcher]s.
+Future inAnyOrder(Iterable matchers) {
+ matchers = matchers.toSet();
+ return _expectOrCollect(emitsInAnyOrder(matchers));
+}
+
+/// Expects that the expectations established in either [block1] or [block2]
+/// will match the emitted events.
+///
+/// If both blocks match, the one that consumed more events will be used.
+Future allowEither(void Function() block1, void Function() block2) =>
+ _expectOrCollect(emitsAnyOf(
+ [_collectStreamMatcher(block1), _collectStreamMatcher(block2)]));
+
+/// Allows the expectations established in [block] to match the emitted events.
+///
+/// If the expectations in [block] don't match, no error will be raised and no
+/// events will be consumed. If this is used at the end of a test,
+/// [startClosingEventStream] should be called before it.
+Future allowEvents(void Function() block) =>
+ _expectOrCollect(mayEmit(_collectStreamMatcher(block)));
+
+/// Returns a StreamMatcher that matches a [WatchEvent] with the given [type]
+/// and [path].
+Matcher isWatchEvent(ChangeType type, String path) {
+ return predicate((e) {
+ return e is WatchEvent &&
+ e.type == type &&
+ e.path == p.join(d.sandbox, p.normalize(path));
+ }, 'is $type $path');
+}
+
+/// Returns a [Matcher] that matches a [WatchEvent] for an add event for [path].
+Matcher isAddEvent(String path) => isWatchEvent(ChangeType.ADD, path);
+
+/// Returns a [Matcher] that matches a [WatchEvent] for a modification event for
+/// [path].
+Matcher isModifyEvent(String path) => isWatchEvent(ChangeType.MODIFY, path);
+
+/// Returns a [Matcher] that matches a [WatchEvent] for a removal event for
+/// [path].
+Matcher isRemoveEvent(String path) => isWatchEvent(ChangeType.REMOVE, path);
+
+/// Expects that the next event emitted will be for an add event for [path].
+Future expectAddEvent(String path) =>
+ _expectOrCollect(isWatchEvent(ChangeType.ADD, path));
+
+/// Expects that the next event emitted will be for a modification event for
+/// [path].
+Future expectModifyEvent(String path) =>
+ _expectOrCollect(isWatchEvent(ChangeType.MODIFY, path));
+
+/// Expects that the next event emitted will be for a removal event for [path].
+Future expectRemoveEvent(String path) =>
+ _expectOrCollect(isWatchEvent(ChangeType.REMOVE, path));
+
+/// Consumes a modification event for [path] if one is emitted at this point in
+/// the schedule, but doesn't throw an error if it isn't.
+///
+/// If this is used at the end of a test, [startClosingEventStream] should be
+/// called before it.
+Future allowModifyEvent(String path) =>
+ _expectOrCollect(mayEmit(isWatchEvent(ChangeType.MODIFY, path)));
+
+/// Track a fake timestamp to be used when writing files. This always increases
+/// so that files that are deleted and re-created do not have their timestamp
+/// set back to a previously used value.
+int _nextTimestamp = 1;
+
+/// Schedules writing a file in the sandbox at [path] with [contents].
+///
+/// If [contents] is omitted, creates an empty file. If [updateModified] is
+/// `false`, the mock file modification time is not changed.
+void writeFile(String path, {String? contents, bool? updateModified}) {
+ contents ??= '';
+ updateModified ??= true;
+
+ var fullPath = p.join(d.sandbox, path);
+
+ // Create any needed subdirectories.
+ var dir = Directory(p.dirname(fullPath));
+ if (!dir.existsSync()) {
+ dir.createSync(recursive: true);
+ }
+
+ File(fullPath).writeAsStringSync(contents);
+
+ if (updateModified) {
+ path = p.normalize(path);
+
+ _mockFileModificationTimes[path] = _nextTimestamp++;
+ }
+}
+
+/// Schedules deleting a file in the sandbox at [path].
+void deleteFile(String path) {
+ File(p.join(d.sandbox, path)).deleteSync();
+
+ _mockFileModificationTimes.remove(path);
+}
+
+/// Schedules renaming a file in the sandbox from [from] to [to].
+void renameFile(String from, String to) {
+ File(p.join(d.sandbox, from)).renameSync(p.join(d.sandbox, to));
+
+ // Make sure we always use the same separator on Windows.
+ to = p.normalize(to);
+
+ _mockFileModificationTimes.update(to, (value) => value + 1,
+ ifAbsent: () => 1);
+}
+
+/// Schedules creating a directory in the sandbox at [path].
+void createDir(String path) {
+ Directory(p.join(d.sandbox, path)).createSync();
+}
+
+/// Schedules renaming a directory in the sandbox from [from] to [to].
+void renameDir(String from, String to) {
+ Directory(p.join(d.sandbox, from)).renameSync(p.join(d.sandbox, to));
+
+ // Migrate timestamps for any files in this folder.
+ final knownFilePaths = _mockFileModificationTimes.keys.toList();
+ for (final filePath in knownFilePaths) {
+ if (p.isWithin(from, filePath)) {
+ _mockFileModificationTimes[filePath.replaceAll(from, to)] =
+ _mockFileModificationTimes[filePath]!;
+ _mockFileModificationTimes.remove(filePath);
+ }
+ }
+}
+
+/// Schedules deleting a directory in the sandbox at [path].
+void deleteDir(String path) {
+ Directory(p.join(d.sandbox, path)).deleteSync(recursive: true);
+}
+
+/// Runs [callback] with every permutation of non-negative numbers for each
+/// argument less than [limit].
+///
+/// Returns a set of all values returns by [callback].
+///
+/// [limit] defaults to 3.
+Set<S> withPermutations<S>(S Function(int, int, int) callback, {int? limit}) {
+ limit ??= 3;
+ var results = <S>{};
+ for (var i = 0; i < limit; i++) {
+ for (var j = 0; j < limit; j++) {
+ for (var k = 0; k < limit; k++) {
+ results.add(callback(i, j, k));
+ }
+ }
+ }
+ return results;
+}