Skip to content

Commit f99109d

Browse files
committed
feat: Experimental command line completion support
1 parent 4c5cc3a commit f99109d

4 files changed

Lines changed: 336 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: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
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+
Null runWithConfig(final Configuration<CompletionOption> commandConfig) {
48+
final target = commandConfig.value(CompletionOption.target);
49+
final execName = commandConfig.optionalValue(CompletionOption.execName);
50+
final file = commandConfig.optionalValue(CompletionOption.file);
51+
52+
final betterRunner = runner;
53+
if (betterRunner == null) {
54+
throw Exception('BetterCommandRunner not set in the completion command');
55+
}
56+
57+
final usage = UsageRepresentation.compile(
58+
betterRunner,
59+
execNameOverride: execName,
60+
);
61+
62+
final IOSink out = file?.openWrite() ?? stdout;
63+
64+
switch (target) {
65+
case CompletionTarget.completely:
66+
CompletelyYamlGenerator().generate(out, usage);
67+
break;
68+
}
69+
}
70+
}
71+
72+
class CommandUsage<O extends OptionDefinition> {
73+
final List<String> commandSequence;
74+
final List<O> options;
75+
final List<CommandUsage> subcommands;
76+
77+
CommandUsage({
78+
required this.commandSequence,
79+
required this.options,
80+
required this.subcommands,
81+
});
82+
83+
String get command => commandSequence.last;
84+
}
85+
86+
abstract class UsageRepresentation {
87+
/// Compiles a usage representation tree for all subcommands and options
88+
/// for a command runner.
89+
static CommandUsage compile(
90+
final BetterCommandRunner runner, {
91+
final String? execNameOverride,
92+
}) {
93+
return _generateForCommand(
94+
[execNameOverride ?? runner.executableName],
95+
_filterOptions(runner.globalOptions),
96+
runner.commands.values,
97+
const [],
98+
);
99+
}
100+
101+
static CommandUsage _generateForCommand(
102+
final List<String> commandSequence,
103+
final List<OptionDefinition> globalOptions,
104+
final Iterable<Command> subcommands,
105+
final List<OptionDefinition> options,
106+
) {
107+
final validOptions = [...globalOptions, ..._filterOptions(options)];
108+
final validSubcommands =
109+
subcommands.whereType<BetterCommand>().where((final c) => !c.hidden);
110+
111+
return CommandUsage(
112+
commandSequence: commandSequence,
113+
options: validOptions,
114+
subcommands: validSubcommands
115+
.map((final subcommand) => _generateForCommand(
116+
[...commandSequence, subcommand.name],
117+
globalOptions,
118+
subcommand.subcommands.values,
119+
subcommand.options,
120+
))
121+
.toList(),
122+
);
123+
}
124+
125+
static List<OptionDefinition> _filterOptions(
126+
final List<OptionDefinition> options,
127+
) {
128+
return options
129+
.where((final o) => !o.option.hide)
130+
.where((final o) => o.option.argName != null || o.option.argPos != null)
131+
.toList();
132+
}
133+
}
134+
135+
/// Interface for generating a usage representation for a command.
136+
abstract interface class UsageRepresentationGenerator {
137+
void generate(
138+
final IOSink out,
139+
final CommandUsage usage,
140+
);
141+
}
142+
143+
/// Generates usage representation for a command in the YAML format
144+
/// of the `completely` tool.
145+
/// https://github.com/bashly-framework/completely
146+
class CompletelyYamlGenerator implements UsageRepresentationGenerator {
147+
@override
148+
void generate(
149+
final IOSink out,
150+
final CommandUsage usage,
151+
) {
152+
if (usage.subcommands.isEmpty && usage.options.isEmpty) {
153+
return;
154+
}
155+
156+
out.writeln('${usage.commandSequence.join(' ')}:');
157+
158+
for (final subcommand in usage.subcommands) {
159+
out.writeln('- ${subcommand.command}');
160+
}
161+
162+
_generateCompletelyForOptions(out, usage);
163+
out.writeln();
164+
165+
for (final subcommand in usage.subcommands) {
166+
generate(out, subcommand);
167+
}
168+
}
169+
170+
static void _generateCompletelyForOptions(
171+
final IOSink out,
172+
final CommandUsage usage,
173+
) {
174+
// options
175+
for (final option in usage.options) {
176+
if (option.option.argName != null) {
177+
out.writeln('- --${option.option.argName}');
178+
179+
if (option.option case final FlagOption flagOption) {
180+
if (flagOption.negatable && !flagOption.hideNegatedUsage) {
181+
out.writeln('- --no-${flagOption.argName}');
182+
}
183+
}
184+
}
185+
186+
if (option.option.argAbbrev != null) {
187+
out.writeln('- -${option.option.argAbbrev}');
188+
}
189+
190+
if (option.option.argPos == 0) {
191+
final values = _getOptionValues(option.option);
192+
if (values.isNotEmpty) {
193+
_generateCompletelyForOptionValues(out, values);
194+
}
195+
}
196+
// can't currently complete for positional options after the first one
197+
}
198+
199+
// value completions for each option
200+
for (final option in usage.options) {
201+
_generateCompletelyForOption(out, usage.commandSequence, option.option);
202+
}
203+
}
204+
205+
static void _generateCompletelyForOption(
206+
final IOSink out,
207+
final List<String> commandSequence,
208+
final ConfigOptionBase option,
209+
) {
210+
if (option is FlagOption) {
211+
return;
212+
}
213+
214+
if (option.argName case final String argName) {
215+
_generateCompletelyForArgNameOption(
216+
out,
217+
commandSequence,
218+
option,
219+
'--$argName',
220+
);
221+
}
222+
if (option.argAbbrev case final String argAbbrev) {
223+
_generateCompletelyForArgNameOption(
224+
out,
225+
commandSequence,
226+
option,
227+
'-$argAbbrev',
228+
);
229+
}
230+
}
231+
232+
static void _generateCompletelyForArgNameOption(
233+
final IOSink out,
234+
final List<String> commandSequence,
235+
final ConfigOptionBase option,
236+
final String argName,
237+
) {
238+
final values = _getOptionValues(option);
239+
if (values.isNotEmpty) {
240+
out.writeln('${commandSequence.join(' ')}*$argName:');
241+
_generateCompletelyForOptionValues(out, values);
242+
}
243+
}
244+
245+
static List<String> _getOptionValues(
246+
final ConfigOptionBase option,
247+
) {
248+
if (option.allowedValues case final List allowedValues) {
249+
return allowedValues.map((final v) => '$v').toList();
250+
}
251+
252+
switch (option.option) {
253+
case EnumOption():
254+
final enumParser = option.option.valueParser as EnumParser;
255+
return enumParser.enumValues.map((final e) => e.name).toList();
256+
case FileOption():
257+
return ['<file>'];
258+
case DirOption():
259+
return ['<directory>'];
260+
default:
261+
return [];
262+
}
263+
}
264+
265+
static void _generateCompletelyForOptionValues(
266+
final IOSink out,
267+
final Iterable values,
268+
) {
269+
for (final value in values) {
270+
out.writeln('- $value');
271+
}
272+
}
273+
}

0 commit comments

Comments
 (0)