Skip to content

Commit 9b22397

Browse files
authored
Add support for debug protocol setVariableRequest. (#283)
* Add support for debug protocol setVariableRequest. * Cleaned up some comments in the SetVariable method and removed some exceptions that we no longer need to catch now that we aren't using System.Convert.ChangeType().
1 parent 30f246b commit 9b22397

File tree

13 files changed

+465
-10
lines changed

13 files changed

+465
-10
lines changed

src/PowerShellEditorServices.Protocol/DebugAdapter/InitializeRequest.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,5 +52,11 @@ public class InitializeResponseBody
5252
/// supports a (side effect free) evaluate request for data hovers.
5353
/// </summary>
5454
public bool SupportsEvaluateForHovers { get; set; }
55+
56+
/// <summary>
57+
/// Gets or sets a boolean value that determines whether the debug adapter
58+
/// supports allowing the user to set a variable from the Variables debug windows.
59+
/// </summary>
60+
public bool SupportsSetVariable { get; set; }
5561
}
5662
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
//
2+
// Copyright (c) Microsoft. All rights reserved.
3+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
4+
//
5+
6+
using System.Diagnostics;
7+
using Microsoft.PowerShell.EditorServices.Protocol.MessageProtocol;
8+
9+
namespace Microsoft.PowerShell.EditorServices.Protocol.DebugAdapter
10+
{
11+
/// <summary>
12+
/// SetVariable request; value of command field is "setVariable".
13+
/// Request is initiated when user uses the debugger Variables UI to change the value of a variable.
14+
/// </summary>
15+
public class SetVariableRequest
16+
{
17+
public static readonly
18+
RequestType<SetVariableRequestArguments, SetVariableResponseBody> Type =
19+
RequestType<SetVariableRequestArguments, SetVariableResponseBody>.Create("setVariable");
20+
}
21+
22+
[DebuggerDisplay("VariablesReference = {VariablesReference}")]
23+
public class SetVariableRequestArguments
24+
{
25+
public int VariablesReference { get; set; }
26+
27+
public string Name { get; set; }
28+
29+
public string Value { get; set; }
30+
}
31+
32+
public class SetVariableResponseBody
33+
{
34+
public string Value { get; set; }
35+
}
36+
}

src/PowerShellEditorServices.Protocol/PowerShellEditorServices.Protocol.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
<Compile Include="DebugAdapter\ConfigurationDoneRequest.cs" />
5555
<Compile Include="DebugAdapter\ContinueRequest.cs" />
5656
<Compile Include="DebugAdapter\SetFunctionBreakpointsRequest.cs" />
57+
<Compile Include="DebugAdapter\SetVariableRequest.cs" />
5758
<Compile Include="LanguageServer\EditorCommands.cs" />
5859
<Compile Include="LanguageServer\FindModuleRequest.cs" />
5960
<Compile Include="LanguageServer\InstallModuleRequest.cs" />

src/PowerShellEditorServices.Protocol/Server/DebugAdapter.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
44
//
55

6+
using Microsoft.PowerShell.EditorServices.Debugging;
67
using Microsoft.PowerShell.EditorServices.Protocol.DebugAdapter;
78
using Microsoft.PowerShell.EditorServices.Protocol.MessageProtocol;
89
using Microsoft.PowerShell.EditorServices.Protocol.MessageProtocol.Channel;
@@ -67,6 +68,7 @@ protected override void Initialize()
6768
this.SetRequestHandler(StackTraceRequest.Type, this.HandleStackTraceRequest);
6869
this.SetRequestHandler(ScopesRequest.Type, this.HandleScopesRequest);
6970
this.SetRequestHandler(VariablesRequest.Type, this.HandleVariablesRequest);
71+
this.SetRequestHandler(SetVariableRequest.Type, this.HandleSetVariablesRequest);
7072
this.SetRequestHandler(SourceRequest.Type, this.HandleSourceRequest);
7173
this.SetRequestHandler(EvaluateRequest.Type, this.HandleEvaluateRequest);
7274
}
@@ -461,6 +463,42 @@ protected async Task HandleVariablesRequest(
461463
await requestContext.SendResult(variablesResponse);
462464
}
463465

