diff --git a/.gitignore b/.gitignore index f73bd5823..d74b9f73b 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,4 @@ module/PowerShellEditorServices/Third\ Party\ Notices.txt # JetBrains generated file (Rider, intelliJ) .idea/ +nupkgs/ diff --git a/PowerShellEditorServices.Common.props b/PowerShellEditorServices.Common.props index 80d45b7ba..563a917ba 100644 --- a/PowerShellEditorServices.Common.props +++ b/PowerShellEditorServices.Common.props @@ -14,8 +14,9 @@ true - true - true + false + false + false true diff --git a/global.json b/global.json index 4c4c3ae5e..6a288505a 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "8.0.100", - "rollForward": "latestFeature", + "version": "10.0.100", + "rollForward": "latestMajor", "allowPrerelease": false } } diff --git a/src/PowerShellEditorServices/Server/PsesDebugServer.cs b/src/PowerShellEditorServices/Server/PsesDebugServer.cs index 6aca82e04..428ca0119 100644 --- a/src/PowerShellEditorServices/Server/PsesDebugServer.cs +++ b/src/PowerShellEditorServices/Server/PsesDebugServer.cs @@ -51,8 +51,10 @@ public PsesDebugServer( /// A task that completes when the server is ready. 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(); diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/DebugEventHandlerService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/DebugEventHandlerService.cs index e3237a3b0..4ad656311 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/DebugEventHandlerService.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/DebugEventHandlerService.cs @@ -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: diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs index 90faceda4..5edbab79e 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs @@ -49,6 +49,7 @@ internal class DebugService private VariableContainerDetails globalScopeVariables; private VariableContainerDetails scriptScopeVariables; private VariableContainerDetails localScopeVariables; + private List immyScopeVariables = new(); private StackFrameDetails[] stackFrameDetails; private readonly PropertyInfo invocationTypeScriptPositionProperty; @@ -596,7 +597,7 @@ public VariableScope[] GetVariableScopes(int stackFrameId) int autoVariablesId = stackFrames[stackFrameId].AutoVariables.Id; int commandVariablesId = stackFrames[stackFrameId].CommandVariables.Id; - return new VariableScope[] + var scopes = new List { new VariableScope(autoVariablesId, VariableContainerDetails.AutoVariablesName), new VariableScope(commandVariablesId, VariableContainerDetails.CommandVariablesName), @@ -604,6 +605,14 @@ public VariableScope[] GetVariableScopes(int stackFrameId) 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 @@ -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 { @@ -689,6 +701,78 @@ private async Task FetchVariableContainerAsync(string return scopeVariableContainer; } + /// + /// 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. + /// + 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>(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(); + 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); @@ -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; } @@ -769,10 +854,13 @@ 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 }; } @@ -780,6 +868,23 @@ private bool ShouldAddToAutoVariables(VariableInfo variableInfo) return variableInfo.Types[0].EndsWith("PSVariable"); } + private static readonly HashSet 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 diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/VariableDetails.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/VariableDetails.cs index f67230af5..29bbe681f 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/VariableDetails.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/VariableDetails.cs @@ -29,6 +29,11 @@ internal class VariableDetails : VariableDetailsBase protected object ValueObject { get; } private VariableDetails[] cachedChildren; + /// + /// The original PSVariable if this was created from one, used for attribute inspection. + /// + public PSVariable PSVariable { get; } + #endregion #region Constructors @@ -43,6 +48,7 @@ internal class VariableDetails : VariableDetailsBase public VariableDetails(PSVariable psVariable) : this(DollarPrefix + psVariable.Name, psVariable.Value) { + PSVariable = psVariable; } /// diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs index 4c99ff747..e29bcc0ae 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs @@ -49,6 +49,7 @@ public BreakpointHandlers( public async Task 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."; @@ -82,31 +83,60 @@ public async Task 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 = 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 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); } diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs index df8165319..59e21fe3c 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs @@ -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()); @@ -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); } diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs index 700c154b9..ea057da7f 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs @@ -124,6 +124,7 @@ public LaunchAndAttachHandler( public async Task Handle(PsesLaunchRequestArguments request, CancellationToken cancellationToken) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] LaunchAndAttachHandler.Handle called, Script='{request.Script}'\n"); // The debugger has officially started. We use this to later check if we should stop it. ((PsesInternalHost)_executionService).DebugContext.IsActive = true; diff --git a/src/PowerShellEditorServices/Services/PowerShell/Execution/SynchronousPowerShellTask.cs b/src/PowerShellEditorServices/Services/PowerShell/Execution/SynchronousPowerShellTask.cs index 7c05b3da2..3936afa9f 100644 --- a/src/PowerShellEditorServices/Services/PowerShell/Execution/SynchronousPowerShellTask.cs +++ b/src/PowerShellEditorServices/Services/PowerShell/Execution/SynchronousPowerShellTask.cs @@ -57,6 +57,8 @@ public SynchronousPowerShellTask( public override IReadOnlyList Run(CancellationToken cancellationToken) { + var cmdText = _psCommand.GetInvocationText(); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] SynchronousPowerShellTask.Run on thread {System.Environment.CurrentManagedThreadId}, command={cmdText.Substring(0, System.Math.Min(50, cmdText.Length))}\n"); _psesHost.Runspace.ThrowCancelledIfUnusable(); PowerShellContextFrame frame = _psesHost.PushPowerShellForExecution(); try @@ -133,7 +135,103 @@ private IReadOnlyList ExecuteNormally(CancellationToken cancellationTok invocationSettings.ErrorActionPreference = ActionPreference.Stop; } - result = _pwsh.InvokeCommand(_psCommand, invocationSettings); + // Check _debuggingMode right before Invoke + try { + var debugger = _pwsh.Runspace.Debugger; + var debuggerType = debugger.GetType(); + var contextField = debuggerType.GetField("_context", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (contextField != null) { + var context = contextField.GetValue(debugger); + var execContextType = context.GetType(); + var debuggingModeField = execContextType.GetField("_debuggingMode", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (debuggingModeField != null) { + var debuggingMode = debuggingModeField.GetValue(context); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] ExecuteNormally: _context._debuggingMode={debuggingMode} BEFORE Invoke, InBreakpoint={debugger.InBreakpoint}\n"); + } + // Check if Runspace.DefaultRunspace matches our runspace + var defaultRunspace = System.Management.Automation.Runspaces.Runspace.DefaultRunspace; + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] ExecuteNormally: Runspace.DefaultRunspace.Id={defaultRunspace?.Id}, _pwsh.Runspace.Id={_pwsh.Runspace.Id}, same={defaultRunspace == _pwsh.Runspace}\n"); + if (defaultRunspace != null && defaultRunspace != _pwsh.Runspace) { + var ecProp = typeof(System.Management.Automation.Runspaces.Runspace).GetProperty("ExecutionContext", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var tlsExecContext = ecProp?.GetValue(defaultRunspace); + var tlsDebugMode = execContextType.GetField("_debuggingMode", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(tlsExecContext); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] ExecuteNormally: TLS _debuggingMode={tlsDebugMode}\n"); + } + // Check if the ExecutionContext from the runspace matches the debugger's context + var ecProp2 = typeof(System.Management.Automation.Runspaces.Runspace).GetProperty("ExecutionContext", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var runspaceEc = ecProp2?.GetValue(_pwsh.Runspace); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] ExecuteNormally: runspace.ExecutionContext == debugger._context: {runspaceEc == context}\n"); + // Check _idToBreakpoint + var idToBreakpointField = debuggerType.GetField("_idToBreakpoint", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (idToBreakpointField != null) { + var idToBreakpoint = idToBreakpointField.GetValue(debugger) as System.Collections.IDictionary; + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] ExecuteNormally: _idToBreakpoint count={idToBreakpoint?.Count}\n"); + if (idToBreakpoint != null) { + foreach (System.Collections.DictionaryEntry entry in idToBreakpoint) { + var bp = entry.Value; + var bpType = bp.GetType(); + var scriptProp = bpType.GetProperty("Script"); + var lineProp = bpType.GetProperty("Line"); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] breakpoint: id={entry.Key}, script='{scriptProp?.GetValue(bp)}', line={lineProp?.GetValue(bp)}\n"); + // Check SequencePointIndex and BreakpointBitArray + var spIndexProp = bpType.GetProperty("SequencePointIndex", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (spIndexProp != null) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] SequencePointIndex={spIndexProp.GetValue(bp)}\n"); + } + var bpBitArrayProp = bpType.GetProperty("BreakpointBitArray", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (bpBitArrayProp != null) { + var bitArray = bpBitArrayProp.GetValue(bp) as System.Collections.BitArray; + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] BreakpointBitArray={(bitArray == null ? "null" : bitArray.Count + " bits")}\n"); + } + } + } + } + // Check _pendingBreakpoints + var pendingBpField = debuggerType.GetField("_pendingBreakpoints", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (pendingBpField != null) { + var pendingBp = pendingBpField.GetValue(debugger) as System.Collections.IDictionary; + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] ExecuteNormally: _pendingBreakpoints count={pendingBp?.Count}\n"); + if (pendingBp != null) { + foreach (System.Collections.DictionaryEntry entry in pendingBp) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] pending key='{entry.Key}'\n"); + } + } + } + } + // Also log the command text + var cmdText = _psCommand.GetInvocationText(); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] ExecuteNormally: about to invoke '{cmdText.Substring(0, System.Math.Min(100, cmdText.Length))}'\n"); + } catch (System.Exception ex) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] ExecuteNormally: reflection failed: {ex.Message}\n"); + } + + // Check CurrentRunspace before Invoke + try { + var dbg = _pwsh.Runspace.Debugger; + var dbgType = dbg.GetType(); + var contextField = dbgType.GetField("_context", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (contextField != null) { + var context = contextField.GetValue(dbg); + var crProp = context?.GetType().GetProperty("CurrentRunspace", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var cr = crProp?.GetValue(context); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] ExecuteNormally: BEFORE Invoke CurrentRunspace={cr?.GetHashCode()}, isNull={cr == null}, ctxHash={context?.GetHashCode()}, thread={System.Environment.CurrentManagedThreadId}\n"); + } + } catch { } + + result = _pwsh.InvokeCommand(_psCommand, invocationSettings); + + // Check CurrentRunspace after Invoke + try { + var dbg = _pwsh.Runspace.Debugger; + var dbgType = dbg.GetType(); + var contextField = dbgType.GetField("_context", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (contextField != null) { + var context = contextField.GetValue(dbg); + var crProp = context?.GetType().GetProperty("CurrentRunspace", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var cr = crProp?.GetValue(context); + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] ExecuteNormally: AFTER Invoke CurrentRunspace={cr?.GetHashCode()}, isNull={cr == null}, ctxHash={context?.GetHashCode()}, thread={System.Environment.CurrentManagedThreadId}\n"); + } + } catch { } cancellationToken.ThrowIfCancellationRequested(); } // Allow terminate exceptions to propagate for flow control. diff --git a/src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs b/src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs index 01d33bd42..0d1786386 100644 --- a/src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs +++ b/src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs @@ -890,13 +890,13 @@ private void PushPowerShellAndMaybeRunLoop(PowerShellContextFrame frame, bool sk try { - if (_psFrameStack.Count == 1) + if (frame.IsDebug) { - RunTopLevelExecutionLoop(); + RunDebugExecutionLoop(); } - else if (frame.IsDebug) + else if (_psFrameStack.Count == 1) { - RunDebugExecutionLoop(); + RunTopLevelExecutionLoop(); } else { @@ -1290,9 +1290,11 @@ private void InvokeInput(string input, CancellationToken cancellationToken) private void AddRunspaceEventHandlers(Runspace runspace) { + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] AddRunspaceEventHandlers: runspace Id={runspace.Id}, Debugger type={runspace.Debugger.GetType().Name}\n"); runspace.Debugger.DebuggerStop += OnDebuggerStopped; runspace.Debugger.BreakpointUpdated += OnBreakpointUpdated; runspace.StateChanged += OnRunspaceStateChanged; + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] AddRunspaceEventHandlers: DebuggerStop subscribed, DebugMode={runspace.Debugger.DebugMode}\n"); } private void RemoveRunspaceEventHandlers(Runspace runspace) @@ -1545,6 +1547,7 @@ internal void WaitForExternalDebuggerStops() private void OnDebuggerStopped(object sender, DebuggerStopEventArgs debuggerStopEventArgs) { + try { System.Console.Error.WriteLine("PSES_ON_DEBUGGER_STOPPED_CALLED"); System.IO.File.AppendAllText("/tmp/pses-debug.log", "[PSES] PSES_ON_DEBUGGER_STOPPED_CALLED\n"); } catch (System.Exception ex) { System.Console.Error.WriteLine($"PSES_ON_DEBUGGER_STOPPED_FAILED: {ex.Message}"); } // If ErrorActionPreference is set to Break, any engine exception is going to trigger a // pipeline stop. Technically this is the same behavior as a standalone PowerShell // process, but we use pipeline stops with greater frequency due to features like run @@ -1569,10 +1572,13 @@ private void OnDebuggerStopped(object sender, DebuggerStopEventArgs debuggerStop if (triggerObject is PipelineStoppedException pse) { + System.IO.File.AppendAllText("/tmp/pses-debug2.log", "[PSES] OnDebuggerStopped: throwing PipelineStoppedException\n"); throw pse; } } + System.IO.File.AppendAllText("/tmp/pses-debug.log", $"[PSES] OnDebuggerStopped: past trigger check, isExternal={Environment.CurrentManagedThreadId != _pipelineThread.ManagedThreadId}, thread={Environment.CurrentManagedThreadId}, pipelineThread={_pipelineThread.ManagedThreadId}\n"); + // The debugger has officially started. We use this to later check if we should stop it. DebugContext.IsActive = true; diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/DidChangeWatchedFilesHandler.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/DidChangeWatchedFilesHandler.cs index edbe9b0ad..70cdc067b 100644 --- a/src/PowerShellEditorServices/Services/TextDocument/Handlers/DidChangeWatchedFilesHandler.cs +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/DidChangeWatchedFilesHandler.cs @@ -66,7 +66,11 @@ public Task Handle(DidChangeWatchedFilesParams request, CancellationToken matcher.AddExcludePatterns(_workspaceService.ExcludeFilesGlob); foreach (FileEvent change in request.Changes) { - if (matcher.Match(change.Uri.GetFileSystemPath()).HasMatches) + string changePath = change.Uri.ToUri().IsFile + ? change.Uri.GetFileSystemPath() + : change.Uri.ToUri().AbsolutePath; + + if (matcher.Match(changePath).HasMatches) { continue; } @@ -100,7 +104,7 @@ public Task Handle(DidChangeWatchedFilesParams request, CancellationToken string fileContents; try { - fileContents = WorkspaceService.ReadFileContents(change.Uri); + fileContents = _workspaceService.ReadFileContents(change.Uri); } catch { diff --git a/src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs b/src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs index fb6772d2b..a1eb7a4fb 100644 --- a/src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs +++ b/src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs @@ -183,12 +183,31 @@ internal static List GetLines(string text) /// True if the path is an untitled file, false otherwise. internal static bool IsUntitledPath(string path) { - Validate.IsNotNull(nameof(path), path); - // This may not have been given a URI, so return false instead of throwing. - return Uri.IsWellFormedUriString(path, UriKind.RelativeOrAbsolute) && - !string.Equals(DocumentUri.From(path).Scheme, Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase); + if (!Uri.IsWellFormedUriString(path, UriKind.RelativeOrAbsolute)) + { + return false; + } + + DocumentUri documentUri = DocumentUri.From(path); + string scheme = documentUri.Scheme?.ToLowerInvariant(); + if (!IsSupportedScheme(scheme)) + { + return false; + } + + return scheme switch + { + "inmemory" or "untitled" or "vscode-notebook-cell" => true, + _ => false, + }; } + internal static bool IsSupportedScheme(string scheme) => scheme?.ToLowerInvariant() switch + { + "file" or "inmemory" or "untitled" or "vscode-notebook-cell" or "pspath" => true, + _ => false, + }; + /// /// Gets a line from the file's contents. /// diff --git a/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs b/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs index ad9b04552..7e1ee82e8 100644 --- a/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs +++ b/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs @@ -6,10 +6,16 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Management.Automation; using System.Security; using System.Text; +using System.Threading; +using Microsoft.Extensions.FileSystemGlobbing; using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Services.Workspace; using Microsoft.PowerShell.EditorServices.Utility; using OmniSharp.Extensions.LanguageServer.Protocol; using OmniSharp.Extensions.LanguageServer.Protocol.Models; @@ -24,15 +30,42 @@ internal class WorkspaceService { #region Private Fields + // List of all file extensions considered PowerShell files in the .Net Core Framework. + private static readonly string[] s_psFileExtensionsCoreFramework = + { + ".ps1", + ".psm1", + ".psd1" + }; + + // .Net Core doesn't appear to use the same three letter pattern matching rule although the docs + // suggest it should be find the '.ps1xml' files because we search for the pattern '*.ps1'. + // ref https://docs.microsoft.com/en-us/dotnet/api/system.io.directory.getfiles?view=netcore-2.1#System_IO_Directory_GetFiles_System_String_System_String_System_IO_EnumerationOptions_ + private static readonly string[] s_psFileExtensionsFullFramework = + { + ".ps1", + ".psm1", + ".psd1", + ".ps1xml" + }; + // An array of globs which includes everything. private static readonly string[] s_psIncludeAllGlob = new[] { "**/*" }; + private const string s_psPathScheme = "pspath"; + + // The host component of pspath:// URIs for items from the ScriptPSProvider drive. + // PowerShell's PSPath uses the drive name (pspath:) but we need the provider name + // as the URI host to round-trip correctly through GetPowerShellPath. + private const string s_psPathProviderHost = "ScriptPSProvider"; + private readonly ILogger logger; private readonly Version powerShellVersion; private readonly ConcurrentDictionary workspaceFiles = new(); + private readonly PsesInternalHost psesInternalHost; #endregion @@ -79,11 +112,18 @@ public WorkspaceService(ILoggerFactory factory) FollowSymlinks = true; } + /// + /// Creates a new instance of the Workspace class backed by a PowerShell host. + /// + public WorkspaceService(ILoggerFactory factory, PsesInternalHost psesInternalHost) + : this(factory) => this.psesInternalHost = psesInternalHost; + #endregion #region Public Methods - public IEnumerable WorkspacePaths => WorkspaceFolders.Select(i => i.Uri.GetFileSystemPath()); + public IEnumerable WorkspacePaths => WorkspaceFolders.Select( + folder => folder.Uri.ToUri().IsFile ? folder.Uri.GetFileSystemPath() : GetPowerShellPath(folder.Uri)); /// /// Gets an open file in the workspace. If the file isn't open but exists on the filesystem, load and return it. @@ -118,18 +158,8 @@ public ScriptFile GetFile(DocumentUri documentUri) // Make sure the file isn't already loaded into the workspace if (!workspaceFiles.TryGetValue(keyName, out ScriptFile scriptFile)) { - // This method allows FileNotFoundException to bubble up - // if the file isn't found. - using (StreamReader streamReader = OpenStreamReader(documentUri)) - { - scriptFile = - new ScriptFile( - documentUri, - streamReader, - powerShellVersion); - - workspaceFiles[keyName] = scriptFile; - } + scriptFile = ScriptFile.Create(documentUri, ReadFileContents(documentUri), powerShellVersion); + workspaceFiles[keyName] = scriptFile; logger.LogDebug("Opened file on disk: " + documentUri.ToString()); } @@ -171,18 +201,10 @@ public bool TryGetFile(Uri fileUri, out ScriptFile scriptFile) => /// The out parameter that will contain the ScriptFile object. public bool TryGetFile(DocumentUri documentUri, out ScriptFile scriptFile) { - switch (documentUri.Scheme) + if (!ScriptFile.IsSupportedScheme(documentUri.Scheme)) { - // List supported schemes here - case "file": - case "inmemory": - case "untitled": - case "vscode-notebook-cell": - break; - - default: - scriptFile = null; - return false; + scriptFile = null; + return false; } try @@ -281,7 +303,14 @@ public void CloseFile(ScriptFile scriptFile) Validate.IsNotNull(nameof(scriptFile), scriptFile); string keyName = GetFileKey(scriptFile.DocumentUri); - workspaceFiles.TryRemove(keyName, out ScriptFile _); + if (workspaceFiles.TryRemove(keyName, out ScriptFile _)) + { + logger.LogDebug("Closed file: " + scriptFile.DocumentUri); + } + else + { + logger.LogWarning("Tried to close file that was not open: " + scriptFile.DocumentUri); + } } /// @@ -314,6 +343,35 @@ public string GetRelativePath(ScriptFile scriptFile) return fileUri.ToString(); } + /// + /// Finds a file in the first workspace folder where it exists, if possible. + /// Used as a backwards-compatible way to find files in the workspace. + /// + /// + /// Best possible path. + public string FindFileInWorkspace(string filePath) + { + // If the file path is already an absolute path, just return it. + if (Path.IsPathRooted(filePath)) + { + return filePath; + } + + // If the file path is relative, try to find it in the workspace folders. + foreach (WorkspaceFolder workspaceFolder in WorkspaceFolders) + { + string folderPath = workspaceFolder.Uri.GetFileSystemPath(); + string combinedPath = Path.Combine(folderPath, filePath); + if (File.Exists(combinedPath)) + { + return combinedPath; + } + } + + // If the file path is not found in the workspace folders, return the original path. + return filePath; + } + /// /// Enumerate all the PowerShell (ps1, psm1, psd1) files in the workspace in a recursive manner, using default values. /// @@ -337,7 +395,95 @@ public IEnumerable EnumeratePSFiles( string[] excludeGlobs, string[] includeGlobs, int maxDepth, - bool ignoreReparsePoints) => []; + bool ignoreReparsePoints) + { + string[] powerShellWorkspacePaths = GetPowerShellWorkspacePaths() + .Where(path => !string.IsNullOrEmpty(path)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + string[] fileSystemWorkspacePaths = GetFileSystemWorkspacePaths() + .Where(path => !string.IsNullOrEmpty(path)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (powerShellWorkspacePaths.Length == 0 && fileSystemWorkspacePaths.Length == 0) + { + yield break; + } + + if (psesInternalHost is not null && powerShellWorkspacePaths.Length > 0) + { + // Determine if all workspace paths are FileSystem provider paths. + // Non-FileSystem providers (e.g. pspath:) don't support Include/Exclude/Depth/FollowSymlink + // dynamic parameters, so we only add them for FileSystem paths. + bool allFileSystem = powerShellWorkspacePaths.All( + p => p.StartsWith("Microsoft.PowerShell.FileSystem", StringComparison.OrdinalIgnoreCase) + || !p.Contains("::")); + + PSCommand psCommand = new PSCommand() + .AddCommand(@"Microsoft.PowerShell.Management\Get-ChildItem") + .AddParameter("LiteralPath", powerShellWorkspacePaths) + .AddParameter("Recurse") + .AddParameter("ErrorAction", ActionPreference.SilentlyContinue) + .AddParameter("Force"); + + if (allFileSystem) + { + psCommand + .AddParameter("Include", includeGlobs.Concat(VersionUtils.IsNetCore ? s_psFileExtensionsCoreFramework : s_psFileExtensionsFullFramework).ToArray()) + .AddParameter("Exclude", excludeGlobs) + .AddParameter("Depth", maxDepth); + + if (VersionUtils.IsNetCore) + { + psCommand.AddParameter("FollowSymlink", !ignoreReparsePoints); + } + } + + psCommand + .AddCommand("Where-Object") + .AddParameter("Property", "PSIsContainer") + .AddParameter("EQ") + .AddParameter("Value", false); + + IReadOnlyList results = psesInternalHost.InvokePSCommand(psCommand, null, CancellationToken.None); + foreach (string path in results.Select(ConvertWorkspaceItemPath).Where(path => !string.IsNullOrEmpty(path))) + { + yield return path; + } + + yield break; + } + + Matcher matcher = new(); + foreach (string pattern in includeGlobs) { matcher.AddInclude(pattern); } + foreach (string pattern in excludeGlobs) { matcher.AddExclude(pattern); } + + foreach (string rootPath in fileSystemWorkspacePaths) + { + if (!Directory.Exists(rootPath)) + { + continue; + } + + WorkspaceFileSystemWrapperFactory fsFactory = new( + rootPath, + maxDepth, + VersionUtils.IsNetCore ? s_psFileExtensionsCoreFramework : s_psFileExtensionsFullFramework, + ignoreReparsePoints, + logger); + + PatternMatchingResult fileMatchResult = matcher.Execute(fsFactory.RootDirectory); + foreach (FilePatternMatch item in fileMatchResult.Files) + { + // item.Path always contains forward slashes in paths when it should be backslashes on Windows. + // Since we're returning strings here, it's important to use the correct directory separator. + string path = VersionUtils.IsWindows ? item.Path.Replace('/', Path.DirectorySeparatorChar) : item.Path; + yield return Path.Combine(rootPath, path); + } + } + } #endregion @@ -353,10 +499,161 @@ internal static StreamReader OpenStreamReader(DocumentUri uri) return new StreamReader(fileStream, new UTF8Encoding(), detectEncodingFromByteOrderMarks: true); } - internal static string ReadFileContents(DocumentUri uri) + internal string ReadFileContents(DocumentUri uri) { - using StreamReader reader = OpenStreamReader(uri); - return reader.ReadToEnd(); + if (uri.ToUri().IsFile || psesInternalHost is null) + { + using StreamReader reader = OpenStreamReader(uri); + return reader.ReadToEnd(); + } + + string psPath = GetPowerShellPath(uri); + try + { + IReadOnlyList result = psesInternalHost.InvokePSCommand( + new PSCommand() + .AddCommand(@"Microsoft.PowerShell.Management\Get-Content") + .AddParameter("LiteralPath", psPath) + .AddParameter("ErrorAction", ActionPreference.Stop), + new PowerShellExecutionOptions { ThrowOnError = true }, + CancellationToken.None); + + return string.Join(Environment.NewLine, result); + } + catch (ActionPreferenceStopException ex) + when (ex.ErrorRecord.CategoryInfo.Category == ErrorCategory.ObjectNotFound + && ex.ErrorRecord.TargetObject is string[] missingFiles + && missingFiles.Length == 1) + { + throw new FileNotFoundException(ex.ErrorRecord.ToString(), missingFiles[0], ex.ErrorRecord.Exception); + } + } + + // Return only file-backed workspace roots as filesystem paths. + // Example: + // file:///repo -> /repo + // pspath://FileSystem/C%3A/repo -> excluded + private IEnumerable GetFileSystemWorkspacePaths() + { + if (WorkspaceFolders.Count > 0) + { + return WorkspaceFolders + .Select(folder => folder.Uri) + .Where(uri => uri.ToUri().IsFile) + .Select(uri => uri.GetFileSystemPath()); + } + + return string.IsNullOrEmpty(InitialWorkingDirectory) + ? Array.Empty() + : new[] { InitialWorkingDirectory }; + } + + // Return only provider-backed workspace roots as PowerShell literal paths. + // Example: + // pspath://FileSystem/C%3A/repo -> FileSystem::C:/repo + // file:///repo -> excluded + private IEnumerable GetPowerShellWorkspacePaths() + { + if (WorkspaceFolders.Count > 0) + { + return WorkspaceFolders + .Select(folder => folder.Uri) + .Where(uri => !uri.ToUri().IsFile) + .Select(GetPowerShellPath); + } + + return Array.Empty(); + } + + // Normalize Get-ChildItem output to a workspace path string. + // Example: + // FullName=/repo/a.ps1 -> /repo/a.ps1 + // PSPath=Registry::HKEY_CURRENT_USER\\Software\\Foo -> pspath://Registry/HKEY_CURRENT_USER/Software/Foo + private static string ConvertWorkspaceItemPath(PSObject item) + { + if (item.Properties["FullName"]?.Value is string fullName && !string.IsNullOrEmpty(fullName)) + { + return fullName; + } + + return item.Properties["PSPath"]?.Value is string psPath && !string.IsNullOrEmpty(psPath) + ? CreatePowerShellPathUri(psPath) + : null; + } + + // Convert a document URI to the literal path PowerShell commands should use. + // Example: + // file:///repo/a.ps1 -> /repo/a.ps1 + // pspath://FileSystem/C%3A/repo/a.ps1 -> FileSystem::C:/repo/a.ps1 + private static string GetPowerShellPath(DocumentUri uri) + { + Uri parsedUri = uri.ToUri(); + if (parsedUri.IsFile) + { + return parsedUri.LocalPath; + } + + if (string.Equals(uri.Scheme, s_psPathScheme, StringComparison.OrdinalIgnoreCase)) + { + string provider = parsedUri.GetComponents(UriComponents.Host, UriFormat.Unescaped); + string path = Uri.UnescapeDataString(parsedUri.AbsolutePath); + if (path.Length >= 3 && path[0] == '/' && char.IsLetter(path[1]) && path[2] == ':') + { + path = path.TrimStart('/'); + } + + return string.IsNullOrEmpty(provider) + ? path.TrimStart('/') + : $"{provider}::{path}"; + } + + throw new NotSupportedException($"Unsupported URI scheme '{uri.Scheme}'."); + } + + // Convert a PowerShell provider path to the pspath:// document form used by the workspace. + // Example: + // FileSystem::C:\\repo\\a.ps1 -> pspath://FileSystem/C%3A/repo/a.ps1 + // Registry::HKEY_CURRENT_USER\\Software\\Foo -> pspath://Registry/HKEY_CURRENT_USER/Software/Foo + // pspath:\local\Function\MyScript.ps1 -> pspath://ScriptPSProvider/local/Function/MyScript.ps1 + // ScriptPSProvider::pspath:\local\Function\MyScript.ps1 -> pspath://ScriptPSProvider/local/Function/MyScript.ps1 + private static string CreatePowerShellPathUri(string psPath) + { + // Strip the provider qualifier prefix if present (e.g. "ScriptPSProvider::pspath:\..." -> "pspath:\...") + string pathPart = psPath; + if (pathPart.Contains("::")) + { + string[] qualifiedParts = pathPart.Split(new[] { "::" }, 2, StringSplitOptions.None); + if (qualifiedParts.Length == 2) + { + pathPart = qualifiedParts[1]; + } + } + + // Handle drive-qualified paths from the pspath: provider drive, e.g. + // pspath:\local\Function\MyScript.ps1 + // PowerShell sets PSPath to the drive-qualified path (potentially provider-qualified + // as "ScriptPSProvider::pspath:\..."), so we strip the provider prefix above and + // then reconstruct the provider-qualified URI form. + if (pathPart.StartsWith($"{s_psPathScheme}:", StringComparison.OrdinalIgnoreCase)) + { + int colonIndex = pathPart.IndexOf(':'); + string drivePath = pathPart.Substring(colonIndex + 1).Replace('\\', '/').TrimStart('/'); + string driveEncodedPath = string.Join("/", drivePath.Split('/').Select(Uri.EscapeDataString)); + return $"{s_psPathScheme}://{s_psPathProviderHost}/{driveEncodedPath}"; + } + + // For other provider-qualified paths (e.g. FileSystem::C:\repo\a.ps1), + // use the original psPath which still has the "::" separator. + string[] parts = psPath.Split(new[] { "::" }, 2, StringSplitOptions.None); + if (parts.Length != 2) + { + return $"{s_psPathScheme}:///{Uri.EscapeDataString(psPath)}"; + } + + string provider = parts[0].Split('\\').Last(); + string normalizedPath = parts[1].Replace('\\', '/'); + string encodedPath = string.Join("/", normalizedPath.Split('/').Select(Uri.EscapeDataString)); + return $"{s_psPathScheme}://{Uri.EscapeDataString(provider)}/{encodedPath}"; } internal string ResolveWorkspacePath(string path) => ResolveRelativeScriptPath(InitialWorkingDirectory, path); diff --git a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs index 30b020c30..5142bdbde 100644 --- a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs +++ b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs @@ -33,21 +33,22 @@ internal class TestReadLine : IReadLine } [Trait("Category", "DebugService")] - public class DebugServiceTests : IDisposable + public class DebugServiceTests : IAsyncLifetime { - private readonly PsesInternalHost psesHost; - private readonly BreakpointService breakpointService; - private readonly DebugService debugService; + private PsesInternalHost psesHost; + private BreakpointService breakpointService; + private DebugService debugService; private readonly BlockingCollection debuggerStoppedQueue = new(); - private readonly WorkspaceService workspace; - private readonly ScriptFile debugScriptFile; - private readonly ScriptFile oddPathScriptFile; - private readonly ScriptFile variableScriptFile; + private WorkspaceService workspace; + private ScriptFile debugScriptFile; + private ScriptFile oddPathScriptFile; + private ScriptFile psProviderPathScriptFile; + private ScriptFile variableScriptFile; private readonly TestReadLine testReadLine = new(); - public DebugServiceTests() + public async Task InitializeAsync() { - psesHost = PsesHostFactory.Create(NullLoggerFactory.Instance); + psesHost = await PsesHostFactory.Create(NullLoggerFactory.Instance); // This is required for remote debugging, but we call it here to end up in the same // state as the usual startup path. psesHost.DebugContext.EnableDebugMode(); @@ -70,32 +71,51 @@ public DebugServiceTests() debugService.DebuggerStopped += OnDebuggerStopped; // Load the test debug files. - workspace = new WorkspaceService(NullLoggerFactory.Instance); + workspace = new WorkspaceService(NullLoggerFactory.Instance, psesHost); debugScriptFile = GetDebugScript("DebugTest.ps1"); oddPathScriptFile = GetDebugScript("Debug' W&ith $Params [Test].ps1"); variableScriptFile = GetDebugScript("VariableTest.ps1"); + + string variableScriptFilePath = TestUtilities.GetSharedPath(Path.Combine("Debugging", "VariableTest.ps1")); + dynamic psItem = (await psesHost.ExecutePSCommandAsync( + new PSCommand() + .AddCommand("Get-Item") + .AddParameter("LiteralPath", variableScriptFilePath), + CancellationToken.None)).First(); + + psProviderPathScriptFile = workspace.GetFile(ConvertPSPathToUri((string)psItem.PSPath.ToString())); } - public void Dispose() + public async Task DisposeAsync() { debugService.Abort(); + await Task.Run(psesHost.StopAsync); debuggerStoppedQueue.Dispose(); -#pragma warning disable VSTHRD002 - psesHost.StopAsync().Wait(); -#pragma warning restore VSTHRD002 - GC.SuppressFinalize(this); } /// - /// This event handler lets us test that the debugger stopped or paused as expected. It will - /// deadlock if called in the PSES Pipeline Thread, which can easily happen in this test - /// code when methods on are called. Hence we treat this test - /// code like UI code and use 'ConfigureAwait(true)' or 'Task.Run(...)' to ensure we stay - /// OFF the pipeline thread. + /// This event handler lets us test that the debugger stopped or paused + /// as expected. It will deadlock if called in the PSES Pipeline Thread. + /// Hence we use 'Task.Run(...)' when accessing the queue to ensure we + /// stay OFF the pipeline thread. /// /// /// - private void OnDebuggerStopped(object sender, DebuggerStoppedEventArgs e) => debuggerStoppedQueue.Add(e); + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD110:Observe result of async calls", Justification = "This intentionally fires and forgets on another thread.")] + private void OnDebuggerStopped(object sender, DebuggerStoppedEventArgs e) => Task.Run(() => debuggerStoppedQueue.Add(e)); + + // Convert a PowerShell provider path into the pspath:// URI form used by workspace tests. + // Example: + // FileSystem::C:\\repo\\a.ps1 -> pspath://FileSystem/C%3A/repo/a.ps1 + // Registry::HKEY_CURRENT_USER\\Software\\Foo -> pspath://Registry/HKEY_CURRENT_USER/Software/Foo + private static string ConvertPSPathToUri(string psPath) + { + string[] parts = psPath.Split(new[] { "::" }, 2, StringSplitOptions.None); + string provider = parts[0].Split('\\').Last(); + string normalizedPath = parts[1].Replace('\\', '/'); + string encodedPath = string.Join("/", normalizedPath.Split('/').Select(Uri.EscapeDataString)); + return $"pspath://{Uri.EscapeDataString(provider)}/{encodedPath}"; + } private ScriptFile GetDebugScript(string fileName) => workspace.GetFile(TestUtilities.GetSharedPath(Path.Combine("Debugging", fileName))); @@ -118,20 +138,20 @@ private Task ExecuteScriptFileAsync(string scriptFilePath, params string[] args) private Task ExecuteVariableScriptFileAsync() => ExecuteScriptFileAsync(variableScriptFile.FilePath); - private void AssertDebuggerPaused() + private async Task AssertDebuggerPaused() { using CancellationTokenSource cts = new(60000); - DebuggerStoppedEventArgs eventArgs = debuggerStoppedQueue.Take(cts.Token); + DebuggerStoppedEventArgs eventArgs = await Task.Run(() => debuggerStoppedQueue.Take(cts.Token)); Assert.Empty(eventArgs.OriginalEvent.Breakpoints); } - private void AssertDebuggerStopped( + private async Task AssertDebuggerStopped( string scriptPath = "", int lineNumber = -1, CommandBreakpointDetails commandBreakpointDetails = default) { - using CancellationTokenSource cts = new(60000); - DebuggerStoppedEventArgs eventArgs = debuggerStoppedQueue.Take(cts.Token); + using CancellationTokenSource cts = new(30000); + DebuggerStoppedEventArgs eventArgs = await Task.Run(() => debuggerStoppedQueue.Take(cts.Token)); Assert.True(psesHost.DebugContext.IsStopped); @@ -176,8 +196,8 @@ await debugService.SetCommandBreakpointsAsync( Task> executeTask = psesHost.ExecutePSCommandAsync( new PSCommand().AddScript("Get-Random -SetSeed 42 -Maximum 100"), CancellationToken.None); - AssertDebuggerStopped("", 1); - await Task.Run(debugService.Continue); + await AssertDebuggerStopped("", 1); + debugService.Continue(); Assert.Equal(17, (await executeTask)[0]); StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); @@ -204,7 +224,7 @@ await debugService.SetCommandBreakpointsAsync( public async Task DebuggerAcceptsScriptArgs(string[] args) { IReadOnlyList breakpoints = await debugService.SetLineBreakpointsAsync( - oddPathScriptFile, + oddPathScriptFile.FilePath, new[] { BreakpointDetails.Create(oddPathScriptFile.FilePath, 3) }); Assert.Single(breakpoints); @@ -218,7 +238,7 @@ public async Task DebuggerAcceptsScriptArgs(string[] args) Task _ = ExecuteScriptFileAsync(oddPathScriptFile.FilePath, args); - AssertDebuggerStopped(oddPathScriptFile.FilePath, 3); + await AssertDebuggerStopped(oddPathScriptFile.FilePath, 3); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -285,7 +305,7 @@ public async Task DebuggerStopsOnFunctionBreakpoints() new[] { CommandBreakpointDetails.Create("Write-Host") }); Task _ = ExecuteDebugFileAsync(); - AssertDebuggerStopped(debugScriptFile.FilePath, 6); + await AssertDebuggerStopped(debugScriptFile.FilePath, 6); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -296,8 +316,8 @@ public async Task DebuggerStopsOnFunctionBreakpoints() Assert.Equal("1", i.ValueString); // The function breakpoint should fire the next time through the loop. - await Task.Run(debugService.Continue); - AssertDebuggerStopped(debugScriptFile.FilePath, 6); + debugService.Continue(); + await AssertDebuggerStopped(debugScriptFile.FilePath, 6); variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -313,7 +333,7 @@ public async Task DebuggerSetsAndClearsLineBreakpoints() { IReadOnlyList breakpoints = await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 5), BreakpointDetails.Create(debugScriptFile.FilePath, 10) @@ -326,7 +346,7 @@ await debugService.SetLineBreakpointsAsync( Assert.Equal(10, breakpoints[1].LineNumber); breakpoints = await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 2) }); confirmedBreakpoints = await GetConfirmedBreakpoints(debugScriptFile); @@ -334,7 +354,7 @@ await debugService.SetLineBreakpointsAsync( Assert.Equal(2, breakpoints[0].LineNumber); await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, Array.Empty()); IReadOnlyList remainingBreakpoints = await GetConfirmedBreakpoints(debugScriptFile); @@ -345,16 +365,16 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerStopsOnLineBreakpoints() { await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 5), BreakpointDetails.Create(debugScriptFile.FilePath, 7) }); Task _ = ExecuteDebugFileAsync(); - AssertDebuggerStopped(debugScriptFile.FilePath, 5); - await Task.Run(debugService.Continue); - AssertDebuggerStopped(debugScriptFile.FilePath, 7); + await AssertDebuggerStopped(debugScriptFile.FilePath, 5); + debugService.Continue(); + await AssertDebuggerStopped(debugScriptFile.FilePath, 7); } [Fact] @@ -364,13 +384,13 @@ public async Task DebuggerStopsOnConditionalBreakpoints() const int breakpointValue2 = 20; await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 7, null, $"$i -eq {breakpointValue1} -or $i -eq {breakpointValue2}"), }); Task _ = ExecuteDebugFileAsync(); - AssertDebuggerStopped(debugScriptFile.FilePath, 7); + await AssertDebuggerStopped(debugScriptFile.FilePath, 7); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -382,8 +402,8 @@ await debugService.SetLineBreakpointsAsync( // The conditional breakpoint should not fire again, until the value of // i reaches breakpointValue2. - await Task.Run(debugService.Continue); - AssertDebuggerStopped(debugScriptFile.FilePath, 7); + debugService.Continue(); + await AssertDebuggerStopped(debugScriptFile.FilePath, 7); variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -400,13 +420,13 @@ public async Task DebuggerStopsOnHitConditionBreakpoint() const int hitCount = 5; await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 6, null, null, $"{hitCount}"), }); Task _ = ExecuteDebugFileAsync(); - AssertDebuggerStopped(debugScriptFile.FilePath, 6); + await AssertDebuggerStopped(debugScriptFile.FilePath, 6); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -423,11 +443,11 @@ public async Task DebuggerStopsOnConditionalAndHitConditionBreakpoint() const int hitCount = 5; await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 6, null, "$i % 2 -eq 0", $"{hitCount}") }); Task _ = ExecuteDebugFileAsync(); - AssertDebuggerStopped(debugScriptFile.FilePath, 6); + await AssertDebuggerStopped(debugScriptFile.FilePath, 6); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -444,7 +464,7 @@ public async Task DebuggerProvidesMessageForInvalidConditionalBreakpoint() { IReadOnlyList breakpoints = await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { // TODO: Add this breakpoint back when it stops moving around?! The ordering // of these two breakpoints seems to do with which framework executes the @@ -472,7 +492,7 @@ public async Task DebuggerFindsParsableButInvalidSimpleBreakpointConditions() { IReadOnlyList breakpoints = await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 5, column: null, condition: "$i == 100"), BreakpointDetails.Create(debugScriptFile.FilePath, 7, column: null, condition: "$i > 100") @@ -495,18 +515,16 @@ public async Task DebuggerBreaksWhenRequested() IReadOnlyList confirmedBreakpoints = await GetConfirmedBreakpoints(debugScriptFile); Assert.Empty(confirmedBreakpoints); Task _ = ExecuteDebugFileAsync(); - // NOTE: This must be run on a separate thread so the async event handlers can fire. - await Task.Run(debugService.Break); - AssertDebuggerPaused(); + debugService.Break(); + await AssertDebuggerPaused(); } [Fact] public async Task DebuggerRunsCommandsWhileStopped() { Task _ = ExecuteDebugFileAsync(); - // NOTE: This must be run on a separate thread so the async event handlers can fire. - await Task.Run(debugService.Break); - AssertDebuggerPaused(); + debugService.Break(); + await AssertDebuggerPaused(); // Try running a command from outside the pipeline thread Task> executeTask = psesHost.ExecutePSCommandAsync( @@ -526,16 +544,17 @@ await debugService.SetCommandBreakpointsAsync( ScriptFile testScript = GetDebugScript("PSDebugContextTest.ps1"); Task _ = ExecuteScriptFileAsync(testScript.FilePath); - AssertDebuggerStopped(testScript.FilePath, 11); + await AssertDebuggerStopped(testScript.FilePath, 11); VariableDetails prompt = await debugService.EvaluateExpressionAsync("prompt", false, CancellationToken.None); Assert.Equal("True > ", prompt.ValueString); } - [SkippableFact] - public async Task DebuggerBreaksInUntitledScript() + [Theory] + [InlineData("Command")] + [InlineData("Line")] + public async Task DebuggerBreaksInUntitledScript(string breakpointType) { - Skip.IfNot(VersionUtils.PSEdition == "Core", "Untitled script breakpoints only supported in PowerShell Core"); const string contents = "Write-Output $($MyInvocation.Line)"; const string scriptPath = "untitled:Untitled-1"; Assert.True(ScriptFile.IsUntitledPath(scriptPath)); @@ -544,14 +563,23 @@ public async Task DebuggerBreaksInUntitledScript() Assert.Equal(contents, scriptFile.Contents); Assert.True(workspace.TryGetFile(scriptPath, out ScriptFile _)); - await debugService.SetCommandBreakpointsAsync( - new[] { CommandBreakpointDetails.Create("Write-Output") }); + if (breakpointType == "Command") + { + await debugService.SetCommandBreakpointsAsync( + new[] { CommandBreakpointDetails.Create("Write-Output") }); + } + else + { + await debugService.SetLineBreakpointsAsync( + scriptFile.FilePath, + new[] { BreakpointDetails.Create(scriptPath, 1) }); + } ConfigurationDoneHandler configurationDoneHandler = new( - NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null, psesHost); + NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null); Task _ = configurationDoneHandler.LaunchScriptAsync(scriptPath); - AssertDebuggerStopped(scriptPath, 1); + await AssertDebuggerStopped(scriptPath, 1); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.CommandVariablesName); VariableDetailsBase myInvocation = Array.Find(variables, v => v.Name == "$MyInvocation"); @@ -570,7 +598,7 @@ await debugService.SetCommandBreakpointsAsync( public async Task RecordsF5CommandInPowerShellHistory() { ConfigurationDoneHandler configurationDoneHandler = new( - NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null, psesHost); + NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null); await configurationDoneHandler.LaunchScriptAsync(debugScriptFile.FilePath); IReadOnlyList historyResult = await psesHost.ExecutePSCommandAsync( @@ -610,7 +638,7 @@ public async Task RecordsF8CommandInHistory() public async Task OddFilePathsLaunchCorrectly() { ConfigurationDoneHandler configurationDoneHandler = new( - NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null, psesHost); + NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null); await configurationDoneHandler.LaunchScriptAsync(oddPathScriptFile.FilePath); IReadOnlyList historyResult = await psesHost.ExecutePSCommandAsync( @@ -621,15 +649,29 @@ public async Task OddFilePathsLaunchCorrectly() Assert.Equal(". " + PSCommandHelpers.EscapeScriptFilePath(oddPathScriptFile.FilePath), Assert.Single(historyResult)); } + [Fact] + public async Task PSProviderPathsLaunchCorrectly() + { + ConfigurationDoneHandler configurationDoneHandler = new( + NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null); + await configurationDoneHandler.LaunchScriptAsync(psProviderPathScriptFile.FilePath); + + IReadOnlyList historyResult = await psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("(Get-History).CommandLine"), + CancellationToken.None); + + Assert.Equal(". $args[0]", Assert.Single(historyResult)); + } + [Fact] public async Task DebuggerVariableStringDisplaysCorrectly() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 8) }); Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -643,11 +685,11 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerGetsVariables() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 21) }); Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -693,11 +735,11 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerSetsVariablesNoConversion() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 14) }); Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); VariableScope[] scopes = debugService.GetVariableScopes(0); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -723,8 +765,8 @@ await debugService.SetLineBreakpointsAsync( // The above just tests that the debug service returns the correct new value string. // Let's step the debugger and make sure the values got set to the new values. - await Task.Run(debugService.StepOver); - AssertDebuggerStopped(variableScriptFile.FilePath); + debugService.StepOver(); + await AssertDebuggerStopped(variableScriptFile.FilePath); // Test set of a local string variable (not strongly typed) variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -746,12 +788,12 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerSetsVariablesWithConversion() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 14) }); // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); VariableScope[] scopes = debugService.GetVariableScopes(0); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -779,8 +821,8 @@ await debugService.SetLineBreakpointsAsync( // The above just tests that the debug service returns the correct new value string. // Let's step the debugger and make sure the values got set to the new values. - await Task.Run(debugService.StepOver); - AssertDebuggerStopped(variableScriptFile.FilePath); + debugService.StepOver(); + await AssertDebuggerStopped(variableScriptFile.FilePath); // Test set of a local string variable (not strongly typed but force conversion) variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -802,12 +844,12 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerVariableEnumDisplaysCorrectly() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 15) }); // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); VariableDetailsBase[] variables = await debugService.GetVariables(stackFrames[0].AutoVariables.Id, CancellationToken.None); @@ -822,12 +864,12 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerVariableHashtableDisplaysCorrectly() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 11) }); // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); VariableDetailsBase[] variables = await debugService.GetVariables(stackFrames[0].AutoVariables.Id, CancellationToken.None); @@ -855,12 +897,12 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerVariableNullStringDisplaysCorrectly() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 16) }); // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); VariableDetailsBase[] variables = await debugService.GetVariables(stackFrames[0].AutoVariables.Id, CancellationToken.None); @@ -875,12 +917,12 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerVariablePSObjectDisplaysCorrectly() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 17) }); // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); VariableDetailsBase[] variables = await debugService.GetVariables(stackFrames[0].AutoVariables.Id, CancellationToken.None); @@ -907,7 +949,7 @@ public async Task DebuggerEnumerableShowsRawView() // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(commandBreakpointDetails: breakpoint); + await AssertDebuggerStopped(commandBreakpointDetails: breakpoint); VariableDetailsBase simpleArrayVar = Array.Find( await GetVariables(VariableContainerDetails.ScriptScopeName), @@ -964,7 +1006,7 @@ public async Task DebuggerDictionaryShowsRawView() // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(commandBreakpointDetails: breakpoint); + await AssertDebuggerStopped(commandBreakpointDetails: breakpoint); VariableDetailsBase simpleDictionaryVar = Array.Find( await GetVariables(VariableContainerDetails.ScriptScopeName), @@ -1027,7 +1069,7 @@ public async Task DebuggerDerivedDictionaryPropertyInRawView() // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(commandBreakpointDetails: breakpoint); + await AssertDebuggerStopped(commandBreakpointDetails: breakpoint); VariableDetailsBase sortedDictionaryVar = Array.Find( await GetVariables(VariableContainerDetails.ScriptScopeName), @@ -1071,12 +1113,12 @@ await GetVariables(VariableContainerDetails.ScriptScopeName), public async Task DebuggerVariablePSCustomObjectDisplaysCorrectly() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 18) }); // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); VariableDetailsBase[] variables = await debugService.GetVariables(stackFrames[0].AutoVariables.Id, CancellationToken.None); @@ -1100,12 +1142,12 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerVariableProcessObjectDisplaysCorrectly() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 19) }); // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); VariableDetailsBase[] variables = await debugService.GetVariables(stackFrames[0].AutoVariables.Id, CancellationToken.None); @@ -1134,7 +1176,7 @@ await debugService.SetCommandBreakpointsAsync( ScriptFile testScript = GetDebugScript("GetChildItemTest.ps1"); Task _ = ExecuteScriptFileAsync(testScript.FilePath); - AssertDebuggerStopped(testScript.FilePath, 2); + await AssertDebuggerStopped(testScript.FilePath, 2); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); VariableDetailsBase var = Array.Find(variables, v => v.Name == "$file"); @@ -1154,7 +1196,7 @@ public async Task DebuggerToStringShouldMarshallToPipeline() // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(commandBreakpointDetails: breakpoint); + await AssertDebuggerStopped(commandBreakpointDetails: breakpoint); VariableDetailsBase[] vars = await GetVariables(VariableContainerDetails.ScriptScopeName); VariableDetailsBase customToStrings = Array.Find(vars, i => i.Name is "$CustomToStrings"); diff --git a/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs b/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs index 11c27c4cf..7a1239ea8 100644 --- a/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs +++ b/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs @@ -665,8 +665,9 @@ public void DocumentUriReturnsCorrectStringForAbsolutePath() [InlineData(@"C:\Users\me\Documents\test.ps1", false)] [InlineData("/Users/me/Documents/test.ps1", false)] [InlineData("vscode-notebook-cell:/Users/me/Documents/test.ps1#0001", true)] - [InlineData("https://microsoft.com", true)] + [InlineData("https://microsoft.com", false)] [InlineData("Untitled:Untitled-1", true)] + [InlineData("pspath://filesystem/C%3A/Users/me/Documents/test.ps1", false)] [InlineData(@"'a log statement' > 'c:\Users\me\Documents\test.txt' ", false)] public void IsUntitledFileIsCorrect(string path, bool expected) => Assert.Equal(expected, ScriptFile.IsUntitledPath(path)); diff --git a/test/PowerShellEditorServices.Test/packages.lock.json b/test/PowerShellEditorServices.Test/packages.lock.json index 23fd75c30..5136423ff 100644 --- a/test/PowerShellEditorServices.Test/packages.lock.json +++ b/test/PowerShellEditorServices.Test/packages.lock.json @@ -11,6 +11,15 @@ "Microsoft.CodeCoverage": "17.9.0" } }, + "Microsoft.NETFramework.ReferenceAssemblies": { + "type": "Direct", + "requested": "[1.0.3, )", + "resolved": "1.0.3", + "contentHash": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==", + "dependencies": { + "Microsoft.NETFramework.ReferenceAssemblies.net462": "1.0.3" + } + }, "Microsoft.PowerShell.5.ReferenceAssemblies": { "type": "Direct", "requested": "[1.1.0, )", @@ -174,6 +183,11 @@ "System.Runtime.CompilerServices.Unsafe": "6.0.0" } }, + "Microsoft.NETFramework.ReferenceAssemblies.net462": { + "type": "Transitive", + "resolved": "1.0.3", + "contentHash": "IzAV30z22ESCeQfxP29oVf4qEo8fBGXLXSU6oacv/9Iqe6PzgHDKCaWfwMBak7bSJQM0F5boXWoZS+kChztRIQ==" + }, "Microsoft.TestPlatform.ObjectModel": { "type": "Transitive", "resolved": "17.8.0", @@ -536,7 +550,7 @@ "Microsoft.PowerShell.EditorServices.Test.Shared": { "type": "Project", "dependencies": { - "Microsoft.PowerShell.EditorServices": "[0.0.1, )" + "Microsoft.PowerShell.EditorServices": "[3.18.1, )" } } }, @@ -1671,7 +1685,7 @@ "Microsoft.PowerShell.EditorServices.Test.Shared": { "type": "Project", "dependencies": { - "Microsoft.PowerShell.EditorServices": "[0.0.1, )" + "Microsoft.PowerShell.EditorServices": "[3.18.1, )" } } }, @@ -2794,7 +2808,7 @@ "Microsoft.PowerShell.EditorServices.Test.Shared": { "type": "Project", "dependencies": { - "Microsoft.PowerShell.EditorServices": "[0.0.1, )" + "Microsoft.PowerShell.EditorServices": "[3.18.1, )" } } }, @@ -3916,7 +3930,7 @@ "Microsoft.PowerShell.EditorServices.Test.Shared": { "type": "Project", "dependencies": { - "Microsoft.PowerShell.EditorServices": "[0.0.1, )" + "Microsoft.PowerShell.EditorServices": "[3.18.1, )" } } }