Skip to content

Commit aada678

Browse files
authored
feat: handle completion request (#17)
1 parent aa086c8 commit aada678

File tree

12 files changed

+581
-21
lines changed

12 files changed

+581
-21
lines changed

lib/cli_completion.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ library cli_completion;
44

55
export 'src/command_runner/commands/commands.dart';
66
export 'src/command_runner/completion_command_runner.dart';
7+
export 'src/handling/completion_result.dart';
8+
export 'src/handling/completion_state.dart';

lib/src/command_runner/commands/handle_completion_command.dart

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import 'dart:async';
22

33
import 'package:args/command_runner.dart';
44
import 'package:cli_completion/src/command_runner/completion_command_runner.dart';
5-
5+
import 'package:cli_completion/src/handling/completion_state.dart';
6+
import 'package:cli_completion/src/handling/parser.dart';
67
import 'package:mason_logger/mason_logger.dart';
78

89
/// {@template handle_completion_request_command}
@@ -34,12 +35,27 @@ class HandleCompletionRequestCommand<T> extends Command<T> {
3435
final Logger logger;
3536

3637
@override
37-
FutureOr<T>? run() {
38-
logger
39-
..info('USA')
40-
..info('Brazil')
41-
..info('Netherlands');
38+
CompletionCommandRunner<T> get runner {
39+
return super.runner! as CompletionCommandRunner<T>;
40+
}
4241

42+
@override
43+
FutureOr<T>? run() {
44+
try {
45+
final completionState = CompletionState.fromEnvironment(
46+
runner.environmentOverride,
47+
);
48+
if (completionState == null) {
49+
return null;
50+
}
51+
52+
final result = CompletionParser(completionState).parse();
53+
54+
runner.renderCompletionResult(result);
55+
} on Exception {
56+
// Do not output any Exception here, since even error messages are
57+
// interpreted as completion suggestions
58+
}
4359
return null;
4460
}
4561
}

lib/src/command_runner/completion_command_runner.dart

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import 'dart:async';
22

33
import 'package:args/args.dart';
44
import 'package:args/command_runner.dart';
5-
import 'package:cli_completion/src/command_runner/commands/commands.dart';
5+
import 'package:cli_completion/cli_completion.dart';
66
import 'package:cli_completion/src/exceptions.dart';
77
import 'package:cli_completion/src/install/completion_installation.dart';
88
import 'package:cli_completion/src/system_shell.dart';
@@ -35,10 +35,11 @@ abstract class CompletionCommandRunner<T> extends CommandRunner<T> {
3535
final Logger completionInstallationLogger = Logger();
3636

3737
/// Environment map which can be overridden for testing purposes.
38-
@visibleForTesting
38+
@internal
3939
Map<String, String>? environmentOverride;
4040

41-
SystemShell? get _systemShell =>
41+
/// The [SystemShell] used to determine the current shell.
42+
SystemShell? get systemShell =>
4243
SystemShell.current(environmentOverride: environmentOverride);
4344

4445
CompletionInstallation? _completionInstallation;
@@ -48,7 +49,7 @@ abstract class CompletionCommandRunner<T> extends CommandRunner<T> {
4849
var completionInstallation = _completionInstallation;
4950

5051
completionInstallation ??= CompletionInstallation.fromSystemShell(
51-
systemShell: _systemShell,
52+
systemShell: systemShell,
5253
logger: completionInstallationLogger,
5354
);
5455

@@ -81,4 +82,19 @@ abstract class CompletionCommandRunner<T> extends CommandRunner<T> {
8182
completionInstallationLogger.err(e.toString());
8283
}
8384
}
85+
86+
/// Renders a [CompletionResult] into the current system shell.
87+
///
88+
/// This is called after a completion request (sent by a shell function) is
89+
/// parsed and the output is ready to be displayed.
90+
///
91+
/// Override this to intercept and customize the general
92+
/// output of the completions.
93+
void renderCompletionResult(CompletionResult completionResult) {
94+
final systemShell = this.systemShell;
95+
if (systemShell == null) {
96+
return;
97+
}
98+
completionResult.render(completionLogger, systemShell);
99+
}
84100
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import 'package:cli_completion/src/system_shell.dart';
2+
import 'package:mason_logger/mason_logger.dart';
3+
import 'package:meta/meta.dart';
4+
5+
/// {@template completion_result}
6+
/// Describes the result of a completion handling process.
7+
/// {@endtemplate}
8+
///
9+
/// Generated after parsing a completion request from the shell, it is
10+
/// responsible to contain the information to be sent back to the shell
11+
/// (via stdout) including suggestions and its metadata (description).
12+
///
13+
/// See also:
14+
/// - [ValueCompletionResult]
15+
/// - [EmptyCompletionResult]
16+
@immutable
17+
abstract class CompletionResult {
18+
/// Creates a [CompletionResult] that contains predefined suggestions.
19+
const factory CompletionResult.fromMap(Map<String, String?> completions) =
20+
ValueCompletionResult._fromMap;
21+
22+
const CompletionResult._();
23+
24+
/// Render the completion suggestions on the [shell].
25+
void render(Logger logger, SystemShell shell);
26+
}
27+
28+
/// {@template value_completion_result}
29+
/// A [CompletionResult] that contains completion suggestions.
30+
/// {@endtemplate}
31+
class ValueCompletionResult extends CompletionResult {
32+
/// {@macro value_completion_result}
33+
ValueCompletionResult()
34+
: _completions = <String, String?>{},
35+
super._();
36+
37+
/// Create a [ValueCompletionResult] with predefined completion suggestions
38+
///
39+
/// Since this can be const, calling "addSuggestion" on instances created
40+
/// with this constructor may result in runtime exceptions.
41+
/// Use [CompletionResult.fromMap] instead.
42+
const ValueCompletionResult._fromMap(this._completions) : super._();
43+
44+
/// A map of completion suggestions to their descriptions.
45+
final Map<String, String?> _completions;
46+
47+
/// Adds an entry to the current pool of suggestions. Overrides any previous
48+
/// entry with the same [completion].
49+
void addSuggestion(String completion, [String? description]) {
50+
_completions[completion] = description;
51+
}
52+
53+
@override
54+
void render(Logger logger, SystemShell shell) {
55+
for (final entry in _completions.entries) {
56+
switch (shell) {
57+
case SystemShell.zsh:
58+
// On zsh, colon acts as delimitation between a suggestion and its
59+
// description. Any literal colon should be escaped.
60+
final suggestion = entry.key.replaceAll(':', r'\:');
61+
final description = entry.value?.replaceAll(':', r'\:');
62+
63+
logger.info(
64+
'$suggestion${description != null ? ':$description' : ''}',
65+
);
66+
break;
67+
case SystemShell.bash:
68+
logger.info(entry.key);
69+
break;
70+
}
71+
}
72+
}
73+
}
74+
75+
/// {@template no_completion_result}
76+
/// A [CompletionResult] that indicates that no completion suggestions should be
77+
/// displayed.
78+
/// {@endtemplate}
79+
class EmptyCompletionResult extends CompletionResult {
80+
/// {@macro no_completion_result}
81+
const EmptyCompletionResult() : super._();
82+
83+
@override
84+
void render(Logger logger, SystemShell shell) {}
85+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import 'dart:io';
2+
3+
import 'package:equatable/equatable.dart';
4+
5+
import 'package:meta/meta.dart';
6+
7+
/// {@template completion_state}
8+
/// A description of the state of a user input when requesting completion.
9+
/// {@endtemplate}
10+
@immutable
11+
class CompletionState extends Equatable {
12+
/// {@macro completion_state}
13+
@visibleForTesting
14+
const CompletionState({
15+
required this.cword,
16+
required this.cpoint,
17+
required this.cline,
18+
required this.args,
19+
});
20+
21+
/// The index of the word being completed
22+
final int cword;
23+
24+
/// The position of the cursor upon completion request
25+
final int cpoint;
26+
27+
/// The user prompt that is being completed
28+
final String cline;
29+
30+
/// The arguments that were passed by the user so far
31+
final Iterable<String> args;
32+
33+
@override
34+
bool? get stringify => true;
35+
36+
/// Creates a [CompletionState] from the environment variables set by the
37+
/// shell script.
38+
static CompletionState? fromEnvironment([
39+
Map<String, String>? environmentOverride,
40+
]) {
41+
final environment = environmentOverride ?? Platform.environment;
42+
final cword = environment['COMP_CWORD'];
43+
final cpoint = environment['COMP_POINT'];
44+
final compLine = environment['COMP_LINE'];
45+
46+
if (cword == null || cpoint == null || compLine == null) {
47+
return null;
48+
}
49+
50+
final cwordInt = int.tryParse(cword);
51+
final cpointInt = int.tryParse(cpoint);
52+
53+
if (cwordInt == null || cpointInt == null) {
54+
return null;
55+
}
56+
57+
final args = compLine.trimLeft().split(' ').skip(1);
58+
59+
return CompletionState(
60+
cword: cwordInt,
61+
cpoint: cpointInt,
62+
cline: compLine,
63+
args: args,
64+
);
65+
}
66+
67+
@override
68+
List<Object?> get props => [cword, cpoint, cline, args];
69+
}

lib/src/handling/parser.dart

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import 'package:args/args.dart';
2+
import 'package:cli_completion/cli_completion.dart';
3+
4+
/// {@template completion_parser}
5+
/// The workhorse of the completion system.
6+
///
7+
/// It is responsible for discovering the possible completions given a
8+
/// [CompletionState].
9+
/// {@endtemplate}
10+
class CompletionParser {
11+
/// {@macro completion_parser}
12+
CompletionParser(this._state);
13+
14+
final CompletionState _state;
15+
16+
/// Do not complete if there is an argument terminator in the middle of
17+
/// the sentence
18+
bool _containsArgumentTerminator() {
19+
final args = _state.args;
20+
return args.isNotEmpty && args.take(args.length - 1).contains('--');
21+
}
22+
23+
/// Parse the given [CompletionState] into a [CompletionResult] given the
24+
/// structure of commands and options declared by the CLIs [ArgParser].
25+
CompletionResult parse() {
26+
if (_containsArgumentTerminator()) {
27+
return const EmptyCompletionResult();
28+
}
29+
30+
// todo(renancaraujo): actually suggest useful things
31+
return const CompletionResult.fromMap({
32+
'Brazil': 'A country',
33+
'USA': 'Another country',
34+
'Netherlands': 'Guess what: a country',
35+
'Portugal': 'Yep, a country'
36+
});
37+
}
38+
}

lib/src/system_shell.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ enum SystemShell {
1111
bash;
1212

1313
/// Identifies the current shell.
14+
///
15+
/// Based on https://stackoverflow.com/a/3327022
1416
static SystemShell? current({
1517
Map<String, String>? environmentOverride,
1618
}) {

pubspec.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ environment:
88

99
dependencies:
1010
args: ^2.3.1
11+
equatable: ^2.0.5
1112
mason_logger: ^0.2.2
1213
meta: ^1.8.0
1314
path: ^1.8.2

test/src/command_runner/commands/handle_completion_command_test.dart

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,19 +36,46 @@ void main() {
3636
);
3737
});
3838

39-
group('when run', () {
40-
test('with no args', () async {
39+
group('run', () {
40+
test('should display completion', () async {
41+
final output = StringBuffer();
42+
when(() {
43+
commandRunner.completionLogger.info(any());
44+
}).thenAnswer((invocation) {
45+
output.writeln(invocation.positionalArguments.first);
46+
});
47+
48+
commandRunner.environmentOverride = {
49+
'SHELL': '/foo/bar/zsh',
50+
'COMP_LINE': 'example_cli some_command --discrete foo',
51+
'COMP_POINT': '12',
52+
'COMP_CWORD': '2'
53+
};
54+
await commandRunner.run(['completion']);
55+
56+
expect(output.toString(), r'''
57+
Brazil:A country
58+
USA:Another country
59+
Netherlands:Guess what\: a country
60+
Portugal:Yep, a country
61+
''');
62+
});
63+
64+
test('should supress error messages', () async {
65+
final output = StringBuffer();
66+
when(() {
67+
commandRunner.completionLogger.info(any());
68+
}).thenThrow(Exception('oh no'));
69+
70+
commandRunner.environmentOverride = {
71+
'SHELL': '/foo/bar/zsh',
72+
'COMP_LINE': 'example_cli some_command --discrete foo',
73+
'COMP_POINT': '12',
74+
'COMP_CWORD': '2'
75+
};
4176
await commandRunner.run(['completion']);
4277

43-
verify(() {
44-
commandRunner.completionLogger.info('USA');
45-
}).called(1);
46-
verify(() {
47-
commandRunner.completionLogger.info('Brazil');
48-
}).called(1);
49-
verify(() {
50-
commandRunner.completionLogger.info('Netherlands');
51-
}).called(1);
78+
expect(output.toString(), '');
5279
});
5380
});
5481
});

0 commit comments

Comments
 (0)