Skip to content

Commit d3d5aea

Browse files
Fix completion for types when using namespaces
Type completion when utilizing `using namespace` statements was really hit or miss due to replacement text not matching the filter text. Also add support for completing and retaining `$PSScriptRoot`. Requires a change in PowerShell that is not yet merged to work, but does not break without the changes needed.
1 parent 6114c38 commit d3d5aea

File tree

1 file changed

+129
-8
lines changed

1 file changed

+129
-8
lines changed

src/PowerShellEditorServices/Services/TextDocument/Handlers/CompletionHandler.cs

Lines changed: 129 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ internal class PsesCompletionHandler : CompletionHandlerBase
3030
private readonly IRunspaceContext _runspaceContext;
3131
private readonly IInternalPowerShellExecutionService _executionService;
3232
private readonly WorkspaceService _workspaceService;
33+
private CompletionCapability _completionCapability;
3334

3435
public PsesCompletionHandler(
3536
ILoggerFactory factory,
@@ -43,13 +44,21 @@ public PsesCompletionHandler(
4344
_workspaceService = workspaceService;
4445
}
4546

46-
protected override CompletionRegistrationOptions CreateRegistrationOptions(CompletionCapability capability, ClientCapabilities clientCapabilities) => new()
47+
protected override CompletionRegistrationOptions CreateRegistrationOptions(CompletionCapability capability, ClientCapabilities clientCapabilities)
48+
{
49+
_completionCapability = capability;
50+
return new CompletionRegistrationOptions()
4751
{
4852
// TODO: What do we do with the arguments?
4953
DocumentSelector = LspUtils.PowerShellDocumentSelector,
5054
ResolveProvider = true,
51-
TriggerCharacters = new[] { ".", "-", ":", "\\", "$", " " }
55+
TriggerCharacters = new[] { ".", "-", ":", "\\", "$", " " },
5256
};
57+
}
58+
59+
public bool SupportsSnippets => _completionCapability?.CompletionItem?.SnippetSupport is true;
60+
61+
public bool SupportsCommitCharacters => _completionCapability?.CompletionItem?.CommitCharactersSupport is true;
5362

5463
public override async Task<CompletionList> Handle(CompletionParams request, CancellationToken cancellationToken)
5564
{
@@ -143,6 +152,14 @@ internal async Task<CompletionResults> GetCompletionsInFileAsync(
143152
result.ReplacementIndex,
144153
result.ReplacementIndex + result.ReplacementLength);
145154

155+
string textToBeReplaced = string.Empty;
156+
if (result.ReplacementLength is not 0)
157+
{
158+
textToBeReplaced = scriptFile.Contents.Substring(
159+
result.ReplacementIndex,
160+
result.ReplacementLength);
161+
}
162+
146163
bool isIncomplete = false;
147164
// Create OmniSharp CompletionItems from PowerShell CompletionResults. We use a for loop
148165
// because the index is used for sorting.
@@ -159,16 +176,25 @@ internal async Task<CompletionResults> GetCompletionsInFileAsync(
159176
isIncomplete = true;
160177
}
161178

162-
completionItems[i] = CreateCompletionItem(result.CompletionMatches[i], replacedRange, i + 1);
179+
completionItems[i] = CreateCompletionItem(
180+
result.CompletionMatches[i],
181+
replacedRange,
182+
i + 1,
183+
textToBeReplaced,
184+
scriptFile);
185+
163186
_logger.LogTrace("Created completion item: " + completionItems[i] + " with " + completionItems[i].TextEdit);
164187
}
188+
165189
return new CompletionResults(isIncomplete, completionItems);
166190
}
167191

