Skip to content

Commit

Permalink
add tab completion
Browse files Browse the repository at this point in the history
  • Loading branch information
Giuspepe committed Aug 29, 2023
1 parent a052773 commit 48b2ed2
Show file tree
Hide file tree
Showing 11 changed files with 252 additions and 26 deletions.
3 changes: 2 additions & 1 deletion sidekick/lib/sidekick.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:io';

import 'package:args/command_runner.dart';
import 'package:cli_completion/cli_completion.dart';
import 'package:meta/meta.dart';
import 'package:process/process.dart';
import 'package:sidekick/src/commands/init_command.dart';
Expand All @@ -27,7 +28,7 @@ Future<void> main(List<String> args) async {
}

@visibleForTesting
class GlobalSidekickCommandRunner extends CommandRunner {
class GlobalSidekickCommandRunner extends CompletionCommandRunner<void> {
GlobalSidekickCommandRunner({
this.processManager = const LocalProcessManager(),
}) : super('sidekick', _desc) {
Expand Down
16 changes: 16 additions & 0 deletions sidekick/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.11.0"
cli_completion:
dependency: "direct main"
description:
name: cli_completion
sha256: "595b192451285749369e99b3c2fd9a9aea7d74ed1aba61cddeb9ad8c84a44812"
url: "https://pub.dev"
source: hosted
version: "0.3.0"
cli_util:
dependency: transitive
description:
Expand Down Expand Up @@ -337,6 +345,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.0"
mason_logger:
dependency: transitive
description:
name: mason_logger
sha256: c12860ae81f0bdc5d05d7914fe473bfb294047e9dc64e8995b5753c7207e81b9
url: "https://pub.dev"
source: hosted
version: "0.2.8"
matcher:
dependency: transitive
description:
Expand Down
1 change: 1 addition & 0 deletions sidekick/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ environment:
dependencies:
acronym: ^0.5.1
args: ^2.3.2
cli_completion: ^0.3.0
dartx: ^1.0.0
dcli: '>=2.2.0 <=2.2.3'
http: '>=0.13.3 <=2.0.0'
Expand Down
60 changes: 60 additions & 0 deletions sidekick/test/tab_completion_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import 'package:sidekick_core/sidekick_core.dart';
import 'package:test/expect.dart';
import 'package:test/scaffolding.dart';

import 'util/cli_completion.dart';
import 'util/cli_runner.dart';

void main() {
/// Tests in this file install a sidekick CLI named 'dashi' globally.
/// To make sure this doesn't interfere with other tests, delete the symlink
void uninstallGlobalCli() {
// TODO use GlobalSidekickRoot.binDir when made available through updated sidekick_core dependency
final userHome = Platform.environment['HOME']!;
final sidekickBinDir = Directory('$userHome/.sidekick/bin');
// beware: sidekickBinDir.file('dashi').existsSync() always returns false because it's a link, not a file
final dashiSymLink = sidekickBinDir
.listSync()
.firstOrNullWhere((element) => element.name == 'dashi');

if (dashiSymLink != null) {
dashiSymLink.deleteSync();
}
}

setUp(uninstallGlobalCli);
tearDown(uninstallGlobalCli);

test('Prints info when tab completions are not installed', () async {
await withSidekickCli((cli) async {
final p = await cli.run([]);

final command = yellow('dashi sidekick install-global');
final expectedMessage =
'${cyan('Run')} $command ${cyan('to enable tab completion.')}';

final stdout = await p.stdoutStream().toList();
expect(stdout, contains(expectedMessage));
});
});

test('Tab prints completions', () async {
await withSidekickCli(
(cli) async {
await expectLater(
'dashi',
cli.suggests({
'dart': 'Calls dart',
'deps': 'Gets dependencies for all packages',
'clean': 'Cleans the project',
'analyze': 'Dart analyzes the whole project',
'format': 'Formats all Dart files in the repository.',
'sidekick': 'Manages the sidekick CLI',
'--help': 'Print this usage information.',
'--version': 'Print the sidekick version of this CLI.',
}),
);
},
);
});
}
1 change: 1 addition & 0 deletions sidekick/test/test_runner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,6 @@ void main() {
group('init_test', init_test.main);
group('plugins_test', plugins_test.main);
group('recompile_test', recompile_test.main);
group('tab_completion_test', update_test.main);
group('update_test', update_test.main);
}
111 changes: 111 additions & 0 deletions sidekick/test/util/cli_completion.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copied and adapted from package:cli_completion, file https://github.com/VeryGoodOpenSource/cli_completion/blob/a5b3571c03d964c08c6ce52b1cc907b2f93c0861/example/test/integration/utils.dart

import 'dart:async';
import 'dart:io';

import 'package:sidekick_test/fake_stdio.dart';
import 'package:test/test.dart';

import 'cli_runner.dart';

extension RunCompletionCommandExtension on SidekickCli {
/// Returns a matcher that matches if the tab completion of this CLI returns the given suggestions
///
/// This is done by running the hidden 'completion' command of the CLI and checking its result
Matcher suggests(Map<String, String?> suggestions, {int? whenCursorIsAt}) =>
_CliCompletionMatcher(
suggestions,
cursorIndex: whenCursorIsAt,
cli: this,
);

Future<Map<String, String?>> _runCompletionCommand(
String line, {
int? cursorIndex,
}) async {
final map = <String, String?>{};

void onWriteln([Object? object]) {
// Simulate the shell behavior of interpreting the output of the completion.
final line = object! as String;

// A regex that finds all colons, except the ones preceded by backslash
final res = line.split(RegExp(r'(?<!\\):'));

final description = res.length > 1 ? res[1] : null;

map[res.first] = description;
}

final stdout = FakeStdoutStream(onWriteln: onWriteln);

return await IOOverrides.runZoned(
stdout: () => stdout,
() async {
final environmentOverride = {
'SHELL': '/foo/bar/zsh',
..._prepareEnvForLineInput(line, cursorIndex: cursorIndex),
};
final process =
await run(['completion'], environment: environmentOverride);
final completions = await process.stdoutStream().toList();
final map = <String, String?>{};

for (final completionString in completions) {
// A regex that finds all colons, except the ones preceded by backslash
final res = completionString.split(RegExp(r'(?<!\\):'));

final description = res.length > 1 ? res[1] : null;

map[res.first] = description;
}

return map;
},
);
}
}

class _CliCompletionMatcher extends CustomMatcher {
final SidekickCli cli;

_CliCompletionMatcher(
Map<String, String?> suggestions, {
required this.cli,
this.cursorIndex,
}) : super(
'Completes with the expected suggestions',
'suggestions',
completion(suggestions),
);

final int? cursorIndex;

@override
Object? featureValueOf(dynamic line) {
if (line is! String) {
throw ArgumentError.value(line, 'line', 'must be a String');
}

return cli._runCompletionCommand(line, cursorIndex: cursorIndex);
}
}

/// Simulate the shell behavior of completing a command line.
Map<String, String> _prepareEnvForLineInput(String line, {int? cursorIndex}) {
final cpoint = cursorIndex ?? line.length;
var cword = 0;
line.split(' ').fold(0, (value, element) {
final total = value + 1 + element.length;
if (total < cpoint) {
cword++;
return total;
}
return value;
});
return {
'COMP_LINE': line,
'COMP_POINT': '$cpoint',
'COMP_CWORD': '$cword',
};
}
5 changes: 4 additions & 1 deletion sidekick/test/util/cli_runner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -135,20 +135,23 @@ class SidekickCli {
SidekickCli._(this.root, this.name);

/// Runs the CLI's entrypoint and verifies that it exits with exit code 0
Future<void> run(
Future<TestProcess> run(
List<String> args, {
Directory? workingDirectory,
Map<String, String>? environment,
}) async {
final process = await TestProcess.start(
_entrypoint.path,
args,
workingDirectory: workingDirectory?.path ?? root.path,
environment: environment,
);

if (await process.exitCode != 0) {
process.stdoutStream().listen(print);
process.stderrStream().listen(print);
}
await process.shouldExit(0);
return process;
}
}
39 changes: 27 additions & 12 deletions sidekick_core/lib/sidekick_core.dart
Original file line number Diff line number Diff line change
@@ -1,17 +1,9 @@
/// The core library for Sidekick CLIs
library sidekick_core;

import 'dart:io';

import 'package:args/args.dart';
import 'package:args/command_runner.dart';
import 'package:dartx/dartx_io.dart';
import 'package:dcli/dcli.dart';
import 'package:path/path.dart';
import 'package:pub_semver/pub_semver.dart';
import 'package:cli_completion/cli_completion.dart';
import 'package:sidekick_core/sidekick_core.dart';
import 'package:sidekick_core/src/commands/update_command.dart';
import 'package:sidekick_core/src/dart_package.dart';
import 'package:sidekick_core/src/repository.dart';
import 'package:sidekick_core/src/sidekick_context.dart';
import 'package:sidekick_core/src/version_checker.dart';

Expand Down Expand Up @@ -230,7 +222,7 @@ SidekickCommandRunner initializeSidekick({
}

/// A CommandRunner that makes lookups in [SidekickContext] faster
class SidekickCommandRunner<T> extends CommandRunner<T> {
class SidekickCommandRunner<T> extends CompletionCommandRunner<T> {
SidekickCommandRunner._({
required String description,
this.mainProject,
Expand All @@ -244,6 +236,12 @@ class SidekickCommandRunner<T> extends CommandRunner<T> {
);
}

// tab completion only works if the CLI is installed globally (run `<cli> sidekick install-global)
// if the CLI is not installed globally and the autocompletion script is invoked nonetheless,
// it prints an error message which is used as suggestion (<cli> _<cli>_completion:4: command not found: <cli>)
@override
bool get enableAutoInstall => isProgramInstalled(SidekickContext.cliName);

@Deprecated('Use SidekickContext.projectRoot or SidekickContext.repository')
Repository get repository => findRepository();

Expand Down Expand Up @@ -296,8 +294,25 @@ class SidekickCommandRunner<T> extends CommandRunner<T> {
final result = await super.runCommand(parsedArgs);
return result;
} finally {
final reservedCommands = [
HandleCompletionRequestCommand.commandName,
InstallCompletionFilesCommand.commandName,
];
// don't print anything additionally when running the hidden tab completion command (runs in the background when pressing tab),
// otherwise anything that is printed will also be used as suggestion
final isRunningTabCompletionCommand =
reservedCommands.contains(parsedArgs?.command?.name);

if (!enableAutoInstall && !isRunningTabCompletionCommand) {
final command =
yellow('${SidekickContext.cliName} sidekick install-global');
print('${cyan('Run')} $command ${cyan('to enable tab completion.')}');
}

try {
if (_isUpdateCheckEnabled && !_isSidekickCliUpdateCommand(parsedArgs)) {
if (_isUpdateCheckEnabled &&
!_isSidekickCliUpdateCommand(parsedArgs) &&
!isRunningTabCompletionCommand) {
// print warning if the user didn't fully update their CLI
_checkCliVersionIntegrity();
// print warning if CLI update is available
Expand Down
5 changes: 3 additions & 2 deletions sidekick_core/lib/src/commands/install_global_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import 'package:sidekick_core/src/global_sidekick_root.dart';

class InstallGlobalCommand extends Command {
@override
final String description = 'Installs this custom sidekick CLI globally';
final String description =
'Installs this custom sidekick CLI globally and enables tab completion';

@override
final String name = 'install-global';
Expand All @@ -26,7 +27,7 @@ class InstallGlobalCommand extends Command {
if (dcli.isOnPATH(GlobalSidekickRoot.binDir.path)) {
print(
'\n'
"You can now use '${SidekickContext.cliName}' from everywhere\n"
"You can now use '${SidekickContext.cliName}' with tab completions from everywhere\n"
'\n',
);
return;
Expand Down
1 change: 1 addition & 0 deletions sidekick_core/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ environment:

dependencies:
args: ^2.1.0
cli_completion: ^0.3.0
dartx: ^1.1.0
dcli: '>=2.2.0 <=2.2.3'
glob: ^2.0.2
Expand Down
Loading

0 comments on commit 48b2ed2

Please sign in to comment.