Skip to content

Commit a97d8dd

Browse files
committed
Updating with more robust quoting of completionText
1 parent 8d2e766 commit a97d8dd

File tree

1 file changed

+129
-34
lines changed

1 file changed

+129
-34
lines changed

1-Draft/RFC-ArgumentCompleter-BaseClass.md

Lines changed: 129 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,14 @@ public class GitCommitCompleter : ArgumentCompleter
4444

4545
private void CompleteGitCommitHash()
4646
{
47-
var user = GetFakeBoundParameter<string>("User");
48-
foreach(var commit in GitExe.Log())
47+
var user = GetBoundParameterOrDefault<string>("User", defaultValue: null);
48+
foreach (var commit in GitExe.Log())
4949
{
50-
if (user is { } u && commit.User != u){
50+
if (user is { } u && commit.User != u)
51+
{
5152
continue;
5253
}
53-
CompleteMatching(text: commit.Hash, tooltip: $"{commit.User}\r\n{commit.Description}}": CompletionMatch.AnyContainsWithWordToComplete);
54+
CompleteMatching(text: commit.Hash, toolTip: $"{commit.User}\r\n{commit.Description}", completionMatch: CompletionMatch.AnyContainsWordToComplete);
5455
}
5556
}
5657
}
@@ -59,30 +60,7 @@ public class GitCommitCompleter : ArgumentCompleter
5960

6061
## Specification
6162

62-
```CSharp
63-
using System.Collections;
64-
using System.Collections.Generic;
65-
using System.Diagnostics.CodeAnalysis;
66-
using System.Management.Automation.Language;
67-
68-
namespace System.Management.Automation
69-
{
70-
71-
public enum CompletionMatch
72-
{
73-
TextStartsWithWordToComplete,
74-
TextContainsWordToComplete,
75-
AnyStartsWithWordToComplete,
76-
AnyContainsWordToComplete,
77-
}
78-
79-
public enum CompletionResultSortKind
80-
{
81-
None,
82-
PreferStartsWithWordToComplete,
83-
Sorted
84-
}
85-
63+
```csharp
8664
/// <summary>
8765
/// Base class for writing custom Argument Completers
8866
/// </summary>
@@ -94,6 +72,7 @@ namespace System.Management.Automation
9472
private IDictionary? _fakeBoundParameters;
9573
private string? _wordToComplete;
9674
private CommandAst? _commandAst;
75+
private string _quote;
9776

9877
protected ArgumentCompleter(StringComparison stringComparison = StringComparison.CurrentCultureIgnoreCase) : this(01, stringComparison)
9978
{
@@ -106,6 +85,8 @@ namespace System.Management.Automation
10685
{
10786
_results = new List<CompletionResult>(capacity: capacity);
10887
}
88+
89+
_quote = string.Empty;
10990
}
11091
private List<CompletionResult> Results => _results ??= new List<CompletionResult>();
11192

