diff --git a/.github/ISSUE_TEMPLATE/io.md b/.github/ISSUE_TEMPLATE/io.md new file mode 100644 index 000000000..5646f0f94 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/io.md @@ -0,0 +1,5 @@ +--- +name: "package:io" +about: "Create a bug or file a feature request against package:io." +labels: "package:io" +--- \ No newline at end of file diff --git a/.github/labeler.yml b/.github/labeler.yml index 45c2239b1..eca80bbc2 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -64,6 +64,10 @@ - changed-files: - any-glob-to-any-file: 'pkgs/html/**' +'package:io': + - changed-files: + - any-glob-to-any-file: 'pkgs/io/**' + 'package:json_rpc_2': - changed-files: - any-glob-to-any-file: 'pkgs/json_rpc_2/**' diff --git a/.github/workflows/io.yaml b/.github/workflows/io.yaml new file mode 100644 index 000000000..0c719a6bf --- /dev/null +++ b/.github/workflows/io.yaml @@ -0,0 +1,72 @@ +name: package:io + +on: + # Run on PRs and pushes to the default branch. + push: + branches: [ main ] + paths: + - '.github/workflows/io.yml' + - 'pkgs/io/**' + pull_request: + branches: [ main ] + paths: + - '.github/workflows/io.yml' + - 'pkgs/io/**' + schedule: + - cron: "0 0 * * 0" + +env: + PUB_ENVIRONMENT: bot.github + + +defaults: + run: + working-directory: pkgs/io/ + + +jobs: + # Check code formatting and static analysis on a single OS (linux) + # against Dart dev and stable. + analyze: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: [dev, 3.4] + 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, stable + test: + needs: analyze + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # Add macos-latest and/or windows-latest if relevant for this package. + os: [ubuntu-latest] + sdk: [dev, 3.4] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - run: dart test + if: always() && steps.install.outcome == 'success' diff --git a/README.md b/README.md index ed90416dd..50517c36e 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ don't naturally belong to other topic monorepos (like | [file_testing](pkgs/file_testing/) | Testing utilities for package:file. | [![package issues](https://img.shields.io/badge/package:file_testing-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Afile_testing) | [![pub package](https://img.shields.io/pub/v/file_testing.svg)](https://pub.dev/packages/file_testing) | | [graphs](pkgs/graphs/) | Graph algorithms that operate on graphs in any representation. | [![package issues](https://img.shields.io/badge/package:graphs-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Agraphs) | [![pub package](https://img.shields.io/pub/v/graphs.svg)](https://pub.dev/packages/graphs) | | [html](pkgs/html/) | APIs for parsing and manipulating HTML content outside the browser. | [![package issues](https://img.shields.io/badge/package:html-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Ahtml) | [![pub package](https://img.shields.io/pub/v/html.svg)](https://pub.dev/packages/html) | +| [io](pkgs/io/) | Utilities for the Dart VM Runtime including support for ANSI colors, file copying, and standard exit code values. | [![pub package](https://img.shields.io/pub/v/io.svg)](https://pub.dev/packages/io) | | [json_rpc_2](pkgs/json_rpc_2/) | Utilities to write a client or server using the JSON-RPC 2.0 spec. | [![package issues](https://img.shields.io/badge/package:json_rpc_2-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Ajson_rpc_2) | [![pub package](https://img.shields.io/pub/v/json_rpc_2.svg)](https://pub.dev/packages/json_rpc_2) | | [mime](pkgs/mime/) | Utilities for handling media (MIME) types, including determining a type from a file extension and file contents. | [![package issues](https://img.shields.io/badge/package:mime-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Amime) | [![pub package](https://img.shields.io/pub/v/mime.svg)](https://pub.dev/packages/mime) | | [oauth2](pkgs/oauth2/) | A client library for authenticating with a remote service via OAuth2 on behalf of a user, and making authorized HTTP requests with the user's OAuth2 credentials. | [![package issues](https://img.shields.io/badge/package:oauth2-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Aoauth2) | [![pub package](https://img.shields.io/pub/v/oauth2.svg)](https://pub.dev/packages/oauth2) | diff --git a/pkgs/io/.gitignore b/pkgs/io/.gitignore new file mode 100644 index 000000000..01d42c084 --- /dev/null +++ b/pkgs/io/.gitignore @@ -0,0 +1,4 @@ +.dart_tool/ +.pub/ +.packages +pubspec.lock diff --git a/pkgs/io/AUTHORS b/pkgs/io/AUTHORS new file mode 100644 index 000000000..ff0936427 --- /dev/null +++ b/pkgs/io/AUTHORS @@ -0,0 +1,7 @@ +# Below is a list of people and organizations that have contributed +# to the project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. + diff --git a/pkgs/io/CHANGELOG.md b/pkgs/io/CHANGELOG.md new file mode 100644 index 000000000..e0631fa95 --- /dev/null +++ b/pkgs/io/CHANGELOG.md @@ -0,0 +1,119 @@ +## 1.0.5 + +* Require Dart 3.4. +* Move to `dart-lang/tools` monorepo. + +## 1.0.4 + +* Updates to the readme. + +## 1.0.3 + +* Revert `meta` constraint to `^1.3.0`. + +## 1.0.2 + +* Update `meta` constraint to `>=1.3.0 <3.0.0`. + +## 1.0.1 + +* Update code examples to call the unified `dart` developer tool. + +## 1.0.0 + +* Migrate this package to null-safety. +* Require Dart >=2.12. + +## 0.3.5 + +* Require Dart >=2.1. +* Remove dependency on `package:charcode`. + +## 0.3.4 + +* Fix a number of issues affecting the package score on `pub.dev`. + +## 0.3.3 + +* Updates for Dart 2 constants. Require at least Dart `2.0.0-dev.54`. + +* Fix the type of `StartProcess` typedef to match `Process.start` from + `dart:io`. + +## 0.3.2+1 + +* `ansi.dart` + + * The "forScript" code paths now ignore the `ansiOutputEnabled` value. Affects + the `escapeForScript` property on `AnsiCode` and the `wrap` and `wrapWith` + functions when `forScript` is true. + +## 0.3.2 + +* `ansi.dart` + + * Added `forScript` named argument to top-level `wrapWith` function. + + * `AnsiCode` + + * Added `String get escapeForScript` property. + + * Added `forScript` named argument to `wrap` function. + +## 0.3.1 + +- Added `SharedStdIn.nextLine` (similar to `readLineSync`) and `lines`: + +```dart +main() async { + // Prints the first line entered on stdin. + print(await sharedStdIn.nextLine()); + + // Prints all remaining lines. + await for (final line in sharedStdIn.lines) { + print(line); + } +} +``` + +- Added a `copyPath` and `copyPathSync` function, similar to `cp -R`. + +- Added a dependency on `package:path`. + +- Added the remaining missing arguments to `ProcessManager.spawnX` which + forward to `Process.start`. It is now an interchangeable function for running + a process. + +## 0.3.0 + +- **BREAKING CHANGE**: The `arguments` argument to `ProcessManager.spawn` is + now positional (not named) and required. This makes it more similar to the + built-in `Process.start`, and easier to use as a drop in replacement: + +```dart +main() { + processManager.spawn('dart', ['--version']); +} +``` + +- Fixed a bug where processes created from `ProcessManager.spawn` could not + have their `stdout`/`stderr` read through their respective getters (a runtime + error was always thrown). + +- Added `ProcessMangaer#spawnBackground`, which does not forward `stdin`. + +- Added `ProcessManager#spawnDetached`, which does not forward any I/O. + +- Added the `shellSplit()` function, which parses a list of arguments in the + same manner as [the POSIX shell][what_is_posix_shell]. + +[what_is_posix_shell]: https://pubs.opengroup.org/onlinepubs/9699919799/utilities/contents.html + +## 0.2.0 + +- Initial commit of... + - `FutureOr String isExecutable(path)`. + - `ExitCode` + - `ProcessManager` and `Spawn` + - `sharedStdIn` and `SharedStdIn` + - `ansi.dart` library with support for formatting terminal output diff --git a/pkgs/io/LICENSE b/pkgs/io/LICENSE new file mode 100644 index 000000000..03af64abe --- /dev/null +++ b/pkgs/io/LICENSE @@ -0,0 +1,27 @@ +Copyright 2017, 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/io/README.md b/pkgs/io/README.md new file mode 100644 index 000000000..adbc94162 --- /dev/null +++ b/pkgs/io/README.md @@ -0,0 +1,104 @@ +[![Build Status](https://github.com/dart-lang/tools/actions/workflows/io.yaml/badge.svg)](https://github.com/dart-lang/tools/actions/workflows/io.yaml) +[![pub package](https://img.shields.io/pub/v/io.svg)](https://pub.dev/packages/io) +[![package publisher](https://img.shields.io/pub/publisher/io.svg)](https://pub.dev/packages/io/publisher) + +Contains utilities for the Dart VM's `dart:io`. + +## Usage - `io.dart` + +### Files + +#### `isExecutable` + +Returns whether a provided file path is considered _executable_ on the host +operating system. + +### Processes + +#### `ExitCode` + +An `enum`-like class that contains known exit codes. + +#### `ProcessManager` + +A higher-level service for spawning and communicating with processes. + +##### Use `spawn` to create a process with std[in|out|err] forwarded by default + +```dart +Future main() async { + final manager = ProcessManager(); + + // Print `dart` tool version to stdout. + print('** Running `dart --version`'); + var spawn = await manager.spawn('dart', ['--version']); + await spawn.exitCode; + + // Check formatting and print the result to stdout. + print('** Running `dart format --output=none .`'); + spawn = await manager.spawn('dart', ['format', '--output=none', '.']); + await spawn.exitCode; + + // Check if a package is ready for publishing. + // Upon hitting a blocking stdin state, you may directly + // output to the processes's stdin via your own, similar to how a bash or + // shell script would spawn a process. + print('** Running pub publish'); + spawn = await manager.spawn('dart', ['pub', 'publish', '--dry-run']); + await spawn.exitCode; + + // Closes stdin for the entire program. + await sharedStdIn.terminate(); +} +``` + +#### `sharedStdIn` + +A safer version of the default `stdin` stream from `dart:io` that allows a +subscriber to cancel their subscription, and then allows a _new_ subscriber to +start listening. This differs from the default behavior where only a single +listener is ever allowed in the application lifecycle: + +```dart +test('should allow multiple subscribers', () async { + final logs = []; + final asUtf8 = sharedStdIn.transform(UTF8.decoder); + // Wait for input for the user. + logs.add(await asUtf8.first); + // Wait for more input for the user. + logs.add(await asUtf8.first); + expect(logs, ['Hello World', 'Goodbye World']); +}); +``` + +For testing, an instance of `SharedStdIn` may be created directly. + +## Usage - `ansi.dart` + +```dart +import 'dart:io' as io; +import 'package:io/ansi.dart'; + +void main() { + // To use one style, call the `wrap` method on one of the provided top-level + // values. + io.stderr.writeln(red.wrap("Bad error!")); + + // To use multiple styles, call `wrapWith`. + print(wrapWith('** Important **', [red, styleBold, styleUnderlined])); + + // The wrap functions will simply return the provided value unchanged if + // `ansiOutputEnabled` is false. + // + // You can override the value `ansiOutputEnabled` by wrapping code in + // `overrideAnsiOutput`. + overrideAnsiOutput(false, () { + assert('Normal text' == green.wrap('Normal text')); + }); +} +``` + +## Publishing automation + +For information about our publishing automation and release process, see +https://github.com/dart-lang/ecosystem/wiki/Publishing-automation. diff --git a/pkgs/io/analysis_options.yaml b/pkgs/io/analysis_options.yaml new file mode 100644 index 000000000..6d74ee93f --- /dev/null +++ b/pkgs/io/analysis_options.yaml @@ -0,0 +1,32 @@ +# https://dart.dev/guides/language/analysis-options +include: package:dart_flutter_team_lints/analysis_options.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + +linter: + rules: + - avoid_bool_literals_in_conditional_expressions + - avoid_classes_with_only_static_members + - avoid_private_typedef_functions + - avoid_redundant_argument_values + - avoid_returning_this + - avoid_unused_constructor_parameters + - avoid_void_async + - cancel_subscriptions + - join_return_with_assignment + - literal_only_boolean_expressions + - missing_whitespace_between_adjacent_strings + - no_adjacent_strings_in_list + - no_runtimeType_toString + - prefer_const_declarations + - prefer_expression_function_bodies + - prefer_final_locals + - unnecessary_await_in_return + - unnecessary_breaks + - use_if_null_to_convert_nulls_to_bools + - use_raw_strings + - use_string_buffers diff --git a/pkgs/io/example/example.dart b/pkgs/io/example/example.dart new file mode 100644 index 000000000..8e358fdbb --- /dev/null +++ b/pkgs/io/example/example.dart @@ -0,0 +1,33 @@ +// Copyright (c) 2017, 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:math'; + +import 'package:io/ansi.dart'; + +/// Prints a sample of all of the `AnsiCode` values. +void main(List args) { + final forScript = args.contains('--for-script'); + + if (!ansiOutputEnabled) { + print('`ansiOutputEnabled` is `false`.'); + print("Don't expect pretty output."); + } + _preview('Foreground', foregroundColors, forScript); + _preview('Background', backgroundColors, forScript); + _preview('Styles', styles, forScript); +} + +void _preview(String name, List values, bool forScript) { + print(''); + final longest = values.map((ac) => ac.name.length).reduce(max); + + print(wrapWith('** $name **', [styleBold, styleUnderlined])); + for (var code in values) { + final header = + '${code.name.padRight(longest)} ${code.code.toString().padLeft(3)}'; + + print("$header: ${code.wrap('Sample', forScript: forScript)}"); + } +} diff --git a/pkgs/io/example/spawn_process_example.dart b/pkgs/io/example/spawn_process_example.dart new file mode 100644 index 000000000..b7ba24748 --- /dev/null +++ b/pkgs/io/example/spawn_process_example.dart @@ -0,0 +1,33 @@ +// Copyright (c) 2017, 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:io/io.dart'; + +/// Runs a few subcommands in the `dart` command. +Future main() async { + final manager = ProcessManager(); + + // Print `dart` tool version to stdout. + print('** Running `dart --version`'); + var spawn = await manager.spawn('dart', ['--version']); + await spawn.exitCode; + + // Check formatting and print the result to stdout. + print('** Running `dart format --output=none .`'); + spawn = await manager.spawn('dart', ['format', '--output=none', '.']); + await spawn.exitCode; + + // Check if a package is ready for publishing. + // Upon hitting a blocking stdin state, you may directly + // output to the processes's stdin via your own, similar to how a bash or + // shell script would spawn a process. + print('** Running pub publish'); + spawn = await manager.spawn('dart', ['pub', 'publish', '--dry-run']); + await spawn.exitCode; + + // Closes stdin for the entire program. + await sharedStdIn.terminate(); +} diff --git a/pkgs/io/lib/ansi.dart b/pkgs/io/lib/ansi.dart new file mode 100644 index 000000000..a2adbe791 --- /dev/null +++ b/pkgs/io/lib/ansi.dart @@ -0,0 +1,5 @@ +// Copyright (c) 2017, 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. + +export 'src/ansi_code.dart'; diff --git a/pkgs/io/lib/io.dart b/pkgs/io/lib/io.dart new file mode 100644 index 000000000..8ee0843af --- /dev/null +++ b/pkgs/io/lib/io.dart @@ -0,0 +1,10 @@ +// Copyright (c) 2017, 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. + +export 'src/copy_path.dart' show copyPath, copyPathSync; +export 'src/exit_code.dart' show ExitCode; +export 'src/permissions.dart' show isExecutable; +export 'src/process_manager.dart' show ProcessManager, Spawn, StartProcess; +export 'src/shared_stdin.dart' show SharedStdIn, sharedStdIn; +export 'src/shell_words.dart' show shellSplit; diff --git a/pkgs/io/lib/src/ansi_code.dart b/pkgs/io/lib/src/ansi_code.dart new file mode 100644 index 000000000..c9a22c541 --- /dev/null +++ b/pkgs/io/lib/src/ansi_code.dart @@ -0,0 +1,316 @@ +// Copyright (c) 2017, 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' as io; + +const _ansiEscapeLiteral = '\x1B'; +const _ansiEscapeForScript = r'\033'; + +/// Whether formatted ANSI output is enabled for [wrapWith] and [AnsiCode.wrap]. +/// +/// By default, returns `true` if both `stdout.supportsAnsiEscapes` and +/// `stderr.supportsAnsiEscapes` from `dart:io` are `true`. +/// +/// The default can be overridden by setting the [Zone] variable [AnsiCode] to +/// either `true` or `false`. +/// +/// [overrideAnsiOutput] is provided to make this easy. +bool get ansiOutputEnabled => + Zone.current[AnsiCode] as bool? ?? + (io.stdout.supportsAnsiEscapes && io.stderr.supportsAnsiEscapes); + +/// Returns `true` no formatting is required for [input]. +bool _isNoop(bool skip, String? input, bool? forScript) => + skip || + input == null || + input.isEmpty || + !((forScript ?? false) || ansiOutputEnabled); + +/// Allows overriding [ansiOutputEnabled] to [enableAnsiOutput] for the code run +/// within [body]. +T overrideAnsiOutput(bool enableAnsiOutput, T Function() body) => + runZoned(body, zoneValues: {AnsiCode: enableAnsiOutput}); + +/// The type of code represented by [AnsiCode]. +class AnsiCodeType { + final String _name; + + /// A foreground color. + static const AnsiCodeType foreground = AnsiCodeType._('foreground'); + + /// A style. + static const AnsiCodeType style = AnsiCodeType._('style'); + + /// A background color. + static const AnsiCodeType background = AnsiCodeType._('background'); + + /// A reset value. + static const AnsiCodeType reset = AnsiCodeType._('reset'); + + const AnsiCodeType._(this._name); + + @override + String toString() => 'AnsiType.$_name'; +} + +/// Standard ANSI escape code for customizing terminal text output. +/// +/// [Source](https://en.wikipedia.org/wiki/ANSI_escape_code#Colors) +class AnsiCode { + /// The numeric value associated with this code. + final int code; + + /// The [AnsiCode] that resets this value, if one exists. + /// + /// Otherwise, `null`. + final AnsiCode? reset; + + /// A description of this code. + final String name; + + /// The type of code that is represented. + final AnsiCodeType type; + + const AnsiCode._(this.name, this.type, this.code, this.reset); + + /// Represents the value escaped for use in terminal output. + String get escape => '$_ansiEscapeLiteral[${code}m'; + + /// Represents the value as an unescaped literal suitable for scripts. + String get escapeForScript => '$_ansiEscapeForScript[${code}m'; + + String _escapeValue({bool forScript = false}) => + forScript ? escapeForScript : escape; + + /// Wraps [value] with the [escape] value for this code, followed by + /// [resetAll]. + /// + /// If [forScript] is `true`, the return value is an unescaped literal. The + /// value of [ansiOutputEnabled] is also ignored. + /// + /// Returns `value` unchanged if + /// * [value] is `null` or empty + /// * both [ansiOutputEnabled] and [forScript] are `false`. + /// * [type] is [AnsiCodeType.reset] + String? wrap(String? value, {bool forScript = false}) => + _isNoop(type == AnsiCodeType.reset, value, forScript) + ? value + : '${_escapeValue(forScript: forScript)}$value' + '${reset!._escapeValue(forScript: forScript)}'; + + @override + String toString() => '$name ${type._name} ($code)'; +} + +/// Returns a [String] formatted with [codes]. +/// +/// If [forScript] is `true`, the return value is an unescaped literal. The +/// value of [ansiOutputEnabled] is also ignored. +/// +/// Returns `value` unchanged if +/// * [value] is `null` or empty. +/// * both [ansiOutputEnabled] and [forScript] are `false`. +/// * [codes] is empty. +/// +/// Throws an [ArgumentError] if +/// * [codes] contains more than one value of type [AnsiCodeType.foreground]. +/// * [codes] contains more than one value of type [AnsiCodeType.background]. +/// * [codes] contains any value of type [AnsiCodeType.reset]. +String? wrapWith(String? value, Iterable codes, + {bool forScript = false}) { + // Eliminate duplicates + final myCodes = codes.toSet(); + + if (_isNoop(myCodes.isEmpty, value, forScript)) { + return value; + } + + var foreground = 0, background = 0; + for (var code in myCodes) { + switch (code.type) { + case AnsiCodeType.foreground: + foreground++; + if (foreground > 1) { + throw ArgumentError.value(codes, 'codes', + 'Cannot contain more than one foreground color code.'); + } + case AnsiCodeType.background: + background++; + if (background > 1) { + throw ArgumentError.value(codes, 'codes', + 'Cannot contain more than one foreground color code.'); + } + case AnsiCodeType.reset: + throw ArgumentError.value( + codes, 'codes', 'Cannot contain reset codes.'); + case AnsiCodeType.style: + // Ignore. + break; + } + } + + final sortedCodes = myCodes.map((ac) => ac.code).toList()..sort(); + final escapeValue = forScript ? _ansiEscapeForScript : _ansiEscapeLiteral; + + return "$escapeValue[${sortedCodes.join(';')}m$value" + '${resetAll._escapeValue(forScript: forScript)}'; +} + +// +// Style values +// + +const styleBold = AnsiCode._('bold', AnsiCodeType.style, 1, resetBold); +const styleDim = AnsiCode._('dim', AnsiCodeType.style, 2, resetDim); +const styleItalic = AnsiCode._('italic', AnsiCodeType.style, 3, resetItalic); +const styleUnderlined = + AnsiCode._('underlined', AnsiCodeType.style, 4, resetUnderlined); +const styleBlink = AnsiCode._('blink', AnsiCodeType.style, 5, resetBlink); +const styleReverse = AnsiCode._('reverse', AnsiCodeType.style, 7, resetReverse); + +/// Not widely supported. +const styleHidden = AnsiCode._('hidden', AnsiCodeType.style, 8, resetHidden); + +/// Not widely supported. +const styleCrossedOut = + AnsiCode._('crossed out', AnsiCodeType.style, 9, resetCrossedOut); + +// +// Reset values +// + +const resetAll = AnsiCode._('all', AnsiCodeType.reset, 0, null); + +// NOTE: bold is weird. The reset code seems to be 22 sometimes – not 21 +// See https://gitlab.com/gnachman/iterm2/issues/3208 +const resetBold = AnsiCode._('bold', AnsiCodeType.reset, 22, null); +const resetDim = AnsiCode._('dim', AnsiCodeType.reset, 22, null); +const resetItalic = AnsiCode._('italic', AnsiCodeType.reset, 23, null); +const resetUnderlined = AnsiCode._('underlined', AnsiCodeType.reset, 24, null); +const resetBlink = AnsiCode._('blink', AnsiCodeType.reset, 25, null); +const resetReverse = AnsiCode._('reverse', AnsiCodeType.reset, 27, null); +const resetHidden = AnsiCode._('hidden', AnsiCodeType.reset, 28, null); +const resetCrossedOut = AnsiCode._('crossed out', AnsiCodeType.reset, 29, null); + +// +// Foreground values +// + +const black = AnsiCode._('black', AnsiCodeType.foreground, 30, resetAll); +const red = AnsiCode._('red', AnsiCodeType.foreground, 31, resetAll); +const green = AnsiCode._('green', AnsiCodeType.foreground, 32, resetAll); +const yellow = AnsiCode._('yellow', AnsiCodeType.foreground, 33, resetAll); +const blue = AnsiCode._('blue', AnsiCodeType.foreground, 34, resetAll); +const magenta = AnsiCode._('magenta', AnsiCodeType.foreground, 35, resetAll); +const cyan = AnsiCode._('cyan', AnsiCodeType.foreground, 36, resetAll); +const lightGray = + AnsiCode._('light gray', AnsiCodeType.foreground, 37, resetAll); +const defaultForeground = + AnsiCode._('default', AnsiCodeType.foreground, 39, resetAll); +const darkGray = AnsiCode._('dark gray', AnsiCodeType.foreground, 90, resetAll); +const lightRed = AnsiCode._('light red', AnsiCodeType.foreground, 91, resetAll); +const lightGreen = + AnsiCode._('light green', AnsiCodeType.foreground, 92, resetAll); +const lightYellow = + AnsiCode._('light yellow', AnsiCodeType.foreground, 93, resetAll); +const lightBlue = + AnsiCode._('light blue', AnsiCodeType.foreground, 94, resetAll); +const lightMagenta = + AnsiCode._('light magenta', AnsiCodeType.foreground, 95, resetAll); +const lightCyan = + AnsiCode._('light cyan', AnsiCodeType.foreground, 96, resetAll); +const white = AnsiCode._('white', AnsiCodeType.foreground, 97, resetAll); + +// +// Background values +// + +const backgroundBlack = + AnsiCode._('black', AnsiCodeType.background, 40, resetAll); +const backgroundRed = AnsiCode._('red', AnsiCodeType.background, 41, resetAll); +const backgroundGreen = + AnsiCode._('green', AnsiCodeType.background, 42, resetAll); +const backgroundYellow = + AnsiCode._('yellow', AnsiCodeType.background, 43, resetAll); +const backgroundBlue = + AnsiCode._('blue', AnsiCodeType.background, 44, resetAll); +const backgroundMagenta = + AnsiCode._('magenta', AnsiCodeType.background, 45, resetAll); +const backgroundCyan = + AnsiCode._('cyan', AnsiCodeType.background, 46, resetAll); +const backgroundLightGray = + AnsiCode._('light gray', AnsiCodeType.background, 47, resetAll); +const backgroundDefault = + AnsiCode._('default', AnsiCodeType.background, 49, resetAll); +const backgroundDarkGray = + AnsiCode._('dark gray', AnsiCodeType.background, 100, resetAll); +const backgroundLightRed = + AnsiCode._('light red', AnsiCodeType.background, 101, resetAll); +const backgroundLightGreen = + AnsiCode._('light green', AnsiCodeType.background, 102, resetAll); +const backgroundLightYellow = + AnsiCode._('light yellow', AnsiCodeType.background, 103, resetAll); +const backgroundLightBlue = + AnsiCode._('light blue', AnsiCodeType.background, 104, resetAll); +const backgroundLightMagenta = + AnsiCode._('light magenta', AnsiCodeType.background, 105, resetAll); +const backgroundLightCyan = + AnsiCode._('light cyan', AnsiCodeType.background, 106, resetAll); +const backgroundWhite = + AnsiCode._('white', AnsiCodeType.background, 107, resetAll); + +/// All of the [AnsiCode] values that represent [AnsiCodeType.style]. +const List styles = [ + styleBold, + styleDim, + styleItalic, + styleUnderlined, + styleBlink, + styleReverse, + styleHidden, + styleCrossedOut +]; + +/// All of the [AnsiCode] values that represent [AnsiCodeType.foreground]. +const List foregroundColors = [ + black, + red, + green, + yellow, + blue, + magenta, + cyan, + lightGray, + defaultForeground, + darkGray, + lightRed, + lightGreen, + lightYellow, + lightBlue, + lightMagenta, + lightCyan, + white +]; + +/// All of the [AnsiCode] values that represent [AnsiCodeType.background]. +const List backgroundColors = [ + backgroundBlack, + backgroundRed, + backgroundGreen, + backgroundYellow, + backgroundBlue, + backgroundMagenta, + backgroundCyan, + backgroundLightGray, + backgroundDefault, + backgroundDarkGray, + backgroundLightRed, + backgroundLightGreen, + backgroundLightYellow, + backgroundLightBlue, + backgroundLightMagenta, + backgroundLightCyan, + backgroundWhite +]; diff --git a/pkgs/io/lib/src/charcodes.dart b/pkgs/io/lib/src/charcodes.dart new file mode 100644 index 000000000..4acaf0ab3 --- /dev/null +++ b/pkgs/io/lib/src/charcodes.dart @@ -0,0 +1,34 @@ +// Copyright (c) 2021, 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. + +// Generated using: +// pub global run charcode \$=dollar \'=single_quote \"=double_quote \ +// \' '\\\n"$`# \t' + +/// "Horizontal Tab" control character, common name. +const int $tab = 0x09; + +/// "Line feed" control character. +const int $lf = 0x0a; + +/// Space character. +const int $space = 0x20; + +/// Character `"`, short name. +const int $doubleQuote = 0x22; + +/// Character `#`. +const int $hash = 0x23; + +/// Character `$`. +const int $dollar = 0x24; + +/// Character "'". +const int $singleQuote = 0x27; + +/// Character `\`. +const int $backslash = 0x5c; + +/// Character `` ` ``. +const int $backquote = 0x60; diff --git a/pkgs/io/lib/src/copy_path.dart b/pkgs/io/lib/src/copy_path.dart new file mode 100644 index 000000000..3a999b610 --- /dev/null +++ b/pkgs/io/lib/src/copy_path.dart @@ -0,0 +1,69 @@ +// Copyright (c) 2017, 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 'package:path/path.dart' as p; + +bool _doNothing(String from, String to) { + if (p.canonicalize(from) == p.canonicalize(to)) { + return true; + } + if (p.isWithin(from, to)) { + throw ArgumentError('Cannot copy from $from to $to'); + } + return false; +} + +/// Copies all of the files in the [from] directory to [to]. +/// +/// This is similar to `cp -R `: +/// * Symlinks are supported. +/// * Existing files are over-written, if any. +/// * If [to] is within [from], throws [ArgumentError] (an infinite operation). +/// * If [from] and [to] are canonically the same, no operation occurs. +/// +/// Returns a future that completes when complete. +Future copyPath(String from, String to) async { + if (_doNothing(from, to)) { + return; + } + await Directory(to).create(recursive: true); + await for (final file in Directory(from).list(recursive: true)) { + final copyTo = p.join(to, p.relative(file.path, from: from)); + if (file is Directory) { + await Directory(copyTo).create(recursive: true); + } else if (file is File) { + await File(file.path).copy(copyTo); + } else if (file is Link) { + await Link(copyTo).create(await file.target(), recursive: true); + } + } +} + +/// Copies all of the files in the [from] directory to [to]. +/// +/// This is similar to `cp -R `: +/// * Symlinks are supported. +/// * Existing files are over-written, if any. +/// * If [to] is within [from], throws [ArgumentError] (an infinite operation). +/// * If [from] and [to] are canonically the same, no operation occurs. +/// +/// This action is performed synchronously (blocking I/O). +void copyPathSync(String from, String to) { + if (_doNothing(from, to)) { + return; + } + Directory(to).createSync(recursive: true); + for (final file in Directory(from).listSync(recursive: true)) { + final copyTo = p.join(to, p.relative(file.path, from: from)); + if (file is Directory) { + Directory(copyTo).createSync(recursive: true); + } else if (file is File) { + File(file.path).copySync(copyTo); + } else if (file is Link) { + Link(copyTo).createSync(file.targetSync(), recursive: true); + } + } +} diff --git a/pkgs/io/lib/src/exit_code.dart b/pkgs/io/lib/src/exit_code.dart new file mode 100644 index 000000000..d4055585e --- /dev/null +++ b/pkgs/io/lib/src/exit_code.dart @@ -0,0 +1,82 @@ +// Copyright (c) 2017, 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. + +/// Exit code constants. +/// +/// [Source](https://www.freebsd.org/cgi/man.cgi?query=sysexits). +class ExitCode { + /// Command completed successfully. + static const success = ExitCode._(0, 'success'); + + /// Command was used incorrectly. + /// + /// This may occur if the wrong number of arguments was used, a bad flag, or + /// bad syntax in a parameter. + static const usage = ExitCode._(64, 'usage'); + + /// Input data was used incorrectly. + /// + /// This should occur only for user data (not system files). + static const data = ExitCode._(65, 'data'); + + /// An input file (not a system file) did not exist or was not readable. + static const noInput = ExitCode._(66, 'noInput'); + + /// User specified did not exist. + static const noUser = ExitCode._(67, 'noUser'); + + /// Host specified did not exist. + static const noHost = ExitCode._(68, 'noHost'); + + /// A service is unavailable. + /// + /// This may occur if a support program or file does not exist. This may also + /// be used as a catch-all error when something you wanted to do does not + /// work, but you do not know why. + static const unavailable = ExitCode._(69, 'unavailable'); + + /// An internal software error has been detected. + /// + /// This should be limited to non-operating system related errors as possible. + static const software = ExitCode._(70, 'software'); + + /// An operating system error has been detected. + /// + /// This intended to be used for such thing as `cannot fork` or `cannot pipe`. + static const osError = ExitCode._(71, 'osError'); + + /// Some system file (e.g. `/etc/passwd`) does not exist or could not be read. + static const osFile = ExitCode._(72, 'osFile'); + + /// A (user specified) output file cannot be created. + static const cantCreate = ExitCode._(73, 'cantCreate'); + + /// An error occurred doing I/O on some file. + static const ioError = ExitCode._(74, 'ioError'); + + /// Temporary failure, indicating something is not really an error. + /// + /// In some cases, this can be re-attempted and will succeed later. + static const tempFail = ExitCode._(75, 'tempFail'); + + /// You did not have sufficient permissions to perform the operation. + /// + /// This is not intended for file system problems, which should use [noInput] + /// or [cantCreate], but rather for higher-level permissions. + static const noPerm = ExitCode._(77, 'noPerm'); + + /// Something was found in an unconfigured or misconfigured state. + static const config = ExitCode._(78, 'config'); + + /// Exit code value. + final int code; + + /// Name of the exit code. + final String _name; + + const ExitCode._(this.code, this._name); + + @override + String toString() => '$_name: $code'; +} diff --git a/pkgs/io/lib/src/permissions.dart b/pkgs/io/lib/src/permissions.dart new file mode 100644 index 000000000..c51694369 --- /dev/null +++ b/pkgs/io/lib/src/permissions.dart @@ -0,0 +1,69 @@ +// Copyright (c) 2017, 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'; + +/// What type of permission is granted to a file based on file permission roles. +enum _FilePermission { + execute, + // Although these two values are unused, their positions in the enum are + // meaningful. + write, // ignore: unused_field + read, // ignore: unused_field + setGid, + setUid, + sticky, +} + +/// What type of role is assigned to a file. +enum _FilePermissionRole { + world, + group, + user, +} + +/// Returns whether file [stat] has [permission] for a [role] type. +bool _hasPermission( + FileStat stat, + _FilePermission permission, { + _FilePermissionRole role = _FilePermissionRole.world, +}) { + final index = _permissionBitIndex(permission, role); + return (stat.mode & (1 << index)) != 0; +} + +int _permissionBitIndex(_FilePermission permission, _FilePermissionRole role) => + switch (permission) { + _FilePermission.setUid => 11, + _FilePermission.setGid => 10, + _FilePermission.sticky => 9, + _ => (role.index * 3) + permission.index + }; + +/// Returns whether [path] is considered an executable file on this OS. +/// +/// May optionally define how to implement [getStat] or whether to execute based +/// on whether this is the windows platform ([isWindows]) - if not set it is +/// automatically extracted from `dart:io#Platform`. +/// +/// **NOTE**: On windows this always returns `true`. +FutureOr isExecutable( + String path, { + bool? isWindows, + FutureOr Function(String path) getStat = FileStat.stat, +}) { + // Windows has no concept of executable. + if (isWindows ?? Platform.isWindows) return true; + final stat = getStat(path); + if (stat is FileStat) { + return _isExecutable(stat); + } + return stat.then(_isExecutable); +} + +bool _isExecutable(FileStat stat) => + stat.type == FileSystemEntityType.file && + _FilePermissionRole.values.any( + (role) => _hasPermission(stat, _FilePermission.execute, role: role)); diff --git a/pkgs/io/lib/src/process_manager.dart b/pkgs/io/lib/src/process_manager.dart new file mode 100644 index 000000000..84d22ec87 --- /dev/null +++ b/pkgs/io/lib/src/process_manager.dart @@ -0,0 +1,255 @@ +// Copyright (c) 2017, 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. + +// ignore_for_file: close_sinks,cancel_subscriptions + +import 'dart:async'; +import 'dart:io' as io; + +import 'package:meta/meta.dart'; + +import 'shared_stdin.dart'; + +/// Type definition for both [io.Process.start] and [ProcessManager.spawn]. +/// +/// Useful for taking different implementations of this base functionality. +typedef StartProcess = Future Function( + String executable, + List arguments, { + String workingDirectory, + Map environment, + bool includeParentEnvironment, + bool runInShell, + io.ProcessStartMode mode, +}); + +/// A high-level abstraction around using and managing processes on the system. +abstract class ProcessManager { + /// Terminates the global `stdin` listener, making future listens impossible. + /// + /// This method should be invoked only at the _end_ of a program's execution. + static Future terminateStdIn() async { + await sharedStdIn.terminate(); + } + + /// Create a new instance of [ProcessManager] for the current platform. + /// + /// May manually specify whether the current platform [isWindows], otherwise + /// this is derived from the Dart runtime (i.e. [io.Platform.isWindows]). + factory ProcessManager({ + Stream>? stdin, + io.IOSink? stdout, + io.IOSink? stderr, + bool? isWindows, + }) { + stdin ??= sharedStdIn; + stdout ??= io.stdout; + stderr ??= io.stderr; + isWindows ??= io.Platform.isWindows; + if (isWindows) { + return _WindowsProcessManager(stdin, stdout, stderr); + } + return _UnixProcessManager(stdin, stdout, stderr); + } + + final Stream> _stdin; + final io.IOSink _stdout; + final io.IOSink _stderr; + + const ProcessManager._(this._stdin, this._stdout, this._stderr); + + /// Spawns a process by invoking [executable] with [arguments]. + /// + /// This is _similar_ to [io.Process.start], but all standard input and output + /// is forwarded/routed between the process and the host, similar to how a + /// shell script works. + /// + /// Returns a future that completes with a handle to the spawned process. + Future spawn( + String executable, + Iterable arguments, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + io.ProcessStartMode mode = io.ProcessStartMode.normal, + }) async { + final process = io.Process.start( + executable, + arguments.toList(), + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: includeParentEnvironment, + runInShell: runInShell, + mode: mode, + ); + return _ForwardingSpawn(await process, _stdin, _stdout, _stderr); + } + + /// Spawns a process by invoking [executable] with [arguments]. + /// + /// This is _similar_ to [io.Process.start], but `stdout` and `stderr` is + /// forwarded/routed between the process and host, similar to how a shell + /// script works. + /// + /// Returns a future that completes with a handle to the spawned process. + Future spawnBackground( + String executable, + Iterable arguments, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + io.ProcessStartMode mode = io.ProcessStartMode.normal, + }) async { + final process = io.Process.start( + executable, + arguments.toList(), + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: includeParentEnvironment, + runInShell: runInShell, + mode: mode, + ); + return _ForwardingSpawn( + await process, + const Stream.empty(), + _stdout, + _stderr, + ); + } + + /// Spawns a process by invoking [executable] with [arguments]. + /// + /// This is _identical to [io.Process.start] (no forwarding of I/O). + /// + /// Returns a future that completes with a handle to the spawned process. + Future spawnDetached( + String executable, + Iterable arguments, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + io.ProcessStartMode mode = io.ProcessStartMode.normal, + }) async => + io.Process.start( + executable, + arguments.toList(), + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: includeParentEnvironment, + runInShell: runInShell, + mode: mode, + ); +} + +/// A process instance created and managed through [ProcessManager]. +/// +/// Unlike one created directly by [io.Process.start] or [io.Process.run], a +/// spawned process works more like executing a command in a shell script. +class Spawn implements io.Process { + final io.Process _delegate; + + Spawn._(this._delegate) { + _delegate.exitCode.then((_) => _onClosed()); + } + + @mustCallSuper + void _onClosed() {} + + @override + bool kill([io.ProcessSignal signal = io.ProcessSignal.sigterm]) => + _delegate.kill(signal); + + @override + Future get exitCode => _delegate.exitCode; + + @override + int get pid => _delegate.pid; + + @override + Stream> get stderr => _delegate.stderr; + + @override + io.IOSink get stdin => _delegate.stdin; + + @override + Stream> get stdout => _delegate.stdout; +} + +/// Forwards `stdin`/`stdout`/`stderr` to/from the host. +class _ForwardingSpawn extends Spawn { + final StreamSubscription> _stdInSub; + final StreamSubscription> _stdOutSub; + final StreamSubscription> _stdErrSub; + final StreamController> _stdOut; + final StreamController> _stdErr; + + factory _ForwardingSpawn( + io.Process delegate, + Stream> stdin, + io.IOSink stdout, + io.IOSink stderr, + ) { + final stdoutSelf = StreamController>(); + final stderrSelf = StreamController>(); + final stdInSub = stdin.listen(delegate.stdin.add); + final stdOutSub = delegate.stdout.listen((event) { + stdout.add(event); + stdoutSelf.add(event); + }); + final stdErrSub = delegate.stderr.listen((event) { + stderr.add(event); + stderrSelf.add(event); + }); + return _ForwardingSpawn._delegate( + delegate, + stdInSub, + stdOutSub, + stdErrSub, + stdoutSelf, + stderrSelf, + ); + } + + _ForwardingSpawn._delegate( + super.delegate, + this._stdInSub, + this._stdOutSub, + this._stdErrSub, + this._stdOut, + this._stdErr, + ) : super._(); + + @override + void _onClosed() { + _stdInSub.cancel(); + _stdOutSub.cancel(); + _stdErrSub.cancel(); + super._onClosed(); + } + + @override + Stream> get stdout => _stdOut.stream; + + @override + Stream> get stderr => _stdErr.stream; +} + +class _UnixProcessManager extends ProcessManager { + const _UnixProcessManager( + super.stdin, + super.stdout, + super.stderr, + ) : super._(); +} + +class _WindowsProcessManager extends ProcessManager { + const _WindowsProcessManager( + super.stdin, + super.stdout, + super.stderr, + ) : super._(); +} diff --git a/pkgs/io/lib/src/shared_stdin.dart b/pkgs/io/lib/src/shared_stdin.dart new file mode 100644 index 000000000..72bb50c40 --- /dev/null +++ b/pkgs/io/lib/src/shared_stdin.dart @@ -0,0 +1,99 @@ +// Copyright (c) 2017, 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:convert'; +import 'dart:io'; + +import 'package:meta/meta.dart'; + +/// A shared singleton instance of `dart:io`'s [stdin] stream. +/// +/// _Unlike_ the normal [stdin] stream, [sharedStdIn] may switch subscribers +/// as long as the previous subscriber cancels before the new subscriber starts +/// listening. +/// +/// [SharedStdIn.terminate] *must* be invoked in order to close the underlying +/// connection to [stdin], allowing your program to close automatically without +/// hanging. +final SharedStdIn sharedStdIn = SharedStdIn(stdin); + +/// A singleton wrapper around `stdin` that allows new subscribers. +/// +/// This class is visible in order to be used as a test harness for mock +/// implementations of `stdin`. In normal programs, [sharedStdIn] should be +/// used directly. +@visibleForTesting +class SharedStdIn extends Stream> { + StreamController>? _current; + StreamSubscription>? _sub; + + SharedStdIn([Stream>? stream]) { + _sub = (stream ??= stdin).listen(_onInput); + } + + /// Returns a future that completes with the next line. + /// + /// This is similar to the standard [Stdin.readLineSync], but asynchronous. + Future nextLine({Encoding encoding = systemEncoding}) => + lines(encoding: encoding).first; + + /// Returns the stream transformed as UTF8 strings separated by line breaks. + /// + /// This is similar to synchronous code using [Stdin.readLineSync]: + /// ```dart + /// while (true) { + /// var line = stdin.readLineSync(); + /// // ... + /// } + /// ``` + /// + /// ... but asynchronous. + Stream lines({Encoding encoding = systemEncoding}) => + transform(utf8.decoder).transform(const LineSplitter()); + + void _onInput(List event) => _getCurrent().add(event); + + StreamController> _getCurrent() => + _current ??= StreamController>( + onCancel: () { + _current = null; + }, + sync: true); + + @override + StreamSubscription> listen( + void Function(List event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + if (_sub == null) { + throw StateError('Stdin has already been terminated.'); + } + // ignore: close_sinks + final controller = _getCurrent(); + if (controller.hasListener) { + throw StateError('' + 'Subscriber already listening. The existing subscriber must cancel ' + 'before another may be added.'); + } + return controller.stream.listen( + onData, + onDone: onDone, + onError: onError, + cancelOnError: cancelOnError, + ); + } + + /// Terminates the connection to `stdin`, closing all subscription. + Future terminate() async { + if (_sub == null) { + throw StateError('Stdin has already been terminated.'); + } + await _sub?.cancel(); + await _current?.close(); + _sub = null; + } +} diff --git a/pkgs/io/lib/src/shell_words.dart b/pkgs/io/lib/src/shell_words.dart new file mode 100644 index 000000000..5fca6d9d9 --- /dev/null +++ b/pkgs/io/lib/src/shell_words.dart @@ -0,0 +1,142 @@ +// Copyright (c) 2017, 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. + +// ignore_for_file: comment_references + +import 'package:string_scanner/string_scanner.dart'; + +import 'charcodes.dart'; + +/// Splits [command] into tokens according to [the POSIX shell +/// specification][spec]. +/// +/// [spec]: http://pubs.opengroup.org/onlinepubs/9699919799/utilities/contents.html +/// +/// This returns the unquoted values of quoted tokens. For example, +/// `shellSplit('foo "bar baz"')` returns `["foo", "bar baz"]`. It does not +/// currently support here-documents. It does *not* treat dynamic features such +/// as parameter expansion specially. For example, `shellSplit("foo $(bar +/// baz)")` returns `["foo", "$(bar", "baz)"]`. +/// +/// This will discard any comments at the end of [command]. +/// +/// Throws a [FormatException] if [command] isn't a valid shell command. +List shellSplit(String command) { + final scanner = StringScanner(command); + final results = []; + final token = StringBuffer(); + + // Whether a token is being parsed, as opposed to a separator character. This + // is different than just [token.isEmpty], because empty quoted tokens can + // exist. + var hasToken = false; + + while (!scanner.isDone) { + final next = scanner.readChar(); + switch (next) { + case $backslash: + // Section 2.2.1: A that is not quoted shall preserve the + // literal value of the following character, with the exception of a + // . If a follows the , the shell shall + // interpret this as line continuation. The and + // shall be removed before splitting the input into tokens. Since the + // escaped is removed entirely from the input and is not + // replaced by any white space, it cannot serve as a token separator. + if (scanner.scanChar($lf)) break; + + hasToken = true; + token.writeCharCode(scanner.readChar()); + + case $singleQuote: + hasToken = true; + // Section 2.2.2: Enclosing characters in single-quotes ( '' ) shall + // preserve the literal value of each character within the + // single-quotes. A single-quote cannot occur within single-quotes. + final firstQuote = scanner.position - 1; + while (!scanner.scanChar($singleQuote)) { + _checkUnmatchedQuote(scanner, firstQuote); + token.writeCharCode(scanner.readChar()); + } + + case $doubleQuote: + hasToken = true; + // Section 2.2.3: Enclosing characters in double-quotes ( "" ) shall + // preserve the literal value of all characters within the + // double-quotes, with the exception of the characters backquote, + // , and . + // + // (Note that this code doesn't preserve special behavior of backquote + // or dollar sign within double quotes, since those are dynamic + // features.) + final firstQuote = scanner.position - 1; + while (!scanner.scanChar($doubleQuote)) { + _checkUnmatchedQuote(scanner, firstQuote); + + if (scanner.scanChar($backslash)) { + _checkUnmatchedQuote(scanner, firstQuote); + + // The shall retain its special meaning as an escape + // character (see Escape Character (Backslash)) only when followed + // by one of the following characters when considered special: + // + // $ ` " \ + final next = scanner.readChar(); + if (next == $lf) continue; + if (next == $dollar || + next == $backquote || + next == $doubleQuote || + next == $backslash) { + token.writeCharCode(next); + } else { + token + ..writeCharCode($backslash) + ..writeCharCode(next); + } + } else { + token.writeCharCode(scanner.readChar()); + } + } + + case $hash: + // Section 2.3: If the current character is a '#' [and the previous + // characters was not part of a word], it and all subsequent characters + // up to, but excluding, the next shall be discarded as a + // comment. The that ends the line is not considered part of + // the comment. + if (hasToken) { + token.writeCharCode($hash); + break; + } + + while (!scanner.isDone && scanner.peekChar() != $lf) { + scanner.readChar(); + } + + case $space: + case $tab: + case $lf: + // ignore: invariant_booleans + if (hasToken) results.add(token.toString()); + hasToken = false; + token.clear(); + + default: + hasToken = true; + token.writeCharCode(next); + } + } + + if (hasToken) results.add(token.toString()); + return results; +} + +/// Throws a [FormatException] if [scanner] is done indicating that a closing +/// quote matching the one at position [openingQuote] is missing. +void _checkUnmatchedQuote(StringScanner scanner, int openingQuote) { + if (!scanner.isDone) return; + final type = scanner.substring(openingQuote, openingQuote + 1) == '"' + ? 'double' + : 'single'; + scanner.error('Unmatched $type quote.', position: openingQuote, length: 1); +} diff --git a/pkgs/io/pubspec.yaml b/pkgs/io/pubspec.yaml new file mode 100644 index 000000000..7e00d993d --- /dev/null +++ b/pkgs/io/pubspec.yaml @@ -0,0 +1,19 @@ +name: io +description: >- + Utilities for the Dart VM Runtime including support for ANSI colors, file + copying, and standard exit code values. +version: 1.0.5 +repository: https://github.com/dart-lang/tools/tree/main/pkgs/io + +environment: + sdk: ^3.4.0 + +dependencies: + meta: ^1.3.0 + path: ^1.8.0 + string_scanner: ^1.1.0 + +dev_dependencies: + dart_flutter_team_lints: ^3.0.0 + test: ^1.16.6 + test_descriptor: ^2.0.0 diff --git a/pkgs/io/test/_files/is_executable.sh b/pkgs/io/test/_files/is_executable.sh new file mode 100755 index 000000000..f1f641af1 --- /dev/null +++ b/pkgs/io/test/_files/is_executable.sh @@ -0,0 +1 @@ +#!/usr/bin/env bash diff --git a/pkgs/io/test/_files/is_not_executable.sh b/pkgs/io/test/_files/is_not_executable.sh new file mode 100644 index 000000000..f1f641af1 --- /dev/null +++ b/pkgs/io/test/_files/is_not_executable.sh @@ -0,0 +1 @@ +#!/usr/bin/env bash diff --git a/pkgs/io/test/_files/stderr_hello.dart b/pkgs/io/test/_files/stderr_hello.dart new file mode 100644 index 000000000..ac7a7d341 --- /dev/null +++ b/pkgs/io/test/_files/stderr_hello.dart @@ -0,0 +1,7 @@ +// Copyright (c) 2017, 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'; + +void main() => stderr.write('Hello'); diff --git a/pkgs/io/test/_files/stdin_echo.dart b/pkgs/io/test/_files/stdin_echo.dart new file mode 100644 index 000000000..256e0eee7 --- /dev/null +++ b/pkgs/io/test/_files/stdin_echo.dart @@ -0,0 +1,7 @@ +// Copyright (c) 2017, 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'; + +void main() => stdout.writeln('You said: ${stdin.readLineSync()}'); diff --git a/pkgs/io/test/_files/stdout_hello.dart b/pkgs/io/test/_files/stdout_hello.dart new file mode 100644 index 000000000..af3bf51ef --- /dev/null +++ b/pkgs/io/test/_files/stdout_hello.dart @@ -0,0 +1,7 @@ +// Copyright (c) 2017, 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'; + +void main() => stdout.write('Hello'); diff --git a/pkgs/io/test/ansi_code_test.dart b/pkgs/io/test/ansi_code_test.dart new file mode 100644 index 000000000..98ae68b63 --- /dev/null +++ b/pkgs/io/test/ansi_code_test.dart @@ -0,0 +1,187 @@ +// Copyright (c) 2017, 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('vm') +library; + +import 'dart:io'; + +import 'package:io/ansi.dart'; +import 'package:test/test.dart'; + +const _ansiEscapeLiteral = '\x1B'; +const _ansiEscapeForScript = r'\033'; +const sampleInput = 'sample input'; + +void main() { + group('ansiOutputEnabled', () { + test('default value matches dart:io', () { + expect(ansiOutputEnabled, + stdout.supportsAnsiEscapes && stderr.supportsAnsiEscapes); + }); + + test('override true', () { + overrideAnsiOutput(true, () { + expect(ansiOutputEnabled, isTrue); + }); + }); + + test('override false', () { + overrideAnsiOutput(false, () { + expect(ansiOutputEnabled, isFalse); + }); + }); + + test('forScript variaents ignore `ansiOutputEnabled`', () { + const expected = + '$_ansiEscapeForScript[34m$sampleInput$_ansiEscapeForScript[0m'; + + for (var override in [true, false]) { + overrideAnsiOutput(override, () { + expect(blue.escapeForScript, '$_ansiEscapeForScript[34m'); + expect(blue.wrap(sampleInput, forScript: true), expected); + expect(wrapWith(sampleInput, [blue], forScript: true), expected); + }); + } + }); + }); + + test('foreground and background colors match', () { + expect(foregroundColors, hasLength(backgroundColors.length)); + + for (var i = 0; i < foregroundColors.length; i++) { + final foreground = foregroundColors[i]; + expect(foreground.type, AnsiCodeType.foreground); + expect(foreground.name.toLowerCase(), foreground.name, + reason: 'All names should be lower case'); + final background = backgroundColors[i]; + expect(background.type, AnsiCodeType.background); + expect(background.name.toLowerCase(), background.name, + reason: 'All names should be lower case'); + + expect(foreground.name, background.name); + + // The last base-10 digit also matches – good to sanity check + expect(foreground.code % 10, background.code % 10); + } + }); + + test('all styles are styles', () { + for (var style in styles) { + expect(style.type, AnsiCodeType.style); + expect(style.name.toLowerCase(), style.name, + reason: 'All names should be lower case'); + if (style == styleBold) { + expect(style.reset, resetBold); + } else { + expect(style.reset!.code, equals(style.code + 20)); + } + expect(style.name, equals(style.reset!.name)); + } + }); + + for (var forScript in [true, false]) { + group(forScript ? 'forScript' : 'escaped', () { + final escapeLiteral = + forScript ? _ansiEscapeForScript : _ansiEscapeLiteral; + + group('wrap', () { + _test('color', () { + final expected = '$escapeLiteral[34m$sampleInput$escapeLiteral[0m'; + + expect(blue.wrap(sampleInput, forScript: forScript), expected); + }); + + _test('style', () { + final expected = '$escapeLiteral[1m$sampleInput$escapeLiteral[22m'; + + expect(styleBold.wrap(sampleInput, forScript: forScript), expected); + }); + + _test('style', () { + final expected = '$escapeLiteral[34m$sampleInput$escapeLiteral[0m'; + + expect(blue.wrap(sampleInput, forScript: forScript), expected); + }); + + test('empty', () { + expect(blue.wrap('', forScript: forScript), ''); + }); + + test(null, () { + expect(blue.wrap(null, forScript: forScript), isNull); + }); + }); + + group('wrapWith', () { + _test('foreground', () { + final expected = '$escapeLiteral[34m$sampleInput$escapeLiteral[0m'; + + expect(wrapWith(sampleInput, [blue], forScript: forScript), expected); + }); + + _test('background', () { + final expected = '$escapeLiteral[44m$sampleInput$escapeLiteral[0m'; + + expect(wrapWith(sampleInput, [backgroundBlue], forScript: forScript), + expected); + }); + + _test('style', () { + final expected = '$escapeLiteral[1m$sampleInput$escapeLiteral[0m'; + + expect(wrapWith(sampleInput, [styleBold], forScript: forScript), + expected); + }); + + _test('2 styles', () { + final expected = '$escapeLiteral[1;3m$sampleInput$escapeLiteral[0m'; + + expect( + wrapWith(sampleInput, [styleBold, styleItalic], + forScript: forScript), + expected); + }); + + _test('2 foregrounds', () { + expect( + () => wrapWith(sampleInput, [blue, white], forScript: forScript), + throwsArgumentError); + }); + + _test('multi', () { + final expected = + '$escapeLiteral[1;4;34;107m$sampleInput$escapeLiteral[0m'; + + expect( + wrapWith(sampleInput, + [blue, backgroundWhite, styleBold, styleUnderlined], + forScript: forScript), + expected); + }); + + test('no codes', () { + expect(wrapWith(sampleInput, []), sampleInput); + }); + + _test('empty', () { + expect( + wrapWith('', [blue, backgroundWhite, styleBold], + forScript: forScript), + ''); + }); + + _test('null', () { + expect( + wrapWith(null, [blue, backgroundWhite, styleBold], + forScript: forScript), + isNull); + }); + }); + }); + } +} + +void _test(String name, T Function() body) => + test(name, () => overrideAnsiOutput(true, body)); diff --git a/pkgs/io/test/copy_path_test.dart b/pkgs/io/test/copy_path_test.dart new file mode 100644 index 000000000..fd1e9ceee --- /dev/null +++ b/pkgs/io/test/copy_path_test.dart @@ -0,0 +1,45 @@ +// Copyright (c) 2017, 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('vm') +library; + +import 'package:io/io.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; + +void main() { + test('should copy a directory (async)', () async { + await _create(); + await copyPath(p.join(d.sandbox, 'parent'), p.join(d.sandbox, 'copy')); + await _validate(); + }); + + test('should copy a directory (sync)', () async { + await _create(); + copyPathSync(p.join(d.sandbox, 'parent'), p.join(d.sandbox, 'copy')); + await _validate(); + }); + + test('should catch an infinite operation', () async { + await _create(); + expect( + copyPath( + p.join(d.sandbox, 'parent'), + p.join(d.sandbox, 'parent', 'child'), + ), + throwsArgumentError, + ); + }); +} + +d.DirectoryDescriptor _struct() => d.dir('parent', [ + d.dir('child', [ + d.file('foo.txt'), + ]), + ]); + +Future _create() => _struct().create(); +Future _validate() => _struct().validate(); diff --git a/pkgs/io/test/permissions_test.dart b/pkgs/io/test/permissions_test.dart new file mode 100644 index 000000000..478e8df83 --- /dev/null +++ b/pkgs/io/test/permissions_test.dart @@ -0,0 +1,37 @@ +// Copyright (c) 2017, 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('vm') +library; + +import 'package:io/io.dart'; +import 'package:test/test.dart'; + +void main() { + group('isExecutable', () { + const files = 'test/_files'; + const shellIsExec = '$files/is_executable.sh'; + const shellNotExec = '$files/is_not_executable.sh'; + + group('on shell scripts', () { + test('should return true for "is_executable.sh"', () async { + expect(await isExecutable(shellIsExec), isTrue); + }); + + test('should return false for "is_not_executable.sh"', () async { + expect(await isExecutable(shellNotExec), isFalse); + }); + }, testOn: '!windows'); + + group('on shell scripts [windows]', () { + test('should return true for "is_executable.sh"', () async { + expect(await isExecutable(shellIsExec, isWindows: true), isTrue); + }); + + test('should return true for "is_not_executable.sh"', () async { + expect(await isExecutable(shellNotExec, isWindows: true), isTrue); + }); + }); + }); +} diff --git a/pkgs/io/test/process_manager_test.dart b/pkgs/io/test/process_manager_test.dart new file mode 100644 index 000000000..9871a77a6 --- /dev/null +++ b/pkgs/io/test/process_manager_test.dart @@ -0,0 +1,100 @@ +// Copyright (c) 2017, 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. + +// ignore_for_file: close_sinks + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:io/io.dart' hide sharedStdIn; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + StreamController fakeStdIn; + late ProcessManager processManager; + SharedStdIn sharedStdIn; + late List stdoutLog; + late List stderrLog; + + test('spawn functions should match the type definition of Process.start', () { + const isStartProcess = TypeMatcher(); + expect(Process.start, isStartProcess); + final manager = ProcessManager(); + expect(manager.spawn, isStartProcess); + expect(manager.spawnBackground, isStartProcess); + expect(manager.spawnDetached, isStartProcess); + }); + + group('spawn', () { + setUp(() async { + fakeStdIn = StreamController(sync: true); + sharedStdIn = SharedStdIn(fakeStdIn.stream.map((s) => s.codeUnits)); + stdoutLog = []; + stderrLog = []; + + final stdoutController = StreamController>(sync: true); + stdoutController.stream.map(utf8.decode).listen(stdoutLog.add); + final stdout = IOSink(stdoutController); + final stderrController = StreamController>(sync: true); + stderrController.stream.map(utf8.decode).listen(stderrLog.add); + final stderr = IOSink(stderrController); + + processManager = ProcessManager( + stdin: sharedStdIn, + stdout: stdout, + stderr: stderr, + ); + }); + + final dart = Platform.executable; + + test('should output Hello from another process [via stdout]', () async { + final spawn = await processManager.spawn( + dart, + [p.join('test', '_files', 'stdout_hello.dart')], + ); + await spawn.exitCode; + expect(stdoutLog, ['Hello']); + }); + + test('should output Hello from another process [via stderr]', () async { + final spawn = await processManager.spawn( + dart, + [p.join('test', '_files', 'stderr_hello.dart')], + ); + await spawn.exitCode; + expect(stderrLog, ['Hello']); + }); + + test('should forward stdin to another process', () async { + final spawn = await processManager.spawn( + dart, + [p.join('test', '_files', 'stdin_echo.dart')], + ); + spawn.stdin.writeln('Ping'); + await spawn.exitCode; + expect(stdoutLog.join(), contains('You said: Ping')); + }); + + group('should return a Process where', () { + test('.stdout is readable', () async { + final spawn = await processManager.spawn( + dart, + [p.join('test', '_files', 'stdout_hello.dart')], + ); + expect(await spawn.stdout.transform(utf8.decoder).first, 'Hello'); + }); + + test('.stderr is readable', () async { + final spawn = await processManager.spawn( + dart, + [p.join('test', '_files', 'stderr_hello.dart')], + ); + expect(await spawn.stderr.transform(utf8.decoder).first, 'Hello'); + }); + }); + }); +} diff --git a/pkgs/io/test/shared_stdin_test.dart b/pkgs/io/test/shared_stdin_test.dart new file mode 100644 index 000000000..71629ec8d --- /dev/null +++ b/pkgs/io/test/shared_stdin_test.dart @@ -0,0 +1,80 @@ +// Copyright (c) 2017, 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:convert'; + +import 'package:io/io.dart' hide sharedStdIn; +import 'package:test/test.dart'; + +void main() { + // ignore: close_sinks + late StreamController fakeStdIn; + late SharedStdIn sharedStdIn; + + setUp(() { + fakeStdIn = StreamController(sync: true); + sharedStdIn = SharedStdIn(fakeStdIn.stream.map((s) => s.codeUnits)); + }); + + test('should allow a single subscriber', () async { + final logs = []; + final sub = sharedStdIn.transform(utf8.decoder).listen(logs.add); + fakeStdIn.add('Hello World'); + await sub.cancel(); + expect(logs, ['Hello World']); + }); + + test('should allow multiple subscribers', () async { + final logs = []; + final asUtf8 = sharedStdIn.transform(utf8.decoder); + var sub = asUtf8.listen(logs.add); + fakeStdIn.add('Hello World'); + await sub.cancel(); + sub = asUtf8.listen(logs.add); + fakeStdIn.add('Goodbye World'); + await sub.cancel(); + expect(logs, ['Hello World', 'Goodbye World']); + }); + + test('should throw if a subscriber is still active', () async { + final active = sharedStdIn.listen((_) {}); + expect(() => sharedStdIn.listen((_) {}), throwsStateError); + await active.cancel(); + expect(() => sharedStdIn.listen((_) {}), returnsNormally); + }); + + test('should return a stream of lines', () async { + expect( + sharedStdIn.lines(), + emitsInOrder([ + 'I', + 'Think', + 'Therefore', + 'I', + 'Am', + ]), + ); + [ + 'I\nThink\n', + 'Therefore\n', + 'I\n', + 'Am\n', + ].forEach(fakeStdIn.add); + }); + + test('should return the next line', () { + expect(sharedStdIn.nextLine(), completion('Hello World')); + fakeStdIn.add('Hello World\n'); + }); + + test('should allow listening for new lines multiple times', () async { + expect(sharedStdIn.nextLine(), completion('Hello World')); + fakeStdIn.add('Hello World\n'); + await Future.value(); + + expect(sharedStdIn.nextLine(), completion('Hello World')); + fakeStdIn.add('Hello World\n'); + }); +} diff --git a/pkgs/io/test/shell_words_test.dart b/pkgs/io/test/shell_words_test.dart new file mode 100644 index 000000000..dc4441cdd --- /dev/null +++ b/pkgs/io/test/shell_words_test.dart @@ -0,0 +1,185 @@ +// Copyright (c) 2017, 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:io/io.dart'; +import 'package:test/test.dart'; + +void main() { + group('shellSplit()', () { + group('returns an empty list for', () { + test('an empty string', () { + expect(shellSplit(''), isEmpty); + }); + + test('spaces', () { + expect(shellSplit(' '), isEmpty); + }); + + test('tabs', () { + expect(shellSplit('\t\t\t'), isEmpty); + }); + + test('newlines', () { + expect(shellSplit('\n\n\n'), isEmpty); + }); + + test('a comment', () { + expect(shellSplit('#foo bar baz'), isEmpty); + }); + + test('a mix', () { + expect(shellSplit(' \t\n# foo'), isEmpty); + }); + }); + + group('parses unquoted', () { + test('a single token', () { + expect(shellSplit('foo'), equals(['foo'])); + }); + + test('multiple tokens', () { + expect(shellSplit('foo bar baz'), equals(['foo', 'bar', 'baz'])); + }); + + test('tokens separated by tabs', () { + expect(shellSplit('foo\tbar\tbaz'), equals(['foo', 'bar', 'baz'])); + }); + + test('tokens separated by newlines', () { + expect(shellSplit('foo\nbar\nbaz'), equals(['foo', 'bar', 'baz'])); + }); + + test('a token after whitespace', () { + expect(shellSplit(' \t\nfoo'), equals(['foo'])); + }); + + test('a token before whitespace', () { + expect(shellSplit('foo \t\n'), equals(['foo'])); + }); + + test('a token with a hash', () { + expect(shellSplit('foo#bar'), equals(['foo#bar'])); + }); + + test('a token before a comment', () { + expect(shellSplit('foo #bar'), equals(['foo'])); + }); + + test('dynamic shell features', () { + expect( + shellSplit(r'foo $(bar baz)'), equals(['foo', r'$(bar', 'baz)'])); + expect(shellSplit('foo `bar baz`'), equals(['foo', '`bar', 'baz`'])); + expect(shellSplit(r'foo $bar | baz'), + equals(['foo', r'$bar', '|', 'baz'])); + }); + }); + + group('parses a backslash', () { + test('before a normal character', () { + expect(shellSplit(r'foo\bar'), equals(['foobar'])); + }); + + test('before a dynamic shell feature', () { + expect(shellSplit(r'foo\$bar'), equals([r'foo$bar'])); + }); + + test('before a single quote', () { + expect(shellSplit(r"foo\'bar"), equals(["foo'bar"])); + }); + + test('before a double quote', () { + expect(shellSplit(r'foo\"bar'), equals(['foo"bar'])); + }); + + test('before a space', () { + expect(shellSplit(r'foo\ bar'), equals(['foo bar'])); + }); + + test('at the beginning of a token', () { + expect(shellSplit(r'\ foo'), equals([' foo'])); + }); + + test('before whitespace followed by a hash', () { + expect(shellSplit(r'\ #foo'), equals([' #foo'])); + }); + + test('before a newline in a token', () { + expect(shellSplit('foo\\\nbar'), equals(['foobar'])); + }); + + test('before a newline outside a token', () { + expect(shellSplit('foo \\\n bar'), equals(['foo', 'bar'])); + }); + + test('before a backslash', () { + expect(shellSplit(r'foo\\bar'), equals([r'foo\bar'])); + }); + }); + + group('parses single quotes', () { + test('that are empty', () { + expect(shellSplit("''"), equals([''])); + }); + + test('that contain normal characters', () { + expect(shellSplit("'foo'"), equals(['foo'])); + }); + + test('that contain active characters', () { + expect(shellSplit("'\" \\#'"), equals([r'" \#'])); + }); + + test('before a hash', () { + expect(shellSplit("''#foo"), equals([r'#foo'])); + }); + + test('inside a token', () { + expect(shellSplit("foo'bar baz'qux"), equals([r'foobar bazqux'])); + }); + + test('without a closing quote', () { + expect(() => shellSplit("'foo bar"), throwsFormatException); + }); + }); + + group('parses double quotes', () { + test('that are empty', () { + expect(shellSplit('""'), equals([''])); + }); + + test('that contain normal characters', () { + expect(shellSplit('"foo"'), equals(['foo'])); + }); + + test('that contain otherwise-active characters', () { + expect(shellSplit('"\' #"'), equals(["' #"])); + }); + + test('that contain escaped characters', () { + expect(shellSplit(r'"\$\`\"\\"'), equals([r'$`"\'])); + }); + + test('that contain an escaped newline', () { + expect(shellSplit('"\\\n"'), equals([''])); + }); + + test("that contain a backslash that's not an escape", () { + expect(shellSplit(r'"f\oo"'), equals([r'f\oo'])); + }); + + test('before a hash', () { + expect(shellSplit('""#foo'), equals([r'#foo'])); + }); + + test('inside a token', () { + expect(shellSplit('foo"bar baz"qux'), equals([r'foobar bazqux'])); + }); + + test('without a closing quote', () { + expect(() => shellSplit('"foo bar'), throwsFormatException); + expect(() => shellSplit(r'"foo bar\'), throwsFormatException); + }); + }); + }); +}