Skip to content

Commit 9f17d7f

Browse files
JustinGroteandyleejordan
authored andcommitted
Fix debugger's "auto" variables container
We want this container to hold everything that is most likely contextually relevant to the user while debugging. It is specifically NOT analagous to PowerShell's automatic variables, but to Visual Studio's "auto" view of a debugger's variables. This means that for the top of the callstack, we merge the local scope's variables with those in the frame and filter to our own curated list. We also cleaned up our variable property rehydration path.
1 parent 29cfe73 commit 9f17d7f

File tree

1 file changed

+117
-55
lines changed

1 file changed

+117
-55
lines changed

src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs

+117-55
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ internal class DebugService
4141

4242
private readonly IPowerShellDebugContext _debugContext;
4343

44+
// The LSP protocol refers to variables by individual IDs, this is an iterator for that purpose.
4445
private int nextVariableId;
4546
private string temporaryScriptListingPath;
4647
private List<VariableDetailsBase> variables;
@@ -654,7 +655,12 @@ private async Task FetchStackFramesAndVariablesAsync(string scriptNameOverride)
654655
}
655656
}
656657

657-
private async Task<VariableContainerDetails> FetchVariableContainerAsync(string scope)
658+
private Task<VariableContainerDetails> FetchVariableContainerAsync(string scope)
659+
{
660+
return FetchVariableContainerAsync(scope, autoVarsOnly: false);
661+
}
662+
663+
private async Task<VariableContainerDetails> FetchVariableContainerAsync(string scope, bool autoVarsOnly)
658664
{
659665
PSCommand psCommand = new PSCommand()
660666
.AddCommand("Get-Variable")
@@ -692,8 +698,17 @@ private async Task<VariableContainerDetails> FetchVariableContainerAsync(string
692698
{
693699
continue;
694700
}
701+
var variableInfo = TryVariableInfo(psVariableObject);
702+
if (variableInfo is null || !ShouldAddAsVariable(variableInfo))
703+
{
704+
continue;
705+
}
706+
if (autoVarsOnly && !ShouldAddToAutoVariables(variableInfo))
707+
{
708+
continue;
709+
}
695710

696-
var variableDetails = new VariableDetails(psVariableObject) { Id = nextVariableId++ };
711+
var variableDetails = new VariableDetails(variableInfo.Variable) { Id = nextVariableId++ };
697712
variables.Add(variableDetails);
698713
scopeVariableContainer.Children.Add(variableDetails.Name, variableDetails);
699714
}
@@ -702,70 +717,95 @@ private async Task<VariableContainerDetails> FetchVariableContainerAsync(string
702717
return scopeVariableContainer;
703718
}
704719

705-
// TODO: This function needs explanation, thought, and improvement.
706-
private bool AddToAutoVariables(PSObject psvariable)
720+
// This is a helper type for FetchStackFramesAsync to preserve the variable Type after deserialization.
721+
private record VariableInfo(string[] Types, PSVariable Variable);
722+
723+
// Create a VariableInfo for both serialized and deserialized variables.
724+
private static VariableInfo TryVariableInfo(PSObject psObject)
707725
{
708-
string variableName = psvariable.Properties["Name"].Value as string;
709-
object variableValue = psvariable.Properties["Value"].Value;
726+
if (psObject.TypeNames.Contains("System.Management.Automation.PSVariable"))
727+
{
728+
return new VariableInfo(psObject.TypeNames.ToArray(), psObject.BaseObject as PSVariable);
729+
}
730+
if (psObject.TypeNames.Contains("Deserialized.System.Management.Automation.PSVariable"))
731+
{
732+
// Rehydrate the relevant variable properties and recreate it.
733+
ScopedItemOptions options = (ScopedItemOptions)Enum.Parse(typeof(ScopedItemOptions), psObject.Properties["Options"].Value.ToString());
734+
PSVariable reconstructedVar = new(
735+
psObject.Properties["Name"].Value.ToString(),
736+
psObject.Properties["Value"].Value,
737+
options
738+
);
739+
return new VariableInfo(psObject.TypeNames.ToArray(), reconstructedVar);
740+
}
710741

711-
// Don't put any variables created by PSES in the Auto variable container.
712-
if (variableName.StartsWith(PsesGlobalVariableNamePrefix)
713-
|| variableName.Equals("PSDebugContext"))
742+
return null;
743+
}
744+
745+
/// <summary>
746+
/// Filters out variables we don't care about such as built-ins
747+
/// </summary>
748+
private static bool ShouldAddAsVariable(VariableInfo variableInfo)
749+
{
750+
// Filter built-in constant or readonly variables like $true, $false, $null, etc.
751+
ScopedItemOptions variableScope = variableInfo.Variable.Options;
752+
var constantAllScope = ScopedItemOptions.AllScope | ScopedItemOptions.Constant;
753+
var readonlyAllScope = ScopedItemOptions.AllScope | ScopedItemOptions.ReadOnly;
754+
if (((variableScope & constantAllScope) == constantAllScope)
755+
|| ((variableScope & readonlyAllScope) == readonlyAllScope))
714756
{
715757
return false;
716758
}
717759

718-
ScopedItemOptions variableScope = ScopedItemOptions.None;
719-
PSPropertyInfo optionsProperty = psvariable.Properties["Options"];
720-
if (string.Equals(optionsProperty.TypeNameOfValue, "System.String"))
760+
if (variableInfo.Variable.Name switch { "null" => true, _ => false })
721761
{
722-
if (!Enum.TryParse<ScopedItemOptions>(
723-
optionsProperty.Value as string,
724-
out variableScope))
725-
{
726-
_logger.LogWarning($"Could not parse a variable's ScopedItemOptions value of '{optionsProperty.Value}'");
727-
}
762+
return false;
728763
}
729-
else if (optionsProperty.Value is ScopedItemOptions)
764+
765+
return true;
766+
}
767+
768+
// This method curates variables that should be added to the "auto" view, which we define as variables that are
769+
// very likely to be contextually relevant to the user, in an attempt to reduce noise when debugging.
770+
// Variables not listed here can still be found in the other containers like local and script, this is
771+
// provided as a convenience.
772+
private bool ShouldAddToAutoVariables(VariableInfo variableInfo)
773+
{
774+
var variableToAdd = variableInfo.Variable;
775+
if (!ShouldAddAsVariable(variableInfo))
730776
{
731-
variableScope = (ScopedItemOptions)optionsProperty.Value;
777+
return false;
732778
}
733779

734-
// Some local variables, if they exist, should be displayed by default
735-
if (psvariable.TypeNames[0].EndsWith("LocalVariable"))
780+
// Filter internal variables created by Powershell Editor Services.
781+
if (variableToAdd.Name.StartsWith(PsesGlobalVariableNamePrefix)
782+
|| variableToAdd.Name.Equals("PSDebugContext"))
736783
{
737-
if (variableName.Equals("PSItem") || variableName.Equals("_"))
738-
{
739-
return true;
740-
}
741-
else if (variableName.Equals("args", StringComparison.OrdinalIgnoreCase))
742-
{
743-
return variableValue is Array array && array.Length > 0;
744-
}
745-
746784
return false;
747785
}
748-
else if (!psvariable.TypeNames[0].EndsWith(nameof(PSVariable)))
786+
787+
// Filter Global-Scoped variables. We first cast to VariableDetails to ensure the prefix
788+
// is added for purposes of comparison.
789+
VariableDetails variableToAddDetails = new(variableToAdd);
790+
if (globalScopeVariables.Children.ContainsKey(variableToAddDetails.Name))
749791
{
750792
return false;
751793
}
752794

753-
var constantAllScope = ScopedItemOptions.AllScope | ScopedItemOptions.Constant;
754-
var readonlyAllScope = ScopedItemOptions.AllScope | ScopedItemOptions.ReadOnly;
755-
756-
if (((variableScope & constantAllScope) == constantAllScope)
757-
|| ((variableScope & readonlyAllScope) == readonlyAllScope))
795+
// We curate a list of LocalVariables that, if they exist, should be displayed by default.
796+
if (variableInfo.Types[0].EndsWith("LocalVariable"))
758797
{
759-
// The constructor we are using here does not automatically add the dollar prefix,
760-
// so we do it manually.
761-
string prefixedVariableName = VariableDetails.DollarPrefix + variableName;
762-
if (globalScopeVariables.Children.ContainsKey(prefixedVariableName))
798+
return variableToAdd.Name switch
763799
{
764-
return false;
765-
}
800+
"PSItem" or "_" or "" => true,
801+
"args" or "input" => variableToAdd.Value is Array array && array.Length > 0,
802+
"PSBoundParameters" => variableToAdd.Value is IDictionary dict && dict.Count > 0,
803+
_ => false
804+
};
766805
}
767806

768-
return true;
807+
// Any other PSVariables that survive the above criteria should be included.
808+
return variableInfo.Types[0].EndsWith("PSVariable");
769809
}
770810

