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
}
}
2 changes: 2 additions & 0 deletions src/PowerShellEditorServices/Server/PsesDebugServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@ public PsesDebugServer(
/// <returns>A task that completes when the server is ready.</returns>
public async Task StartAsync()
{
System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] PsesDebugServer.StartAsync entered\n");
_debugAdapterServer = await DebugAdapterServer.From(options =>
{
System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] DebugAdapterServer.From callback\n");
// We need to let the PowerShell Context Service know that we are in a debug session
// so that it doesn't send the powerShell/startDebugger message.
_psesHost = ServiceProvider.GetService<PsesInternalHost>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ internal void UnregisterEventHandlers()

private void OnDebuggerStopped(object sender, DebuggerStoppedEventArgs e)
{
System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] OnDebuggerStopped: reason={e.OriginalEvent.Breakpoints.Count} breakpoints\n");
// Provide the reason for why the debugger has stopped script execution.
// See https://github.com/Microsoft/vscode/issues/3648
// The reason is displayed in the breakpoints viewlet. Some recommended reasons are:
Expand Down
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 @@ -49,6 +49,7 @@ public BreakpointHandlers(

public async Task<SetBreakpointsResponse> Handle(SetBreakpointsArguments request, CancellationToken cancellationToken)
{
System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] BreakpointHandlers.Handle called, path='{request.Source.Path}'\n");
if (!_workspaceService.TryGetFile(request.Source.Path, out ScriptFile scriptFile))
{
string message = _debugStateService.NoDebug ? string.Empty : "Source file could not be accessed, breakpoint not set.";
Expand Down Expand Up @@ -82,31 +83,60 @@ public async Task<SetBreakpointsResponse> Handle(SetBreakpointsArguments request
};
}

// At this point, the source file has been verified as a PowerShell script.
// Use the DocumentUri directly — the frontend now uses pspath:// URIs everywhere,
// so no normalization is needed.
string breakpointScriptPath = scriptFile.DocumentUri.ToString();
IReadOnlyList<BreakpointDetails> breakpointDetails = request.Breakpoints
.Select((srcBreakpoint) => BreakpointDetails.Create(
scriptFile.FilePath,
breakpointScriptPath,
srcBreakpoint.Line,
srcBreakpoint.Column,
srcBreakpoint.Condition,
srcBreakpoint.HitCondition,
srcBreakpoint.LogMessage)).ToList();

System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] BreakpointHandlers: FilePath='{scriptFile.FilePath}', DocumentUri='{scriptFile.DocumentUri}', breakpointScriptPath='{breakpointScriptPath}', #breakpoints={breakpointDetails.Count}, line={breakpointDetails[0].LineNumber}\n");

// If this is a "run without debugging (Ctrl+F5)" session ignore requests to set breakpoints.
IReadOnlyList<BreakpointDetails> updatedBreakpointDetails = breakpointDetails;
System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] BreakpointHandlers: NoDebug={_debugStateService.NoDebug}\n");
if (!_debugStateService.NoDebug)
{
System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] BreakpointHandlers: calling WaitForSetBreakpointHandleAsync\n");
await _debugStateService.WaitForSetBreakpointHandleAsync().ConfigureAwait(false);
System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] BreakpointHandlers: calling SetLineBreakpointsAsync\n");

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);
System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] BreakpointHandlers: added LocalScript to DebugMode (was {currentMode})\n");
}

updatedBreakpointDetails =
await _debugService.SetLineBreakpointsAsync(
scriptFile,
breakpointDetails).ConfigureAwait(false);
System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] BreakpointHandlers: SetLineBreakpointsAsync returned {updatedBreakpointDetails.Count} breakpoints, verified={updatedBreakpointDetails.FirstOrDefault()?.Verified}\n");

// 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);
System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] BreakpointHandlers: re-called SetDebugMode after breakpoint registration\n");
}
catch (Exception e)
{
System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] BreakpointHandlers: EXCEPTION: {e}\n");
// Log whatever the error is
_logger.LogException($"Caught error while setting breakpoints in SetBreakpoints handler for file {scriptFile?.FilePath}", e);
}
Expand Down
Loading