diff --git a/src/PowerShellEditorServices/Extensions/Api/WorkspaceService.cs b/src/PowerShellEditorServices/Extensions/Api/WorkspaceService.cs index 443183f49..8e5472633 100644 --- a/src/PowerShellEditorServices/Extensions/Api/WorkspaceService.cs +++ b/src/PowerShellEditorServices/Extensions/Api/WorkspaceService.cs @@ -45,7 +45,7 @@ public interface IEditorScriptFile public interface IWorkspaceService { /// - /// The root path of the workspace. + /// The root path of the workspace for the current editor. /// string WorkspacePath { get; } @@ -116,7 +116,9 @@ internal WorkspaceService( ExcludedFileGlobs = _workspaceService.ExcludeFilesGlob.AsReadOnly(); } - public string WorkspacePath => _workspaceService.WorkspacePath; + // TODO: This needs to use the associated EditorContext to get the workspace for the current + // editor instead of the initial working directory. + public string WorkspacePath => _workspaceService.InitialWorkingDirectory; public bool FollowSymlinks => _workspaceService.FollowSymlinks; diff --git a/src/PowerShellEditorServices/Extensions/EditorWorkspace.cs b/src/PowerShellEditorServices/Extensions/EditorWorkspace.cs index 1a471b034..18f001d56 100644 --- a/src/PowerShellEditorServices/Extensions/EditorWorkspace.cs +++ b/src/PowerShellEditorServices/Extensions/EditorWorkspace.cs @@ -18,7 +18,7 @@ public sealed class EditorWorkspace #region Properties /// - /// Gets the current workspace path if there is one or null otherwise. + /// Gets the current workspace path if there is one for the open editor or null otherwise. /// public string Path => editorOperations.GetWorkspacePath(); diff --git a/src/PowerShellEditorServices/Server/PsesLanguageServer.cs b/src/PowerShellEditorServices/Server/PsesLanguageServer.cs index 05099b36b..440e84a70 100644 --- a/src/PowerShellEditorServices/Server/PsesLanguageServer.cs +++ b/src/PowerShellEditorServices/Server/PsesLanguageServer.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.IO; +using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -13,7 +14,6 @@ using Microsoft.PowerShell.EditorServices.Services.Template; using Newtonsoft.Json.Linq; using OmniSharp.Extensions.JsonRpc; -using OmniSharp.Extensions.LanguageServer.Protocol.Models; using OmniSharp.Extensions.LanguageServer.Protocol.Server; using OmniSharp.Extensions.LanguageServer.Server; using Serilog; @@ -130,12 +130,7 @@ public async Task StartAsync() WorkspaceService workspaceService = languageServer.Services.GetService(); if (initializeParams.WorkspaceFolders is not null) { - // TODO: Support multi-workspace. - foreach (WorkspaceFolder workspaceFolder in initializeParams.WorkspaceFolders) - { - workspaceService.WorkspacePath = workspaceFolder.Uri.GetFileSystemPath(); - break; - } + workspaceService.WorkspaceFolders.AddRange(initializeParams.WorkspaceFolders); } // Parse initialization options. @@ -149,13 +144,19 @@ public async Task StartAsync() // // NOTE: The keys start with a lowercase because OmniSharp's client // (used for testing) forces it to be that way. - LoadProfiles = initializationOptions?.GetValue("enableProfileLoading")?.Value() ?? true, - // TODO: Consider deprecating the setting which sets this and - // instead use WorkspacePath exclusively. - InitialWorkingDirectory = initializationOptions?.GetValue("initialWorkingDirectory")?.Value() ?? workspaceService.WorkspacePath, - ShellIntegrationEnabled = initializationOptions?.GetValue("shellIntegrationEnabled")?.Value() ?? false + LoadProfiles = initializationOptions?.GetValue("enableProfileLoading")?.Value() + ?? true, + // First check the setting, then use the first workspace folder, + // finally fall back to CWD. + InitialWorkingDirectory = initializationOptions?.GetValue("initialWorkingDirectory")?.Value() + ?? workspaceService.WorkspaceFolders.FirstOrDefault()?.Uri.GetFileSystemPath() + ?? Directory.GetCurrentDirectory(), + ShellIntegrationEnabled = initializationOptions?.GetValue("shellIntegrationEnabled")?.Value() + ?? false }; + workspaceService.InitialWorkingDirectory = hostStartOptions.InitialWorkingDirectory; + _psesHost = languageServer.Services.GetService(); return _psesHost.TryStartAsync(hostStartOptions, cancellationToken); }); diff --git a/src/PowerShellEditorServices/Services/Extension/EditorOperationsService.cs b/src/PowerShellEditorServices/Services/Extension/EditorOperationsService.cs index 0f0f2a01a..fa6da7f90 100644 --- a/src/PowerShellEditorServices/Services/Extension/EditorOperationsService.cs +++ b/src/PowerShellEditorServices/Services/Extension/EditorOperationsService.cs @@ -191,7 +191,9 @@ public async Task SaveFileAsync(string currentPath, string newSavePath) }).ReturningVoid(CancellationToken.None).ConfigureAwait(false); } - public string GetWorkspacePath() => _workspaceService.WorkspacePath; + // TODO: This should get the current editor's context and use it to determine which + // workspace it's in. + public string GetWorkspacePath() => _workspaceService.InitialWorkingDirectory; public string GetWorkspaceRelativePath(string filePath) => _workspaceService.GetRelativePath(filePath); diff --git a/src/PowerShellEditorServices/Services/Symbols/SymbolReference.cs b/src/PowerShellEditorServices/Services/Symbols/SymbolReference.cs index 2c6c6ef8f..5e36bf63b 100644 --- a/src/PowerShellEditorServices/Services/Symbols/SymbolReference.cs +++ b/src/PowerShellEditorServices/Services/Symbols/SymbolReference.cs @@ -66,5 +66,19 @@ public SymbolReference( } IsDeclaration = isDeclaration; } + + /// + /// This is only used for unit tests! + /// + internal SymbolReference(string id, SymbolType type) + { + Id = id; + Type = type; + Name = ""; + NameRegion = new("", "", 0, 0, 0, 0, 0, 0); + ScriptRegion = NameRegion; + SourceLine = ""; + FilePath = ""; + } } } diff --git a/src/PowerShellEditorServices/Services/Workspace/Handlers/ConfigurationHandler.cs b/src/PowerShellEditorServices/Services/Workspace/Handlers/ConfigurationHandler.cs index ea7214a0a..230a82d58 100644 --- a/src/PowerShellEditorServices/Services/Workspace/Handlers/ConfigurationHandler.cs +++ b/src/PowerShellEditorServices/Services/Workspace/Handlers/ConfigurationHandler.cs @@ -58,7 +58,7 @@ public override async Task Handle(DidChangeConfigurationParams request, Ca _configurationService.CurrentSettings.Update( incomingSettings.Powershell, - _workspaceService.WorkspacePath, + _workspaceService.InitialWorkingDirectory, _logger); // Run any events subscribed to configuration updates diff --git a/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs b/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs index 002a757ad..f705101f8 100644 --- a/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs +++ b/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs @@ -1,10 +1,11 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Security; using System.Text; using Microsoft.Extensions.FileSystemGlobbing; @@ -13,6 +14,7 @@ using Microsoft.PowerShell.EditorServices.Services.Workspace; using Microsoft.PowerShell.EditorServices.Utility; using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; namespace Microsoft.PowerShell.EditorServices.Services { @@ -58,9 +60,19 @@ internal class WorkspaceService #region Properties /// - /// Gets or sets the root path of the workspace. + /// Gets or sets the initial working directory. + /// + /// This is settable by the same key in the initialization options, and likely corresponds + /// to the root of the workspace if only one workspace folder is being used. However, in + /// multi-root workspaces this may be any workspace folder's root (or none if overridden). + /// /// - public string WorkspacePath { get; set; } + public string InitialWorkingDirectory { get; set; } + + /// + /// Gets or sets the folders of the workspace. + /// + public List WorkspaceFolders { get; set; } /// /// Gets or sets the default list of file globs to exclude during workspace searches. @@ -83,6 +95,7 @@ public WorkspaceService(ILoggerFactory factory) { powerShellVersion = VersionUtils.PSVersion; logger = factory.CreateLogger(); + WorkspaceFolders = new List(); ExcludeFilesGlob = new List(); FollowSymlinks = true; } @@ -299,9 +312,9 @@ public string GetRelativePath(string filePath) { string resolvedPath = filePath; - if (!IsPathInMemory(filePath) && !string.IsNullOrEmpty(WorkspacePath)) + if (!IsPathInMemory(filePath) && !string.IsNullOrEmpty(InitialWorkingDirectory)) { - Uri workspaceUri = new(WorkspacePath); + Uri workspaceUri = new(InitialWorkingDirectory); Uri fileUri = new(filePath); resolvedPath = workspaceUri.MakeRelativeUri(fileUri).ToString(); @@ -331,39 +344,46 @@ public IEnumerable EnumeratePSFiles() } /// - /// Enumerate all the PowerShell (ps1, psm1, psd1) files in the workspace in a recursive manner. + /// Enumerate all the PowerShell (ps1, psm1, psd1) files in the workspace folders in a + /// recursive manner. Falls back to initial working directory if there are no workspace folders. /// /// An enumerator over the PowerShell files found in the workspace. public IEnumerable EnumeratePSFiles( string[] excludeGlobs, string[] includeGlobs, int maxDepth, - bool ignoreReparsePoints - ) + bool ignoreReparsePoints) { - if (WorkspacePath is null || !Directory.Exists(WorkspacePath)) - { - yield break; - } + IEnumerable rootPaths = WorkspaceFolders.Count == 0 + ? new List { InitialWorkingDirectory } + : WorkspaceFolders.Select(i => i.Uri.GetFileSystemPath()); Matcher matcher = new(); foreach (string pattern in includeGlobs) { matcher.AddInclude(pattern); } foreach (string pattern in excludeGlobs) { matcher.AddExclude(pattern); } - WorkspaceFileSystemWrapperFactory fsFactory = new( - WorkspacePath, - maxDepth, - VersionUtils.IsNetCore ? s_psFileExtensionsCoreFramework : s_psFileExtensionsFullFramework, - ignoreReparsePoints, - logger - ); - PatternMatchingResult fileMatchResult = matcher.Execute(fsFactory.RootDirectory); - foreach (FilePatternMatch item in fileMatchResult.Files) + foreach (string rootPath in rootPaths) { - // item.Path always contains forward slashes in paths when it should be backslashes on Windows. - // Since we're returning strings here, it's important to use the correct directory separator. - string path = VersionUtils.IsWindows ? item.Path.Replace('/', Path.DirectorySeparatorChar) : item.Path; - yield return Path.Combine(WorkspacePath, path); + if (!Directory.Exists(rootPath)) + { + continue; + } + + WorkspaceFileSystemWrapperFactory fsFactory = new( + rootPath, + maxDepth, + VersionUtils.IsNetCore ? s_psFileExtensionsCoreFramework : s_psFileExtensionsFullFramework, + ignoreReparsePoints, + logger); + + PatternMatchingResult fileMatchResult = matcher.Execute(fsFactory.RootDirectory); + foreach (FilePatternMatch item in fileMatchResult.Files) + { + // item.Path always contains forward slashes in paths when it should be backslashes on Windows. + // Since we're returning strings here, it's important to use the correct directory separator. + string path = VersionUtils.IsWindows ? item.Path.Replace('/', Path.DirectorySeparatorChar) : item.Path; + yield return Path.Combine(rootPath, path); + } } } @@ -423,7 +443,7 @@ internal static bool IsPathInMemory(string filePath) return isInMemory; } - internal string ResolveWorkspacePath(string path) => ResolveRelativeScriptPath(WorkspacePath, path); + internal string ResolveWorkspacePath(string path) => ResolveRelativeScriptPath(InitialWorkingDirectory, path); internal string ResolveRelativeScriptPath(string baseFilePath, string relativePath) { diff --git a/test/PowerShellEditorServices.Test/Language/SymbolsServiceTests.cs b/test/PowerShellEditorServices.Test/Language/SymbolsServiceTests.cs index 257e36880..c633c6326 100644 --- a/test/PowerShellEditorServices.Test/Language/SymbolsServiceTests.cs +++ b/test/PowerShellEditorServices.Test/Language/SymbolsServiceTests.cs @@ -23,6 +23,8 @@ using Microsoft.PowerShell.EditorServices.Test.Shared.SymbolDetails; using Microsoft.PowerShell.EditorServices.Test.Shared.Symbols; using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; using Xunit; namespace PowerShellEditorServices.Test.Language @@ -38,10 +40,11 @@ public class SymbolsServiceTests : IDisposable public SymbolsServiceTests() { psesHost = PsesHostFactory.Create(NullLoggerFactory.Instance); - workspace = new WorkspaceService(NullLoggerFactory.Instance) + workspace = new WorkspaceService(NullLoggerFactory.Instance); + workspace.WorkspaceFolders.Add(new WorkspaceFolder { - WorkspacePath = TestUtilities.GetSharedPath("References") - }; + Uri = DocumentUri.FromFileSystemPath(TestUtilities.GetSharedPath("References")) + }); symbolsService = new SymbolsService( NullLoggerFactory.Instance, psesHost, @@ -226,6 +229,23 @@ public async Task FindsReferencesOnFunction() }); } + [Fact] + public async Task FindsReferenceAcrossMultiRootWorkspace() + { + workspace.WorkspaceFolders = new[] { "Debugging", "ParameterHints", "SymbolDetails" } + .Select(i => new WorkspaceFolder + { + Uri = DocumentUri.FromFileSystemPath(TestUtilities.GetSharedPath(i)) + }).ToList(); + + SymbolReference symbol = new("fn Get-Process", SymbolType.Function); + IEnumerable symbols = await symbolsService.ScanForReferencesOfSymbolAsync(symbol).ConfigureAwait(true); + Assert.Collection(symbols.OrderBy(i => i.FilePath), + i => Assert.EndsWith("VariableTest.ps1", i.FilePath), + i => Assert.EndsWith("ParamHints.ps1", i.FilePath), + i => Assert.EndsWith("SymbolDetails.ps1", i.FilePath)); + } + [Fact] public async Task FindsReferencesOnFunctionIncludingAliases() { diff --git a/test/PowerShellEditorServices.Test/Session/WorkspaceTests.cs b/test/PowerShellEditorServices.Test/Session/WorkspaceTests.cs index c76da5328..2c7a44279 100644 --- a/test/PowerShellEditorServices.Test/Session/WorkspaceTests.cs +++ b/test/PowerShellEditorServices.Test/Session/WorkspaceTests.cs @@ -39,7 +39,7 @@ public void CanResolveWorkspaceRelativePath() string expectedOutsidePath = TestUtilities.NormalizePath("../PeerPath/FilePath.ps1"); // Test with a workspace path - workspace.WorkspacePath = workspacePath; + workspace.InitialWorkingDirectory = workspacePath; Assert.Equal(expectedInsidePath, workspace.GetRelativePath(testPathInside)); Assert.Equal(expectedOutsidePath, workspace.GetRelativePath(testPathOutside)); Assert.Equal(testPathAnotherDrive, workspace.GetRelativePath(testPathAnotherDrive)); @@ -49,7 +49,7 @@ internal static WorkspaceService FixturesWorkspace() { return new WorkspaceService(NullLoggerFactory.Instance) { - WorkspacePath = TestUtilities.NormalizePath("Fixtures/Workspace") + InitialWorkingDirectory = TestUtilities.NormalizePath("Fixtures/Workspace") }; } @@ -94,10 +94,10 @@ public void CanRecurseDirectoryTree() List expected = new() { - Path.Combine(workspace.WorkspacePath, "nested", "donotfind.ps1"), - Path.Combine(workspace.WorkspacePath, "nested", "nestedmodule.psd1"), - Path.Combine(workspace.WorkspacePath, "nested", "nestedmodule.psm1"), - Path.Combine(workspace.WorkspacePath, "rootfile.ps1") + Path.Combine(workspace.InitialWorkingDirectory, "nested", "donotfind.ps1"), + Path.Combine(workspace.InitialWorkingDirectory, "nested", "nestedmodule.psd1"), + Path.Combine(workspace.InitialWorkingDirectory, "nested", "nestedmodule.psm1"), + Path.Combine(workspace.InitialWorkingDirectory, "rootfile.ps1") }; // .NET Core doesn't appear to use the same three letter pattern matching rule although the docs @@ -105,7 +105,7 @@ public void CanRecurseDirectoryTree() // ref https://docs.microsoft.com/en-us/dotnet/api/system.io.directory.getfiles?view=netcore-2.1#System_IO_Directory_GetFiles_System_String_System_String_System_IO_EnumerationOptions_ if (RuntimeInformation.FrameworkDescription.StartsWith(".NET Framework")) { - expected.Insert(3, Path.Combine(workspace.WorkspacePath, "other", "other.ps1xml")); + expected.Insert(3, Path.Combine(workspace.InitialWorkingDirectory, "other", "other.ps1xml")); } Assert.Equal(expected, actual); @@ -122,7 +122,7 @@ public void CanRecurseDirectoryTreeWithLimit() maxDepth: 1, ignoreReparsePoints: s_defaultIgnoreReparsePoints ); - Assert.Equal(new[] { Path.Combine(workspace.WorkspacePath, "rootfile.ps1") }, actual); + Assert.Equal(new[] { Path.Combine(workspace.InitialWorkingDirectory, "rootfile.ps1") }, actual); } [Fact] @@ -138,8 +138,8 @@ public void CanRecurseDirectoryTreeWithGlobs() ); Assert.Equal(new[] { - Path.Combine(workspace.WorkspacePath, "nested", "nestedmodule.psd1"), - Path.Combine(workspace.WorkspacePath, "rootfile.ps1") + Path.Combine(workspace.InitialWorkingDirectory, "nested", "nestedmodule.psd1"), + Path.Combine(workspace.InitialWorkingDirectory, "rootfile.ps1") }, actual); } @@ -181,7 +181,7 @@ public void CanDetermineIsPathInMemory() public void CanOpenAndCloseFile() { WorkspaceService workspace = FixturesWorkspace(); - string filePath = Path.GetFullPath(Path.Combine(workspace.WorkspacePath, "rootfile.ps1")); + string filePath = Path.GetFullPath(Path.Combine(workspace.InitialWorkingDirectory, "rootfile.ps1")); ScriptFile file = workspace.GetFile(filePath); Assert.Equal(workspace.GetOpenedFiles(), new[] { file });