Merge pull request #91 from michalt/custom-watcher-factory

Implement the ability to register custom watcher implementations
diff --git a/lib/src/custom_watcher_factory.dart b/lib/src/custom_watcher_factory.dart
new file mode 100644
index 0000000..b7f81fa
--- /dev/null
+++ b/lib/src/custom_watcher_factory.dart
@@ -0,0 +1,83 @@
+import '../watcher.dart';
+
+/// Defines a way to create a custom watcher instead of the default ones.
+///
+/// This will be used when a [DirectoryWatcher] or [FileWatcher] would be
+/// created and will take precedence over the default ones.
+abstract class CustomWatcherFactory {
+  /// Uniquely identify this watcher.
+  String get id;
+
+  /// Tries to create a [DirectoryWatcher] for the provided path.
+  ///
+  /// Returns `null` if the path is not supported by this factory.
+  DirectoryWatcher createDirectoryWatcher(String path, {Duration pollingDelay});
+
+  /// Tries to create a [FileWatcher] for the provided path.
+  ///
+  /// Returns `null` if the path is not supported by this factory.
+  FileWatcher createFileWatcher(String path, {Duration pollingDelay});
+}
+
+/// Registers a custom watcher.
+///
+/// It's only allowed to register a watcher factory once per [id] and at most
+/// one factory should apply to any given file (creating a [Watcher] will fail
+/// otherwise).
+void registerCustomWatcherFactory(CustomWatcherFactory customFactory) {
+  if (_customWatcherFactories.containsKey(customFactory.id)) {
+    throw ArgumentError('A custom watcher with id `${customFactory.id}` '
+        'has already been registered');
+  }
+  _customWatcherFactories[customFactory.id] = customFactory;
+}
+
+/// 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) {
+    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) {
+    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;
+}
+
+/// Unregisters a custom watcher and returns it.
+///
+/// Returns `null` if the id was never registered.
+CustomWatcherFactory unregisterCustomWatcherFactory(String id) =>
+    _customWatcherFactories.remove(id);
+
+Iterable<CustomWatcherFactory> get customWatcherFactories =>
+    _customWatcherFactories.values;
+
+final _customWatcherFactories = <String, CustomWatcherFactory>{};
diff --git a/lib/src/directory_watcher.dart b/lib/src/directory_watcher.dart
index e0ef3fc..3fe5004 100644
--- a/lib/src/directory_watcher.dart
+++ b/lib/src/directory_watcher.dart
@@ -5,10 +5,11 @@
 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';
 import 'directory_watcher/polling.dart';
+import 'directory_watcher/windows.dart';
 
 /// Watches the contents of a directory and emits [WatchEvent]s when something
 /// in the directory has changed.
@@ -29,6 +30,9 @@
   /// 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);
diff --git a/lib/src/file_watcher.dart b/lib/src/file_watcher.dart
index c4abddd..c41e5e6 100644
--- a/lib/src/file_watcher.dart
+++ b/lib/src/file_watcher.dart
@@ -5,6 +5,7 @@
 import 'dart:io';
 
 import '../watcher.dart';
+import 'custom_watcher_factory.dart';
 import 'file_watcher/native.dart';
 import 'file_watcher/polling.dart';
 
@@ -29,6 +30,10 @@
   /// 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.
diff --git a/lib/watcher.dart b/lib/watcher.dart
index 107ac8f..f5c7d3e 100644
--- a/lib/watcher.dart
+++ b/lib/watcher.dart
@@ -5,15 +5,20 @@
 import 'dart:async';
 import 'dart:io';
 
-import 'src/watch_event.dart';
 import 'src/directory_watcher.dart';
 import 'src/file_watcher.dart';
+import 'src/watch_event.dart';
 
-export 'src/watch_event.dart';
+export 'src/custom_watcher_factory.dart'
+    show
+        CustomWatcherFactory,
+        registerCustomWatcherFactory,
+        unregisterCustomWatcherFactory;
 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.
diff --git a/test/custom_watcher_factory_test.dart b/test/custom_watcher_factory_test.dart
new file mode 100644
index 0000000..6c9ffd8
--- /dev/null
+++ b/test/custom_watcher_factory_test.dart
@@ -0,0 +1,146 @@
+import 'dart:async';
+
+import 'package:test/test.dart';
+import 'package:watcher/watcher.dart';
+
+import 'utils.dart';
+
+void main() {
+  _MemFs memFs;
+  final defaultFactoryId = 'MemFs';
+
+  setUp(() {
+    memFs = _MemFs();
+    registerCustomWatcherFactory(_MemFsWatcherFactory(defaultFactoryId, memFs));
+  });
+
+  tearDown(() async {
+    unregisterCustomWatcherFactory(defaultFactoryId);
+  });
+
+  test('notifes 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('notifes 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('unregister works', () async {
+    unregisterCustomWatcherFactory(defaultFactoryId);
+
+    watcherFactory = (path) => FileWatcher(path);
+    try {
+      // This uses standard files, so it wouldn't trigger an event in
+      // _MemFsWatcher.
+      writeFile('file.txt');
+      await startWatcher(path: 'file.txt');
+      deleteFile('file.txt');
+    } finally {
+      watcherFactory = null;
+    }
+
+    await expectRemoveEvent('file.txt');
+  });
+
+  test('registering twice throws', () async {
+    expect(
+        () => registerCustomWatcherFactory(
+            _MemFsWatcherFactory(defaultFactoryId, memFs)),
+        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.
+    registerCustomWatcherFactory(_MemFsWatcherFactory('Different id', memFs));
+    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 implements CustomWatcherFactory {
+  @override
+  final String id;
+  final _MemFs _memFs;
+  _MemFsWatcherFactory(this.id, this._memFs);
+
+  @override
+  DirectoryWatcher createDirectoryWatcher(String path,
+          {Duration pollingDelay}) =>
+      _MemFsWatcher(path, _memFs.watchStream(path));
+
+  @override
+  FileWatcher createFileWatcher(String path, {Duration pollingDelay}) =>
+      _MemFsWatcher(path, _memFs.watchStream(path));
+}