Skip to content

Commit 5be01f5

Browse files
committed
First Stage work to move to a more stateless AstVisitor for renames
1 parent 4f89c6b commit 5be01f5

File tree

5 files changed

+216
-50
lines changed

5 files changed

+216
-50
lines changed

src/PowerShellEditorServices/Services/PowerShell/Refactoring/IterativeFunctionVistor.cs renamed to src/PowerShellEditorServices/Services/PowerShell/Refactoring/IterativeFunctionRename.cs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,16 @@ public IterativeFunctionRename(string OldName, string NewName, int StartLineNumb
2929
this.StartColumnNumber = StartColumnNumber;
3030
this.ScriptAst = ScriptAst;
3131

32-
Ast Node = Utilities.GetAstAtPositionOfType(StartLineNumber, StartColumnNumber, ScriptAst,
33-
typeof(FunctionDefinitionAst), typeof(CommandAst));
32+
ScriptPosition position = new(null, StartLineNumber, StartColumnNumber, null);
33+
Ast node = ScriptAst.FindAtPosition(position, [typeof(FunctionDefinitionAst), typeof(CommandAst)]);
3434

35-
if (Node != null)
35+
if (node != null)
3636
{
37-
if (Node is FunctionDefinitionAst FuncDef && FuncDef.Name.ToLower() == OldName.ToLower())
37+
if (node is FunctionDefinitionAst funcDef && funcDef.Name.ToLower() == OldName.ToLower())
3838
{
39-
TargetFunctionAst = FuncDef;
39+
TargetFunctionAst = funcDef;
4040
}
41-
if (Node is CommandAst commdef && commdef.GetCommandName().ToLower() == OldName.ToLower())
41+
if (node is CommandAst commdef && commdef.GetCommandName().ToLower() == OldName.ToLower())
4242
{
4343
TargetFunctionAst = Utilities.GetFunctionDefByCommandAst(OldName, StartLineNumber, StartColumnNumber, ScriptAst);
4444
if (TargetFunctionAst == null)
@@ -57,6 +57,7 @@ public class NodeProcessingState
5757
public bool ShouldRename { get; set; }
5858
public IEnumerator<Ast> ChildrenEnumerator { get; set; }
5959
}
60+
6061
public bool DetermineChildShouldRenameState(NodeProcessingState currentState, Ast child)
6162
{
6263
// The Child Has the name we are looking for
@@ -75,7 +76,6 @@ public bool DetermineChildShouldRenameState(NodeProcessingState currentState, As
7576
DuplicateFunctionAst = funcDef;
7677
return false;
7778
}
78-
7979
}
8080
else if (child?.Parent?.Parent is ScriptBlockAst)
8181
{
@@ -105,6 +105,7 @@ public bool DetermineChildShouldRenameState(NodeProcessingState currentState, As
105105
}
106106
return currentState.ShouldRename;
107107
}
108+
108109
public void Visit(Ast root)
109110
{
110111
Stack<NodeProcessingState> processingStack = new();

src/PowerShellEditorServices/Services/TextDocument/Handlers/CompletionHandler.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ internal CompletionItem CreateCompletionItem(
270270
{
271271
Validate.IsNotNull(nameof(result), result);
272272

273-
OmniSharp.Extensions.LanguageServer.Protocol.Models.TextEdit textEdit = new()
273+
TextEdit textEdit = new()
274274
{
275275
NewText = result.CompletionText,
276276
Range = new Range

src/PowerShellEditorServices/Services/TextDocument/Services/RenameService.cs

Lines changed: 180 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -109,28 +109,13 @@ ILanguageServerConfiguration config
109109

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

112-
internal static TextEdit[] RenameFunction(Ast token, Ast scriptAst, RenameParams renameParams)
112+
internal static TextEdit[] RenameFunction(Ast target, Ast scriptAst, RenameParams renameParams)
113113
{
114-
ScriptPositionAdapter position = renameParams.Position;
115-
116-
string tokenName = "";
117-
if (token is FunctionDefinitionAst funcDef)
118-
{
119-
tokenName = funcDef.Name;
120-
}
121-
else if (token.Parent is CommandAst CommAst)
114+
if (target is not FunctionDefinitionAst or CommandAst)
122115
{
123-
tokenName = CommAst.GetCommandName();
116+
throw new HandlerErrorException($"Asked to rename a function but the target is not a viable function type: {target.GetType()}. This should not happen as PrepareRename should have already checked for viability. File an issue if you see this.");
124117
}
125-
IterativeFunctionRename visitor = new(
126-
tokenName,
127-
renameParams.NewName,
128-
position.Line,
129-
position.Column,
130-
scriptAst
131-
);
132-
visitor.Visit(scriptAst);
133-
return visitor.Modifications.ToArray();
118+
134119
}
135120

136121
internal static TextEdit[] RenameVariable(Ast symbol, Ast scriptAst, RenameParams requestParams)
@@ -195,7 +180,7 @@ internal static TextEdit[] RenameVariable(Ast symbol, Ast scriptAst, RenameParam
195180
/// <summary>
196181
/// Return an extent that only contains the position of the name of the function, for Client highlighting purposes.
197182
/// </summary>
198-
private static ScriptExtentAdapter GetFunctionNameExtent(FunctionDefinitionAst ast)
183+
public static ScriptExtentAdapter GetFunctionNameExtent(FunctionDefinitionAst ast)
199184
{
200185
string name = ast.Name;
201186
// FIXME: Gather dynamically from the AST and include backticks and whatnot that might be present
@@ -208,9 +193,132 @@ private static ScriptExtentAdapter GetFunctionNameExtent(FunctionDefinitionAst a
208193
}
209194
}
210195

196+
/// <summary>
197+
/// A visitor that renames a function given a particular target. The Edits property contains the edits when complete.
198+
/// You should use a new instance for each rename operation.
199+
/// Skipverify can be used as a performance optimization when you are sure you are in scope.
200+
/// </summary>
201+
/// <param name="target"></param>
202+
public class RenameFunctionVisitor(Ast target, string oldName, string newName, bool skipVerify = false) : AstVisitor
203+
{
204+
public List<TextEdit> Edits { get; } = new();
205+
private Ast? CurrentDocument;
206+
207+
// Wire up our visitor to the relevant AST types we are potentially renaming
208+
public override AstVisitAction VisitFunctionDefinition(FunctionDefinitionAst ast) => Visit(ast);
209+
public override AstVisitAction VisitCommand(CommandAst ast) => Visit(ast);
210+
211+
public AstVisitAction Visit(Ast ast)
212+
{
213+
/// If this is our first run, we need to verify we are in scope.
214+
if (!skipVerify && CurrentDocument is null)
215+
{
216+
if (ast.Find(ast => ast == target, true) is null)
217+
{
218+
throw new TargetSymbolNotFoundException("The target this visitor would rename is not present in the AST. This is a bug and you should file an issue");
219+
}
220+
CurrentDocument = ast;
221+
222+
// If our target was a command, we need to find the original function.
223+
if (target is CommandAst command)
224+
{
225+
target = CurrentDocument.GetFunctionDefinition(command)
226+
?? throw new TargetSymbolNotFoundException("The command to rename does not have a function definition.");
227+
}
228+
}
229+
if (CurrentDocument != ast)
230+
{
231+
throw new TargetSymbolNotFoundException("The visitor should not be reused to rename a different document. It should be created new for each rename operation. This is a bug and you should file an issue");
232+
}
233+
234+
if (ShouldRename(ast))
235+
{
236+
Edits.Add(GetRenameFunctionEdit(ast));
237+
return AstVisitAction.Continue;
238+
}
239+
else
240+
{
241+
return AstVisitAction.SkipChildren;
242+
}
243+
244+
/// TODO: Is there a way we can know we are fully outside where the function might be referenced, and if so, call a AstVisitAction Abort as a perf optimization?
245+
}
246+
247+
public bool ShouldRename(Ast candidate)
248+
{
249+
// There should be only one function definition and if it is not our target, it may be a duplicately named function
250+
if (candidate is FunctionDefinitionAst funcDef)
251+
{
252+
return funcDef == target;
253+
}
254+
255+
if (candidate is not CommandAst)
256+
{
257+
throw new InvalidOperationException($"ShouldRename for a function had an Unexpected Ast Type {candidate.GetType()}. This is a bug and you should file an issue.");
258+
}
259+
260+
// Determine if calls of the function are in the same scope as the function definition
261+
if (candidate?.Parent?.Parent is ScriptBlockAst)
262+
{
263+
return target.Parent.Parent == candidate.Parent.Parent;
264+
}
265+
else if (candidate?.Parent is StatementBlockAst)
266+
{
267+
return candidate.Parent == target.Parent;
268+
}
269+
270+
// If we get this far, we hit an edge case
271+
throw new InvalidOperationException("ShouldRename for a function could not determine the viability of a rename. This is a bug and you should file an issue.");
272+
}
273+
274+
private TextEdit GetRenameFunctionEdit(Ast candidate)
275+
{
276+
if (candidate is FunctionDefinitionAst funcDef)
277+
{
278+
if (funcDef != target)
279+
{
280+
throw new InvalidOperationException("GetRenameFunctionEdit was called on an Ast that was not the target. This is a bug and you should file an issue.");
281+
}
282+
283+
ScriptExtentAdapter functionNameExtent = RenameService.GetFunctionNameExtent(funcDef);
284+
285+
return new TextEdit()
286+
{
287+
NewText = newName,
288+
Range = functionNameExtent
289+
};
290+
}
291+
292+
// Should be CommandAst past this point.
293+
if (candidate is not CommandAst command)
294+
{
295+
throw new InvalidOperationException($"Expected a command but got {candidate.GetType()}");
296+
}
297+
298+
if (command.GetCommandName()?.ToLower() == oldName.ToLower() &&
299+
target.Extent.StartLineNumber <= command.Extent.StartLineNumber)
300+
{
301+
if (command.CommandElements[0] is not StringConstantExpressionAst funcName)
302+
{
303+
throw new InvalidOperationException("Command element should always have a string expresssion as its first item. This is a bug and you should report it.");
304+
}
305+
306+
return new TextEdit()
307+
{
308+
NewText = newName,
309+
Range = new ScriptExtentAdapter(funcName.Extent)
310+
};
311+
}
312+
313+
throw new InvalidOperationException("GetRenameFunctionEdit was not provided a FuncitonDefinition or a CommandAst");
314+
}
315+
}
316+
211317
public class RenameSymbolOptions
212318
{
213319
public bool CreateAlias { get; set; }
320+
321+
214322
}
215323

216324

@@ -274,6 +382,55 @@ public static class AstExtensions
274382
return mostSpecificAst;
275383
}
276384

385+
public static FunctionDefinitionAst? GetFunctionDefinition(this Ast ast, CommandAst command)
386+
{
387+
string? name = command.GetCommandName();
388+
if (name is null) { return null; }
389+
390+
List<FunctionDefinitionAst> FunctionDefinitions = ast.FindAll(ast =>
391+
{
392+
return ast is FunctionDefinitionAst funcDef &&
393+
funcDef.Name.ToLower() == name &&
394+
(funcDef.Extent.EndLineNumber < command.Extent.StartLineNumber ||
395+
(funcDef.Extent.EndColumnNumber <= command.Extent.StartColumnNumber &&
396+
funcDef.Extent.EndLineNumber <= command.Extent.StartLineNumber));
397+
}, true).Cast<FunctionDefinitionAst>().ToList();
398+
399+
// return the function def if we only have one match
400+
if (FunctionDefinitions.Count == 1)
401+
{
402+
return FunctionDefinitions[0];
403+
}
404+
// Determine which function definition is the right one
405+
FunctionDefinitionAst? CorrectDefinition = null;
406+
for (int i = FunctionDefinitions.Count - 1; i >= 0; i--)
407+
{
408+
FunctionDefinitionAst element = FunctionDefinitions[i];
409+
410+
Ast parent = element.Parent;
411+
// walk backwards till we hit a functiondefinition if any
412+
while (null != parent)
413+
{
414+
if (parent is FunctionDefinitionAst)
415+
{
416+
break;
417+
}
418+
parent = parent.Parent;
419+
}
420+
// we have hit the global scope of the script file
421+
if (null == parent)
422+
{
423+
CorrectDefinition = element;
424+
break;
425+
}
426+
427+
if (command?.Parent == parent)
428+
{
429+
CorrectDefinition = (FunctionDefinitionAst)parent;
430+
}
431+
}
432+
return CorrectDefinition;
433+
}
277434
}
278435

279436
internal class Utilities
@@ -453,9 +610,10 @@ public ScriptPositionAdapter(int Line, int Column) : this(new ScriptPosition(nul
453610
public ScriptPositionAdapter(ScriptPosition position) : this((IScriptPosition)position) { }
454611

455612
public ScriptPositionAdapter(Position position) : this(position.Line + 1, position.Character + 1) { }
613+
456614
public static implicit operator ScriptPositionAdapter(Position position) => new(position);
457615
public static implicit operator Position(ScriptPositionAdapter scriptPosition) => new
458-
(
616+
(
459617
scriptPosition.position.LineNumber - 1, scriptPosition.position.ColumnNumber - 1
460618
);
461619

@@ -522,7 +680,7 @@ internal record ScriptExtentAdapter(IScriptExtent extent) : IScriptExtent
522680
public int StartLineNumber => extent.StartLineNumber;
523681
public string Text => extent.Text;
524682

525-
public bool Contains(IScriptPosition position) => Contains((ScriptPositionAdapter)position);
683+
public bool Contains(IScriptPosition position) => Contains(new ScriptPositionAdapter(position));
526684

527685
public bool Contains(ScriptPositionAdapter position)
528686
{

test/PowerShellEditorServices.Test/Refactoring/PrepareRenameHandlerTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,14 +155,14 @@ public static partial class RenameTestTargetExtensions
155155
/// <summary>
156156
/// Extension Method to convert a RenameTestTarget to a RenameParams. Needed because RenameTestTarget is in a separate project.
157157
/// </summary>
158-
public static RenameParams ToRenameParams(this RenameTestTarget testCase)
158+
public static RenameParams ToRenameParams(this RenameTestTarget testCase, string subPath)
159159
=> new()
160160
{
161161
Position = new ScriptPositionAdapter(Line: testCase.Line, Column: testCase.Column),
162162
TextDocument = new TextDocumentIdentifier
163163
{
164164
Uri = DocumentUri.FromFileSystemPath(
165-
TestUtilities.GetSharedPath($"Refactoring/Functions/{testCase.FileName}")
165+
TestUtilities.GetSharedPath($"Refactoring/{subPath}/{testCase.FileName}")
166166
)
167167
},
168168
NewName = testCase.NewName

test/PowerShellEditorServices.Test/Refactoring/RenameHandlerTests.cs

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
using OmniSharp.Extensions.LanguageServer.Protocol;
1010
using OmniSharp.Extensions.LanguageServer.Protocol.Models;
1111
using static PowerShellEditorServices.Test.Refactoring.RefactorUtilities;
12-
using System.IO;
1312
using System.Linq;
1413
using System.Threading;
1514
using Xunit;
@@ -50,35 +49,43 @@ public static TheoryData<RenameTestTargetSerializable> FunctionTestCases()
5049
=> new(RefactorFunctionTestCases.TestCases.Select(RenameTestTargetSerializable.FromRenameTestTarget));
5150

5251
[Theory]
53-
[MemberData(nameof(VariableTestCases))]
54-
public async void RenamedSymbol(RenameTestTarget s)
52+
[MemberData(nameof(FunctionTestCases))]
53+
public async void RenamedFunction(RenameTestTarget s)
5554
{
56-
string fileName = s.FileName;
57-
ScriptFile scriptFile = GetTestScript(fileName);
55+
RenameParams request = s.ToRenameParams("Functions");
56+
WorkspaceEdit response = await testHandler.Handle(request, CancellationToken.None);
57+
DocumentUri testScriptUri = request.TextDocument.Uri;
5858

59-
WorkspaceEdit response = await testHandler.Handle(s.ToRenameParams(), CancellationToken.None);
59+
string expected = workspace.GetFile
60+
(
61+
testScriptUri.ToString().Substring(0, testScriptUri.ToString().Length - 4) + "Renamed.ps1"
62+
).Contents;
63+
64+
ScriptFile scriptFile = workspace.GetFile(testScriptUri);
6065

61-
string expected = GetTestScript(fileName.Substring(0, fileName.Length - 4) + "Renamed.ps1").Contents;
62-
string actual = GetModifiedScript(scriptFile.Contents, response.Changes[s.ToRenameParams().TextDocument.Uri].ToArray());
66+
string actual = GetModifiedScript(scriptFile.Contents, response.Changes[testScriptUri].ToArray());
6367

68+
Assert.NotEmpty(response.Changes[testScriptUri]);
6469
Assert.Equal(expected, actual);
6570
}
6671

6772
[Theory]
68-
[MemberData(nameof(FunctionTestCases))]
69-
public async void RenamedFunction(RenameTestTarget s)
73+
[MemberData(nameof(VariableTestCases))]
74+
public async void RenamedVariable(RenameTestTarget s)
7075
{
71-
string fileName = s.FileName;
72-
ScriptFile scriptFile = GetTestScript(fileName);
76+
RenameParams request = s.ToRenameParams("Variables");
77+
WorkspaceEdit response = await testHandler.Handle(request, CancellationToken.None);
78+
DocumentUri testScriptUri = request.TextDocument.Uri;
7379

74-
WorkspaceEdit response = await testHandler.Handle(s.ToRenameParams(), CancellationToken.None);
80+
string expected = workspace.GetFile
81+
(
82+
testScriptUri.ToString().Substring(0, testScriptUri.ToString().Length - 4) + "Renamed.ps1"
83+
).Contents;
7584

76-
string expected = GetTestScript(fileName.Substring(0, fileName.Length - 4) + "Renamed.ps1").Contents;
77-
string actual = GetModifiedScript(scriptFile.Contents, response.Changes[s.ToRenameParams().TextDocument.Uri].ToArray());
85+
ScriptFile scriptFile = workspace.GetFile(testScriptUri);
86+
87+
string actual = GetModifiedScript(scriptFile.Contents, response.Changes[testScriptUri].ToArray());
7888

7989
Assert.Equal(expected, actual);
8090
}
81-
82-
private ScriptFile GetTestScript(string fileName) =>
83-
workspace.GetFile(TestUtilities.GetSharedPath(Path.Combine("Refactoring", "Functions", fileName)));
8491
}

0 commit comments

Comments
 (0)