Skip to content

Commit 99f01ef

Browse files
dee-seerjmholt
authored andcommitted
Fix #17: Add go to definition support for dot sourced file paths (#786)
* Implement go-to-definition for dot-sourced files * Support $PSScriptRoot in dot-sourced files * Add PathUtils for unified path normalization
1 parent 65d8e70 commit 99f01ef

File tree

10 files changed

+195
-23
lines changed

10 files changed

+195
-23
lines changed

src/PowerShellEditorServices/Language/AstOperations.cs

+3-2
Original file line numberDiff line numberDiff line change
@@ -321,10 +321,11 @@ static private bool IsPowerShellDataFileAstNode(dynamic node, Type[] levelAstMap
321321
/// Finds all files dot sourced in a script
322322
/// </summary>
323323
/// <param name="scriptAst">The abstract syntax tree of the given script</param>
324+
/// <param name="psScriptRoot">Pre-calculated value of $PSScriptRoot</param>
324325
/// <returns></returns>
325-
static public string[] FindDotSourcedIncludes(Ast scriptAst)
326+
static public string[] FindDotSourcedIncludes(Ast scriptAst, string psScriptRoot)
326327
{
327-
FindDotSourcedVisitor dotSourcedVisitor = new FindDotSourcedVisitor();
328+
FindDotSourcedVisitor dotSourcedVisitor = new FindDotSourcedVisitor(psScriptRoot);
328329
scriptAst.Visit(dotSourcedVisitor);
329330

330331
return dotSourcedVisitor.DotSourcedFiles.ToArray();

src/PowerShellEditorServices/Language/FindDotSourcedVisitor.cs

+54-10
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
44
//
55

6+
using System;
67
using System.Collections.Generic;
78
using System.Management.Automation.Language;
9+
using Microsoft.PowerShell.EditorServices.Utility;
810

911
namespace Microsoft.PowerShell.EditorServices
1012
{
@@ -13,14 +15,21 @@ namespace Microsoft.PowerShell.EditorServices
1315
/// </summary>
1416
internal class FindDotSourcedVisitor : AstVisitor
1517
{
16-
/// <summary>
17-
/// A hash set of the dot sourced files (because we don't want duplicates)
18-
/// </summary>
18+
private readonly string _psScriptRoot;
19+
20+
/// <summary>
21+
/// A hash set of the dot sourced files (because we don't want duplicates)
22+
/// </summary>
1923
public HashSet<string> DotSourcedFiles { get; private set; }
2024

21-
public FindDotSourcedVisitor()
25+
/// <summary>
26+
/// Creates a new instance of the FindDotSourcedVisitor class.
27+
/// </summary>
28+
/// <param name="psScriptRoot">Pre-calculated value of $PSScriptRoot</param>
29+
public FindDotSourcedVisitor(string psScriptRoot)
2230
{
23-
this.DotSourcedFiles = new HashSet<string>();
31+
DotSourcedFiles = new HashSet<string>(StringComparer.CurrentCultureIgnoreCase);
32+
_psScriptRoot = psScriptRoot;
2433
}
2534

2635
/// <summary>
@@ -32,15 +41,50 @@ public FindDotSourcedVisitor()
3241
/// or a decision to continue if it wasn't found</returns>
3342
public override AstVisitAction VisitCommand(CommandAst commandAst)
3443
{
35-
if (commandAst.InvocationOperator.Equals(TokenKind.Dot) &&
36-
commandAst.CommandElements[0] is StringConstantExpressionAst)
44+
CommandElementAst commandElementAst = commandAst.CommandElements[0];
45+
if (commandAst.InvocationOperator.Equals(TokenKind.Dot))
3746
{
38-
// Strip any quote characters off of the string
39-
string fileName = commandAst.CommandElements[0].Extent.Text.Trim('\'', '"');
40-
DotSourcedFiles.Add(fileName);
47+
string path;
48+
switch (commandElementAst)
49+
{
50+
case StringConstantExpressionAst stringConstantExpressionAst:
51+
path = stringConstantExpressionAst.Value;
52+
break;
53+
54+
case ExpandableStringExpressionAst expandableStringExpressionAst:
55+
path = GetPathFromExpandableStringExpression(expandableStringExpressionAst);
56+
break;
57+
58+
default:
59+
path = null;
60+
break;
61+
}
62+
63+
if (!string.IsNullOrWhiteSpace(path))
64+
{
65+
DotSourcedFiles.Add(PathUtils.NormalizePathSeparators(path));
66+
}
4167
}
4268

4369
return base.VisitCommand(commandAst);
4470
}
71+
72+
private string GetPathFromExpandableStringExpression(ExpandableStringExpressionAst expandableStringExpressionAst)
73+
{
74+
var path = expandableStringExpressionAst.Value;
75+
foreach (var nestedExpression in expandableStringExpressionAst.NestedExpressions)
76+
{
77+
// If the string contains the variable $PSScriptRoot, we replace it with the corresponding value.
78+
if (!(nestedExpression is VariableExpressionAst variableAst
79+
&& variableAst.VariablePath.UserPath.Equals("PSScriptRoot", StringComparison.OrdinalIgnoreCase)))
80+
{
81+
return null; // We return null instead of a partially evaluated ExpandableStringExpression.
82+
}
83+
84+
path = path.Replace(variableAst.ToString(), _psScriptRoot);
85+
}
86+
87+
return path;
88+
}
4589
}
4690
}

src/PowerShellEditorServices/Language/LanguageService.cs

+32-5
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
using System.Management.Automation.Language;
1515
using System.Runtime.InteropServices;
1616
using System.Security;
17+
using System.Text.RegularExpressions;
1718
using System.Threading;
1819
using System.Threading.Tasks;
1920

@@ -409,19 +410,30 @@ public async Task<GetDefinitionResult> GetDefinitionOfSymbol(
409410
// look through the referenced files until definition is found
410411
// or there are no more file to look through
411412
SymbolReference foundDefinition = null;
412-
for (int i = 0; i < referencedFiles.Length; i++)
413+
foreach (ScriptFile scriptFile in referencedFiles)
413414
{
414415
foundDefinition =
415416
AstOperations.FindDefinitionOfSymbol(
416-
referencedFiles[i].ScriptAst,
417+
scriptFile.ScriptAst,
417418
foundSymbol);
418419

419-
filesSearched.Add(referencedFiles[i].FilePath);
420+
filesSearched.Add(scriptFile.FilePath);
420421
if (foundDefinition != null)
421422
{
422-
foundDefinition.FilePath = referencedFiles[i].FilePath;
423+
foundDefinition.FilePath = scriptFile.FilePath;
423424
break;
424425
}
426+
427+
if (foundSymbol.SymbolType == SymbolType.Function)
428+
{
429+
// Dot-sourcing is parsed as a "Function" Symbol.
430+
string dotSourcedPath = GetDotSourcedPath(foundSymbol, workspace, scriptFile);
431+
if (scriptFile.FilePath == dotSourcedPath)
432+
{
433+
foundDefinition = new SymbolReference(SymbolType.Function, foundSymbol.SymbolName, scriptFile.ScriptAst.Extent, scriptFile.FilePath);
434+
break;
435+
}
436+
}
425437
}
426438

427439
// if the definition the not found in referenced files
@@ -475,6 +487,21 @@ await CommandHelpers.GetCommandInfo(
475487
null;
476488
}
477489

490+
/// <summary>
491+
/// Gets a path from a dot-source symbol.
492+
/// </summary>
493+
/// <param name="symbol">The symbol representing the dot-source expression.</param>
494+
/// <param name="workspace">The current workspace</param>
495+
/// <param name="scriptFile">The script file containing the symbol</param>
496+
/// <returns></returns>
497+
private static string GetDotSourcedPath(SymbolReference symbol, Workspace workspace, ScriptFile scriptFile)
498+
{
499+
string cleanedUpSymbol = PathUtils.NormalizePathSeparators(symbol.SymbolName.Trim('\'', '"'));
500+
string psScriptRoot = Path.GetDirectoryName(scriptFile.FilePath);
501+
return workspace.ResolveRelativeScriptPath(psScriptRoot,
502+
Regex.Replace(cleanedUpSymbol, @"\$PSScriptRoot|\${PSScriptRoot}", psScriptRoot, RegexOptions.IgnoreCase));
503+
}
504+
478505
/// <summary>
479506
/// Finds all the occurences of a symbol in the script given a file location
480507
/// </summary>
@@ -712,7 +739,7 @@ await _powerShellContext.GetRunspaceHandle(
712739
{
713740
if (!_cmdletToAliasDictionary.ContainsKey(aliasInfo.Definition))
714741
{
715-
_cmdletToAliasDictionary.Add(aliasInfo.Definition, new List<String>{ aliasInfo.Name });
742+
_cmdletToAliasDictionary.Add(aliasInfo.Definition, new List<String> { aliasInfo.Name });
716743
}
717744
else
718745
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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.IO;
7+
using System.Runtime.InteropServices;
8+
9+
namespace Microsoft.PowerShell.EditorServices.Utility
10+
{
11+
/// <summary>
12+
/// Utility to help handling paths across different platforms.
13+
/// </summary>
14+
/// <remarks>
15+
/// Some constants were copied from the internal System.Management.Automation.StringLiterals class.
16+
/// </remarks>
17+
internal static class PathUtils
18+
{
19+
/// <summary>
20+
/// The default path separator used by the base implementation of the providers.
21+
///
22+
/// Porting note: IO.Path.DirectorySeparatorChar is correct for all platforms. On Windows,
23+
/// it is '\', and on Linux, it is '/', as expected.
24+
/// </summary>
25+
internal static readonly char DefaultPathSeparator = Path.DirectorySeparatorChar;
26+
internal static readonly string DefaultPathSeparatorString = DefaultPathSeparator.ToString();
27+
28+
/// <summary>
29+
/// The alternate path separator used by the base implementation of the providers.
30+
///
31+
/// Porting note: we do not use .NET's AlternatePathSeparatorChar here because it correctly
32+
/// states that both the default and alternate are '/' on Linux. However, for PowerShell to
33+
/// be "slash agnostic", we need to use the assumption that a '\' is the alternate path
34+
/// separator on Linux.
35+
/// </summary>
36+
internal static readonly char AlternatePathSeparator = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? '/' : '\\';
37+
internal static readonly string AlternatePathSeparatorString = AlternatePathSeparator.ToString();
38+
39+
/// <summary>
40+
/// Converts all alternate path separators to the current platform's main path separators.
41+
/// </summary>
42+
/// <param name="path">The path to normalize.</param>
43+
/// <returns>The normalized path.</returns>
44+
public static string NormalizePathSeparators(string path)
45+
{
46+
return string.IsNullOrWhiteSpace(path) ? path : path.Replace(AlternatePathSeparator, DefaultPathSeparator);
47+
}
48+
}
49+
}

src/PowerShellEditorServices/Workspace/ScriptFile.cs

+2-3
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public class ScriptFile
2020
{
2121
#region Private Fields
2222

23-
private static readonly string[] s_newlines = new []
23+
private static readonly string[] s_newlines = new[]
2424
{
2525
"\r\n",
2626
"\n"
@@ -649,8 +649,7 @@ private void ParseFileContents()
649649
.ToArray();
650650

651651
//Get all dot sourced referenced files and store them
652-
this.ReferencedFiles =
653-
AstOperations.FindDotSourcedIncludes(this.ScriptAst);
652+
this.ReferencedFiles = AstOperations.FindDotSourcedIncludes(this.ScriptAst, Path.GetDirectoryName(this.FilePath));
654653
}
655654

656655
#endregion

src/PowerShellEditorServices/Workspace/Workspace.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -530,7 +530,7 @@ private string GetBaseFilePath(string filePath)
530530
return Path.GetDirectoryName(filePath);
531531
}
532532

533-
private string ResolveRelativeScriptPath(string baseFilePath, string relativePath)
533+
internal string ResolveRelativeScriptPath(string baseFilePath, string relativePath)
534534
{
535535
string combinedPath = null;
536536
Exception resolveException = null;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
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 Microsoft.PowerShell.EditorServices;
7+
8+
namespace Microsoft.PowerShell.EditorServices.Test.Shared.Definition
9+
{
10+
public class FindsDotSourcedFile
11+
{
12+
public static readonly ScriptRegion SourceDetails =
13+
new ScriptRegion
14+
{
15+
File = @"References\DotSources.ps1",
16+
StartLineNumber = 1,
17+
StartColumnNumber = 3
18+
};
19+
}
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
. ./ReferenceFileE.ps1
2+
. "$PSScriptRoot/ReferenceFileE.ps1"
3+
. "${PSScriptRoot}/ReferenceFileE.ps1"
4+
. './ReferenceFileE.ps1'
5+
. "./ReferenceFileE.ps1"
6+
. .\ReferenceFileE.ps1
7+
. '.\ReferenceFileE.ps1'
8+
. ".\ReferenceFileE.ps1"
9+
. ReferenceFileE.ps1
10+
. 'ReferenceFileE.ps1'
11+
. "ReferenceFileE.ps1"
12+
. ./dir/../ReferenceFileE.ps1
13+
. ./invalidfile.ps1
14+
. ""
15+
. $someVar
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
. .\ReferenceFileC.ps1
1+
. "$PSScriptRoot\ReferenceFileC.ps1"
22

33
Get-ChildItem
44

5-
My-Function "testb"
5+
My-Function "testb"

test/PowerShellEditorServices.Test/Language/LanguageServiceTests.cs

+17
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,23 @@ await this.GetDefinition(
158158
Assert.Equal("My-Function", definition.SymbolName);
159159
}
160160

161+
[Fact]
162+
public async Task LanguageServiceFindsDotSourcedFile()
163+
{
164+
GetDefinitionResult definitionResult =
165+
await this.GetDefinition(
166+
FindsDotSourcedFile.SourceDetails);
167+
168+
SymbolReference definition = definitionResult.FoundDefinition;
169+
Assert.True(
170+
definitionResult.FoundDefinition.FilePath.EndsWith(
171+
Path.Combine("References", "ReferenceFileE.ps1")),
172+
"Unexpected reference file: " + definitionResult.FoundDefinition.FilePath);
173+
Assert.Equal(1, definition.ScriptRegion.StartLineNumber);
174+
Assert.Equal(1, definition.ScriptRegion.StartColumnNumber);
175+
Assert.Equal("./ReferenceFileE.ps1", definition.SymbolName);
176+
}
177+
161178
[Fact]
162179
public async Task LanguageServiceFindsFunctionDefinitionInWorkspace()
163180
{

0 commit comments

Comments
 (0)