Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,4 @@ module/PowerShellEditorServices/Third\ Party\ Notices.txt

# JetBrains generated file (Rider, intelliJ)
.idea/
nupkgs/
5 changes: 3 additions & 2 deletions PowerShellEditorServices.Common.props
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@
<!-- See https://learn.microsoft.com/en-us/nuget/consume-packages/package-references-in-project-files#locking-dependencies -->
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<!-- See: https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/overview -->
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<EnableNETAnalyzers>false</EnableNETAnalyzers>
<EnforceCodeStyleInBuild>false</EnforceCodeStyleInBuild>
<RunAnalyzers>false</RunAnalyzers>
<!-- Required to enable IDE0005 as error -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<!-- TODO: Enable <AnalysisMode>All</AnalysisMode> -->
Expand Down
4 changes: 2 additions & 2 deletions global.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"sdk": {
"version": "8.0.100",
"rollForward": "latestFeature",
"version": "10.0.100",
"rollForward": "latestMajor",
"allowPrerelease": false
}
}
119 changes: 112 additions & 7 deletions src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ internal class DebugService
private VariableContainerDetails globalScopeVariables;
private VariableContainerDetails scriptScopeVariables;
private VariableContainerDetails localScopeVariables;
private List<VariableContainerDetails> immyScopeVariables = new();
private StackFrameDetails[] stackFrameDetails;
private readonly PropertyInfo invocationTypeScriptPositionProperty;

Expand Down Expand Up @@ -596,14 +597,22 @@ public VariableScope[] GetVariableScopes(int stackFrameId)
int autoVariablesId = stackFrames[stackFrameId].AutoVariables.Id;
int commandVariablesId = stackFrames[stackFrameId].CommandVariables.Id;

return new VariableScope[]
var scopes = new List<VariableScope>
{
new VariableScope(autoVariablesId, VariableContainerDetails.AutoVariablesName),
new VariableScope(commandVariablesId, VariableContainerDetails.CommandVariablesName),
new VariableScope(localScopeVariables.Id, VariableContainerDetails.LocalScopeName),
new VariableScope(scriptScopeVariables.Id, VariableContainerDetails.ScriptScopeName),
new VariableScope(globalScopeVariables.Id, VariableContainerDetails.GlobalScopeName),
};

// Add Immy-specific variable scopes (e.g. "Script", "RunContext", "Action", "Session")
foreach (var immyScope in immyScopeVariables)
{
scopes.Add(new VariableScope(immyScope.Id, immyScope.Name));
}

return scopes.ToArray();
}

#endregion
Expand All @@ -629,6 +638,9 @@ private async Task FetchStackFramesAndVariablesAsync(string scriptNameOverride)
localScopeVariables = await FetchVariableContainerAsync(VariableContainerDetails.LocalScopeName).ConfigureAwait(false);

await FetchStackFramesAsync(scriptNameOverride).ConfigureAwait(false);

