Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Rewrite the CLI internals #265

Merged
merged 30 commits into from
Jun 23, 2020
Merged

Rewrite the CLI internals #265

merged 30 commits into from
Jun 23, 2020

Conversation

kasperisager
Copy link
Contributor

@kasperisager kasperisager commented Jun 16, 2020

This pull request introduces new internals of the CLI package, removing the dependency on @oclif. The reason for doing this is twofold:

  1. Initialisation times of the various @oclif packages weren't great. Doing a simple $ alfa --help took upwards of 2 seconds. With the changes introduced in this pull request, doing $ alfa --help takes about 120 ms.

  2. There were some unfortunate "features" in @oclif that were causing annoyances. For starters, the way things were modelled in TypeScript made things like flags and positional arguments a little difficult to work with. Notably, positional arguments weren't typed at all. Several other annoyances were also encountered that I'll outline further below.

To this end, this pull request introduces 3 new building blocks for putting together command line applications.

Flag<T>

This type models a flag, or an option as they're perhaps more commonly known, with a value of type T. The term "flag" avoids collision with the existing Option<T> type for modelling optional types. Also, since flags can be non-optional, it avoids the somewhat oxymoronic term "required option". Flags are constructed by supplying:

  1. A name, which is expected to be what's recognised when setting the flag on the command line.

  2. A description, which is output as part of --help.

  3. A parser, which determines how the flag should be parsed given a list of command line arguments, typically called argv.

Once constructed, the following methods are available for further modifying the behaviour and presentation of flags:

  • Flag<T>#type(type: string): Flag<T>

    This method sets the "type" of the flag as shown as part of --help. For example, flag.type("string") would result in a --help output of --flag <string>.

  • Flag<T>#alias(alias: string): Flag<T>

    This method defines an additional alias for the flag, which is another name that is expected to be recognised when setting the flag on the command line.

  • Flag<T>#default(value: T): Flag<T>

    This method specifies the default value of the flag which will be used if the flag is not specified.

  • Flag<T>#optional(): Flag<Option<T>>

    This method turns the flag into an optional flag.

    Going from required to optional, rather than the other way around as seen in @oclif, makes the resulting types intuitive to understand and is more ergonomic than having a type like Flag<T> exposed as T | undefined if it's optional or defaulting the type to Flag<Option<T>> and trying to unwrap it to Flag<T> it it's required. The latter is what I initially tried in order to keep things aligned with @oclif and it didn't turn out well.

  • Flag<T>#repeatable(): Flag<Iterable<T>>

    This method turns the flag into a repeatable flag. This means that the flag can be specified multiple times and the individual values joined into a list.

    @oclif also supports something similar through the multiple option, but this has the downside of also allowing a list syntax for specifying values. This means that a flag can both be repeated, such as --flag foo --flag bar, and also specified with a list of values, such as --flag foo bar. The latter unfortunately risks positional arguments being parsed as part of a flag. For example, this manifested itself when one tried to filter the types of outcomes produced by $ alfa audit:

    $ alfa audit --outcomes passed failed https://example.com

    The above command would fail with an error stating that https://example.com isn't a valid outcome. The solution was to use the "end of options" indicator, which is by no means intuitive:

    $ alfa audit --outcomes passed failed -- https://example.com

    Repeated flags don't suffer from this as the only thing they allow is repeating the same flag multiple times. Then, instead of overwriting the old flag once a new one is encountered, all flags are collected into a list. This means that the above has now turned into this:

    $ alfa audit --outcome passed --outcome failed https://example.com

    ⚠️ This change is breaking and means that all flags that previously specified the multiple option and used pluralised names are now implemented as repeatable flags with singular names. This applies to the following flags:

    • --headers/--cookies have been replaced by the repeatable --header/--cookie flags which now only accepts a header:value/cookie=value pair. New non-repeatable --headers/--cookies flags that take a path to a JSON file with headers/cookies have been introduced.

    • --outcomes has been replaced by the repeatable --outcome flag.

  • Flag<T>#negatable(mapper: Mapper<T, T>): Flag<T>

    This method turns the flag into a negatable flag. Negatable flags provide a special --no-<flag> variant that can be used to negate the flag as determined by the mapper argument.

  • Flag<T>#choices<U extends T>(...choices: Array<U>): Flag<U>

    This method specifies the allowed choices of value of the flag and is similar to calling flag.filter(equals(...choices)).

