diff --git a/lib/installer.dart b/lib/installer.dart index fe5ee55..d34b03a 100644 --- a/lib/installer.dart +++ b/lib/installer.dart @@ -2,7 +2,5 @@ /// {@canonicalFor system_shell.SystemShell} library installer; -export 'src/installer/completion_installation.dart'; -export 'src/installer/exceptions.dart'; -export 'src/installer/shell_completion_configuration.dart'; +export 'src/installer/installer.dart'; export 'src/system_shell.dart'; diff --git a/lib/src/installer/completion_installation.dart b/lib/src/installer/completion_installation.dart index 5f6064f..aae63ef 100644 --- a/lib/src/installer/completion_installation.dart +++ b/lib/src/installer/completion_installation.dart @@ -65,6 +65,7 @@ class CompletionInstallation { /// shell. It is null if the current shell is unknown. final ShellCompletionConfiguration? configuration; + /// {@template completion_config_dir} /// Define the [Directory] in which the /// completion configuration files will be stored. /// @@ -73,6 +74,7 @@ class CompletionInstallation { /// /// If [isWindows] is false, it will return the directory defined by /// $XDG_CONFIG_HOME/.dart_cli_completion or $HOME/.dart_cli_completion + /// {@endtemplate} @visibleForTesting Directory get completionConfigDir { if (isWindows) { @@ -90,39 +92,39 @@ class CompletionInstallation { } } - /// Install completion configuration files for a [rootCommand] in the + /// Install completion configuration files for a [executableName] in the /// current shell. /// /// It will create: /// - A completion script file in [completionConfigDir] that is named after - /// the [rootCommand] and the current shell (e.g. `very_good.bash`). + /// the [executableName] and the current shell (e.g. `very_good.bash`). /// - A config file in [completionConfigDir] that is named after the current /// shell (e.g. `bash-config.bash`) that sources the aforementioned /// completion script file. /// - A line in the shell config file (e.g. `.bash_profile`) that sources /// the aforementioned config file. - void install(String rootCommand) { + void install(String executableName) { final configuration = this.configuration; if (configuration == null) { throw CompletionInstallationException( message: 'Unknown shell.', - rootCommand: rootCommand, + executableName: executableName, ); } logger.detail( - 'Installing completion for the command $rootCommand ' - 'on ${configuration.name}', + '''Installing completion for the command $executableName on ${configuration.name}''', ); createCompletionConfigDir(); - final completionFileCreated = writeCompletionScriptForCommand(rootCommand); - writeCompletionConfigForShell(rootCommand); - writeToShellConfigFile(rootCommand); + final completionFileCreated = + writeCompletionScriptForExecutable(executableName); + writeCompletionConfigForShell(executableName); + writeToShellConfigFile(executableName); if (completionFileCreated) { - _logSourceInstructions(rootCommand); + _logSourceInstructions(executableName); } } @@ -135,8 +137,7 @@ class CompletionInstallation { final completionConfigDirPath = completionConfigDir.path; logger.info( - 'Creating completion configuration directory ' - 'at $completionConfigDirPath', + '''Creating completion configuration directory at $completionConfigDirPath''', ); if (completionConfigDir.existsSync()) { @@ -149,10 +150,10 @@ class CompletionInstallation { completionConfigDir.createSync(); } - /// Creates a configuration file exclusively to [rootCommand] and the + /// Creates a configuration file exclusively to [executableName] and the /// identified shell. /// - /// The file will be named after the [rootCommand] and the current shell + /// The file will be named after the [executableName] and the current shell /// (e.g. `very_good.bash`). /// /// The file will be created in [completionConfigDir]. @@ -161,69 +162,72 @@ class CompletionInstallation { /// /// Returns true if the file was created, false otherwise. @visibleForTesting - bool writeCompletionScriptForCommand(String rootCommand) { + bool writeCompletionScriptForExecutable(String executableName) { final configuration = this.configuration!; - final completionConfigDirPath = completionConfigDir.path; - final commandScriptName = '$rootCommand.${configuration.name}'; - final commandScriptPath = path.join( - completionConfigDirPath, - commandScriptName, - ); + final executableCompletionScriptFile = + ExecutableCompletionConfiguration.fromShellConfiguration( + executabelName: executableName, + shellConfiguration: configuration, + ).completionScriptFile(completionConfigDir); + logger.info( - 'Writing completion script for $rootCommand on $commandScriptPath', + '''Writing completion script for $executableName on ${executableCompletionScriptFile.path}''', ); - - final scriptFile = File(commandScriptPath); - - if (scriptFile.existsSync()) { + if (executableCompletionScriptFile.existsSync()) { logger.warn( - 'A script file for $rootCommand was already found on ' - '$commandScriptPath.', + '''A script file for $executableName was already found on ${executableCompletionScriptFile.path}''', ); return false; } - scriptFile.writeAsStringSync(configuration.scriptTemplate(rootCommand)); - + executableCompletionScriptFile.writeAsStringSync( + configuration.scriptTemplate(executableName), + ); return true; } - /// Adds a reference for the command-specific config file created on - /// [writeCompletionScriptForCommand] the the global completion config file. + /// Adds a reference for the executable-specific config file created on + /// [writeCompletionScriptForExecutable] the the global completion config + /// file. @visibleForTesting - void writeCompletionConfigForShell(String rootCommand) { + void writeCompletionConfigForShell(String executableName) { final configuration = this.configuration!; - final completionConfigDirPath = completionConfigDir.path; + final shellCompletionConfig = + configuration.completionScriptFile(completionConfigDir); - final configPath = path.join( - completionConfigDirPath, - configuration.completionConfigForShellFileName, + logger.info( + '''Adding config for $executableName config entry to ${shellCompletionConfig.path}''', ); - logger.info('Adding config for $rootCommand config entry to $configPath'); - - final configFile = File(configPath); - if (!configFile.existsSync()) { - logger.info('No file found at $configPath, creating one now'); - configFile.createSync(); + if (!shellCompletionConfig.existsSync()) { + logger.info( + '''No file found at ${shellCompletionConfig.path}, creating one now''', + ); + shellCompletionConfig.createSync(); } - final commandScriptName = '$rootCommand.${configuration.name}'; - final containsLine = - configFile.readAsStringSync().contains(commandScriptName); + final executable = ExecutableCompletionConfiguration.fromShellConfiguration( + executabelName: executableName, + shellConfiguration: configuration, + ); + final executableEntry = executable.entry; - if (containsLine) { + if (executableEntry.existsIn(shellCompletionConfig)) { logger.warn( - 'A config entry for $rootCommand was already found on $configPath.', + '''A config entry for $executableName was already found on ${shellCompletionConfig.path}.''', ); return; } - _sourceScriptOnFile( - configFile: configFile, - scriptName: rootCommand, - scriptPath: path.join(completionConfigDirPath, commandScriptName), + final executableScriptFile = executable.completionScriptFile( + completionConfigDir, ); + final content = configuration.completionReferenceTemplate( + executableName: executableName, + executableScriptFilePath: executableScriptFile.path, + ); + executableEntry.appendTo(shellCompletionConfig, content: content); + logger.info('Added config to ${shellCompletionConfig.path}'); } String get _shellRCFilePath => @@ -232,53 +236,47 @@ class CompletionInstallation { /// Write a source to the completion global script in the shell configuration /// file, which its location is described by the [configuration]. @visibleForTesting - void writeToShellConfigFile(String rootCommand) { + void writeToShellConfigFile(String executableName) { final configuration = this.configuration!; logger.info( - 'Adding dart cli completion config entry ' - 'to $_shellRCFilePath', + '''Adding dart cli completion config entry to $_shellRCFilePath''', ); - final completionConfigDirPath = completionConfigDir.path; - - final completionConfigPath = path.join( - completionConfigDirPath, - configuration.completionConfigForShellFileName, - ); + final shellCompletionConfigFile = + configuration.completionScriptFile(completionConfigDir); final shellRCFile = File(_shellRCFilePath); - if (!shellRCFile.existsSync()) { throw CompletionInstallationException( - rootCommand: rootCommand, + executableName: executableName, message: 'No configuration file found at ${shellRCFile.path}', ); } final containsLine = - shellRCFile.readAsStringSync().contains(completionConfigPath); + shellRCFile.readAsStringSync().contains(shellCompletionConfigFile.path); if (containsLine) { - logger.warn('A completion config entry was already found on' - ' $_shellRCFilePath.'); + logger.warn( + '''A completion config entry was already found on $_shellRCFilePath''', + ); return; } - _sourceScriptOnFile( - configFile: shellRCFile, - scriptName: 'Completion', - description: 'Completion scripts setup. ' - 'Remove the following line to uninstall', - scriptPath: path.join( - completionConfigDir.path, - configuration.completionConfigForShellFileName, - ), + // TODO(alestiago): Define a template function instead. + final content = ''' +## Completion scripts setup. Remove the following line to uninstall +${configuration.sourceLineTemplate(shellCompletionConfigFile.path)}'''; + const ScriptEntry('Completion').appendTo( + shellRCFile, + content: content, ); + logger.info('Added config to ${shellRCFile.path}'); } /// Tells the user to source the shell configuration file. - void _logSourceInstructions(String rootCommand) { + void _logSourceInstructions(String executableName) { final level = logger.level; logger ..level = Level.info @@ -291,35 +289,6 @@ class CompletionInstallation { ) ..level = level; } - - void _sourceScriptOnFile({ - required File configFile, - required String scriptName, - required String scriptPath, - String? description, - }) { - assert( - configFile.existsSync(), - 'Sourcing a script line into an nonexistent config file.', - ); - - final configFilePath = configFile.path; - - description ??= 'Completion config for "$scriptName"'; - - configFile.writeAsStringSync( - mode: FileMode.append, - ''' -\n## [$scriptName] -## $description -${configuration!.sourceLineTemplate(scriptPath)} -## [/$scriptName] - -''', - ); - - logger.info('Added config to $configFilePath'); - } } /// Resolve the home from a path string diff --git a/lib/src/installer/exceptions.dart b/lib/src/installer/exceptions.dart index 06106b1..7236b3d 100644 --- a/lib/src/installer/exceptions.dart +++ b/lib/src/installer/exceptions.dart @@ -5,16 +5,16 @@ class CompletionInstallationException implements Exception { /// {@macro completion_installation_exception} CompletionInstallationException({ required this.message, - required this.rootCommand, + required this.executableName, }); /// The error message for this exception final String message; - /// The command for which the installation failed. - final String rootCommand; + /// The executable name for which the installation failed. + final String executableName; @override - String toString() => 'Could not install completion scripts for $rootCommand: ' - '$message'; + String toString() => + '''Could not install completion scripts for $executableName: $message'''; } diff --git a/lib/src/installer/executable_completion_configuration.dart b/lib/src/installer/executable_completion_configuration.dart new file mode 100644 index 0000000..2758ca7 --- /dev/null +++ b/lib/src/installer/executable_completion_configuration.dart @@ -0,0 +1,138 @@ +import 'dart:io'; +import 'package:cli_completion/installer.dart'; +import 'package:meta/meta.dart'; +import 'package:path/path.dart' as path; + +/// A type definition for functions that creates the content of a +/// completion script given a [executableName]. +typedef CompletionScriptTemplate = String Function(String executableName); + +/// {@template executable_completion_configuration} +/// An executable that originated from [shellName]. +/// {@endtemplate} +class ExecutableCompletionConfiguration { + /// {@macro executable_completion_configuration} + const ExecutableCompletionConfiguration({ + required this.name, + required this.shellName, + required this.sourceLineTemplate, + }); + + /// Creates a [ExecutableCompletionConfiguration] given the current + /// [ShellCompletionConfiguration]. + factory ExecutableCompletionConfiguration.fromShellConfiguration({ + required String executabelName, + required ShellCompletionConfiguration shellConfiguration, + }) { + final CompletionScriptTemplate scriptTemplate; + switch (shellConfiguration.name) { + case 'zsh': + scriptTemplate = zshCompletionScriptTemplate; + break; + case 'bash': + } + + return ExecutableCompletionConfiguration( + name: executabelName, + shellName: shellConfiguration.name, + sourceLineTemplate: shellConfiguration.sourceLineTemplate, + ); + } + + /// The name of the executable. + /// + /// For example: + /// - `flutter` given `flutter create`. + /// - `git` given `git commit`. + final String name; + + /// {@macro shell_name} + /// + /// Indicates where this [ExecutableCompletionConfiguration] originated from. + // TODO(alestiago): Consider replacing with enum and enhanced enumeration with string name member. + final String shellName; + + /// {@macro source_line_template} + final SourceStringTemplate sourceLineTemplate; + + /// The completion script file for this [ExecutableCompletionConfiguration]. + /// + /// A completion script file contains the completion script for a specific + /// executable and shell. + /// + /// The [completionConfigDir] denotes where the completion script file for + /// this [ExecutableCompletionConfiguration] should be located. + File completionScriptFile(Directory completionConfigDir) { + final commandScriptPath = path.join( + completionConfigDir.path, + '$name.$shellName', + ); + return File(commandScriptPath); + } + + /// A script entry for this [ExecutableCompletionConfiguration]. + ScriptEntry get entry => ScriptEntry(name); +} + +// TODO(alestiago): Consider subclassing instead of using a factory. +/// The [CompletionScriptTemplate] for a zsh shell. +@visibleForTesting +CompletionScriptTemplate zshCompletionScriptTemplate = ( + String executableName, +) { + // Completion script for zsh. + // + // Based on https://github.com/mklabs/tabtab/blob/master/lib/scripts/zsh.sh + return ''' +if type compdef &>/dev/null; then + _${executableName}_completion () { + local reply + local si=\$IFS + + IFS=\$'\n' reply=(\$(COMP_CWORD="\$((CURRENT-1))" COMP_LINE="\$BUFFER" COMP_POINT="\$CURSOR" $executableName completion -- "\${words[@]}")) + IFS=\$si + + if [[ -z "\$reply" ]]; then + _path_files + else + _describe 'values' reply + fi + } + compdef _${executableName}_completion $executableName +fi +'''; +}; + +/// The [CompletionScriptTemplate] for a bash shell. +@visibleForTesting +CompletionScriptTemplate bashCompletionScriptTemplate = ( + String executableName, +) { + // Completion script for bash. + // + // Based on https://github.com/mklabs/tabtab/blob/master/lib/scripts/bash.sh + return ''' +if type complete &>/dev/null; then + _${executableName}_completion () { + local words cword + if type _get_comp_words_by_ref &>/dev/null; then + _get_comp_words_by_ref -n = -n @ -n : -w words -i cword + else + cword="\$COMP_CWORD" + words=("\${COMP_WORDS[@]}") + fi + local si="\$IFS" + IFS=\$'\n' COMPREPLY=(\$(COMP_CWORD="\$cword" \\ + COMP_LINE="\$COMP_LINE" \\ + COMP_POINT="\$COMP_POINT" \\ + $executableName completion -- "\${words[@]}" \\ + 2>/dev/null)) || return \$? + IFS="\$si" + if type __ltrim_colon_completions &>/dev/null; then + __ltrim_colon_completions "\${words[cword]}" + fi + } + complete -o default -F _${executableName}_completion $executableName +fi +'''; +}; diff --git a/lib/src/installer/installer.dart b/lib/src/installer/installer.dart new file mode 100644 index 0000000..ad3422c --- /dev/null +++ b/lib/src/installer/installer.dart @@ -0,0 +1,5 @@ +export 'completion_installation.dart'; +export 'exceptions.dart'; +export 'executable.dart'; +export 'script_entry.dart'; +export 'shell_completion_configuration.dart'; diff --git a/lib/src/installer/script_entry.dart b/lib/src/installer/script_entry.dart new file mode 100644 index 0000000..3f4eb23 --- /dev/null +++ b/lib/src/installer/script_entry.dart @@ -0,0 +1,51 @@ +import 'dart:io'; + +/// {@template script_entry} +/// A script entry is a section of a file that starts with [startComment] and +/// ends with [endComment]. +/// {@endtemplate} +class ScriptEntry { + /// {@macro script_entry} + const ScriptEntry(this.name); + + /// The name of the entry. + final String name; + + /// The start comment of the entry. + String get startComment => '\n## [$name] '; + + /// The end comment of the entry. + String get endComment => '\n## [/$name]\n'; + + /// Whether there is an entry with [name] in [file]. + /// + /// If the [file] does not exist, this will return false. + bool existsIn(File file) { + if (!file.existsSync()) return false; + final content = file.readAsStringSync(); + // TODO(alestiago): Refine logic with regular expressions. + return content.contains(startComment) && content.contains(endComment); + } + + /// Adds an entry with [name] to the end of the [file]. + /// + /// If the [file] does not exist, it will be created. + /// + /// If [content] is not null, it will be added within the entry. + // TODO(alestiago): Consider having content as a member of the class. + void appendTo(File file, {String? content}) { + if (!file.existsSync()) { + file.createSync(recursive: true); + } + + final entry = StringBuffer() + ..writeln(startComment) + ..write(content) + ..writeln(endComment); + + file.writeAsStringSync( + entry.toString(), + mode: FileMode.append, + ); + } +} diff --git a/lib/src/installer/shell_completion_configuration.dart b/lib/src/installer/shell_completion_configuration.dart index e028a13..8228c15 100644 --- a/lib/src/installer/shell_completion_configuration.dart +++ b/lib/src/installer/shell_completion_configuration.dart @@ -1,12 +1,12 @@ +import 'dart:io'; + import 'package:cli_completion/installer.dart'; import 'package:meta/meta.dart'; -/// A type definition for functions that creates the content of a -/// completion script given a [rootCommand] -typedef CompletionScriptTemplate = String Function(String rootCommand); +import 'package:path/path.dart' as path; /// A type definition for functions that describes -/// the source line given a [scriptPath] +/// the source line given a [scriptPath]. typedef SourceStringTemplate = String Function(String scriptPath); /// {@template shell_completion_configuration} @@ -38,23 +38,52 @@ class ShellCompletionConfiguration { } } + /// {@template shell_name} /// A descriptive string to identify the shell among others. + /// {@endtemplate} + // TODO: Remove name for the enhanced enumeration. final String name; /// The location of a config file that is run upon shell start. /// Eg: .bash_profile or .zshrc final String shellRCFile; + /// {@template source_line_template} /// Generates a line to sources of a script file. + /// {@endtemplate} final SourceStringTemplate sourceLineTemplate; /// Generates the contents of a completion script. final CompletionScriptTemplate scriptTemplate; - /// The name for the config file for this shell. - String get completionConfigForShellFileName => '$name-config.$name'; + /// The template for the completion reference that is added to the + /// completion script file of this shell. + String completionReferenceTemplate({ + required String executableName, + required String executableScriptFilePath, + }) { + return ''' +## Completion config for "$executableName" +${sourceLineTemplate(executableScriptFilePath)}'''; + } + + /// The configuration file for this shell. + /// + /// A configuration file for this shell is a barrel file that sources + /// the completion script for [ExecutableCompletionConfiguration]s. + /// + /// The [completionConfigDir] denotes where the completion script file + /// should be located. + File completionScriptFile(Directory completionConfigDir) { + final commandScriptPath = path.join( + completionConfigDir.path, + '$name-config.$name', + ); + return File(commandScriptPath); + } } +// TODO(alestiago): Consider subclassing instead of using a factory. /// A [ShellCompletionConfiguration] for zsh. @visibleForTesting final zshConfiguration = ShellCompletionConfiguration._( diff --git a/lib/src/system_shell.dart b/lib/src/system_shell.dart index 77963cd..ad5ffeb 100644 --- a/lib/src/system_shell.dart +++ b/lib/src/system_shell.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:path/path.dart' as path; /// The supported shells. +// TODO(alestiago): Add name to this enum. enum SystemShell { /// The Zsh shell: https://www.zsh.org/ zsh, diff --git a/test/src/command_runner/completion_command_runner_test.dart b/test/src/command_runner/completion_command_runner_test.dart index 982b8aa..2959280 100644 --- a/test/src/command_runner/completion_command_runner_test.dart +++ b/test/src/command_runner/completion_command_runner_test.dart @@ -154,7 +154,10 @@ void main() { when( () => commandRunner.completionInstallation.install('test'), ).thenThrow( - CompletionInstallationException(message: 'oops', rootCommand: 'test'), + CompletionInstallationException( + message: 'oops', + executableName: 'test', + ), ); await commandRunner.run(['ahoy']); diff --git a/test/src/installer/completion_installation_test.dart b/test/src/installer/completion_installation_test.dart index c57307c..d2aab3a 100644 --- a/test/src/installer/completion_installation_test.dart +++ b/test/src/installer/completion_installation_test.dart @@ -166,7 +166,8 @@ void main() { expect(configFile.existsSync(), false); installation.createCompletionConfigDir(); - var result = installation.writeCompletionScriptForCommand('very_good'); + var result = + installation.writeCompletionScriptForExecutable('very_good'); expect(configFile.existsSync(), true); expect(result, true); @@ -186,7 +187,7 @@ void main() { ), ); - result = installation.writeCompletionScriptForCommand('very_good'); + result = installation.writeCompletionScriptForExecutable('very_good'); expect(result, false); @@ -297,6 +298,7 @@ void main() { test( 'installing completion for a command when it is already installed', () { + // FIXME(alestiago): Look into this test failure. final installation = CompletionInstallation( logger: logger, isWindows: false, diff --git a/test/src/installer/shell_completion_configuration_test.dart b/test/src/installer/shell_completion_configuration_test.dart index 9778690..71bfab4 100644 --- a/test/src/installer/shell_completion_configuration_test.dart +++ b/test/src/installer/shell_completion_configuration_test.dart @@ -1,4 +1,7 @@ +import 'dart:io'; + import 'package:cli_completion/installer.dart'; +import 'package:path/path.dart' as path; import 'package:test/test.dart'; void main() { @@ -46,11 +49,18 @@ fi '''); }); - test('completionConfigForShellFileName', () { - expect( - zshConfiguration.completionConfigForShellFileName, - 'zsh-config.zsh', - ); + group('completionScriptFile', () { + test('name is valid', () { + final file = zshConfiguration.completionScriptFile(Directory('/tmp')); + expect(path.basename(file.path), 'zsh-config.zsh'); + }); + + test('path is relative to directory', () { + final directory = Directory('/tmp'); + final file = zshConfiguration.completionScriptFile(directory); + final expectedPath = path.join(directory.path, 'zsh-config.zsh'); + expect(file.path, expectedPath); + }); }); }); @@ -103,11 +113,19 @@ fi '''); }); - test('completionConfigForShellFileName', () { - expect( - bashConfiguration.completionConfigForShellFileName, - 'bash-config.bash', - ); + group('completionScriptFile', () { + test('name is valid', () { + final file = + bashConfiguration.completionScriptFile(Directory('/tmp')); + expect(path.basename(file.path), 'bash-config.bash'); + }); + + test('path is relative to directory', () { + final directory = Directory('/tmp'); + final file = bashConfiguration.completionScriptFile(directory); + final expectedPath = path.join(directory.path, 'bash-config.bash'); + expect(file.path, expectedPath); + }); }); }); });