Skip to content

Commit e92df78

Browse files
committed
Add name validation and related tests, also rearrange/rename some test fixtures
1 parent ec2ab16 commit e92df78

File tree

12 files changed

+135
-117
lines changed

12 files changed

+135
-117
lines changed

src/PowerShellEditorServices/Services/TextDocument/RenameService.cs

Lines changed: 85 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ ILanguageServerConfiguration config
6969

7070
// Since LSP 3.16 we can simply basically return a DefaultBehavior true or null to signal to the client that the position is valid for rename and it should use its default selection criteria (which is probably the language semantic highlighting or grammar). For the current scope of the rename provider, this should be fine, but we have the option to supply the specific range in the future for special cases.
7171
RangeOrPlaceholderRange renameSupported = new(new RenameDefaultBehavior() { DefaultBehavior = true });
72+
7273
return (renameResponse?.Changes?[request.TextDocument.Uri].ToArray().Length > 0)
7374
? renameSupported
7475
: null;
@@ -95,9 +96,8 @@ or CommandAst
9596
=> RenameFunction(tokenToRename, scriptFile.ScriptAst, request),
9697

9798
VariableExpressionAst
98-
or ParameterAst
9999
or CommandParameterAst
100-
or AssignmentStatementAst
100+
or StringConstantExpressionAst
101101
=> RenameVariable(tokenToRename, scriptFile.ScriptAst, request, options.createParameterAlias),
102102

103103
_ => throw new InvalidOperationException("This should not happen as PrepareRename should have already checked for viability. File an issue if you see this.")
@@ -114,24 +114,14 @@ or AssignmentStatementAst
114114

115115
// TODO: We can probably merge these two methods with Generic Type constraints since they are factored into overloading
116116

117-
internal static TextEdit[] RenameFunction(Ast target, Ast scriptAst, RenameParams renameParams)
117+
private static TextEdit[] RenameFunction(Ast target, Ast scriptAst, RenameParams renameParams)
118118
{
119-
if (target is not (FunctionDefinitionAst or CommandAst))
120-
{
121-
throw new HandlerErrorException($"Asked to rename a function but the target is not a viable function type: {target.GetType()}. This is a bug, file an issue if you see this.");
122-
}
123-
124119
RenameFunctionVisitor visitor = new(target, renameParams.NewName);
125120
return visitor.VisitAndGetEdits(scriptAst);
126121
}
127122