466+
protected async Task HandleSetVariablesRequest(
467+
SetVariableRequestArguments setVariableParams,
468+
RequestContext<SetVariableResponseBody> requestContext)
469+
{
470+
try
471+
{
472+
string updatedValue =
473+
await editorSession.DebugService.SetVariable(
474+
setVariableParams.VariablesReference,
475+
setVariableParams.Name,
476+
setVariableParams.Value);
477+
478+
var setVariableResponse = new SetVariableResponseBody
479+
{
480+
Value = updatedValue
481+
};
482+
483+
await requestContext.SendResult(setVariableResponse);
484+
}
485+
catch (Exception ex) when (ex is ArgumentTransformationMetadataException ||
486+
ex is InvalidPowerShellExpressionException ||
487+
ex is SessionStateUnauthorizedAccessException)
488+
{
489+
// Catch common, innocuous errors caused by the user supplying a value that can't be converted or the variable is not settable.
490+
Logger.Write(LogLevel.Verbose, $"Failed to set variable: {ex.Message}");
491+
await requestContext.SendError(ex.Message);
492+
}
493+
catch (Exception ex)
494+
{
495+
Logger.Write(LogLevel.Error, $"Unexpected error setting variable: {ex.Message}");
496+
string msg =
497+
$"Unexpected error: {ex.GetType().Name} - {ex.Message} Please report this error to the PowerShellEditorServices project on GitHub.";
498+
await requestContext.SendError(msg);
499+
}
500+
}
501+
464502
protected Task HandleSourceRequest(
465503
SourceRequestArguments sourceParams,
466504
RequestContext<SourceResponseBody> requestContext)

src/PowerShellEditorServices.Protocol/Server/DebugAdapterBase.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ await requestContext.SendResult(
5959
new InitializeResponseBody {
6060
SupportsConfigurationDoneRequest = true,
6161
SupportsConditionalBreakpoints = true,
62-
SupportsFunctionBreakpoints = true
62+
SupportsFunctionBreakpoints = true,
63+
SupportsSetVariable = true
6364
});
6465

6566
// Send the Initialized event so that we get breakpoints

src/PowerShellEditorServices/Debugging/DebugService.cs

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
using System.Linq;
99
using System.Management.Automation;
1010
using System.Management.Automation.Language;
11+
using System.Text;
1112
using System.Threading.Tasks;
13+
using Microsoft.PowerShell.EditorServices.Debugging;
1214
using Microsoft.PowerShell.EditorServices.Utility;
1315