// Build Immy-specific variable scopes from VariableLayerAttribute on each PSVariable.
FetchImmyVariableScopesAsync();
}
finally
{
Expand Down Expand Up @@ -689,6 +701,78 @@ private async Task<VariableContainerDetails> FetchVariableContainerAsync(string
return scopeVariableContainer;
}

/// <summary>
/// Reads VariableLayerAttribute from each variable's Attributes collection (set by
/// ImmyBot's MetascriptRunspaceExtensions) and organizes variables into Immy-specific
/// scope containers like "Script", "RunContext", "Action", "Session", etc.
/// These are not real PowerShell scopes but logical groupings that show
/// which ImmyBot layer injected each variable.
/// </summary>
private void FetchImmyVariableScopesAsync()
{
immyScopeVariables.Clear();

// Scan all fetched scope variables for VariableLayerAttribute.
// We check by type name since PSES can't reference ImmyBot's attribute class.
const string layerAttrTypeName = "VariableLayerAttribute";

var layerGroups = new Dictionary<string, List<VariableDetailsBase>>(StringComparer.OrdinalIgnoreCase);

void ScanScope(VariableContainerDetails scope)
{
foreach (var child in scope.Children.Values)
{
// VariableDetails wraps a PSVariable; check its Attributes.
if (child is VariableDetails varDetails && varDetails.PSVariable is PSVariable psVar)
{
foreach (var attr in psVar.Attributes)
{
if (attr.GetType().Name.Equals(layerAttrTypeName, StringComparison.Ordinal))
{
string layer = attr.GetType().GetProperty("Layer")?.GetValue(attr)?.ToString() ?? "Unknown";
if (!layerGroups.TryGetValue(layer, out var list))
{
list = new List<VariableDetailsBase>();
layerGroups[layer] = list;
}
list.Add(child);
break;
}
}
}
}
}

ScanScope(globalScopeVariables);
ScanScope(scriptScopeVariables);
ScanScope(localScopeVariables);

foreach (var layerGroup in layerGroups)
{
string layerName = layerGroup.Key;

VariableContainerDetails layerContainer = new(nextVariableId++, layerName);
variables.Add(layerContainer);

foreach (var varDetails in layerGroup.Value)
{
if (!layerContainer.Children.ContainsKey(varDetails.Name))
{
layerContainer.Children.Add(varDetails.Name, varDetails);
}
}

if (layerContainer.Children.Count > 0)
{
immyScopeVariables.Add(layerContainer);
}
else
{
variables.Remove(layerContainer);
}
}
}

// This is a helper type for FetchStackFramesAsync to preserve the variable Type after deserialization.
private record VariableInfo(string[] Types, PSVariable Variable);

Expand Down Expand Up @@ -756,10 +840,11 @@ private bool ShouldAddToAutoVariables(VariableInfo variableInfo)
return false;
}

// Filter Global-Scoped variables. We first cast to VariableDetails to ensure the prefix
// is added for purposes of comparison.
VariableDetails variableToAddDetails = new(variableToAdd);
if (globalScopeVariables.Children.ContainsKey(variableToAddDetails.Name))
// Filter well-known PowerShell built-in/preference variables to reduce noise.
// We used to filter everything in the global scope, but that also filtered
// user-defined variables that ended up in the global scope (e.g. from
// dot-sourced scripts). Instead, we now only filter known noise variables.
if (s_builtInVariableNames.Contains(variableToAdd.Name))
{
return false;
}
Expand All @@ -769,17 +854,37 @@ private bool ShouldAddToAutoVariables(VariableInfo variableInfo)
{
return variableToAdd.Name switch
{
"PSItem" or "_" or "" => true,
// Skip empty/nothing variables
null or "" or "_" => false,
// Only show args/input if they have content
"args" or "input" => variableToAdd.Value is Array array && array.Length > 0,
"PSBoundParameters" => variableToAdd.Value is IDictionary dict && dict.Count > 0,
_ => false
// Show all other local variables (e.g. $a, $result, $computer)
_ => true
};
}

// Any other PSVariables that survive the above criteria should be included.
return variableInfo.Types[0].EndsWith("PSVariable");
}

private static readonly HashSet<string> s_builtInVariableNames = new(StringComparer.OrdinalIgnoreCase)
{
"ConfirmPreference", "DebugPreference", "Error", "ErrorActionPreference", "ErrorView",
"ExecutionContext", "FormatEnumerationLimit", "HOME", "Host", "InformationPreference",
"input", "MaximumHistoryCount", "MyInvocation", "NestedPromptLevel", "OutputEncoding",
"PID", "PROFILE", "ProgressPreference", "PSBoundParameters", "PSCommandPath",
"PSCulture", "PSDebugContext", "PSDefaultParameterValues", "PSEmailServer",
"PSHome", "PSItem", "PSLogUserProfile", "PSModuleAutoLoadingPreference",
"PSModulePath", "PSNativeCommandArgumentPassing", "PSNativeCommandUseErrorActionPreference",
"PSScriptRoot", "PSSessionConfigurationName", "PSSessionOption", "PSStyle",
"PSUICulture", "PSVersionTable", "PWD", "ShellId", "StackTrace",
"VerbosePreference", "WarningPreference", "WhatIfPreference", "^", "$",
"?", "true", "false", "null", "args", "PSCommand", "PSPath",
"ForEach", "Where", "psEditor", "ImmyBotVersion", "ImmyScriptPath",
"CanAccessParentTenant", "__psEditorServices_CallStack",
};