128-
internal static TextEdit[] RenameVariable(Ast symbol, Ast scriptAst, RenameParams requestParams, bool createParameterAlias)
123+
private static TextEdit[] RenameVariable(Ast symbol, Ast scriptAst, RenameParams requestParams, bool createParameterAlias)
129124
{
130-
if (symbol is not (VariableExpressionAst or ParameterAst or CommandParameterAst or StringConstantExpressionAst))
131-
{
132-
throw new HandlerErrorException($"Asked to rename a variable but the target is not a viable variable type: {symbol.GetType()}. This is a bug, file an issue if you see this.");
133-
}
134-
135125
NewRenameVariableVisitor visitor = new(
136126
symbol, requestParams.NewName
137127
);
@@ -144,22 +134,33 @@ internal static TextEdit[] RenameVariable(Ast symbol, Ast scriptAst, RenameParam
144134
/// <returns>Ast of the token or null if no renamable symbol was found</returns>
145135
internal static Ast? FindRenamableSymbol(ScriptFile scriptFile, ScriptPositionAdapter position)
146136
{
147-
Ast? ast = scriptFile.ScriptAst.FindClosest(position,
148-
[
137+
List<Type> renameableAstTypes = [
149138
// Functions
150139
typeof(FunctionDefinitionAst),
151140
typeof(CommandAst),
152141

153142
// Variables
154143
typeof(VariableExpressionAst),
155-
typeof(CommandParameterAst)
156-
// FIXME: Splat parameter in hashtable
157-
]);
144+
typeof(CommandParameterAst),
145+
typeof(StringConstantExpressionAst)
146+
];
147+
Ast? ast = scriptFile.ScriptAst.FindClosest(position, renameableAstTypes.ToArray());
148+
149+
if (ast is StringConstantExpressionAst stringAst)
150+
{
151+
// Only splat string parameters should be considered for evaluation.
152+
if (stringAst.FindSplatParameterReference() is not null) { return stringAst; }
153+
// Otherwise redo the search without stringConstant, so the most specific is a command, etc.
154+
renameableAstTypes.Remove(typeof(StringConstantExpressionAst));
155+
ast = scriptFile.ScriptAst.FindClosest(position, renameableAstTypes.ToArray());
156+
}
157+
158+
// Performance optimizations
158159

159160
// Only the function name is valid for rename, not other components
160161
if (ast is FunctionDefinitionAst funcDefAst)
161162
{
162-
if (!GetFunctionNameExtent(funcDefAst).Contains(position))
163+
if (!funcDefAst.GetFunctionNameExtent().Contains(position))
163164
{
164165
return null;
165166
}
@@ -179,23 +180,9 @@ internal static TextEdit[] RenameVariable(Ast symbol, Ast scriptAst, RenameParam
179180
}
180181
}
181182

182-
return ast;
183-
}
184183

185184

186-
/// <summary>
187-
/// Return an extent that only contains the position of the name of the function, for Client highlighting purposes.
188-
/// </summary>
189-
internal static ScriptExtentAdapter GetFunctionNameExtent(FunctionDefinitionAst ast)
190-
{
191-
string name = ast.Name;
192-
// FIXME: Gather dynamically from the AST and include backticks and whatnot that might be present
193-
int funcLength = "function ".Length;
194-
ScriptExtentAdapter funcExtent = new(ast.Extent);
195-
funcExtent.Start = funcExtent.Start.Delta(0, funcLength);
196-
funcExtent.End = funcExtent.Start.Delta(0, name.Length);
197-
198-
return funcExtent;
185+
return ast;
199186
}
200187

201188
/// <summary>
@@ -322,7 +309,7 @@ internal AstVisitAction Visit(Ast ast)
322309
{
323310
FunctionDefinitionAst f => f,
324311
CommandAst command => CurrentDocument.FindFunctionDefinition(command)
325-
?? throw new HandlerErrorException("The command to rename does not have a function definition. Renaming a function is only supported when the function is defined within the same scope"),
312+
?? throw new HandlerErrorException("The command to rename does not have a function definition. Renaming a function is only supported when the function is defined within an accessible scope"),
326313
_ => throw new Exception($"Unsupported AST type {target.GetType()} encountered")
327314
};
328315
};
@@ -373,7 +360,12 @@ private TextEdit GetRenameFunctionEdit(Ast candidate)
373360
throw new InvalidOperationException("GetRenameFunctionEdit was called on an Ast that was not the target. This is a bug and you should file an issue.");
374361
}
375362

376-
ScriptExtentAdapter functionNameExtent = RenameService.GetFunctionNameExtent(funcDef);
363+
if (!IsValidFunctionName(newName))
364+
{
365+
throw new HandlerErrorException($"{newName} is not a valid function name.");
366+
}
367+
368+
ScriptExtentAdapter functionNameExtent = funcDef.GetFunctionNameExtent();
377369

378370
return new TextEdit()
379371
{
@@ -399,6 +391,19 @@ private TextEdit GetRenameFunctionEdit(Ast candidate)
399391
Range = new ScriptExtentAdapter(funcName.Extent)
400392
};
401393
}
394+
395+
internal static bool IsValidFunctionName(string name)
396+
{
397+
// Allows us to supply function:varname or varname and get a proper result
398+
string candidate = "function " + name.TrimStart('$').TrimStart('-') + " {}";
399+
Parser.ParseInput(candidate, out Token[] tokens, out _);
400+
return tokens.Length == 5
401+
&& tokens[0].Kind == TokenKind.Function
402+
&& tokens[1].Kind == TokenKind.Identifier
403+
&& tokens[2].Kind == TokenKind.LCurly
404+
&& tokens[3].Kind == TokenKind.RCurly
405+
&& tokens[4].Kind == TokenKind.EndOfInput;
406+
}
402407
}
403408

