diff --git a/packages/cli_tools/README_completion.md b/packages/cli_tools/README_completion.md new file mode 100644 index 0000000..2f793ee --- /dev/null +++ b/packages/cli_tools/README_completion.md @@ -0,0 +1,49 @@ +## Command line completion for scloud + +There is now an experimental feature to generate and install command line +completion in bash for commands using `BetterCommandRunner`. + +### Prerequisites + +This requires the tool `completely`, which also requires ruby to be installed. + +https://github.com/bashly-framework/completely + +```sh +gem install completely +``` + +or with homebrew: + +```sh +brew install brew-gem +brew gem install completely +``` + +### Activate + +Construct `BetterCommandRunner` with the flag `experimentalCompletionCommand` +set to `true`. + +### Install completion + +When the `completely` tool is installed, run e.g: + +```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 +``` + +This will write the completions script to `~/.local/share/bash-completion/completions/`, +where bash picks it up automatically on start. + +In order to update the completions in the current bash shell, run: + +```sh +exec bash +``` + +For end users, the generated bash script can be distributed as a file for them +to install directly. diff --git a/packages/cli_tools/lib/src/better_command_runner/better_command.dart b/packages/cli_tools/lib/src/better_command_runner/better_command.dart index 0385a61..b523f6c 100644 --- a/packages/cli_tools/lib/src/better_command_runner/better_command.dart +++ b/packages/cli_tools/lib/src/better_command_runner/better_command.dart @@ -78,12 +78,12 @@ abstract class BetterCommand extends Command { } @override - BetterCommand? get parent => - super.parent as BetterCommand?; + BetterCommand? get parent => + super.parent as BetterCommand?; @override - BetterCommandRunner? get runner => - super.runner as BetterCommandRunner?; + BetterCommandRunner? get runner => + super.runner as BetterCommandRunner?; @override ArgParser get argParser => _argParser; 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 ee7329b..6606bfd 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,6 +5,8 @@ import 'package:args/args.dart'; import 'package:args/command_runner.dart'; import 'package:config/config.dart'; +import 'completion_command.dart'; + /// A function type for executing code before running a command. typedef OnBeforeRunCommand = Future Function(BetterCommandRunner runner); @@ -146,6 +148,7 @@ class BetterCommandRunner final OnBeforeRunCommand? onBeforeRunCommand, final OnAnalyticsEvent? onAnalyticsEvent, final int? wrapTextColumn, + final bool experimentalCompletionCommand = false, final List? globalOptions, final Map? env, }) : _messageOutput = messageOutput, @@ -170,8 +173,15 @@ class BetterCommandRunner ); } prepareOptionsForParsing(_globalOptions, argParser); + + if (experimentalCompletionCommand) { + addCommand(CompletionCommand()); + } } + /// The global option definitions. + List get globalOptions => _globalOptions; + /// The [MessageOutput] for the command runner. /// It is also used for the commands unless they have their own. MessageOutput? get messageOutput => _messageOutput; 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 new file mode 100644 index 0000000..2ce9c6c --- /dev/null +++ b/packages/cli_tools/lib/src/better_command_runner/completion_command.dart @@ -0,0 +1,280 @@ +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'); + } + } +}