Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ internal List<CompletionResult> GetResultHelper(CompletionContext completionCont
break;

completionContext.WordToComplete = tokenAtCursor.Text;
result = CompletionCompleters.CompleteComment(completionContext);
result = CompletionCompleters.CompleteComment(completionContext, ref replacementIndex, ref replacementLength);
break;

case TokenKind.StringExpandable:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4923,13 +4923,22 @@ private static SortedSet<string> BuildSpecialVariablesCache()

#region Comments

// Complete the history entries
internal static List<CompletionResult> CompleteComment(CompletionContext context)
internal static List<CompletionResult> CompleteComment(CompletionContext context, ref int replacementIndex, ref int replacementLength)
{
List<CompletionResult> results = new List<CompletionResult>();
// Complete #requires statements
if (context.WordToComplete.StartsWith("#requires ", StringComparison.OrdinalIgnoreCase))
{
return CompleteRequires(context, ref replacementIndex, ref replacementLength);
}

var results = new List<CompletionResult>();

// 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<PSObject> psobjs;
Expand Down Expand Up @@ -4990,6 +4999,197 @@ internal static List<CompletionResult> CompleteComment(CompletionContext context
return results;
}

private static List<CompletionResult> CompleteRequires(CompletionContext context, ref int replacementIndex, ref int replacementLength)
{
var results = new List<CompletionResult>();

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<string, string> 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<string, string> 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<string>(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<string, string> s_requiresParameters = new SortedList<string, string>(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<string, string> s_requiresPSEditions = new SortedList<string, string>(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<string, string> s_requiresModuleSpecKeys = new SortedList<string, string>(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
Expand Down
69 changes: 69 additions & 0 deletions test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down