diff --git a/CHANGELOG.md b/CHANGELOG.md index 95b472e87..8b090a20b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,9 @@ -## 1.49.12 +## 1.50.0 + +### Command Line Interface + +* Closing the standard input stream will now cause the `--watch` command to stop + running. ### Embedded Sass diff --git a/bin/sass.dart b/bin/sass.dart index a95ad65ba..13b17ea53 100644 --- a/bin/sass.dart +++ b/bin/sass.dart @@ -4,6 +4,7 @@ import 'dart:isolate'; +import 'package:async/async.dart'; import 'package:path/path.dart' as p; import 'package:stack_trace/stack_trace.dart'; import 'package:term_glyph/term_glyph.dart' as term_glyph; @@ -55,7 +56,8 @@ Future main(List args) async { var graph = StylesheetGraph( ImportCache(loadPaths: options.loadPaths, logger: options.logger)); if (options.watch) { - await watch(options, graph); + await CancelableOperation.race([onStdinClose(), watch(options, graph)]) + .valueOrCancellation(); return; } diff --git a/lib/src/executable/watch.dart b/lib/src/executable/watch.dart index 8989ab50e..1790a2222 100644 --- a/lib/src/executable/watch.dart +++ b/lib/src/executable/watch.dart @@ -2,8 +2,10 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'dart:async'; import 'dart:collection'; +import 'package:async/async.dart'; import 'package:path/path.dart' as p; import 'package:stack_trace/stack_trace.dart'; import 'package:stream_transform/stream_transform.dart'; @@ -19,41 +21,46 @@ import 'compile_stylesheet.dart'; import 'options.dart'; /// Watches all the files in [graph] for changes and updates them as necessary. -Future watch(ExecutableOptions options, StylesheetGraph graph) async { - var directoriesToWatch = [ - ..._sourceDirectoriesToDestinations(options).keys, - for (var dir in _sourcesToDestinations(options).keys) p.dirname(dir), - ...options.loadPaths - ]; - - var dirWatcher = MultiDirWatcher(poll: options.poll); - await Future.wait(directoriesToWatch.map((dir) { - // If a directory doesn't exist, watch its parent directory so that we're - // notified once it starts existing. - while (!dirExists(dir)) { - dir = p.dirname(dir); - } - return dirWatcher.watch(dir); - })); - - // Before we start paying attention to changes, compile all the stylesheets as - // they currently exist. This ensures that changes that come in update a - // known-good state. - var watcher = _Watcher(options, graph); - for (var entry in _sourcesToDestinations(options).entries) { - graph.addCanonical(FilesystemImporter('.'), - p.toUri(canonicalize(entry.key)), p.toUri(entry.key), - recanonicalize: false); - var success = - await watcher.compile(entry.key, entry.value, ifModified: true); - if (!success && options.stopOnError) { - dirWatcher.events.listen(null).cancel(); - return; +///z +/// Canceling the operation closes the watcher. +CancelableOperation watch( + ExecutableOptions options, StylesheetGraph graph) { + return unwrapCancelableOperation(() async { + var directoriesToWatch = [ + ..._sourceDirectoriesToDestinations(options).keys, + for (var dir in _sourcesToDestinations(options).keys) p.dirname(dir), + ...options.loadPaths + ]; + + var dirWatcher = MultiDirWatcher(poll: options.poll); + await Future.wait(directoriesToWatch.map((dir) { + // If a directory doesn't exist, watch its parent directory so that we're + // notified once it starts existing. + while (!dirExists(dir)) { + dir = p.dirname(dir); + } + return dirWatcher.watch(dir); + })); + + // Before we start paying attention to changes, compile all the stylesheets as + // they currently exist. This ensures that changes that come in update a + // known-good state. + var watcher = _Watcher(options, graph); + for (var entry in _sourcesToDestinations(options).entries) { + graph.addCanonical(FilesystemImporter('.'), + p.toUri(canonicalize(entry.key)), p.toUri(entry.key), + recanonicalize: false); + var success = + await watcher.compile(entry.key, entry.value, ifModified: true); + if (!success && options.stopOnError) { + dirWatcher.events.listen(null).cancel(); + return CancelableOperation.fromFuture(Future.value()); + } } - } - print("Sass is watching for changes. Press Ctrl-C to stop.\n"); - await watcher.watch(dirWatcher); + print("Sass is watching for changes. Press Ctrl-C to stop.\n"); + return watcher.watch(dirWatcher); + }()); } /// Holds state that's shared across functions that react to changes on the @@ -124,31 +131,39 @@ class _Watcher { /// Listens to `watcher.events` and updates the filesystem accordingly. /// - /// Returns a future that will only complete if an unexpected error occurs. - Future watch(MultiDirWatcher watcher) async { - await for (var event in _debounceEvents(watcher.events)) { - var extension = p.extension(event.path); - if (extension != '.sass' && extension != '.scss' && extension != '.css') { - continue; - } + /// Returns an operation that will only complete if an unexpected error occurs + /// (or if a complation error occurs and `--stop-on-error` is passed). This + /// operation can be cancelled to close the watcher. + CancelableOperation watch(MultiDirWatcher watcher) { + StreamSubscription? subscription; + return CancelableOperation.fromFuture(() async { + subscription = _debounceEvents(watcher.events).listen(null); + await for (var event in SubscriptionStream(subscription!)) { + var extension = p.extension(event.path); + if (extension != '.sass' && + extension != '.scss' && + extension != '.css') { + continue; + } - switch (event.type) { - case ChangeType.MODIFY: - var success = await _handleModify(event.path); - if (!success && _options.stopOnError) return; - break; - - case ChangeType.ADD: - var success = await _handleAdd(event.path); - if (!success && _options.stopOnError) return; - break; - - case ChangeType.REMOVE: - var success = await _handleRemove(event.path); - if (!success && _options.stopOnError) return; - break; + switch (event.type) { + case ChangeType.MODIFY: + var success = await _handleModify(event.path); + if (!success && _options.stopOnError) return; + break; + + case ChangeType.ADD: + var success = await _handleAdd(event.path); + if (!success && _options.stopOnError) return; + break; + + case ChangeType.REMOVE: + var success = await _handleRemove(event.path); + if (!success && _options.stopOnError) return; + break; + } } - } + }(), onCancel: () => subscription?.cancel()); } /// Handles a modify event for the stylesheet at [path]. diff --git a/lib/src/io/interface.dart b/lib/src/io/interface.dart index 0ee35bc53..f25b6cc3e 100644 --- a/lib/src/io/interface.dart +++ b/lib/src/io/interface.dart @@ -2,6 +2,7 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'package:async/async.dart'; import 'package:watcher/watcher.dart'; /// An output sink that writes to this process's standard error. @@ -94,6 +95,15 @@ String? getEnvironmentVariable(String name) => throw ''; int get exitCode => throw ''; set exitCode(int value) => throw ''; +/// If stdin is a TTY, returns a [CancelableOperation] that completes once it +/// closes. +/// +/// Otherwise, returns a [CancelableOperation] that never completes. +/// +/// As long as this is uncanceled, it will monopolize stdin so that nothing else +/// can read from it. +CancelableOperation onStdinClose() => throw ''; + /// Recursively watches the directory at [path] for modifications. /// /// Returns a future that completes with a single-subscription stream once the diff --git a/lib/src/io/node.dart b/lib/src/io/node.dart index 12e24d5a6..97a13b009 100644 --- a/lib/src/io/node.dart +++ b/lib/src/io/node.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:js_util'; +import 'package:async/async.dart'; import 'package:js/js.dart'; import 'package:node_interop/fs.dart'; import 'package:node_interop/node_interop.dart'; @@ -195,6 +196,9 @@ final stderr = Stderr(process.stderr); @JS('process.stdout.isTTY') external bool? get isTTY; +@JS('process.stdin.isTTY') +external bool? get isStdinTTY; + bool get hasTerminal => isTTY == true; bool get isWindows => process.platform == 'win32'; @@ -212,6 +216,14 @@ int get exitCode => process.exitCode; set exitCode(int code) => process.exitCode = code; +CancelableOperation onStdinClose() { + var completer = CancelableCompleter(); + if (isStdinTTY == true) { + process.stdin.on('end', allowInterop(() => completer.complete())); + } + return completer.operation; +} + Future> watchDir(String path, {bool poll = false}) { var watcher = chokidar.watch( path, ChokidarOptions(disableGlobbing: true, usePolling: poll)); diff --git a/lib/src/io/vm.dart b/lib/src/io/vm.dart index bdc89916b..72d74fde4 100644 --- a/lib/src/io/vm.dart +++ b/lib/src/io/vm.dart @@ -90,6 +90,10 @@ DateTime modificationTime(String path) { String? getEnvironmentVariable(String name) => io.Platform.environment[name]; +CancelableOperation onStdinClose() => io.stdin.hasTerminal + ? CancelableOperation.fromSubscription(io.stdin.listen(null)) + : CancelableCompleter().operation; + Future> watchDir(String path, {bool poll = false}) async { var watcher = poll ? PollingDirectoryWatcher(path) : DirectoryWatcher(path); diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 07f7fc046..3542a9cba 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; +import 'package:async/async.dart'; import 'package:charcode/charcode.dart'; import 'package:collection/collection.dart'; import 'package:source_span/source_span.dart'; @@ -379,6 +380,22 @@ Future putIfAbsentAsync( return value; } +/// Unwraps a [Future] that wraps a [CancelableOperation]. +/// +/// If the returned operation is cancelled, it will cancel the inner operation +/// as soon as the future completes. +CancelableOperation unwrapCancelableOperation( + Future> future) { + var completer = CancelableCompleter( + onCancel: () => future.then((operation) => operation.cancel())); + + future.then((operation) { + operation.then(completer.complete, onError: completer.completeError); + }, onError: completer.completeError); + + return completer.operation; +} + /// Returns a deep copy of a map that contains maps. Map> copyMapOfMap(Map> map) => {for (var entry in map.entries) entry.key: Map.of(entry.value)}; diff --git a/pubspec.yaml b/pubspec.yaml index d758d89cd..99a6d535f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: sass -version: 1.49.12-dev +version: 1.50.0-dev description: A Sass implementation in Dart. homepage: https://github.com/sass/dart-sass @@ -12,7 +12,7 @@ environment: dependencies: args: ^2.0.0 - async: ^2.5.0 + async: ^2.9.0 charcode: ^1.2.0 cli_repl: ^0.2.1 collection: ^1.15.0