diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 00000000..cde02ad6 --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,15 @@ +# Dependabot configuration file. +# See https://docs.github.com/en/code-security/dependabot/dependabot-version-updates +version: 2 + +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + labels: + - autosubmit + groups: + github-actions: + patterns: + - "*" diff --git a/.github/labeler.yaml b/.github/labeler.yaml new file mode 100644 index 00000000..074dd1f4 --- /dev/null +++ b/.github/labeler.yaml @@ -0,0 +1,5 @@ +# Configuration for .github/workflows/pull_request_label.yml. + +"package-args": + - changed-files: + - any-glob-to-any-file: 'pkgs/args/**' diff --git a/.github/workflows/args.yaml b/.github/workflows/args.yaml new file mode 100644 index 00000000..dc758934 --- /dev/null +++ b/.github/workflows/args.yaml @@ -0,0 +1,72 @@ +name: Dart CI + +on: + # Run CI on pushes to the main branch, and on PRs against main. + push: + branches: [ main ] + paths: + - '.github/workflows/args.yaml' + - 'pkgs/args/**' + pull_request: + branches: [ main ] + paths: + - '.github/workflows/args.yaml' + - 'pkgs/args/**' + schedule: + - cron: "0 0 * * 0" +env: + PUB_ENVIRONMENT: bot.github + +defaults: + run: + working-directory: pkgs/args/ + +jobs: + # Check code formatting and static analysis on a single OS (linux) + # against Dart dev. + analyze: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: [dev] + steps: + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 + - uses: dart-lang/setup-dart@0a8a0fc875eb934c15d08629302413c671d3f672 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Check formatting + if: always() && steps.install.outcome == 'success' + run: dart format --output=none --set-exit-if-changed . + - name: Analyze code + run: dart analyze --fatal-infos + if: always() && steps.install.outcome == 'success' + + # Run tests on a matrix consisting of three 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: + os: [ubuntu-latest] + sdk: ['3.3', dev] + steps: + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 + - uses: dart-lang/setup-dart@0a8a0fc875eb934c15d08629302413c671d3f672 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Run VM tests + run: dart test --platform vm + if: always() && steps.install.outcome == 'success' + - name: Run Chrome tests + run: dart test --platform chrome + if: always() && steps.install.outcome == 'success' diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 00000000..205a02db --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,14 @@ +# A CI configuration to auto-publish pub packages. + +name: Publish + +on: + pull_request: + branches: [ main ] + push: + tags: [ '[A-z]+-v[0-9]+.[0-9]+.[0-9]+' ] + +jobs: + publish: + if: ${{ github.repository_owner == 'dart-lang' }} + uses: dart-lang/ecosystem/.github/workflows/publish.yaml@main diff --git a/.github/workflows/pull_request_label.yaml b/.github/workflows/pull_request_label.yaml new file mode 100644 index 00000000..54e3df53 --- /dev/null +++ b/.github/workflows/pull_request_label.yaml @@ -0,0 +1,22 @@ +# This workflow applies labels to pull requests based on the paths that are +# modified in the pull request. +# +# Edit `.github/labeler.yml` to configure labels. For more information, see +# https://github.com/actions/labeler. + +name: Pull Request Labeler +permissions: read-all + +on: + pull_request_target + +jobs: + label: + permissions: + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" + sync-labels: true diff --git a/README.md b/README.md index 2fbc6037..7b81d0b4 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ This repository is home to various Dart packages under the [dart.dev](https://pu | Package | Description | Version | |---|---|---| +| [args](pkgs/args/) | Library for defining parsers for parsing raw command-line arguments into a set + of options and values. | [![pub package](https://img.shields.io/pub/v/args.svg)](https://pub.dev/packages/args) | ## Publishing automation @@ -15,4 +17,4 @@ For information about our publishing automation and release process, see https://github.com/dart-lang/ecosystem/wiki/Publishing-automation. For additional information about contributing, see our -[contributing](CONTRIBUTING.md) page. \ No newline at end of file +[contributing](CONTRIBUTING.md) page. diff --git a/pkgs/args/.gitignore b/pkgs/args/.gitignore new file mode 100644 index 00000000..813a31ec --- /dev/null +++ b/pkgs/args/.gitignore @@ -0,0 +1,16 @@ +# Don’t commit the following directories created by pub. +.buildlog +.pub/ +.dart_tool/ +build/ +packages +.packages + +# Or the files created by dart2js. +*.dart.js +*.js_ +*.js.deps +*.js.map + +# Include when developing application packages. +pubspec.lock diff --git a/pkgs/args/.test_config b/pkgs/args/.test_config new file mode 100644 index 00000000..412fc5c5 --- /dev/null +++ b/pkgs/args/.test_config @@ -0,0 +1,3 @@ +{ + "test_package": true +} \ No newline at end of file diff --git a/pkgs/args/CHANGELOG.md b/pkgs/args/CHANGELOG.md new file mode 100644 index 00000000..15b392b1 --- /dev/null +++ b/pkgs/args/CHANGELOG.md @@ -0,0 +1,346 @@ +## 2.6.0 + +* Added source argument when throwing a `ArgParserException`. +* Fix inconsistent `FormatException` messages +* Require Dart 3.3 +* Move to `dart-lang/core` monorepo. + +## 2.5.0 + +* Introduce new typed `ArgResults` `flag(String)`, `option(String)`, and + `multiOption(String)` methods. +* Require Dart 3.0. + +## 2.4.2 + +* Change the validation of `mandatory` options; they now perform validation when + the value is retrieved (from the `ArgResults` object), instead of when the + args are parsed. +* Require Dart 2.19. + +## 2.4.1 + +* Add a `CONTRIBUTING.md` file; move the publishing automation docs from the + readme into the contributing doc. +* Added package topics to the pubspec file. + +## 2.4.0 + +* Command suggestions will now also suggest based on aliases of a command. +* Introduce getter `Command.suggestionAliases` for names that cannot be used as + aliases, but will trigger suggestions. + +## 2.3.2 + +* Require Dart 2.18 + +## 2.3.1 + +* Switch to using package:lints. +* Address an issue with the readme API documentation (#211). +* Populate the pubspec `repository` field. + +## 2.3.0 + +* Add the ability to group commands by category in usage text. + +## 2.2.0 + +* Suggest similar commands if an unknown command is encountered, when using the + `CommandRunner`. + * The max edit distance for suggestions defaults to 2, but can be configured + using the `suggestionDistanceLimit` parameter on the constructor. You can + set it to `0` to disable the feature. + +## 2.1.1 + +* Fix a bug with `mandatory` options which caused a null assertion failure when + used within a command. + +## 2.1.0 + +* Add a `mandatory` argument to require the presence of an option. +* Add `aliases` named argument to `addFlag`, `addOption`, and `addMultiOption`, + as well as a public `findByNameOrAlias` method on `ArgParser`. This allows + you to provide aliases for an argument name, which eases the transition from + one argument name to another. + +## 2.0.0 + +* Stable null safety release. + +## 2.0.0-nullsafety.0 + +* Migrate to null safety. +* **BREAKING** Remove APIs that had been marked as deprecated: + + * Instead of the `allowMulti` and `splitCommas` arguments to + `ArgParser.addOption()`, use `ArgParser.addMultiOption()`. + * Instead of `ArgParser.getUsage()`, use `ArgParser.usage`. + * Instead of `Option.abbreviation`, use `Option.abbr`. + * Instead of `Option.defaultValue`, use `Option.defaultsTo`. + * Instead of `OptionType.FLAG/SINGLE/MULTIPLE`, use + `OptionType.flag/single/multiple`. +* Add a more specific function type to the `callback` argument of `addOption`. + +## 1.6.0 + +* Remove `help` from the list of commands in usage. +* Remove the blank lines in usage which separated the help for options that + happened to span multiple lines. + +## 1.5.4 + +* Fix a bug with option names containing underscores. +* Point towards `CommandRunner` in the docs for `ArgParser.addCommand` since it + is what most authors will want to use instead. + +## 1.5.3 + +* Improve arg parsing performance: use queues instead of lists internally to + get linear instead of quadratic performance, which is important for large + numbers of args (>1000). And, use simple string manipulation instead of + regular expressions for a 1.5x improvement everywhere. +* No longer automatically add a 'help' option to commands that don't validate + their arguments (fix #123). + +## 1.5.2 + +* Added support for `usageLineLength` in `CommandRunner` + +## 1.5.1 + +* Added more comprehensive word wrapping when `usageLineLength` is set. + +## 1.5.0 + +* Add `usageLineLength` to control word wrapping usage text. + +## 1.4.4 + +* Set max SDK version to `<3.0.0`, and adjust other dependencies. + +## 1.4.3 + +* Display the default values for options with `allowedHelp` specified. + +## 1.4.2 + +* Narrow the SDK constraint to only allow SDK versions that support `FutureOr`. + +## 1.4.1 + +* Fix the way default values for multi-valued options are printed in argument + usage. + +## 1.4.0 + +* Deprecated `OptionType.FLAG`, `OptionType.SINGLE`, and `OptionType.MULTIPLE` + in favor of `OptionType.flag`, `OptionType.single`, and `OptionType.multiple` + which follow the style guide. + +* Deprecated `Option.abbreviation` and `Option.defaultValue` in favor of + `Option.abbr` and `Option.defaultsTo`. This makes all of `Option`'s fields + match the corresponding parameters to `ArgParser.addOption()`. + +* Deprecated the `allowMultiple` and `splitCommas` arguments to + `ArgParser.addOption()` in favor of a separate `ArgParser.addMultiOption()` + method. This allows us to provide more accurate type information, and to avoid + adding flags that only make sense for multi-options in places where they might + be usable for single-value options. + +## 1.3.0 + +* Type `Command.run()`'s return value as `FutureOr`. + +## 1.2.0 + +* Type the `callback` parameter to `ArgParser.addOption()` as `Function` rather + than `void Function(value)`. This allows strong-mode users to write `callback: + (String value) { ... }` rather than having to manually cast `value` to a + `String` (or a `List` with `allowMultiple: true`). + +## 1.1.0 + +* `ArgParser.parse()` now takes an `Iterable` rather than a + `List`. + +* `ArgParser.addOption()`'s `allowed` option now takes an `Iterable` + rather than a `List`. + +## 1.0.2 + +* Fix analyzer warning + +## 1.0.1 + +* Fix a fuzzy arrow type warning. + +## 1.0.0 + +* **Breaking change**: The `allowTrailingOptions` argument to `new + ArgumentParser()` defaults to `true` instead of `false`. + +* Add `new ArgParser.allowAnything()`. This allows any input, without parsing + any options. + +## 0.13.7 + +* Add explicit support for forwarding the value returned by `Command.run()` to + `CommandRunner.run()`. This worked unintentionally prior to 0.13.6+1. + +* Add type arguments to `CommandRunner` and `Command` to indicate the return + values of the `run()` functions. + +## 0.13.6+1 + +* When a `CommandRunner` is passed `--help` before any commands, it now prints + the usage of the chosen command. + +## 0.13.6 + +* `ArgParser.parse()` now throws an `ArgParserException`, which implements + `FormatException` and has a field that lists the commands that were parsed. + +* If `CommandRunner.run()` encounters a parse error for a subcommand, it now + prints the subcommand's usage rather than the global usage. + +## 0.13.5 + +* Allow `CommandRunner.argParser` and `Command.argParser` to be overridden in + strong mode. + +## 0.13.4+2 + +* Fix a minor documentation error. + +## 0.13.4+1 + +* Ensure that multiple-value arguments produce reified `List`s. + +## 0.13.4 + +* By default, only the first line of a command's description is included in its + parent runner's usage string. This returns to the default behavior from + before 0.13.3+1. + +* A `Command.summary` getter has been added to explicitly control the summary + that appears in the parent runner's usage string. This getter defaults to the + first line of the description, but can be overridden if the user wants a + multi-line summary. + +## 0.13.3+6 + +* README fixes. + +## 0.13.3+5 + +* Make strong mode clean. + +## 0.13.3+4 + +* Use the proper `usage` getter in the README. + +## 0.13.3+3 + +* Add an explicit default value for the `allowTrailingOptions` parameter to `new + ArgParser()`. This doesn't change the behavior at all; the option already + defaulted to `false`, and passing in `null` still works. + +## 0.13.3+2 + +* Documentation fixes. + +## 0.13.3+1 + +* Print all lines of multi-line command descriptions. + +## 0.13.2 + +* Allow option values that look like options. This more closely matches the + behavior of [`getopt`][getopt], the *de facto* standard for option parsing. + +[getopt]: https://man7.org/linux/man-pages/man3/getopt.3.html + +## 0.13.1 + +* Add `ArgParser.addSeparator()`. Separators allow users to group their options + in the usage text. + +## 0.13.0 + +* **Breaking change**: An option that allows multiple values will now + automatically split apart comma-separated values. This can be controlled with + the `splitCommas` option. + +## 0.12.2+6 + +* Remove the dependency on the `collection` package. + +## 0.12.2+5 + +* Add syntax highlighting to the README. + +## 0.12.2+4 + +* Add an example of using command-line arguments to the README. + +## 0.12.2+3 + +* Fixed implementation of ArgResults.options to really use Iterable + instead of Iterable cast to Iterable. + +## 0.12.2+2 + +* Updated dependency constraint on `unittest`. + +* Formatted source code. + +* Fixed use of deprecated API in example. + +## 0.12.2+1 + +* Fix the built-in `help` command for `CommandRunner`. + +## 0.12.2 + +* Add `CommandRunner` and `Command` classes which make it easy to build a + command-based command-line application. + +* Add an `ArgResults.arguments` field, which contains the original argument list. + +## 0.12.1 + +* Replace `ArgParser.getUsage()` with `ArgParser.usage`, a getter. + `ArgParser.getUsage()` is now deprecated, to be removed in args version 1.0.0. + +## 0.12.0+2 + +* Widen the version constraint on the `collection` package. + +## 0.12.0+1 + +* Remove the documentation link from the pubspec so this is linked to + pub.dev by default. + +## 0.12.0 + +* Removed public constructors for `ArgResults` and `Option`. + +* `ArgResults.wasParsed()` can be used to determine if an option was actually + parsed or the default value is being returned. + +* Replaced `isFlag` and `allowMultiple` fields in the `Option` class with a + three-value `OptionType` enum. + +* Options may define `valueHelp` which will then be shown in the usage. + +## 0.11.0 + +* Move handling trailing options from `ArgParser.parse()` into `ArgParser` + itself. This lets subcommands have different behavior for how they handle + trailing options. + +## 0.10.0+2 + +* Usage ignores hidden options when determining column widths. diff --git a/pkgs/args/CONTRIBUTING.md b/pkgs/args/CONTRIBUTING.md new file mode 100644 index 00000000..d8db4ac2 --- /dev/null +++ b/pkgs/args/CONTRIBUTING.md @@ -0,0 +1,56 @@ +# How to Contribute + +We'd love to accept your patches and contributions to this project. There are +just a few small guidelines you need to follow. + +## Contributor License Agreement + +Contributions to this project must be accompanied by a Contributor License +Agreement (CLA). You (or your employer) retain the copyright to your +contribution; this simply gives us permission to use and redistribute your +contributions as part of the project. Head over to + to see your current agreements on file or +to sign a new one. + +You generally only need to submit a CLA once, so if you've already submitted one +(even if it was for a different project), you probably don't need to do it +again. + +## Code Reviews + +All submissions, including submissions by project members, require review. We +use GitHub pull requests for this purpose. Consult +[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more +information on using pull requests. + +## Coding style + +The Dart source code in this repo follows the: + + * [Dart style guide](https://dart.dev/guides/language/effective-dart/style) + +You should familiarize yourself with those guidelines. + +## File headers + +All files in the Dart project must start with the following header; if you add a +new file please also add this. The year should be a single number stating the +year the file was created (don't use a range like "2011-2012"). Additionally, if +you edit an existing file, you shouldn't update the year. + + // Copyright (c) 2023, 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. + +## Publishing automation + +For information about our publishing automation and release process, see +https://github.com/dart-lang/ecosystem/wiki/Publishing-automation. + +## Community Guidelines + +This project follows +[Google's Open Source Community Guidelines](https://opensource.google/conduct/). + +We pledge to maintain an open and welcoming environment. For details, see our +[code of conduct](https://dart.dev/code-of-conduct). diff --git a/pkgs/args/LICENSE b/pkgs/args/LICENSE new file mode 100644 index 00000000..ab3bfa01 --- /dev/null +++ b/pkgs/args/LICENSE @@ -0,0 +1,27 @@ +Copyright 2013, 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/args/README.md b/pkgs/args/README.md new file mode 100644 index 00000000..05f36eb7 --- /dev/null +++ b/pkgs/args/README.md @@ -0,0 +1,459 @@ +[![Dart CI](https://github.com/dart-lang/core/actions/workflows/args.yaml/badge.svg)](https://github.com/dart-lang/core/actions/workflows/args.yaml) +[![pub package](https://img.shields.io/pub/v/args.svg)](https://pub.dev/packages/args) +[![package publisher](https://img.shields.io/pub/publisher/args.svg)](https://pub.dev/packages/args/publisher) + +Parses raw command-line arguments into a set of options and values. + +This library supports [GNU][] and [POSIX][] style options, and it works in both +server-side and client-side apps. + +## Defining options + +First create an [ArgParser][]: + + var parser = ArgParser(); + +Then define a set of options on that parser using [addOption()][addOption] and +[addFlag()][addFlag]. Here's the minimal way to create an option named "name": + + parser.addOption('name'); + +When an option can only be set or unset (as opposed to taking a string value), +use a flag: + +```dart +parser.addFlag('name'); +``` + +Flag options, by default, accept a 'no-' prefix to negate the option. You can +disable the 'no-' prefix using the `negatable` parameter: + +```dart +parser.addFlag('name', negatable: false); +``` + +*Note:* From here on out, "option" refers to both regular options and flags. In +cases where the distinction matters, we'll use "non-flag option." + +Options can have an optional single-character abbreviation, specified with the +`abbr` parameter: + +```dart +parser.addOption('mode', abbr: 'm'); +parser.addFlag('verbose', abbr: 'v'); +``` + +Options can also have a default value, specified with the `defaultsTo` +parameter. The default value is used when arguments don't specify the option. + +```dart +parser.addOption('mode', defaultsTo: 'debug'); +parser.addFlag('verbose', defaultsTo: false); +``` + +The default value for non-flag options can be any string. For flags, it must +be a `bool`. + +To validate a non-flag option, you can use the `allowed` parameter to provide an +allowed set of values. When you do, the parser throws an +[`ArgParserException`][ArgParserException] if the value for an option is not in +the allowed set. Here's an example of specifying allowed values: + +```dart +parser.addOption('mode', allowed: ['debug', 'release']); +``` + +You can use the `callback` parameter to associate a function with an option. +Later, when parsing occurs, the callback function is invoked with the value of +the option: + +```dart +parser.addOption('mode', callback: (mode) => print('Got mode $mode')); +parser.addFlag('verbose', callback: (verbose) { + if (verbose) print('Verbose'); +}); +``` + +The callbacks for all options are called whenever a set of arguments is parsed. +If an option isn't provided in the args, its callback is passed the default +value, or `null` if no default value is set. + +If an option is `mandatory` but not provided, the results object throws an +[`ArgumentError`][ArgumentError] on retrieval. + +```dart +parser.addOption('mode', mandatory: true); +``` + +## Parsing arguments + +Once you have an [ArgParser][] set up with some options and flags, you use it by +calling [ArgParser.parse()][parse] with a set of arguments: + +```dart +var results = parser.parse(['some', 'command', 'line', 'args']); +``` + +These arguments usually come from the arguments to `main()`. For example: + + main(List args) { + // ... + var results = parser.parse(args); + } + +However, you can pass in any list of strings. The `parse()` method returns an +instance of [ArgResults][], a map-like object that contains the values of the +parsed options. + +```dart +var parser = ArgParser(); +parser.addOption('mode'); +parser.addFlag('verbose', defaultsTo: true); +var results = parser.parse(['--mode', 'debug', 'something', 'else']); + +print(results.option('mode')); // debug +print(results.flag('verbose')); // true +``` + +By default, the `parse()` method allows additional flags and options to be +passed after positional parameters unless `--` is used to indicate that all +further parameters will be positional. The positional arguments go into +[ArgResults.rest][rest]. + +```dart +print(results.rest); // ['something', 'else'] +``` + +To stop parsing options as soon as a positional argument is found, +`allowTrailingOptions: false` when creating the [ArgParser][]. + +## Specifying options + +To actually pass in options and flags on the command line, use GNU or POSIX +style. Consider this option: + +```dart +parser.addOption('name', abbr: 'n'); +``` + +You can specify its value on the command line using any of the following: + +``` +--name=somevalue +--name somevalue +-nsomevalue +-n somevalue +``` + +Consider this flag: + +```dart +parser.addFlag('name', abbr: 'n'); +``` + +You can set it to true using one of the following: + +``` +--name +-n +``` + +You can set it to false using the following: + +``` +--no-name +``` + +Multiple flag abbreviations can be collapsed into a single argument. Say you +define these flags: + +```dart +parser + ..addFlag('verbose', abbr: 'v') + ..addFlag('french', abbr: 'f') + ..addFlag('iambic-pentameter', abbr: 'i'); +``` + +You can set all three flags at once: + +``` +-vfi +``` + +By default, an option has only a single value, with later option values +overriding earlier ones; for example: + +```dart +var parser = ArgParser(); +parser.addOption('mode'); +var results = parser.parse(['--mode', 'on', '--mode', 'off']); +print(results.option('mode')); // prints 'off' +``` + +Multiple values can be parsed with `addMultiOption()`. With this method, an +option can occur multiple times, and the `parse()` method returns a list of +values: + +```dart +var parser = ArgParser(); +parser.addMultiOption('mode'); +var results = parser.parse(['--mode', 'on', '--mode', 'off']); +print(results.multiOption('mode')); // prints '[on, off]' +``` + +By default, values for a multi-valued option may also be separated with commas: + +```dart +var parser = ArgParser(); +parser.addMultiOption('mode'); +var results = parser.parse(['--mode', 'on,off']); +print(results.multiOption('mode')); // prints '[on, off]' +``` + +This can be disabled by passing `splitCommas: false`. + +## Defining commands + +In addition to *options*, you can also define *commands*. A command is a named +argument that has its own set of options. For example, consider this shell +command: + +``` +$ git commit -a +``` + +The executable is `git`, the command is `commit`, and the `-a` option is an +option passed to the command. You can add a command using the [addCommand][] +method: + +```dart +var parser = ArgParser(); +var command = parser.addCommand('commit'); +``` + +It returns another [ArgParser][], which you can then use to define options +specific to that command. If you already have an [ArgParser][] for the command's +options, you can pass it in: + +```dart +var parser = ArgParser(); +var command = ArgParser(); +parser.addCommand('commit', command); +``` + +The [ArgParser][] for a command can then define options or flags: + +```dart +command.addFlag('all', abbr: 'a'); +``` + +You can add multiple commands to the same parser so that a user can select one +from a range of possible commands. When parsing an argument list, you can then +determine which command was entered and what options were provided for it. + +```dart +var results = parser.parse(['commit', '-a']); +print(results.command.name); // "commit" +print(results.command['all']); // true +``` + +Options for a command must appear after the command in the argument list. For +example, given the above parser, `"git -a commit"` is *not* valid. The parser +tries to find the right-most command that accepts an option. For example: + +```dart +var parser = ArgParser(); +parser.addFlag('all', abbr: 'a'); +var command = parser.addCommand('commit'); +command.addFlag('all', abbr: 'a'); + +var results = parser.parse(['commit', '-a']); +print(results.command['all']); // true +``` + +Here, both the top-level parser and the `"commit"` command can accept a `"-a"` +(which is probably a bad command line interface, admittedly). In that case, when +`"-a"` appears after `"commit"`, it is applied to that command. If it appears to +the left of `"commit"`, it is given to the top-level parser. + +## Dispatching Commands + +If you're writing a command-based application, you can use the [CommandRunner][] +and [Command][] classes to help structure it. [CommandRunner][] has built-in +support for dispatching to [Command][]s based on command-line arguments, as well +as handling `--help` flags and invalid arguments. + +When using the [CommandRunner][] it replaces the [ArgParser][]. + +In the following example we build a dart application called `dgit` that takes commands `commit` and `stash`. + +The [CommandRunner][] takes an `executableName` which is used to generate the help message. + +e.g. +`dgit commit -a` + +File `dgit.dart` + +```dart +void main(List args) { + var runner = CommandRunner("dgit", "A dart implementation of distributed version control.") + ..addCommand(CommitCommand()) + ..addCommand(StashCommand()) + ..run(args); +} +``` + +When the above `run(args)` line executes it parses the command line args looking for one of the commands (`commit` or `stash`). + +If the [CommandRunner][] finds a matching command then the [CommandRunner][] calls the overridden `run()` method on the matching command (e.g. CommitCommand().run). + +Commands are defined by extending the [Command][] class. For example: + +```dart +class CommitCommand extends Command { + // The [name] and [description] properties must be defined by every + // subclass. + final name = "commit"; + final description = "Record changes to the repository."; + + CommitCommand() { + // we can add command specific arguments here. + // [argParser] is automatically created by the parent class. + argParser.addFlag('all', abbr: 'a'); + } + + // [run] may also return a Future. + void run() { + // [argResults] is set before [run()] is called and contains the flags/options + // passed to this command. + print(argResults.flag('all')); + } +} +``` + +### CommandRunner Arguments +The [CommandRunner][] allows you to specify both global args as well as command specific arguments (and even sub-command specific arguments). + +#### Global Arguments +Add argments directly to the [CommandRunner] to specify global arguments: + +Adding global arguments + +```dart +var runner = CommandRunner('dgit', "A dart implementation of distributed version control."); +// add global flag +runner.argParser.addFlag('verbose', abbr: 'v', help: 'increase logging'); +``` + +#### Command specific Arguments +Add arguments to each [Command][] to specify [Command][] specific arguments. + +```dart + CommitCommand() { + // we can add command specific arguments here. + // [argParser] is automatically created by the parent class. + argParser.addFlag('all', abbr: 'a'); + } +``` + +### SubCommands + +Commands can also have subcommands, which are added with [addSubcommand][]. A +command with subcommands can't run its own code, so [run][] doesn't need to be +implemented. For example: + +```dart +class StashCommand extends Command { + final String name = "stash"; + final String description = "Stash changes in the working directory."; + + StashCommand() { + addSubcommand(StashSaveCommand()); + addSubcommand(StashListCommand()); + } +} +``` + +### Default Help Command + +[CommandRunner][] automatically adds a `help` command that displays usage +information for commands, as well as support for the `--help` flag for all +commands. If it encounters an error parsing the arguments or processing a +command, it throws a [UsageException][]; your `main()` method should catch these and +print them appropriately. For example: + +```dart +runner.run(arguments).catchError((error) { + if (error is! UsageException) throw error; + print(error); + exit(64); // Exit code 64 indicates a usage error. +}); +``` + +## Displaying usage + +You can automatically generate nice help text, suitable for use as the output of +`--help`. To display good usage information, you should provide some help text +when you create your options. + +To define help text for an entire option, use the `help:` parameter: + +```dart +parser.addOption('mode', help: 'The compiler configuration', + allowed: ['debug', 'release']); +parser.addFlag('verbose', help: 'Show additional diagnostic info'); +``` + +For non-flag options, you can also provide a help string for the parameter: + +```dart +parser.addOption('out', help: 'The output path', valueHelp: 'path', + allowed: ['debug', 'release']); +``` + +For non-flag options, you can also provide detailed help for each expected value +by using the `allowedHelp:` parameter: + +```dart +parser.addOption('arch', help: 'The architecture to compile for', + allowedHelp: { + 'ia32': 'Intel x86', + 'arm': 'ARM Holding 32-bit chip' + }); +``` + +To display the help, use the [usage][usage] getter: + +```dart +print(parser.usage); +``` + +The resulting string looks something like this: + +``` +--mode The compiler configuration + [debug, release] + +--out= The output path +--[no-]verbose Show additional diagnostic info +--arch The architecture to compile for + [arm] ARM Holding 32-bit chip + [ia32] Intel x86 +``` + +[posix]: https://pubs.opengroup.org/onlinepubs/009695399/basedefs/xbd_chap12.html#tag_12_02 +[gnu]: https://www.gnu.org/prep/standards/standards.html#Command_002dLine-Interfaces +[ArgParser]: https://pub.dev/documentation/args/latest/args/ArgParser/ArgParser.html +[ArgParserException]: https://pub.dev/documentation/args/latest/args/ArgParserException-class.html +[ArgResults]: https://pub.dev/documentation/args/latest/args/ArgResults-class.html +[CommandRunner]: https://pub.dev/documentation/args/latest/command_runner/CommandRunner-class.html +[Command]: https://pub.dev/documentation/args/latest/command_runner/Command-class.html +[UsageException]: https://pub.dev/documentation/args/latest/command_runner/UsageException-class.html +[addOption]: https://pub.dev/documentation/args/latest/args/ArgParser/addOption.html +[addFlag]: https://pub.dev/documentation/args/latest/args/ArgParser/addFlag.html +[parse]: https://pub.dev/documentation/args/latest/args/ArgParser/parse.html +[rest]: https://pub.dev/documentation/args/latest/args/ArgResults/rest.html +[addCommand]: https://pub.dev/documentation/args/latest/args/ArgParser/addCommand.html +[usage]: https://pub.dev/documentation/args/latest/args/ArgParser/usage.html +[addSubcommand]: https://pub.dev/documentation/args/latest/command_runner/Command/addSubcommand.html +[run]: https://pub.dev/documentation/args/latest/command_runner/CommandRunner/run.html diff --git a/pkgs/args/analysis_options.yaml b/pkgs/args/analysis_options.yaml new file mode 100644 index 00000000..a96c5ee4 --- /dev/null +++ b/pkgs/args/analysis_options.yaml @@ -0,0 +1,14 @@ +# https://dart.dev/guides/language/analysis-options + +include: package:dart_flutter_team_lints/analysis_options.yaml + +linter: + rules: + - avoid_unused_constructor_parameters + - cancel_subscriptions + - literal_only_boolean_expressions + - missing_whitespace_between_adjacent_strings + - no_adjacent_strings_in_list + - no_runtimeType_toString + - package_api_docs + - unnecessary_await_in_return diff --git a/pkgs/args/example/arg_parser/README.md b/pkgs/args/example/arg_parser/README.md new file mode 100644 index 00000000..c137bcdb --- /dev/null +++ b/pkgs/args/example/arg_parser/README.md @@ -0,0 +1,3 @@ +# Example of using `ArgParser` + +`dart run example.dart` diff --git a/pkgs/args/example/arg_parser/example.dart b/pkgs/args/example/arg_parser/example.dart new file mode 100644 index 00000000..0a6cc97c --- /dev/null +++ b/pkgs/args/example/arg_parser/example.dart @@ -0,0 +1,169 @@ +// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// This is an example of converting the args in test.dart to use this API. +/// It shows what it looks like to build an [ArgParser] and then, when the code +/// is run, demonstrates what the generated usage text looks like. +library; + +import 'dart:io'; + +import 'package:args/args.dart'; + +void main() { + var parser = ArgParser(); + + parser.addSeparator('===== Platform'); + + parser.addOption('compiler', + abbr: 'c', + defaultsTo: 'none', + help: 'Specify any compilation step (if needed).', + allowed: [ + 'none', + 'dart2js', + 'dartc' + ], + allowedHelp: { + 'none': 'Do not compile the Dart code (run native Dart code on the' + ' VM).\n(only valid with the following runtimes: vm, drt)', + 'dart2js': 'Compile dart code to JavaScript by running dart2js.\n' + '(only valid with the following runtimes: d8, drt, chrome\n' + 'safari, ie, firefox, opera, none (compile only))', + 'dartc': 'Perform static analysis on Dart code by running dartc.\n' + '(only valid with the following runtimes: none)', + }); + + parser.addOption('runtime', + abbr: 'r', + defaultsTo: 'vm', + help: 'Where the tests should be run.', + allowed: [ + 'vm', + 'd8', + 'drt', + 'dartium', + 'ff', + 'firefox', + 'chrome', + 'safari', + 'ie', + 'opera', + 'none' + ], + allowedHelp: { + 'vm': 'Run Dart code on the standalone dart vm.', + 'd8': 'Run JavaScript from the command line using v8.', + 'drt': 'Run Dart or JavaScript in the headless version of Chrome,\n' + 'content shell.', + 'dartium': 'Run Dart or JavaScript in Dartium.', + 'ff': 'Run JavaScript in Firefox', + 'chrome': 'Run JavaScript in Chrome', + 'safari': 'Run JavaScript in Safari', + 'ie': 'Run JavaScript in Internet Explorer', + 'opera': 'Run JavaScript in Opera', + 'none': 'No runtime, compile only (for example, used for dartc static\n' + 'analysis tests).', + }); + + parser.addOption('arch', + abbr: 'a', + defaultsTo: 'ia32', + help: 'The architecture to run tests for', + allowed: ['all', 'ia32', 'x64', 'simarm']); + + parser.addOption('system', + abbr: 's', + defaultsTo: Platform.operatingSystem, + help: 'The operating system to run tests on', + allowed: ['linux', 'macos', 'windows']); + + parser.addSeparator('===== Runtime'); + + parser.addOption('mode', + abbr: 'm', + defaultsTo: 'debug', + help: 'Mode in which to run the tests', + allowed: ['all', 'debug', 'release']); + + parser.addFlag('checked', + defaultsTo: false, help: 'Run tests in checked mode'); + + parser.addFlag('host-checked', + defaultsTo: false, help: 'Run compiler in checked mode'); + + parser.addOption('timeout', abbr: 't', help: 'Timeout in seconds'); + + parser.addOption('tasks', + abbr: 'j', + defaultsTo: Platform.numberOfProcessors.toString(), + help: 'The number of parallel tasks to run'); + + parser.addOption('shards', + defaultsTo: '1', + help: 'The number of instances that the tests will be sharded over'); + + parser.addOption('shard', + defaultsTo: '1', + help: 'The index of this instance when running in sharded mode'); + + parser.addFlag('valgrind', + defaultsTo: false, help: 'Run tests through valgrind'); + + parser.addSeparator('===== Output'); + + parser.addOption('progress', + abbr: 'p', + defaultsTo: 'compact', + help: 'Progress indication mode', + allowed: [ + 'compact', + 'color', + 'line', + 'verbose', + 'silent', + 'status', + 'buildbot' + ]); + + parser.addFlag('report', + defaultsTo: false, + help: 'Print a summary report of the number of tests, by expectation'); + + parser.addFlag('verbose', + abbr: 'v', defaultsTo: false, help: 'Verbose output'); + + parser.addFlag('list', + defaultsTo: false, help: 'List tests only, do not run them'); + + parser.addFlag('time', + help: 'Print timing information after running tests', defaultsTo: false); + + parser.addFlag('batch', + abbr: 'b', help: 'Run browser tests in batch mode', defaultsTo: true); + + parser.addSeparator('===== Miscellaneous'); + + parser.addFlag('keep-generated-tests', + defaultsTo: false, + help: 'Keep the generated files in the temporary directory'); + + parser.addOption('special-command', help: """ +Special command support. Wraps the command line in +a special command. The special command should contain +an '@' character which will be replaced by the normal +command. + +For example if the normal command that will be executed +is 'dart file.dart' and you specify special command +'python -u valgrind.py @ suffix' the final command will be +'python -u valgrind.py dart file.dart suffix'"""); + + parser.addOption('dart', help: 'Path to dart executable'); + parser.addOption('drt', help: 'Path to content shell executable'); + parser.addOption('dartium', help: 'Path to Dartium Chrome executable'); + parser.addOption('mandatory', help: 'A mandatory option', mandatory: true); + + print(parser.usage); +} diff --git a/pkgs/args/example/arg_parser/pubspec.yaml b/pkgs/args/example/arg_parser/pubspec.yaml new file mode 100644 index 00000000..52f193a8 --- /dev/null +++ b/pkgs/args/example/arg_parser/pubspec.yaml @@ -0,0 +1,13 @@ +# Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file +# for details. All rights reserved. Use of this source code is governed by a +# BSD-style license that can be found in the LICENSE file. + +name: arg_parser_example +version: 1.0.0 +description: An example of using ArgParser +publish_to: 'none' +environment: + sdk: '>=2.14.0 <3.0.0' +dependencies: + args: + path: ../.. diff --git a/pkgs/args/example/command_runner/README.md b/pkgs/args/example/command_runner/README.md new file mode 100644 index 00000000..48bf60e7 --- /dev/null +++ b/pkgs/args/example/command_runner/README.md @@ -0,0 +1,5 @@ +# Example of using `CommandRunner` + +This example uses `CommandRunner` to create a tool for drawing ascii art shapes. + +`dart run draw.dart circle --radius=10` diff --git a/pkgs/args/example/command_runner/draw.dart b/pkgs/args/example/command_runner/draw.dart new file mode 100644 index 00000000..018bf592 --- /dev/null +++ b/pkgs/args/example/command_runner/draw.dart @@ -0,0 +1,142 @@ +// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:math'; + +import 'package:args/command_runner.dart'; + +void main(List args) async { + final runner = CommandRunner('draw', 'Draws shapes') + ..addCommand(SquareCommand()) + ..addCommand(CircleCommand()) + ..addCommand(TriangleCommand()); + runner.argParser.addOption('char', help: 'The character to use for drawing'); + final output = await runner.run(args); + print(output); +} + +class SquareCommand extends Command { + SquareCommand() { + argParser.addOption('size', help: 'Size of the square'); + } + + @override + String get name => 'square'; + + @override + String get description => 'Draws a square'; + + @override + List get aliases => ['s']; + + @override + FutureOr? run() { + final size = int.parse(argResults?.option('size') ?? '20'); + final char = globalResults?.option('char')?[0] ?? '#'; + return draw(size, size, char, (x, y) => true); + } +} + +class CircleCommand extends Command { + CircleCommand() { + argParser.addOption('radius', help: 'Radius of the circle'); + } + + @override + String get name => 'circle'; + + @override + String get description => 'Draws a circle'; + + @override + List get aliases => ['c']; + + @override + FutureOr? run() { + final size = 2 * int.parse(argResults?.option('radius') ?? '10'); + final char = globalResults?.option('char')?[0] ?? '#'; + return draw(size, size, char, (x, y) => x * x + y * y < 1); + } +} + +class TriangleCommand extends Command { + TriangleCommand() { + addSubcommand(EquilateralTriangleCommand()); + addSubcommand(IsoscelesTriangleCommand()); + } + + @override + String get name => 'triangle'; + + @override + String get description => 'Draws a triangle'; + + @override + List get aliases => ['t']; +} + +class EquilateralTriangleCommand extends Command { + EquilateralTriangleCommand() { + argParser.addOption('size', help: 'Size of the triangle'); + } + + @override + String get name => 'equilateral'; + + @override + String get description => 'Draws an equilateral triangle'; + + @override + List get aliases => ['e']; + + @override + FutureOr? run() { + final size = int.parse(argResults?.option('size') ?? '20'); + final char = globalResults?.option('char')?[0] ?? '#'; + return drawTriangle(size, size * sqrt(3) ~/ 2, char); + } +} + +class IsoscelesTriangleCommand extends Command { + IsoscelesTriangleCommand() { + argParser.addOption('width', help: 'Width of the triangle'); + argParser.addOption('height', help: 'Height of the triangle'); + } + + @override + String get name => 'isosceles'; + + @override + String get description => 'Draws an isosceles triangle'; + + @override + List get aliases => ['i']; + + @override + FutureOr? run() { + final width = int.parse(argResults?.option('width') ?? '50'); + final height = int.parse(argResults?.option('height') ?? '10'); + final char = globalResults?.option('char')?[0] ?? '#'; + return drawTriangle(width, height, char); + } +} + +String draw( + int width, int height, String char, bool Function(double, double) pixel) { + final out = StringBuffer(); + for (var y = 0; y <= height; ++y) { + final ty = 2 * y / height - 1; + for (var x = 0; x <= width; ++x) { + final tx = 2 * x / width - 1; + out.write(pixel(tx, ty) ? char : ' '); + } + out.write('\n'); + } + return out.toString(); +} + +String drawTriangle(int width, int height, String char) { + return draw(width, height, char, (x, y) => x.abs() <= (1 + y) / 2); +} diff --git a/pkgs/args/example/command_runner/pubspec.yaml b/pkgs/args/example/command_runner/pubspec.yaml new file mode 100644 index 00000000..0745be61 --- /dev/null +++ b/pkgs/args/example/command_runner/pubspec.yaml @@ -0,0 +1,13 @@ +# Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file +# for details. All rights reserved. Use of this source code is governed by a +# BSD-style license that can be found in the LICENSE file. + +name: command_runner_example +version: 1.0.0 +description: An example of using CommandRunner +publish_to: 'none' +environment: + sdk: '>=2.14.0 <3.0.0' +dependencies: + args: + path: ../.. diff --git a/pkgs/args/lib/args.dart b/pkgs/args/lib/args.dart new file mode 100644 index 00000000..6011d1eb --- /dev/null +++ b/pkgs/args/lib/args.dart @@ -0,0 +1,8 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +export 'src/arg_parser.dart' show ArgParser; +export 'src/arg_parser_exception.dart' show ArgParserException; +export 'src/arg_results.dart' show ArgResults; +export 'src/option.dart' show Option, OptionType; diff --git a/pkgs/args/lib/command_runner.dart b/pkgs/args/lib/command_runner.dart new file mode 100644 index 00000000..e72a08d4 --- /dev/null +++ b/pkgs/args/lib/command_runner.dart @@ -0,0 +1,559 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:collection'; +import 'dart:math' as math; + +import 'src/arg_parser.dart'; +import 'src/arg_parser_exception.dart'; +import 'src/arg_results.dart'; +import 'src/help_command.dart'; +import 'src/usage_exception.dart'; +import 'src/utils.dart'; + +export 'src/usage_exception.dart'; + +/// A class for invoking [Command]s based on raw command-line arguments. +/// +/// The type argument `T` represents the type returned by [Command.run] and +/// [CommandRunner.run]; it can be ommitted if you're not using the return +/// values. +class CommandRunner { + /// The name of the executable being run. + /// + /// Used for error reporting and [usage]. + final String executableName; + + /// A short description of this executable. + final String description; + + /// A single-line template for how to invoke this executable. + /// + /// Defaults to `"$executableName arguments`". Subclasses can + /// override this for a more specific template. + String get invocation => '$executableName [arguments]'; + + /// Generates a string displaying usage information for the executable. + /// + /// This includes usage for the global arguments as well as a list of + /// top-level commands. + String get usage => _wrap('$description\n\n') + _usageWithoutDescription; + + /// An optional footer for [usage]. + /// + /// If a subclass overrides this to return a string, it will automatically be + /// added to the end of [usage]. + String? get usageFooter => null; + + /// Returns [usage] with [description] removed from the beginning. + String get _usageWithoutDescription { + var usagePrefix = 'Usage:'; + var buffer = StringBuffer(); + buffer.writeln( + '$usagePrefix ${_wrap(invocation, hangingIndent: usagePrefix.length)}\n', + ); + buffer.writeln(_wrap('Global options:')); + buffer.writeln('${argParser.usage}\n'); + buffer.writeln( + '${_getCommandUsage(_commands, lineLength: argParser.usageLineLength)}\n', + ); + buffer.write(_wrap( + 'Run "$executableName help " for more information about a ' + 'command.')); + if (usageFooter != null) { + buffer.write('\n${_wrap(usageFooter!)}'); + } + return buffer.toString(); + } + + /// An unmodifiable view of all top-level commands defined for this runner. + Map> get commands => UnmodifiableMapView(_commands); + final _commands = >{}; + + /// The top-level argument parser. + /// + /// Global options should be registered with this parser; they'll end up + /// available via [Command.globalResults]. Commands should be registered with + /// [addCommand] rather than directly on the parser. + ArgParser get argParser => _argParser; + final ArgParser _argParser; + + /// The maximum edit distance allowed when suggesting possible intended + /// commands. + /// + /// Set to `0` in order to disable suggestions, defaults to `2`. + final int suggestionDistanceLimit; + + CommandRunner(this.executableName, this.description, + {int? usageLineLength, this.suggestionDistanceLimit = 2}) + : _argParser = ArgParser(usageLineLength: usageLineLength) { + argParser.addFlag('help', + abbr: 'h', negatable: false, help: 'Print this usage information.'); + addCommand(HelpCommand()); + } + + /// Prints the usage information for this runner. + /// + /// This is called internally by [run] and can be overridden by subclasses to + /// control how output is displayed or integrate with a logging system. + void printUsage() => print(usage); + + /// Throws a [UsageException] with [message]. + Never usageException(String message) => + throw UsageException(message, _usageWithoutDescription); + + /// Adds [Command] as a top-level command to this runner. + void addCommand(Command command) { + var names = [command.name, ...command.aliases]; + for (var name in names) { + _commands[name] = command; + argParser.addCommand(name, command.argParser); + } + command._runner = this; + } + + /// Parses [args] and invokes [Command.run] on the chosen command. + /// + /// This always returns a [Future] in case the command is asynchronous. The + /// [Future] will throw a [UsageException] if [args] was invalid. + Future run(Iterable args) => + Future.sync(() => runCommand(parse(args))); + + /// Parses [args] and returns the result, converting an [ArgParserException] + /// to a [UsageException]. + /// + /// This is notionally a protected method. It may be overridden or called from + /// subclasses, but it shouldn't be called externally. + ArgResults parse(Iterable args) { + try { + return argParser.parse(args); + } on ArgParserException catch (error) { + if (error.commands.isEmpty) usageException(error.message); + + var command = commands[error.commands.first]!; + for (var commandName in error.commands.skip(1)) { + command = command.subcommands[commandName]!; + } + + command.usageException(error.message); + } + } + + /// Runs the command specified by [topLevelResults]. + /// + /// This is notionally a protected method. It may be overridden or called from + /// subclasses, but it shouldn't be called externally. + /// + /// It's useful to override this to handle global flags and/or wrap the entire + /// command in a block. For example, you might handle the `--verbose` flag + /// here to enable verbose logging before running the command. + /// + /// This returns the return value of [Command.run]. + Future runCommand(ArgResults topLevelResults) async { + var argResults = topLevelResults; + var commands = _commands; + Command? command; + var commandString = executableName; + + while (commands.isNotEmpty) { + if (argResults.command == null) { + if (argResults.rest.isEmpty) { + if (command == null) { + // No top-level command was chosen. + printUsage(); + return null; + } + + command.usageException('Missing subcommand for "$commandString".'); + } else { + var requested = argResults.rest[0]; + + // Build up a help message containing similar commands, if found. + var similarCommands = + _similarCommandsText(requested, commands.values); + + if (command == null) { + usageException( + 'Could not find a command named "$requested".$similarCommands'); + } + + command.usageException('Could not find a subcommand named ' + '"$requested" for "$commandString".$similarCommands'); + } + } + + // Step into the command. + argResults = argResults.command!; + command = commands[argResults.name]!; + command._globalResults = topLevelResults; + command._argResults = argResults; + commands = command._subcommands as Map>; + commandString += ' ${argResults.name}'; + + if (argResults.options.contains('help') && argResults.flag('help')) { + command.printUsage(); + return null; + } + } + + if (topLevelResults.flag('help')) { + command!.printUsage(); + return null; + } + + // Make sure there aren't unexpected arguments. + if (!command!.takesArguments && argResults.rest.isNotEmpty) { + command.usageException( + 'Command "${argResults.name}" does not take any arguments.'); + } + + return (await command.run()) as T?; + } + + // Returns help text for commands similar to `name`, in sorted order. + String _similarCommandsText(String name, Iterable> commands) { + if (suggestionDistanceLimit <= 0) return ''; + var distances = , int>{}; + var candidates = + SplayTreeSet>((a, b) => distances[a]! - distances[b]!); + for (var command in commands) { + if (command.hidden) continue; + for (var alias in [ + command.name, + ...command.aliases, + ...command.suggestionAliases + ]) { + var distance = _editDistance(name, alias); + if (distance <= suggestionDistanceLimit) { + distances[command] = + math.min(distances[command] ?? distance, distance); + candidates.add(command); + } + } + } + if (candidates.isEmpty) return ''; + + var similar = StringBuffer(); + similar + ..writeln() + ..writeln() + ..writeln('Did you mean one of these?'); + for (var command in candidates) { + similar.writeln(' ${command.name}'); + } + + return similar.toString(); + } + + String _wrap(String text, {int? hangingIndent}) => wrapText(text, + length: argParser.usageLineLength, hangingIndent: hangingIndent); +} + +/// A single command. +/// +/// A command is known as a "leaf command" if it has no subcommands and is meant +/// to be run. Leaf commands must override [run]. +/// +/// A command with subcommands is known as a "branch command" and cannot be run +/// itself. It should call [addSubcommand] (often from the constructor) to +/// register subcommands. +abstract class Command { + /// The name of this command. + String get name; + + /// A description of this command, included in [usage]. + String get description; + + /// A short description of this command, included in [parent]'s + /// [CommandRunner.usage]. + /// + /// This defaults to the first line of [description]. + String get summary => description.split('\n').first; + + /// The command's category. + /// + /// Displayed in [parent]'s [CommandRunner.usage]. Commands with categories + /// will be grouped together, and displayed after commands without a category. + String get category => ''; + + /// A single-line template for how to invoke this command (e.g. `"pub get + /// `package`"`). + String get invocation { + var parents = [name]; + for (var command = parent; command != null; command = command.parent) { + parents.add(command.name); + } + parents.add(runner!.executableName); + + var invocation = parents.reversed.join(' '); + return _subcommands.isNotEmpty + ? '$invocation [arguments]' + : '$invocation [arguments]'; + } + + /// The command's parent command, if this is a subcommand. + /// + /// This will be `null` until [addSubcommand] has been called with + /// this command. + Command? get parent => _parent; + Command? _parent; + + /// The command runner for this command. + /// + /// This will be `null` until [CommandRunner.addCommand] has been called with + /// this command or one of its parents. + CommandRunner? get runner { + if (parent == null) return _runner; + return parent!.runner; + } + + CommandRunner? _runner; + + /// The parsed global argument results. + /// + /// This will be `null` until just before [Command.run] is called. + ArgResults? get globalResults => _globalResults; + ArgResults? _globalResults; + + /// The parsed argument results for this command. + /// + /// This will be `null` until just before [Command.run] is called. + ArgResults? get argResults => _argResults; + ArgResults? _argResults; + + /// The argument parser for this command. + /// + /// Options for this command should be registered with this parser (often in + /// the constructor); they'll end up available via [argResults]. Subcommands + /// should be registered with [addSubcommand] rather than directly on the + /// parser. + /// + /// This can be overridden to change the arguments passed to the `ArgParser` + /// constructor. + ArgParser get argParser => _argParser; + final _argParser = ArgParser(); + + /// Generates a string displaying usage information for this command. + /// + /// This includes usage for the command's arguments as well as a list of + /// subcommands, if there are any. + String get usage => _wrap('$description\n\n') + _usageWithoutDescription; + + /// An optional footer for [usage]. + /// + /// If a subclass overrides this to return a string, it will automatically be + /// added to the end of [usage]. + String? get usageFooter => null; + + String _wrap(String text, {int? hangingIndent}) { + return wrapText(text, + length: argParser.usageLineLength, hangingIndent: hangingIndent); + } + + /// Returns [usage] with [description] removed from the beginning. + String get _usageWithoutDescription { + var length = argParser.usageLineLength; + var usagePrefix = 'Usage: '; + var buffer = StringBuffer() + ..writeln( + usagePrefix + _wrap(invocation, hangingIndent: usagePrefix.length)) + ..writeln(argParser.usage); + + if (_subcommands.isNotEmpty) { + buffer.writeln(); + buffer.writeln(_getCommandUsage( + _subcommands, + isSubcommand: true, + lineLength: length, + )); + } + + buffer.writeln(); + buffer.write( + _wrap('Run "${runner!.executableName} help" to see global options.')); + + if (usageFooter != null) { + buffer.writeln(); + buffer.write(_wrap(usageFooter!)); + } + + return buffer.toString(); + } + + /// An unmodifiable view of all sublevel commands of this command. + Map> get subcommands => UnmodifiableMapView(_subcommands); + final _subcommands = >{}; + + /// Whether or not this command should be hidden from help listings. + /// + /// This is intended to be overridden by commands that want to mark themselves + /// hidden. + /// + /// By default, leaf commands are always visible. Branch commands are visible + /// as long as any of their leaf commands are visible. + bool get hidden { + // Leaf commands are visible by default. + if (_subcommands.isEmpty) return false; + + // Otherwise, a command is hidden if all of its subcommands are. + return _subcommands.values.every((subcommand) => subcommand.hidden); + } + + /// Whether or not this command takes positional arguments in addition to + /// options. + /// + /// If false, [CommandRunner.run] will throw a [UsageException] if arguments + /// are provided. Defaults to true. + /// + /// This is intended to be overridden by commands that don't want to receive + /// arguments. It has no effect for branch commands. + bool get takesArguments => true; + + /// Alternate names for this command. + /// + /// These names won't be used in the documentation, but they will work when + /// invoked on the command line. + /// + /// This is intended to be overridden. + List get aliases => const []; + + /// Alternate non-functional names for this command. + /// + /// These names won't be used in the documentation, and also they won't work + /// when invoked on the command line. But if an unknown command is used it + /// will be matched against this when creating suggestions. + /// + /// A name does not have to be repeated both here and in [aliases]. + /// + /// This is intended to be overridden. + List get suggestionAliases => const []; + + Command() { + if (!argParser.allowsAnything) { + argParser.addFlag('help', + abbr: 'h', negatable: false, help: 'Print this usage information.'); + } + } + + /// Runs this command. + /// + /// The return value is wrapped in a `Future` if necessary and returned by + /// [CommandRunner.runCommand]. + FutureOr? run() { + throw UnimplementedError(_wrap('Leaf command $this must implement run().')); + } + + /// Adds [Command] as a subcommand of this. + void addSubcommand(Command command) { + var names = [command.name, ...command.aliases]; + for (var name in names) { + _subcommands[name] = command; + argParser.addCommand(name, command.argParser); + } + command._parent = this; + } + + /// Prints the usage information for this command. + /// + /// This is called internally by [run] and can be overridden by subclasses to + /// control how output is displayed or integrate with a logging system. + void printUsage() => print(usage); + + /// Throws a [UsageException] with [message]. + Never usageException(String message) => + throw UsageException(_wrap(message), _usageWithoutDescription); +} + +/// Returns a string representation of [commands] fit for use in a usage string. +/// +/// [isSubcommand] indicates whether the commands should be called "commands" or +/// "subcommands". +String _getCommandUsage(Map commands, + {bool isSubcommand = false, int? lineLength}) { + // Don't include aliases. + var names = + commands.keys.where((name) => !commands[name]!.aliases.contains(name)); + + // Filter out hidden ones, unless they are all hidden. + var visible = names.where((name) => !commands[name]!.hidden); + if (visible.isNotEmpty) names = visible; + + // Show the commands alphabetically. + names = names.toList()..sort(); + + // Group the commands by category. + var commandsByCategory = SplayTreeMap>(); + for (var name in names) { + var category = commands[name]!.category; + commandsByCategory.putIfAbsent(category, () => []).add(commands[name]!); + } + final categories = commandsByCategory.keys.toList(); + + var length = names.map((name) => name.length).reduce(math.max); + + var buffer = StringBuffer('Available ${isSubcommand ? "sub" : ""}commands:'); + var columnStart = length + 5; + for (var category in categories) { + if (category != '') { + buffer.writeln(); + buffer.writeln(); + buffer.write(category); + } + for (var command in commandsByCategory[category]!) { + var lines = wrapTextAsLines(command.summary, + start: columnStart, length: lineLength); + buffer.writeln(); + buffer.write(' ${padRight(command.name, length)} ${lines.first}'); + + for (var line in lines.skip(1)) { + buffer.writeln(); + buffer.write(' ' * columnStart); + buffer.write(line); + } + } + } + + return buffer.toString(); +} + +/// Returns the edit distance between `from` and `to`. +// +/// Allows for edits, deletes, substitutions, and swaps all as single cost. +/// +/// See https://en.wikipedia.org/wiki/Damerau%E2%80%93Levenshtein_distance#Optimal_string_alignment_distance +int _editDistance(String from, String to) { + // Add a space in front to mimic indexing by 1 instead of 0. + from = ' $from'; + to = ' $to'; + var distances = [ + for (var i = 0; i < from.length; i++) + [ + for (var j = 0; j < to.length; j++) + if (i == 0) j else if (j == 0) i else 0, + ], + ]; + + for (var i = 1; i < from.length; i++) { + for (var j = 1; j < to.length; j++) { + // Removals from `from`. + var min = distances[i - 1][j] + 1; + // Additions to `from`. + min = math.min(min, distances[i][j - 1] + 1); + // Substitutions (and equality). + min = math.min( + min, + distances[i - 1][j - 1] + + // Cost is zero if substitution was not actually necessary. + (from[i] == to[j] ? 0 : 1)); + // Allows for basic swaps, but no additional edits of swapped regions. + if (i > 1 && j > 1 && from[i] == to[j - 1] && from[i - 1] == to[j]) { + min = math.min(min, distances[i - 2][j - 2] + 1); + } + distances[i][j] = min; + } + } + + return distances.last.last; +} diff --git a/pkgs/args/lib/src/allow_anything_parser.dart b/pkgs/args/lib/src/allow_anything_parser.dart new file mode 100644 index 00000000..46dfc940 --- /dev/null +++ b/pkgs/args/lib/src/allow_anything_parser.dart @@ -0,0 +1,106 @@ +// 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:collection'; + +import 'arg_parser.dart'; +import 'arg_results.dart'; +import 'option.dart'; +import 'parser.dart'; + +/// An ArgParser that treats *all input* as non-option arguments. +class AllowAnythingParser implements ArgParser { + @override + Map get options => const {}; + @override + Map get commands => const {}; + @override + bool get allowTrailingOptions => false; + @override + bool get allowsAnything => true; + @override + int? get usageLineLength => null; + + @override + ArgParser addCommand(String name, [ArgParser? parser]) { + throw UnsupportedError( + "ArgParser.allowAnything().addCommands() isn't supported."); + } + + @override + void addFlag(String name, + {String? abbr, + String? help, + bool? defaultsTo = false, + bool negatable = true, + void Function(bool)? callback, + bool hide = false, + List aliases = const []}) { + throw UnsupportedError( + "ArgParser.allowAnything().addFlag() isn't supported."); + } + + @override + void addOption(String name, + {String? abbr, + String? help, + String? valueHelp, + Iterable? allowed, + Map? allowedHelp, + String? defaultsTo, + void Function(String?)? callback, + bool allowMultiple = false, + bool? splitCommas, + bool mandatory = false, + bool hide = false, + List aliases = const []}) { + throw UnsupportedError( + "ArgParser.allowAnything().addOption() isn't supported."); + } + + @override + void addMultiOption(String name, + {String? abbr, + String? help, + String? valueHelp, + Iterable? allowed, + Map? allowedHelp, + Iterable? defaultsTo, + void Function(List)? callback, + bool splitCommas = true, + bool hide = false, + List aliases = const []}) { + throw UnsupportedError( + "ArgParser.allowAnything().addMultiOption() isn't supported."); + } + + @override + void addSeparator(String text) { + throw UnsupportedError( + "ArgParser.allowAnything().addSeparator() isn't supported."); + } + + @override + ArgResults parse(Iterable args) => + Parser(null, this, Queue.of(args)).parse(); + + @override + String get usage => ''; + + @override + dynamic defaultFor(String option) { + throw ArgumentError('No option named $option'); + } + + @override + dynamic getDefault(String option) { + throw ArgumentError('No option named $option'); + } + + @override + Option? findByAbbreviation(String abbr) => null; + + @override + Option? findByNameOrAlias(String name) => null; +} diff --git a/pkgs/args/lib/src/arg_parser.dart b/pkgs/args/lib/src/arg_parser.dart new file mode 100644 index 00000000..c47c0030 --- /dev/null +++ b/pkgs/args/lib/src/arg_parser.dart @@ -0,0 +1,367 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:collection'; + +import 'allow_anything_parser.dart'; +import 'arg_results.dart'; +import 'option.dart'; +import 'parser.dart'; +import 'usage.dart'; + +/// A class for taking a list of raw command line arguments and parsing out +/// options and flags from them. +class ArgParser { + final Map _options; + final Map _commands; + + /// A map of aliases to the option names they alias. + final Map _aliases; + + /// The options that have been defined for this parser. + final Map options; + + /// The commands that have been defined for this parser. + final Map commands; + + /// A list of the [Option]s in [options] intermingled with [String] + /// separators. + final _optionsAndSeparators = []; + + /// Whether or not this parser parses options that appear after non-option + /// arguments. + final bool allowTrailingOptions; + + /// An optional maximum line length for [usage] messages. + /// + /// If specified, then help messages in the usage are wrapped at the given + /// column, after taking into account the width of the options. Will refuse to + /// wrap help text to less than 10 characters of help text per line if there + /// isn't enough space on the line. It preserves embedded newlines, and + /// attempts to wrap at whitespace breaks (although it will split words if + /// there is no whitespace at which to split). + /// + /// If null (the default), help messages are not wrapped. + final int? usageLineLength; + + /// Whether or not this parser treats unrecognized options as non-option + /// arguments. + bool get allowsAnything => false; + + /// Creates a new ArgParser. + /// + /// If [allowTrailingOptions] is `true` (the default), the parser will parse + /// flags and options that appear after positional arguments. If it's `false`, + /// the parser stops parsing as soon as it finds an argument that is neither + /// an option nor a command. + factory ArgParser({bool allowTrailingOptions = true, int? usageLineLength}) => + ArgParser._({}, {}, {}, + allowTrailingOptions: allowTrailingOptions, + usageLineLength: usageLineLength); + + /// Creates a new ArgParser that treats *all input* as non-option arguments. + /// + /// This is intended to allow arguments to be passed through to child + /// processes without needing to be redefined in the parent. + /// + /// Options may not be defined for this parser. + factory ArgParser.allowAnything() = AllowAnythingParser; + + ArgParser._(Map options, Map commands, + this._aliases, + {this.allowTrailingOptions = true, this.usageLineLength}) + : _options = options, + options = UnmodifiableMapView(options), + _commands = commands, + commands = UnmodifiableMapView(commands); + + /// Defines a command. + /// + /// A command is a named argument which may in turn define its own options and + /// subcommands using the given parser. If [parser] is omitted, implicitly + /// creates a new one. Returns the parser for the command. + /// + /// Note that adding commands this way will not impact the [usage] string. To + /// add commands which are included in the usage string see `CommandRunner`. + ArgParser addCommand(String name, [ArgParser? parser]) { + // Make sure the name isn't in use. + if (_commands.containsKey(name)) { + throw ArgumentError('Duplicate command "$name".'); + } + + parser ??= ArgParser(); + _commands[name] = parser; + return parser; + } + + /// Defines a boolean flag. + /// + /// This adds an [Option] with the given properties to [options]. + /// + /// The [abbr] argument is a single-character string that can be used as a + /// shorthand for this flag. For example, `abbr: "a"` will allow the user to + /// pass `-a` to enable the flag. + /// + /// The [help] argument is used by [usage] to describe this flag. + /// + /// The [defaultsTo] argument indicates the value this flag will have if the + /// user doesn't explicitly pass it in. + /// + /// The [negatable] argument indicates whether this flag's value can be set to + /// `false`. For example, if [name] is `flag`, the user can pass `--no-flag` + /// to set its value to `false`. + /// + /// The [callback] argument is invoked with the flag's value when the flag + /// is parsed. Note that this makes argument parsing order-dependent in ways + /// that are often surprising, and its use is discouraged in favor of reading + /// values from the [ArgResults]. + /// + /// If [hide] is `true`, this option won't be included in [usage]. + /// + /// If [aliases] is provided, these are used as aliases for [name]. These + /// aliases will not appear as keys in the [options] map. + /// + /// Throws an [ArgumentError] if: + /// + /// * There is already an option named [name]. + /// * There is already an option using abbreviation [abbr]. + void addFlag(String name, + {String? abbr, + String? help, + bool? defaultsTo = false, + bool negatable = true, + void Function(bool)? callback, + bool hide = false, + List aliases = const []}) { + _addOption( + name, + abbr, + help, + null, + null, + null, + defaultsTo, + callback == null ? null : (bool value) => callback(value), + OptionType.flag, + negatable: negatable, + hide: hide, + aliases: aliases); + } + + /// Defines an option that takes a value. + /// + /// This adds an [Option] with the given properties to [options]. + /// + /// The [abbr] argument is a single-character string that can be used as a + /// shorthand for this option. For example, `abbr: "a"` will allow the user to + /// pass `-a value` or `-avalue`. + /// + /// The [help] argument is used by [usage] to describe this option. + /// + /// The [valueHelp] argument is used by [usage] as a name for the value this + /// option takes. For example, `valueHelp: "FOO"` will include + /// `--option=` rather than just `--option` in the usage string. + /// + /// The [allowed] argument is a list of valid values for this option. If + /// it's non-`null` and the user passes a value that's not included in the + /// list, [parse] will throw a [FormatException]. The allowed values will also + /// be included in [usage]. + /// + /// The [allowedHelp] argument is a map from values in [allowed] to + /// documentation for those values that will be included in [usage]. + /// + /// The [defaultsTo] argument indicates the value this option will have if the + /// user doesn't explicitly pass it in (or `null` by default). + /// + /// The [callback] argument is invoked with the option's value when the option + /// is parsed, or with `null` if the option was not parsed. + /// Note that this makes argument parsing order-dependent in ways that are + /// often surprising, and its use is discouraged in favor of reading values + /// from the [ArgResults]. + /// + /// If [hide] is `true`, this option won't be included in [usage]. + /// + /// If [aliases] is provided, these are used as aliases for [name]. These + /// aliases will not appear as keys in the [options] map. + /// + /// Throws an [ArgumentError] if: + /// + /// * There is already an option with name [name]. + /// * There is already an option using abbreviation [abbr]. + void addOption(String name, + {String? abbr, + String? help, + String? valueHelp, + Iterable? allowed, + Map? allowedHelp, + String? defaultsTo, + void Function(String?)? callback, + bool mandatory = false, + bool hide = false, + List aliases = const []}) { + _addOption(name, abbr, help, valueHelp, allowed, allowedHelp, defaultsTo, + callback, OptionType.single, + mandatory: mandatory, hide: hide, aliases: aliases); + } + + /// Defines an option that takes multiple values. + /// + /// The [abbr] argument is a single-character string that can be used as a + /// shorthand for this option. For example, `abbr: "a"` will allow the user to + /// pass `-a value` or `-avalue`. + /// + /// The [help] argument is used by [usage] to describe this option. + /// + /// The [valueHelp] argument is used by [usage] as a name for the value this + /// argument takes. For example, `valueHelp: "FOO"` will include + /// `--option=` rather than just `--option` in the usage string. + /// + /// The [allowed] argument is a list of valid values for this argument. If + /// it's non-`null` and the user passes a value that's not included in the + /// list, [parse] will throw a [FormatException]. The allowed values will also + /// be included in [usage]. + /// + /// The [allowedHelp] argument is a map from values in [allowed] to + /// documentation for those values that will be included in [usage]. + /// + /// The [defaultsTo] argument indicates the values this option will have if + /// the user doesn't explicitly pass it in (or `[]` by default). + /// + /// The [callback] argument is invoked with the option's value when the option + /// is parsed. Note that this makes argument parsing order-dependent in ways + /// that are often surprising, and its use is discouraged in favor of reading + /// values from the [ArgResults]. + /// + /// If [splitCommas] is `true` (the default), multiple options may be passed + /// by writing `--option a,b` in addition to `--option a --option b`. + /// + /// If [hide] is `true`, this option won't be included in [usage]. + /// + /// If [aliases] is provided, these are used as aliases for [name]. These + /// aliases will not appear as keys in the [options] map. + /// + /// Throws an [ArgumentError] if: + /// + /// * There is already an option with name [name]. + /// * There is already an option using abbreviation [abbr]. + void addMultiOption(String name, + {String? abbr, + String? help, + String? valueHelp, + Iterable? allowed, + Map? allowedHelp, + Iterable? defaultsTo, + void Function(List)? callback, + bool splitCommas = true, + bool hide = false, + List aliases = const []}) { + _addOption( + name, + abbr, + help, + valueHelp, + allowed, + allowedHelp, + defaultsTo?.toList() ?? [], + callback == null ? null : (List value) => callback(value), + OptionType.multiple, + splitCommas: splitCommas, + hide: hide, + aliases: aliases); + } + + void _addOption( + String name, + String? abbr, + String? help, + String? valueHelp, + Iterable? allowed, + Map? allowedHelp, + Object? defaultsTo, + Function? callback, + OptionType type, + {bool negatable = false, + bool? splitCommas, + bool mandatory = false, + bool hide = false, + List aliases = const []}) { + var allNames = [name, ...aliases]; + if (allNames.any((name) => findByNameOrAlias(name) != null)) { + throw ArgumentError('Duplicate option or alias "$name".'); + } + + // Make sure the abbreviation isn't too long or in use. + if (abbr != null) { + var existing = findByAbbreviation(abbr); + if (existing != null) { + throw ArgumentError( + 'Abbreviation "$abbr" is already used by "${existing.name}".'); + } + } + + // Make sure the option is not mandatory with a default value. + if (mandatory && defaultsTo != null) { + throw ArgumentError( + 'The option $name cannot be mandatory and have a default value.'); + } + + var option = newOption(name, abbr, help, valueHelp, allowed, allowedHelp, + defaultsTo, callback, type, + negatable: negatable, + splitCommas: splitCommas, + mandatory: mandatory, + hide: hide, + aliases: aliases); + _options[name] = option; + _optionsAndSeparators.add(option); + for (var alias in aliases) { + _aliases[alias] = name; + } + } + + /// Adds a separator line to the usage. + /// + /// In the usage text for the parser, this will appear between any options + /// added before this call and ones added after it. + void addSeparator(String text) { + _optionsAndSeparators.add(text); + } + + /// Parses [args], a list of command-line arguments, matches them against the + /// flags and options defined by this parser, and returns the result. + ArgResults parse(Iterable args) => + Parser(null, this, Queue.of(args)).parse(); + + /// Generates a string displaying usage information for the defined options. + /// + /// This is basically the help text shown on the command line. + String get usage { + return generateUsage(_optionsAndSeparators, lineLength: usageLineLength); + } + + /// Returns the default value for [option]. + dynamic defaultFor(String option) { + var value = findByNameOrAlias(option); + if (value == null) { + throw ArgumentError('No option named $option'); + } + return value.defaultsTo; + } + + @Deprecated('Use defaultFor instead.') + dynamic getDefault(String option) => defaultFor(option); + + /// Finds the option whose abbreviation is [abbr], or `null` if no option has + /// that abbreviation. + Option? findByAbbreviation(String abbr) { + for (var option in options.values) { + if (option.abbr == abbr) return option; + } + return null; + } + + /// Finds the option whose name or alias matches [name], or `null` if no + /// option has that name or alias. + Option? findByNameOrAlias(String name) => options[_aliases[name] ?? name]; +} diff --git a/pkgs/args/lib/src/arg_parser_exception.dart b/pkgs/args/lib/src/arg_parser_exception.dart new file mode 100644 index 00000000..fbee82b3 --- /dev/null +++ b/pkgs/args/lib/src/arg_parser_exception.dart @@ -0,0 +1,22 @@ +// Copyright (c) 2016, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// An exception thrown by `ArgParser`. +class ArgParserException extends FormatException { + /// The command(s) that were parsed before discovering the error. + /// + /// This will be empty if the error was on the root parser. + final List commands; + + /// The name of the argument that was being parsed when the error was + /// discovered. + final String? argumentName; + + ArgParserException(super.message, + [Iterable? commands, + this.argumentName, + super.source, + super.offset]) + : commands = commands == null ? const [] : List.unmodifiable(commands); +} diff --git a/pkgs/args/lib/src/arg_results.dart b/pkgs/args/lib/src/arg_results.dart new file mode 100644 index 00000000..72c4410d --- /dev/null +++ b/pkgs/args/lib/src/arg_results.dart @@ -0,0 +1,151 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:collection'; + +import 'arg_parser.dart'; + +/// Creates a new [ArgResults]. +/// +/// Since [ArgResults] doesn't have a public constructor, this lets [ArgParser] +/// get to it. This function isn't exported to the public API of the package. +ArgResults newArgResults( + ArgParser parser, + Map parsed, + String? name, + ArgResults? command, + List rest, + List arguments) { + return ArgResults._(parser, parsed, name, command, rest, arguments); +} + +/// The results of parsing a series of command line arguments using +/// [ArgParser.parse]. +/// +/// Includes the parsed options and any remaining unparsed command line +/// arguments. +class ArgResults { + /// The [ArgParser] whose options were parsed for these results. + final ArgParser _parser; + + /// The option values that were parsed from arguments. + final Map _parsed; + + /// The name of the command for which these options are parsed, or `null` if + /// these are the top-level results. + final String? name; + + /// The command that was selected, or `null` if none was. + /// + /// This will contain the options that were selected for that command. + final ArgResults? command; + + /// The remaining command-line arguments that were not parsed as options or + /// flags. + /// + /// If `--` was used to separate the options from the remaining arguments, + /// it will not be included in this list unless parsing stopped before the + /// `--` was reached. + final List rest; + + /// The original arguments that were parsed. + final List arguments; + + ArgResults._(this._parser, this._parsed, this.name, this.command, + List rest, List arguments) + : rest = UnmodifiableListView(rest), + arguments = UnmodifiableListView(arguments); + + /// Returns the parsed or default command-line option named [name]. + /// + /// [name] must be a valid option name in the parser. + /// + /// > [!Note] + /// > Callers should prefer using the more strongly typed methods - [flag] for + /// > flags, [option] for options, and [multiOption] for multi-options. + dynamic operator [](String name) { + if (!_parser.options.containsKey(name)) { + throw ArgumentError('Could not find an option named "--$name".'); + } + + final option = _parser.options[name]!; + if (option.mandatory && !_parsed.containsKey(name)) { + throw ArgumentError('Option $name is mandatory.'); + } + + return option.valueOrDefault(_parsed[name]); + } + + /// Returns the parsed or default command-line flag named [name]. + /// + /// [name] must be a valid flag name in the parser. + bool flag(String name) { + var option = _parser.options[name]; + if (option == null) { + throw ArgumentError('Could not find an option named "--$name".'); + } + if (!option.isFlag) { + throw ArgumentError('"$name" is not a flag.'); + } + return option.valueOrDefault(_parsed[name]) as bool; + } + + /// Returns the parsed or default command-line option named [name]. + /// + /// [name] must be a valid option name in the parser. + String? option(String name) { + var option = _parser.options[name]; + if (option == null) { + throw ArgumentError('Could not find an option named "--$name".'); + } + if (!option.isSingle) { + throw ArgumentError('"$name" is a multi-option.'); + } + return option.valueOrDefault(_parsed[name]) as String?; + } + + /// Returns the list of parsed (or default) command-line options for [name]. + /// + /// [name] must be a valid option name in the parser. + List multiOption(String name) { + var option = _parser.options[name]; + if (option == null) { + throw ArgumentError('Could not find an option named "--$name".'); + } + if (!option.isMultiple) { + throw ArgumentError('"$name" is not a multi-option.'); + } + return option.valueOrDefault(_parsed[name]) as List; + } + + /// The names of the available options. + /// + /// Includes the options whose values were parsed or that have defaults. + /// Options that weren't present and have no default are omitted. + Iterable get options { + var result = _parsed.keys.toSet(); + + // Include the options that have defaults. + _parser.options.forEach((name, option) { + if (option.defaultsTo != null) result.add(name); + }); + + return result; + } + + /// Returns `true` if the option with [name] was parsed from an actual + /// argument. + /// + /// Returns `false` if it wasn't provided and the default value or no default + /// value would be used instead. + /// + /// [name] must be a valid option name in the parser. + bool wasParsed(String name) { + if (!_parser.options.containsKey(name)) { + throw ArgumentError('Could not find an option named "--$name".'); + } + + return _parsed.containsKey(name); + } +} diff --git a/pkgs/args/lib/src/help_command.dart b/pkgs/args/lib/src/help_command.dart new file mode 100644 index 00000000..f04d014d --- /dev/null +++ b/pkgs/args/lib/src/help_command.dart @@ -0,0 +1,60 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import '../command_runner.dart'; + +/// The built-in help command that's added to every [CommandRunner]. +/// +/// This command displays help information for the various subcommands. +class HelpCommand extends Command { + @override + final name = 'help'; + + @override + String get description => + 'Display help information for ${runner!.executableName}.'; + + @override + String get invocation => '${runner!.executableName} help [command]'; + + @override + bool get hidden => true; + + @override + Null run() { + // Show the default help if no command was specified. + if (argResults!.rest.isEmpty) { + runner!.printUsage(); + return; + } + + // Walk the command tree to show help for the selected command or + // subcommand. + var commands = runner!.commands; + Command? command; + var commandString = runner!.executableName; + + for (var name in argResults!.rest) { + if (commands.isEmpty) { + command!.usageException( + 'Command "$commandString" does not expect a subcommand.'); + } + + if (commands[name] == null) { + if (command == null) { + runner!.usageException('Could not find a command named "$name".'); + } + + command.usageException( + 'Could not find a subcommand named "$name" for "$commandString".'); + } + + command = commands[name]; + commands = command!.subcommands; + commandString += ' $name'; + } + + command!.printUsage(); + } +} diff --git a/pkgs/args/lib/src/option.dart b/pkgs/args/lib/src/option.dart new file mode 100644 index 00000000..50a8628a --- /dev/null +++ b/pkgs/args/lib/src/option.dart @@ -0,0 +1,192 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// Creates a new [Option]. +/// +/// Since [Option] doesn't have a public constructor, this lets `ArgParser` +/// get to it. This function isn't exported to the public API of the package. +Option newOption( + String name, + String? abbr, + String? help, + String? valueHelp, + Iterable? allowed, + Map? allowedHelp, + Object? defaultsTo, + Function? callback, + OptionType type, + {bool? negatable, + bool? splitCommas, + bool mandatory = false, + bool hide = false, + List aliases = const []}) { + return Option._(name, abbr, help, valueHelp, allowed, allowedHelp, defaultsTo, + callback, type, + negatable: negatable, + splitCommas: splitCommas, + mandatory: mandatory, + hide: hide, + aliases: aliases); +} + +/// A command-line option. +/// +/// This represents both boolean flags and options which take a value. +class Option { + /// The name of the option that the user passes as an argument. + final String name; + + /// A single-character string that can be used as a shorthand for this option. + /// + /// For example, `abbr: "a"` will allow the user to pass `-a value` or + /// `-avalue`. + final String? abbr; + + /// A description of this option. + final String? help; + + /// A name for the value this option takes. + final String? valueHelp; + + /// A list of valid values for this option. + final List? allowed; + + /// A map from values in [allowed] to documentation for those values. + final Map? allowedHelp; + + /// The value this option will have if the user doesn't explicitly pass it. + final dynamic defaultsTo; + + /// Whether this flag's value can be set to `false`. + /// + /// For example, if [name] is `flag`, the user can pass `--no-flag` to set its + /// value to `false`. + /// + /// This is `null` unless [type] is [OptionType.flag]. + final bool? negatable; + + /// The callback to invoke with the option's value when the option is parsed. + final Function? callback; + + /// Whether this is a flag, a single value option, or a multi-value option. + final OptionType type; + + /// Whether multiple values may be passed by writing `--option a,b` in + /// addition to `--option a --option b`. + final bool splitCommas; + + /// Whether this option must be provided for correct usage. + final bool mandatory; + + /// Whether this option should be hidden from usage documentation. + final bool hide; + + /// All aliases for [name]. + final List aliases; + + /// Whether the option is boolean-valued flag. + bool get isFlag => type == OptionType.flag; + + /// Whether the option takes a single value. + bool get isSingle => type == OptionType.single; + + /// Whether the option allows multiple values. + bool get isMultiple => type == OptionType.multiple; + + Option._( + this.name, + this.abbr, + this.help, + this.valueHelp, + Iterable? allowed, + Map? allowedHelp, + this.defaultsTo, + this.callback, + this.type, + {this.negatable, + bool? splitCommas, + this.mandatory = false, + this.hide = false, + this.aliases = const []}) + : allowed = allowed == null ? null : List.unmodifiable(allowed), + allowedHelp = + allowedHelp == null ? null : Map.unmodifiable(allowedHelp), + // If the user doesn't specify [splitCommas], it defaults to true for + // multiple options. + splitCommas = splitCommas ?? type == OptionType.multiple { + if (name.isEmpty) { + throw ArgumentError('Name cannot be empty.'); + } else if (name.startsWith('-')) { + throw ArgumentError('Name $name cannot start with "-".'); + } + + // Ensure name does not contain any invalid characters. + if (_invalidChars.hasMatch(name)) { + throw ArgumentError('Name "$name" contains invalid characters.'); + } + + var abbr = this.abbr; + if (abbr != null) { + if (abbr.length != 1) { + throw ArgumentError('Abbreviation must be null or have length 1.'); + } else if (abbr == '-') { + throw ArgumentError('Abbreviation cannot be "-".'); + } + + if (_invalidChars.hasMatch(abbr)) { + throw ArgumentError('Abbreviation is an invalid character.'); + } + } + } + + /// Returns [value] if non-`null`, otherwise returns the default value for + /// this option. + /// + /// For single-valued options, it will be [defaultsTo] if set or `null` + /// otherwise. For multiple-valued options, it will be an empty list or a + /// list containing [defaultsTo] if set. + dynamic valueOrDefault(Object? value) { + if (value != null) return value; + if (isMultiple) return defaultsTo ?? []; + return defaultsTo; + } + + @Deprecated('Use valueOrDefault instead.') + dynamic getOrDefault(Object? value) => valueOrDefault(value); + + static final _invalidChars = RegExp(r'''[ \t\r\n"'\\/]'''); +} + +/// What kinds of values an option accepts. +class OptionType { + /// An option that can only be `true` or `false`. + /// + /// The presence of the option name itself in the argument list means `true`. + static const flag = OptionType._('OptionType.flag'); + + /// An option that takes a single value. + /// + /// Examples: + /// + /// --mode debug + /// -mdebug + /// --mode=debug + /// + /// If the option is passed more than once, the last one wins. + static const single = OptionType._('OptionType.single'); + + /// An option that allows multiple values. + /// + /// Example: + /// + /// --output text --output xml + /// + /// In the parsed `ArgResults`, a multiple-valued option will always return + /// a list, even if one or no values were passed. + static const multiple = OptionType._('OptionType.multiple'); + + final String name; + + const OptionType._(this.name); +} diff --git a/pkgs/args/lib/src/parser.dart b/pkgs/args/lib/src/parser.dart new file mode 100644 index 00000000..660e56de --- /dev/null +++ b/pkgs/args/lib/src/parser.dart @@ -0,0 +1,381 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:collection'; + +import 'arg_parser.dart'; +import 'arg_parser_exception.dart'; +import 'arg_results.dart'; +import 'option.dart'; + +/// The actual argument parsing class. +/// +/// Unlike [ArgParser] which is really more an "arg grammar", this is the class +/// that does the parsing and holds the mutable state required during a parse. +class Parser { + /// If parser is parsing a command's options, this will be the name of the + /// command. For top-level results, this returns `null`. + final String? _commandName; + + /// The parser for the supercommand of this command parser, or `null` if this + /// is the top-level parser. + final Parser? _parent; + + /// The grammar being parsed. + final ArgParser _grammar; + + /// The arguments being parsed. + final Queue _args; + + /// The remaining non-option, non-command arguments. + final List _rest; + + /// The accumulated parsed options. + final Map _results = {}; + + Parser(this._commandName, this._grammar, this._args, + [this._parent, List? rest]) + : _rest = [...?rest]; + + /// The current argument being parsed. + String get _current => _args.first; + + /// Parses the arguments. This can only be called once. + ArgResults parse() { + var arguments = _args.toList(); + if (_grammar.allowsAnything) { + return newArgResults( + _grammar, const {}, _commandName, null, arguments, arguments); + } + + ArgResults? commandResults; + + // Parse the args. + while (_args.isNotEmpty) { + if (_current == '--') { + // Reached the argument terminator, so stop here. + _args.removeFirst(); + break; + } + + // Try to parse the current argument as a command. This happens before + // options so that commands can have option-like names. + var command = _grammar.commands[_current]; + if (command != null) { + _validate(_rest.isEmpty, 'Cannot specify arguments before a command.', + _current); + var commandName = _args.removeFirst(); + var commandParser = Parser(commandName, command, _args, this, _rest); + + try { + commandResults = commandParser.parse(); + } on ArgParserException catch (error) { + throw ArgParserException( + error.message, + [commandName, ...error.commands], + error.argumentName, + error.source, + error.offset); + } + + // All remaining arguments were passed to command so clear them here. + _rest.clear(); + break; + } + + // Try to parse the current argument as an option. Note that the order + // here matters. + if (_parseSoloOption()) continue; + if (_parseAbbreviation(this)) continue; + if (_parseLongOption()) continue; + + // This argument is neither option nor command, so stop parsing unless + // the [allowTrailingOptions] option is set. + if (!_grammar.allowTrailingOptions) break; + _rest.add(_args.removeFirst()); + } + + // Check if mandatory and invoke existing callbacks. + _grammar.options.forEach((name, option) { + var parsedOption = _results[name]; + + var callback = option.callback; + if (callback == null) return; + + // Check if an option is mandatory and was passed; if not, throw an + // exception. + if (option.mandatory && parsedOption == null) { + throw ArgParserException('Option $name is mandatory.', null, name); + } + + // ignore: avoid_dynamic_calls + callback(option.valueOrDefault(parsedOption)); + }); + + // Add in the leftover arguments we didn't parse to the innermost command. + _rest.addAll(_args); + _args.clear(); + return newArgResults( + _grammar, _results, _commandName, commandResults, _rest, arguments); + } + + /// Pulls the value for [option] from the second argument in [_args]. + /// + /// Validates that there is a valid value there. + void _readNextArgAsValue(Option option, String arg) { + // Take the option argument from the next command line arg. + _validate(_args.isNotEmpty, 'Missing argument for "$arg".', arg); + + _setOption(_results, option, _current, arg); + _args.removeFirst(); + } + + /// Tries to parse the current argument as a "solo" option, which is a single + /// hyphen followed by a single letter. + /// + /// We treat this differently than collapsed abbreviations (like "-abc") to + /// handle the possible value that may follow it. + bool _parseSoloOption() { + // Hand coded regexp: r'^-([a-zA-Z0-9])$' + // Length must be two, hyphen followed by any letter/digit. + if (_current.length != 2) return false; + if (!_current.startsWith('-')) return false; + var opt = _current[1]; + if (!_isLetterOrDigit(opt.codeUnitAt(0))) return false; + return _handleSoloOption(opt); + } + + bool _handleSoloOption(String opt) { + var option = _grammar.findByAbbreviation(opt); + if (option == null) { + // Walk up to the parent command if possible. + _validate(_parent != null, 'Could not find an option or flag "-$opt".', + '-$opt'); + return _parent!._handleSoloOption(opt); + } + + _args.removeFirst(); + + if (option.isFlag) { + _setFlag(_results, option, true); + } else { + _readNextArgAsValue(option, '-$opt'); + } + + return true; + } + + /// Tries to parse the current argument as a series of collapsed abbreviations + /// (like "-abc") or a single abbreviation with the value directly attached + /// to it (like "-mrelease"). + bool _parseAbbreviation(Parser innermostCommand) { + // Hand coded regexp: r'^-([a-zA-Z0-9]+)(.*)$' + // Hyphen then at least one letter/digit then zero or more + // anything-but-newlines. + if (_current.length < 2) return false; + if (!_current.startsWith('-')) return false; + + // Find where we go from letters/digits to rest. + var index = 1; + while (index < _current.length && + _isLetterOrDigit(_current.codeUnitAt(index))) { + ++index; + } + // Must be at least one letter/digit. + if (index == 1) return false; + + // If the first character is the abbreviation for a non-flag option, then + // the rest is the value. + var lettersAndDigits = _current.substring(1, index); + var rest = _current.substring(index); + if (rest.contains('\n') || rest.contains('\r')) return false; + return _handleAbbreviation(lettersAndDigits, rest, innermostCommand); + } + + bool _handleAbbreviation( + String lettersAndDigits, String rest, Parser innermostCommand) { + var c = lettersAndDigits.substring(0, 1); + var first = _grammar.findByAbbreviation(c); + if (first == null) { + // Walk up to the parent command if possible. + _validate(_parent != null, + 'Could not find an option with short name "-$c".', '-$c'); + return _parent! + ._handleAbbreviation(lettersAndDigits, rest, innermostCommand); + } else if (!first.isFlag) { + // The first character is a non-flag option, so the rest must be the + // value. + var value = '${lettersAndDigits.substring(1)}$rest'; + _setOption(_results, first, value, '-$c'); + } else { + // If we got some non-flag characters, then it must be a value, but + // if we got here, it's a flag, which is wrong. + _validate( + rest == '', + 'Option "-$c" is a flag and cannot handle value ' + '"${lettersAndDigits.substring(1)}$rest".', + '-$c'); + + // Not an option, so all characters should be flags. + // We use "innermostCommand" here so that if a parent command parses the + // *first* letter, subcommands can still be found to parse the other + // letters. + for (var i = 0; i < lettersAndDigits.length; i++) { + var c = lettersAndDigits.substring(i, i + 1); + innermostCommand._parseShortFlag(c); + } + } + + _args.removeFirst(); + return true; + } + + void _parseShortFlag(String c) { + var option = _grammar.findByAbbreviation(c); + if (option == null) { + // Walk up to the parent command if possible. + _validate(_parent != null, + 'Could not find an option with short name "-$c".', '-$c'); + _parent!._parseShortFlag(c); + return; + } + + // In a list of short options, only the first can be a non-flag. If + // we get here we've checked that already. + _validate(option.isFlag, + 'Option "-$c" must be a flag to be in a collapsed "-".', '-$c'); + + _setFlag(_results, option, true); + } + + /// Tries to parse the current argument as a long-form named option, which + /// may include a value like "--mode=release" or "--mode release". + bool _parseLongOption() { + // Hand coded regexp: r'^--([a-zA-Z\-_0-9]+)(=(.*))?$' + // Two hyphens then at least one letter/digit/hyphen, optionally an equal + // sign followed by zero or more anything-but-newlines. + + if (!_current.startsWith('--')) return false; + + var index = _current.indexOf('='); + var name = + index == -1 ? _current.substring(2) : _current.substring(2, index); + for (var i = 0; i != name.length; ++i) { + if (!_isLetterDigitHyphenOrUnderscore(name.codeUnitAt(i))) return false; + } + var value = index == -1 ? null : _current.substring(index + 1); + if (value != null && (value.contains('\n') || value.contains('\r'))) { + return false; + } + return _handleLongOption(name, value); + } + + bool _handleLongOption(String name, String? value) { + var option = _grammar.findByNameOrAlias(name); + if (option != null) { + _args.removeFirst(); + if (option.isFlag) { + _validate(value == null, + 'Flag option "--$name" should not be given a value.', '--$name'); + + _setFlag(_results, option, true); + } else if (value != null) { + // We have a value like --foo=bar. + _setOption(_results, option, value, '--$name'); + } else { + // Option like --foo, so look for the value as the next arg. + _readNextArgAsValue(option, '--$name'); + } + } else if (name.startsWith('no-')) { + // See if it's a negated flag. + var positiveName = name.substring('no-'.length); + option = _grammar.findByNameOrAlias(positiveName); + if (option == null) { + // Walk up to the parent command if possible. + _validate(_parent != null, 'Could not find an option named "--$name".', + '--$name'); + return _parent!._handleLongOption(name, value); + } + + _args.removeFirst(); + _validate( + option.isFlag, 'Cannot negate non-flag option "--$name".', '--$name'); + _validate( + option.negatable!, 'Cannot negate option "--$name".', '--$name'); + + _setFlag(_results, option, false); + } else { + // Walk up to the parent command if possible. + _validate(_parent != null, 'Could not find an option named "--$name".', + '--$name'); + return _parent!._handleLongOption(name, value); + } + + return true; + } + + /// Called during parsing to validate the arguments. + /// + /// Throws an [ArgParserException] if [condition] is `false`. + void _validate(bool condition, String message, + [String? args, List? source, int? offset]) { + if (!condition) { + throw ArgParserException(message, null, args, source, offset); + } + } + + /// Validates and stores [value] as the value for [option], which must not be + /// a flag. + void _setOption(Map results, Option option, String value, String arg) { + assert(!option.isFlag); + + if (!option.isMultiple) { + _validateAllowed(option, value, arg); + results[option.name] = value; + return; + } + + var list = results.putIfAbsent(option.name, () => []) as List; + + if (option.splitCommas) { + for (var element in value.split(',')) { + _validateAllowed(option, element, arg); + list.add(element); + } + } else { + _validateAllowed(option, value, arg); + list.add(value); + } + } + + /// Validates and stores [value] as the value for [option], which must be a + /// flag. + void _setFlag(Map results, Option option, bool value) { + assert(option.isFlag); + results[option.name] = value; + } + + /// Validates that [value] is allowed as a value of [option]. + void _validateAllowed(Option option, String value, String arg) { + if (option.allowed == null) return; + + _validate(option.allowed!.contains(value), + '"$value" is not an allowed value for option "$arg".', arg); + } +} + +bool _isLetterOrDigit(int codeUnit) => + // Uppercase letters. + (codeUnit >= 65 && codeUnit <= 90) || + // Lowercase letters. + (codeUnit >= 97 && codeUnit <= 122) || + // Digits. + (codeUnit >= 48 && codeUnit <= 57); + +bool _isLetterDigitHyphenOrUnderscore(int codeUnit) => + _isLetterOrDigit(codeUnit) || + // Hyphen. + codeUnit == 45 || + // Underscore. + codeUnit == 95; diff --git a/pkgs/args/lib/src/usage.dart b/pkgs/args/lib/src/usage.dart new file mode 100644 index 00000000..1ef96273 --- /dev/null +++ b/pkgs/args/lib/src/usage.dart @@ -0,0 +1,255 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:math' as math; + +import '../args.dart'; +import 'utils.dart'; + +/// Generates a string of usage (i.e. help) text for a list of options. +/// +/// Internally, it works like a tabular printer. The output is divided into +/// three horizontal columns, like so: +/// +/// -h, --help Prints the usage information +/// | | | | +/// +/// It builds the usage text up one column at a time and handles padding with +/// spaces and wrapping to the next line to keep the cells correctly lined up. +/// +/// [lineLength] specifies the horizontal character position at which the help +/// text is wrapped. Help that extends past this column will be wrapped at the +/// nearest whitespace (or truncated if there is no available whitespace). If +/// `null` there will not be any wrapping. +String generateUsage(List optionsAndSeparators, {int? lineLength}) => + _Usage(optionsAndSeparators, lineLength).generate(); + +class _Usage { + /// Abbreviation, long name, help. + static const _columnCount = 3; + + /// A list of the [Option]s intermingled with [String] separators. + final List _optionsAndSeparators; + + /// The working buffer for the generated usage text. + final _buffer = StringBuffer(); + + /// The column that the "cursor" is currently on. + /// + /// If the next call to [write()] is not for this column, it will correctly + /// handle advancing to the next column (and possibly the next row). + int _currentColumn = 0; + + /// The width in characters of each column. + late final _columnWidths = _calculateColumnWidths(); + + /// How many newlines need to be rendered before the next bit of text can be + /// written. + /// + /// We do this lazily so that the last bit of usage doesn't have dangling + /// newlines. We only write newlines right *before* we write some real + /// content. + int _newlinesNeeded = 0; + + /// The horizontal character position at which help text is wrapped. + /// + /// Help that extends past this column will be wrapped at the nearest + /// whitespace (or truncated if there is no available whitespace). + final int? lineLength; + + _Usage(this._optionsAndSeparators, this.lineLength); + + /// Generates a string displaying usage information for the defined options. + /// This is basically the help text shown on the command line. + String generate() { + for (var optionOrSeparator in _optionsAndSeparators) { + if (optionOrSeparator is String) { + _writeSeparator(optionOrSeparator); + continue; + } + var option = optionOrSeparator as Option; + if (option.hide) continue; + _writeOption(option); + } + + return _buffer.toString(); + } + + void _writeSeparator(String separator) { + // Ensure that there's always a blank line before a separator. + if (_buffer.isNotEmpty) _buffer.write('\n\n'); + _buffer.write(separator); + _newlinesNeeded = 1; + } + + void _writeOption(Option option) { + _write(0, _abbreviation(option)); + _write(1, '${_longOption(option)}${_mandatoryOption(option)}'); + + if (option.help != null) _write(2, option.help!); + + if (option.allowedHelp != null) { + var allowedNames = option.allowedHelp!.keys.toList(); + allowedNames.sort(); + _newline(); + for (var name in allowedNames) { + _write(1, _allowedTitle(option, name)); + _write(2, option.allowedHelp![name]!); + } + _newline(); + } else if (option.allowed != null) { + _write(2, _buildAllowedList(option)); + } else if (option.isFlag) { + if (option.defaultsTo == true) { + _write(2, '(defaults to on)'); + } + } else if (option.isMultiple) { + if (option.defaultsTo != null && + (option.defaultsTo as Iterable).isNotEmpty) { + var defaults = + (option.defaultsTo as List).map((value) => '"$value"').join(', '); + _write(2, '(defaults to $defaults)'); + } + } else if (option.defaultsTo != null) { + _write(2, '(defaults to "${option.defaultsTo}")'); + } + } + + String _abbreviation(Option option) => + option.abbr == null ? '' : '-${option.abbr}, '; + + String _longOption(Option option) { + String result; + if (option.negatable!) { + result = '--[no-]${option.name}'; + } else { + result = '--${option.name}'; + } + + if (option.valueHelp != null) result += '=<${option.valueHelp}>'; + + return result; + } + + String _mandatoryOption(Option option) { + return option.mandatory ? ' (mandatory)' : ''; + } + + String _allowedTitle(Option option, String allowed) { + var isDefault = option.defaultsTo is List + ? (option.defaultsTo as List).contains(allowed) + : option.defaultsTo == allowed; + return ' [$allowed]${isDefault ? ' (default)' : ''}'; + } + + List _calculateColumnWidths() { + var abbr = 0; + var title = 0; + for (var option in _optionsAndSeparators) { + if (option is! Option) continue; + if (option.hide) continue; + + // Make room in the first column if there are abbreviations. + abbr = math.max(abbr, _abbreviation(option).length); + + // Make room for the option. + title = math.max( + title, _longOption(option).length + _mandatoryOption(option).length); + + // Make room for the allowed help. + if (option.allowedHelp != null) { + for (var allowed in option.allowedHelp!.keys) { + title = math.max(title, _allowedTitle(option, allowed).length); + } + } + } + + // Leave a gutter between the columns. + title += 4; + return [abbr, title]; + } + + void _newline() { + _newlinesNeeded++; + _currentColumn = 0; + } + + void _write(int column, String text) { + var lines = text.split('\n'); + // If we are writing the last column, word wrap it to fit. + if (column == _columnWidths.length && lineLength != null) { + var start = + _columnWidths.take(column).reduce((start, width) => start + width); + lines = [ + for (var line in lines) + ...wrapTextAsLines(line, start: start, length: lineLength), + ]; + } + + // Strip leading and trailing empty lines. + while (lines.isNotEmpty && lines.first.trim() == '') { + lines.removeAt(0); + } + while (lines.isNotEmpty && lines.last.trim() == '') { + lines.removeLast(); + } + + for (var line in lines) { + _writeLine(column, line); + } + } + + void _writeLine(int column, String text) { + // Write any pending newlines. + while (_newlinesNeeded > 0) { + _buffer.write('\n'); + _newlinesNeeded--; + } + + // Advance until we are at the right column (which may mean wrapping around + // to the next line. + while (_currentColumn != column) { + if (_currentColumn < _columnCount - 1) { + _buffer.write(' ' * _columnWidths[_currentColumn]); + } else { + _buffer.write('\n'); + } + _currentColumn = (_currentColumn + 1) % _columnCount; + } + + if (column < _columnWidths.length) { + // Fixed-size column, so pad it. + _buffer.write(text.padRight(_columnWidths[column])); + } else { + // The last column, so just write it. + _buffer.write(text); + } + + // Advance to the next column. + _currentColumn = (_currentColumn + 1) % _columnCount; + + // If we reached the last column, we need to wrap to the next line. + if (column == _columnCount - 1) _newlinesNeeded++; + } + + String _buildAllowedList(Option option) { + var isDefault = option.defaultsTo is List + ? (option.defaultsTo as List).contains + : (String value) => value == option.defaultsTo; + + var allowedBuffer = StringBuffer(); + allowedBuffer.write('['); + var first = true; + for (var allowed in option.allowed!) { + if (!first) allowedBuffer.write(', '); + allowedBuffer.write(allowed); + if (isDefault(allowed)) { + allowedBuffer.write(' (default)'); + } + first = false; + } + allowedBuffer.write(']'); + return allowedBuffer.toString(); + } +} diff --git a/pkgs/args/lib/src/usage_exception.dart b/pkgs/args/lib/src/usage_exception.dart new file mode 100644 index 00000000..fc8910ef --- /dev/null +++ b/pkgs/args/lib/src/usage_exception.dart @@ -0,0 +1,13 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +class UsageException implements Exception { + final String message; + final String usage; + + UsageException(this.message, this.usage); + + @override + String toString() => '$message\n\n$usage'; +} diff --git a/pkgs/args/lib/src/utils.dart b/pkgs/args/lib/src/utils.dart new file mode 100644 index 00000000..ae5e0936 --- /dev/null +++ b/pkgs/args/lib/src/utils.dart @@ -0,0 +1,143 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. +import 'dart:math' as math; + +/// Pads [source] to [length] by adding spaces at the end. +String padRight(String source, int length) => + source + ' ' * (length - source.length); + +/// Wraps a block of text into lines no longer than [length]. +/// +/// Tries to split at whitespace, but if that's not good enough to keep it +/// under the limit, then it splits in the middle of a word. +/// +/// Preserves indentation (leading whitespace) for each line (delimited by '\n') +/// in the input, and indents wrapped lines the same amount. +/// +/// If [hangingIndent] is supplied, then that many spaces are added to each +/// line, except for the first line. This is useful for flowing text with a +/// heading prefix (e.g. "Usage: "): +/// +/// ```dart +/// var prefix = "Usage: "; +/// print( +/// prefix + wrapText(invocation, hangingIndent: prefix.length, length: 40), +/// ); +/// ``` +/// +/// yields: +/// ``` +/// Usage: app main_command +/// [arguments] +/// ``` +/// +/// If [length] is not specified, then no wrapping occurs, and the original +/// [text] is returned unchanged. +String wrapText(String text, {int? length, int? hangingIndent}) { + if (length == null) return text; + hangingIndent ??= 0; + var splitText = text.split('\n'); + var result = []; + for (var line in splitText) { + var trimmedText = line.trimLeft(); + final leadingWhitespace = + line.substring(0, line.length - trimmedText.length); + List notIndented; + if (hangingIndent != 0) { + // When we have a hanging indent, we want to wrap the first line at one + // width, and the rest at another (offset by hangingIndent), so we wrap + // them twice and recombine. + var firstLineWrap = wrapTextAsLines(trimmedText, + length: length - leadingWhitespace.length); + notIndented = [firstLineWrap.removeAt(0)]; + trimmedText = trimmedText.substring(notIndented[0].length).trimLeft(); + if (firstLineWrap.isNotEmpty) { + notIndented.addAll(wrapTextAsLines(trimmedText, + length: length - leadingWhitespace.length - hangingIndent)); + } + } else { + notIndented = wrapTextAsLines(trimmedText, + length: length - leadingWhitespace.length); + } + String? hangingIndentString; + result.addAll(notIndented.map((String line) { + // Don't return any lines with just whitespace on them. + if (line.isEmpty) return ''; + var result = '${hangingIndentString ?? ''}$leadingWhitespace$line'; + hangingIndentString ??= ' ' * hangingIndent!; + return result; + })); + } + return result.join('\n'); +} + +/// Wraps a block of text into lines no longer than [length], +/// starting at the [start] column, and returns the result as a list of strings. +/// +/// Tries to split at whitespace, but if that's not good enough to keep it +/// under the limit, then splits in the middle of a word. Preserves embedded +/// newlines, but not indentation (it trims whitespace from each line). +/// +/// If [length] is not specified, then no wrapping occurs, and the original +/// [text] is returned after splitting it on newlines. Whitespace is not trimmed +/// in this case. +List wrapTextAsLines(String text, {int start = 0, int? length}) { + assert(start >= 0); + + /// Returns true if the code unit at [index] in [text] is a whitespace + /// character. + /// + /// Based on: https://en.wikipedia.org/wiki/Whitespace_character#Unicode + bool isWhitespace(String text, int index) { + var rune = text.codeUnitAt(index); + return rune >= 0x0009 && rune <= 0x000D || + rune == 0x0020 || + rune == 0x0085 || + rune == 0x1680 || + rune == 0x180E || + rune >= 0x2000 && rune <= 0x200A || + rune == 0x2028 || + rune == 0x2029 || + rune == 0x202F || + rune == 0x205F || + rune == 0x3000 || + rune == 0xFEFF; + } + + if (length == null) return text.split('\n'); + + var result = []; + var effectiveLength = math.max(length - start, 10); + for (var line in text.split('\n')) { + line = line.trim(); + if (line.length <= effectiveLength) { + result.add(line); + continue; + } + + var currentLineStart = 0; + int? lastWhitespace; + for (var i = 0; i < line.length; ++i) { + if (isWhitespace(line, i)) lastWhitespace = i; + + if (i - currentLineStart >= effectiveLength) { + // Back up to the last whitespace, unless there wasn't any, in which + // case we just split where we are. + if (lastWhitespace != null) i = lastWhitespace; + + result.add(line.substring(currentLineStart, i).trim()); + + // Skip any intervening whitespace. + while (isWhitespace(line, i) && i < line.length) { + i++; + } + + currentLineStart = i; + lastWhitespace = null; + } + } + result.add(line.substring(currentLineStart).trim()); + } + return result; +} diff --git a/pkgs/args/pubspec.yaml b/pkgs/args/pubspec.yaml new file mode 100644 index 00000000..859e1860 --- /dev/null +++ b/pkgs/args/pubspec.yaml @@ -0,0 +1,16 @@ +name: args +version: 2.6.0 +description: >- + Library for defining parsers for parsing raw command-line arguments into a set + of options and values using GNU and POSIX style options. +repository: https://github.com/dart-lang/core/main/pkgs/args + +topics: + - cli + +environment: + sdk: ^3.3.0 + +dev_dependencies: + dart_flutter_team_lints: ^3.0.0 + test: ^1.16.0 diff --git a/pkgs/args/test/allow_anything_test.dart b/pkgs/args/test/allow_anything_test.dart new file mode 100644 index 00000000..52e22b79 --- /dev/null +++ b/pkgs/args/test/allow_anything_test.dart @@ -0,0 +1,67 @@ +// 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:args/args.dart'; +import 'package:args/command_runner.dart'; +import 'package:test/test.dart'; + +import 'test_utils.dart'; + +void main() { + group('new ArgParser.allowAnything()', () { + late ArgParser parser; + setUp(() { + parser = ArgParser.allowAnything(); + }); + + test('exposes empty values', () { + expect(parser.options, isEmpty); + expect(parser.commands, isEmpty); + expect(parser.allowTrailingOptions, isFalse); + expect(parser.allowsAnything, isTrue); + expect(parser.usage, isEmpty); + expect(parser.findByAbbreviation('a'), isNull); + }); + + test('mutation methods throw errors', () { + expect(() => parser.addCommand('command'), throwsUnsupportedError); + expect(() => parser.addFlag('flag'), throwsUnsupportedError); + expect(() => parser.addOption('option'), throwsUnsupportedError); + expect(() => parser.addSeparator('==='), throwsUnsupportedError); + }); + + test('getDefault() throws an error', () { + expect(() => parser.defaultFor('option'), throwsArgumentError); + }); + + test('parses all values as rest arguments', () { + var results = parser.parse(['--foo', '-abc', '--', 'bar']); + expect(results.options, isEmpty); + expect(results.rest, equals(['--foo', '-abc', '--', 'bar'])); + expect(results.arguments, equals(['--foo', '-abc', '--', 'bar'])); + expect(results.command, isNull); + expect(results.name, isNull); + }); + + test('works as a subcommand', () { + var commandParser = ArgParser()..addCommand('command', parser); + var results = + commandParser.parse(['command', '--foo', '-abc', '--', 'bar']); + expect(results.command!.options, isEmpty); + expect(results.command!.rest, equals(['--foo', '-abc', '--', 'bar'])); + expect( + results.command!.arguments, equals(['--foo', '-abc', '--', 'bar'])); + expect(results.command!.name, equals('command')); + }); + + test('works as a subcommand in a CommandRunner', () async { + var commandRunner = + CommandRunner('command', 'Description of command'); + var command = AllowAnythingCommand(); + commandRunner.addCommand(command); + + await commandRunner.run([command.name, '--foo', '--bar', '-b', 'qux']); + }); + }); +} diff --git a/pkgs/args/test/args_test.dart b/pkgs/args/test/args_test.dart new file mode 100644 index 00000000..04bbc471 --- /dev/null +++ b/pkgs/args/test/args_test.dart @@ -0,0 +1,349 @@ +// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:args/args.dart'; +import 'package:test/test.dart'; + +import 'test_utils.dart'; + +void main() { + group('ArgParser.addFlag()', () { + test('throws ArgumentError if the flag already exists', () { + var parser = ArgParser(); + parser.addFlag('foo'); + throwsIllegalArg(() => parser.addFlag('foo')); + }); + + test('throws ArgumentError if the option already exists', () { + var parser = ArgParser(); + parser.addOption('foo'); + throwsIllegalArg(() => parser.addFlag('foo')); + }); + + test('throws ArgumentError if the abbreviation exists', () { + var parser = ArgParser(); + parser.addFlag('foo', abbr: 'f'); + throwsIllegalArg(() => parser.addFlag('flummox', abbr: 'f')); + }); + + test( + 'throws ArgumentError if the abbreviation is longer ' + 'than one character', () { + var parser = ArgParser(); + throwsIllegalArg(() => parser.addFlag('flummox', abbr: 'flu')); + }); + + test('throws ArgumentError if a flag name is invalid', () { + var parser = ArgParser(); + + for (var name in _invalidOptions) { + var reason = '${Error.safeToString(name)} is not valid'; + throwsIllegalArg(() => parser.addFlag(name), reason: reason); + } + }); + + test('accepts valid flag names', () { + var parser = ArgParser(); + + for (var name in _validOptions) { + var reason = '${Error.safeToString(name)} is valid'; + expect(() => parser.addFlag(name), returnsNormally, reason: reason); + } + }); + }); + + group('ArgParser.addOption()', () { + test('throws ArgumentError if the flag already exists', () { + var parser = ArgParser(); + parser.addFlag('foo'); + throwsIllegalArg(() => parser.addOption('foo')); + }); + + test('throws ArgumentError if the option already exists', () { + var parser = ArgParser(); + parser.addOption('foo'); + throwsIllegalArg(() => parser.addOption('foo')); + }); + + test('throws ArgumentError if the abbreviation exists', () { + var parser = ArgParser(); + parser.addFlag('foo', abbr: 'f'); + throwsIllegalArg(() => parser.addOption('flummox', abbr: 'f')); + }); + + test( + 'throws ArgumentError if the abbreviation is longer ' + 'than one character', () { + var parser = ArgParser(); + throwsIllegalArg(() => parser.addOption('flummox', abbr: 'flu')); + }); + + test('throws ArgumentError if the abbreviation is empty', () { + var parser = ArgParser(); + throwsIllegalArg(() => parser.addOption('flummox', abbr: '')); + }); + + test('throws ArgumentError if the abbreviation is an invalid value', () { + var parser = ArgParser(); + for (var name in _invalidOptions) { + throwsIllegalArg(() => parser.addOption('flummox', abbr: name)); + } + }); + + test('throws ArgumentError if the abbreviation is a dash', () { + var parser = ArgParser(); + throwsIllegalArg(() => parser.addOption('flummox', abbr: '-')); + }); + + test('allows explict null value for "abbr"', () { + var parser = ArgParser(); + expect(() => parser.addOption('flummox', abbr: null), returnsNormally); + }); + + test('throws ArgumentError if an option name is invalid', () { + var parser = ArgParser(); + + for (var name in _invalidOptions) { + var reason = '${Error.safeToString(name)} is not valid'; + throwsIllegalArg(() => parser.addOption(name), reason: reason); + } + }); + + test('accepts valid option names', () { + var parser = ArgParser(); + + for (var name in _validOptions) { + var reason = '${Error.safeToString(name)} is valid'; + expect(() => parser.addOption(name), returnsNormally, reason: reason); + } + }); + }); + + group('ArgParser.getDefault()', () { + test('returns the default value for an option', () { + var parser = ArgParser(); + parser.addOption('mode', defaultsTo: 'debug'); + expect(parser.defaultFor('mode'), 'debug'); + }); + + test('throws if the option is unknown', () { + var parser = ArgParser(); + parser.addOption('mode', defaultsTo: 'debug'); + throwsIllegalArg(() => parser.defaultFor('undefined')); + }); + }); + + group('ArgParser.commands', () { + test('returns an empty map if there are no commands', () { + var parser = ArgParser(); + expect(parser.commands, isEmpty); + }); + + test('returns the commands that were added', () { + var parser = ArgParser(); + parser.addCommand('hide'); + parser.addCommand('seek'); + expect(parser.commands, hasLength(2)); + expect(parser.commands['hide'], isNotNull); + expect(parser.commands['seek'], isNotNull); + }); + + test('iterates over the commands in the order they were added', () { + var parser = ArgParser(); + parser.addCommand('a'); + parser.addCommand('d'); + parser.addCommand('b'); + parser.addCommand('c'); + expect(parser.commands.keys, equals(['a', 'd', 'b', 'c'])); + }); + }); + + group('ArgParser.options', () { + test('returns an empty map if there are no options', () { + var parser = ArgParser(); + expect(parser.options, isEmpty); + }); + + test('returns the options that were added', () { + var parser = ArgParser(); + parser.addFlag('hide'); + parser.addOption('seek'); + expect(parser.options, hasLength(2)); + expect(parser.options['hide'], isNotNull); + expect(parser.options['seek'], isNotNull); + }); + + test('iterates over the options in the order they were added', () { + var parser = ArgParser(); + parser.addFlag('a'); + parser.addOption('d'); + parser.addFlag('b'); + parser.addOption('c'); + expect(parser.options.keys, equals(['a', 'd', 'b', 'c'])); + }); + }); + + group('ArgParser.findByNameOrAlias', () { + test('returns null if there is no match', () { + var parser = ArgParser(); + expect(parser.findByNameOrAlias('a'), isNull); + }); + + test('can find options by alias', () { + var parser = ArgParser()..addOption('a', aliases: ['b']); + expect(parser.findByNameOrAlias('b'), + isA