1416
namespace Microsoft.PowerShell.EditorServices
@@ -278,7 +280,7 @@ public VariableDetailsBase[] GetVariables(int variableReferenceId)
278280
/// </summary>
279281
/// <param name="variableExpression">The variable expression string to evaluate.</param>
280282
/// <param name="stackFrameId">The ID of the stack frame in which the expression should be evaluated.</param>
281-
/// <returns>A VariableDetails object containing the result.</returns>
283+
/// <returns>A VariableDetailsBase object containing the result.</returns>
282284
public VariableDetailsBase GetVariableFromExpression(string variableExpression, int stackFrameId)
283285
{
284286
// Break up the variable path
@@ -314,6 +316,154 @@ public VariableDetailsBase GetVariableFromExpression(string variableExpression,
314316
return resolvedVariable;
315317
}
316318

319+
/// <summary>
320+
/// Sets the specified variable by container variableReferenceId and variable name to the
321+
/// specified new value. If the variable cannot be set or converted to that value this
322+
/// method will throw InvalidPowerShellExpressionException, ArgumentTransformationMetadataException, or
323+
/// SessionStateUnauthorizedAccessException.
324+
/// </summary>
325+
/// <param name="variableContainerReferenceId">The container (Autos, Local, Script, Global) that holds the variable.</param>
326+
/// <param name="name">The name of the variable prefixed with $.</param>
327+
/// <param name="value">The new string value. This value must not be null. If you want to set the variable to $null
328+
/// pass in the string "$null".</param>
329+
/// <returns>The string representation of the value the variable was set to.</returns>
330+
public async Task<string> SetVariable(int variableContainerReferenceId, string name, string value)
331+
{
332+
Validate.IsNotNull(nameof(name), name);
333+
Validate.IsNotNull(nameof(value), value);
334+
335+
Logger.Write(LogLevel.Verbose, $"SetVariableRequest for '{name}' to value string (pre-quote processing): '{value}'");
336+
337+
// An empty or whitespace only value is not a valid expression for SetVariable.
338+
if (value.Trim().Length == 0)
339+
{
340+
throw new InvalidPowerShellExpressionException("Expected an expression.");
341+
}
342+
343+
// Evaluate the expression to get back a PowerShell object from the expression string.
344+
PSCommand psCommand = new PSCommand();
345+
psCommand.AddScript(value);
346+
var errorMessages = new StringBuilder();
347+
var results =
348+
await this.powerShellContext.ExecuteCommand<object>(
349+
psCommand,
350+
errorMessages,
351+
false,
352+
false);
353+
354+
// Check if PowerShell's evaluation of the expression resulted in an error.
355+
object psobject = results.FirstOrDefault();
356+
if ((psobject == null) && (errorMessages.Length > 0))
357+
{
358+
throw new InvalidPowerShellExpressionException(errorMessages.ToString());
359+
}
360+
361+
// If PowerShellContext.ExecuteCommand returns an ErrorRecord as output, the expression failed evaluation.
362+
// Ideally we would have a separate means from communicating error records apart from normal output.
363+
ErrorRecord errorRecord = psobject as ErrorRecord;
364+
if (errorRecord != null)
365+
{
366+
throw new InvalidPowerShellExpressionException(errorRecord.ToString());
367+
}
368+
369+
// OK, now we have a PS object from the supplied value string (expression) to assign to a variable.
370+
// Get the variable referenced by variableContainerReferenceId and variable name.
371+
VariableContainerDetails variableContainer = (VariableContainerDetails)this.variables[variableContainerReferenceId];
372+
VariableDetailsBase variable = variableContainer.Children[name];
373+
374+
// Determine scope in which the variable lives. This is required later for the call to Get-Variable -Scope.
375+
string scope = null;
376+
if (variableContainerReferenceId == this.scriptScopeVariables.Id)
377+
{
378+
scope = "Script";
379+
}
380+
else if (variableContainerReferenceId == this.globalScopeVariables.Id)
381+
{
382+
scope = "Global";
383+
}
384+
else
385+
{
386+
// Determine which stackframe's local scope the variable is in.
387+
for (int i = 0; i < this.stackFrameDetails.Length; i++)
388+
{
389+
var stackFrame = this.stackFrameDetails[i];
390+
if (stackFrame.LocalVariables.ContainsVariable(variable.Id))
391+
{
392+
scope = i.ToString();
393+
break;
394+
}
395+
}
396+
}
397+
398+
if (scope == null)
399+
{
400+
// Hmm, this would be unexpected. No scope means do not pass GO, do not collect $200.
401+
throw new Exception("Could not find the scope for this variable.");
402+
}
403+
404+
// Now that we have the scope, get the associated PSVariable object for the variable to be set.
405+
psCommand.Commands.Clear();
406+
psCommand = new PSCommand();
407+
psCommand.AddCommand(@"Microsoft.PowerShell.Utility\Get-Variable");
408+
psCommand.AddParameter("Name", name.TrimStart('$'));
409+
psCommand.AddParameter("Scope", scope);
410+
411+
IEnumerable<PSVariable> result = await this.powerShellContext.ExecuteCommand<PSVariable>(psCommand, sendErrorToHost: false);
412+
PSVariable psVariable = result.FirstOrDefault();
413+
if (psVariable == null)
414+
{
415+
throw new Exception($"Failed to retrieve PSVariable object for '{name}' from scope '{scope}'.");
416+
}
417+
418+
// We have the PSVariable object for the variable the user wants to set and an object to assign to that variable.
419+
// The last step is to determine whether the PSVariable is "strongly typed" which may require a conversion.
420+
// If it is not strongly typed, we simply assign the object directly to the PSVariable potentially changing its type.
421+
// Turns out ArgumentTypeConverterAttribute is not public. So we call the attribute through it's base class -
422+
// ArgumentTransformationAttribute.
423+
var argTypeConverterAttr =
424+
psVariable.Attributes
425+
.OfType<ArgumentTransformationAttribute>()
426+
.FirstOrDefault(a => a.GetType().Name.Equals("ArgumentTypeConverterAttribute"));
427+
428+
if (argTypeConverterAttr != null)
429+
{
430+
// PSVariable is strongly typed. Need to apply the conversion/transform to the new value.
431+
psCommand.Commands.Clear();
432+
psCommand = new PSCommand();
433+
psCommand.AddCommand(@"Microsoft.PowerShell.Utility\Get-Variable");
434+
psCommand.AddParameter("Name", "ExecutionContext");
435+
psCommand.AddParameter("ValueOnly");
436+
437+
errorMessages.Clear();
438+
439+
var getExecContextResults =
440+
await this.powerShellContext.ExecuteCommand<object>(
441+
psCommand,
442+
errorMessages,
443+
sendErrorToHost: false);
444+
445+
EngineIntrinsics executionContext = getExecContextResults.OfType<EngineIntrinsics>().FirstOrDefault();
446+
447+
var msg = $"Setting variable '{name}' using conversion to value: {psobject ?? "<null>"}";
448+
Logger.Write(LogLevel.Verbose, msg);
449+
450+
psVariable.Value = argTypeConverterAttr.Transform(executionContext, psobject);
451+
}
452+
else
453+
{
454+
// PSVariable is *not* strongly typed. In this case, whack the old value with the new value.
455+
var msg = $"Setting variable '{name}' directly to value: {psobject ?? "<null>"} - previous type was {psVariable.Value?.GetType().Name ?? "<unknown>"}";
456+
Logger.Write(LogLevel.Verbose, msg);
457+
psVariable.Value = psobject;
458+
}
459+
460+
// Use the VariableDetails.ValueString functionality to get the string representation for client debugger.
461+
// This makes the returned string consistent with the strings normally displayed for variables in the debugger.
462+
var tempVariable = new VariableDetails(psVariable);
463+
Logger.Write(LogLevel.Verbose, $"Set variable '{name}' to: {tempVariable.ValueString ?? "<null>"}");
464+
return tempVariable.ValueString;
465+
}
466+
317467
/// <summary>
318468
/// Evaluates an expression in the context of the stopped
319469
/// debugger. This method will execute the specified expression
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//
2+
// Copyright (c) Microsoft. All rights reserved.
3+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
4+
//
5+
6+
using System;
7+
8+
namespace Microsoft.PowerShell.EditorServices.Debugging
9+
{
10+
/// <summary>
11+
/// Represents the exception that is thrown when an invalid expression is provided to the DebugService's SetVariable method.
12+
/// </summary>
13+
public class InvalidPowerShellExpressionException : Exception
14+
{
15+
/// <summary>
16+
/// Initializes a new instance of the SetVariableExpressionException class.
17+
/// </summary>
18+
/// <param name="message">Message indicating why the expression is invalid.</param>
19+
public InvalidPowerShellExpressionException(string message)
20+
: base(message)
21+
{
22+
}
23+
}
24+
}

src/PowerShellEditorServices/Debugging/VariableContainerDetails.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,5 +77,23 @@ public override VariableDetailsBase[] GetChildren()
7777
this.children.Values.CopyTo(variablesArray, 0);
7878
return variablesArray;
7979
}
80+
81+
/// <summary>
82+
/// Determines whether this variable container contains the specified variable by its referenceId.
83+
/// </summary>
84+
/// <param name="variableReferenceId">The variableReferenceId to search for.</param>
85+
/// <returns>Returns true if this variable container directly contains the specified variableReferenceId, false otherwise.</returns>
86+
public bool ContainsVariable(int variableReferenceId)
87+
{
88+
foreach (VariableDetailsBase value in this.children.Values)
89+
{
90+
if (value.Id == variableReferenceId)
91+
{
92+
return true;
93+
}
94+
}
95+
96+
return false;
97+
}
8098
}
8199
}

src/PowerShellEditorServices/Debugging/VariableDetails.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,13 @@ private static string GetValueString(object value, bool isExpandable)
145145

146146
if (value == null)
147147
{
148-
valueString = "null";
148+
// Set to identifier recognized by PowerShell to make setVariable from the debug UI more natural.
149+
valueString = "$null";
150+
}
151+
else if (value is bool)
152+
{
153+
// Set to identifier recognized by PowerShell to make setVariable from the debug UI more natural.
154+
valueString = (bool) value ? "$true" : "$false";
149155
}
150156
else if (isExpandable)
151157
{

src/PowerShellEditorServices/PowerShellEditorServices.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
<Compile Include="Debugging\BreakpointDetailsBase.cs" />
7272
<Compile Include="Debugging\DebugService.cs" />
7373
<Compile Include="Debugging\CommandBreakpointDetails.cs" />
74+
<Compile Include="Debugging\InvalidPowerShellExpressionException.cs" />
7475
<Compile Include="Debugging\StackFrameDetails.cs" />
7576
<Compile Include="Debugging\VariableDetails.cs" />
7677
<Compile Include="Debugging\VariableDetailsBase.cs" />

0 commit comments

Comments
 (0)