168-
internal static CompletionItem CreateCompletionItem(
192+
internal CompletionItem CreateCompletionItem(
169193
CompletionResult result,
170194
BufferRange completionRange,
171-
int sortIndex)
195+
int sortIndex,
196+
string textToBeReplaced,
197+
ScriptFile scriptFile)
172198
{
173199
Validate.IsNotNull(nameof(result), result);
174200

@@ -200,7 +226,9 @@ internal static CompletionItem CreateCompletionItem(
200226
? string.Empty : detail, // Don't repeat label.
201227
// Retain PowerShell's sort order with the given index.
202228
SortText = $"{sortIndex:D4}{result.ListItemText}",
203-
FilterText = result.CompletionText,
229+
FilterText = result.ResultType is CompletionResultType.Type
230+
? GetTypeFilterText(textToBeReplaced, result.CompletionText)
231+
: result.CompletionText,
204232
// Used instead of Label when TextEdit is unsupported
205233
InsertText = result.CompletionText,
206234
// Used instead of InsertText when possible
@@ -212,8 +240,8 @@ internal static CompletionItem CreateCompletionItem(
212240
CompletionResultType.Text => item with { Kind = CompletionItemKind.Text },
213241
CompletionResultType.History => item with { Kind = CompletionItemKind.Reference },
214242
CompletionResultType.Command => item with { Kind = CompletionItemKind.Function },
215-
CompletionResultType.ProviderItem => item with { Kind = CompletionItemKind.File },
216-
CompletionResultType.ProviderContainer => TryBuildSnippet(result.CompletionText, out string snippet)
243+
CompletionResultType.ProviderItem or CompletionResultType.ProviderContainer
244+
=> CreateProviderItemCompletion(item, result, scriptFile, textToBeReplaced),
217245
? item with
218246
{
219247
Kind = CompletionItemKind.Folder,
@@ -245,6 +273,99 @@ internal static CompletionItem CreateCompletionItem(
245273
};
246274
}
247275

276+
private CompletionItem CreateProviderItemCompletion(
277+
CompletionItem item,
278+
CompletionResult result,
279+
ScriptFile scriptFile,
280+
string textToBeReplaced)
281+
{
282+
// TODO: Work out a way to do this generally instead of special casing PSScriptRoot.
283+
//
284+
// This code relies on PowerShell/PowerShell#17376. Until that makes it into a release
285+
// no matches will be returned anyway.
286+
const string PSScriptRootVariable = "$PSScriptRoot";
287+
string completionText = result.CompletionText;
288+
if (textToBeReplaced.IndexOf(PSScriptRootVariable, StringComparison.OrdinalIgnoreCase) is int variableIndex and not -1
289+
&& System.IO.Path.GetDirectoryName(scriptFile.FilePath) is string scriptFolder and not ""
290+
&& completionText.IndexOf(scriptFolder, StringComparison.OrdinalIgnoreCase) is int pathIndex and not -1
291+
&& !scriptFile.IsInMemory)
292+
{
293+
completionText = completionText
294+
.Remove(pathIndex, scriptFolder.Length)
295+
.Insert(variableIndex, textToBeReplaced.Substring(variableIndex, PSScriptRootVariable.Length));
296+
}
297+
298+
InsertTextFormat insertFormat;
299+
TextEdit edit;
300+
CompletionItemKind itemKind;
301+
if (result.ResultType is CompletionResultType.ProviderContainer
302+
&& SupportsSnippets
303+
&& TryBuildSnippet(completionText, out string snippet))
304+
{
305+
edit = item.TextEdit.TextEdit with { NewText = snippet };
306+
insertFormat = InsertTextFormat.Snippet;
307+
itemKind = CompletionItemKind.Folder;
308+
}
309+
else
310+
{
311+
edit = item.TextEdit.TextEdit with { NewText = completionText };
312+
insertFormat = default;
313+
itemKind = CompletionItemKind.File;
314+
}
315+
316+
return item with
317+
{
318+
Kind = itemKind,
319+
TextEdit = edit,
320+
InsertText = completionText,
321+
FilterText = completionText,
322+
InsertTextFormat = insertFormat,
323+
CommitCharacters = MaybeAddCommitCharacters("\\", "/", "'", "\""),
324+
};
325+
}
326+
327+
private Container<string> MaybeAddCommitCharacters(params string[] characters)
328+
=> SupportsCommitCharacters ? new Container<string>(characters) : null;
329+
330+
private static string GetTypeFilterText(string textToBeReplaced, string completionText)
331+
{
332+
// FilterText for a type name with using statements gets a little complicated. Consider
333+
// this script:
334+
//
335+
// using namespace System.Management.Automation
336+
// [System.Management.Automation.Tracing.]
337+
//
338+
// Since we're emitting an edit that replaces `System.Management.Automation.Tracing.` with
339+
// `Tracing.NullWriter` (for example), we can't use CompletionText as the filter. If we
340+
// do, we won't find any matches because it's trying to filter `Tracing.NullWriter` with
341+
// `System.Management.Automation.Tracing.` which is too different. So we prepend each
342+
// namespace that exists in our original text but does not in our completion text.
343+
if (!textToBeReplaced.Contains('.'))
344+
{
345+
return completionText;
346+
}
347+
348+
string[] oldTypeParts = textToBeReplaced.Split('.');
349+
string[] newTypeParts = completionText.Split('.');
350+
351+
StringBuilder newFilterText = new(completionText);
352+
353+
int newPartsIndex = newTypeParts.Length - 2;
354+
for (int i = oldTypeParts.Length - 2; i >= 0; i--)
355+
{
356+
if (newPartsIndex is >= 0
357+
&& newTypeParts[newPartsIndex].Equals(oldTypeParts[i], StringComparison.OrdinalIgnoreCase))
358+
{
359+
newPartsIndex--;
360+
continue;
361+
}
362+
363+
newFilterText.Insert(0, '.').Insert(0, oldTypeParts[i]);
364+
}
365+
366+
return newFilterText.ToString();
367+
}
368+
248369
private static readonly Regex s_typeRegex = new(@"^(\[.+\])", RegexOptions.Compiled);
249370

250371
/// <summary>

0 commit comments

Comments
 (0)