Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Abort sass if stdin is closed when watching #1411

Merged
merged 6 commits into from Apr 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 6 additions & 1 deletion 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

Expand Down
4 changes: 3 additions & 1 deletion bin/sass.dart
Expand Up @@ -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;
Expand Down Expand Up @@ -55,7 +56,8 @@ Future<void> main(List<String> 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;
}

Expand Down
127 changes: 71 additions & 56 deletions lib/src/executable/watch.dart
Expand Up @@ -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';
Expand All @@ -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<void> 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<void> 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<void>.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
Expand Down Expand Up @@ -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<void> 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<void> watch(MultiDirWatcher watcher) {
StreamSubscription<WatchEvent>? subscription;
return CancelableOperation<void>.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].
Expand Down
10 changes: 10 additions & 0 deletions lib/src/io/interface.dart
Expand Up @@ -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.
Expand Down Expand Up @@ -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<void> onStdinClose() => throw '';

/// Recursively watches the directory at [path] for modifications.
///
/// Returns a future that completes with a single-subscription stream once the
Expand Down
12 changes: 12 additions & 0 deletions lib/src/io/node.dart
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand All @@ -212,6 +216,14 @@ int get exitCode => process.exitCode;

set exitCode(int code) => process.exitCode = code;

CancelableOperation<void> onStdinClose() {
var completer = CancelableCompleter<void>();
if (isStdinTTY == true) {
process.stdin.on('end', allowInterop(() => completer.complete()));
}
return completer.operation;
}

Future<Stream<WatchEvent>> watchDir(String path, {bool poll = false}) {
var watcher = chokidar.watch(
path, ChokidarOptions(disableGlobbing: true, usePolling: poll));
Expand Down
4 changes: 4 additions & 0 deletions lib/src/io/vm.dart
Expand Up @@ -90,6 +90,10 @@ DateTime modificationTime(String path) {

String? getEnvironmentVariable(String name) => io.Platform.environment[name];

CancelableOperation<void> onStdinClose() => io.stdin.hasTerminal
? CancelableOperation.fromSubscription(io.stdin.listen(null))
: CancelableCompleter<void>().operation;

Future<Stream<WatchEvent>> watchDir(String path, {bool poll = false}) async {
var watcher = poll ? PollingDirectoryWatcher(path) : DirectoryWatcher(path);

Expand Down
17 changes: 17 additions & 0 deletions lib/src/utils.dart
Expand Up @@ -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';
Expand Down Expand Up @@ -379,6 +380,22 @@ Future<V> putIfAbsentAsync<K, V>(
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<T> unwrapCancelableOperation<T>(
Future<CancelableOperation<T>> future) {
var completer = CancelableCompleter<T>(
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<K1, Map<K2, V>> copyMapOfMap<K1, K2, V>(Map<K1, Map<K2, V>> map) =>
{for (var entry in map.entries) entry.key: Map.of(entry.value)};
Expand Down
4 changes: 2 additions & 2 deletions 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

Expand All @@ -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
Expand Down