From 11aceae4b92d234184ec89436ba92403fbbd37bf Mon Sep 17 00:00:00 2001 From: yotsuda Date: Thu, 27 Nov 2025 20:18:43 +0900 Subject: [PATCH 1/4] Fix Set-PSDebug -Trace to display all lines of multiline commands (#8113) --- .../engine/debugger/debugger.cs | 9 ++- .../engine/parser/Position.cs | 59 +++++++++++++++++++ .../Set-PSDebug.Tests.ps1 | 46 +++++++++++++++ 3 files changed, 112 insertions(+), 2 deletions(-) diff --git a/src/System.Management.Automation/engine/debugger/debugger.cs b/src/System.Management.Automation/engine/debugger/debugger.cs index 985d90ab3ec..a707748deeb 100644 --- a/src/System.Management.Automation/engine/debugger/debugger.cs +++ b/src/System.Management.Automation/engine/debugger/debugger.cs @@ -4161,13 +4161,18 @@ internal void Trace(string messageId, string resourceString, params object[] arg internal void TraceLine(IScriptExtent extent) { - string msg = PositionUtilities.BriefMessage(extent.StartScriptPosition); + string msg = PositionUtilities.BriefMessage(extent); InternalHostUserInterface ui = (InternalHostUserInterface)_context.EngineHostInterface.UI; ActionPreference pref = _context.PSDebugTraceStep ? ActionPreference.Inquire : ActionPreference.Continue; - ui.WriteDebugLine(msg, ref pref); + // Write each line separately so each gets the DEBUG: prefix + string[] lines = msg.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None); + foreach (string line in lines) + { + ui.WriteDebugLine(line, ref pref); + } if (pref == ActionPreference.Continue) _context.PSDebugTraceStep = false; diff --git a/src/System.Management.Automation/engine/parser/Position.cs b/src/System.Management.Automation/engine/parser/Position.cs index 9e1363e4a53..f39d25ec89d 100644 --- a/src/System.Management.Automation/engine/parser/Position.cs +++ b/src/System.Management.Automation/engine/parser/Position.cs @@ -281,6 +281,65 @@ internal static string BriefMessage(IScriptPosition position) return StringUtil.Format(ParserStrings.TraceScriptLineMessage, position.LineNumber, message.ToString()); } + /// + /// Return a message for an extent that may span multiple lines. + /// For single-line extents, the format is: + /// 12+ >>>> $x + $b. + /// For multi-line extents (e.g., line continuation with backtick), the format is: + /// 12+ >>>> Write-Output "foo ` + /// 13+ >>>> bar" + /// + internal static string BriefMessage(IScriptExtent extent) + { + // For single-line extents, delegate to the existing single-position method + if (extent.StartLineNumber == extent.EndLineNumber) + { + return BriefMessage(extent.StartScriptPosition); + } + + // For multi-line extents, include all lines + string[] lines = extent.Text.Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.None); + StringBuilder result = new StringBuilder(); + int lineNumber = extent.StartLineNumber; + + for (int i = 0; i < lines.Length; i++) + { + string line = lines[i]; + StringBuilder message = new StringBuilder(line); + + // Insert the marker at the appropriate position + if (i == 0) + { + // For the first line, insert at the start column + if (extent.StartColumnNumber > message.Length + 1) + { + message.Append(" <<<< "); + } + else + { + message.Insert(extent.StartColumnNumber - 1, " >>>> "); + } + } + else + { + // For continuation lines, insert the marker at the beginning + message.Insert(0, " >>>> "); + } + + string formattedLine = StringUtil.Format(ParserStrings.TraceScriptLineMessage, lineNumber, message.ToString()); + + if (i > 0) + { + result.AppendLine(); + } + + result.Append(formattedLine); + lineNumber++; + } + + return result.ToString(); + } + internal static IScriptExtent NewScriptExtent(IScriptExtent start, IScriptExtent end) { if (start == end) diff --git a/test/powershell/Modules/Microsoft.PowerShell.Core/Set-PSDebug.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Core/Set-PSDebug.Tests.ps1 index 297e494d4d2..0f8af2eb59e 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Core/Set-PSDebug.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Core/Set-PSDebug.Tests.ps1 @@ -26,5 +26,51 @@ Describe "Set-PSDebug" -Tags "CI" { [ClassWithDefaultCtor]::new() } | Should -Not -Throw } + + It "Should trace all lines of a multiline command" { + $tempScript = Join-Path $TestDrive "multiline-trace.ps1" + $scriptContent = "Set-PSDebug -Trace 1`nWrite-Output `"foo ```nbar`"" + Set-Content -Path $tempScript -Value $scriptContent -NoNewline + + # Run in a separate process to capture trace output + $pinfo = [System.Diagnostics.ProcessStartInfo]::new() + $pinfo.FileName = (Get-Process -Id $PID).Path + $pinfo.Arguments = "-NoProfile -File `"$tempScript`"" + $pinfo.RedirectStandardOutput = $true + $pinfo.UseShellExecute = $false + + $process = [System.Diagnostics.Process]::new() + $process.StartInfo = $pinfo + $process.Start() | Should -BeTrue + $output = $process.StandardOutput.ReadToEnd() + $process.WaitForExit() + + # The debug trace for multiline commands should include all lines with DEBUG: prefix + $output | Should -Match 'DEBUG:.*Write-Output' -Because "debug output should contain the command with DEBUG: prefix" + $output | Should -Match 'DEBUG:.*bar"' -Because "debug output should contain the continuation line with DEBUG: prefix" + } + + It "Should trace all lines of a multiline command with -Trace 2" { + $tempScript = Join-Path $TestDrive "multiline-trace2.ps1" + $scriptContent = "Set-PSDebug -Trace 2`nWrite-Output `"foo ```nbar`"" + Set-Content -Path $tempScript -Value $scriptContent -NoNewline + + # Run in a separate process to capture trace output + $pinfo = [System.Diagnostics.ProcessStartInfo]::new() + $pinfo.FileName = (Get-Process -Id $PID).Path + $pinfo.Arguments = "-NoProfile -File `"$tempScript`"" + $pinfo.RedirectStandardOutput = $true + $pinfo.UseShellExecute = $false + + $process = [System.Diagnostics.Process]::new() + $process.StartInfo = $pinfo + $process.Start() | Should -BeTrue + $output = $process.StandardOutput.ReadToEnd() + $process.WaitForExit() + + # The debug trace for multiline commands should include all lines with DEBUG: prefix + $output | Should -Match 'DEBUG:.*Write-Output' -Because "debug output should contain the command with DEBUG: prefix" + $output | Should -Match 'DEBUG:.*bar"' -Because "debug output should contain the continuation line with DEBUG: prefix" + } } } From feecda0f9f546b0ed67814eba7eb9a1653e82e98 Mon Sep 17 00:00:00 2001 From: yotsuda Date: Wed, 14 Jan 2026 10:54:15 +0900 Subject: [PATCH 2/4] Add CR to line split pattern for consistency --- src/System.Management.Automation/engine/debugger/debugger.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/System.Management.Automation/engine/debugger/debugger.cs b/src/System.Management.Automation/engine/debugger/debugger.cs index a707748deeb..b459eee152b 100644 --- a/src/System.Management.Automation/engine/debugger/debugger.cs +++ b/src/System.Management.Automation/engine/debugger/debugger.cs @@ -4168,7 +4168,7 @@ internal void TraceLine(IScriptExtent extent) ActionPreference.Inquire : ActionPreference.Continue; // Write each line separately so each gets the DEBUG: prefix - string[] lines = msg.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None); + string[] lines = msg.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); foreach (string line in lines) { ui.WriteDebugLine(line, ref pref); From 71d46a1b13c4269ab8c3a08dfdaf0c71f8d47b5d Mon Sep 17 00:00:00 2001 From: yotsuda Date: Wed, 14 Jan 2026 21:59:54 +0900 Subject: [PATCH 3/4] Refactor BriefMessage to return string array instead of single string --- .../engine/debugger/debugger.cs | 4 +-- .../engine/parser/Position.cs | 25 +++++++------------ 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/src/System.Management.Automation/engine/debugger/debugger.cs b/src/System.Management.Automation/engine/debugger/debugger.cs index b459eee152b..4a1dc4de69b 100644 --- a/src/System.Management.Automation/engine/debugger/debugger.cs +++ b/src/System.Management.Automation/engine/debugger/debugger.cs @@ -4161,14 +4161,12 @@ internal void Trace(string messageId, string resourceString, params object[] arg internal void TraceLine(IScriptExtent extent) { - string msg = PositionUtilities.BriefMessage(extent); + string[] lines = PositionUtilities.BriefMessage(extent); InternalHostUserInterface ui = (InternalHostUserInterface)_context.EngineHostInterface.UI; ActionPreference pref = _context.PSDebugTraceStep ? ActionPreference.Inquire : ActionPreference.Continue; - // Write each line separately so each gets the DEBUG: prefix - string[] lines = msg.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); foreach (string line in lines) { ui.WriteDebugLine(line, ref pref); diff --git a/src/System.Management.Automation/engine/parser/Position.cs b/src/System.Management.Automation/engine/parser/Position.cs index f39d25ec89d..b85dc93110d 100644 --- a/src/System.Management.Automation/engine/parser/Position.cs +++ b/src/System.Management.Automation/engine/parser/Position.cs @@ -282,24 +282,24 @@ internal static string BriefMessage(IScriptPosition position) } /// - /// Return a message for an extent that may span multiple lines. - /// For single-line extents, the format is: + /// Return messages for an extent that may span multiple lines. + /// For single-line extents, returns an array with one element: /// 12+ >>>> $x + $b. - /// For multi-line extents (e.g., line continuation with backtick), the format is: + /// For multi-line extents (e.g., line continuation with backtick), returns an array with one element per line: /// 12+ >>>> Write-Output "foo ` /// 13+ >>>> bar" /// - internal static string BriefMessage(IScriptExtent extent) + internal static string[] BriefMessage(IScriptExtent extent) { // For single-line extents, delegate to the existing single-position method if (extent.StartLineNumber == extent.EndLineNumber) { - return BriefMessage(extent.StartScriptPosition); + return new[] { BriefMessage(extent.StartScriptPosition) }; } // For multi-line extents, include all lines - string[] lines = extent.Text.Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.None); - StringBuilder result = new StringBuilder(); + string[] lines = extent.Text.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); + string[] result = new string[lines.Length]; int lineNumber = extent.StartLineNumber; for (int i = 0; i < lines.Length; i++) @@ -326,18 +326,11 @@ internal static string BriefMessage(IScriptExtent extent) message.Insert(0, " >>>> "); } - string formattedLine = StringUtil.Format(ParserStrings.TraceScriptLineMessage, lineNumber, message.ToString()); - - if (i > 0) - { - result.AppendLine(); - } - - result.Append(formattedLine); + result[i] = StringUtil.Format(ParserStrings.TraceScriptLineMessage, lineNumber, message.ToString()); lineNumber++; } - return result.ToString(); + return result; } internal static IScriptExtent NewScriptExtent(IScriptExtent start, IScriptExtent end) From 5c0bc30849c2f433cd9aba283d239a8b199ef655 Mon Sep 17 00:00:00 2001 From: yotsuda Date: Thu, 15 Jan 2026 06:54:38 +0900 Subject: [PATCH 4/4] Add timeout to process wait in Set-PSDebug tests --- .../Microsoft.PowerShell.Core/Set-PSDebug.Tests.ps1 | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/powershell/Modules/Microsoft.PowerShell.Core/Set-PSDebug.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Core/Set-PSDebug.Tests.ps1 index 0f8af2eb59e..ec645a8bd85 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Core/Set-PSDebug.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Core/Set-PSDebug.Tests.ps1 @@ -43,7 +43,11 @@ Describe "Set-PSDebug" -Tags "CI" { $process.StartInfo = $pinfo $process.Start() | Should -BeTrue $output = $process.StandardOutput.ReadToEnd() - $process.WaitForExit() + $exited = $process.WaitForExit(5000) + if (-not $exited) { + $process.Kill() + } + $exited | Should -BeTrue -Because "process should exit within timeout" # The debug trace for multiline commands should include all lines with DEBUG: prefix $output | Should -Match 'DEBUG:.*Write-Output' -Because "debug output should contain the command with DEBUG: prefix" @@ -66,7 +70,11 @@ Describe "Set-PSDebug" -Tags "CI" { $process.StartInfo = $pinfo $process.Start() | Should -BeTrue $output = $process.StandardOutput.ReadToEnd() - $process.WaitForExit() + $exited = $process.WaitForExit(5000) + if (-not $exited) { + $process.Kill() + } + $exited | Should -BeTrue -Because "process should exit within timeout" # The debug trace for multiline commands should include all lines with DEBUG: prefix $output | Should -Match 'DEBUG:.*Write-Output' -Because "debug output should contain the command with DEBUG: prefix"