Types

Out of the box support for the following types of flags is currently included:

  • Flag.string(): Flag<string>, which constructs a flag that takes a string value. This flag therefore accepts any kind of value.

  • Flag.number(): Flag<number>, which constructs a flag that takes a number value for which Number.isFinite() holds.

  • Flag.integer(): Flag<number>, which constructs a flag that takes an integer value for which Number.isInteger() holds.

  • Flag.boolean(): Flag<boolean>, which constructs a flag that takes a boolean value, true or false. The value can also left out, which is the same as passing true. Boolean flags are negatable by default with the negated variant flipping the truth value of the flag.

Argument<T>

This type models a positional argument with a value of type T. Arguments are similar to flags but are recognised by position instead of by name.

Command<F, A, S>

This type models a command with flags of type F, arguments of type A, and subcommands of type S. Commands are constructed by supplying:

  1. A name, which is expected to be what's recognised when the command is invoked as part of a command line application.

  2. A version, which is output as part of --help.

  3. A description, which is output as part of --help.

  4. A record of flags, which are made available to the command runner.

  5. Either:

    • A record of arguments, which are made available to the command runner.

    • A record of subcommands, which take the place of arguments and can be invoked using $ <command> <subcommand>.

  6. A parent command, if the command is used as a subcommand of another command.

  7. A runner, which is a function that is invoked with the parsed flags and arguments specified previously. In the case of commands that contain subcommands, the runner is only invoked when no subcommand is specified.

One annoyance that this type solves compared to @oclif is the fact the keys in the records of flags and arguments in no way influence the names with which the flags and arguments are recognised on the command line and presented as part of --help, which is not the case in @oclif. This makes it possible to have repeatable flag such as --cookie be exposed using a pluralised name such as flags.cookies.

@kasperisager kasperisager added the major Backwards-incompatible change that touches public API label Jun 16, 2020
@kasperisager kasperisager self-assigned this Jun 16, 2020
@kasperisager kasperisager marked this pull request as ready for review June 19, 2020 09:46
@kasperisager kasperisager requested a review from Jym77 June 19, 2020 09:46
Copy link
Contributor

@Jym77 Jym77 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like solid design choices.
The only real concern I have is about the impossibility to have --no-foo version of boolean flags which, I'd say, is a fairly common construction in CLI.

Also, the PR description should be saved somewhere as internal doc 😀

@kasperisager
Copy link
Contributor Author

I've added the ability to construct negatable flags through the Flag<T>#negate(mapper: Mapper<T>): Flag<T> method. The mapper function is run when the flag is invoked with a --no- prefix. All boolean flags are negatable by default.

@kasperisager kasperisager requested a review from Jym77 June 23, 2020 07:58
* master:
  Avoid discarding rules when adding to `RuleTree` (#274)
@kasperisager kasperisager merged commit 434c3da into master Jun 23, 2020
@kasperisager kasperisager deleted the cli-overhaul branch June 23, 2020 09:31
kasperisager added a commit that referenced this pull request Jun 23, 2020
* master:
  Rewrite the CLI internals (#265)
  Avoid discarding rules when adding to `RuleTree` (#274)
  Move presentational role conflict resolution down to Role.from (#273)
  Support passing additional arguments to `Parser`
  Update README
  Rewrite the @siteimprove/alfa-math package (#268)
  Create security.md
  Treat kind attribute as enumerated (#269)
  Account for presentational role conflict resolution in ARIA feature mappings (#264)
  Update lockfile
  Prevent infinite instantiation of `Interview` type (#266)
  Lazy load syntax definitions (#263)
  Add user agent styles for `<noscript>` element (#260)
  Fix some parse issues in `Media`
  `Preference.initial()` -> `Preference.unset()`
  Add scripting and user preferences as `Device` parameters (#259)
  Give build scripts a once-over
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
major Backwards-incompatible change that touches public API
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants