Skip to content

Commit 3c4a09a

Browse files
feat: Experimental command line completion support (#65)
This is 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 is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Adds an experimental "completion" command to generate Completely-format shell completions (supports Bash). - Can emit output to stdout or a file and lets you override the executable name. - The completion includes global options, command hierarchy, subcommands, and option value hints. - Command is opt-in via a new experimental flag when launching the CLI. - **Documentation** - New README describing prerequisites, installation, generation, installation/updating of the Bash completion script. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 4c5cc3a commit 3c4a09a

4 files changed

Lines changed: 343 additions & 4 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
## Command line completion for scloud
2+
3+
There is now an experimental feature to generate and install command line
4+
completion in bash for commands using `BetterCommandRunner`.
5+
6+
### Prerequisites
7+
8+
This requires the tool `completely`, which also requires ruby to be installed.
9+
10+
https://github.com/bashly-framework/completely
11+
12+
```sh
13+
gem install completely
14+
```
15+
16+
or with homebrew:
17+
18+
```sh
19+
brew install brew-gem
20+
brew gem install completely
21+
```
22+
23+
### Activate
24+
25+
Construct `BetterCommandRunner` with the flag `experimentalCompletionCommand`
26+
set to `true`.
27+
28+
### Install completion
29+
30+
When the `completely` tool is installed, run e.g:
31+
32+
```sh
33+
my-command completion -f completely.yaml
34+
completely generate completely.yaml completely.bash
35+
mkdir -p ~/.local/share/bash-completion/completions
36+
cp completely.bash ~/.local/share/bash-completion/completions/my-command.bash
37+
```
38+
39+
This will write the completions script to `~/.local/share/bash-completion/completions/`,
40+
where bash picks it up automatically on start.
41+
42+
In order to update the completions in the current bash shell, run:
43+
44+
```sh
45+
exec bash
46+
```
47+
48+
For end users, the generated bash script can be distributed as a file for them
49+
to install directly.

‎packages/cli_tools/lib/src/better_command_runner/better_command.dart‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,12 @@ abstract class BetterCommand<O extends OptionDefinition, T> extends Command<T> {
7878
}
7979

8080
@override
81-
BetterCommand<dynamic, T>? get parent =>
82-
super.parent as BetterCommand<dynamic, T>?;
81+
BetterCommand<OptionDefinition, T>? get parent =>
82+
super.parent as BetterCommand<OptionDefinition, T>?;
8383

8484
@override
85-
BetterCommandRunner<dynamic, T>? get runner =>
86-
super.runner as BetterCommandRunner<dynamic, T>?;
85+
BetterCommandRunner<OptionDefinition, T>? get runner =>
86+
super.runner as BetterCommandRunner<OptionDefinition, T>?;
8787

8888
@override
8989
ArgParser get argParser => _argParser;

‎packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import 'package:args/args.dart';
55
import 'package:args/command_runner.dart';
66
import 'package:config/config.dart';
77

8+
import 'completion_command.dart';
9+
810
/// A function type for executing code before running a command.
911
typedef OnBeforeRunCommand = Future<void> Function(BetterCommandRunner runner);
1012

@@ -146,6 +148,7 @@ class BetterCommandRunner<O extends OptionDefinition, T>
146148
final OnBeforeRunCommand? onBeforeRunCommand,
147149
final OnAnalyticsEvent? onAnalyticsEvent,
148150
final int? wrapTextColumn,
151+
final bool experimentalCompletionCommand = false,
149152
final List<O>? globalOptions,
150153
final Map<String, String>? env,
151154
}) : _messageOutput = messageOutput,
@@ -170,8 +173,15 @@ class BetterCommandRunner<O extends OptionDefinition, T>
170173
);
171174
}
172175
prepareOptionsForParsing(_globalOptions, argParser);
176+
177+
if (experimentalCompletionCommand) {
178+
addCommand(CompletionCommand());
179+
}
173180
}
174181

182+
/// The global option definitions.
183+
List<O> get globalOptions => _globalOptions;
184+
175185
/// The [MessageOutput] for the command runner.
176186
/// It is also used for the commands unless they have their own.
177187
MessageOutput? get messageOutput => _messageOutput;
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import 'dart:io' show IOSink, stdout;
2+
3+
import 'package:args/command_runner.dart' show Command;
4+
import 'package:config/config.dart';
5+
6+
import 'better_command.dart';
7+
import 'better_command_runner.dart';
8+
9+
enum CompletionTarget {
10+
completely,
11+
}
12+
13+
enum CompletionOption<V extends Object> implements OptionDefinition<V> {
14+
target(EnumOption(
15+
enumParser: EnumParser(CompletionTarget.values),
16+
argName: 'target',
17+
argAbbrev: 't',
18+
defaultsTo: CompletionTarget.completely,
19+
)),
20+
execName(StringOption(
21+
argName: 'exec-name',
22+
argAbbrev: 'e',
23+
helpText: 'Override the name of the executable',
24+
)),
25+
file(FileOption(
26+
argName: 'file',
27+
argAbbrev: 'f',
28+
helpText: 'Write the specification to a file instead of stdout',
29+
));
30+
31+
const CompletionOption(this.option);
32+
33+
@override
34+
final ConfigOptionBase<V> option;
35+
}
36+
37+
class CompletionCommand<T> extends BetterCommand<CompletionOption, T> {
38+
CompletionCommand() : super(options: CompletionOption.values);
39+
40+
@override
41+
String get name => 'completion';
42+
43+
@override
44+
String get description => 'Generate a command line completion specification';
45+
46+
@override
47+
Future<T> runWithConfig(
48+
final Configuration<CompletionOption> commandConfig) async {
49+
final target = commandConfig.value(CompletionOption.target);
50+
final execName = commandConfig.optionalValue(CompletionOption.execName);
51+
final file = commandConfig.optionalValue(CompletionOption.file);
52+
53+
final betterRunner = runner;
54+
if (betterRunner == null) {
55+
throw Exception('BetterCommandRunner not set in the completion command');
56+
}
57+
58+
final usage = UsageRepresentation.compile(
59+
betterRunner,
60+
execNameOverride: execName,
61+
);
62+
63+
final IOSink out = file?.openWrite() ?? stdout;
64+
65+
switch (target) {
66+
case CompletionTarget.completely:
67+
CompletelyYamlGenerator().generate(out, usage);
68+
break;
69+
}
70+
71+
if (file != null) {
72+
await out.flush();
73+
await out.close();
74+
}
75+
return null as T;
76+
}
77+
}
78+
79+
class CommandUsage<O extends OptionDefinition> {
80+
final List<String> commandSequence;
81+
final List<O> options;
82+
final List<CommandUsage> subcommands;
83+
84+
CommandUsage({
85+
required this.commandSequence,
86+
required this.options,
87+
required this.subcommands,
88+
});
89+
90+
String get command => commandSequence.last;
91+
}
92+
93+
abstract class UsageRepresentation {
94+
/// Compiles a usage representation tree for all subcommands and options
95+
/// for a command runner.
96+
static CommandUsage compile(
97+
final BetterCommandRunner runner, {
98+
final String? execNameOverride,
99+
}) {
100+
return _generateForCommand(
101+
[execNameOverride ?? runner.executableName],
102+
_filterOptions(runner.globalOptions),
103+
runner.commands.values,
104+
const [],
105+
);
106+
}
107+
108+
static CommandUsage _generateForCommand(
109+
final List<String> commandSequence,
110+
final List<OptionDefinition> globalOptions,
111+
final Iterable<Command> subcommands,
112+
final List<OptionDefinition> options,
113+
) {
114+
final validOptions = [...globalOptions, ..._filterOptions(options)];
115+
final validSubcommands =
116+
subcommands.whereType<BetterCommand>().where((final c) => !c.hidden);
117+
118+
return CommandUsage(
119+
commandSequence: commandSequence,
120+
options: validOptions,
121+
subcommands: validSubcommands
122+
.map((final subcommand) => _generateForCommand(
123+
[...commandSequence, subcommand.name],
124+
globalOptions,
125+
subcommand.subcommands.values,
126+
subcommand.options,
127+
))
128+
.toList(),
129+
);
130+
}
131+
132+
static List<OptionDefinition> _filterOptions(
133+
final List<OptionDefinition> options,
134+
) {
135+
return options
136+
.where((final o) => !o.option.hide)
137+
.where((final o) => o.option.argName != null || o.option.argPos != null)
138+
.toList();
139+
}
140+
}
141+
142+
/// Interface for generating a usage representation for a command.
143+
abstract interface class UsageRepresentationGenerator {
144+
void generate(
145+
final IOSink out,
146+
final CommandUsage usage,
147+
);
148+
}
149+
150+
/// Generates usage representation for a command in the YAML format
151+
/// of the `completely` tool.
152+
/// https://github.com/bashly-framework/completely
153+
class CompletelyYamlGenerator implements UsageRepresentationGenerator {
154+
@override
155+
void generate(
156+
final IOSink out,
157+
final CommandUsage usage,
158+
) {
159+
if (usage.subcommands.isEmpty && usage.options.isEmpty) {
160+
return;
161+
}
162+
163+
out.writeln('${usage.commandSequence.join(' ')}:');
164+
165+
for (final subcommand in usage.subcommands) {
166+
out.writeln(' - ${subcommand.command}');
167+
}
168+
169+
_generateCompletelyForOptions(out, usage);
170+
out.writeln();
171+
172+
for (final subcommand in usage.subcommands) {
173+
generate(out, subcommand);
174+
}
175+
}
176+
177+
static void _generateCompletelyForOptions(
178+
final IOSink out,
179+
final CommandUsage usage,
180+
) {
181+
// options
182+
for (final option in usage.options) {
183+
if (option.option.argName != null) {
184+
out.writeln(' - --${option.option.argName}');
185+
186+
if (option.option case final FlagOption flagOption) {
187+
if (flagOption.negatable && !flagOption.hideNegatedUsage) {
188+
out.writeln(' - --no-${flagOption.argName}');
189+
}
190+
}
191+
}
192+
193+
if (option.option.argAbbrev != null) {
194+
out.writeln(' - -${option.option.argAbbrev}');
195+
}
196+
197+
if (option.option.argPos == 0) {
198+
final values = _getOptionValues(option.option);
199+
if (values.isNotEmpty) {
200+
_generateCompletelyForOptionValues(out, values);
201+
}
202+
}
203+
// can't currently complete for positional options after the first one
204+
}
205+
206+
// value completions for each option
207+
for (final option in usage.options) {
208+
_generateCompletelyForOption(out, usage.commandSequence, option.option);
209+
}
210+
}
211+
212+
static void _generateCompletelyForOption(
213+
final IOSink out,
214+
final List<String> commandSequence,
215+
final ConfigOptionBase option,
216+
) {
217+
if (option is FlagOption) {
218+
return;
219+
}
220+
221+
if (option.argName case final String argName) {
222+
_generateCompletelyForArgNameOption(
223+
out,
224+
commandSequence,
225+
option,
226+
' --$argName',
227+
);
228+
}
229+
if (option.argAbbrev case final String argAbbrev) {
230+
_generateCompletelyForArgNameOption(
231+
out,
232+
commandSequence,
233+
option,
234+
' -$argAbbrev',
235+
);
236+
}
237+
}
238+
239+
static void _generateCompletelyForArgNameOption(
240+
final IOSink out,
241+
final List<String> commandSequence,
242+
final ConfigOptionBase option,
243+
final String argName,
244+
) {
245+
final values = _getOptionValues(option);
246+
if (values.isNotEmpty) {
247+
out.writeln('${commandSequence.join(' ')}*$argName:');
248+
_generateCompletelyForOptionValues(out, values);
249+
}
250+
}
251+
252+
static List<String> _getOptionValues(
253+
final ConfigOptionBase option,
254+
) {
255+
if (option.allowedValues case final List allowedValues) {
256+
return allowedValues.map(option.valueParser.format).toList();
257+
}
258+
259+
switch (option.option) {
260+
case EnumOption():
261+
final enumParser = option.option.valueParser as EnumParser;
262+
return enumParser.enumValues.map(enumParser.format).toList();
263+
case FileOption():
264+
return ['<file>'];
265+
case DirOption():
266+
return ['<directory>'];
267+
default:
268+
return [];
269+
}
270+
}
271+
272+
static void _generateCompletelyForOptionValues(
273+
final IOSink out,
274+
final Iterable values,
275+
) {
276+
for (final value in values) {
277+
out.writeln(' - $value');
278+
}
279+
}
280+
}

0 commit comments

Comments
 (0)