404409
internal class NewRenameVariableVisitor(Ast target, string newName, bool skipVerify = false) : RenameVisitorBase
@@ -430,7 +435,7 @@ internal AstVisitAction Visit(Ast ast)
430435
VariableDefinition = target.GetTopVariableAssignment();
431436
if (VariableDefinition is null)
432437
{
433-
throw new HandlerErrorException("The element to rename does not have a definition. Renaming an element is only supported when the element is defined within the same scope");
438+
throw new HandlerErrorException("The variable element to rename does not have a definition. Renaming an element is only supported when the variable element is defined within an accessible scope");
434439
}
435440
}
436441
else if (CurrentDocument != ast.GetHighestParent())
@@ -464,24 +469,51 @@ private TextEdit GetRenameVariableEdit(Ast ast)
464469
{
465470
return ast switch
466471
{
467-
VariableExpressionAst var => new TextEdit
468-
{
469-
NewText = '$' + NewName,
470-
Range = new ScriptExtentAdapter(var.Extent)
471-
},
472-
CommandParameterAst param => new TextEdit
473-
{
474-
NewText = '-' + NewName,
475-
Range = new ScriptExtentAdapter(param.Extent)
476-
},
477-
StringConstantExpressionAst stringAst => new TextEdit
478-
{
479-
NewText = NewName,
480-
Range = new ScriptExtentAdapter(stringAst.Extent)
481-
},
472+
VariableExpressionAst var => !IsValidVariableName(NewName)
473+
? throw new HandlerErrorException($"${NewName} is not a valid variable name.")
474+
: new TextEdit
475+
{
476+
NewText = '$' + NewName,
477+
Range = new ScriptExtentAdapter(var.Extent)
478+
},
479+
StringConstantExpressionAst stringAst => !IsValidVariableName(NewName)
480+
? throw new Exception($"{NewName} is not a valid variable name.")
481+
: new TextEdit
482+
{
483+
NewText = NewName,
484+
Range = new ScriptExtentAdapter(stringAst.Extent)
485+
},
486+
CommandParameterAst param => !IsValidCommandParameterName(NewName)
487+
? throw new Exception($"-{NewName} is not a valid command parameter name.")
488+
: new TextEdit
489+
{
490+
NewText = '-' + NewName,
491+
Range = new ScriptExtentAdapter(param.Extent)
492+
},
482493
_ => throw new InvalidOperationException($"GetRenameVariableEdit was called on an Ast that was not the target. This is a bug and you should file an issue.")
483494
};
484495
}
496+
497+
internal static bool IsValidVariableName(string name)
498+
{
499+
// Allows us to supply $varname or varname and get a proper result
500+
string candidate = '$' + name.TrimStart('$').TrimStart('-');
501+
Parser.ParseInput(candidate, out Token[] tokens, out _);
502+
return tokens.Length is 2
503+
&& tokens[0].Kind == TokenKind.Variable
504+
&& tokens[1].Kind == TokenKind.EndOfInput;
505+
}
506+
507+
internal static bool IsValidCommandParameterName(string name)
508+
{
509+
// Allows us to supply -varname or varname and get a proper result
510+
string candidate = "Command -" + name.TrimStart('$').TrimStart('-');
511+
Parser.ParseInput(candidate, out Token[] tokens, out _);
512+
return tokens.Length == 3
513+
&& tokens[0].Kind == TokenKind.Command
514+
&& tokens[1].Kind == TokenKind.Parameter
515+
&& tokens[2].Kind == TokenKind.EndOfInput;
516+
}
485517
}
486518

487519
/// <summary>

src/PowerShellEditorServices/Utility/AstExtensions.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,4 +523,19 @@ public static bool HasParent(this Ast ast, Ast parent)
523523
return false;
524524
}
525525

526+
527+
/// <summary>
528+
/// Return an extent that only contains the position of the name of the function, for Client highlighting purposes.
529+
/// </summary>
530+
internal static ScriptExtentAdapter GetFunctionNameExtent(this FunctionDefinitionAst ast)
531+
{
532+
string name = ast.Name;
533+
// FIXME: Gather dynamically from the AST and include backticks and whatnot that might be present
534+
int funcLength = "function ".Length;
535+
ScriptExtentAdapter funcExtent = new(ast.Extent);
536+
funcExtent.Start = funcExtent.Start.Delta(0, funcLength);
537+
funcExtent.End = funcExtent.Start.Delta(0, name.Length);
538+
539+
return funcExtent;
540+
}
526541
}

test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/RefactorFunctionTestCases.cs renamed to test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/_RefactorFunctionTestCases.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ public class RefactorFunctionTestCases
1010
/// </summary>
1111
public static RenameTestTarget[] TestCases =
1212
[
13+
new("FunctionSimple.ps1", Line: 1, Column: 11 ),
14+
new("FunctionSimple.ps1", Line: 1, Column: 1, NoResult: true ),
15+
new("FunctionSimple.ps1", Line: 2, Column: 4, NoResult: true ),
16+
new("FunctionSimple.ps1", Line: 1, Column: 11, NewName: "Bad Name", ShouldThrow: true ),
1317
new("FunctionCallWIthinStringExpression.ps1", Line: 1, Column: 10 ),
1418
new("FunctionCmdlet.ps1", Line: 1, Column: 10 ),
1519
new("FunctionForeach.ps1", Line: 11, Column: 5 ),
@@ -21,7 +25,6 @@ public class RefactorFunctionTestCases
2125
new("FunctionOuterHasNestedFunction.ps1", Line: 1, Column: 10 ),
2226
new("FunctionSameName.ps1", Line: 3, Column: 14 ),
2327
new("FunctionScriptblock.ps1", Line: 5, Column: 5 ),
24-
new("FunctionsSingle.ps1", Line: 1, Column: 11 ),
2528
new("FunctionWithInnerFunction.ps1", Line: 5, Column: 5 ),
2629
new("FunctionWithInternalCalls.ps1", Line: 3, Column: 6 ),
2730
];

test/PowerShellEditorServices.Test.Shared/Refactoring/RenameTestTarget.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,21 +28,24 @@ public class RenameTestTarget
2828
public string NewName = "Renamed";
2929

3030
public bool ShouldFail;
31+
public bool ShouldThrow;
3132

3233
/// <param name="FileName">The test case file name e.g. testScript.ps1</param>
3334
/// <param name="Line">The line where the cursor should be positioned for the rename</param>
3435
/// <param name="Column">The column/character indent where ther cursor should be positioned for the rename</param>
3536
/// <param name="NewName">What the target symbol represented by the line and column should be renamed to. Defaults to "Renamed" if not specified</param>
36-
/// <param name="ShouldFail">This test case should not succeed and return either null or a handler error</param>
37-
public RenameTestTarget(string FileName, int Line, int Column, string NewName = "Renamed", bool ShouldFail = false)
37+
/// <param name="NoResult">This test case should return null (cannot be renamed)</param>
38+
/// <param name="ShouldThrow">This test case should throw a HandlerErrorException meaning user needs to be alerted in a custom way</param>
39+
public RenameTestTarget(string FileName, int Line, int Column, string NewName = "Renamed", bool NoResult = false, bool ShouldThrow = false)
3840
{
3941
this.FileName = FileName;
4042
this.Line = Line;
4143
this.Column = Column;
4244
this.NewName = NewName;
43-
this.ShouldFail = ShouldFail;
45+
this.ShouldFail = NoResult;
46+
this.ShouldThrow = ShouldThrow;
4447
}
4548
public RenameTestTarget() { }
4649

47-
public override string ToString() => $"{FileName.Substring(0, FileName.Length - 4)} {Line}:{Column} N:{NewName} F:{ShouldFail}";
50+
public override string ToString() => $"{FileName.Substring(0, FileName.Length - 4)} {Line}:{Column} N:{NewName} F:{ShouldFail} T:{ShouldThrow}";
4851
}

