-
Notifications
You must be signed in to change notification settings - Fork 3
feat: Experimental command line completion support #65
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
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
280 changes: 280 additions & 0 deletions
280
packages/cli_tools/lib/src/better_command_runner/completion_command.dart
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<V extends Object> implements OptionDefinition<V> { | ||
| 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<V> option; | ||
| } | ||
|
|
||
| class CompletionCommand<T> extends BetterCommand<CompletionOption, T> { | ||
| CompletionCommand() : super(options: CompletionOption.values); | ||
|
|
||
| @override | ||
| String get name => 'completion'; | ||
|
|
||
| @override | ||
| String get description => 'Generate a command line completion specification'; | ||
|
|
||
| @override | ||
| Future<T> runWithConfig( | ||
| final Configuration<CompletionOption> 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<O extends OptionDefinition> { | ||
| final List<String> commandSequence; | ||
| final List<O> options; | ||
| final List<CommandUsage> 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<String> commandSequence, | ||
| final List<OptionDefinition> globalOptions, | ||
| final Iterable<Command> subcommands, | ||
| final List<OptionDefinition> options, | ||
| ) { | ||
| final validOptions = [...globalOptions, ..._filterOptions(options)]; | ||
| final validSubcommands = | ||
| subcommands.whereType<BetterCommand>().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<OptionDefinition> _filterOptions( | ||
| final List<OptionDefinition> 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<String> 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<String> commandSequence, | ||
| final ConfigOptionBase option, | ||
| final String argName, | ||
| ) { | ||
| final values = _getOptionValues(option); | ||
| if (values.isNotEmpty) { | ||
| out.writeln('${commandSequence.join(' ')}*$argName:'); | ||
| _generateCompletelyForOptionValues(out, values); | ||
| } | ||
| } | ||
|
|
||
christerswahn marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| static List<String> _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 ['<file>']; | ||
| case DirOption(): | ||
| return ['<directory>']; | ||
| default: | ||
| return []; | ||
| } | ||
| } | ||
|
|
||
| static void _generateCompletelyForOptionValues( | ||
| final IOSink out, | ||
| final Iterable values, | ||
| ) { | ||
| for (final value in values) { | ||
| out.writeln(' - $value'); | ||
| } | ||
| } | ||
| } | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.