Skip to content

Commit 7916a0c

Browse files
Overhaul workspace search for symbol references (#1917)
Significantly reduce performance overhead of reference finding in large workspaces. * Dependent on PowerShell/vscode-powershell#4170 * Adds a reference cache to every ScriptFile * Workspace scan is performed only once on first request * An LSP file system watcher provides updates for files not changed via Did*TextDocument notifications * Adds a setting to only search "open documents" for references. This disables both the initial workspace scan and the file system watcher, relying only on Did*TextDocument notifications. As a stress test I opened up my profile directory (which has ~3k script files in total in the Modules directory) and created a file with a function definition on every line. I then tabbed to a different file, and then tabbed back to the new file. Before the changes, the references code lens took ~10 seconds to populate and my CPU spiked to ~50% usage. After the changes, they populated instantly and CPU spiked to ~2% usage.
1 parent 1d06b7c commit 7916a0c

File tree

12 files changed

+515
-74
lines changed

12 files changed

+515
-74
lines changed

src/PowerShellEditorServices/Server/PsesLanguageServer.cs

+1
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ public async Task StartAsync()
119119
.WithHandler<ShowHelpHandler>()
120120
.WithHandler<ExpandAliasHandler>()
121121
.WithHandler<PsesSemanticTokensHandler>()
122+
.WithHandler<DidChangeWatchedFilesHandler>()
122123
// NOTE: The OnInitialize delegate gets run when we first receive the
123124
// _Initialize_ request:
124125
// https://microsoft.github.io/language-server-protocol/specifications/specification-current/#initialize

src/PowerShellEditorServices/Services/PowerShell/Utility/CommandHelpers.cs

+34
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4+
using System;
45
using System.Collections.Concurrent;
56
using System.Collections.Generic;
67
using System.Management.Automation;
@@ -57,6 +58,39 @@ public record struct AliasMap(
5758
internal static readonly ConcurrentDictionary<string, List<string>> s_cmdletToAliasCache = new(System.StringComparer.OrdinalIgnoreCase);
5859
internal static readonly ConcurrentDictionary<string, string> s_aliasToCmdletCache = new(System.StringComparer.OrdinalIgnoreCase);
5960

61+
/// <summary>
62+
/// Gets the actual command behind a fully module qualified command invocation, e.g.
63+
/// <c>Microsoft.PowerShell.Management\Get-ChildItem</c> will return <c>Get-ChildItem</c>
64+
/// </summary>
65+
/// <param name="invocationName">
66+
/// The potentially module qualified command name at the site of invocation.
67+
/// </param>
68+
/// <param name="moduleName">
69+
/// A reference that will contain the module name if the invocation is module qualified.
70+
/// </param>
71+
/// <returns>The actual command name.</returns>
72+
public static string StripModuleQualification(string invocationName, out ReadOnlyMemory<char> moduleName)
73+
{
74+
int slashIndex = invocationName.LastIndexOfAny(new[] { '\\', '/' });
75+
if (slashIndex is -1)
76+
{
77+
moduleName = default;
78+
return invocationName;
79+
}
80+
81+
// If '\' is the last character then it's probably not a module qualified command.
82+
if (slashIndex == invocationName.Length - 1)
83+
{
84+
moduleName = default;
85+
return invocationName;
86+
}
87+
88+
// Storing moduleName as ROMemory saves a string allocation in the common case where it
89+
// is not needed.
90+
moduleName = invocationName.AsMemory().Slice(0, slashIndex);
91+
return invocationName.Substring(slashIndex + 1);
92+
}
93+
6094
/// <summary>
6195
/// Gets the CommandInfo instance for a command with a particular name.
6296
/// </summary>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
#nullable enable
5+
6+
using System;
7+
using System.Collections.Concurrent;
8+
using System.Management.Automation.Language;
9+
using Microsoft.PowerShell.EditorServices.Services.TextDocument;
10+
using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility;
11+
using Microsoft.PowerShell.EditorServices.Services.Symbols;
12+
13+
namespace Microsoft.PowerShell.EditorServices.Services;
14+
15+
/// <summary>
16+
/// Represents the symbols that are referenced and their locations within a single document.
17+
/// </summary>
18+
internal sealed class ReferenceTable
19+
{
20+
private readonly ScriptFile _parent;
21+
22+
private readonly ConcurrentDictionary<string, ConcurrentBag<IScriptExtent>> _symbolReferences = new(StringComparer.OrdinalIgnoreCase);
23+
24+
private bool _isInited;
25+
26+
public ReferenceTable(ScriptFile parent) => _parent = parent;
27+
28+
/// <summary>
29+
/// Clears the reference table causing it to rescan the source AST when queried.
30+
/// </summary>
31+
public void TagAsChanged()
32+
{
33+
_symbolReferences.Clear();
34+
_isInited = false;
35+
}
36+
37+
// Prefer checking if the dictionary has contents to determine if initialized. The field
38+
// `_isInited` is to guard against rescanning files with no command references, but will
39+
// generally be less reliable of a check.
40+
private bool IsInitialized => !_symbolReferences.IsEmpty || _isInited;
41+
42+
internal bool TryGetReferences(string command, out ConcurrentBag<IScriptExtent>? references)
43+
{
44+
EnsureInitialized();
45+
return _symbolReferences.TryGetValue(command, out references);
46+
}
47+
48+
internal void EnsureInitialized()
49+
{
50+
if (IsInitialized)
51+
{
52+
return;
53+
}
54+
55+
_parent.ScriptAst.Visit(new ReferenceVisitor(this));
56+
}
57+
58+
private void AddReference(string symbol, IScriptExtent extent)
59+
{
60+
_symbolReferences.AddOrUpdate(
61+
symbol,
62+
_ => new ConcurrentBag<IScriptExtent> { extent },
63+
(_, existing) =>
64+
{
65+
existing.Add(extent);
66+
return existing;
67+
});
68+
}
69+
70+
private sealed class ReferenceVisitor : AstVisitor
71+
{
72+
private readonly ReferenceTable _references;
73+
74+
public ReferenceVisitor(ReferenceTable references) => _references = references;
75+
76+
public override AstVisitAction VisitCommand(CommandAst commandAst)
77+
{
78+
string? commandName = GetCommandName(commandAst);
79+
if (string.IsNullOrEmpty(commandName))
80+
{
81+
return AstVisitAction.Continue;
82+
}
83+
84+
_references.AddReference(
85+
CommandHelpers.StripModuleQualification(commandName, out _),
86+
commandAst.CommandElements[0].Extent);
87+
88+
return AstVisitAction.Continue;
89+
90+
static string? GetCommandName(CommandAst commandAst)
91+
{
92+
string commandName = commandAst.GetCommandName();
93+
if (!string.IsNullOrEmpty(commandName))
94+
{
95+
return commandName;
96+
}
97+
98+
if (commandAst.CommandElements[0] is not ExpandableStringExpressionAst expandableStringExpressionAst)
99+
{
100+
return null;
101+
}
102+
103+
return AstOperations.TryGetInferredValue(expandableStringExpressionAst, out string value) ? value : null;
104+
}
105+
}
106+
107+
public override AstVisitAction VisitVariableExpression(VariableExpressionAst variableExpressionAst)
108+
{
109+
// TODO: Consider tracking unscoped variable references only when they declared within
110+
// the same function definition.
111+
_references.AddReference(
112+
$"${variableExpressionAst.VariablePath.UserPath}",
113+
variableExpressionAst.Extent);
114+
115+
return AstVisitAction.Continue;
116+
}
117+
}
118+
}

0 commit comments

Comments
 (0)