Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions packages/cli_tools/README_completion.md
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.
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,12 @@ abstract class BetterCommand<O extends OptionDefinition, T> extends Command<T> {
}

@override
BetterCommand<dynamic, T>? get parent =>
super.parent as BetterCommand<dynamic, T>?;
BetterCommand<OptionDefinition, T>? get parent =>
super.parent as BetterCommand<OptionDefinition, T>?;

@override
BetterCommandRunner<dynamic, T>? get runner =>
super.runner as BetterCommandRunner<dynamic, T>?;
BetterCommandRunner<OptionDefinition, T>? get runner =>
super.runner as BetterCommandRunner<OptionDefinition, T>?;

@override
ArgParser get argParser => _argParser;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> Function(BetterCommandRunner runner);

Expand Down Expand Up @@ -146,6 +148,7 @@ class BetterCommandRunner<O extends OptionDefinition, T>
final OnBeforeRunCommand? onBeforeRunCommand,
final OnAnalyticsEvent? onAnalyticsEvent,
final int? wrapTextColumn,
final bool experimentalCompletionCommand = false,
final List<O>? globalOptions,
final Map<String, String>? env,
}) : _messageOutput = messageOutput,
Expand All @@ -170,8 +173,15 @@ class BetterCommandRunner<O extends OptionDefinition, T>
);
}
prepareOptionsForParsing(_globalOptions, argParser);

if (experimentalCompletionCommand) {
addCommand(CompletionCommand());
}
}

/// The global option definitions.
List<O> 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;
Expand Down
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);
}
}

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');
}
}
}