@@ -115,6 +96,7 @@ namespace System.Management.Automation
11596
string wordToComplete, CommandAst commandAst, IDictionary fakeBoundParameters)
11697
{
11798
_fakeBoundParameters = fakeBoundParameters;
99+
_quote = CompletionCompleters.HandleDoubleAndSingleQuote(ref wordToComplete);
118100
WordToComplete = wordToComplete;
119101
CommandAst = commandAst;
120102
var sortKind = AddCompletionsFor(commandName: commandName, parameterName: parameterName, fakeBoundParameters: fakeBoundParameters);
@@ -149,11 +131,12 @@ namespace System.Management.Automation
149131
/// <param name="listItemText">the text to be displayed in a list</param>
150132
/// <param name="toolTip">the text for the tooltip with details to be displayed about the object</param>
151133
/// <param name="resultType">the type of completion result</param>
152-
public void Complete(string text, string? listItemText = null, string? toolTip = null, CompletionResultType resultType = CompletionResultType.ParameterValue)
134+
/// <param name="isGlobbingPath"><see langword="true"/> if the parameter to complete is a globbing path. This escapes '[' and ']'.</param>
135+
public void Complete(string text, string? listItemText = null, string? toolTip = null, CompletionResultType resultType = CompletionResultType.ParameterValue, bool isGlobbingPath = false)
153136
{
154137
if (text == null) throw new ArgumentNullException(nameof(text));
155138

156-
var quotedText = QuoteCompletionText(text: text);
139+
var quotedText = QuoteCompletionText(completionText: text, isGlobbingPath);
157140
var completionResult = new CompletionResult(completionText: quotedText, listItemText ?? text,
158141
resultType: resultType, toolTip ?? text);
159142
Results.Add(item: completionResult);
@@ -255,9 +238,49 @@ namespace System.Management.Automation
255238
/// <summary>
256239
/// If necessary, puts quotation marks around the completion text
257240
/// </summary>
258-
/// <param name="text">The text to complete</param>
259-
/// <returns></returns>
260-
protected virtual string QuoteCompletionText(string text) => text.Contains(" ") ? $@"""{text}""" : text;
241+
/// <param name="completionText">The text to complete</param>
242+
/// <param name="isGlobbingPath"><see langword="true"/> if the characters [ and ] should be escaped.</param>
243+
/// <returns>A quoted string, if quoting was necessary. Otherwise <see param="completionText"/>.</returns>
244+
protected virtual string QuoteCompletionText(string completionText, bool isGlobbingPath = false)
245+
{
246+
if (CompletionCompleters.CompletionRequiresQuotes(completionText, isGlobbingPath))
247+
{
248+
var quoteInUse = _quote == string.Empty ? "'" : _quote;
249+
if (quoteInUse == "'")
250+
{
251+
completionText = completionText.Replace("'", "''");
252+
}
253+
else
254+
{
255+
// When double quote is in use, we have to escape the backtip and '$' even when using literal path
256+
// Get-Content -LiteralPath ".\a``g.txt"
257+
completionText = completionText.Replace("`", "``");
258+
completionText = completionText.Replace("$", "`$");
259+
}
260+
261+
if (isGlobbingPath)
262+
{
263+
if (quoteInUse == "'")
264+
{
265+
completionText = completionText.Replace("[", "`[");
266+
completionText = completionText.Replace("]", "`]");
267+
}
268+
else
269+
{
270+
completionText = completionText.Replace("[", "``[");
271+
completionText = completionText.Replace("]", "``]");
272+
}
273+
}
274+
275+
completionText = quoteInUse + completionText + quoteInUse;
276+
}
277+
else if (_quote != string.Empty)
278+
{
279+
completionText = _quote + completionText + _quote;
280+
}
281+
282+
return completionText;
283+
}
261284

262285
/// <summary>
263286
/// Predicate to test if a string starts with <see cref="WordToComplete" />
@@ -340,7 +363,79 @@ namespace System.Management.Automation
340363
private set => _commandAst = value;
341364
}
342365
}
343-
}
366+
367+
internal class CompletionCompleters
368+
{
369+
/// <summary>
370+
/// Determines what the <see param="wordToComplete"/> is without quotes, and
371+
/// what quote character, if any, is uses
372+
/// </summary>
373+
/// <returns>The quote character, ' or ", or the empty string.</returns>
374+
internal static string HandleDoubleAndSingleQuote(ref string wordToComplete)
375+
{
376+
string quote = string.Empty;
377+
378+
if (!string.IsNullOrEmpty(wordToComplete) && (wordToComplete[0].IsSingleQuote() || wordToComplete[0].IsDoubleQuote()))
379+
{
380+
char frontQuote = wordToComplete[0];
381+
int length = wordToComplete.Length;
382+
383+
if (length == 1)
384+
{
385+
wordToComplete = string.Empty;
386+
quote = frontQuote.IsSingleQuote() ? "'" : "\"";
387+
}
388+
else if (length > 1)
389+
{
390+
if ((wordToComplete[length - 1].IsDoubleQuote() && frontQuote.IsDoubleQuote()) || (wordToComplete[length - 1].IsSingleQuote() && frontQuote.IsSingleQuote()))
391+
{
392+
wordToComplete = wordToComplete.Substring(1, length - 2);
393+
quote = frontQuote.IsSingleQuote() ? "'" : "\"";
394+
}
395+
else if (!wordToComplete[length - 1].IsDoubleQuote() && !wordToComplete[length - 1].IsSingleQuote())
396+
{
397+
wordToComplete = wordToComplete.Substring(1);
398+
quote = frontQuote.IsSingleQuote() ? "'" : "\"";
399+
}
400+
}
401+
}
402+
403+
return quote;
404+
}
405+
406+
407+
/// <summary>
408+
/// Determines if the item to complete requires quotes
409+
/// </summary>
410+
internal static bool CompletionRequiresQuotes(string completion, bool escape)
411+
{
412+
// If the tokenizer sees the completion as more than two tokens, or if there is some error, then
413+
// some form of quoting is necessary (if it's a variable, we'd need ${}, filenames would need [], etc.)
414+
415+
Parser.ParseInput(completion, out Token[] tokens, out ParseError[] errors);
416+
417+
ReadOnlySpan<char> charToCheck = escape ? stackalloc char[] { '$', '[', ']', '`' } : stackalloc char[] { '$', '`' };
418+
419+
// Expect no errors and 2 tokens (1 is for our completion, the other is eof)
420+
// Or if the completion is a keyword, we ignore the errors
421+
bool requireQuote = !(errors.Length == 0 && tokens.Length == 2);
422+
if ((!requireQuote && tokens[0] is StringToken) ||
423+
(tokens.Length == 2 && (tokens[0].TokenFlags & TokenFlags.Keyword) != 0))
424+
{
425+
requireQuote = false;
426+
var value = tokens[0].Text.AsSpan();
427+
if (value.IndexOfAny(charToCheck) != -1)
428+
requireQuote = true;
429+
}
430+
431+
return requireQuote;
432+
}
433+
}
434+
435+
internal static class CharExtensions {
436+
public static bool IsSingleQuote(this char c) => c == '\'';
437+
public static bool IsDoubleQuote(this char c) => c == '\"';
438+
}
344439

345440
```
346441

0 commit comments

Comments
 (0)