Skip to content

Commit

Permalink
feat: handle completion request (#17)
Browse files Browse the repository at this point in the history
  • Loading branch information
renancaraujo committed Nov 22, 2022
1 parent aa086c8 commit aada678
Show file tree
Hide file tree
Showing 12 changed files with 581 additions and 21 deletions.
2 changes: 2 additions & 0 deletions lib/cli_completion.dart
Expand Up @@ -4,3 +4,5 @@ library cli_completion;

export 'src/command_runner/commands/commands.dart';
export 'src/command_runner/completion_command_runner.dart';
export 'src/handling/completion_result.dart';
export 'src/handling/completion_state.dart';
28 changes: 22 additions & 6 deletions lib/src/command_runner/commands/handle_completion_command.dart
Expand Up @@ -2,7 +2,8 @@ import 'dart:async';

import 'package:args/command_runner.dart';
import 'package:cli_completion/src/command_runner/completion_command_runner.dart';

import 'package:cli_completion/src/handling/completion_state.dart';
import 'package:cli_completion/src/handling/parser.dart';
import 'package:mason_logger/mason_logger.dart';

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

@override
FutureOr<T>? run() {
logger
..info('USA')
..info('Brazil')
..info('Netherlands');
CompletionCommandRunner<T> get runner {
return super.runner! as CompletionCommandRunner<T>;
}

@override
FutureOr<T>? run() {
try {
final completionState = CompletionState.fromEnvironment(
runner.environmentOverride,
);
if (completionState == null) {
return null;
}

final result = CompletionParser(completionState).parse();

runner.renderCompletionResult(result);
} on Exception {
// Do not output any Exception here, since even error messages are
// interpreted as completion suggestions
}
return null;
}
}
24 changes: 20 additions & 4 deletions lib/src/command_runner/completion_command_runner.dart
Expand Up @@ -2,7 +2,7 @@ import 'dart:async';

import 'package:args/args.dart';
import 'package:args/command_runner.dart';
import 'package:cli_completion/src/command_runner/commands/commands.dart';
import 'package:cli_completion/cli_completion.dart';
import 'package:cli_completion/src/exceptions.dart';
import 'package:cli_completion/src/install/completion_installation.dart';
import 'package:cli_completion/src/system_shell.dart';
Expand Down Expand Up @@ -35,10 +35,11 @@ abstract class CompletionCommandRunner<T> extends CommandRunner<T> {
final Logger completionInstallationLogger = Logger();

/// Environment map which can be overridden for testing purposes.
@visibleForTesting
@internal
Map<String, String>? environmentOverride;

SystemShell? get _systemShell =>
/// The [SystemShell] used to determine the current shell.
SystemShell? get systemShell =>
SystemShell.current(environmentOverride: environmentOverride);

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

completionInstallation ??= CompletionInstallation.fromSystemShell(
systemShell: _systemShell,
systemShell: systemShell,
logger: completionInstallationLogger,
);

Expand Down Expand Up @@ -81,4 +82,19 @@ abstract class CompletionCommandRunner<T> extends CommandRunner<T> {
completionInstallationLogger.err(e.toString());
}
}

/// Renders a [CompletionResult] into the current system shell.
///
/// This is called after a completion request (sent by a shell function) is
/// parsed and the output is ready to be displayed.
///
/// Override this to intercept and customize the general
/// output of the completions.
void renderCompletionResult(CompletionResult completionResult) {
final systemShell = this.systemShell;
if (systemShell == null) {
return;
}
completionResult.render(completionLogger, systemShell);
}
}
85 changes: 85 additions & 0 deletions lib/src/handling/completion_result.dart
@@ -0,0 +1,85 @@
import 'package:cli_completion/src/system_shell.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:meta/meta.dart';

/// {@template completion_result}
/// Describes the result of a completion handling process.
/// {@endtemplate}
///
/// Generated after parsing a completion request from the shell, it is
/// responsible to contain the information to be sent back to the shell
/// (via stdout) including suggestions and its metadata (description).
///
/// See also:
/// - [ValueCompletionResult]
/// - [EmptyCompletionResult]
@immutable
abstract class CompletionResult {
/// Creates a [CompletionResult] that contains predefined suggestions.
const factory CompletionResult.fromMap(Map<String, String?> completions) =
ValueCompletionResult._fromMap;

const CompletionResult._();

/// Render the completion suggestions on the [shell].
void render(Logger logger, SystemShell shell);
}

/// {@template value_completion_result}
/// A [CompletionResult] that contains completion suggestions.
/// {@endtemplate}
class ValueCompletionResult extends CompletionResult {
/// {@macro value_completion_result}
ValueCompletionResult()
: _completions = <String, String?>{},
super._();

/// Create a [ValueCompletionResult] with predefined completion suggestions
///
/// Since this can be const, calling "addSuggestion" on instances created
/// with this constructor may result in runtime exceptions.
/// Use [CompletionResult.fromMap] instead.
const ValueCompletionResult._fromMap(this._completions) : super._();

/// A map of completion suggestions to their descriptions.
final Map<String, String?> _completions;

/// Adds an entry to the current pool of suggestions. Overrides any previous
/// entry with the same [completion].
void addSuggestion(String completion, [String? description]) {
_completions[completion] = description;
}

@override
void render(Logger logger, SystemShell shell) {
for (final entry in _completions.entries) {
switch (shell) {
case SystemShell.zsh:
// On zsh, colon acts as delimitation between a suggestion and its
// description. Any literal colon should be escaped.
final suggestion = entry.key.replaceAll(':', r'\:');
final description = entry.value?.replaceAll(':', r'\:');

logger.info(
'$suggestion${description != null ? ':$description' : ''}',
);
break;
case SystemShell.bash:
logger.info(entry.key);
break;
}
}
}
}

