diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionAnalysis.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionAnalysis.cs index 95b26ff6907..4479897dfd4 100644 --- a/src/System.Management.Automation/engine/CommandCompletion/CompletionAnalysis.cs +++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionAnalysis.cs @@ -473,7 +473,7 @@ internal List GetResultHelper(CompletionContext completionCont break; completionContext.WordToComplete = tokenAtCursor.Text; - result = CompletionCompleters.CompleteComment(completionContext); + result = CompletionCompleters.CompleteComment(completionContext, ref replacementIndex, ref replacementLength); break; case TokenKind.StringExpandable: diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionCompleters.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionCompleters.cs index 853b79e7843..1735528423d 100644 --- a/src/System.Management.Automation/engine/CommandCompletion/CompletionCompleters.cs +++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionCompleters.cs @@ -4923,13 +4923,22 @@ private static SortedSet BuildSpecialVariablesCache() #region Comments - // Complete the history entries - internal static List CompleteComment(CompletionContext context) + internal static List CompleteComment(CompletionContext context, ref int replacementIndex, ref int replacementLength) { - List results = new List(); + // Complete #requires statements + if (context.WordToComplete.StartsWith("#requires ", StringComparison.OrdinalIgnoreCase)) + { + return CompleteRequires(context, ref replacementIndex, ref replacementLength); + } + var results = new List(); + + // Complete the history entries Match matchResult = Regex.Match(context.WordToComplete, @"^#([\w\-]*)$"); - if (!matchResult.Success) { return results; } + if (!matchResult.Success) + { + return results; + } string wordToComplete = matchResult.Groups[1].Value; Collection psobjs; @@ -4990,6 +4999,197 @@ internal static List CompleteComment(CompletionContext context return results; } + private static List CompleteRequires(CompletionContext context, ref int replacementIndex, ref int replacementLength) + { + var results = new List(); + + int cursorIndex = context.CursorPosition.ColumnNumber - 1; + string lineToCursor = context.CursorPosition.Line.Substring(0, cursorIndex); + + // RunAsAdministrator must be the last parameter in a Requires statement so no completion if the cursor is after the parameter. + if (lineToCursor.Contains(" -RunAsAdministrator", StringComparison.OrdinalIgnoreCase)) + { + return results; + } + + // Regex to find parameter like " -Parameter1" or " -" + MatchCollection hashtableKeyMatches = Regex.Matches(lineToCursor, @"\s+-([A-Za-z]+|$)"); + if (hashtableKeyMatches.Count == 0) + { + return results; + } + + Group currentParameterMatch = hashtableKeyMatches[^1].Groups[1]; + + // Complete the parameter if the cursor is at a parameter + if (currentParameterMatch.Index + currentParameterMatch.Length == cursorIndex) + { + string currentParameterPrefix = currentParameterMatch.Value; + + replacementIndex = context.CursorPosition.Offset - currentParameterPrefix.Length; + replacementLength = currentParameterPrefix.Length; + + // Produce completions for all parameters that begin with the prefix we've found, + // but which haven't already been specified in the line we need to complete + foreach (KeyValuePair parameter in s_requiresParameters) + { + if (parameter.Key.StartsWith(currentParameterPrefix, StringComparison.OrdinalIgnoreCase) + && !context.CursorPosition.Line.Contains($" -{parameter.Key}", StringComparison.OrdinalIgnoreCase)) + { + results.Add(new CompletionResult(parameter.Key, parameter.Key, CompletionResultType.ParameterName, parameter.Value)); + } + } + + return results; + } + + // Regex to find parameter values (any text that appears after various delimiters) + hashtableKeyMatches = Regex.Matches(lineToCursor, @"(\s+|,|;|{|\""|'|=)(\w+|$)"); + string currentValue; + if (hashtableKeyMatches.Count == 0) + { + currentValue = string.Empty; + } + else + { + currentValue = hashtableKeyMatches[^1].Groups[2].Value; + } + + replacementIndex = context.CursorPosition.Offset - currentValue.Length; + replacementLength = currentValue.Length; + + // Complete PSEdition parameter values + if (currentParameterMatch.Value.Equals("PSEdition", StringComparison.OrdinalIgnoreCase)) + { + foreach (KeyValuePair psEditionEntry in s_requiresPSEditions) + { + if (psEditionEntry.Key.StartsWith(currentValue, StringComparison.OrdinalIgnoreCase)) + { + results.Add(new CompletionResult(psEditionEntry.Key, psEditionEntry.Key, CompletionResultType.ParameterValue, psEditionEntry.Value)); + } + } + + return results; + } + + // Complete Modules module specification values + if (currentParameterMatch.Value.Equals("Modules", StringComparison.OrdinalIgnoreCase)) + { + int hashtableStart = lineToCursor.LastIndexOf("@{"); + int hashtableEnd = lineToCursor.LastIndexOf('}'); + + bool insideHashtable = hashtableStart != -1 && (hashtableEnd == -1 || hashtableEnd < hashtableStart); + + // If not inside a hashtable, try to complete a module simple name + if (!insideHashtable) + { + context.WordToComplete = currentValue; + return CompleteModuleName(context, true); + } + + string hashtableString = lineToCursor.Substring(hashtableStart); + + // Regex to find hashtable keys with or without quotes + hashtableKeyMatches = Regex.Matches(hashtableString, @"(@{|;)\s*(?:'|\""|\w*)\w*"); + + // Build the list of keys we might want to complete, based on what's already been provided + var moduleSpecKeysToComplete = new HashSet(s_requiresModuleSpecKeys.Keys); + bool sawModuleNameLast = false; + foreach (Match existingHashtableKeyMatch in hashtableKeyMatches) + { + string existingHashtableKey = existingHashtableKeyMatch.Value.TrimStart(s_hashtableKeyPrefixes); + + if (string.IsNullOrEmpty(existingHashtableKey)) + { + continue; + } + + // Remove the existing key we just saw + moduleSpecKeysToComplete.Remove(existingHashtableKey); + + // We need to remember later if we saw "ModuleName" as the last hashtable key, for completions + if (sawModuleNameLast = existingHashtableKey.Equals("ModuleName", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + // "RequiredVersion" is mutually exclusive with "ModuleVersion" and "MaximumVersion" + if (existingHashtableKey.Equals("ModuleVersion", StringComparison.OrdinalIgnoreCase) + || existingHashtableKey.Equals("MaximumVersion", StringComparison.OrdinalIgnoreCase)) + { + moduleSpecKeysToComplete.Remove("RequiredVersion"); + continue; + } + + if (existingHashtableKey.Equals("RequiredVersion", StringComparison.OrdinalIgnoreCase)) + { + moduleSpecKeysToComplete.Remove("ModuleVersion"); + moduleSpecKeysToComplete.Remove("MaximumVersion"); + continue; + } + } + + Group lastHashtableKeyPrefixGroup = hashtableKeyMatches[^1].Groups[0]; + + // If we're not completing a key for the hashtable, try to complete module names, but nothing else + bool completingHashtableKey = lastHashtableKeyPrefixGroup.Index + lastHashtableKeyPrefixGroup.Length == hashtableString.Length; + if (!completingHashtableKey) + { + if (sawModuleNameLast) + { + context.WordToComplete = currentValue; + return CompleteModuleName(context, true); + } + + return results; + } + + // Now try to complete hashtable keys + foreach (string moduleSpecKey in moduleSpecKeysToComplete) + { + if (moduleSpecKey.StartsWith(currentValue, StringComparison.OrdinalIgnoreCase)) + { + results.Add(new CompletionResult(moduleSpecKey, moduleSpecKey, CompletionResultType.ParameterValue, s_requiresModuleSpecKeys[moduleSpecKey])); + } + } + } + + return results; + } + + private static readonly IReadOnlyDictionary s_requiresParameters = new SortedList(StringComparer.OrdinalIgnoreCase) + { + { "Modules", "Specifies PowerShell modules that the script requires." }, + { "PSEdition", "Specifies a PowerShell edition that the script requires." }, + { "RunAsAdministrator", "Specifies that PowerShell must be running as administrator on Windows." }, + { "Version", "Specifies the minimum version of PowerShell that the script requires." }, + }; + + private static readonly IReadOnlyDictionary s_requiresPSEditions = new SortedList(StringComparer.OrdinalIgnoreCase) + { + { "Core", "Specifies that the script requires PowerShell Core to run." }, + { "Desktop", "Specifies that the script requires Windows PowerShell to run." }, + }; + + private static readonly IReadOnlyDictionary s_requiresModuleSpecKeys = new SortedList(StringComparer.OrdinalIgnoreCase) + { + { "ModuleName", "Required. Specifies the module name." }, + { "GUID", "Optional. Specifies the GUID of the module." }, + { "ModuleVersion", "Specifies a minimum acceptable version of the module." }, + { "RequiredVersion", "Specifies an exact, required version of the module." }, + { "MaximumVersion", "Specifies the maximum acceptable version of the module." }, + }; + + private static readonly char[] s_hashtableKeyPrefixes = new[] + { + '@', + '{', + ';', + '"', + '\'', + ' ', + }; + #endregion Comments #region Members diff --git a/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 b/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 index c42f30e510a..6503ff8d7ec 100644 --- a/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 +++ b/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 @@ -908,6 +908,75 @@ Describe "TabCompletion" -Tags CI { $res.CompletionMatches[0].CompletionText | Should -BeExactly "Test history completion" } + It "Test #requires parameter completion" { + $res = TabExpansion2 -inputScript "#requires -" -cursorColumn 11 + $res.CompletionMatches.Count | Should -BeGreaterThan 0 + $res.CompletionMatches[0].CompletionText | Should -BeExactly "Modules" + } + + It "Test #requires parameter value completion" { + $res = TabExpansion2 -inputScript "#requires -PSEdition " -cursorColumn 21 + $res.CompletionMatches.Count | Should -BeGreaterThan 0 + $res.CompletionMatches[0].CompletionText | Should -BeExactly "Core" + } + + It "Test no completion after #requires -RunAsAdministrator" { + $res = TabExpansion2 -inputScript "#requires -RunAsAdministrator -" -cursorColumn 31 + $res.CompletionMatches | Should -HaveCount 0 + } + + It "Test no suggestions for already existing parameters in #requires" { + $res = TabExpansion2 -inputScript "#requires -Modules -" -cursorColumn 20 + $res.CompletionMatches.CompletionText | Should -Not -Contain "Modules" + } + + It "Test module completion in #requires without quotes" { + $res = TabExpansion2 -inputScript "#requires -Modules P" -cursorColumn 20 + $res.CompletionMatches.Count | Should -BeGreaterThan 0 + $res.CompletionMatches.CompletionText | Should -Contain "Pester" + } + + It "Test module completion in #requires with quotes" { + $res = TabExpansion2 -inputScript '#requires -Modules "' -cursorColumn 20 + $res.CompletionMatches.Count | Should -BeGreaterThan 0 + $res.CompletionMatches.CompletionText | Should -Contain "Pester" + } + + It "Test module completion in #requires with multiple modules" { + $res = TabExpansion2 -inputScript "#requires -Modules Pester," -cursorColumn 26 + $res.CompletionMatches.Count | Should -BeGreaterThan 0 + $res.CompletionMatches.CompletionText | Should -Contain "Pester" + } + + It "Test hashtable key completion in #requires statement for modules" { + $res = TabExpansion2 -inputScript "#requires -Modules @{" -cursorColumn 21 + $res.CompletionMatches.Count | Should -BeGreaterThan 0 + $res.CompletionMatches[0].CompletionText | Should -BeExactly "GUID" + } + + It "Test no suggestions for already existing hashtable keys in #requires statement for modules" { + $res = TabExpansion2 -inputScript '#requires -Modules @{ModuleName="Pester";' -cursorColumn 41 + $res.CompletionMatches.Count | Should -BeGreaterThan 0 + $res.CompletionMatches.CompletionText | Should -Not -Contain "ModuleName" + } + + It "Test no suggestions for mutually exclusive hashtable keys in #requires statement for modules" { + $res = TabExpansion2 -inputScript '#requires -Modules @{ModuleName="Pester";RequiredVersion="1.0";' -cursorColumn 63 + $res.CompletionMatches.CompletionText | Should -BeExactly "GUID" + } + + It "Test no suggestions for RequiredVersion key in #requires statement when ModuleVersion is specified" { + $res = TabExpansion2 -inputScript '#requires -Modules @{ModuleName="Pester";ModuleVersion="1.0";' -cursorColumn 61 + $res.CompletionMatches.Count | Should -BeGreaterThan 0 + $res.CompletionMatches.CompletionText | Should -Not -Contain "RequiredVersion" + } + + It "Test module completion in #requires statement for hashtables" { + $res = TabExpansion2 -inputScript '#requires -Modules @{ModuleName="p' -cursorColumn 34 + $res.CompletionMatches.Count | Should -BeGreaterThan 0 + $res.CompletionMatches.CompletionText | Should -Contain "Pester" + } + It "Test Attribute member completion" { $inputStr = "function bar { [parameter(]param() }" $res = TabExpansion2 -inputScript $inputStr -cursorColumn ($inputStr.IndexOf('(') + 1)