test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/RefactorVariableTestCases.cs renamed to test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/_RefactorVariableTestCases.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ public class RefactorVariableTestCases
55
{
66
public static RenameTestTarget[] TestCases =
77
[
8-
new ("SimpleVariableAssignment.ps1", Line: 1, Column: 1, NewName: "$Renamed"),
9-
new ("SimpleVariableAssignment.ps1", Line: 1, Column: 1),
10-
new ("SimpleVariableAssignment.ps1", Line: 2, Column: 1, NewName: "Wrong", ShouldFail: true),
8+
new ("VariableSimpleAssignment.ps1", Line: 1, Column: 1),
9+
new ("VariableSimpleAssignment.ps1", Line: 1, Column: 1, NewName: "$Renamed"),
10+
new ("VariableSimpleAssignment.ps1", Line: 1, Column: 1, NewName: "$Bad Name", ShouldThrow: true),
11+
new ("VariableSimpleAssignment.ps1", Line: 1, Column: 1, NewName: "Bad Name", ShouldThrow: true),
12+
new ("VariableSimpleAssignment.ps1", Line: 1, Column: 6, NoResult: true),
1113
new ("VariableCommandParameter.ps1", Line: 3, Column: 17),
1214
new ("VariableCommandParameter.ps1", Line: 10, Column: 10),
1315
new ("VariableCommandParameterSplatted.ps1", Line: 3, Column: 19 ),
@@ -32,6 +34,5 @@ public class RefactorVariableTestCases
3234
new ("VariableWithinCommandAstScriptBlock.ps1", Line: 3, Column: 75),
3335
new ("VariableWithinForeachObject.ps1", Line: 2, Column: 1),
3436
new ("VariableWithinHastableExpression.ps1", Line: 3, Column: 46),
35-
new ("ParameterUndefinedFunction.ps1", Line: 1, Column: 39, ShouldFail: true),
3637
];
3738
}

test/PowerShellEditorServices.Test/Refactoring/PrepareRenameHandlerTests.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,9 @@ public async Task FindsFunction(RenameTestTarget s)
7272
{
7373
result = await testHandler.Handle(testParams, CancellationToken.None);
7474
}
75-
catch (HandlerErrorException)
75+
catch (HandlerErrorException err)
7676
{
77-
Assert.True(s.ShouldFail);
77+
Assert.True(s.ShouldThrow, $"Unexpected HandlerErrorException: {err.Message}");
7878
return;
7979
}
8080
if (s.ShouldFail)
@@ -100,7 +100,7 @@ public async Task FindsVariable(RenameTestTarget s)
100100
}
101101
catch (HandlerErrorException err)
102102
{
103-
Assert.True(s.ShouldFail, err.Message);
103+
Assert.True(s.ShouldThrow, $"Unexpected HandlerErrorException: {err.Message}");
104104
return;
105105
}
106106
if (s.ShouldFail)
@@ -213,6 +213,7 @@ public void Serialize(IXunitSerializationInfo info)
213213
info.AddValue(nameof(Column), Column);
214214
info.AddValue(nameof(NewName), NewName);
215215
info.AddValue(nameof(ShouldFail), ShouldFail);
216+
info.AddValue(nameof(ShouldThrow), ShouldThrow);
216217
}
217218

218219
public void Deserialize(IXunitSerializationInfo info)
@@ -222,6 +223,7 @@ public void Deserialize(IXunitSerializationInfo info)
222223
Column = info.GetValue<int>(nameof(Column));
223224
NewName = info.GetValue<string>(nameof(NewName));
224225
ShouldFail = info.GetValue<bool>(nameof(ShouldFail));
226+
ShouldThrow = info.GetValue<bool>(nameof(ShouldThrow));
225227
}
226228

227229
public static RenameTestTargetSerializable FromRenameTestTarget(RenameTestTarget t)
@@ -231,6 +233,7 @@ public static RenameTestTargetSerializable FromRenameTestTarget(RenameTestTarget
231233
Column = t.Column,
232234
Line = t.Line,
233235
NewName = t.NewName,
234-
ShouldFail = t.ShouldFail
236+
ShouldFail = t.ShouldFail,
237+
ShouldThrow = t.ShouldThrow
235238
};
236239
}

0 commit comments

Comments
 (0)