diff --git a/CliFx.Demo/Program.cs b/CliFx.Demo/Program.cs index 9773537f..53e3f05b 100644 --- a/CliFx.Demo/Program.cs +++ b/CliFx.Demo/Program.cs @@ -30,6 +30,7 @@ public static async Task Main() => .SetDescription("Demo application showcasing CliFx features.") .AddCommandsFromThisAssembly() .UseTypeActivator(GetServiceProvider().GetRequiredService) + .AllowSuggestMode(true) .Build() .RunAsync(); } diff --git a/CliFx/CliApplication.cs b/CliFx/CliApplication.cs index bbe427fb..38981fef 100644 --- a/CliFx/CliApplication.cs +++ b/CliFx/CliApplication.cs @@ -8,6 +8,7 @@ using CliFx.Infrastructure; using CliFx.Input; using CliFx.Schema; +using CliFx.Suggestions; using CliFx.Utils; using CliFx.Utils.Extensions; @@ -32,6 +33,7 @@ public class CliApplication private readonly ITypeActivator _typeActivator; private readonly CommandBinder _commandBinder; + private readonly IFileSystem _fileSystem; /// /// Initializes an instance of . @@ -40,7 +42,8 @@ public CliApplication( ApplicationMetadata metadata, ApplicationConfiguration configuration, IConsole console, - ITypeActivator typeActivator) + ITypeActivator typeActivator, + IFileSystem fileSystem) { Metadata = metadata; Configuration = configuration; @@ -48,6 +51,7 @@ public CliApplication( _typeActivator = typeActivator; _commandBinder = new CommandBinder(typeActivator); + _fileSystem = fileSystem; } private bool IsDebugModeEnabled(CommandInput commandInput) => @@ -107,11 +111,16 @@ private async ValueTask RunAsync(ApplicationSchema applicationSchema, Comma } // Handle suggest directive + if (Configuration.IsSuggestModeAllowed) + { + new SuggestionService(applicationSchema, _fileSystem).EnsureInstalled(Metadata.Title); + } + if (IsSuggestModeEnabled(commandInput)) { - new SuggestionService(applicationSchema) - .GetSuggestions(commandInput).ToList() - .ForEach(p => _console.Output.WriteLine(p)); + new SuggestionService(applicationSchema, _fileSystem) + .GetSuggestions(commandInput).ToList() + .ForEach(p => _console.Output.WriteLine(p)); return 0; } diff --git a/CliFx/CliApplicationBuilder.cs b/CliFx/CliApplicationBuilder.cs index dffe3893..e0947a5c 100644 --- a/CliFx/CliApplicationBuilder.cs +++ b/CliFx/CliApplicationBuilder.cs @@ -26,6 +26,7 @@ public partial class CliApplicationBuilder private string? _description; private IConsole? _console; private ITypeActivator? _typeActivator; + private IFileSystem? _fileSystem; /// /// Adds a command to the application. @@ -121,6 +122,7 @@ public CliApplicationBuilder AllowSuggestMode(bool isAllowed = true) return this; } + /// /// Sets application title, which is shown in the help text. /// @@ -177,6 +179,15 @@ public CliApplicationBuilder UseConsole(IConsole console) return this; } + /// + /// Configures the application to use the specified implementation of . + /// + CliApplicationBuilder UseFileSystem(IFileSystem fileSystem) + { + _fileSystem = fileSystem; + return this; + } + /// /// Configures the application to use the specified implementation of . /// @@ -215,7 +226,8 @@ public CliApplication Build() metadata, configuration, _console ?? new SystemConsole(), - _typeActivator ?? new DefaultTypeActivator() + _typeActivator ?? new DefaultTypeActivator(), + _fileSystem ?? new FileSystem() ); } } diff --git a/CliFx/Infrastructure/FileSystem.cs b/CliFx/Infrastructure/FileSystem.cs new file mode 100644 index 00000000..4b81291a --- /dev/null +++ b/CliFx/Infrastructure/FileSystem.cs @@ -0,0 +1,27 @@ +using System.IO; + +namespace CliFx.Infrastructure +{ + class FileSystem : IFileSystem + { + public void Copy(string sourceFileName, string destFileName) + { + File.Copy(sourceFileName, destFileName); + } + + public bool Exists(string path) + { + return File.Exists(path); + } + + public string ReadAllText(string path) + { + return File.ReadAllText(path); + } + + public void WriteAllText(string path, string content) + { + File.WriteAllText(path, content); + } + } +} diff --git a/CliFx/Infrastructure/IFileSystem.cs b/CliFx/Infrastructure/IFileSystem.cs new file mode 100644 index 00000000..1c0464d5 --- /dev/null +++ b/CliFx/Infrastructure/IFileSystem.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace CliFx.Infrastructure +{ + /// + /// Abstraction for the file system + /// + public interface IFileSystem + { + /// + /// Determines whether the specified file exists. + /// + bool Exists(string filePath); + + /// + /// Opens a text file, reads all the text in the file, and then closes the file. + /// + string ReadAllText(string filePath); + + /// + /// Creates a new file, writes the specified string to the file, and then closes + /// the file. If the target file already exists, it is overwritten. + /// + void WriteAllText(string filePath, string content); + + /// + /// Copies an existing file to a new file. Overwriting a file of the same name is + /// not allowed. + /// + void Copy(string path, string backupPath); + } +} diff --git a/CliFx/Suggestions/BashSuggestEnvironment.cs b/CliFx/Suggestions/BashSuggestEnvironment.cs new file mode 100644 index 00000000..2798d655 --- /dev/null +++ b/CliFx/Suggestions/BashSuggestEnvironment.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace CliFx.Suggestions +{ + class BashSuggestEnvironment : ISuggestEnvironment + { + public string Version => "V1"; + + public bool ShouldInstall() + { + if (Environment.OSVersion.Platform == PlatformID.Unix || Environment.OSVersion.Platform == PlatformID.MacOSX) + { + return File.Exists(GetInstallPath()); + } + return false; + } + + public string GetInstallPath() + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".bashrc"); + } + + public string GetInstallCommand(string commandName) + { + var safeName = commandName.Replace(" ", "_"); + return $@" +### clifx-suggest-begins-here-{safeName}-{Version} +# this block provides auto-complete for the {commandName} command +# and assumes that {commandName} is on the path +_{safeName}_complete() +{{ + local word=${{COMP_WORDS[COMP_CWORD]}} + + # generate unique environment variable + CLIFX_CMD_CACHE=""clifx-suggest-$(uuidgen)"" + # replace hyphens with underscores to make it valid + CLIFX_CMD_CACHE=${{CLIFX_CMD_CACHE//\-/_}} + + export $CLIFX_CMD_CACHE=${{COMP_LINE}} + + local completions + completions=""$({commandName} ""[suggest]"" --cursor ""${{COMP_POINT}}"" --envvar $CLIFX_CMD_CACHE 2>/dev/null)"" + if [ $? -ne 0]; then + completions="""" + fi + + unset $CLIFX_CMD_CACHE + + COMPREPLY=( $(compgen -W ""$completions"" -- ""$word"") ) +}} + +complete -f -F _{safeName}_complete ""{commandName}"" + +### clifx-suggest-ends-here-{safeName}"; + } + } +} diff --git a/CliFx/Suggestions/ISuggestEnvironment.cs b/CliFx/Suggestions/ISuggestEnvironment.cs new file mode 100644 index 00000000..28d3860a --- /dev/null +++ b/CliFx/Suggestions/ISuggestEnvironment.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace CliFx.Suggestions +{ + interface ISuggestEnvironment + { + bool ShouldInstall(); + + string Version { get; } + + string GetInstallPath(); + + string GetInstallCommand(string command); + } +} diff --git a/CliFx/Suggestions/PowershellSuggestEnvironment.cs b/CliFx/Suggestions/PowershellSuggestEnvironment.cs new file mode 100644 index 00000000..7990a743 --- /dev/null +++ b/CliFx/Suggestions/PowershellSuggestEnvironment.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace CliFx.Suggestions +{ + class PowershellSuggestEnvironment : ISuggestEnvironment + { + public string Version => "V1"; + + public bool ShouldInstall() + { + if (Environment.OSVersion.Platform == PlatformID.Unix || Environment.OSVersion.Platform == PlatformID.MacOSX) + { + return File.Exists("/usr/bin/pwsh"); + + } + return true; + } + + public string GetInstallPath() + { + var baseDir = ""; + if (Environment.OSVersion.Platform == PlatformID.Unix || Environment.OSVersion.Platform == PlatformID.MacOSX) + { + baseDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", ".powershell"); + } + else + { + var myDocuments = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments, Environment.SpecialFolderOption.DoNotVerify); + baseDir = Path.Combine(myDocuments, "WindowsPowerShell"); + } + + return Path.Combine(baseDir, "Microsoft.PowerShell_profile.ps1"); + } + + public string GetInstallCommand(string commandName) + { + var safeName = commandName.Replace(" ", "_"); + return $@" +### clifx-suggest-begins-here-{safeName}-{Version} +# this block provides auto-complete for the {commandName} command +# and assumes that {commandName} is on the path +$scriptblock = {{ + param($wordToComplete, $commandAst, $cursorPosition) + $command = ""{commandName}"" + + $commandCacheId = ""clifx-suggest-"" + (new-guid).ToString() + Set-Content -path ""ENV:\$commandCacheId"" -value $commandAst + + $result = &$command `[suggest`] --envvar $commandCacheId --cursor $cursorPosition | ForEach-Object {{ + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + }} + + Remove-Item -Path ""ENV:\$commandCacheId"" + $result +}} + +Register-ArgumentCompleter -Native -CommandName ""{commandName}"" -ScriptBlock $scriptblock +### clifx-suggest-ends-here-{safeName}"; + } + } +} diff --git a/CliFx/SuggestionService.cs b/CliFx/Suggestions/SuggestionService.cs similarity index 57% rename from CliFx/SuggestionService.cs rename to CliFx/Suggestions/SuggestionService.cs index 60370c4f..dc4e70f0 100644 --- a/CliFx/SuggestionService.cs +++ b/CliFx/Suggestions/SuggestionService.cs @@ -1,19 +1,25 @@ -using CliFx.Input; +using CliFx.Infrastructure; +using CliFx.Input; using CliFx.Schema; using CliFx.Utils; using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Text; +using System.Text.RegularExpressions; -namespace CliFx +namespace CliFx.Suggestions { internal class SuggestionService { private ApplicationSchema _applicationSchema; + private readonly IFileSystem _fileSystem; - public SuggestionService(ApplicationSchema applicationSchema) + public SuggestionService(ApplicationSchema applicationSchema, IFileSystem fileSystem) { _applicationSchema = applicationSchema; + _fileSystem = fileSystem; } public IEnumerable GetSuggestions(CommandInput commandInput) @@ -72,5 +78,45 @@ private static List NoSuggestions() { return new List(); } + + public void EnsureInstalled(string commandName) + { + foreach (var env in new ISuggestEnvironment[] { new BashSuggestEnvironment(), new PowershellSuggestEnvironment() }) + { + var path = env.GetInstallPath(); + + if(!env.ShouldInstall()) + { + continue; + } + + if (!_fileSystem.Exists(path)) + { + _fileSystem.WriteAllText(path, ""); + } + + var pattern = $"### clifx-suggest-begins-here-{Regex.Escape(commandName)}-{env.Version}.*### clifx-suggest-ends-here-{Regex.Escape(commandName)}"; + var script = _fileSystem.ReadAllText(path); + var match = Regex.Match(script, pattern, RegexOptions.Singleline); + if (match.Success) + { + continue; + } + + var uninstallPattern = $"### clifx-suggest-begins-here-{Regex.Escape(commandName)}.*### clifx-suggest-ends-here-{Regex.Escape(commandName)}"; + var sb = new StringBuilder(Regex.Replace(script, uninstallPattern, "", RegexOptions.Singleline)); + sb.AppendLine(env.GetInstallCommand(commandName)); + + // move backup to temp folder for OS to delete eventually (just in case something really bad happens) + var tempFile = Path.GetFileName(path); + var tempExtension = Path.GetExtension(tempFile) + $".backup_{DateTime.UtcNow.ToFileTime()}"; + tempFile = Path.ChangeExtension(tempFile, tempExtension); + var backupPath = Path.Combine(Path.GetTempPath(), tempFile); + + _fileSystem.Copy(path, backupPath); + _fileSystem.WriteAllText(path, sb.ToString()); + } + } } -} \ No newline at end of file +} +