Skip to content

Commit 387d843

Browse files
Resolve evaluate/watch variables from the most-local scope
When a watch or `evaluate` request resolved a naked variable reference, `GetVariableFromExpression` searched the flat `variables` list, which is populated broadest-to-narrowest (global, then script, then local, then stack frames). `FirstOrDefault` therefore returned the global/parent-scope copy of a variable whenever the same name also existed in a more local scope, even though the Variables explorer and PowerShell itself show the local value. We now search the local, script, and global scope containers in that order first, falling back to the full flat list so names that only live in a frame still resolve. This matches PowerShell's own variable resolution semantics. Added a regression test (and `VariableScopeTest.ps1`) that shadows a variable across scopes and asserts the local value wins. Fixes #1882. Drafted by Copilot (Claude Opus 4.8). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6ad4f46 commit 387d843

3 files changed

Lines changed: 36 additions & 1 deletion

File tree

src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,16 @@ public async Task<VariableDetailsBase> GetVariableFromExpression(string variable
308308
await debugInfoHandle.WaitAsync(cancellationToken).ConfigureAwait(false);
309309
try
310310
{
311-
variableList = variables;
311+
// Search the scope containers in order of narrowest to broadest scope (local,
312+
// then script, then global) so that a variable defined in a more local scope
313+
// correctly shadows one of the same name in a parent scope, matching
314+
// PowerShell's own variable resolution. The flattened list of every variable is
315+
// appended as a fallback so that names not present in those scopes (such as
316+
// frame-specific variables) still resolve. See issue #1882.
317+
variableList = new[] { localScopeVariables, scriptScopeVariables, globalScopeVariables }
318+
.Where(container => container is not null)
319+
.SelectMany(container => container.Children.Values)
320+
.Concat(variables);
312321
}
313322
finally
314323
{
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
$scopeTestVariable = "from parent scope"
2+
& {
3+
$scopeTestVariable = "from local scope"
4+
Write-Output $scopeTestVariable
5+
}

test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,27 @@ await debugService.SetLineBreakpointsAsync(
644644
Assert.False(var.IsExpandable);
645645
}
646646

647+
// Regression test for #1882: when a variable of the same name exists in both a parent
648+
// scope and the current (more local) scope, evaluating it via a watch/evaluate request
649+
// must return the most-local value, matching what's shown in the Variables explorer and
650+
// PowerShell's own variable resolution.
651+
[Fact]
652+
public async Task DebuggerResolvesVariableFromMostLocalScope()
653+
{
654+
ScriptFile scopeScriptFile = GetDebugScript("VariableScopeTest.ps1");
655+
await debugService.SetLineBreakpointsAsync(
656+
scopeScriptFile.FilePath,
657+
new[] { BreakpointDetails.Create(scopeScriptFile.FilePath, 4) });
658+
659+
Task _ = ExecuteScriptFileAsync(scopeScriptFile.FilePath);
660+
await AssertDebuggerStopped(scopeScriptFile.FilePath, 4);
661+
662+
VariableDetailsBase resolved = await debugService.GetVariableFromExpression(
663+
"$scopeTestVariable", CancellationToken.None);
664+
Assert.NotNull(resolved);
665+
Assert.Equal("\"from local scope\"", resolved.ValueString);
666+
}
667+
647668
[Fact]
648669
public async Task DebuggerGetsVariables()
649670
{

0 commit comments

Comments
 (0)