From b25b51de4cc4dd4562e745d8956e16df41e41fe7 Mon Sep 17 00:00:00 2001 From: Staffan Gustafsson Date: Wed, 25 Nov 2020 17:47:30 +0100 Subject: [PATCH 1/3] RFC for an argument completer base class --- 1-Draft/RFC-ArgumentCompleter-BaseClass.md | 348 +++++++++++++++++++++ 1 file changed, 348 insertions(+) create mode 100644 1-Draft/RFC-ArgumentCompleter-BaseClass.md diff --git a/1-Draft/RFC-ArgumentCompleter-BaseClass.md b/1-Draft/RFC-ArgumentCompleter-BaseClass.md new file mode 100644 index 000000000..a7d53ee08 --- /dev/null +++ b/1-Draft/RFC-ArgumentCompleter-BaseClass.md @@ -0,0 +1,348 @@ +--- +RFC: RFC +Author: Staffan Gustafsson +Status: Draft +SupercededBy: +Version: . +Area: Command Completion +Comments Due: 1 month +Plan to implement: Yes +--- + +Provide a base class, ArgumentCompleterBase, to simplify writing custom argument completers + +Some common tasks needs to be done almost every time a completer is written. + +Things like quoting arguments if they contain spaces, and matching against `WordToComplete`. +Ensuring that the results are sorted is another such task. + +## Motivation + + As a Developer, + I can use the base class when writing custom argument completers, + so that results are achieved faster, with high quality, and + so that users get a consistent completion experience. + +## User Experience + +```csharp +using namespace System.Management.Automation; + +public class GetCommitCompleter : ArgumentCompleter +{ + protected override CompletionResultSortKind AddCompletionsFor(string commandName, string parameterName, IDictionary fakeBoundParameters) + { + switch (parameterName) + { + case nameof(GitRebaseCommand.CommitHash): + CompleteGitCommitHash(); + break; + } + + return CompletionResultSortKind.Sorted; + } + + private void CompleteGitCommitHash() + { + var user = GetFakeBoundParameter("User"); + foreach(var commit in GitExe.Log()) + { + if (user is { } u && commit.User != u){ + continue; + } + CompleteMatching(text: commit.Hash, tooltip: $"{commit.User}\r\n{commit.Description}}": CompletionMatch.AnyContainsWithWordToComplete); + } + } +} +``` + + +## Specification + +```CSharp +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Management.Automation.Language; + +namespace System.Management.Automation +{ + + public enum CompletionMatch + { + TextStartsWithWordToComplete, + TextContainsWordToComplete, + AnyStartsWithWordToComplete, + AnyContainsWordToComplete, + } + + public enum CompletionResultSortKind + { + None, + PreferStartsWithWordToComplete, + Sorted + } + + /// + /// Base class for writing custom Argument Completers + /// + /// Derived classes should override + public abstract class ArgumentCompleter : IArgumentCompleter + { + private readonly StringComparison _stringComparison; + private List? _results; + private IDictionary? _fakeBoundParameters; + private string? _wordToComplete; + private CommandAst? _commandAst; + + protected ArgumentCompleter(StringComparison stringComparison = StringComparison.CurrentCultureIgnoreCase) : this(01, stringComparison) + { + } + + protected ArgumentCompleter(int capacity, StringComparison stringComparison = StringComparison.CurrentCultureIgnoreCase) + { + _stringComparison = stringComparison; + if (capacity > 0) + { + _results = new List(capacity: capacity); + } + } + private List Results => _results ??= new List(); + +#pragma warning disable CA1033 // Interface methods should be callable by child types + IEnumerable? IArgumentCompleter.CompleteArgument(string commandName, string parameterName, +#pragma warning restore CA1033 // Interface methods should be callable by child types + string wordToComplete, CommandAst commandAst, IDictionary fakeBoundParameters) + { + _fakeBoundParameters = fakeBoundParameters; + WordToComplete = wordToComplete; + CommandAst = commandAst; + var sortKind = AddCompletionsFor(commandName: commandName, parameterName: parameterName, fakeBoundParameters: fakeBoundParameters); + SortResults(sortKind); + return _results; + } + + [return: NotNullIfNotNull("defaultValue")] + public T? GetBoundParameterOrDefault(string parameterName, T? defaultValue) where T : class => _fakeBoundParameters?[key: parameterName] is T value ? value : defaultValue; + + /// + /// Override in child class to add completions by calling + /// + /// the command to complete parameters for + /// the parameter to complete + /// previously specified command parameters + protected abstract CompletionResultSortKind AddCompletionsFor(string commandName, string parameterName, IDictionary fakeBoundParameters); + + /// + /// Adds a completion result to the result set + /// + /// the completion result to add + public void Complete(CompletionResult completionResult) + { + Results.Add(item: completionResult); + } + + /// + /// Adds a completion result to the result set with the specified parameters + /// + /// the text to be used as the auto completion result + /// the text to be displayed in a list + /// the text for the tooltip with details to be displayed about the object + /// the type of completion result + public void Complete(string text, string? listItemText = null, string? toolTip = null, CompletionResultType resultType = CompletionResultType.ParameterValue) + { + if (text == null) throw new ArgumentNullException(nameof(text)); + + var quotedText = QuoteCompletionText(text: text); + var completionResult = new CompletionResult(completionText: quotedText, listItemText ?? text, + resultType: resultType, toolTip ?? text); + Results.Add(item: completionResult); + } + + /// + /// Adds a completion result to the result set if the text starts with + /// + /// The comparison is case insensitive + /// the text to be used as the auto completion result + /// the text to be displayed in a list + /// the text for the tooltip with details to be displayed about the object + /// the type of completion result + /// specifies how item(s) are match against . + public void CompleteMatching(string text, string? listItemText = null, string? toolTip = null, CompletionResultType resultType = CompletionResultType.ParameterValue, CompletionMatch completionMatch = CompletionMatch.TextStartsWithWordToComplete) + { + switch (completionMatch) + { + case CompletionMatch.TextStartsWithWordToComplete: + CompleteIfTextStartsWithWordToComplete(text, listItemText, toolTip, resultType); + break; + case CompletionMatch.TextContainsWordToComplete: + CompleteIfTextContainsWordToComplete(text, listItemText, toolTip, resultType); + break; + case CompletionMatch.AnyStartsWithWordToComplete: + CompleteIfAnyStartsWithWordToComplete(text, listItemText, toolTip, resultType); + break; + case CompletionMatch.AnyContainsWordToComplete: + CompleteIfAnyContainsWordToComplete(text, listItemText, toolTip, resultType); + break; + default: + throw new ArgumentOutOfRangeException(nameof(completionMatch), completionMatch, null); + } + } + + /// + /// Adds a completion result to the result set if the text starts with + /// + /// The comparison is case insensitive + /// the text to be used as the auto completion result + /// the text to be displayed in a list + /// the text for the tooltip with details to be displayed about the object + /// the type of completion result + private void CompleteIfTextStartsWithWordToComplete(string text, string? listItemText = null, string? toolTip = null, CompletionResultType resultType = CompletionResultType.ParameterValue) + { + if (StartWithWordToComplete(text: text)) + Complete(text: text, listItemText ?? text, toolTip ?? text, + resultType: resultType); + } + + /// + /// Adds a completion result to the result set if the any string argument starts with + /// + /// The comparison is case insensitive + /// the text to be used as the auto completion result + /// the text to be displayed in a list + /// the text for the tooltip with details to be displayed about the object + /// the type of completion result + private void CompleteIfAnyStartsWithWordToComplete(string text, string? listItemText = null, + string? toolTip = null, CompletionResultType resultType = CompletionResultType.ParameterValue) + { + if (StartWithWordToComplete(text: text) || StartWithWordToComplete(text: listItemText) || StartWithWordToComplete(text: toolTip)) + Complete(text: text, listItemText ?? text, toolTip ?? text, + resultType: resultType); + } + + /// + /// Adds a completion result to the result set if the any string argument starts with + /// + /// The comparison is case insensitive + /// the text to be used as the auto completion result + /// the text to be displayed in a list + /// the text for the tooltip with details to be displayed about the object + /// the type of completion result + private void CompleteIfAnyContainsWordToComplete(string text, string? listItemText = null, + string? toolTip = null, CompletionResultType resultType = CompletionResultType.ParameterValue) + { + if (ContainsWordToComplete(text: text) || ContainsWordToComplete(text: listItemText) || ContainsWordToComplete(text: toolTip)) + Complete(text: text, listItemText ?? text, toolTip ?? text, + resultType: resultType); + } + + /// + /// Adds a completion result to the result set if the text contains + /// + /// The comparison is case insensitive + /// the text to be used as the auto completion result + /// the text to be displayed in a list + /// the text for the tooltip with details to be displayed about the object + /// the type of completion result + private void CompleteIfTextContainsWordToComplete(string text, string? listItemText = null, + string? toolTip = null, CompletionResultType resultType = CompletionResultType.ParameterValue) + { + if (ContainsWordToComplete(text: text)) + Complete(text: text, listItemText ?? text, toolTip ?? text, + resultType: resultType); + } + + /// + /// If necessary, puts quotation marks around the completion text + /// + /// The text to complete + /// + protected virtual string QuoteCompletionText(string text) => text.Contains(" ") ? $@"""{text}""" : text; + + /// + /// Predicate to test if a string starts with + /// + /// text to compare to . + /// if the text contains , otherwise + public bool StartWithWordToComplete(string? text) => StartWithWordToComplete(text, _stringComparison); + + /// + /// Predicate to test if a string starts with + /// + /// text to compare to . + /// Determines whether the beginning of this string instance matches the specified string when compared using the specified comparison option. + /// if the text contains , otherwise + public bool StartWithWordToComplete(string? text, StringComparison stringComparison) => + !string.IsNullOrEmpty(value: text) && text.StartsWith(value: WordToComplete, comparisonType: stringComparison); + + /// + /// Predicate to test if a string starts with + /// + /// the text to test + /// if the text contains , otherwise + public bool ContainsWordToComplete(string? text) => ContainsWordToComplete(text, _stringComparison); + + /// + /// Predicate to test if a string starts with + /// + /// the text to test + /// Determines whether the beginning of this string instance matches the specified string when compared using the specified comparison option. + /// if the text contains , otherwise + public bool ContainsWordToComplete(string? text, StringComparison stringComparison) => + !string.IsNullOrEmpty(value: text) && text.IndexOf(value: WordToComplete, comparisonType: stringComparison) != -1; + + /// + /// Sort the completion results as specified by the parameter. + /// + /// Specifies of the completion results should be returned as is or sorted in some way. + protected virtual void SortResults(CompletionResultSortKind completionResultSortKind) + { + int CompareStartWithWordToCompleteCompletionResult(CompletionResult x, CompletionResult y) + { + var startsWith = x.CompletionText.StartsWith(WordToComplete, _stringComparison).CompareTo(y.CompletionText.StartsWith(WordToComplete, _stringComparison)); + return startsWith != 0 ? startsWith : string.Compare(strA: x.CompletionText, strB: y.CompletionText, comparisonType: StringComparison.Ordinal); + } + + int CompareCompletionResult(CompletionResult x, CompletionResult y) => string.Compare(strA: x.CompletionText, strB: y.CompletionText, _stringComparison); + + switch (completionResultSortKind) + { + case CompletionResultSortKind.None: + break; + case CompletionResultSortKind.PreferStartsWithWordToComplete: + Results.Sort(comparison: CompareStartWithWordToCompleteCompletionResult); + break; + case CompletionResultSortKind.Sorted: + Results.Sort(comparison: CompareCompletionResult); + break; + default: + throw new ArgumentOutOfRangeException(nameof(completionResultSortKind), completionResultSortKind, null); + } + } + + public bool Empty => _results?.Count == 0; + + /// + /// The word to complete + /// + public string WordToComplete + { + get => _wordToComplete ?? throw new InvalidOperationException(); + private set => _wordToComplete = value; + } + + /// + /// The Command Ast for the command to complete + /// + public CommandAst CommandAst + { + get => _commandAst ?? throw new InvalidOperationException(); + private set => _commandAst = value; + } + } +} + +``` + +## Alternate Proposals and Considerations + From 8d2e76677cfbc9962728e6e9a59c3910b84cc8cd Mon Sep 17 00:00:00 2001 From: Staffan Gustafsson Date: Thu, 20 May 2021 23:50:44 +0200 Subject: [PATCH 2/3] Update 1-Draft/RFC-ArgumentCompleter-BaseClass.md Co-authored-by: Steve Lee --- 1-Draft/RFC-ArgumentCompleter-BaseClass.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/1-Draft/RFC-ArgumentCompleter-BaseClass.md b/1-Draft/RFC-ArgumentCompleter-BaseClass.md index a7d53ee08..6bf25d358 100644 --- a/1-Draft/RFC-ArgumentCompleter-BaseClass.md +++ b/1-Draft/RFC-ArgumentCompleter-BaseClass.md @@ -28,7 +28,7 @@ Ensuring that the results are sorted is another such task. ```csharp using namespace System.Management.Automation; -public class GetCommitCompleter : ArgumentCompleter +public class GitCommitCompleter : ArgumentCompleter { protected override CompletionResultSortKind AddCompletionsFor(string commandName, string parameterName, IDictionary fakeBoundParameters) { @@ -345,4 +345,3 @@ namespace System.Management.Automation ``` ## Alternate Proposals and Considerations - From a97d8dd08a30c3e4aa3ffc2780d733ee21fe7726 Mon Sep 17 00:00:00 2001 From: Staffan Gustafsson Date: Fri, 21 May 2021 01:25:30 +0200 Subject: [PATCH 3/3] Updating with more robust quoting of completionText --- 1-Draft/RFC-ArgumentCompleter-BaseClass.md | 163 ++++++++++++++++----- 1 file changed, 129 insertions(+), 34 deletions(-) diff --git a/1-Draft/RFC-ArgumentCompleter-BaseClass.md b/1-Draft/RFC-ArgumentCompleter-BaseClass.md index 6bf25d358..fa0bd4f2b 100644 --- a/1-Draft/RFC-ArgumentCompleter-BaseClass.md +++ b/1-Draft/RFC-ArgumentCompleter-BaseClass.md @@ -44,13 +44,14 @@ public class GitCommitCompleter : ArgumentCompleter private void CompleteGitCommitHash() { - var user = GetFakeBoundParameter("User"); - foreach(var commit in GitExe.Log()) + var user = GetBoundParameterOrDefault("User", defaultValue: null); + foreach (var commit in GitExe.Log()) { - if (user is { } u && commit.User != u){ + if (user is { } u && commit.User != u) + { continue; } - CompleteMatching(text: commit.Hash, tooltip: $"{commit.User}\r\n{commit.Description}}": CompletionMatch.AnyContainsWithWordToComplete); + CompleteMatching(text: commit.Hash, toolTip: $"{commit.User}\r\n{commit.Description}", completionMatch: CompletionMatch.AnyContainsWordToComplete); } } } @@ -59,30 +60,7 @@ public class GitCommitCompleter : ArgumentCompleter ## Specification -```CSharp -using System.Collections; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Management.Automation.Language; - -namespace System.Management.Automation -{ - - public enum CompletionMatch - { - TextStartsWithWordToComplete, - TextContainsWordToComplete, - AnyStartsWithWordToComplete, - AnyContainsWordToComplete, - } - - public enum CompletionResultSortKind - { - None, - PreferStartsWithWordToComplete, - Sorted - } - +```csharp /// /// Base class for writing custom Argument Completers /// @@ -94,6 +72,7 @@ namespace System.Management.Automation private IDictionary? _fakeBoundParameters; private string? _wordToComplete; private CommandAst? _commandAst; + private string _quote; protected ArgumentCompleter(StringComparison stringComparison = StringComparison.CurrentCultureIgnoreCase) : this(01, stringComparison) { @@ -106,6 +85,8 @@ namespace System.Management.Automation { _results = new List(capacity: capacity); } + + _quote = string.Empty; } private List Results => _results ??= new List(); @@ -115,6 +96,7 @@ namespace System.Management.Automation string wordToComplete, CommandAst commandAst, IDictionary fakeBoundParameters) { _fakeBoundParameters = fakeBoundParameters; + _quote = CompletionCompleters.HandleDoubleAndSingleQuote(ref wordToComplete); WordToComplete = wordToComplete; CommandAst = commandAst; var sortKind = AddCompletionsFor(commandName: commandName, parameterName: parameterName, fakeBoundParameters: fakeBoundParameters); @@ -149,11 +131,12 @@ namespace System.Management.Automation /// the text to be displayed in a list /// the text for the tooltip with details to be displayed about the object /// the type of completion result - public void Complete(string text, string? listItemText = null, string? toolTip = null, CompletionResultType resultType = CompletionResultType.ParameterValue) + /// if the parameter to complete is a globbing path. This escapes '[' and ']'. + public void Complete(string text, string? listItemText = null, string? toolTip = null, CompletionResultType resultType = CompletionResultType.ParameterValue, bool isGlobbingPath = false) { if (text == null) throw new ArgumentNullException(nameof(text)); - var quotedText = QuoteCompletionText(text: text); + var quotedText = QuoteCompletionText(completionText: text, isGlobbingPath); var completionResult = new CompletionResult(completionText: quotedText, listItemText ?? text, resultType: resultType, toolTip ?? text); Results.Add(item: completionResult); @@ -255,9 +238,49 @@ namespace System.Management.Automation /// /// If necessary, puts quotation marks around the completion text /// - /// The text to complete - /// - protected virtual string QuoteCompletionText(string text) => text.Contains(" ") ? $@"""{text}""" : text; + /// The text to complete + /// if the characters [ and ] should be escaped. + /// A quoted string, if quoting was necessary. Otherwise . + protected virtual string QuoteCompletionText(string completionText, bool isGlobbingPath = false) + { + if (CompletionCompleters.CompletionRequiresQuotes(completionText, isGlobbingPath)) + { + var quoteInUse = _quote == string.Empty ? "'" : _quote; + if (quoteInUse == "'") + { + completionText = completionText.Replace("'", "''"); + } + else + { + // When double quote is in use, we have to escape the backtip and '$' even when using literal path + // Get-Content -LiteralPath ".\a``g.txt" + completionText = completionText.Replace("`", "``"); + completionText = completionText.Replace("$", "`$"); + } + + if (isGlobbingPath) + { + if (quoteInUse == "'") + { + completionText = completionText.Replace("[", "`["); + completionText = completionText.Replace("]", "`]"); + } + else + { + completionText = completionText.Replace("[", "``["); + completionText = completionText.Replace("]", "``]"); + } + } + + completionText = quoteInUse + completionText + quoteInUse; + } + else if (_quote != string.Empty) + { + completionText = _quote + completionText + _quote; + } + + return completionText; + } /// /// Predicate to test if a string starts with @@ -340,7 +363,79 @@ namespace System.Management.Automation private set => _commandAst = value; } } -} + + internal class CompletionCompleters + { + /// + /// Determines what the is without quotes, and + /// what quote character, if any, is uses + /// + /// The quote character, ' or ", or the empty string. + internal static string HandleDoubleAndSingleQuote(ref string wordToComplete) + { + string quote = string.Empty; + + if (!string.IsNullOrEmpty(wordToComplete) && (wordToComplete[0].IsSingleQuote() || wordToComplete[0].IsDoubleQuote())) + { + char frontQuote = wordToComplete[0]; + int length = wordToComplete.Length; + + if (length == 1) + { + wordToComplete = string.Empty; + quote = frontQuote.IsSingleQuote() ? "'" : "\""; + } + else if (length > 1) + { + if ((wordToComplete[length - 1].IsDoubleQuote() && frontQuote.IsDoubleQuote()) || (wordToComplete[length - 1].IsSingleQuote() && frontQuote.IsSingleQuote())) + { + wordToComplete = wordToComplete.Substring(1, length - 2); + quote = frontQuote.IsSingleQuote() ? "'" : "\""; + } + else if (!wordToComplete[length - 1].IsDoubleQuote() && !wordToComplete[length - 1].IsSingleQuote()) + { + wordToComplete = wordToComplete.Substring(1); + quote = frontQuote.IsSingleQuote() ? "'" : "\""; + } + } + } + + return quote; + } + + + /// + /// Determines if the item to complete requires quotes + /// + internal static bool CompletionRequiresQuotes(string completion, bool escape) + { + // If the tokenizer sees the completion as more than two tokens, or if there is some error, then + // some form of quoting is necessary (if it's a variable, we'd need ${}, filenames would need [], etc.) + + Parser.ParseInput(completion, out Token[] tokens, out ParseError[] errors); + + ReadOnlySpan charToCheck = escape ? stackalloc char[] { '$', '[', ']', '`' } : stackalloc char[] { '$', '`' }; + + // Expect no errors and 2 tokens (1 is for our completion, the other is eof) + // Or if the completion is a keyword, we ignore the errors + bool requireQuote = !(errors.Length == 0 && tokens.Length == 2); + if ((!requireQuote && tokens[0] is StringToken) || + (tokens.Length == 2 && (tokens[0].TokenFlags & TokenFlags.Keyword) != 0)) + { + requireQuote = false; + var value = tokens[0].Text.AsSpan(); + if (value.IndexOfAny(charToCheck) != -1) + requireQuote = true; + } + + return requireQuote; + } + } + + internal static class CharExtensions { + public static bool IsSingleQuote(this char c) => c == '\''; + public static bool IsDoubleQuote(this char c) => c == '\"'; + } ```