/// {@template no_completion_result}
/// A [CompletionResult] that indicates that no completion suggestions should be
/// displayed.
/// {@endtemplate}
class EmptyCompletionResult extends CompletionResult {
/// {@macro no_completion_result}
const EmptyCompletionResult() : super._();

@override
void render(Logger logger, SystemShell shell) {}
}
69 changes: 69 additions & 0 deletions lib/src/handling/completion_state.dart
@@ -0,0 +1,69 @@
import 'dart:io';

import 'package:equatable/equatable.dart';

import 'package:meta/meta.dart';

/// {@template completion_state}
/// A description of the state of a user input when requesting completion.
/// {@endtemplate}
@immutable
class CompletionState extends Equatable {
/// {@macro completion_state}
@visibleForTesting
const CompletionState({
required this.cword,
required this.cpoint,
required this.cline,
required this.args,
});

/// The index of the word being completed
final int cword;

/// The position of the cursor upon completion request
final int cpoint;

/// The user prompt that is being completed
final String cline;

/// The arguments that were passed by the user so far
final Iterable<String> args;

@override
bool? get stringify => true;

/// Creates a [CompletionState] from the environment variables set by the
/// shell script.
static CompletionState? fromEnvironment([
Map<String, String>? environmentOverride,
]) {
final environment = environmentOverride ?? Platform.environment;
final cword = environment['COMP_CWORD'];
final cpoint = environment['COMP_POINT'];
final compLine = environment['COMP_LINE'];

if (cword == null || cpoint == null || compLine == null) {
return null;
}

final cwordInt = int.tryParse(cword);
final cpointInt = int.tryParse(cpoint);

if (cwordInt == null || cpointInt == null) {
return null;
}

final args = compLine.trimLeft().split(' ').skip(1);

return CompletionState(
cword: cwordInt,
cpoint: cpointInt,
cline: compLine,
args: args,
);
}

@override
List<Object?> get props => [cword, cpoint, cline, args];
}
38 changes: 38 additions & 0 deletions lib/src/handling/parser.dart
@@ -0,0 +1,38 @@
import 'package:args/args.dart';
import 'package:cli_completion/cli_completion.dart';

/// {@template completion_parser}
/// The workhorse of the completion system.
///
/// It is responsible for discovering the possible completions given a
/// [CompletionState].
/// {@endtemplate}
class CompletionParser {
/// {@macro completion_parser}
CompletionParser(this._state);

final CompletionState _state;

/// Do not complete if there is an argument terminator in the middle of
/// the sentence
bool _containsArgumentTerminator() {
final args = _state.args;
return args.isNotEmpty && args.take(args.length - 1).contains('--');
}

/// Parse the given [CompletionState] into a [CompletionResult] given the
/// structure of commands and options declared by the CLIs [ArgParser].
CompletionResult parse() {
if (_containsArgumentTerminator()) {
return const EmptyCompletionResult();
}

// todo(renancaraujo): actually suggest useful things
return const CompletionResult.fromMap({
'Brazil': 'A country',
'USA': 'Another country',
'Netherlands': 'Guess what: a country',
'Portugal': 'Yep, a country'
});
}
}
2 changes: 2 additions & 0 deletions lib/src/system_shell.dart
Expand Up @@ -11,6 +11,8 @@ enum SystemShell {
bash;

/// Identifies the current shell.
///
/// Based on https://stackoverflow.com/a/3327022
static SystemShell? current({
Map<String, String>? environmentOverride,
}) {
Expand Down
1 change: 1 addition & 0 deletions pubspec.yaml
Expand Up @@ -8,6 +8,7 @@ environment:

dependencies:
args: ^2.3.1
equatable: ^2.0.5
mason_logger: ^0.2.2
meta: ^1.8.0
path: ^1.8.2
Expand Down
Expand Up @@ -36,19 +36,46 @@ void main() {
);
});

group('when run', () {
test('with no args', () async {
group('run', () {
test('should display completion', () async {
final output = StringBuffer();
when(() {
commandRunner.completionLogger.info(any());
}).thenAnswer((invocation) {
output.writeln(invocation.positionalArguments.first);
});

commandRunner.environmentOverride = {
'SHELL': '/foo/bar/zsh',
'COMP_LINE': 'example_cli some_command --discrete foo',
'COMP_POINT': '12',
'COMP_CWORD': '2'
};
await commandRunner.run(['completion']);

expect(output.toString(), r'''
Brazil:A country
USA:Another country
Netherlands:Guess what\: a country
Portugal:Yep, a country
''');
});

test('should supress error messages', () async {
final output = StringBuffer();
when(() {
commandRunner.completionLogger.info(any());
}).thenThrow(Exception('oh no'));

commandRunner.environmentOverride = {
'SHELL': '/foo/bar/zsh',
'COMP_LINE': 'example_cli some_command --discrete foo',
'COMP_POINT': '12',
'COMP_CWORD': '2'
};
await commandRunner.run(['completion']);

verify(() {
commandRunner.completionLogger.info('USA');
}).called(1);
verify(() {
commandRunner.completionLogger.info('Brazil');
}).called(1);
verify(() {
commandRunner.completionLogger.info('Netherlands');
}).called(1);
expect(output.toString(), '');
});
});
});
Expand Down

0 comments on commit aada678

Please sign in to comment.