diff --git a/src/PowerShellEditorServices/Language/AstOperations.cs b/src/PowerShellEditorServices/Language/AstOperations.cs index 38a8956a3..9076b1cb9 100644 --- a/src/PowerShellEditorServices/Language/AstOperations.cs +++ b/src/PowerShellEditorServices/Language/AstOperations.cs @@ -321,10 +321,11 @@ static private bool IsPowerShellDataFileAstNode(dynamic node, Type[] levelAstMap /// Finds all files dot sourced in a script /// /// The abstract syntax tree of the given script + /// Pre-calculated value of $PSScriptRoot /// - static public string[] FindDotSourcedIncludes(Ast scriptAst) + static public string[] FindDotSourcedIncludes(Ast scriptAst, string psScriptRoot) { - FindDotSourcedVisitor dotSourcedVisitor = new FindDotSourcedVisitor(); + FindDotSourcedVisitor dotSourcedVisitor = new FindDotSourcedVisitor(psScriptRoot); scriptAst.Visit(dotSourcedVisitor); return dotSourcedVisitor.DotSourcedFiles.ToArray(); diff --git a/src/PowerShellEditorServices/Language/FindDotSourcedVisitor.cs b/src/PowerShellEditorServices/Language/FindDotSourcedVisitor.cs index 49d4d66ab..c0295a228 100644 --- a/src/PowerShellEditorServices/Language/FindDotSourcedVisitor.cs +++ b/src/PowerShellEditorServices/Language/FindDotSourcedVisitor.cs @@ -3,8 +3,10 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. // +using System; using System.Collections.Generic; using System.Management.Automation.Language; +using Microsoft.PowerShell.EditorServices.Utility; namespace Microsoft.PowerShell.EditorServices { @@ -13,14 +15,21 @@ namespace Microsoft.PowerShell.EditorServices /// internal class FindDotSourcedVisitor : AstVisitor { - /// - /// A hash set of the dot sourced files (because we don't want duplicates) - /// + private readonly string _psScriptRoot; + + /// + /// A hash set of the dot sourced files (because we don't want duplicates) + /// public HashSet DotSourcedFiles { get; private set; } - public FindDotSourcedVisitor() + /// + /// Creates a new instance of the FindDotSourcedVisitor class. + /// + /// Pre-calculated value of $PSScriptRoot + public FindDotSourcedVisitor(string psScriptRoot) { - this.DotSourcedFiles = new HashSet(); + DotSourcedFiles = new HashSet(StringComparer.CurrentCultureIgnoreCase); + _psScriptRoot = psScriptRoot; } /// @@ -32,15 +41,50 @@ public FindDotSourcedVisitor() /// or a decision to continue if it wasn't found public override AstVisitAction VisitCommand(CommandAst commandAst) { - if (commandAst.InvocationOperator.Equals(TokenKind.Dot) && - commandAst.CommandElements[0] is StringConstantExpressionAst) + CommandElementAst commandElementAst = commandAst.CommandElements[0]; + if (commandAst.InvocationOperator.Equals(TokenKind.Dot)) { - // Strip any quote characters off of the string - string fileName = commandAst.CommandElements[0].Extent.Text.Trim('\'', '"'); - DotSourcedFiles.Add(fileName); + string path; + switch (commandElementAst) + { + case StringConstantExpressionAst stringConstantExpressionAst: + path = stringConstantExpressionAst.Value; + break; + + case ExpandableStringExpressionAst expandableStringExpressionAst: + path = GetPathFromExpandableStringExpression(expandableStringExpressionAst); + break; + + default: + path = null; + break; + } + + if (!string.IsNullOrWhiteSpace(path)) + { + DotSourcedFiles.Add(PathUtils.NormalizePathSeparators(path)); + } } return base.VisitCommand(commandAst); } + + private string GetPathFromExpandableStringExpression(ExpandableStringExpressionAst expandableStringExpressionAst) + { + var path = expandableStringExpressionAst.Value; + foreach (var nestedExpression in expandableStringExpressionAst.NestedExpressions) + { + // If the string contains the variable $PSScriptRoot, we replace it with the corresponding value. + if (!(nestedExpression is VariableExpressionAst variableAst + && variableAst.VariablePath.UserPath.Equals("PSScriptRoot", StringComparison.OrdinalIgnoreCase))) + { + return null; // We return null instead of a partially evaluated ExpandableStringExpression. + } + + path = path.Replace(variableAst.ToString(), _psScriptRoot); + } + + return path; + } } } diff --git a/src/PowerShellEditorServices/Language/LanguageService.cs b/src/PowerShellEditorServices/Language/LanguageService.cs index f721bd1f5..92964e1b6 100644 --- a/src/PowerShellEditorServices/Language/LanguageService.cs +++ b/src/PowerShellEditorServices/Language/LanguageService.cs @@ -14,6 +14,7 @@ using System.Management.Automation.Language; using System.Runtime.InteropServices; using System.Security; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -409,19 +410,30 @@ public async Task GetDefinitionOfSymbol( // look through the referenced files until definition is found // or there are no more file to look through SymbolReference foundDefinition = null; - for (int i = 0; i < referencedFiles.Length; i++) + foreach (ScriptFile scriptFile in referencedFiles) { foundDefinition = AstOperations.FindDefinitionOfSymbol( - referencedFiles[i].ScriptAst, + scriptFile.ScriptAst, foundSymbol); - filesSearched.Add(referencedFiles[i].FilePath); + filesSearched.Add(scriptFile.FilePath); if (foundDefinition != null) { - foundDefinition.FilePath = referencedFiles[i].FilePath; + foundDefinition.FilePath = scriptFile.FilePath; break; } + + if (foundSymbol.SymbolType == SymbolType.Function) + { + // Dot-sourcing is parsed as a "Function" Symbol. + string dotSourcedPath = GetDotSourcedPath(foundSymbol, workspace, scriptFile); + if (scriptFile.FilePath == dotSourcedPath) + { + foundDefinition = new SymbolReference(SymbolType.Function, foundSymbol.SymbolName, scriptFile.ScriptAst.Extent, scriptFile.FilePath); + break; + } + } } // if the definition the not found in referenced files @@ -475,6 +487,21 @@ await CommandHelpers.GetCommandInfo( null; } + /// + /// Gets a path from a dot-source symbol. + /// + /// The symbol representing the dot-source expression. + /// The current workspace + /// The script file containing the symbol + /// + private static string GetDotSourcedPath(SymbolReference symbol, Workspace workspace, ScriptFile scriptFile) + { + string cleanedUpSymbol = PathUtils.NormalizePathSeparators(symbol.SymbolName.Trim('\'', '"')); + string psScriptRoot = Path.GetDirectoryName(scriptFile.FilePath); + return workspace.ResolveRelativeScriptPath(psScriptRoot, + Regex.Replace(cleanedUpSymbol, @"\$PSScriptRoot|\${PSScriptRoot}", psScriptRoot, RegexOptions.IgnoreCase)); + } + /// /// Finds all the occurences of a symbol in the script given a file location /// @@ -712,7 +739,7 @@ await _powerShellContext.GetRunspaceHandle( { if (!_cmdletToAliasDictionary.ContainsKey(aliasInfo.Definition)) { - _cmdletToAliasDictionary.Add(aliasInfo.Definition, new List{ aliasInfo.Name }); + _cmdletToAliasDictionary.Add(aliasInfo.Definition, new List { aliasInfo.Name }); } else { diff --git a/src/PowerShellEditorServices/Utility/PathUtils.cs b/src/PowerShellEditorServices/Utility/PathUtils.cs new file mode 100644 index 000000000..6b62122ec --- /dev/null +++ b/src/PowerShellEditorServices/Utility/PathUtils.cs @@ -0,0 +1,49 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.IO; +using System.Runtime.InteropServices; + +namespace Microsoft.PowerShell.EditorServices.Utility +{ + /// + /// Utility to help handling paths across different platforms. + /// + /// + /// Some constants were copied from the internal System.Management.Automation.StringLiterals class. + /// + internal static class PathUtils + { + /// + /// The default path separator used by the base implementation of the providers. + /// + /// Porting note: IO.Path.DirectorySeparatorChar is correct for all platforms. On Windows, + /// it is '\', and on Linux, it is '/', as expected. + /// + internal static readonly char DefaultPathSeparator = Path.DirectorySeparatorChar; + internal static readonly string DefaultPathSeparatorString = DefaultPathSeparator.ToString(); + + /// + /// The alternate path separator used by the base implementation of the providers. + /// + /// Porting note: we do not use .NET's AlternatePathSeparatorChar here because it correctly + /// states that both the default and alternate are '/' on Linux. However, for PowerShell to + /// be "slash agnostic", we need to use the assumption that a '\' is the alternate path + /// separator on Linux. + /// + internal static readonly char AlternatePathSeparator = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? '/' : '\\'; + internal static readonly string AlternatePathSeparatorString = AlternatePathSeparator.ToString(); + + /// + /// Converts all alternate path separators to the current platform's main path separators. + /// + /// The path to normalize. + /// The normalized path. + public static string NormalizePathSeparators(string path) + { + return string.IsNullOrWhiteSpace(path) ? path : path.Replace(AlternatePathSeparator, DefaultPathSeparator); + } + } +} diff --git a/src/PowerShellEditorServices/Workspace/ScriptFile.cs b/src/PowerShellEditorServices/Workspace/ScriptFile.cs index 4851af99c..a81200887 100644 --- a/src/PowerShellEditorServices/Workspace/ScriptFile.cs +++ b/src/PowerShellEditorServices/Workspace/ScriptFile.cs @@ -20,7 +20,7 @@ public class ScriptFile { #region Private Fields - private static readonly string[] s_newlines = new [] + private static readonly string[] s_newlines = new[] { "\r\n", "\n" @@ -649,8 +649,7 @@ private void ParseFileContents() .ToArray(); //Get all dot sourced referenced files and store them - this.ReferencedFiles = - AstOperations.FindDotSourcedIncludes(this.ScriptAst); + this.ReferencedFiles = AstOperations.FindDotSourcedIncludes(this.ScriptAst, Path.GetDirectoryName(this.FilePath)); } #endregion diff --git a/src/PowerShellEditorServices/Workspace/Workspace.cs b/src/PowerShellEditorServices/Workspace/Workspace.cs index b53832f07..69847ee4d 100644 --- a/src/PowerShellEditorServices/Workspace/Workspace.cs +++ b/src/PowerShellEditorServices/Workspace/Workspace.cs @@ -530,7 +530,7 @@ private string GetBaseFilePath(string filePath) return Path.GetDirectoryName(filePath); } - private string ResolveRelativeScriptPath(string baseFilePath, string relativePath) + internal string ResolveRelativeScriptPath(string baseFilePath, string relativePath) { string combinedPath = null; Exception resolveException = null; diff --git a/test/PowerShellEditorServices.Test.Shared/Definition/FindsDotSourcedFile.cs b/test/PowerShellEditorServices.Test.Shared/Definition/FindsDotSourcedFile.cs new file mode 100644 index 000000000..20160081a --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Definition/FindsDotSourcedFile.cs @@ -0,0 +1,20 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.PowerShell.EditorServices; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.Definition +{ + public class FindsDotSourcedFile + { + public static readonly ScriptRegion SourceDetails = + new ScriptRegion + { + File = @"References\DotSources.ps1", + StartLineNumber = 1, + StartColumnNumber = 3 + }; + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/References/DotSources.ps1 b/test/PowerShellEditorServices.Test.Shared/References/DotSources.ps1 new file mode 100644 index 000000000..685183ea6 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/References/DotSources.ps1 @@ -0,0 +1,15 @@ +. ./ReferenceFileE.ps1 +. "$PSScriptRoot/ReferenceFileE.ps1" +. "${PSScriptRoot}/ReferenceFileE.ps1" +. './ReferenceFileE.ps1' +. "./ReferenceFileE.ps1" +. .\ReferenceFileE.ps1 +. '.\ReferenceFileE.ps1' +. ".\ReferenceFileE.ps1" +. ReferenceFileE.ps1 +. 'ReferenceFileE.ps1' +. "ReferenceFileE.ps1" +. ./dir/../ReferenceFileE.ps1 +. ./invalidfile.ps1 +. "" +. $someVar diff --git a/test/PowerShellEditorServices.Test.Shared/References/ReferenceFileB.ps1 b/test/PowerShellEditorServices.Test.Shared/References/ReferenceFileB.ps1 index add70c1d6..980ed33da 100644 --- a/test/PowerShellEditorServices.Test.Shared/References/ReferenceFileB.ps1 +++ b/test/PowerShellEditorServices.Test.Shared/References/ReferenceFileB.ps1 @@ -1,5 +1,5 @@ -. .\ReferenceFileC.ps1 +. "$PSScriptRoot\ReferenceFileC.ps1" Get-ChildItem -My-Function "testb" \ No newline at end of file +My-Function "testb" diff --git a/test/PowerShellEditorServices.Test/Language/LanguageServiceTests.cs b/test/PowerShellEditorServices.Test/Language/LanguageServiceTests.cs index 1e19a544a..a432fb73f 100644 --- a/test/PowerShellEditorServices.Test/Language/LanguageServiceTests.cs +++ b/test/PowerShellEditorServices.Test/Language/LanguageServiceTests.cs @@ -158,6 +158,23 @@ await this.GetDefinition( Assert.Equal("My-Function", definition.SymbolName); } + [Fact] + public async Task LanguageServiceFindsDotSourcedFile() + { + GetDefinitionResult definitionResult = + await this.GetDefinition( + FindsDotSourcedFile.SourceDetails); + + SymbolReference definition = definitionResult.FoundDefinition; + Assert.True( + definitionResult.FoundDefinition.FilePath.EndsWith( + Path.Combine("References", "ReferenceFileE.ps1")), + "Unexpected reference file: " + definitionResult.FoundDefinition.FilePath); + Assert.Equal(1, definition.ScriptRegion.StartLineNumber); + Assert.Equal(1, definition.ScriptRegion.StartColumnNumber); + Assert.Equal("./ReferenceFileE.ps1", definition.SymbolName); + } + [Fact] public async Task LanguageServiceFindsFunctionDefinitionInWorkspace() {