diff --git a/packages/cli_tools/README.md b/packages/cli_tools/README.md index 4367ea2..6a5f26e 100644 --- a/packages/cli_tools/README.md +++ b/packages/cli_tools/README.md @@ -9,6 +9,12 @@ These tools were developed for the Serverpod CLI but can be used in any Dart pro The `config` library has been moved into its own package, published on pub.dev as [config](https://pub.dev/packages/config). +## Command line completion + +`BetterCommandRunner` has support for command-line completion, which is currently +an experimental feature and requires installing an additional tool. See: +[README_completion.md](README_completion.md) + ## Contributing to the Project We are happy to accept contributions. To contribute, please do the following: diff --git a/packages/cli_tools/README_completion.md b/packages/cli_tools/README_completion.md index 2f793ee..a97ed72 100644 --- a/packages/cli_tools/README_completion.md +++ b/packages/cli_tools/README_completion.md @@ -1,14 +1,94 @@ -## Command line completion for scloud +## Command line completion -There is now an experimental feature to generate and install command line -completion in bash for commands using `BetterCommandRunner`. +`BetterCommandRunner` can generate and install command line completion +in bash and some other shells for all its subcommands and options. + +As command developer you will need to install an additional tool that generates +the completion shell script. Two tools are currently supported. + +The shell script needs to be installed by end users to enable completion. + +### Enable experimental feature + +Enable this experimental feature by constructing `BetterCommandRunner` +with the flag `experimentalCompletionCommand` set to `true`. + +## Using the tool `carapace` + +Carapace supports a lot of shells, including Bash, ZSH, Fish, Elvish, +Powershell, Cmd, and more. +However end users need to install the `carapace` tool as well as the generated +script for the command. + +https://carapace.sh/ ### Prerequisites -This requires the tool `completely`, which also requires ruby to be installed. +This requires the tool `carapace`. + +```sh +brew install carapace +``` +For installing in other environments, see: +https://carapace-sh.github.io/carapace-bin/install.html + +### Install completion for the command + +When the `carapace` tool is installed, generate the completion for the command. + +Note that the YAML file must have the same name as the command executable +before the `.yaml` extension. + +```sh +my-command completion generate -f my-command.yaml -t carapace +cp my-command.yaml "${UserConfigDir}/carapace/specs/" +``` + +If you need to specify a specific command name to use in the generated YAML +file, use the `-e` option. + +> Note: ${UserConfigDir} refers to the platform-specific user configuration +directory. On MacOS this is `~/Library/Application Support/` (even though many +other Bash commands use `~/.local/share/`), and on Windows this is `%APPDATA%`. +This can be overridden with the env var `XDG_CONFIG_HOME`, but be aware this +affects lots of applications. + +Run the following once for the current shell, +or add to your shell startup script: + +Bash: +```bash +source <(carapace my-command) +``` + +Zsh: +```zsh +zstyle ':completion:*' format $'\e[2;37mCompleting %d\e[m' +source <(carapace my-command) +``` + +For more information and installing in other shells, see: +https://carapace-sh.github.io/carapace-bin/setup.html + + +### Distribution + +End users will need to install `carapace` and copy the Yaml file to the proper +location, even if the Yaml file is distributed with the command. + + +## Using the tool `completely` + +Completely supports Bash and ZSH. It's benefit is that it doesn't require the +end user to install any tool besides the generated shell completion script. https://github.com/bashly-framework/completely +### Prerequisites + +This requires the command developer to install the tool `completely`, +which also requires ruby to be installed. + ```sh gem install completely ``` @@ -20,24 +100,26 @@ brew install brew-gem brew gem install completely ``` -### Activate +### Generate completion for the command -Construct `BetterCommandRunner` with the flag `experimentalCompletionCommand` -set to `true`. +When the `completely` tool is installed, run commands similar to the following +to generate the bash completion script for the command. -### Install completion +```sh +my-command completion generate -f my-command.yaml -t completely +completely generate my-command.yaml my-command.bash +``` -When the `completely` tool is installed, run e.g: +To install the completions in the bash shell, run: ```sh -my-command completion -f completely.yaml -completely generate completely.yaml completely.bash mkdir -p ~/.local/share/bash-completion/completions -cp completely.bash ~/.local/share/bash-completion/completions/my-command.bash +cp my-command.bash ~/.local/share/bash-completion/completions/ ``` -This will write the completions script to `~/.local/share/bash-completion/completions/`, -where bash picks it up automatically on start. +Completions scripts in `~/.local/share/bash-completion/completions/` +are automatically picked up by bash on start. Note that they must be named +the same as the command, except for the `.bash` suffix. In order to update the completions in the current bash shell, run: @@ -45,5 +127,27 @@ In order to update the completions in the current bash shell, run: exec bash ``` +If the completions directory already exists, you can update the completion +script with this one-liner which doesn't create the intermediate files. +(This approach requires that your command does not generate any other output +to `stdout` when run this way.) + +```sh +my-command completion generate -t completely | completely generate - >~/.local/share/bash-completion/completions/my-command.bash +``` + +### ZSH + +If you are using Oh-My-Zsh, bash completions should already be enabled. +Otherwise you should enable completion by adding this to your ~/.zshrc +(if is it not already there): + +```sh +autoload -Uz +X compinit && compinit +autoload -Uz +X bashcompinit && bashcompinit +``` + +### Distribution + For end users, the generated bash script can be distributed as a file for them -to install directly. +to install directly in their `~/.local/share/bash-completion/completions/`. diff --git a/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart b/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart index 6606bfd..70ef694 100644 --- a/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart +++ b/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart @@ -5,7 +5,7 @@ import 'package:args/args.dart'; import 'package:args/command_runner.dart'; import 'package:config/config.dart'; -import 'completion_command.dart'; +import 'completion/completion_command.dart'; /// A function type for executing code before running a command. typedef OnBeforeRunCommand = Future Function(BetterCommandRunner runner); diff --git a/packages/cli_tools/lib/src/better_command_runner/completion/carapace_generator.dart b/packages/cli_tools/lib/src/better_command_runner/completion/carapace_generator.dart new file mode 100644 index 0000000..db883d3 --- /dev/null +++ b/packages/cli_tools/lib/src/better_command_runner/completion/carapace_generator.dart @@ -0,0 +1,194 @@ +import 'dart:io' show IOSink; + +import 'package:config/config.dart'; + +import 'usage_representation.dart'; + +/// Generates usage representation for a command in the YAML format +/// of the `carapace` tool. +/// https://github.com/carapace-sh/carapace +class CarapaceYamlGenerator implements UsageRepresentationGenerator { + @override + void generate( + final IOSink out, + final CommandUsage usage, + ) { + out.writeln( + r'# yaml-language-server: $schema=https://carapace.sh/schemas/command.json'); + + _generateForCommand(out, usage, 0); + } + + void _generateForCommand( + final IOSink out, + final CommandUsage usage, + int indentLevel, + ) { + if (indentLevel == 0) { + _writeWithIndent(out, 'name: ${usage.commandSequence.last}', indentLevel); + } else { + _writeWithIndent( + out, '- name: ${usage.commandSequence.last}', indentLevel); + indentLevel += 1; + } + + if (usage.persistentOptions.isNotEmpty) { + _writeWithIndent(out, 'persistentFlags:', indentLevel); + _declareOptions(out, usage.persistentOptions, indentLevel + 1); + } + + if (usage.options.isNotEmpty) { + _writeWithIndent(out, 'flags:', indentLevel); + _declareOptions(out, usage.options, indentLevel + 1); + } + + final allOptions = [...usage.persistentOptions, ...usage.options]; + + final exclusiveGroups = _generateExclusiveOptionGroups(allOptions); + if (exclusiveGroups.isNotEmpty) { + _writeWithIndent(out, 'exclusiveFlags:', indentLevel); + for (final group in exclusiveGroups) { + _writeWithIndent(out, '- [${group.join(', ')}]', indentLevel + 1); + } + } + + final specs = _generateOptionCompletionSpecs(allOptions); + if (specs.isNotEmpty) { + _writeWithIndent(out, 'completion:', indentLevel); + _writeWithIndent(out, 'flag:', indentLevel + 1); + for (final spec in specs) { + _writeWithIndent(out, spec, indentLevel + 2); + } + } + + out.writeln(); + + if (usage.subcommands.isNotEmpty) { + _writeWithIndent(out, 'commands:', indentLevel); + for (final subcommand in usage.subcommands) { + _generateForCommand(out, subcommand, indentLevel + 1); + } + } + } + + static void _writeWithIndent( + final IOSink out, + final String text, + final int indentLevel, + ) { + out.writeln('${_getIndent(indentLevel)}$text'); + } + + static String _getIndent(final int indentLevel) => ' ' * indentLevel; + + static void _declareOptions( + final IOSink out, + final List options, + final int indentLevel, + ) { + // options + for (final option in options) { + _declareOption(out, option.option, indentLevel); + } + } + + static void _declareOption( + final IOSink out, + final ConfigOptionBase option, + final int indentLevel, + ) { + final names = [ + if (option.argAbbrev != null) '-${option.argAbbrev}', + if (option.argName != null) '--${option.argName}', + ]; + + String attributes = ''; + if (option is! FlagOption) { + attributes += '='; + } + if (option is MultiOption) { + attributes += '*'; + } + if (option.mandatory && option is! FlagOption) { + attributes += '!'; + } + + _writeWithIndent( + out, + '${names.join(', ')}$attributes: ${option.helpText ?? ''}', + indentLevel, + ); + + if (option case final FlagOption flagOption) { + if (flagOption.negatable && !flagOption.hideNegatedUsage) { + _writeWithIndent( + out, + '--no-${option.argName}$attributes: ${option.helpText ?? ''}', + indentLevel, + ); + } + } + } + + static List> _generateExclusiveOptionGroups( + final List options, + ) { + final groups = >[]; + for (final option in options) { + if (option.option case final FlagOption flagOption) { + final argName = flagOption.argName; + if (argName != null && + flagOption.negatable && + !flagOption.hideNegatedUsage) { + groups.add([argName, 'no-$argName']); + } + } + } + return groups; + } + + static List _generateOptionCompletionSpecs( + final List options, + ) { + final specs = []; + for (final option in options) { + final name = option.option.argName ?? option.option.argAbbrev; + if (name == null) { + continue; + } + + final values = _getOptionValues(option.option); + if (values.isNotEmpty) { + final valueSpec = values.map((final v) => '"$v"').join(', '); + specs.add('$name: [$valueSpec]'); + } + } + return specs; + } + + static List _getOptionValues( + final ConfigOptionBase option, + ) { + if (option case final MultiOption multiOption) { + if (multiOption.allowedElementValues case final List allowedValues) { + return allowedValues + .map(multiOption.multiParser.elementParser.format) + .toList(); + } + } else if (option.allowedValues case final List allowedValues) { + return allowedValues.map(option.valueParser.format).toList(); + } + + switch (option.option) { + case EnumOption(): + final enumParser = option.option.valueParser as EnumParser; + return enumParser.enumValues.map(enumParser.format).toList(); + case FileOption(): + return [r'$files']; + case DirOption(): + return [r'$directories']; + default: + return []; + } + } +} diff --git a/packages/cli_tools/lib/src/better_command_runner/completion/completely_generator.dart b/packages/cli_tools/lib/src/better_command_runner/completion/completely_generator.dart new file mode 100644 index 0000000..4b67c1c --- /dev/null +++ b/packages/cli_tools/lib/src/better_command_runner/completion/completely_generator.dart @@ -0,0 +1,156 @@ +import 'dart:io' show IOSink; + +import 'package:config/config.dart'; + +import 'usage_representation.dart'; + +/// Generates usage representation for a command in the YAML format +/// of the `completely` tool. +/// https://github.com/bashly-framework/completely +class CompletelyYamlGenerator implements UsageRepresentationGenerator { + @override + void generate( + final IOSink out, + final CommandUsage usage, + ) { + _innerGenerate(out, usage, const []); + } + + void _innerGenerate( + final IOSink out, + final CommandUsage usage, + final List inheritedOptions, + ) { + if (usage.subcommands.isEmpty && + usage.persistentOptions.isEmpty && + usage.options.isEmpty && + inheritedOptions.isEmpty) { + return; + } + + out.writeln('${usage.commandSequence.join(' ')}:'); + + for (final subcommand in usage.subcommands) { + out.writeln(' - ${subcommand.command}'); + } + + _generateCompletelyForOptions(out, usage, inheritedOptions); + out.writeln(); + + final propagatedOptions = [...inheritedOptions, ...usage.persistentOptions]; + for (final subcommand in usage.subcommands) { + _innerGenerate(out, subcommand, propagatedOptions); + } + } + + static void _generateCompletelyForOptions( + final IOSink out, + final CommandUsage usage, + final List inheritedOptions, + ) { + final allOptions = [ + ...inheritedOptions, + ...usage.persistentOptions, + ...usage.options + ]; + + // options + for (final option in allOptions) { + if (option.option.argName != null) { + out.writeln(' - --${option.option.argName}'); + + if (option.option case final FlagOption flagOption) { + if (flagOption.negatable && !flagOption.hideNegatedUsage) { + out.writeln(' - --no-${flagOption.argName}'); + } + } + } + + if (option.option.argAbbrev != null) { + out.writeln(' - -${option.option.argAbbrev}'); + } + + if (option.option.argPos == 0) { + final values = _getOptionValues(option.option); + if (values.isNotEmpty) { + _generateCompletelyForOptionValues(out, values); + } + } + // can't currently complete for positional options after the first one + } + + // value completions for each option + for (final option in allOptions) { + _generateCompletelyForOption(out, usage.commandSequence, option.option); + } + } + + static void _generateCompletelyForOption( + final IOSink out, + final List commandSequence, + final ConfigOptionBase option, + ) { + if (option is FlagOption) { + return; + } + + if (option.argName case final String argName) { + _generateCompletelyForArgNameOption( + out, + commandSequence, + option, + '--$argName', + ); + } + if (option.argAbbrev case final String argAbbrev) { + _generateCompletelyForArgNameOption( + out, + commandSequence, + option, + '-$argAbbrev', + ); + } + } + + static void _generateCompletelyForArgNameOption( + final IOSink out, + final List commandSequence, + final ConfigOptionBase option, + final String argName, + ) { + final values = _getOptionValues(option); + if (values.isNotEmpty) { + out.writeln('${commandSequence.join(' ')}*$argName:'); + _generateCompletelyForOptionValues(out, values); + } + } + + static List _getOptionValues( + final ConfigOptionBase option, + ) { + if (option.allowedValues case final List allowedValues) { + return allowedValues.map(option.valueParser.format).toList(); + } + + switch (option.option) { + case EnumOption(): + final enumParser = option.option.valueParser as EnumParser; + return enumParser.enumValues.map(enumParser.format).toList(); + case FileOption(): + return ['']; + case DirOption(): + return ['']; + default: + return []; + } + } + + static void _generateCompletelyForOptionValues( + final IOSink out, + final Iterable values, + ) { + for (final value in values) { + out.writeln(' - $value'); + } + } +} diff --git a/packages/cli_tools/lib/src/better_command_runner/completion/completion_command.dart b/packages/cli_tools/lib/src/better_command_runner/completion/completion_command.dart new file mode 100644 index 0000000..6bcbf83 --- /dev/null +++ b/packages/cli_tools/lib/src/better_command_runner/completion/completion_command.dart @@ -0,0 +1,39 @@ +import 'dart:async'; + +import 'package:config/config.dart'; + +import '../better_command.dart'; +import 'completion_generate_command.dart'; +import 'completion_target.dart'; + +abstract final class CompletionOptions { + static const targetOption = EnumOption( + enumParser: EnumParser(CompletionTarget.values), + argName: 'target', + argAbbrev: 't', + helpText: 'The target tool format', + mandatory: true, + ); + static const execNameOption = StringOption( + argName: 'exec-name', + argAbbrev: 'e', + helpText: 'Override the name of the executable', + ); +} + +class CompletionCommand extends BetterCommand { + CompletionCommand() { + addSubcommand(CompletionGenerateCommand()); + } + + @override + String get name => 'completion'; + + @override + String get description => 'Command line completion commands'; + + @override + FutureOr? runWithConfig(final Configuration commandConfig) { + return null; + } +} diff --git a/packages/cli_tools/lib/src/better_command_runner/completion/completion_generate_command.dart b/packages/cli_tools/lib/src/better_command_runner/completion/completion_generate_command.dart new file mode 100644 index 0000000..bbe243d --- /dev/null +++ b/packages/cli_tools/lib/src/better_command_runner/completion/completion_generate_command.dart @@ -0,0 +1,72 @@ +import 'dart:io' show IOSink, stdout; + +import 'package:config/config.dart'; + +import '../better_command.dart'; +import 'carapace_generator.dart'; +import 'completely_generator.dart'; +import 'completion_command.dart' show CompletionOptions; +import 'completion_target.dart'; +import 'usage_representation.dart'; + +enum CompletionGenerateOption implements OptionDefinition { + target(CompletionOptions.targetOption), + execName(CompletionOptions.execNameOption), + file(FileOption( + argName: 'file', + argAbbrev: 'f', + helpText: 'Write the specification to a file instead of stdout', + )); + + const CompletionGenerateOption(this.option); + + @override + final ConfigOptionBase option; +} + +class CompletionGenerateCommand + extends BetterCommand { + CompletionGenerateCommand() : super(options: CompletionGenerateOption.values); + + @override + String get name => 'generate'; + + @override + String get description => 'Generate a command line completion specification'; + + @override + Future runWithConfig( + final Configuration commandConfig) async { + final target = commandConfig.value(CompletionGenerateOption.target); + final execName = + commandConfig.optionalValue(CompletionGenerateOption.execName); + final file = commandConfig.optionalValue(CompletionGenerateOption.file); + + final betterRunner = runner; + if (betterRunner == null) { + throw Exception('BetterCommandRunner not set in the completion command'); + } + + final usage = UsageRepresentation.compile( + betterRunner, + execNameOverride: execName, + ); + + final IOSink out = file?.openWrite() ?? stdout; + + switch (target) { + case CompletionTarget.completely: + CompletelyYamlGenerator().generate(out, usage); + break; + case CompletionTarget.carapace: + CarapaceYamlGenerator().generate(out, usage); + break; + } + + if (file != null) { + await out.flush(); + await out.close(); + } + return null as T; + } +} diff --git a/packages/cli_tools/lib/src/better_command_runner/completion/completion_target.dart b/packages/cli_tools/lib/src/better_command_runner/completion/completion_target.dart new file mode 100644 index 0000000..212c716 --- /dev/null +++ b/packages/cli_tools/lib/src/better_command_runner/completion/completion_target.dart @@ -0,0 +1,4 @@ +enum CompletionTarget { + completely, + carapace, +} diff --git a/packages/cli_tools/lib/src/better_command_runner/completion/usage_representation.dart b/packages/cli_tools/lib/src/better_command_runner/completion/usage_representation.dart new file mode 100644 index 0000000..3db72fa --- /dev/null +++ b/packages/cli_tools/lib/src/better_command_runner/completion/usage_representation.dart @@ -0,0 +1,91 @@ +import 'dart:io' show IOSink; + +import 'package:args/command_runner.dart' show Command; +import 'package:config/config.dart'; + +import '../better_command.dart'; +import '../better_command_runner.dart'; + +/// Interface for generating a usage representation for a command. +abstract interface class UsageRepresentationGenerator { + void generate( + final IOSink out, + final CommandUsage usage, + ); +} + +class CommandUsage { + final List commandSequence; + final List subcommands; + + /// Persistent options - the options of the current command that continue to + /// be available for all subcommands. + /// (In Dart, this usually only applies to global options.) + final List persistentOptions; + + /// Regular options - the options of the current command that are not + /// persistent. + final List options; + + CommandUsage({ + required this.commandSequence, + required this.subcommands, + required this.persistentOptions, + required this.options, + }); + + String get command => commandSequence.last; +} + +abstract class UsageRepresentation { + /// Compiles a usage representation tree for all subcommands and options + /// for a command runner. + static CommandUsage compile( + final BetterCommandRunner runner, { + final String? execNameOverride, + }) { + return _generateForCommand( + [execNameOverride ?? runner.executableName], + runner.commands.values, + _filterOptions(runner.globalOptions), + const [], + ); + } + + static CommandUsage _generateForCommand( + final List commandSequence, + final Iterable subcommands, + final List persistentOptions, + final List options, + ) { + final validOptions = _filterOptions(options); + final validSubcommands = + subcommands.whereType().where((final c) => !c.hidden); + + return CommandUsage( + commandSequence: commandSequence, + persistentOptions: persistentOptions, + options: validOptions, + subcommands: validSubcommands + .map((final subcommand) => _generateForCommand( + [...commandSequence, subcommand.name], + subcommand.subcommands.values, + const [], + subcommand.options, + )) + .toList(), + ); + } + + static List _filterOptions( + final List options, + ) { + return options + .where((final o) => !o.option.hide) + .where((final o) => + o.option.argName != null || + o.option.argAbbrev != null || + o.option.argPos != null) + .toList(); + } +} diff --git a/packages/cli_tools/lib/src/better_command_runner/completion_command.dart b/packages/cli_tools/lib/src/better_command_runner/completion_command.dart deleted file mode 100644 index 2ce9c6c..0000000 --- a/packages/cli_tools/lib/src/better_command_runner/completion_command.dart +++ /dev/null @@ -1,280 +0,0 @@ -import 'dart:io' show IOSink, stdout; - -import 'package:args/command_runner.dart' show Command; -import 'package:config/config.dart'; - -import 'better_command.dart'; -import 'better_command_runner.dart'; - -enum CompletionTarget { - completely, -} - -enum CompletionOption implements OptionDefinition { - target(EnumOption( - enumParser: EnumParser(CompletionTarget.values), - argName: 'target', - argAbbrev: 't', - defaultsTo: CompletionTarget.completely, - )), - execName(StringOption( - argName: 'exec-name', - argAbbrev: 'e', - helpText: 'Override the name of the executable', - )), - file(FileOption( - argName: 'file', - argAbbrev: 'f', - helpText: 'Write the specification to a file instead of stdout', - )); - - const CompletionOption(this.option); - - @override - final ConfigOptionBase option; -} - -class CompletionCommand extends BetterCommand { - CompletionCommand() : super(options: CompletionOption.values); - - @override - String get name => 'completion'; - - @override - String get description => 'Generate a command line completion specification'; - - @override - Future runWithConfig( - final Configuration commandConfig) async { - final target = commandConfig.value(CompletionOption.target); - final execName = commandConfig.optionalValue(CompletionOption.execName); - final file = commandConfig.optionalValue(CompletionOption.file); - - final betterRunner = runner; - if (betterRunner == null) { - throw Exception('BetterCommandRunner not set in the completion command'); - } - - final usage = UsageRepresentation.compile( - betterRunner, - execNameOverride: execName, - ); - - final IOSink out = file?.openWrite() ?? stdout; - - switch (target) { - case CompletionTarget.completely: - CompletelyYamlGenerator().generate(out, usage); - break; - } - - if (file != null) { - await out.flush(); - await out.close(); - } - return null as T; - } -} - -class CommandUsage { - final List commandSequence; - final List options; - final List subcommands; - - CommandUsage({ - required this.commandSequence, - required this.options, - required this.subcommands, - }); - - String get command => commandSequence.last; -} - -abstract class UsageRepresentation { - /// Compiles a usage representation tree for all subcommands and options - /// for a command runner. - static CommandUsage compile( - final BetterCommandRunner runner, { - final String? execNameOverride, - }) { - return _generateForCommand( - [execNameOverride ?? runner.executableName], - _filterOptions(runner.globalOptions), - runner.commands.values, - const [], - ); - } - - static CommandUsage _generateForCommand( - final List commandSequence, - final List globalOptions, - final Iterable subcommands, - final List options, - ) { - final validOptions = [...globalOptions, ..._filterOptions(options)]; - final validSubcommands = - subcommands.whereType().where((final c) => !c.hidden); - - return CommandUsage( - commandSequence: commandSequence, - options: validOptions, - subcommands: validSubcommands - .map((final subcommand) => _generateForCommand( - [...commandSequence, subcommand.name], - globalOptions, - subcommand.subcommands.values, - subcommand.options, - )) - .toList(), - ); - } - - static List _filterOptions( - final List options, - ) { - return options - .where((final o) => !o.option.hide) - .where((final o) => o.option.argName != null || o.option.argPos != null) - .toList(); - } -} - -/// Interface for generating a usage representation for a command. -abstract interface class UsageRepresentationGenerator { - void generate( - final IOSink out, - final CommandUsage usage, - ); -} - -/// Generates usage representation for a command in the YAML format -/// of the `completely` tool. -/// https://github.com/bashly-framework/completely -class CompletelyYamlGenerator implements UsageRepresentationGenerator { - @override - void generate( - final IOSink out, - final CommandUsage usage, - ) { - if (usage.subcommands.isEmpty && usage.options.isEmpty) { - return; - } - - out.writeln('${usage.commandSequence.join(' ')}:'); - - for (final subcommand in usage.subcommands) { - out.writeln(' - ${subcommand.command}'); - } - - _generateCompletelyForOptions(out, usage); - out.writeln(); - - for (final subcommand in usage.subcommands) { - generate(out, subcommand); - } - } - - static void _generateCompletelyForOptions( - final IOSink out, - final CommandUsage usage, - ) { - // options - for (final option in usage.options) { - if (option.option.argName != null) { - out.writeln(' - --${option.option.argName}'); - - if (option.option case final FlagOption flagOption) { - if (flagOption.negatable && !flagOption.hideNegatedUsage) { - out.writeln(' - --no-${flagOption.argName}'); - } - } - } - - if (option.option.argAbbrev != null) { - out.writeln(' - -${option.option.argAbbrev}'); - } - - if (option.option.argPos == 0) { - final values = _getOptionValues(option.option); - if (values.isNotEmpty) { - _generateCompletelyForOptionValues(out, values); - } - } - // can't currently complete for positional options after the first one - } - - // value completions for each option - for (final option in usage.options) { - _generateCompletelyForOption(out, usage.commandSequence, option.option); - } - } - - static void _generateCompletelyForOption( - final IOSink out, - final List commandSequence, - final ConfigOptionBase option, - ) { - if (option is FlagOption) { - return; - } - - if (option.argName case final String argName) { - _generateCompletelyForArgNameOption( - out, - commandSequence, - option, - ' --$argName', - ); - } - if (option.argAbbrev case final String argAbbrev) { - _generateCompletelyForArgNameOption( - out, - commandSequence, - option, - ' -$argAbbrev', - ); - } - } - - static void _generateCompletelyForArgNameOption( - final IOSink out, - final List commandSequence, - final ConfigOptionBase option, - final String argName, - ) { - final values = _getOptionValues(option); - if (values.isNotEmpty) { - out.writeln('${commandSequence.join(' ')}*$argName:'); - _generateCompletelyForOptionValues(out, values); - } - } - - static List _getOptionValues( - final ConfigOptionBase option, - ) { - if (option.allowedValues case final List allowedValues) { - return allowedValues.map(option.valueParser.format).toList(); - } - - switch (option.option) { - case EnumOption(): - final enumParser = option.option.valueParser as EnumParser; - return enumParser.enumValues.map(enumParser.format).toList(); - case FileOption(): - return ['']; - case DirOption(): - return ['']; - default: - return []; - } - } - - static void _generateCompletelyForOptionValues( - final IOSink out, - final Iterable values, - ) { - for (final value in values) { - out.writeln(' - $value'); - } - } -} diff --git a/packages/cli_tools/test/better_command_runner/completion_test.dart b/packages/cli_tools/test/better_command_runner/completion_test.dart new file mode 100644 index 0000000..3bc5c49 --- /dev/null +++ b/packages/cli_tools/test/better_command_runner/completion_test.dart @@ -0,0 +1,226 @@ +import 'package:cli_tools/better_command_runner.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; + +void main() { + test( + 'Given a BetterCommandRunner without enabling experimental completion feature' + ' when running base command with --help flag' + ' then global usage does not include "completion" command', () async { + final infos = []; + final messageOutput = MessageOutput( + usageLogger: (final u) => infos.add(u), + ); + final runner = BetterCommandRunner( + 'test', + 'test project', + messageOutput: messageOutput, + ); + + await runner.run(['--help']); + + expect(infos, hasLength(1)); + expect( + infos.single, + isNot(stringContainsInOrder([ + 'Generate a command line completion specification', + ])), + ); + }); + + group( + 'Given a BetterCommandRunner with experimental completion feature enabled', + () { + final infos = []; + final messageOutput = MessageOutput( + usageLogger: (final u) => infos.add(u), + ); + + final runner = BetterCommandRunner( + 'test', + 'test project', + messageOutput: messageOutput, + experimentalCompletionCommand: true, + ); + + setUp(() { + infos.clear(); + }); + + test( + 'when running base command with --help flag ' + 'then global usage includes "completion" command', () async { + await runner.run(['--help']); + + expect(infos, hasLength(1)); + expect( + infos.single, + stringContainsInOrder([ + 'Usage: test [arguments]', + 'Global options:', + '-h, --help', + 'Available commands:', + 'completion', + 'Command line completion commands', + ]), + ); + }); + + test( + 'when running subcommand "completion generate -t completely -f " ' + 'then a proper completely specification is written to the file', + () async { + await d.dir('test-dir').create(); + final filePath = p.join(d.sandbox, 'test-dir', 'test.yaml'); + + await runner.run([ + 'completion', + 'generate', + '-t', + 'completely', + '-f', + filePath, + ]); + + final spec = d.file( + filePath, + stringContainsInOrder([ + 'test:', + ' - completion', + ' - --quiet', + ' - -q', + ' - --verbose', + ' - -v', + 'test completion generate*--target:', + ' - completely', + ' - carapace', + 'test completion generate*-t:', + ' - completely', + ' - carapace', + 'test completion generate*--file:', + ' - ', + 'test completion generate*-f:', + ' - ', + ])); + await expectLater(spec.validate(), completes); + }); + + test( + 'when running subcommand "completion generate -t completely -e other-exec-name -f " ' + 'then the completely specification contains other-exec-name', () async { + await d.dir('test-dir').create(); + final filePath = p.join(d.sandbox, 'test-dir', 'test.yaml'); + + await runner.run([ + 'completion', + 'generate', + '-t', + 'completely', + '-e', + 'other-exec-name', + '-f', + filePath, + ]); + + final spec = d.file( + filePath, + stringContainsInOrder([ + 'other-exec-name:', + ' - completion', + ' - --quiet', + ' - -q', + ' - --verbose', + ' - -v', + 'other-exec-name completion generate*--target:', + ' - completely', + ' - carapace', + 'other-exec-name completion generate*-t:', + ' - completely', + ' - carapace', + 'other-exec-name completion generate*--file:', + ' - ', + 'other-exec-name completion generate*-f:', + ' - ', + ])); + await expectLater(spec.validate(), completes); + }); + + test( + 'when running subcommand "completion generate -t carapace -f " ' + 'then a proper carapace specification is written to the file', + () async { + await d.dir('test-dir').create(); + final filePath = p.join(d.sandbox, 'test-dir', 'test.yaml'); + + await runner.run([ + 'completion', + 'generate', + '-t', + 'carapace', + '-f', + filePath, + ]); + + final spec = d.file( + filePath, + stringContainsInOrder([ + r'# yaml-language-server: $schema=https://carapace.sh/schemas/command.json', + 'name: test', + 'persistentFlags:', + ' -q, --quiet: Suppress all cli output. Is overridden by -v, --verbose.', + ' -v, --verbose: Prints additional information useful for development. Overrides --q, --quiet.', + 'commands:', + ' - name: completion', + ' flags:', + ' -t, --target=!: The target tool format', + ' -e, --exec-name=: Override the name of the executable', + ' -f, --file=: Write the specification to a file instead of stdout', + ' completion:', + ' flag:', + ' target: ["completely", "carapace"]', + r' file: ["$files"]', + ])); + await expectLater(spec.validate(), completes); + }); + + test( + 'when running subcommand "completion generate -t carapace -e other-exec-name -f " ' + 'then the carapace specification contains other-exec-name', () async { + await d.dir('test-dir').create(); + final filePath = p.join(d.sandbox, 'test-dir', 'test.yaml'); + + await runner.run([ + 'completion', + 'generate', + '-t', + 'carapace', + '-e', + 'other-exec-name', + '-f', + filePath, + ]); + + final spec = d.file( + filePath, + stringContainsInOrder([ + r'# yaml-language-server: $schema=https://carapace.sh/schemas/command.json', + 'name: other-exec-name', + 'persistentFlags:', + ' -q, --quiet: Suppress all cli output. Is overridden by -v, --verbose.', + ' -v, --verbose: Prints additional information useful for development. Overrides --q, --quiet.', + 'commands:', + ' - name: completion', + ' flags:', + ' -t, --target=!: The target tool format', + ' -e, --exec-name=: Override the name of the executable', + ' -f, --file=: Write the specification to a file instead of stdout', + ' completion:', + ' flag:', + ' target: ["completely", "carapace"]', + r' file: ["$files"]', + ])); + await expectLater(spec.validate(), completes); + }); + }); +} diff --git a/packages/config/lib/src/config/options.dart b/packages/config/lib/src/config/options.dart index 88be5a3..e4d4e15 100644 --- a/packages/config/lib/src/config/options.dart +++ b/packages/config/lib/src/config/options.dart @@ -615,10 +615,11 @@ class MultiParser extends ValueParser> { /// Multi-value configuration option. class MultiOption extends ConfigOptionBase> { + final MultiParser multiParser; final List? allowedElementValues; const MultiOption({ - required final MultiParser multiParser, + required this.multiParser, super.argName, super.argAliases, super.argAbbrev, @@ -640,6 +641,13 @@ class MultiOption extends ConfigOptionBase> { valueParser: multiParser, ); + /// [MultiOption] does not properly support [allowedValues], + /// use [allowedElementValues] instead. + @override + List>? get allowedValues { + return super.allowedValues; + } + @override void _addToArgParser(final ArgParser argParser) { final argName = this.argName; @@ -647,7 +655,6 @@ class MultiOption extends ConfigOptionBase> { throw StateError("Can't add option without arg name to arg parser."); } - final multiParser = valueParser as MultiParser; argParser.addMultiOption( argName, abbr: argAbbrev,