From 99b1ff57f6d527d04262dc5154004c68ce8f8fcf Mon Sep 17 00:00:00 2001 From: ArmaanMcleod Date: Sun, 6 Apr 2025 00:17:35 +1100 Subject: [PATCH 01/31] Add single/double quote support for `Join-String` Argument Completer --- .../commands/utility/Join-String.cs | 128 ++++++++++-------- .../CommandCompletion/CompletionHelpers.cs | 73 ++++++++-- .../resources/TabCompletionStrings.resx | 21 +++ .../TabCompletion/TabCompletion.Tests.ps1 | 82 +++++++++++ test/xUnit/csharp/test_CompletionHelpers.cs | 39 ++++++ 5 files changed, 276 insertions(+), 67 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs index 0aebabe94c7..3b95d3ee697 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs @@ -166,73 +166,91 @@ protected override void EndProcessing() Justification = "Class is instantiated through late-bound reflection")] internal class JoinItemCompleter : IArgumentCompleter { - public IEnumerable CompleteArgument( - string commandName, - string parameterName, - string wordToComplete, - CommandAst commandAst, - IDictionary fakeBoundParameters) + private static readonly IReadOnlyList s_formatStringValues = new List { - switch (parameterName) - { - case "Separator": return CompleteSeparator(wordToComplete); - case "FormatString": return CompleteFormatString(wordToComplete); - } + "[{0}]", + "{0:N2}", +#if UNIX + "`n `${0}", + "`n [string] `${0}" +#else + "`r`n `${0}", + "`r`n [string] `${0}" +#endif + }; - return null; - } + private static readonly string NewLineText = +#if UNIX + "`n"; +#else + "`r`n"; +#endif - private static IEnumerable CompleteFormatString(string wordToComplete) + private static readonly IReadOnlyList s_separatorValues = new List { - var res = new List(); - void AddMatching(string completionText) - { - if (completionText.StartsWith(wordToComplete, StringComparison.OrdinalIgnoreCase)) - { - res.Add(new CompletionResult(completionText)); - } - } - - AddMatching("'[{0}]'"); - AddMatching("'{0:N2}'"); - AddMatching("\"`r`n `${0}\""); - AddMatching("\"`r`n [string] `${0}\""); + ",", + ", ", + ";", + "; ", + NewLineText, + "-", + " " + }; - return res; - } - - private IEnumerable CompleteSeparator(string wordToComplete) + private static readonly Dictionary s_separatorMappings = new() { - var res = new List(10); + { ",", (TabCompletionStrings.SeparatorCommaToolTip, "Comma") }, + { ", ", (TabCompletionStrings.SeparatorCommaSpaceToolTip, "Comma-Space") }, + { ";", (TabCompletionStrings.SeparatorSemiColonToolTip, "Semi-Colon") }, + { "; ", (TabCompletionStrings.SeparatorSemiColonSpaceToolTip, "Semi-Colon-Space") }, + { NewLineText, (StringUtil.Format(TabCompletionStrings.SeparatorNewlineToolTip, NewLineText), "Newline") }, + { "-", (TabCompletionStrings.SeparatorDashToolTip, "Dash") }, + { " ", (TabCompletionStrings.SeparatorSpaceToolTip, "Space") } + }; - void AddMatching(string completionText, string listText, string toolTip) - { - if (completionText.StartsWith(wordToComplete, StringComparison.OrdinalIgnoreCase)) - { - res.Add(new CompletionResult(completionText, listText, CompletionResultType.ParameterValue, toolTip)); - } - } + private static string GetSeparatorToolTip(string separator) + => s_separatorMappings.TryGetValue(separator, out var mapping) + ? mapping.Tooltip + : separator; - AddMatching("', '", "Comma-Space", "', ' - Comma-Space"); - AddMatching("';'", "Semi-Colon", "';' - Semi-Colon "); - AddMatching("'; '", "Semi-Colon-Space", "'; ' - Semi-Colon-Space"); - AddMatching($"\"{NewLineText}\"", "Newline", $"{NewLineText} - Newline"); - AddMatching("','", "Comma", "',' - Comma"); - AddMatching("'-'", "Dash", "'-' - Dash"); - AddMatching("' '", "Space", "' ' - Space"); - return res; - } + private static string GetSeparatorListItemText(string separator) + => s_separatorMappings.TryGetValue(separator, out var mapping) + ? mapping.ListItemText + : separator; - public string NewLineText + /// + /// Returns completion results for PropertyType parameter. + /// + /// The command name. + /// The parameter name. + /// The word to complete. + /// The command AST. + /// The fake bound parameters. + /// List of Completion Results. + public IEnumerable CompleteArgument( + string commandName, + string parameterName, + string wordToComplete, + CommandAst commandAst, + IDictionary fakeBoundParameters) { - get + switch (parameterName) { -#if UNIX - return "`n"; -#else - return "`r`n"; -#endif + case "FormatString": + return CompletionHelpers.GetMatchingResults( + wordToComplete, + possibleCompletionValues: s_formatStringValues); + + case "Separator": + return CompletionHelpers.GetMatchingResults( + wordToComplete, + possibleCompletionValues: s_separatorValues, + listItemTextMapping: GetSeparatorListItemText, + toolTipMapping: GetSeparatorToolTip, + resultType: CompletionResultType.ParameterValue); } + + return Array.Empty(); } } } diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs index bd44185b460..ca62017753a 100644 --- a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs +++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs @@ -21,36 +21,67 @@ internal static class CompletionHelpers /// The word to complete. /// The possible completion values to iterate. /// The optional tool tip mapping delegate. + /// The optional list item text mapping delegate. /// The optional completion result type. Default is Text. - /// + /// List of matching completion results. internal static IEnumerable GetMatchingResults( string wordToComplete, IEnumerable possibleCompletionValues, Func toolTipMapping = null, + Func listItemTextMapping = null, CompletionResultType resultType = CompletionResultType.Text) { string quote = HandleDoubleAndSingleQuote(ref wordToComplete); - var pattern = WildcardPattern.Get(wordToComplete + "*", WildcardOptions.IgnoreCase); foreach (string value in possibleCompletionValues) { - if (pattern.IsMatch(value)) + if (IsMatch(value, wordToComplete)) { string completionText = QuoteCompletionText(value, quote); + string toolTip = toolTipMapping?.Invoke(value) ?? value; + string listItemText = listItemTextMapping?.Invoke(value) ?? value; - string listItemText = value; - - yield return new CompletionResult( - completionText, - listItemText, - resultType, - toolTip: toolTipMapping is null - ? listItemText - : toolTipMapping(value)); + yield return new CompletionResult(completionText, listItemText, resultType, toolTip); } } } + /// + /// Determines whether the given value matches the specified word or pattern. + /// + /// The input string to check for a match. + /// The word or partial word to compare against the input string. + /// + /// Returns true if the value matches the wordToComplete or the generated wildcard pattern; otherwise, false. + /// + /// + /// The method performs the following checks: + /// 1. If the value contains escaped newline characters, the wordToComplete is normalized + /// and unescaped, and a case-insensitive prefix match is performed. + /// 2. If either the value or wordToComplete contains wildcard characters, a case-insensitive + /// prefix match is performed. This is to protect against issues in WildcardPatternParser.Parse() + /// where strings like '[*' throw WildcardPatternException from wildcards not being escaped. + /// 3. If neither of the above conditions apply, a wildcard pattern is generated from the + /// wordToComplete, appending a wildcard character (*). The pattern is then used to match the value. + /// + internal static bool IsMatch(string value, string wordToComplete) + { + if (ContainsEscapedNewlineString(value)) + { + string normalizedWord = WildcardPattern.Unescape(wordToComplete.ReplaceLineEndings("`")); + return value.StartsWith(normalizedWord, StringComparison.OrdinalIgnoreCase); + } + else if (WildcardPattern.ContainsWildcardCharacters(value) || + WildcardPattern.ContainsWildcardCharacters(wordToComplete)) + { + return value.StartsWith(wordToComplete, StringComparison.OrdinalIgnoreCase); + } + + return WildcardPattern + .Get(wordToComplete + "*", WildcardOptions.IgnoreCase) + .IsMatch(value); + } + /// /// Removes wrapping quotes from a string and returns the quote used, if present. /// @@ -124,6 +155,7 @@ internal static string HandleDoubleAndSingleQuote(ref string wordToComplete) /// There are parsing errors in the input string. /// The parsed token count is not exactly two (the input token + EOF). /// The first token is a string or a PowerShell keyword containing special characters. + /// The first token is a semi colon or comma token. /// /// /// The input string to analyze for quoting requirements. @@ -139,15 +171,26 @@ internal static bool CompletionRequiresQuotes(string completion) Token firstToken = tokens[0]; bool isStringToken = firstToken is StringToken; bool isKeywordToken = (firstToken.TokenFlags & TokenFlags.Keyword) != 0; + bool isSemiToken = firstToken.Kind == TokenKind.Semi; + bool isCommaToken = firstToken.Kind == TokenKind.Comma; if ((!requireQuote && isStringToken) || (isExpectedTokenCount && isKeywordToken)) { requireQuote = ContainsCharsToCheck(firstToken.Text); } + else if (isExpectedTokenCount && (isSemiToken || isCommaToken)) + { + requireQuote = true; + } + return requireQuote; } + private static bool ContainsEscapedNewlineString(string text) + => text.Contains("`r`n", StringComparison.Ordinal) || + text.Contains("`n", StringComparison.Ordinal); + private static bool ContainsCharsToCheck(ReadOnlySpan text) => text.ContainsAny(s_defaultCharsToCheck); @@ -165,6 +208,12 @@ private static bool ContainsCharsToCheck(ReadOnlySpan text) /// internal static string QuoteCompletionText(string completionText, string quote) { + // Escaped newlines e.g. `r`n need be surrounded with double quotes + if (ContainsEscapedNewlineString(completionText)) + { + return "\"" + completionText + "\""; + } + if (!CompletionRequiresQuotes(completionText)) { return quote + completionText + quote; diff --git a/src/System.Management.Automation/resources/TabCompletionStrings.resx b/src/System.Management.Automation/resources/TabCompletionStrings.resx index c201facc3e2..751e04cfd2b 100644 --- a/src/System.Management.Automation/resources/TabCompletionStrings.resx +++ b/src/System.Management.Automation/resources/TabCompletionStrings.resx @@ -586,4 +586,25 @@ using type <AliasName> = <.NET-type> An unsupported registry data type. + + ',' - Comma + + + ', ' - Comma-Space + + + ';' - Semi-Colon + + + '; ' - Semi-Colon-Space + + + {0} - Newline + + + '-' - Dash + + + ' ' - Space + diff --git a/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 b/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 index c7d6ee744e0..7683a7e3720 100644 --- a/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 +++ b/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 @@ -1471,6 +1471,88 @@ param([ValidatePattern( } } + Context "Join-String -Separator & -FormatString parameter completion" { + BeforeAll { + if ($IsWindows) { + $allSeparators = "',' ', ' ';' '; ' ""``r``n"" '-' ' '" + $allFormatStrings = "'[{0}]' '{0:N2}' ""``r``n ```${0}"" ""``r``n [string] ```${0}""" + $newlineSeparator = """``r``n""" + $newlineFormatStrings = """``r``n ```${0}"" ""``r``n [string] ```${0}""" + } + else { + $allSeparators = "',' ', ' ';' '; ' ""``n"" '-' ' '" + $allFormatStrings = "'[{0}]' '{0:N2}' ""``n ```${0}"" ""``n [string] ```${0}""" + $newlineSeparator = """``n""" + $newlineFormatStrings = """``n ```${0}"" ""``n [string] ```${0}""" + } + + $commaSeparators = "',' ', '" + $semiColonSeparators = "';' '; '" + + $squareBracketFormatString = "'[{0}]'" + $curlyBraceFormatString = "'{0:N2}'" + } + + It "Should complete for ''" -TestCases @( + @{ TextInput = "Join-String -Separator "; Expected = $allSeparators } + @{ TextInput = "Join-String -Separator '"; Expected = $allSeparators } + @{ TextInput = "Join-String -Separator """; Expected = $allSeparators.Replace("'", """") } + @{ TextInput = "Join-String -Separator ',"; Expected = $commaSeparators } + @{ TextInput = "Join-String -Separator "","; Expected = $commaSeparators.Replace("'", """") } + @{ TextInput = "Join-String -Separator ';"; Expected = $semiColonSeparators } + @{ TextInput = "Join-String -Separator "";"; Expected = $semiColonSeparators.Replace("'", """") } + @{ TextInput = "Join-String -FormatString "; Expected = $allFormatStrings } + @{ TextInput = "Join-String -FormatString '"; Expected = $allFormatStrings } + @{ TextInput = "Join-String -FormatString """; Expected = $allFormatStrings.Replace("'", """") } + @{ TextInput = "Join-String -FormatString ["; Expected = $squareBracketFormatString } + @{ TextInput = "Join-String -FormatString '["; Expected = $squareBracketFormatString } + @{ TextInput = "Join-String -FormatString ""["; Expected = $squareBracketFormatString.Replace("'", """") } + @{ TextInput = "Join-String -FormatString '{"; Expected = $curlyBraceFormatString } + @{ TextInput = "Join-String -FormatString ""{"; Expected = $curlyBraceFormatString.Replace("'", """") } + ) { + param($TextInput, $Expected) + $res = TabExpansion2 -inputScript $TextInput -cursorColumn $TextInput.Length + $completionText = $res.CompletionMatches.CompletionText + $completionText -join ' ' | Should -BeExactly $Expected + } + + It "Should complete for ''" -Skip:(!$IsWindows) -TestCases @( + @{ TextInput = "Join-String -Separator '``"; Expected = $newlineSeparator } + @{ TextInput = "Join-String -Separator ""``"; Expected = $newlineSeparator } + @{ TextInput = "Join-String -Separator '``r"; Expected = $newlineSeparator } + @{ TextInput = "Join-String -Separator ""``r"; Expected = $newlineSeparator } + @{ TextInput = "Join-String -Separator '``r``"; Expected = $newlineSeparator } + @{ TextInput = "Join-String -Separator ""``r``"; Expected = $newlineSeparator } + @{ TextInput = "Join-String -FormatString '``"; Expected = $newlineFormatStrings } + @{ TextInput = "Join-String -FormatString ""``"; Expected = $newlineFormatStrings } + @{ TextInput = "Join-String -FormatString '``r"; Expected = $newlineFormatStrings } + @{ TextInput = "Join-String -FormatString ""``r"; Expected = $newlineFormatStrings } + @{ TextInput = "Join-String -FormatString '``r``"; Expected = $newlineFormatStrings } + @{ TextInput = "Join-String -FormatString ""``r``"; Expected = $newlineFormatStrings } + ) { + param($TextInput, $Expected) + $res = TabExpansion2 -inputScript $TextInput -cursorColumn $TextInput.Length + $completionText = $res.CompletionMatches.CompletionText + $completionText -join ' ' | Should -BeExactly $Expected + } + + It "Should complete for ''" -Skip:($IsWindows) -TestCases @( + @{ TextInput = "Join-String -Separator '``"; Expected = $newlineSeparator } + @{ TextInput = "Join-String -Separator ""``"; Expected = $newlineSeparator } + @{ TextInput = "Join-String -Separator '``n"; Expected = $newlineSeparator } + @{ TextInput = "Join-String -Separator ""``n"; Expected = $newlineSeparator } + @{ TextInput = "Join-String -FormatString '``"; Expected = $newlineFormatStrings } + @{ TextInput = "Join-String -FormatString ""``"; Expected = $newlineFormatStrings } + @{ TextInput = "Join-String -FormatString '``n"; Expected = $newlineFormatStrings } + @{ TextInput = "Join-String -FormatString ""``n"; Expected = $newlineFormatStrings } + ) { + param($TextInput, $Expected) + $res = TabExpansion2 -inputScript $TextInput -cursorColumn $TextInput.Length + $completionText = $res.CompletionMatches.CompletionText + $completionText -join ' ' | Should -BeExactly $Expected + } + } + Context "Format cmdlet's View paramter completion" { BeforeAll { $viewDefinition = @' diff --git a/test/xUnit/csharp/test_CompletionHelpers.cs b/test/xUnit/csharp/test_CompletionHelpers.cs index c91a478fd85..ee10b21baf8 100644 --- a/test/xUnit/csharp/test_CompletionHelpers.cs +++ b/test/xUnit/csharp/test_CompletionHelpers.cs @@ -38,6 +38,18 @@ public class CompletionHelpersTests [InlineData("key$word", "'", "'key$word'")] [InlineData("key$word", "", "'key$word'")] [InlineData("key$word", "\"", "\"key$word\"")] + [InlineData("`r`n", "\"", "\"`r`n\"")] + [InlineData("`r`n", "'", "\"`r`n\"")] + [InlineData("`r`n", "", "\"`r`n\"")] + [InlineData("`r`n `${0}", "\"", "\"`r`n `${0}\"")] + [InlineData("`r`n `${0}", "'", "\"`r`n `${0}\"")] + [InlineData("`r`n `${0}", "", "\"`r`n `${0}\"")] + [InlineData("`n", "\"", "\"`n\"")] + [InlineData("`n", "'", "\"`n\"")] + [InlineData("`n", "", "\"`n\"")] + [InlineData("`n `${0}", "\"", "\"`n `${0}\"")] + [InlineData("`n `${0}", "'", "\"`n `${0}\"")] + [InlineData("`n `${0}", "", "\"`n `${0}\"")] public void TestQuoteCompletionText( string completionText, string quote, @@ -66,6 +78,10 @@ public void TestQuoteCompletionText( [InlineData("\"", true)] [InlineData("'", true)] [InlineData("", true)] + [InlineData(";", true)] + [InlineData("; ", true)] + [InlineData(",", true)] + [InlineData(", ", true)] public void TestCompletionRequiresQuotes(string completion, bool expected) { bool result = CompletionHelpers.CompletionRequiresQuotes(completion); @@ -92,5 +108,28 @@ public void TestHandleDoubleAndSingleQuote(string wordToComplete, string expecte Assert.Equal(expectedQuote, quote); Assert.Equal(expectedWordToComplete, wordToComplete); } + + [Theory] + [InlineData("word", "word", true)] + [InlineData("Word", "word", true)] + [InlineData("word", "wor", true)] + [InlineData("word", "words", false)] + [InlineData("word`nnext", "word`n", true)] + [InlineData("word`nnext", "word\n", true)] + [InlineData("word`r`nnext", "word`r`n", true)] + [InlineData("word`r`nnext", "word\r\n", true)] + [InlineData("word;next", "word;", true)] + [InlineData("word,next", "word,", true)] + [InlineData("word[*]next", "word[*", true)] + [InlineData("word[abc]next", "word[abc", true)] + [InlineData("word", "word*", false)] + [InlineData("testword", "test", true)] + [InlineData("word", "", true)] + [InlineData("", "word", false)] + public void TestIsMatch(string value, string wordToComplete, bool expected) + { + bool result = CompletionHelpers.IsMatch(value, wordToComplete); + Assert.Equal(expected, result); + } } } From d4a5a7de529e6cfb4452f43d024d60bca084c380 Mon Sep 17 00:00:00 2001 From: ArmaanMcleod Date: Sun, 6 Apr 2025 00:34:20 +1100 Subject: [PATCH 02/31] CodeFactor: SA1413 Add trailing comma in multi-line initializers --- .../commands/utility/Join-String.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs index 3b95d3ee697..f63a5904e14 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs @@ -172,10 +172,10 @@ internal class JoinItemCompleter : IArgumentCompleter "{0:N2}", #if UNIX "`n `${0}", - "`n [string] `${0}" + "`n [string] `${0}", #else "`r`n `${0}", - "`r`n [string] `${0}" + "`r`n [string] `${0}", #endif }; @@ -194,7 +194,7 @@ internal class JoinItemCompleter : IArgumentCompleter "; ", NewLineText, "-", - " " + " ", }; private static readonly Dictionary s_separatorMappings = new() @@ -205,7 +205,7 @@ internal class JoinItemCompleter : IArgumentCompleter { "; ", (TabCompletionStrings.SeparatorSemiColonSpaceToolTip, "Semi-Colon-Space") }, { NewLineText, (StringUtil.Format(TabCompletionStrings.SeparatorNewlineToolTip, NewLineText), "Newline") }, { "-", (TabCompletionStrings.SeparatorDashToolTip, "Dash") }, - { " ", (TabCompletionStrings.SeparatorSpaceToolTip, "Space") } + { " ", (TabCompletionStrings.SeparatorSpaceToolTip, "Space") }, }; private static string GetSeparatorToolTip(string separator) From 401ac4962ed22ba9d2b8c174937dabae1cfaee8c Mon Sep 17 00:00:00 2001 From: ArmaanMcleod Date: Tue, 8 Apr 2025 23:22:31 +1000 Subject: [PATCH 03/31] Split into two argument completers --- .../commands/utility/Join-String.cs | 87 +++++++++++-------- 1 file changed, 49 insertions(+), 38 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs index f63a5904e14..ea511014ba2 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs @@ -41,7 +41,7 @@ public sealed class JoinStringCommand : PSCmdlet /// Gets or sets the delimiter to join the output with. /// [Parameter(Position = 1)] - [ArgumentCompleter(typeof(JoinItemCompleter))] + [ArgumentCompleter(typeof(SeparatorArgumentCompleter))] [AllowEmptyString] public string Separator { @@ -79,7 +79,7 @@ public string Separator /// Gets or sets a format string that is applied to each input object. /// [Parameter(ParameterSetName = "Format")] - [ArgumentCompleter(typeof(JoinItemCompleter))] + [ArgumentCompleter(typeof(FormatStringArgumentCompleter))] public string FormatString { get; set; } /// @@ -160,25 +160,11 @@ protected override void EndProcessing() } } - [SuppressMessage( - "Microsoft.Performance", - "CA1812:AvoidUninstantiatedInternalClasses", - Justification = "Class is instantiated through late-bound reflection")] - internal class JoinItemCompleter : IArgumentCompleter + /// + /// Provides completion for the Separator parameter of the Join-String cmdlet. + /// + public class SeparatorArgumentCompleter : IArgumentCompleter { - private static readonly IReadOnlyList s_formatStringValues = new List - { - "[{0}]", - "{0:N2}", -#if UNIX - "`n `${0}", - "`n [string] `${0}", -#else - "`r`n `${0}", - "`r`n [string] `${0}", -#endif - }; - private static readonly string NewLineText = #if UNIX "`n"; @@ -219,7 +205,7 @@ private static string GetSeparatorListItemText(string separator) : separator; /// - /// Returns completion results for PropertyType parameter. + /// Returns completion results for Separator parameter. /// /// The command name. /// The parameter name. @@ -233,24 +219,49 @@ public IEnumerable CompleteArgument( string wordToComplete, CommandAst commandAst, IDictionary fakeBoundParameters) - { - switch (parameterName) - { - case "FormatString": - return CompletionHelpers.GetMatchingResults( - wordToComplete, - possibleCompletionValues: s_formatStringValues); + => CompletionHelpers.GetMatchingResults( + wordToComplete, + possibleCompletionValues: s_separatorValues, + listItemTextMapping: GetSeparatorListItemText, + toolTipMapping: GetSeparatorToolTip, + resultType: CompletionResultType.ParameterValue); + } - case "Separator": - return CompletionHelpers.GetMatchingResults( - wordToComplete, - possibleCompletionValues: s_separatorValues, - listItemTextMapping: GetSeparatorListItemText, - toolTipMapping: GetSeparatorToolTip, - resultType: CompletionResultType.ParameterValue); - } + /// + /// Provides completion for the FormatString parameter of the Join-String cmdlet. + /// + public class FormatStringArgumentCompleter : IArgumentCompleter + { + private static readonly IReadOnlyList s_formatStringValues = new List + { + "[{0}]", + "{0:N2}", +#if UNIX + "`n `${0}", + "`n [string] `${0}", +#else + "`r`n `${0}", + "`r`n [string] `${0}", +#endif + }; - return Array.Empty(); - } + /// + /// Returns completion results for FormatString parameter. + /// + /// The command name. + /// The parameter name. + /// The word to complete. + /// The command AST. + /// The fake bound parameters. + /// List of Completion Results. + public IEnumerable CompleteArgument( + string commandName, + string parameterName, + string wordToComplete, + CommandAst commandAst, + IDictionary fakeBoundParameters) + => CompletionHelpers.GetMatchingResults( + wordToComplete, + possibleCompletionValues: s_formatStringValues); } } From 51d9437af07fffd8e6239de3319b772245d1fc0e Mon Sep 17 00:00:00 2001 From: Armaan Mcleod Date: Wed, 9 Apr 2025 06:35:34 +1000 Subject: [PATCH 04/31] Update src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs Co-authored-by: Ilya --- .../engine/CommandCompletion/CompletionHelpers.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs index ca62017753a..55438438b9c 100644 --- a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs +++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs @@ -188,8 +188,7 @@ internal static bool CompletionRequiresQuotes(string completion) } private static bool ContainsEscapedNewlineString(string text) - => text.Contains("`r`n", StringComparison.Ordinal) || - text.Contains("`n", StringComparison.Ordinal); + => text.Contains("`n", StringComparison.Ordinal); private static bool ContainsCharsToCheck(ReadOnlySpan text) => text.ContainsAny(s_defaultCharsToCheck); From 20ccaa78671045cbe64ca20fe770bb6fc1bb7b01 Mon Sep 17 00:00:00 2001 From: ArmaanMcleod Date: Wed, 9 Apr 2025 21:56:14 +1000 Subject: [PATCH 05/31] Fix IsMatch method and test --- .../CommandCompletion/CompletionHelpers.cs | 42 ++++++++----------- test/xUnit/csharp/test_CompletionHelpers.cs | 2 +- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs index 55438438b9c..49c73dca52a 100644 --- a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs +++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs @@ -47,39 +47,33 @@ internal static IEnumerable GetMatchingResults( } /// - /// Determines whether the given value matches the specified word or pattern. + /// Checks if the given value matches the specified word or pattern. /// - /// The input string to check for a match. - /// The word or partial word to compare against the input string. + /// The input string to evaluate. + /// The word or pattern to compare against. /// - /// Returns true if the value matches the wordToComplete or the generated wildcard pattern; otherwise, false. + /// true if the value matches the normalized word (case-insensitively) or the wildcard pattern; otherwise, false. /// /// - /// The method performs the following checks: - /// 1. If the value contains escaped newline characters, the wordToComplete is normalized - /// and unescaped, and a case-insensitive prefix match is performed. - /// 2. If either the value or wordToComplete contains wildcard characters, a case-insensitive - /// prefix match is performed. This is to protect against issues in WildcardPatternParser.Parse() - /// where strings like '[*' throw WildcardPatternException from wildcards not being escaped. - /// 3. If neither of the above conditions apply, a wildcard pattern is generated from the - /// wordToComplete, appending a wildcard character (*). The pattern is then used to match the value. + /// This method normalizes word to complete by replacing its line endings with backticks + /// and unescaping special characters. + /// It first does a a case-insensitive literal prefix match. + /// If the literal match fails, the input value is then evaluated against this case-insensitive wildcard pattern. /// internal static bool IsMatch(string value, string wordToComplete) { - if (ContainsEscapedNewlineString(value)) - { - string normalizedWord = WildcardPattern.Unescape(wordToComplete.ReplaceLineEndings("`")); - return value.StartsWith(normalizedWord, StringComparison.OrdinalIgnoreCase); - } - else if (WildcardPattern.ContainsWildcardCharacters(value) || - WildcardPattern.ContainsWildcardCharacters(wordToComplete)) + string normalizedWord = WildcardPattern.Unescape(wordToComplete.ReplaceLineEndings("`")); + + bool match = value.StartsWith(normalizedWord, StringComparison.OrdinalIgnoreCase); + + if (!match) { - return value.StartsWith(wordToComplete, StringComparison.OrdinalIgnoreCase); + match = WildcardPattern + .Get(wordToComplete + "*", WildcardOptions.IgnoreCase) + .IsMatch(value); } - return WildcardPattern - .Get(wordToComplete + "*", WildcardOptions.IgnoreCase) - .IsMatch(value); + return match; } /// @@ -136,7 +130,7 @@ internal static string HandleDoubleAndSingleQuote(ref string wordToComplete) return quoteInUse; } - bool hasFrontQuoteAndNoBackQuote = + bool hasFrontQuoteAndNoBackQuote = (hasFrontSingleQuote || hasFrontDoubleQuote) && !hasBackSingleQuote && !hasBackDoubleQuote; if (hasFrontQuoteAndNoBackQuote) diff --git a/test/xUnit/csharp/test_CompletionHelpers.cs b/test/xUnit/csharp/test_CompletionHelpers.cs index ee10b21baf8..85686dd3892 100644 --- a/test/xUnit/csharp/test_CompletionHelpers.cs +++ b/test/xUnit/csharp/test_CompletionHelpers.cs @@ -122,7 +122,7 @@ public void TestHandleDoubleAndSingleQuote(string wordToComplete, string expecte [InlineData("word,next", "word,", true)] [InlineData("word[*]next", "word[*", true)] [InlineData("word[abc]next", "word[abc", true)] - [InlineData("word", "word*", false)] + [InlineData("word", "word*", true)] [InlineData("testword", "test", true)] [InlineData("word", "", true)] [InlineData("", "word", false)] From 8d556b42c7db0caefc3145753f4a289882d5a0e9 Mon Sep 17 00:00:00 2001 From: Armaan Mcleod Date: Thu, 10 Apr 2025 06:37:48 +1000 Subject: [PATCH 06/31] Update src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs Co-authored-by: Ilya --- .../commands/utility/Join-String.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs index ea511014ba2..2a5d3f6383e 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs @@ -167,9 +167,9 @@ public class SeparatorArgumentCompleter : IArgumentCompleter { private static readonly string NewLineText = #if UNIX - "`n"; + "`n"; #else - "`r`n"; + "`r`n"; #endif private static readonly IReadOnlyList s_separatorValues = new List From 163a6ec99951895e93eff345cf69d8230af19c67 Mon Sep 17 00:00:00 2001 From: ArmaanMcleod Date: Thu, 10 Apr 2025 06:41:36 +1000 Subject: [PATCH 07/31] Fixed indentation --- .../commands/utility/Join-String.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs index 2a5d3f6383e..de3f70d805c 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs @@ -237,11 +237,11 @@ public class FormatStringArgumentCompleter : IArgumentCompleter "[{0}]", "{0:N2}", #if UNIX - "`n `${0}", - "`n [string] `${0}", + "`n `${0}", + "`n [string] `${0}", #else - "`r`n `${0}", - "`r`n [string] `${0}", + "`r`n `${0}", + "`r`n [string] `${0}", #endif }; From 09818bba00a5a55fa8c7cff7f4867c2ef484ba2b Mon Sep 17 00:00:00 2001 From: ArmaanMcleod Date: Fri, 11 Apr 2025 06:53:59 +1000 Subject: [PATCH 08/31] Escape word to complete before it gets to WildcardPattern.Get --- .../engine/CommandCompletion/CompletionHelpers.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs index 49c73dca52a..10612c82e67 100644 --- a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs +++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs @@ -59,6 +59,8 @@ internal static IEnumerable GetMatchingResults( /// and unescaping special characters. /// It first does a a case-insensitive literal prefix match. /// If the literal match fails, the input value is then evaluated against this case-insensitive wildcard pattern. + /// To prevent any exceptions when constructing wildcard pattern with existing wildcard characters, + /// the word to complete is normalized by escaping wildcard characters first. /// internal static bool IsMatch(string value, string wordToComplete) { @@ -68,8 +70,10 @@ internal static bool IsMatch(string value, string wordToComplete) if (!match) { + normalizedWord = WildcardPattern.Escape(wordToComplete); + match = WildcardPattern - .Get(wordToComplete + "*", WildcardOptions.IgnoreCase) + .Get(normalizedWord + "*", WildcardOptions.IgnoreCase) .IsMatch(value); } From d57f8c8c5f538cdb273f24c492afa092058d3848 Mon Sep 17 00:00:00 2001 From: ArmaanMcleod Date: Fri, 11 Apr 2025 07:15:09 +1000 Subject: [PATCH 09/31] Fix Xunit test --- test/xUnit/csharp/test_CompletionHelpers.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/xUnit/csharp/test_CompletionHelpers.cs b/test/xUnit/csharp/test_CompletionHelpers.cs index 85686dd3892..ee10b21baf8 100644 --- a/test/xUnit/csharp/test_CompletionHelpers.cs +++ b/test/xUnit/csharp/test_CompletionHelpers.cs @@ -122,7 +122,7 @@ public void TestHandleDoubleAndSingleQuote(string wordToComplete, string expecte [InlineData("word,next", "word,", true)] [InlineData("word[*]next", "word[*", true)] [InlineData("word[abc]next", "word[abc", true)] - [InlineData("word", "word*", true)] + [InlineData("word", "word*", false)] [InlineData("testword", "test", true)] [InlineData("word", "", true)] [InlineData("", "word", false)] From 33ca0a73cecbb05762a94f6bdf361da17f0fb02b Mon Sep 17 00:00:00 2001 From: ArmaanMcleod Date: Sat, 12 Apr 2025 23:29:10 +1000 Subject: [PATCH 10/31] Add IsMatch delegate which includes escape strategy delegate --- .../commands/utility/Join-String.cs | 3 +- .../CommandCompletion/CompletionHelpers.cs | 35 ++++++++++--------- test/xUnit/csharp/test_CompletionHelpers.cs | 17 ++++++++- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs index de3f70d805c..2e56f011aa5 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs @@ -262,6 +262,7 @@ public IEnumerable CompleteArgument( IDictionary fakeBoundParameters) => CompletionHelpers.GetMatchingResults( wordToComplete, - possibleCompletionValues: s_formatStringValues); + possibleCompletionValues: s_formatStringValues, + escapeStrategy: WildcardPattern.Escape); } } diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs index 10612c82e67..3092f15c778 100644 --- a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs +++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs @@ -23,19 +23,23 @@ internal static class CompletionHelpers /// The optional tool tip mapping delegate. /// The optional list item text mapping delegate. /// The optional completion result type. Default is Text. + /// + /// A delegate that specifies how to escape the word to complete. If null, no escaping is applied. + /// /// List of matching completion results. internal static IEnumerable GetMatchingResults( string wordToComplete, IEnumerable possibleCompletionValues, Func toolTipMapping = null, Func listItemTextMapping = null, - CompletionResultType resultType = CompletionResultType.Text) + CompletionResultType resultType = CompletionResultType.Text, + Func escapeStrategy = null) { string quote = HandleDoubleAndSingleQuote(ref wordToComplete); foreach (string value in possibleCompletionValues) { - if (IsMatch(value, wordToComplete)) + if (IsMatch(value, wordToComplete, escapeStrategy)) { string completionText = QuoteCompletionText(value, quote); string toolTip = toolTipMapping?.Invoke(value) ?? value; @@ -47,22 +51,19 @@ internal static IEnumerable GetMatchingResults( } /// - /// Checks if the given value matches the specified word or pattern. + /// Represents a method that checks whether a value matches a word or pattern. /// - /// The input string to evaluate. + /// The input string to evaluate for a match. /// The word or pattern to compare against. + /// + /// A delegate that specifies how to escape the word to complete. If null, no escaping is applied. + /// /// - /// true if the value matches the normalized word (case-insensitively) or the wildcard pattern; otherwise, false. + /// true if the value matches the word (case-insensitively) or the wildcard pattern; otherwise, false. /// - /// - /// This method normalizes word to complete by replacing its line endings with backticks - /// and unescaping special characters. - /// It first does a a case-insensitive literal prefix match. - /// If the literal match fails, the input value is then evaluated against this case-insensitive wildcard pattern. - /// To prevent any exceptions when constructing wildcard pattern with existing wildcard characters, - /// the word to complete is normalized by escaping wildcard characters first. - /// - internal static bool IsMatch(string value, string wordToComplete) + internal delegate bool MatchDelegate(string value, string wordToComplete, Func escapeStrategy = null); + + internal static readonly MatchDelegate IsMatch = (value, wordToComplete, escapeStrategy) => { string normalizedWord = WildcardPattern.Unescape(wordToComplete.ReplaceLineEndings("`")); @@ -70,15 +71,15 @@ internal static bool IsMatch(string value, string wordToComplete) if (!match) { - normalizedWord = WildcardPattern.Escape(wordToComplete); + string escapedWord = escapeStrategy?.Invoke(wordToComplete) ?? wordToComplete; match = WildcardPattern - .Get(normalizedWord + "*", WildcardOptions.IgnoreCase) + .Get(escapedWord + "*", WildcardOptions.IgnoreCase) .IsMatch(value); } return match; - } + }; /// /// Removes wrapping quotes from a string and returns the quote used, if present. diff --git a/test/xUnit/csharp/test_CompletionHelpers.cs b/test/xUnit/csharp/test_CompletionHelpers.cs index ee10b21baf8..cf20075d634 100644 --- a/test/xUnit/csharp/test_CompletionHelpers.cs +++ b/test/xUnit/csharp/test_CompletionHelpers.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; using System.Management.Automation; using Xunit; @@ -122,7 +123,9 @@ public void TestHandleDoubleAndSingleQuote(string wordToComplete, string expecte [InlineData("word,next", "word,", true)] [InlineData("word[*]next", "word[*", true)] [InlineData("word[abc]next", "word[abc", true)] - [InlineData("word", "word*", false)] + [InlineData("word", "word*", true)] + [InlineData("Word", "word*", true)] + [InlineData("word(Special)", "word*", true)] [InlineData("testword", "test", true)] [InlineData("word", "", true)] [InlineData("", "word", false)] @@ -131,5 +134,17 @@ public void TestIsMatch(string value, string wordToComplete, bool expected) bool result = CompletionHelpers.IsMatch(value, wordToComplete); Assert.Equal(expected, result); } + + [Theory] + [InlineData("word", "word", true)] + [InlineData("word", "word*", false)] + [InlineData("Word", "word*", false)] + [InlineData("word[Special]", "word[*", false)] + public void TestIsMatchWithEscapeStrategy(string value, string wordToComplete, bool expected) + { + Func escapeStrategy = WildcardPattern.Escape; + bool result = CompletionHelpers.IsMatch(value, wordToComplete, escapeStrategy); + Assert.Equal(expected, result); + } } } From f159a3315826174ad95b71ba289fe947d6922e3d Mon Sep 17 00:00:00 2001 From: ArmaanMcleod Date: Sun, 13 Apr 2025 12:46:21 +1000 Subject: [PATCH 11/31] Add MatchStrategy delegates --- .../commands/utility/Join-String.cs | 2 +- .../CommandCompletion/CompletionHelpers.cs | 47 +++++++------------ test/xUnit/csharp/test_CompletionHelpers.cs | 25 +++++++--- 3 files changed, 36 insertions(+), 38 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs index 2e56f011aa5..c90897bb47c 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs @@ -263,6 +263,6 @@ public IEnumerable CompleteArgument( => CompletionHelpers.GetMatchingResults( wordToComplete, possibleCompletionValues: s_formatStringValues, - escapeStrategy: WildcardPattern.Escape); + matchStrategy: CompletionHelpers.WildcardPatternEscapeMatch); } } diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs index 3092f15c778..6164a664fcc 100644 --- a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs +++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs @@ -23,9 +23,7 @@ internal static class CompletionHelpers /// The optional tool tip mapping delegate. /// The optional list item text mapping delegate. /// The optional completion result type. Default is Text. - /// - /// A delegate that specifies how to escape the word to complete. If null, no escaping is applied. - /// + /// The optional match strategy delegate. /// List of matching completion results. internal static IEnumerable GetMatchingResults( string wordToComplete, @@ -33,13 +31,15 @@ internal static IEnumerable GetMatchingResults( Func toolTipMapping = null, Func listItemTextMapping = null, CompletionResultType resultType = CompletionResultType.Text, - Func escapeStrategy = null) + MatchStrategy matchStrategy = null) { string quote = HandleDoubleAndSingleQuote(ref wordToComplete); + matchStrategy ??= DefaultMatch; + foreach (string value in possibleCompletionValues) { - if (IsMatch(value, wordToComplete, escapeStrategy)) + if (matchStrategy(value, wordToComplete)) { string completionText = QuoteCompletionText(value, quote); string toolTip = toolTipMapping?.Invoke(value) ?? value; @@ -50,36 +50,21 @@ internal static IEnumerable GetMatchingResults( } } - /// - /// Represents a method that checks whether a value matches a word or pattern. - /// - /// The input string to evaluate for a match. - /// The word or pattern to compare against. - /// - /// A delegate that specifies how to escape the word to complete. If null, no escaping is applied. - /// - /// - /// true if the value matches the word (case-insensitively) or the wildcard pattern; otherwise, false. - /// - internal delegate bool MatchDelegate(string value, string wordToComplete, Func escapeStrategy = null); + internal delegate bool MatchStrategy(string value, string wordToComplete); - internal static readonly MatchDelegate IsMatch = (value, wordToComplete, escapeStrategy) => - { - string normalizedWord = WildcardPattern.Unescape(wordToComplete.ReplaceLineEndings("`")); + internal static readonly MatchStrategy LiteralMatch = (value, wordToComplete) + => value.StartsWith(WildcardPattern.Unescape(wordToComplete.ReplaceLineEndings("`")), StringComparison.OrdinalIgnoreCase); - bool match = value.StartsWith(normalizedWord, StringComparison.OrdinalIgnoreCase); + internal static readonly MatchStrategy WildcardPatternMatch = (value, wordToComplete) + => WildcardPattern + .Get(wordToComplete + "*", WildcardOptions.IgnoreCase) + .IsMatch(value); - if (!match) - { - string escapedWord = escapeStrategy?.Invoke(wordToComplete) ?? wordToComplete; - - match = WildcardPattern - .Get(escapedWord + "*", WildcardOptions.IgnoreCase) - .IsMatch(value); - } + internal static readonly MatchStrategy WildcardPatternEscapeMatch = (value, wordToComplete) + => LiteralMatch(value, wordToComplete) || WildcardPatternMatch(value, WildcardPattern.Escape(wordToComplete)); - return match; - }; + internal static readonly MatchStrategy DefaultMatch = (value, wordToComplete) + => LiteralMatch(value, wordToComplete) || WildcardPatternMatch(value, wordToComplete); /// /// Removes wrapping quotes from a string and returns the quote used, if present. diff --git a/test/xUnit/csharp/test_CompletionHelpers.cs b/test/xUnit/csharp/test_CompletionHelpers.cs index cf20075d634..9387ad395bf 100644 --- a/test/xUnit/csharp/test_CompletionHelpers.cs +++ b/test/xUnit/csharp/test_CompletionHelpers.cs @@ -129,21 +129,34 @@ public void TestHandleDoubleAndSingleQuote(string wordToComplete, string expecte [InlineData("testword", "test", true)] [InlineData("word", "", true)] [InlineData("", "word", false)] - public void TestIsMatch(string value, string wordToComplete, bool expected) + public void TestDefaultMatch(string value, string wordToComplete, bool expected) { - bool result = CompletionHelpers.IsMatch(value, wordToComplete); + bool result = CompletionHelpers.DefaultMatch(value, wordToComplete); Assert.Equal(expected, result); } [Theory] [InlineData("word", "word", true)] + [InlineData("Word", "word", true)] + [InlineData("word", "wor", true)] + [InlineData("word", "words", false)] + [InlineData("word`nnext", "word`n", true)] + [InlineData("word`nnext", "word\n", true)] + [InlineData("word`r`nnext", "word`r`n", true)] + [InlineData("word`r`nnext", "word\r\n", true)] + [InlineData("word;next", "word;", true)] + [InlineData("word,next", "word,", true)] + [InlineData("word[*]next", "word[*", true)] + [InlineData("word[abc]next", "word[abc", true)] [InlineData("word", "word*", false)] [InlineData("Word", "word*", false)] - [InlineData("word[Special]", "word[*", false)] - public void TestIsMatchWithEscapeStrategy(string value, string wordToComplete, bool expected) + [InlineData("word(Special)", "word*", false)] + [InlineData("testword", "test", true)] + [InlineData("word", "", true)] + [InlineData("", "word", false)] + public void TestWildcardPatternEscapeMatch(string value, string wordToComplete, bool expected) { - Func escapeStrategy = WildcardPattern.Escape; - bool result = CompletionHelpers.IsMatch(value, wordToComplete, escapeStrategy); + bool result = CompletionHelpers.WildcardPatternEscapeMatch(value, wordToComplete); Assert.Equal(expected, result); } } From 99f2e3a8441e93a43eed6bd1c1df7ee8ab2b1f31 Mon Sep 17 00:00:00 2001 From: ArmaanMcleod Date: Sun, 13 Apr 2025 12:49:49 +1000 Subject: [PATCH 12/31] Wrap delegates on multiple lines --- .../engine/CommandCompletion/CompletionHelpers.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs index 6164a664fcc..0f141aa8cba 100644 --- a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs +++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs @@ -53,7 +53,9 @@ internal static IEnumerable GetMatchingResults( internal delegate bool MatchStrategy(string value, string wordToComplete); internal static readonly MatchStrategy LiteralMatch = (value, wordToComplete) - => value.StartsWith(WildcardPattern.Unescape(wordToComplete.ReplaceLineEndings("`")), StringComparison.OrdinalIgnoreCase); + => value.StartsWith( + WildcardPattern.Unescape(wordToComplete.ReplaceLineEndings("`")), + StringComparison.OrdinalIgnoreCase); internal static readonly MatchStrategy WildcardPatternMatch = (value, wordToComplete) => WildcardPattern @@ -61,10 +63,12 @@ internal static IEnumerable GetMatchingResults( .IsMatch(value); internal static readonly MatchStrategy WildcardPatternEscapeMatch = (value, wordToComplete) - => LiteralMatch(value, wordToComplete) || WildcardPatternMatch(value, WildcardPattern.Escape(wordToComplete)); + => LiteralMatch(value, wordToComplete) || + WildcardPatternMatch(value, WildcardPattern.Escape(wordToComplete)); internal static readonly MatchStrategy DefaultMatch = (value, wordToComplete) - => LiteralMatch(value, wordToComplete) || WildcardPatternMatch(value, wordToComplete); + => LiteralMatch(value, wordToComplete) || + WildcardPatternMatch(value, wordToComplete); /// /// Removes wrapping quotes from a string and returns the quote used, if present. From 5a2ab035e905a145df78b7d0064f00c3439b1b6e Mon Sep 17 00:00:00 2001 From: ArmaanMcleod Date: Sun, 13 Apr 2025 18:18:56 +1000 Subject: [PATCH 13/31] Remove separate list for separator values and extract keys from mapping instead --- .../commands/utility/Join-String.cs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs index c90897bb47c..e79d76f0e9e 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs @@ -172,17 +172,6 @@ public class SeparatorArgumentCompleter : IArgumentCompleter "`r`n"; #endif - private static readonly IReadOnlyList s_separatorValues = new List - { - ",", - ", ", - ";", - "; ", - NewLineText, - "-", - " ", - }; - private static readonly Dictionary s_separatorMappings = new() { { ",", (TabCompletionStrings.SeparatorCommaToolTip, "Comma") }, @@ -194,6 +183,8 @@ public class SeparatorArgumentCompleter : IArgumentCompleter { " ", (TabCompletionStrings.SeparatorSpaceToolTip, "Space") }, }; + private static readonly IEnumerable s_separatorValues = s_separatorMappings.Keys; + private static string GetSeparatorToolTip(string separator) => s_separatorMappings.TryGetValue(separator, out var mapping) ? mapping.Tooltip From de63820f08c84cdaf7cfb3d9bef341c3cd1e5aa9 Mon Sep 17 00:00:00 2001 From: ArmaanMcleod Date: Sun, 13 Apr 2025 18:19:14 +1000 Subject: [PATCH 14/31] Make completer classes sealed --- .../commands/utility/Join-String.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs index e79d76f0e9e..9f1d0ef8c9c 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs @@ -163,7 +163,7 @@ protected override void EndProcessing() /// /// Provides completion for the Separator parameter of the Join-String cmdlet. /// - public class SeparatorArgumentCompleter : IArgumentCompleter + public sealed class SeparatorArgumentCompleter : IArgumentCompleter { private static readonly string NewLineText = #if UNIX @@ -221,7 +221,7 @@ public IEnumerable CompleteArgument( /// /// Provides completion for the FormatString parameter of the Join-String cmdlet. /// - public class FormatStringArgumentCompleter : IArgumentCompleter + public sealed class FormatStringArgumentCompleter : IArgumentCompleter { private static readonly IReadOnlyList s_formatStringValues = new List { From 38bc0a7ee63c6bfe6be124a1f09311a012b726ee Mon Sep 17 00:00:00 2001 From: ArmaanMcleod Date: Sun, 13 Apr 2025 18:21:31 +1000 Subject: [PATCH 15/31] Set capacity on collections --- .../commands/utility/Join-String.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs index 9f1d0ef8c9c..b9fc29d45fe 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Join-String.cs @@ -172,7 +172,7 @@ public sealed class SeparatorArgumentCompleter : IArgumentCompleter "`r`n"; #endif - private static readonly Dictionary s_separatorMappings = new() + private static readonly Dictionary s_separatorMappings = new(capacity: 7) { { ",", (TabCompletionStrings.SeparatorCommaToolTip, "Comma") }, { ", ", (TabCompletionStrings.SeparatorCommaSpaceToolTip, "Comma-Space") }, @@ -223,7 +223,7 @@ public IEnumerable CompleteArgument( /// public sealed class FormatStringArgumentCompleter : IArgumentCompleter { - private static readonly IReadOnlyList s_formatStringValues = new List + private static readonly IReadOnlyList s_formatStringValues = new List(capacity: 4) { "[{0}]", "{0:N2}", From 231f7bc0c0fe34ddbfab03f4ba1324dfe6a2a676 Mon Sep 17 00:00:00 2001 From: ArmaanMcleod Date: Sun, 13 Apr 2025 19:10:24 +1000 Subject: [PATCH 16/31] Add XML documentation for methods --- .../CommandCompletion/CompletionHelpers.cs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs index 0f141aa8cba..ecf31cb060f 100644 --- a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs +++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs @@ -50,22 +50,76 @@ internal static IEnumerable GetMatchingResults( } } + /// + /// Defines a strategy for determining if a value matches a word or pattern. + /// + /// The input string to check for a match. + /// The word or pattern to match against. + /// + /// true if the value matches the specified word or pattern; otherwise, false. + /// internal delegate bool MatchStrategy(string value, string wordToComplete); + /// + /// Determines if the given value matches the specified word using a literal, case-insensitive prefix match. + /// + /// + /// true if the value starts with the normalized word (case-insensitively); otherwise, false. + /// + /// + /// The word to complete is normalized by replacing line endings with backticks and unescaping special characters. + /// This normalization ensures that variations in line-ending representations, such as "\r\n" (Windows) & "\n" (UNIX), + /// do not interfere with the matching logic. Without normalization, comparisons will fail. + /// + /// Example: + /// For instance, if the value is "`r`n" and wordToComplete is "\r", + /// the prefix match will fail due to differing line-ending formats. Normalizing both strings to use + /// backticks allows the comparison to succeed. + /// internal static readonly MatchStrategy LiteralMatch = (value, wordToComplete) => value.StartsWith( WildcardPattern.Unescape(wordToComplete.ReplaceLineEndings("`")), StringComparison.OrdinalIgnoreCase); + /// + /// Determines if the given value matches the specified word using wildcard pattern matching. + /// + /// + /// true if the value matches the word as a wildcard pattern; otherwise, false. + /// + /// + /// Wildcard pattern matching allows for flexible matching, where wilcards can represent + /// multiple characters in the input. This strategy is case-insensitive. + /// internal static readonly MatchStrategy WildcardPatternMatch = (value, wordToComplete) => WildcardPattern .Get(wordToComplete + "*", WildcardOptions.IgnoreCase) .IsMatch(value); + /// + /// Determines if the given value matches the specified word using either a literal or wildcard escape strategy. + /// + /// + /// true if the value matches either the literal normalized word or the wildcard pattern with escaping; + /// otherwise, false. + /// + /// + /// This strategy first attempts a literal prefix match and, if unsuccessful, escapes the word to complete to + /// handle any problematic wildcard characters before performing a wildcard match. + /// internal static readonly MatchStrategy WildcardPatternEscapeMatch = (value, wordToComplete) => LiteralMatch(value, wordToComplete) || WildcardPatternMatch(value, WildcardPattern.Escape(wordToComplete)); + /// + /// Determines if the given value matches the specified word using either a literal or wildcard match strategy. + /// + /// + /// true if the value matches either the literal normalized word or the wildcard pattern; otherwise, false. + /// + /// + /// This strategy attempts a literal match first and, if unsuccessful, evaluates the word against a wildcard pattern. + /// internal static readonly MatchStrategy DefaultMatch = (value, wordToComplete) => LiteralMatch(value, wordToComplete) || WildcardPatternMatch(value, wordToComplete); @@ -175,6 +229,14 @@ internal static bool CompletionRequiresQuotes(string completion) return requireQuote; } + /// + /// Determines whether the given text contains an escaped newline string. + /// + /// The input string to check for escaped newlines. + /// + /// true if the text contains the escaped Unix-style newline string ("`n") or + /// the Windows-style newline string ("`r`n"); otherwise, false. + /// private static bool ContainsEscapedNewlineString(string text) => text.Contains("`n", StringComparison.Ordinal); From 8066ef5ce0c27474473425978dd639b69027feda Mon Sep 17 00:00:00 2001 From: ArmaanMcleod Date: Mon, 14 Apr 2025 20:46:17 +1000 Subject: [PATCH 17/31] Fix normalization with newlines --- .../engine/CommandCompletion/CompletionHelpers.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs index ecf31cb060f..157c9fd59fc 100644 --- a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs +++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs @@ -67,7 +67,7 @@ internal static IEnumerable GetMatchingResults( /// true if the value starts with the normalized word (case-insensitively); otherwise, false. /// /// - /// The word to complete is normalized by replacing line endings with backticks and unescaping special characters. + /// The word to complete is normalized by replacing line endings with backticks. /// This normalization ensures that variations in line-ending representations, such as "\r\n" (Windows) & "\n" (UNIX), /// do not interfere with the matching logic. Without normalization, comparisons will fail. /// @@ -76,10 +76,11 @@ internal static IEnumerable GetMatchingResults( /// the prefix match will fail due to differing line-ending formats. Normalizing both strings to use /// backticks allows the comparison to succeed. /// - internal static readonly MatchStrategy LiteralMatch = (value, wordToComplete) - => value.StartsWith( - WildcardPattern.Unescape(wordToComplete.ReplaceLineEndings("`")), - StringComparison.OrdinalIgnoreCase); + internal static readonly MatchStrategy LiteralMatch = (value, wordToComplete) => + { + string normalisedWordNewlines = wordToComplete.Replace("\r", "`r").Replace("\n", "`n"); + return value.StartsWith(normalisedWordNewlines, StringComparison.OrdinalIgnoreCase); + }; /// /// Determines if the given value matches the specified word using wildcard pattern matching. From cd0b123927bc6ae990d79fe4963516c654be29ca Mon Sep 17 00:00:00 2001 From: ArmaanMcleod Date: Fri, 18 Apr 2025 09:49:01 +1000 Subject: [PATCH 18/31] Add const for quotes --- .../engine/CommandCompletion/CompletionHelpers.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs index 157c9fd59fc..147f47a501b 100644 --- a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs +++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs @@ -14,6 +14,9 @@ internal static class CompletionHelpers { private static readonly SearchValues s_defaultCharsToCheck = SearchValues.Create("$`"); + private const string SingleQuote = "'"; + private const string DoubleQuote = "\""; + /// /// Get matching completions from word to complete. /// This makes it easier to handle different variations of completions with consideration of quotes. @@ -157,7 +160,7 @@ internal static string HandleDoubleAndSingleQuote(ref string wordToComplete) return string.Empty; } - string quoteInUse = hasFrontSingleQuote ? "'" : "\""; + string quoteInUse = hasFrontSingleQuote ? SingleQuote : DoubleQuote; int length = wordToComplete.Length; if (length == 1) @@ -261,7 +264,7 @@ internal static string QuoteCompletionText(string completionText, string quote) // Escaped newlines e.g. `r`n need be surrounded with double quotes if (ContainsEscapedNewlineString(completionText)) { - return "\"" + completionText + "\""; + return DoubleQuote + completionText + DoubleQuote; } if (!CompletionRequiresQuotes(completionText)) @@ -269,9 +272,9 @@ internal static string QuoteCompletionText(string completionText, string quote) return quote + completionText + quote; } - string quoteInUse = string.IsNullOrEmpty(quote) ? "'" : quote; + string quoteInUse = string.IsNullOrEmpty(quote) ? SingleQuote : quote; - if (quoteInUse == "'") + if (quoteInUse == SingleQuote) { completionText = CodeGeneration.EscapeSingleQuotedStringContent(completionText); } From f3ec9f9db27ebed2c14b5c84497bf66f443b6c54 Mon Sep 17 00:00:00 2001 From: ArmaanMcleod Date: Sat, 19 Apr 2025 22:30:58 +1000 Subject: [PATCH 19/31] Move normalize newlines code outside of match strategy to separate method. --- .../CommandCompletion/CompletionHelpers.cs | 32 +++++++++---------- test/xUnit/csharp/test_CompletionHelpers.cs | 15 ++++++--- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs index 147f47a501b..c72c034289d 100644 --- a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs +++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs @@ -36,9 +36,11 @@ internal static IEnumerable GetMatchingResults( CompletionResultType resultType = CompletionResultType.Text, MatchStrategy matchStrategy = null) { + matchStrategy ??= DefaultMatch; + string quote = HandleDoubleAndSingleQuote(ref wordToComplete); - matchStrategy ??= DefaultMatch; + wordToComplete = NormalizeLineEndings(wordToComplete); foreach (string value in possibleCompletionValues) { @@ -53,6 +55,15 @@ internal static IEnumerable GetMatchingResults( } } + /// + /// Normalizes the word to complete by replacing line endings with escaped newlines. + /// This is necessary to ensure comparisons are consistent with "\r\n" (Windows) & "\n" (UNIX). + /// + /// The word to complete. + /// The normalized word with escaped newlines replaced. + internal static string NormalizeLineEndings(string wordToComplete) + => wordToComplete.Replace("\r", "`r").Replace("\n", "`n"); + /// /// Defines a strategy for determining if a value matches a word or pattern. /// @@ -67,23 +78,10 @@ internal static IEnumerable GetMatchingResults( /// Determines if the given value matches the specified word using a literal, case-insensitive prefix match. /// /// - /// true if the value starts with the normalized word (case-insensitively); otherwise, false. + /// true if the value starts with the word (case-insensitively); otherwise, false. /// - /// - /// The word to complete is normalized by replacing line endings with backticks. - /// This normalization ensures that variations in line-ending representations, such as "\r\n" (Windows) & "\n" (UNIX), - /// do not interfere with the matching logic. Without normalization, comparisons will fail. - /// - /// Example: - /// For instance, if the value is "`r`n" and wordToComplete is "\r", - /// the prefix match will fail due to differing line-ending formats. Normalizing both strings to use - /// backticks allows the comparison to succeed. - /// - internal static readonly MatchStrategy LiteralMatch = (value, wordToComplete) => - { - string normalisedWordNewlines = wordToComplete.Replace("\r", "`r").Replace("\n", "`n"); - return value.StartsWith(normalisedWordNewlines, StringComparison.OrdinalIgnoreCase); - }; + internal static readonly MatchStrategy LiteralMatch = (value, wordToComplete) + => value.StartsWith(wordToComplete, StringComparison.OrdinalIgnoreCase); /// /// Determines if the given value matches the specified word using wildcard pattern matching. diff --git a/test/xUnit/csharp/test_CompletionHelpers.cs b/test/xUnit/csharp/test_CompletionHelpers.cs index 9387ad395bf..26c55ec5b05 100644 --- a/test/xUnit/csharp/test_CompletionHelpers.cs +++ b/test/xUnit/csharp/test_CompletionHelpers.cs @@ -116,9 +116,7 @@ public void TestHandleDoubleAndSingleQuote(string wordToComplete, string expecte [InlineData("word", "wor", true)] [InlineData("word", "words", false)] [InlineData("word`nnext", "word`n", true)] - [InlineData("word`nnext", "word\n", true)] [InlineData("word`r`nnext", "word`r`n", true)] - [InlineData("word`r`nnext", "word\r\n", true)] [InlineData("word;next", "word;", true)] [InlineData("word,next", "word,", true)] [InlineData("word[*]next", "word[*", true)] @@ -141,9 +139,7 @@ public void TestDefaultMatch(string value, string wordToComplete, bool expected) [InlineData("word", "wor", true)] [InlineData("word", "words", false)] [InlineData("word`nnext", "word`n", true)] - [InlineData("word`nnext", "word\n", true)] [InlineData("word`r`nnext", "word`r`n", true)] - [InlineData("word`r`nnext", "word\r\n", true)] [InlineData("word;next", "word;", true)] [InlineData("word,next", "word,", true)] [InlineData("word[*]next", "word[*", true)] @@ -159,5 +155,16 @@ public void TestWildcardPatternEscapeMatch(string value, string wordToComplete, bool result = CompletionHelpers.WildcardPatternEscapeMatch(value, wordToComplete); Assert.Equal(expected, result); } + + [Theory] + [InlineData("\n", "`n")] + [InlineData("\r\n", "`r`n")] + [InlineData("word\n", "word`n")] + [InlineData("word\r\n", "word`r`n")] + public void TestNormalizeLineEndings(string wordToComplete, string expected) + { + string result = CompletionHelpers.NormalizeLineEndings(wordToComplete); + Assert.Equal(expected, result); + } } } From 139a77807c63298d2016249df9109210ab951776 Mon Sep 17 00:00:00 2001 From: ArmaanMcleod Date: Sat, 19 Apr 2025 22:40:46 +1000 Subject: [PATCH 20/31] Check newlines in conditions before replacing --- .../engine/CommandCompletion/CompletionHelpers.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs index c72c034289d..ea21ec610f8 100644 --- a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs +++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs @@ -62,7 +62,19 @@ internal static IEnumerable GetMatchingResults( /// The word to complete. /// The normalized word with escaped newlines replaced. internal static string NormalizeLineEndings(string wordToComplete) - => wordToComplete.Replace("\r", "`r").Replace("\n", "`n"); + { + if (wordToComplete.Contains('\r')) + { + wordToComplete = wordToComplete.Replace("\r", "`r"); + } + + if (wordToComplete.Contains('\n')) + { + wordToComplete = wordToComplete.Replace("\n", "`n"); + } + + return wordToComplete; + } /// /// Defines a strategy for determining if a value matches a word or pattern. From bcc9af4e3983047ba35b0397aef13e5e05ca04bc Mon Sep 17 00:00:00 2001 From: ArmaanMcleod Date: Sat, 19 Apr 2025 23:38:12 +1000 Subject: [PATCH 21/31] Revert "Check newlines in conditions before replacing" This reverts commit 139a77807c63298d2016249df9109210ab951776. --- .../engine/CommandCompletion/CompletionHelpers.cs | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs index ea21ec610f8..c72c034289d 100644 --- a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs +++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs @@ -62,19 +62,7 @@ internal static IEnumerable GetMatchingResults( /// The word to complete. /// The normalized word with escaped newlines replaced. internal static string NormalizeLineEndings(string wordToComplete) - { - if (wordToComplete.Contains('\r')) - { - wordToComplete = wordToComplete.Replace("\r", "`r"); - } - - if (wordToComplete.Contains('\n')) - { - wordToComplete = wordToComplete.Replace("\n", "`n"); - } - - return wordToComplete; - } + => wordToComplete.Replace("\r", "`r").Replace("\n", "`n"); /// /// Defines a strategy for determining if a value matches a word or pattern. From e7153fa0c3af6791ab9bd544eed0d7aa71a4330d Mon Sep 17 00:00:00 2001 From: ArmaanMcleod Date: Sun, 20 Apr 2025 09:53:09 +1000 Subject: [PATCH 22/31] Rename method to NormalizeToExpandableString --- .../engine/CommandCompletion/CompletionHelpers.cs | 15 ++++++++++----- test/xUnit/csharp/test_CompletionHelpers.cs | 4 ++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs index c72c034289d..73a790ee215 100644 --- a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs +++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs @@ -39,8 +39,10 @@ internal static IEnumerable GetMatchingResults( matchStrategy ??= DefaultMatch; string quote = HandleDoubleAndSingleQuote(ref wordToComplete); - - wordToComplete = NormalizeLineEndings(wordToComplete); + if (quote == DoubleQuote) + { + wordToComplete = NormalizeToExpandableString(wordToComplete); + } foreach (string value in possibleCompletionValues) { @@ -56,12 +58,15 @@ internal static IEnumerable GetMatchingResults( } /// - /// Normalizes the word to complete by replacing line endings with escaped newlines. - /// This is necessary to ensure comparisons are consistent with "\r\n" (Windows) & "\n" (UNIX). + /// Normalizes the word to complete to expandable string format. /// /// The word to complete. /// The normalized word with escaped newlines replaced. - internal static string NormalizeLineEndings(string wordToComplete) + /// + /// This method replaces all occurrences of "\r" with "`r" and "\n" with "`n" in the input string. + /// This is important for ensuring that the word to complete is in a consistent format for matching. + /// + internal static string NormalizeToExpandableString(string wordToComplete) => wordToComplete.Replace("\r", "`r").Replace("\n", "`n"); /// diff --git a/test/xUnit/csharp/test_CompletionHelpers.cs b/test/xUnit/csharp/test_CompletionHelpers.cs index 26c55ec5b05..e454be8d92e 100644 --- a/test/xUnit/csharp/test_CompletionHelpers.cs +++ b/test/xUnit/csharp/test_CompletionHelpers.cs @@ -161,9 +161,9 @@ public void TestWildcardPatternEscapeMatch(string value, string wordToComplete, [InlineData("\r\n", "`r`n")] [InlineData("word\n", "word`n")] [InlineData("word\r\n", "word`r`n")] - public void TestNormalizeLineEndings(string wordToComplete, string expected) + public void TestNormalizeToExpandableString(string wordToComplete, string expected) { - string result = CompletionHelpers.NormalizeLineEndings(wordToComplete); + string result = CompletionHelpers.NormalizeToExpandableString(wordToComplete); Assert.Equal(expected, result); } } From 0022a79c8796da55e84b6f2907f2c6065d274745 Mon Sep 17 00:00:00 2001 From: ArmaanMcleod Date: Sun, 20 Apr 2025 10:19:57 +1000 Subject: [PATCH 23/31] Add more special character to escape sequence normalizing --- .../CommandCompletion/CompletionHelpers.cs | 33 +++++++++++++++---- test/xUnit/csharp/test_CompletionHelpers.cs | 17 ++++++++-- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs index 73a790ee215..90eaa713fa3 100644 --- a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs +++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs @@ -58,16 +58,35 @@ internal static IEnumerable GetMatchingResults( } /// - /// Normalizes the word to complete to expandable string format. + /// Normalizes the input string to an expandable string format for PowerShell. /// - /// The word to complete. - /// The normalized word with escaped newlines replaced. + /// The input string to be normalized. + /// The normalized string with special characters replaced by their PowerShell escape sequences. /// - /// This method replaces all occurrences of "\r" with "`r" and "\n" with "`n" in the input string. - /// This is important for ensuring that the word to complete is in a consistent format for matching. + /// This method replaces special characters in the input string with their PowerShell equivalent escape sequences: + /// + /// Replaces "\r" (carriage return) with "`r". + /// Replaces "\n" (newline) with "`n". + /// Replaces "\t" (tab) with "`t". + /// Replaces "\0" (null) with "`0". + /// Replaces "\a" (bell) with "`a". + /// Replaces "\b" (backspace) with "`b". + /// Replaces "\u001b" (escape character) with "`e". + /// Replaces "\f" (form feed) with "`f". + /// Replaces "\v" (vertical tab) with "`v". + /// /// - internal static string NormalizeToExpandableString(string wordToComplete) - => wordToComplete.Replace("\r", "`r").Replace("\n", "`n"); + internal static string NormalizeToExpandableString(string value) + => value + .Replace("\r", "`r") + .Replace("\n", "`n") + .Replace("\t", "`t") + .Replace("\0", "`0") + .Replace("\a", "`a") + .Replace("\b", "`b") + .Replace("\u001b", "`e") + .Replace("\f", "`f") + .Replace("\v", "`v"); /// /// Defines a strategy for determining if a value matches a word or pattern. diff --git a/test/xUnit/csharp/test_CompletionHelpers.cs b/test/xUnit/csharp/test_CompletionHelpers.cs index e454be8d92e..c428213a8e6 100644 --- a/test/xUnit/csharp/test_CompletionHelpers.cs +++ b/test/xUnit/csharp/test_CompletionHelpers.cs @@ -158,12 +158,25 @@ public void TestWildcardPatternEscapeMatch(string value, string wordToComplete, [Theory] [InlineData("\n", "`n")] + [InlineData("\r", "`r")] [InlineData("\r\n", "`r`n")] + [InlineData("\t", "`t")] + [InlineData("\0", "`0")] + [InlineData("\a", "`a")] + [InlineData("\b", "`b")] + [InlineData("\u001b", "`e")] + [InlineData("\f", "`f")] + [InlineData("\v", "`v")] [InlineData("word\n", "word`n")] + [InlineData("word\r", "word`r")] + [InlineData("word\t", "word`t")] + [InlineData("word\u001b", "word`e")] + [InlineData("word\f", "word`f")] + [InlineData("word\v", "word`v")] [InlineData("word\r\n", "word`r`n")] - public void TestNormalizeToExpandableString(string wordToComplete, string expected) + public void TestNormalizeToExpandableString(string value, string expected) { - string result = CompletionHelpers.NormalizeToExpandableString(wordToComplete); + string result = CompletionHelpers.NormalizeToExpandableString(value); Assert.Equal(expected, result); } } From 89da820cae411ef71f6be85c1d7c98068ba537e7 Mon Sep 17 00:00:00 2001 From: Armaan Mcleod Date: Sun, 20 Apr 2025 19:35:51 +1000 Subject: [PATCH 24/31] Update src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs Co-authored-by: Ilya --- .../engine/CommandCompletion/CompletionHelpers.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs index 90eaa713fa3..01f192cc9b1 100644 --- a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs +++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs @@ -39,7 +39,7 @@ internal static IEnumerable GetMatchingResults( matchStrategy ??= DefaultMatch; string quote = HandleDoubleAndSingleQuote(ref wordToComplete); - if (quote == DoubleQuote) + if (quote != SingleQuote) { wordToComplete = NormalizeToExpandableString(wordToComplete); } From 7ea1680749c6f14ab850f082bfd0426d90930a33 Mon Sep 17 00:00:00 2001 From: Ilya Date: Sun, 20 Apr 2025 18:34:02 +0500 Subject: [PATCH 25/31] Update test/xUnit/csharp/test_CompletionHelpers.cs --- test/xUnit/csharp/test_CompletionHelpers.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/xUnit/csharp/test_CompletionHelpers.cs b/test/xUnit/csharp/test_CompletionHelpers.cs index c428213a8e6..40d1bc49b46 100644 --- a/test/xUnit/csharp/test_CompletionHelpers.cs +++ b/test/xUnit/csharp/test_CompletionHelpers.cs @@ -143,6 +143,7 @@ public void TestDefaultMatch(string value, string wordToComplete, bool expected) [InlineData("word;next", "word;", true)] [InlineData("word,next", "word,", true)] [InlineData("word[*]next", "word[*", true)] + [InlineData("word`?`[`*`]next", "word?[*", true)] [InlineData("word[abc]next", "word[abc", true)] [InlineData("word", "word*", false)] [InlineData("Word", "word*", false)] From 6156af3987dadcb747c01ca4753f86523d628c31 Mon Sep 17 00:00:00 2001 From: Ilya Date: Sun, 20 Apr 2025 18:47:55 +0500 Subject: [PATCH 26/31] Update test/xUnit/csharp/test_CompletionHelpers.cs --- test/xUnit/csharp/test_CompletionHelpers.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/xUnit/csharp/test_CompletionHelpers.cs b/test/xUnit/csharp/test_CompletionHelpers.cs index 40d1bc49b46..c428213a8e6 100644 --- a/test/xUnit/csharp/test_CompletionHelpers.cs +++ b/test/xUnit/csharp/test_CompletionHelpers.cs @@ -143,7 +143,6 @@ public void TestDefaultMatch(string value, string wordToComplete, bool expected) [InlineData("word;next", "word;", true)] [InlineData("word,next", "word,", true)] [InlineData("word[*]next", "word[*", true)] - [InlineData("word`?`[`*`]next", "word?[*", true)] [InlineData("word[abc]next", "word[abc", true)] [InlineData("word", "word*", false)] [InlineData("Word", "word*", false)] From 0a853cf4fa77052ae34b656ffb15e0a41950f5ae Mon Sep 17 00:00:00 2001 From: Armaan Mcleod Date: Mon, 21 Apr 2025 09:48:05 +1000 Subject: [PATCH 27/31] Update src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs Co-authored-by: Ilya --- .../engine/CommandCompletion/CompletionHelpers.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs index 01f192cc9b1..037fe4ab15b 100644 --- a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs +++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs @@ -123,7 +123,7 @@ internal static string NormalizeToExpandableString(string value) .IsMatch(value); /// - /// Determines if the given value matches the specified word using either a literal or wildcard escape strategy. + /// Determines if the given value matches the specified word considering wildcard characters literally. /// /// /// true if the value matches either the literal normalized word or the wildcard pattern with escaping; From babbde76f49722320556bf9a3c8bdead4d4ba553 Mon Sep 17 00:00:00 2001 From: Armaan Mcleod Date: Mon, 21 Apr 2025 09:48:20 +1000 Subject: [PATCH 28/31] Update src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs Co-authored-by: Ilya --- .../engine/CommandCompletion/CompletionHelpers.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs index 037fe4ab15b..14866c5cc0f 100644 --- a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs +++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs @@ -130,7 +130,7 @@ internal static string NormalizeToExpandableString(string value) /// otherwise, false. /// /// - /// This strategy first attempts a literal prefix match and, if unsuccessful, escapes the word to complete to + /// This strategy first attempts a literal prefix match for performance and, if unsuccessful, escapes the word to complete to /// handle any problematic wildcard characters before performing a wildcard match. /// internal static readonly MatchStrategy WildcardPatternEscapeMatch = (value, wordToComplete) From 002f46ad555b956cc50592a01faf7f6ce6db0f2e Mon Sep 17 00:00:00 2001 From: Armaan Mcleod Date: Mon, 21 Apr 2025 09:48:29 +1000 Subject: [PATCH 29/31] Update src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs Co-authored-by: Ilya --- .../engine/CommandCompletion/CompletionHelpers.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs index 14866c5cc0f..2189b120db5 100644 --- a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs +++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs @@ -138,7 +138,7 @@ internal static string NormalizeToExpandableString(string value) WildcardPatternMatch(value, WildcardPattern.Escape(wordToComplete)); /// - /// Determines if the given value matches the specified word using either a literal or wildcard match strategy. + /// Determines if the given value matches the specified word taking into account wildcard characters. /// /// /// true if the value matches either the literal normalized word or the wildcard pattern; otherwise, false. From 542a28aa4b247df224251c0d2dd3c12606b0476c Mon Sep 17 00:00:00 2001 From: Armaan Mcleod Date: Mon, 21 Apr 2025 09:48:38 +1000 Subject: [PATCH 30/31] Update src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs Co-authored-by: Ilya --- .../engine/CommandCompletion/CompletionHelpers.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs index 2189b120db5..133ce2e6ffc 100644 --- a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs +++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs @@ -144,7 +144,7 @@ internal static string NormalizeToExpandableString(string value) /// true if the value matches either the literal normalized word or the wildcard pattern; otherwise, false. /// /// - /// This strategy attempts a literal match first and, if unsuccessful, evaluates the word against a wildcard pattern. + /// This strategy attempts a literal match first for performance and, if unsuccessful, evaluates the word against a wildcard pattern. /// internal static readonly MatchStrategy DefaultMatch = (value, wordToComplete) => LiteralMatch(value, wordToComplete) || From 7125ec9f13d656af63fa2d9be897fe51650e5dde Mon Sep 17 00:00:00 2001 From: ArmaanMcleod Date: Mon, 21 Apr 2025 10:38:03 +1000 Subject: [PATCH 31/31] Add IgnoreCase to match strategy delegate names --- .../engine/CommandCompletion/CompletionHelpers.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs index 133ce2e6ffc..e7bc41667c2 100644 --- a/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs +++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionHelpers.cs @@ -104,7 +104,7 @@ internal static string NormalizeToExpandableString(string value) /// /// true if the value starts with the word (case-insensitively); otherwise, false. /// - internal static readonly MatchStrategy LiteralMatch = (value, wordToComplete) + internal static readonly MatchStrategy LiteralMatchOrdinalIgnoreCase = (value, wordToComplete) => value.StartsWith(wordToComplete, StringComparison.OrdinalIgnoreCase); /// @@ -117,7 +117,7 @@ internal static string NormalizeToExpandableString(string value) /// Wildcard pattern matching allows for flexible matching, where wilcards can represent /// multiple characters in the input. This strategy is case-insensitive. /// - internal static readonly MatchStrategy WildcardPatternMatch = (value, wordToComplete) + internal static readonly MatchStrategy WildcardPatternMatchIgnoreCase = (value, wordToComplete) => WildcardPattern .Get(wordToComplete + "*", WildcardOptions.IgnoreCase) .IsMatch(value); @@ -134,8 +134,8 @@ internal static string NormalizeToExpandableString(string value) /// handle any problematic wildcard characters before performing a wildcard match. /// internal static readonly MatchStrategy WildcardPatternEscapeMatch = (value, wordToComplete) - => LiteralMatch(value, wordToComplete) || - WildcardPatternMatch(value, WildcardPattern.Escape(wordToComplete)); + => LiteralMatchOrdinalIgnoreCase(value, wordToComplete) || + WildcardPatternMatchIgnoreCase(value, WildcardPattern.Escape(wordToComplete)); /// /// Determines if the given value matches the specified word taking into account wildcard characters. @@ -147,8 +147,8 @@ internal static string NormalizeToExpandableString(string value) /// This strategy attempts a literal match first for performance and, if unsuccessful, evaluates the word against a wildcard pattern. /// internal static readonly MatchStrategy DefaultMatch = (value, wordToComplete) - => LiteralMatch(value, wordToComplete) || - WildcardPatternMatch(value, wordToComplete); + => LiteralMatchOrdinalIgnoreCase(value, wordToComplete) || + WildcardPatternMatchIgnoreCase(value, wordToComplete); /// /// Removes wrapping quotes from a string and returns the quote used, if present.