private async Task FetchStackFramesAsync(string scriptNameOverride)
{
// This glorious hack ensures that Get-PSCallStack returns a list of CallStackFrame
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ internal class VariableDetails : VariableDetailsBase
protected object ValueObject { get; }
private VariableDetails[] cachedChildren;

/// <summary>
/// The original PSVariable if this was created from one, used for attribute inspection.
/// </summary>
public PSVariable PSVariable { get; }

#endregion

#region Constructors
Expand All @@ -43,6 +48,7 @@ internal class VariableDetails : VariableDetailsBase
public VariableDetails(PSVariable psVariable)
: this(DollarPrefix + psVariable.Name, psVariable.Value)
{
PSVariable = psVariable;
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,14 @@ public async Task<SetBreakpointsResponse> Handle(SetBreakpointsArguments request
};
}

// At this point, the source file has been verified as a PowerShell script.
// Use FilePath so the breakpoint Script matches the script's internal path
// (functionContext._file). For files on disk, FilePath is the filesystem path
// (matching what dot-source sets). For pspath:// URIs, FilePath is the URI
// (matching what Parser.ParseInput sets as the Extent.File).
string breakpointScriptPath = scriptFile.FilePath;
IReadOnlyList<BreakpointDetails> breakpointDetails = request.Breakpoints
.Select((srcBreakpoint) => BreakpointDetails.Create(
scriptFile.FilePath,
breakpointScriptPath,
srcBreakpoint.Line,
srcBreakpoint.Column,
srcBreakpoint.Condition,
Expand All @@ -100,10 +104,28 @@ public async Task<SetBreakpointsResponse> Handle(SetBreakpointsArguments request

try
{
// The debugger's DebugMode may be Default or RemoteScript, neither of
// which support SetLineBreakpoint. Use reflection to set it to Local so
// breakpoints can be registered before the script launches.
var debugger = _runspaceContext.CurrentRunspace.Runspace.Debugger;
var debugModeProp = typeof(System.Management.Automation.Debugger).GetProperty("DebugMode");
var currentMode = (System.Management.Automation.DebugModes)debugModeProp!.GetValue(debugger)!;
if ((currentMode & System.Management.Automation.DebugModes.LocalScript) == 0)
{
debugModeProp.SetValue(debugger, currentMode | System.Management.Automation.DebugModes.LocalScript);
}

updatedBreakpointDetails =
await _debugService.SetLineBreakpointsAsync(
scriptFile,
breakpointDetails).ConfigureAwait(false);

// Re-call SetDebugMode after breakpoints are registered. The debugger's
// SetDebugMode internally checks if _idToBreakpoint is non-empty and sets
// _context._debuggingMode to Enabled. If SetDebugMode was called before
// breakpoints were added, _debuggingMode stays 0 and breakpoints are never
// checked during execution.
debugger.SetDebugMode(System.Management.Automation.DebugModes.LocalScript | System.Management.Automation.DebugModes.RemoteScript);
}
catch (Exception e)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,24 +119,15 @@ internal async Task LaunchScriptAsync(string scriptToLaunch)
bool isScriptFile = _workspaceService.TryGetFile(scriptToLaunch, out ScriptFile untitledScript);
if (isScriptFile && BreakpointApiUtils.SupportsBreakpointApis(_runspaceContext.CurrentRunspace))
{
// Parse untitled files with their `Untitled:` URI as the filename which will
// cache the URI and contents within the PowerShell parser. By doing this, we
// light up the ability to debug untitled files with line breakpoints. This is
// only possible with PowerShell 7's new breakpoint APIs since the old API,
// Set-PSBreakpoint, validates that the given path points to a real file.
// Use the DocumentUri directly — the frontend now uses pspath:// URIs everywhere.
string scriptUri = untitledScript.DocumentUri.ToString();

ScriptBlockAst ast = Parser.ParseInput(
untitledScript.Contents,
untitledScript.DocumentUri.ToString(),
scriptUri,
out Token[] _,
out ParseError[] _);

// In order to use utilize the parser's cache (and therefore hit line
// breakpoints) we need to use the AST's `ScriptBlock` object. Due to
// limitations in PowerShell's public API, this means we must use the
// `PSCommand.AddArgument(object)` method, hence this hack where we dot-source
// `$args[0]. Fortunately the dot-source operator maintains a stack of arguments
// on each invocation, so passing the user's arguments directly in the initial
// `AddScript` surprisingly works.
command = PSCommandHelpers
.BuildDotSourceCommandWithArguments("$args[0]", _debugStateService?.Arguments)
.AddArgument(ast.GetScriptBlock());
Expand All @@ -155,10 +146,72 @@ internal async Task LaunchScriptAsync(string scriptToLaunch)
}
}

await _executionService.ExecutePSCommandAsync(
command,
CancellationToken.None,
s_debuggerExecutionOptions).ConfigureAwait(false);
// Fix: Set _debuggingMode on the TLS (Thread Local Storage) execution context.
// The debugger checks _debuggingMode from the TLS context during script execution,
// not from the runspace's debugger context. If TLS _debuggingMode is 0, the debugger
// skips breakpoint checks entirely, even though breakpoints are registered.
try
{
var localPipelineType = typeof(System.Management.Automation.Runspaces.Runspace).Assembly
.GetType("System.Management.Automation.Runspaces.LocalPipeline");
var getCtxMethod = localPipelineType?.GetMethod(
"GetExecutionContextFromTLS",
System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);
var tlsContext = getCtxMethod?.Invoke(null, null);
if (tlsContext is not null)
{
var execContextType = tlsContext.GetType();
var debuggingModeField = execContextType.GetField(
"_debuggingMode",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (debuggingModeField is not null)
{
// DebugModes.LocalScript = 1
debuggingModeField.SetValue(tlsContext, (System.Management.Automation.DebugModes)1);
}
}
}
catch (System.Exception ex)
{
_logger.LogError(ex, "Failed to set TLS _debuggingMode");
}

// Fix: Ensure _context.CurrentRunspace is set on the debugger's ExecutionContext.
// In PSES with UseCurrentThread, the debugger's _context.CurrentRunspace can be null
// which causes OnDebuggerStop to crash with NullReferenceException.
try
{
var dbg = _runspaceContext.CurrentRunspace.Runspace.Debugger;
var dbgType = dbg.GetType();
var contextField = dbgType.GetField(
"_context", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (contextField is not null)
{
var context = contextField.GetValue(dbg);
var currentRunspaceProp = context?.GetType().GetProperty(
"CurrentRunspace", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (currentRunspaceProp is not null && currentRunspaceProp.GetValue(context) is null)
{
currentRunspaceProp.SetValue(context, _runspaceContext.CurrentRunspace.Runspace);
}
}
}
catch (System.Exception ex)
{
_logger.LogError(ex, "Failed to fix null CurrentRunspace on debugger._context");
}

try
{
await _executionService.ExecutePSCommandAsync(
command,
CancellationToken.None,
s_debuggerExecutionOptions).ConfigureAwait(false);
}
catch (System.Exception ex)
{
_logger.LogError(ex, "LaunchScriptAsync: ExecutePSCommandAsync threw");
}

_debugAdapterServer?.SendNotification(EventNames.Terminated);
}
Expand Down
Loading