771811
private async Task FetchStackFramesAsync(string scriptNameOverride)
@@ -798,8 +838,10 @@ private async Task FetchStackFramesAsync(string scriptNameOverride)
798838
: results;
799839

800840
var stackFrameDetailList = new List<StackFrameDetails>();
841+
bool isTopStackFrame = true;
801842
foreach (var callStackFrameItem in callStack)
802843
{
844+
// We have to use reflection to get the variable dictionary.
803845
var callStackFrameComponents = (callStackFrameItem as PSObject).BaseObject as IList;
804846
var callStackFrame = callStackFrameComponents[0] as PSObject;
805847
IDictionary callStackVariables = isOnRemoteMachine
@@ -820,27 +862,47 @@ private async Task FetchStackFramesAsync(string scriptNameOverride)
820862

821863
foreach (DictionaryEntry entry in callStackVariables)
822864
{
823-
// TODO: This should be deduplicated into a new function.
824-
object psVarValue = isOnRemoteMachine
825-
? (entry.Value as PSObject).Properties["Value"].Value
826-
: (entry.Value as PSVariable).Value;
827-
828-
// The constructor we are using here does not automatically add the dollar
829-
// prefix, so we do it manually.
830-
string psVarName = VariableDetails.DollarPrefix + entry.Key.ToString();
831-
var variableDetails = new VariableDetails(psVarName, psVarValue) { Id = nextVariableId++ };
865+
VariableInfo psVarInfo = TryVariableInfo(new PSObject(entry.Value));
866+
if (psVarInfo is null)
867+
{
868+
_logger.LogError($"A object was received that is not a PSVariable object");
869+
continue;
870+
}
871+
872+
var variableDetails = new VariableDetails(psVarInfo.Variable) { Id = nextVariableId++ };
832873
variables.Add(variableDetails);
833874

834875
commandVariables.Children.Add(variableDetails.Name, variableDetails);
835-
if (AddToAutoVariables(new PSObject(entry.Value)))
876+
877+
if (ShouldAddToAutoVariables(psVarInfo))
836878
{
837879
autoVariables.Children.Add(variableDetails.Name, variableDetails);
838880
}
839881
}
840882

841-
var stackFrameDetailsEntry = StackFrameDetails.Create(callStackFrame, autoVariables, commandVariables);
883+
// If this is the top stack frame, we also want to add relevant local variables to
884+
// the "Auto" container (not to be confused with Automatic PowerShell variables).
885+
//
886+
// TODO: We can potentially use `Get-Variable -Scope x` to add relevant local
887+
// variables to other frames but frames and scopes are not perfectly analagous and
888+
// we'd need a way to detect things such as module borders and dot-sourced files.
889+
if (isTopStackFrame)
890+
{
891+
var localScopeAutoVariables = await FetchVariableContainerAsync(VariableContainerDetails.LocalScopeName, autoVarsOnly: true).ConfigureAwait(false);
892+
foreach (KeyValuePair<string, VariableDetailsBase> entry in localScopeAutoVariables.Children)
893+
{
894+
// NOTE: `TryAdd` doesn't work on `IDictionary`.
895+
if (!autoVariables.Children.ContainsKey(entry.Key))
896+
{
897+
autoVariables.Children.Add(entry.Key, entry.Value);
898+
}
899+
}
900+
isTopStackFrame = false;
901+
}
842902

903+
var stackFrameDetailsEntry = StackFrameDetails.Create(callStackFrame, autoVariables, commandVariables);
843904
string stackFrameScriptPath = stackFrameDetailsEntry.ScriptPath;
905+
844906
if (scriptNameOverride is not null
845907
&& string.Equals(stackFrameScriptPath, StackFrameDetails.NoFileScriptPath))
846908
{

0 commit comments

Comments
 (0)