diff --git a/NuGet.Config b/NuGet.Config index 6efc7f7b9..79196aec8 100644 --- a/NuGet.Config +++ b/NuGet.Config @@ -3,4 +3,7 @@ + + + diff --git a/PowerShellEditorServices.build.ps1 b/PowerShellEditorServices.build.ps1 index 91046afb7..c9c8cd17f 100644 --- a/PowerShellEditorServices.build.ps1 +++ b/PowerShellEditorServices.build.ps1 @@ -74,6 +74,24 @@ $script:RequiredBuildAssets = @{ 'Microsoft.PowerShell.EditorServices.Protocol.dll', 'Microsoft.PowerShell.EditorServices.Protocol.pdb' ) + + 'PowerShellEditorServices.Engine' = @( + 'publish/Microsoft.PowerShell.EditorServices.Engine.dll', + 'publish/Microsoft.PowerShell.EditorServices.Engine.pdb', + 'publish/OmniSharp.Extensions.JsonRpc.dll', + 'publish/OmniSharp.Extensions.LanguageProtocol.dll', + 'publish/OmniSharp.Extensions.LanguageServer.dll', + 'publish/Serilog.dll', + 'publish/Serilog.Extensions.Logging.dll', + 'publish/Serilog.Sinks.Console.dll', + 'publish/Microsoft.Extensions.DependencyInjection.Abstractions.dll', + 'publish/Microsoft.Extensions.DependencyInjection.dll', + 'publish/Microsoft.Extensions.Logging.Abstractions.dll', + 'publish/Microsoft.Extensions.Logging.dll', + 'publish/Microsoft.Extensions.Options.dll', + 'publish/Microsoft.Extensions.Primitives.dll', + 'publish/System.Reactive.dll' + ) } $script:VSCodeModuleBinPath = @{ @@ -102,12 +120,6 @@ $script:RequiredNugetBinaries = @{ @{ PackageName = 'System.Security.AccessControl'; PackageVersion = '4.5.0'; TargetRuntime = 'net461' }, @{ PackageName = 'System.IO.Pipes.AccessControl'; PackageVersion = '4.5.1'; TargetRuntime = 'net461' } ) - - '6.0' = @( - @{ PackageName = 'System.Security.Principal.Windows'; PackageVersion = '4.5.0'; TargetRuntime = 'netcoreapp2.0' }, - @{ PackageName = 'System.Security.AccessControl'; PackageVersion = '4.5.0'; TargetRuntime = 'netcoreapp2.0' }, - @{ PackageName = 'System.IO.Pipes.AccessControl'; PackageVersion = '4.5.1'; TargetRuntime = 'netstandard2.0' } - ) } if (Get-Command git -ErrorAction SilentlyContinue) { @@ -326,6 +338,7 @@ namespace Microsoft.PowerShell.EditorServices.Host task Build { exec { & $script:dotnetExe publish -c $Configuration .\src\PowerShellEditorServices\PowerShellEditorServices.csproj -f $script:TargetPlatform } + exec { & $script:dotnetExe publish -c $Configuration .\src\PowerShellEditorServices.Engine\PowerShellEditorServices.Engine.csproj -f $script:TargetPlatform } exec { & $script:dotnetExe publish -c $Configuration .\src\PowerShellEditorServices.Host\PowerShellEditorServices.Host.csproj -f $script:TargetPlatform } exec { & $script:dotnetExe build -c $Configuration .\src\PowerShellEditorServices.VSCode\PowerShellEditorServices.VSCode.csproj $script:TargetFrameworksParam } } diff --git a/PowerShellEditorServices.sln b/PowerShellEditorServices.sln index 6c3671bb0..fce19ffef 100644 --- a/PowerShellEditorServices.sln +++ b/PowerShellEditorServices.sln @@ -28,6 +28,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PowerShellEditorServices.Te EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PowerShellEditorServices.VSCode", "src\PowerShellEditorServices.VSCode\PowerShellEditorServices.VSCode.csproj", "{3B38E8DA-8BFF-4264-AF16-47929E6398A3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerShellEditorServices.Engine", "src\PowerShellEditorServices.Engine\PowerShellEditorServices.Engine.csproj", "{29EEDF03-0990-45F4-846E-2616970D1FA2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -134,6 +136,18 @@ Global {3B38E8DA-8BFF-4264-AF16-47929E6398A3}.Release|x64.Build.0 = Release|Any CPU {3B38E8DA-8BFF-4264-AF16-47929E6398A3}.Release|x86.ActiveCfg = Release|Any CPU {3B38E8DA-8BFF-4264-AF16-47929E6398A3}.Release|x86.Build.0 = Release|Any CPU + {29EEDF03-0990-45F4-846E-2616970D1FA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {29EEDF03-0990-45F4-846E-2616970D1FA2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {29EEDF03-0990-45F4-846E-2616970D1FA2}.Debug|x64.ActiveCfg = Debug|Any CPU + {29EEDF03-0990-45F4-846E-2616970D1FA2}.Debug|x64.Build.0 = Debug|Any CPU + {29EEDF03-0990-45F4-846E-2616970D1FA2}.Debug|x86.ActiveCfg = Debug|Any CPU + {29EEDF03-0990-45F4-846E-2616970D1FA2}.Debug|x86.Build.0 = Debug|Any CPU + {29EEDF03-0990-45F4-846E-2616970D1FA2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {29EEDF03-0990-45F4-846E-2616970D1FA2}.Release|Any CPU.Build.0 = Release|Any CPU + {29EEDF03-0990-45F4-846E-2616970D1FA2}.Release|x64.ActiveCfg = Release|Any CPU + {29EEDF03-0990-45F4-846E-2616970D1FA2}.Release|x64.Build.0 = Release|Any CPU + {29EEDF03-0990-45F4-846E-2616970D1FA2}.Release|x86.ActiveCfg = Release|Any CPU + {29EEDF03-0990-45F4-846E-2616970D1FA2}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -147,5 +161,6 @@ Global {F8A0946A-5D25-4651-8079-B8D5776916FB} = {F594E7FD-1E72-4E51-A496-B019C2BA3180} {E3A5CF5D-6E41-44AC-AE0A-4C227E4BACD4} = {422E561A-8118-4BE7-A54F-9309E4F03AAE} {3B38E8DA-8BFF-4264-AF16-47929E6398A3} = {F594E7FD-1E72-4E51-A496-B019C2BA3180} + {29EEDF03-0990-45F4-846E-2616970D1FA2} = {F594E7FD-1E72-4E51-A496-B019C2BA3180} EndGlobalSection EndGlobal diff --git a/module/PowerShellEditorServices/PowerShellEditorServices.psm1 b/module/PowerShellEditorServices/PowerShellEditorServices.psm1 index 47efd591f..2d05b73ea 100644 --- a/module/PowerShellEditorServices/PowerShellEditorServices.psm1 +++ b/module/PowerShellEditorServices/PowerShellEditorServices.psm1 @@ -8,15 +8,12 @@ if ($PSEdition -eq 'Desktop') { Microsoft.PowerShell.Utility\Add-Type -Path "$PSScriptRoot/bin/Desktop/System.IO.Pipes.AccessControl.dll" Microsoft.PowerShell.Utility\Add-Type -Path "$PSScriptRoot/bin/Desktop/System.Security.AccessControl.dll" Microsoft.PowerShell.Utility\Add-Type -Path "$PSScriptRoot/bin/Desktop/System.Security.Principal.Windows.dll" -} elseif ($PSVersionTable.PSVersion -ge '6.0' -and $PSVersionTable.PSVersion -lt '6.1' -and $IsWindows) { - Microsoft.PowerShell.Utility\Add-Type -Path "$PSScriptRoot/bin/6.0/System.IO.Pipes.AccessControl.dll" - Microsoft.PowerShell.Utility\Add-Type -Path "$PSScriptRoot/bin/6.0/System.Security.AccessControl.dll" - Microsoft.PowerShell.Utility\Add-Type -Path "$PSScriptRoot/bin/6.0/System.Security.Principal.Windows.dll" } Microsoft.PowerShell.Utility\Add-Type -Path "$PSScriptRoot/bin/Microsoft.PowerShell.EditorServices.dll" Microsoft.PowerShell.Utility\Add-Type -Path "$PSScriptRoot/bin/Microsoft.PowerShell.EditorServices.Host.dll" Microsoft.PowerShell.Utility\Add-Type -Path "$PSScriptRoot/bin/Microsoft.PowerShell.EditorServices.Protocol.dll" +Microsoft.PowerShell.Utility\Add-Type -Path "$PSScriptRoot/bin/Microsoft.PowerShell.EditorServices.Engine.dll" function Start-EditorServicesHost { [CmdletBinding()] @@ -97,13 +94,13 @@ function Start-EditorServicesHost { $editorServicesHost = $null $hostDetails = - Microsoft.PowerShell.Utility\New-Object Microsoft.PowerShell.EditorServices.Session.HostDetails @( + Microsoft.PowerShell.Utility\New-Object Microsoft.PowerShell.EditorServices.Engine.HostDetails @( $HostName, $HostProfileId, (Microsoft.PowerShell.Utility\New-Object System.Version @($HostVersion))) $editorServicesHost = - Microsoft.PowerShell.Utility\New-Object Microsoft.PowerShell.EditorServices.Host.EditorServicesHost @( + Microsoft.PowerShell.Utility\New-Object Microsoft.PowerShell.EditorServices.Engine.EditorServicesHost @( $hostDetails, $BundledModulesPath, $EnableConsoleRepl.IsPresent, @@ -114,7 +111,7 @@ function Start-EditorServicesHost { # Build the profile paths using the root paths of the current $profile variable $profilePaths = - Microsoft.PowerShell.Utility\New-Object Microsoft.PowerShell.EditorServices.Session.ProfilePaths @( + Microsoft.PowerShell.Utility\New-Object Microsoft.PowerShell.EditorServices.Engine.ProfilePaths @( $hostDetails.ProfileId, [System.IO.Path]::GetDirectoryName($profile.AllUsersAllHosts), [System.IO.Path]::GetDirectoryName($profile.CurrentUserAllHosts)) @@ -122,32 +119,32 @@ function Start-EditorServicesHost { $editorServicesHost.StartLogging($LogPath, $LogLevel); $languageServiceConfig = - Microsoft.PowerShell.Utility\New-Object Microsoft.PowerShell.EditorServices.Host.EditorServiceTransportConfig + Microsoft.PowerShell.Utility\New-Object Microsoft.PowerShell.EditorServices.Engine.EditorServiceTransportConfig $debugServiceConfig = - Microsoft.PowerShell.Utility\New-Object Microsoft.PowerShell.EditorServices.Host.EditorServiceTransportConfig + Microsoft.PowerShell.Utility\New-Object Microsoft.PowerShell.EditorServices.Engine.EditorServiceTransportConfig switch ($PSCmdlet.ParameterSetName) { "Stdio" { - $languageServiceConfig.TransportType = [Microsoft.PowerShell.EditorServices.Host.EditorServiceTransportType]::Stdio - $debugServiceConfig.TransportType = [Microsoft.PowerShell.EditorServices.Host.EditorServiceTransportType]::Stdio + $languageServiceConfig.TransportType = [Microsoft.PowerShell.EditorServices.Engine.EditorServiceTransportType]::Stdio + $debugServiceConfig.TransportType = [Microsoft.PowerShell.EditorServices.Engine.EditorServiceTransportType]::Stdio break } "NamedPipe" { - $languageServiceConfig.TransportType = [Microsoft.PowerShell.EditorServices.Host.EditorServiceTransportType]::NamedPipe + $languageServiceConfig.TransportType = [Microsoft.PowerShell.EditorServices.Engine.EditorServiceTransportType]::NamedPipe $languageServiceConfig.InOutPipeName = "$LanguageServiceNamedPipe" if ($DebugServiceNamedPipe) { - $debugServiceConfig.TransportType = [Microsoft.PowerShell.EditorServices.Host.EditorServiceTransportType]::NamedPipe + $debugServiceConfig.TransportType = [Microsoft.PowerShell.EditorServices.Engine.EditorServiceTransportType]::NamedPipe $debugServiceConfig.InOutPipeName = "$DebugServiceNamedPipe" } break } "NamedPipeSimplex" { - $languageServiceConfig.TransportType = [Microsoft.PowerShell.EditorServices.Host.EditorServiceTransportType]::NamedPipe + $languageServiceConfig.TransportType = [Microsoft.PowerShell.EditorServices.Engine.EditorServiceTransportType]::NamedPipe $languageServiceConfig.InPipeName = $LanguageServiceInNamedPipe $languageServiceConfig.OutPipeName = $LanguageServiceOutNamedPipe if ($DebugServiceInNamedPipe -and $DebugServiceOutNamedPipe) { - $debugServiceConfig.TransportType = [Microsoft.PowerShell.EditorServices.Host.EditorServiceTransportType]::NamedPipe + $debugServiceConfig.TransportType = [Microsoft.PowerShell.EditorServices.Engine.EditorServiceTransportType]::NamedPipe $debugServiceConfig.InPipeName = $DebugServiceInNamedPipe $debugServiceConfig.OutPipeName = $DebugServiceOutNamedPipe } diff --git a/src/PowerShellEditorServices.Engine/.vscode/launch.json b/src/PowerShellEditorServices.Engine/.vscode/launch.json new file mode 100644 index 000000000..8b60d4fab --- /dev/null +++ b/src/PowerShellEditorServices.Engine/.vscode/launch.json @@ -0,0 +1,42 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": ".NET Core Launch (console)", + "type": "coreclr", + "request": "launch", + "WARNING01": "*********************************************************************************", + "WARNING02": "The C# extension was unable to automatically to decode projects in the current", + "WARNING03": "workspace to create a runnable lanch.json file. A template launch.json file has", + "WARNING04": "been created as a placeholder.", + "WARNING05": "", + "WARNING06": "If OmniSharp is currently unable to load your project, you can attempt to resolve", + "WARNING07": "this by restoring any missing project dependencies (example: run 'dotnet restore')", + "WARNING08": "and by fixing any reported errors from building the projects in your workspace.", + "WARNING09": "If this allows OmniSharp to now load your project then --", + "WARNING10": " * Delete this file", + "WARNING11": " * Open the Visual Studio Code command palette (View->Command Palette)", + "WARNING12": " * run the command: '.NET: Generate Assets for Build and Debug'.", + "WARNING13": "", + "WARNING14": "If your project requires a more complex launch configuration, you may wish to delete", + "WARNING15": "this configuration and pick a different template using the 'Add Configuration...'", + "WARNING16": "button at the bottom of this file.", + "WARNING17": "*********************************************************************************", + "preLaunchTask": "build", + "program": "${workspaceFolder}/bin/Debug//.dll", + "args": [], + "cwd": "${workspaceFolder}", + "console": "internalConsole", + "stopAtEntry": false + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach", + "processId": "${command:pickProcess}" + } + ] +} \ No newline at end of file diff --git a/src/PowerShellEditorServices.Engine/BuildInfo.cs b/src/PowerShellEditorServices.Engine/BuildInfo.cs new file mode 100644 index 000000000..f808390e5 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/BuildInfo.cs @@ -0,0 +1,9 @@ +namespace Microsoft.PowerShell.EditorServices.Engine +{ + public static class BuildInfo + { + public const string BuildVersion = ""; + public const string BuildOrigin = ""; + public static readonly System.DateTime? BuildTime = null; + } +} diff --git a/src/PowerShellEditorServices.Engine/Hosting/EditorServicesHost.cs b/src/PowerShellEditorServices.Engine/Hosting/EditorServicesHost.cs new file mode 100644 index 000000000..9512b7918 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Hosting/EditorServicesHost.cs @@ -0,0 +1,345 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Diagnostics; +using System.Linq; +using System.Management.Automation; +using System.Management.Automation.Host; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Serilog; + +namespace Microsoft.PowerShell.EditorServices.Engine +{ + public enum EditorServicesHostStatus + { + Started, + Failed, + Ended + } + + public enum EditorServiceTransportType + { + NamedPipe, + Stdio + } + + public class EditorServiceTransportConfig + { + public EditorServiceTransportType TransportType { get; set; } + /// + /// Configures the endpoint of the transport. + /// For Stdio it's ignored. + /// For NamedPipe it's the pipe name. + /// + public string InOutPipeName { get; set; } + + public string OutPipeName { get; set; } + + public string InPipeName { get; set; } + + internal string Endpoint => OutPipeName != null && InPipeName != null ? $"In pipe: {InPipeName} Out pipe: {OutPipeName}" : $" InOut pipe: {InOutPipeName}"; + } + + /// + /// Provides a simplified interface for hosting the language and debug services + /// over the named pipe server protocol. + /// + public class EditorServicesHost + { + #region Private Fields + + private readonly IServiceCollection _serviceCollection; + + private readonly HostDetails _hostDetails; + + private ILanguageServer _languageServer; + + private readonly Extensions.Logging.ILogger _logger; + + private readonly ILoggerFactory _factory; + + #endregion + + #region Properties + + public EditorServicesHostStatus Status { get; private set; } + + #endregion + + #region Constructors + + /// + /// Initializes a new instance of the EditorServicesHost class and waits for + /// the debugger to attach if waitForDebugger is true. + /// + /// The details of the host which is launching PowerShell Editor Services. + /// Provides a path to PowerShell modules bundled with the host, if any. Null otherwise. + /// If true, causes the host to wait for the debugger to attach before proceeding. + /// Modules to be loaded when initializing the new runspace. + /// Features to enable for this instance. + public EditorServicesHost( + HostDetails hostDetails, + string bundledModulesPath, + bool enableConsoleRepl, + bool waitForDebugger, + string[] additionalModules, + string[] featureFlags) + : this( + hostDetails, + bundledModulesPath, + enableConsoleRepl, + waitForDebugger, + additionalModules, + featureFlags, + GetInternalHostFromDefaultRunspace()) + { + } + + /// + /// Initializes a new instance of the EditorServicesHost class and waits for + /// the debugger to attach if waitForDebugger is true. + /// + /// The details of the host which is launching PowerShell Editor Services. + /// Provides a path to PowerShell modules bundled with the host, if any. Null otherwise. + /// If true, causes the host to wait for the debugger to attach before proceeding. + /// Modules to be loaded when initializing the new runspace. + /// Features to enable for this instance. + /// The value of the $Host variable in the original runspace. + public EditorServicesHost( + HostDetails hostDetails, + string bundledModulesPath, + bool enableConsoleRepl, + bool waitForDebugger, + string[] additionalModules, + string[] featureFlags, + PSHost internalHost) + { + Validate.IsNotNull(nameof(hostDetails), hostDetails); + Validate.IsNotNull(nameof(internalHost), internalHost); + + _serviceCollection = new ServiceCollection(); + + Log.Logger = new LoggerConfiguration().Enrich.FromLogContext() + .WriteTo.Console() + .CreateLogger(); + _factory = new LoggerFactory().AddSerilog(Log.Logger); + _logger = _factory.CreateLogger(); + + _hostDetails = hostDetails; + + /* + this.hostDetails = hostDetails; + this.enableConsoleRepl = enableConsoleRepl; + this.bundledModulesPath = bundledModulesPath; + this.additionalModules = additionalModules ?? Array.Empty(); + this.featureFlags = new HashSet(featureFlags ?? Array.Empty(); + this.serverCompletedTask = new TaskCompletionSource(); + this.internalHost = internalHost; + */ + +#if DEBUG + if (waitForDebugger) + { + if (System.Diagnostics.Debugger.IsAttached) + { + System.Diagnostics.Debugger.Break(); + } + else + { + System.Diagnostics.Debugger.Launch(); + } + } +#endif + + // Catch unhandled exceptions for logging purposes + AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; + } + + #endregion + + #region Public Methods + + /// + /// Starts the Logger for the specified file path and log level. + /// + /// The path of the log file to be written. + /// The minimum level of log messages to be written. + public void StartLogging(string logFilePath, PsesLogLevel logLevel) + { + FileVersionInfo fileVersionInfo = + FileVersionInfo.GetVersionInfo(this.GetType().GetTypeInfo().Assembly.Location); + + string osVersion = RuntimeInformation.OSDescription; + + string osArch = GetOSArchitecture(); + + string buildTime = BuildInfo.BuildTime?.ToString("s", System.Globalization.CultureInfo.InvariantCulture) ?? ""; + + string logHeader = $@" +PowerShell Editor Services Host v{fileVersionInfo.FileVersion} starting (PID {Process.GetCurrentProcess().Id} + + Host application details: + + Name: {_hostDetails.Name} + Version: {_hostDetails.Version} + ProfileId: {_hostDetails.ProfileId} + Arch: {osArch} + + Operating system details: + + Version: {osVersion} + Arch: {osArch} + + Build information: + + Version: {BuildInfo.BuildVersion} + Origin: {BuildInfo.BuildOrigin} + Date: {buildTime} +"; + + _logger.LogInformation(logHeader); + } + + /// + /// Starts the language service with the specified config. + /// + /// The config that contains information on the communication protocol that will be used. + /// The profiles that will be loaded in the session. + public void StartLanguageService( + EditorServiceTransportConfig config, + ProfilePaths profilePaths) + { + while (System.Diagnostics.Debugger.IsAttached) + { + Console.WriteLine($"{Process.GetCurrentProcess().Id}"); + Thread.Sleep(2000); + } + + _logger.LogInformation($"LSP NamedPipe: {config.InOutPipeName}\nLSP OutPipe: {config.OutPipeName}"); + + _serviceCollection.AddSingleton(); + _serviceCollection.AddSingleton(); + _serviceCollection.AddSingleton(); + _serviceCollection.AddSingleton( + (provider) => { + return AnalysisService.Create( + provider.GetService(), + provider.GetService(), + _factory.CreateLogger()); + } + ); + + _languageServer = new OmnisharpLanguageServerBuilder(_serviceCollection) + { + NamedPipeName = config.InOutPipeName ?? config.InPipeName, + OutNamedPipeName = config.OutPipeName, + LoggerFactory = _factory, + MinimumLogLevel = LogLevel.Trace, + } + .BuildLanguageServer(); + + _logger.LogInformation("Starting language server"); + + Task.Factory.StartNew(() => _languageServer.StartAsync(), + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + + _logger.LogInformation( + string.Format( + "Language service started, type = {0}, endpoint = {1}", + config.TransportType, config.Endpoint)); + } + + /// + /// Starts the debug service with the specified config. + /// + /// The config that contains information on the communication protocol that will be used. + /// The profiles that will be loaded in the session. + /// Determines if we will reuse the session that we have. + public void StartDebugService( + EditorServiceTransportConfig config, + ProfilePaths profilePaths, + bool useExistingSession) + { + /* + this.debugServiceListener = CreateServiceListener(MessageProtocolType.DebugAdapter, config); + this.debugServiceListener.ClientConnect += OnDebugServiceClientConnect; + this.debugServiceListener.Start(); + + this.logger.Write( + LogLevel.Normal, + string.Format( + "Debug service started, type = {0}, endpoint = {1}", + config.TransportType, config.Endpoint)); + */ + } + + /// + /// Stops the language or debug services if either were started. + /// + public void StopServices() + { + // TODO: Need a new way to shut down the services + } + + /// + /// Waits for either the language or debug service to shut down. + /// + public void WaitForCompletion() + { + // TODO: We need a way to know when to complete this task! + _languageServer.WaitForShutdown().Wait(); + } + + #endregion + + #region Private Methods + + private static PSHost GetInternalHostFromDefaultRunspace() + { + using (var pwsh = System.Management.Automation.PowerShell.Create(RunspaceMode.CurrentRunspace)) + { + return pwsh.AddScript("$Host").Invoke().First(); + } + } + + /// + /// Gets the OSArchitecture for logging. Cannot use System.Runtime.InteropServices.RuntimeInformation.OSArchitecture + /// directly, since this tries to load API set DLLs in win7 and crashes. + /// + private string GetOSArchitecture() + { + // If on win7 (version 6.1.x), avoid System.Runtime.InteropServices.RuntimeInformation + if (Environment.OSVersion.Platform == PlatformID.Win32NT && Environment.OSVersion.Version < new Version(6, 2)) + { + if (Environment.Is64BitProcess) + { + return "X64"; + } + + return "X86"; + } + + return RuntimeInformation.OSArchitecture.ToString(); + } + + private void CurrentDomain_UnhandledException( + object sender, + UnhandledExceptionEventArgs e) + { + // Log the exception + _logger.LogError($"FATAL UNHANDLED EXCEPTION: {e.ExceptionObject}"); + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices.Engine/Hosting/HostDetails.cs b/src/PowerShellEditorServices.Engine/Hosting/HostDetails.cs new file mode 100644 index 000000000..febaaf7c8 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Hosting/HostDetails.cs @@ -0,0 +1,92 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; + +namespace Microsoft.PowerShell.EditorServices.Engine +{ + /// + /// Contains details about the current host application (most + /// likely the editor which is using the host process). + /// + public class HostDetails + { + #region Constants + + /// + /// The default host name for PowerShell Editor Services. Used + /// if no host name is specified by the host application. + /// + public const string DefaultHostName = "PowerShell Editor Services Host"; + + /// + /// The default host ID for PowerShell Editor Services. Used + /// for the host-specific profile path if no host ID is specified. + /// + public const string DefaultHostProfileId = "Microsoft.PowerShellEditorServices"; + + /// + /// The default host version for PowerShell Editor Services. If + /// no version is specified by the host application, we use 0.0.0 + /// to indicate a lack of version. + /// + public static readonly Version DefaultHostVersion = new Version("0.0.0"); + + /// + /// The default host details in a HostDetails object. + /// + public static readonly HostDetails Default = new HostDetails(null, null, null); + + #endregion + + #region Properties + + /// + /// Gets the name of the host. + /// + public string Name { get; private set; } + + /// + /// Gets the profile ID of the host, used to determine the + /// host-specific profile path. + /// + public string ProfileId { get; private set; } + + /// + /// Gets the version of the host. + /// + public Version Version { get; private set; } + + #endregion + + #region Constructors + + /// + /// Creates an instance of the HostDetails class. + /// + /// + /// The display name for the host, typically in the form of + /// "[Application Name] Host". + /// + /// + /// The identifier of the PowerShell host to use for its profile path. + /// loaded. Used to resolve a profile path of the form 'X_profile.ps1' + /// where 'X' represents the value of hostProfileId. If null, a default + /// will be used. + /// + /// The host application's version. + public HostDetails( + string name, + string profileId, + Version version) + { + this.Name = name ?? DefaultHostName; + this.ProfileId = profileId ?? DefaultHostProfileId; + this.Version = version ?? DefaultHostVersion; + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices.Engine/Hosting/LogLevel.cs b/src/PowerShellEditorServices.Engine/Hosting/LogLevel.cs new file mode 100644 index 000000000..dfd50ffaf --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Hosting/LogLevel.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Logging; + +namespace Microsoft.PowerShell.EditorServices.Engine +{ + public enum PsesLogLevel + { + Diagnostic, + Verbose, + Normal, + Warning, + Error, + } + + internal static class PsesLogLevelExtensions + { + public static LogLevel ToExtensionsLogLevel(this PsesLogLevel logLevel) + { + switch (logLevel) + { + case PsesLogLevel.Diagnostic: + return LogLevel.Trace; + + case PsesLogLevel.Verbose: + return LogLevel.Debug; + + case PsesLogLevel.Normal: + return LogLevel.Information; + + case PsesLogLevel.Warning: + return LogLevel.Warning; + + case PsesLogLevel.Error: + return LogLevel.Error; + + default: + return LogLevel.Information; + } + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Hosting/ProfilePaths.cs b/src/PowerShellEditorServices.Engine/Hosting/ProfilePaths.cs new file mode 100644 index 000000000..29bab2b56 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Hosting/ProfilePaths.cs @@ -0,0 +1,110 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Management.Automation.Runspaces; + +namespace Microsoft.PowerShell.EditorServices.Engine +{ + /// + /// Provides profile path resolution behavior relative to the name + /// of a particular PowerShell host. + /// + public class ProfilePaths + { + #region Constants + + /// + /// The file name for the "all hosts" profile. Also used as the + /// suffix for the host-specific profile filenames. + /// + public const string AllHostsProfileName = "profile.ps1"; + + #endregion + + #region Properties + + /// + /// Gets the profile path for all users, all hosts. + /// + public string AllUsersAllHosts { get; private set; } + + /// + /// Gets the profile path for all users, current host. + /// + public string AllUsersCurrentHost { get; private set; } + + /// + /// Gets the profile path for the current user, all hosts. + /// + public string CurrentUserAllHosts { get; private set; } + + /// + /// Gets the profile path for the current user and host. + /// + public string CurrentUserCurrentHost { get; private set; } + + #endregion + + #region Public Methods + + /// + /// Creates a new instance of the ProfilePaths class. + /// + /// + /// The identifier of the host used in the host-specific X_profile.ps1 filename. + /// + /// The base path to use for constructing AllUsers profile paths. + /// The base path to use for constructing CurrentUser profile paths. + public ProfilePaths( + string hostProfileId, + string baseAllUsersPath, + string baseCurrentUserPath) + { + this.Initialize(hostProfileId, baseAllUsersPath, baseCurrentUserPath); + } + + private void Initialize( + string hostProfileId, + string baseAllUsersPath, + string baseCurrentUserPath) + { + string currentHostProfileName = + string.Format( + "{0}_{1}", + hostProfileId, + AllHostsProfileName); + + this.AllUsersCurrentHost = Path.Combine(baseAllUsersPath, currentHostProfileName); + this.CurrentUserCurrentHost = Path.Combine(baseCurrentUserPath, currentHostProfileName); + this.AllUsersAllHosts = Path.Combine(baseAllUsersPath, AllHostsProfileName); + this.CurrentUserAllHosts = Path.Combine(baseCurrentUserPath, AllHostsProfileName); + } + + /// + /// Gets the list of profile paths that exist on the filesystem. + /// + /// An IEnumerable of profile path strings to be loaded. + public IEnumerable GetLoadableProfilePaths() + { + var profilePaths = + new string[] + { + this.AllUsersAllHosts, + this.AllUsersCurrentHost, + this.CurrentUserAllHosts, + this.CurrentUserCurrentHost + }; + + return profilePaths.Where(p => File.Exists(p)); + } + + #endregion + } +} + diff --git a/src/PowerShellEditorServices.Engine/Interface/ILanguageServer.cs b/src/PowerShellEditorServices.Engine/Interface/ILanguageServer.cs new file mode 100644 index 000000000..07605468c --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Interface/ILanguageServer.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; + +namespace Microsoft.PowerShell.EditorServices.Engine +{ + public interface ILanguageServer + { + Task StartAsync(); + + Task WaitForShutdown(); + } +} diff --git a/src/PowerShellEditorServices.Engine/Interface/ILanguageServerBuilder.cs b/src/PowerShellEditorServices.Engine/Interface/ILanguageServerBuilder.cs new file mode 100644 index 000000000..0b5801b36 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Interface/ILanguageServerBuilder.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.Logging; + +namespace Microsoft.PowerShell.EditorServices.Engine +{ + public interface ILanguageServerBuilder + { + string NamedPipeName { get; set; } + + string OutNamedPipeName { get; set; } + + ILoggerFactory LoggerFactory { get; set; } + + LogLevel MinimumLogLevel { get; set; } + + ILanguageServer BuildLanguageServer(); + } +} diff --git a/src/PowerShellEditorServices.Engine/LanguageServer/OmnisharpLanguageServer.cs b/src/PowerShellEditorServices.Engine/LanguageServer/OmnisharpLanguageServer.cs new file mode 100644 index 000000000..73a6d8186 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/LanguageServer/OmnisharpLanguageServer.cs @@ -0,0 +1,165 @@ +using System.IO.Pipes; +using System.Reflection; +using System.Threading.Tasks; +using System.Security.Principal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OS = OmniSharp.Extensions.LanguageServer.Server; +using System.Security.AccessControl; +using OmniSharp.Extensions.LanguageServer.Server; +using PowerShellEditorServices.Engine.Services.Handlers; + +namespace Microsoft.PowerShell.EditorServices.Engine +{ + public class OmnisharpLanguageServer : ILanguageServer + { + public class Configuration + { + public string NamedPipeName { get; set; } + + public string OutNamedPipeName { get; set; } + + public ILoggerFactory LoggerFactory { get; set; } + + public LogLevel MinimumLogLevel { get; set; } + + public IServiceCollection Services { get; set; } + } + + // This int will be casted to a PipeOptions enum that only exists in .NET Core 2.1 and up which is why it's not available to us in .NET Standard. + private const int CurrentUserOnly = 0x20000000; + + // In .NET Framework, NamedPipeServerStream has a constructor that takes in a PipeSecurity object. We will use reflection to call the constructor, + // since .NET Framework doesn't have the `CurrentUserOnly` PipeOption. + // doc: https://docs.microsoft.com/en-us/dotnet/api/system.io.pipes.namedpipeserverstream.-ctor?view=netframework-4.7.2#System_IO_Pipes_NamedPipeServerStream__ctor_System_String_System_IO_Pipes_PipeDirection_System_Int32_System_IO_Pipes_PipeTransmissionMode_System_IO_Pipes_PipeOptions_System_Int32_System_Int32_System_IO_Pipes_PipeSecurity_ + private static readonly ConstructorInfo s_netFrameworkPipeServerConstructor = + typeof(NamedPipeServerStream).GetConstructor(new [] { typeof(string), typeof(PipeDirection), typeof(int), typeof(PipeTransmissionMode), typeof(PipeOptions), typeof(int), typeof(int), typeof(PipeSecurity) }); + + private OS.ILanguageServer _languageServer; + + private TaskCompletionSource _serverStart; + + private readonly Configuration _configuration; + + public OmnisharpLanguageServer( + Configuration configuration) + { + _configuration = configuration; + _serverStart = new TaskCompletionSource(); + } + + public async Task StartAsync() + { + _languageServer = await OS.LanguageServer.From(options => { + NamedPipeServerStream namedPipe = CreateNamedPipe( + _configuration.NamedPipeName, + _configuration.OutNamedPipeName, + out NamedPipeServerStream outNamedPipe); + + namedPipe.WaitForConnection(); + if (outNamedPipe != null) + { + outNamedPipe.WaitForConnection(); + } + + options.Input = namedPipe; + options.Output = outNamedPipe ?? namedPipe; + + options.LoggerFactory = _configuration.LoggerFactory; + options.MinimumLogLevel = _configuration.MinimumLogLevel; + options.Services = _configuration.Services; + options + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler(); + }); + + _serverStart.SetResult(true); + } + + public async Task WaitForShutdown() + { + await _serverStart.Task; + await _languageServer.WaitForExit; + } + + private static NamedPipeServerStream CreateNamedPipe( + string inOutPipeName, + string outPipeName, + out NamedPipeServerStream outPipe) + { + // .NET Core implementation is simplest so try that first + if (VersionUtils.IsNetCore) + { + outPipe = outPipeName == null + ? null + : new NamedPipeServerStream( + pipeName: outPipeName, + direction: PipeDirection.Out, + maxNumberOfServerInstances: 1, + transmissionMode: PipeTransmissionMode.Byte, + options: (PipeOptions)CurrentUserOnly); + + return new NamedPipeServerStream( + pipeName: inOutPipeName, + direction: PipeDirection.InOut, + maxNumberOfServerInstances: 1, + transmissionMode: PipeTransmissionMode.Byte, + options: PipeOptions.Asynchronous | (PipeOptions)CurrentUserOnly); + } + + // Now deal with Windows PowerShell + // We need to use reflection to get a nice constructor + + var pipeSecurity = new PipeSecurity(); + + WindowsIdentity identity = WindowsIdentity.GetCurrent(); + WindowsPrincipal principal = new WindowsPrincipal(identity); + + if (principal.IsInRole(WindowsBuiltInRole.Administrator)) + { + // Allow the Administrators group full access to the pipe. + pipeSecurity.AddAccessRule(new PipeAccessRule( + new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null).Translate(typeof(NTAccount)), + PipeAccessRights.FullControl, AccessControlType.Allow)); + } + else + { + // Allow the current user read/write access to the pipe. + pipeSecurity.AddAccessRule(new PipeAccessRule( + WindowsIdentity.GetCurrent().User, + PipeAccessRights.ReadWrite, AccessControlType.Allow)); + } + + outPipe = outPipeName == null + ? null + : (NamedPipeServerStream)s_netFrameworkPipeServerConstructor.Invoke( + new object[] { + outPipeName, + PipeDirection.InOut, + 1, // maxNumberOfServerInstances + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous, + 1024, // inBufferSize + 1024, // outBufferSize + pipeSecurity + }); + + return (NamedPipeServerStream)s_netFrameworkPipeServerConstructor.Invoke( + new object[] { + inOutPipeName, + PipeDirection.InOut, + 1, // maxNumberOfServerInstances + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous, + 1024, // inBufferSize + 1024, // outBufferSize + pipeSecurity + }); + } + } +} diff --git a/src/PowerShellEditorServices.Engine/LanguageServer/OmnisharpLanguageServerBuilder.cs b/src/PowerShellEditorServices.Engine/LanguageServer/OmnisharpLanguageServerBuilder.cs new file mode 100644 index 000000000..801f74037 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/LanguageServer/OmnisharpLanguageServerBuilder.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.PowerShell.EditorServices.Engine +{ + public class OmnisharpLanguageServerBuilder : ILanguageServerBuilder + { + public OmnisharpLanguageServerBuilder(IServiceCollection serviceCollection) + { + Services = serviceCollection; + } + + public string NamedPipeName { get; set; } + + public string OutNamedPipeName { get; set; } + + public ILoggerFactory LoggerFactory { get; set; } = new LoggerFactory(); + + public LogLevel MinimumLogLevel { get; set; } = LogLevel.Trace; + + public IServiceCollection Services { get; } + + public ILanguageServer BuildLanguageServer() + { + var config = new OmnisharpLanguageServer.Configuration() + { + LoggerFactory = LoggerFactory, + MinimumLogLevel = MinimumLogLevel, + NamedPipeName = NamedPipeName, + OutNamedPipeName = OutNamedPipeName, + Services = Services + }; + + return new OmnisharpLanguageServer(config); + } + } +} diff --git a/src/PowerShellEditorServices.Engine/LanguageServerSettings.cs b/src/PowerShellEditorServices.Engine/LanguageServerSettings.cs new file mode 100644 index 000000000..26eb0e9a5 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/LanguageServerSettings.cs @@ -0,0 +1,381 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Security; +using Microsoft.Extensions.Logging; + +namespace Microsoft.PowerShell.EditorServices.Engine +{ + public class LanguageServerSettings + { + public bool EnableProfileLoading { get; set; } + + public ScriptAnalysisSettings ScriptAnalysis { get; set; } + + public CodeFormattingSettings CodeFormatting { get; set; } + + public CodeFoldingSettings CodeFolding { get; set; } + + public LanguageServerSettings() + { + this.ScriptAnalysis = new ScriptAnalysisSettings(); + this.CodeFormatting = new CodeFormattingSettings(); + this.CodeFolding = new CodeFoldingSettings(); + } + + public void Update( + LanguageServerSettings settings, + string workspaceRootPath, + ILogger logger) + { + if (settings != null) + { + this.EnableProfileLoading = settings.EnableProfileLoading; + this.ScriptAnalysis.Update( + settings.ScriptAnalysis, + workspaceRootPath, + logger); + this.CodeFormatting = new CodeFormattingSettings(settings.CodeFormatting); + this.CodeFolding.Update(settings.CodeFolding, logger); + } + } + } + + public class ScriptAnalysisSettings + { + public bool? Enable { get; set; } + + public string SettingsPath { get; set; } + + public ScriptAnalysisSettings() + { + this.Enable = true; + } + + public void Update( + ScriptAnalysisSettings settings, + string workspaceRootPath, + ILogger logger) + { + if (settings != null) + { + this.Enable = settings.Enable; + + string settingsPath = settings.SettingsPath; + + try + { + if (string.IsNullOrWhiteSpace(settingsPath)) + { + settingsPath = null; + } + else if (!Path.IsPathRooted(settingsPath)) + { + if (string.IsNullOrEmpty(workspaceRootPath)) + { + // The workspace root path could be an empty string + // when the user has opened a PowerShell script file + // without opening an entire folder (workspace) first. + // In this case we should just log an error and let + // the specified settings path go through even though + // it will fail to load. + logger.LogError( + "Could not resolve Script Analyzer settings path due to null or empty workspaceRootPath."); + } + else + { + settingsPath = Path.GetFullPath(Path.Combine(workspaceRootPath, settingsPath)); + } + } + + this.SettingsPath = settingsPath; + logger.LogDebug($"Using Script Analyzer settings path - '{settingsPath ?? ""}'."); + } + catch (Exception ex) when ( + ex is NotSupportedException || + ex is PathTooLongException || + ex is SecurityException) + { + // Invalid chars in path like ${env:HOME} can cause Path.GetFullPath() to throw, catch such errors here + logger.LogException( + $"Invalid Script Analyzer settings path - '{settingsPath}'.", + ex); + + this.SettingsPath = null; + } + } + } + } + + /// + /// Code formatting presets. + /// See https://en.wikipedia.org/wiki/Indent_style for details on indent and brace styles. + /// + public enum CodeFormattingPreset + { + /// + /// Use the formatting settings as-is. + /// + Custom, + + /// + /// Configure the formatting settings to resemble the Allman indent/brace style. + /// + Allman, + + /// + /// Configure the formatting settings to resemble the one true brace style variant of K&R indent/brace style. + /// + OTBS, + + /// + /// Configure the formatting settings to resemble the Stroustrup brace style variant of K&R indent/brace style. + /// + Stroustrup + } + + /// + /// Multi-line pipeline style settings. + /// + public enum PipelineIndentationStyle + { + /// + /// After the indentation level only once after the first pipeline and keep this level for the following pipelines. + /// + IncreaseIndentationForFirstPipeline, + + /// + /// After every pipeline, keep increasing the indentation. + /// + IncreaseIndentationAfterEveryPipeline, + + /// + /// Do not increase indentation level at all after pipeline. + /// + NoIndentation + } + + public class CodeFormattingSettings + { + /// + /// Default constructor. + /// > + public CodeFormattingSettings() + { + + } + + /// + /// Copy constructor. + /// + /// An instance of type CodeFormattingSettings. + public CodeFormattingSettings(CodeFormattingSettings codeFormattingSettings) + { + if (codeFormattingSettings == null) + { + throw new ArgumentNullException(nameof(codeFormattingSettings)); + } + + foreach (var prop in this.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + prop.SetValue(this, prop.GetValue(codeFormattingSettings)); + } + } + + public CodeFormattingPreset Preset { get; set; } + public bool OpenBraceOnSameLine { get; set; } + public bool NewLineAfterOpenBrace { get; set; } + public bool NewLineAfterCloseBrace { get; set; } + public PipelineIndentationStyle PipelineIndentationStyle { get; set; } + public bool WhitespaceBeforeOpenBrace { get; set; } + public bool WhitespaceBeforeOpenParen { get; set; } + public bool WhitespaceAroundOperator { get; set; } + public bool WhitespaceAfterSeparator { get; set; } + public bool WhitespaceInsideBrace { get; set; } + public bool WhitespaceAroundPipe { get; set; } + public bool IgnoreOneLineBlock { get; set; } + public bool AlignPropertyValuePairs { get; set; } + public bool UseCorrectCasing { get; set; } + + + /// + /// Get the settings hashtable that will be consumed by PSScriptAnalyzer. + /// + /// The tab size in the number spaces. + /// If true, insert spaces otherwise insert tabs for indentation. + /// + public Hashtable GetPSSASettingsHashtable( + int tabSize, + bool insertSpaces) + { + var settings = GetCustomPSSASettingsHashtable(tabSize, insertSpaces); + var ruleSettings = (Hashtable)(settings["Rules"]); + var closeBraceSettings = (Hashtable)ruleSettings["PSPlaceCloseBrace"]; + var openBraceSettings = (Hashtable)ruleSettings["PSPlaceOpenBrace"]; + switch(Preset) + { + case CodeFormattingPreset.Allman: + openBraceSettings["OnSameLine"] = false; + openBraceSettings["NewLineAfter"] = true; + closeBraceSettings["NewLineAfter"] = true; + break; + + case CodeFormattingPreset.OTBS: + openBraceSettings["OnSameLine"] = true; + openBraceSettings["NewLineAfter"] = true; + closeBraceSettings["NewLineAfter"] = false; + break; + + case CodeFormattingPreset.Stroustrup: + openBraceSettings["OnSameLine"] = true; + openBraceSettings["NewLineAfter"] = true; + closeBraceSettings["NewLineAfter"] = true; + break; + + default: + break; + } + + return settings; + } + + private Hashtable GetCustomPSSASettingsHashtable(int tabSize, bool insertSpaces) + { + return new Hashtable + { + {"IncludeRules", new string[] { + "PSPlaceCloseBrace", + "PSPlaceOpenBrace", + "PSUseConsistentWhitespace", + "PSUseConsistentIndentation", + "PSAlignAssignmentStatement" + }}, + {"Rules", new Hashtable { + {"PSPlaceOpenBrace", new Hashtable { + {"Enable", true}, + {"OnSameLine", OpenBraceOnSameLine}, + {"NewLineAfter", NewLineAfterOpenBrace}, + {"IgnoreOneLineBlock", IgnoreOneLineBlock} + }}, + {"PSPlaceCloseBrace", new Hashtable { + {"Enable", true}, + {"NewLineAfter", NewLineAfterCloseBrace}, + {"IgnoreOneLineBlock", IgnoreOneLineBlock} + }}, + {"PSUseConsistentIndentation", new Hashtable { + {"Enable", true}, + {"IndentationSize", tabSize}, + {"PipelineIndentation", PipelineIndentationStyle }, + {"Kind", insertSpaces ? "space" : "tab"} + }}, + {"PSUseConsistentWhitespace", new Hashtable { + {"Enable", true}, + {"CheckOpenBrace", WhitespaceBeforeOpenBrace}, + {"CheckOpenParen", WhitespaceBeforeOpenParen}, + {"CheckOperator", WhitespaceAroundOperator}, + {"CheckSeparator", WhitespaceAfterSeparator}, + {"CheckInnerBrace", WhitespaceInsideBrace}, + {"CheckPipe", WhitespaceAroundPipe}, + }}, + {"PSAlignAssignmentStatement", new Hashtable { + {"Enable", true}, + {"CheckHashtable", AlignPropertyValuePairs} + }}, + {"PSUseCorrectCasing", new Hashtable { + {"Enable", UseCorrectCasing} + }}, + }} + }; + } + } + + /// + /// Code folding settings + /// + public class CodeFoldingSettings + { + /// + /// Whether the folding is enabled. Default is true as per VSCode + /// + public bool Enable { get; set; } = true; + + /// + /// Whether to show or hide the last line of a folding region. Default is true as per VSCode + /// + public bool ShowLastLine { get; set; } = true; + + /// + /// Update these settings from another settings object + /// + public void Update( + CodeFoldingSettings settings, + ILogger logger) + { + if (settings != null) { + if (this.Enable != settings.Enable) { + this.Enable = settings.Enable; + logger.LogDebug(string.Format("Using Code Folding Enabled - {0}", this.Enable)); + } + if (this.ShowLastLine != settings.ShowLastLine) { + this.ShowLastLine = settings.ShowLastLine; + logger.LogDebug(string.Format("Using Code Folding ShowLastLine - {0}", this.ShowLastLine)); + } + } + } + } + + /// + /// Additional settings from the Language Client that affect Language Server operations but + /// do not exist under the 'powershell' section + /// + public class EditorFileSettings + { + /// + /// Exclude files globs consists of hashtable with the key as the glob and a boolean value to indicate if the + /// the glob is in effect. + /// + public Dictionary Exclude { get; set; } + } + + /// + /// Additional settings from the Language Client that affect Language Server operations but + /// do not exist under the 'powershell' section + /// + public class EditorSearchSettings + { + /// + /// Exclude files globs consists of hashtable with the key as the glob and a boolean value to indicate if the + /// the glob is in effect. + /// + public Dictionary Exclude { get; set; } + /// + /// Whether to follow symlinks when searching + /// + public bool FollowSymlinks { get; set; } = true; + } + + public class LanguageServerSettingsWrapper + { + // NOTE: This property is capitalized as 'Powershell' because the + // mode name sent from the client is written as 'powershell' and + // JSON.net is using camelCasing. + public LanguageServerSettings Powershell { get; set; } + + // NOTE: This property is capitalized as 'Files' because the + // mode name sent from the client is written as 'files' and + // JSON.net is using camelCasing. + public EditorFileSettings Files { get; set; } + + // NOTE: This property is capitalized as 'Search' because the + // mode name sent from the client is written as 'search' and + // JSON.net is using camelCasing. + public EditorSearchSettings Search { get; set; } + } +} diff --git a/src/PowerShellEditorServices.Engine/Logging/LoggerExtensions.cs b/src/PowerShellEditorServices.Engine/Logging/LoggerExtensions.cs new file mode 100644 index 000000000..4faf9629f --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Logging/LoggerExtensions.cs @@ -0,0 +1,32 @@ +using System; +using System.Runtime.CompilerServices; +using System.Text; +using Microsoft.Extensions.Logging; + +namespace Microsoft.PowerShell.EditorServices +{ + internal static class LoggerExtensions + { + public static void LogException( + this ILogger logger, + string message, + Exception exception, + [CallerMemberName] string callerName = null, + [CallerFilePath] string callerSourceFile = null, + [CallerLineNumber] int callerLineNumber = -1) + { + logger.LogError(message, exception); + } + + public static void LogHandledException( + this ILogger logger, + string message, + Exception exception, + [CallerMemberName] string callerName = null, + [CallerFilePath] string callerSourceFile = null, + [CallerLineNumber] int callerLineNumber = -1) + { + logger.LogError(message, exception); + } + } +} diff --git a/src/PowerShellEditorServices.Engine/PowerShellEditorServices.Engine.csproj b/src/PowerShellEditorServices.Engine/PowerShellEditorServices.Engine.csproj new file mode 100644 index 000000000..c0fb4a3df --- /dev/null +++ b/src/PowerShellEditorServices.Engine/PowerShellEditorServices.Engine.csproj @@ -0,0 +1,24 @@ + + + + + + PowerShell Editor Services Engine + Provides common PowerShell editor capabilities as a .NET library. + netstandard2.0 + Microsoft.PowerShell.EditorServices.Engine + Latest + + + + + + + + + + + + + + diff --git a/src/PowerShellEditorServices.Engine/Services/Analysis/AnalysisService.cs b/src/PowerShellEditorServices.Engine/Services/Analysis/AnalysisService.cs new file mode 100644 index 000000000..992fb65ab --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/Analysis/AnalysisService.cs @@ -0,0 +1,946 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Linq; +using System.Threading.Tasks; +using System.Management.Automation.Runspaces; +using System.Management.Automation; +using System.Collections.Generic; +using System.Text; +using System.Collections; +using Microsoft.Extensions.Logging; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; +using System.Threading; + +namespace Microsoft.PowerShell.EditorServices +{ + /// + /// Provides a high-level service for performing semantic analysis + /// of PowerShell scripts. + /// + public class AnalysisService : IDisposable + { + #region Static fields + + /// + /// Defines the list of Script Analyzer rules to include by default if + /// no settings file is specified. + /// + private static readonly string[] s_includedRules = { + "PSUseToExportFieldsInManifest", + "PSMisleadingBacktick", + "PSAvoidUsingCmdletAliases", + "PSUseApprovedVerbs", + "PSAvoidUsingPlainTextForPassword", + "PSReservedCmdletChar", + "PSReservedParams", + "PSShouldProcess", + "PSMissingModuleManifestField", + "PSAvoidDefaultValueSwitchParameter", + "PSUseDeclaredVarsMoreThanAssignments", + "PSPossibleIncorrectComparisonWithNull", + "PSAvoidDefaultValueForMandatoryParameter", + "PSPossibleIncorrectUsageOfRedirectionOperator" + }; + + /// + /// An empty diagnostic result to return when a script fails analysis. + /// + private static readonly PSObject[] s_emptyDiagnosticResult = new PSObject[0]; + + private static readonly string[] s_emptyGetRuleResult = new string[0]; + + private Dictionary> codeActionsPerFile = + new Dictionary>(); + + private static CancellationTokenSource s_existingRequestCancellation; + + /// + /// The indentation to add when the logger lists errors. + /// + private static readonly string s_indentJoin = Environment.NewLine + " "; + + #endregion // Static fields + + #region Private Fields + + /// + /// Maximum number of runspaces we allow to be in use for script analysis. + /// + private const int NumRunspaces = 1; + + /// + /// Name of the PSScriptAnalyzer module, to be used for PowerShell module interactions. + /// + private const string PSSA_MODULE_NAME = "PSScriptAnalyzer"; + + /// + /// Provides logging. + /// + private ILogger _logger; + + /// + /// Runspace pool to generate runspaces for script analysis and handle + /// ansynchronous analysis requests. + /// + private RunspacePool _analysisRunspacePool; + + /// + /// Info object describing the PSScriptAnalyzer module that has been loaded in + /// to provide analysis services. + /// + private PSModuleInfo _pssaModuleInfo; + + private readonly ILanguageServer _languageServer; + private readonly ConfigurationService _configurationService; + + #endregion // Private Fields + + #region Properties + + /// + /// Set of PSScriptAnalyzer rules used for analysis. + /// + public string[] ActiveRules { get; set; } + + /// + /// Gets or sets the path to a settings file (.psd1) + /// containing PSScriptAnalyzer settings. + /// + public string SettingsPath { get; set; } + + #endregion + + #region Constructors + + /// + /// Construct a new AnalysisService object. + /// + /// + /// The runspace pool with PSScriptAnalyzer module loaded that will handle + /// analysis tasks. + /// + /// + /// The path to the PSScriptAnalyzer settings file to handle analysis settings. + /// + /// An array of rules to be used for analysis. + /// Maintains logs for the analysis service. + /// + /// Optional module info of the loaded PSScriptAnalyzer module. If not provided, + /// the analysis service will populate it, but it can be given here to save time. + /// + private AnalysisService( + RunspacePool analysisRunspacePool, + string pssaSettingsPath, + IEnumerable activeRules, + ILanguageServer languageServer, + ConfigurationService configurationService, + ILogger logger, + PSModuleInfo pssaModuleInfo = null) + { + _analysisRunspacePool = analysisRunspacePool; + SettingsPath = pssaSettingsPath; + ActiveRules = activeRules.ToArray(); + _languageServer = languageServer; + _configurationService = configurationService; + _logger = logger; + _pssaModuleInfo = pssaModuleInfo; + } + + #endregion // constructors + + #region Public Methods + + /// + /// Factory method for producing AnalysisService instances. Handles loading of the PSScriptAnalyzer module + /// and runspace pool instantiation before creating the service instance. + /// + /// Path to the PSSA settings file to be used for this service instance. + /// EditorServices logger for logging information. + /// + /// A new analysis service instance with a freshly imported PSScriptAnalyzer module and runspace pool. + /// Returns null if problems occur. This method should never throw. + /// + public static AnalysisService Create(ConfigurationService configurationService, ILanguageServer languageServer, ILogger logger) + { + string settingsPath = configurationService.CurrentSettings.ScriptAnalysis.SettingsPath; + try + { + RunspacePool analysisRunspacePool; + PSModuleInfo pssaModuleInfo; + try + { + // Try and load a PSScriptAnalyzer module with the required version + // by looking on the script path. Deep down, this internally runs Get-Module -ListAvailable, + // so we'll use this to check whether such a module exists + analysisRunspacePool = CreatePssaRunspacePool(out pssaModuleInfo); + + } + catch (Exception e) + { + throw new AnalysisServiceLoadException("PSScriptAnalyzer runspace pool could not be created", e); + } + + if (analysisRunspacePool == null) + { + throw new AnalysisServiceLoadException("PSScriptAnalyzer runspace pool failed to be created"); + } + + // Having more than one runspace doesn't block code formatting if one + // runspace is occupied for diagnostics + analysisRunspacePool.SetMaxRunspaces(NumRunspaces); + analysisRunspacePool.ThreadOptions = PSThreadOptions.ReuseThread; + analysisRunspacePool.Open(); + + var analysisService = new AnalysisService( + analysisRunspacePool, + settingsPath, + s_includedRules, + languageServer, + configurationService, + logger, + pssaModuleInfo); + + // Log what features are available in PSSA here + analysisService.LogAvailablePssaFeatures(); + + return analysisService; + } + catch (AnalysisServiceLoadException e) + { + logger.LogWarning("PSScriptAnalyzer cannot be imported, AnalysisService will be disabled", e); + return null; + } + catch (Exception e) + { + logger.LogWarning("AnalysisService could not be started due to an unexpected exception", e); + return null; + } + } + + /// + /// Get PSScriptAnalyzer settings hashtable for PSProvideCommentHelp rule. + /// + /// Enable the rule. + /// Analyze only exported functions/cmdlets. + /// Use block comment or line comment. + /// Return a vscode snipped correction should be returned. + /// Place comment help at the given location relative to the function definition. + /// A PSScriptAnalyzer settings hashtable. + public static Hashtable GetCommentHelpRuleSettings( + bool enable, + bool exportedOnly, + bool blockComment, + bool vscodeSnippetCorrection, + string placement) + { + var settings = new Dictionary(); + var ruleSettings = new Hashtable(); + ruleSettings.Add("Enable", enable); + ruleSettings.Add("ExportedOnly", exportedOnly); + ruleSettings.Add("BlockComment", blockComment); + ruleSettings.Add("VSCodeSnippetCorrection", vscodeSnippetCorrection); + ruleSettings.Add("Placement", placement); + settings.Add("PSProvideCommentHelp", ruleSettings); + return GetPSSASettingsHashtable(settings); + } + + /// + /// Construct a PSScriptAnalyzer settings hashtable + /// + /// A settings hashtable + /// + public static Hashtable GetPSSASettingsHashtable(IDictionary ruleSettingsMap) + { + var hashtable = new Hashtable(); + var ruleSettingsHashtable = new Hashtable(); + + hashtable["IncludeRules"] = ruleSettingsMap.Keys.ToArray(); + hashtable["Rules"] = ruleSettingsHashtable; + + foreach (var kvp in ruleSettingsMap) + { + ruleSettingsHashtable.Add(kvp.Key, kvp.Value); + } + + return hashtable; + } + + /// + /// Perform semantic analysis on the given ScriptFile and returns + /// an array of ScriptFileMarkers. + /// + /// The ScriptFile which will be analyzed for semantic markers. + /// An array of ScriptFileMarkers containing semantic analysis results. + public async Task> GetSemanticMarkersAsync(ScriptFile file) + { + return await GetSemanticMarkersAsync(file, ActiveRules, SettingsPath); + } + + /// + /// Perform semantic analysis on the given ScriptFile with the given settings. + /// + /// The ScriptFile to be analyzed. + /// ScriptAnalyzer settings + /// + public async Task> GetSemanticMarkersAsync(ScriptFile file, Hashtable settings) + { + return await GetSemanticMarkersAsync(file, null, settings); + } + + /// + /// Perform semantic analysis on the given script with the given settings. + /// + /// The script content to be analyzed. + /// ScriptAnalyzer settings + /// + public async Task> GetSemanticMarkersAsync( + string scriptContent, + Hashtable settings) + { + return await GetSemanticMarkersAsync(scriptContent, null, settings); + } + + /// + /// Returns a list of builtin-in PSScriptAnalyzer rules + /// + public IEnumerable GetPSScriptAnalyzerRules() + { + PowerShellResult getRuleResult = InvokePowerShell("Get-ScriptAnalyzerRule"); + if (getRuleResult == null) + { + _logger.LogWarning("Get-ScriptAnalyzerRule returned null result"); + return s_emptyGetRuleResult; + } + + var ruleNames = new List(); + foreach (var rule in getRuleResult.Output) + { + ruleNames.Add((string)rule.Members["RuleName"].Value); + } + + return ruleNames; + } + + /// + /// Format a given script text with default codeformatting settings. + /// + /// Script text to be formatted + /// ScriptAnalyzer settings + /// The range within which formatting should be applied. + /// The formatted script text. + public async Task FormatAsync( + string scriptDefinition, + Hashtable settings, + int[] rangeList) + { + // We cannot use Range type therefore this workaround of using -1 default value. + // Invoke-Formatter throws a ParameterBinderValidationException if the ScriptDefinition is an empty string. + if (string.IsNullOrEmpty(scriptDefinition)) + { + return null; + } + + var argsDict = new Dictionary { + {"ScriptDefinition", scriptDefinition}, + {"Settings", settings} + }; + if (rangeList != null) + { + argsDict.Add("Range", rangeList); + } + + PowerShellResult result = await InvokePowerShellAsync("Invoke-Formatter", argsDict); + + if (result == null) + { + _logger.LogError("Formatter returned null result"); + return null; + } + + if (result.HasErrors) + { + var errorBuilder = new StringBuilder().Append(s_indentJoin); + foreach (ErrorRecord err in result.Errors) + { + errorBuilder.Append(err).Append(s_indentJoin); + } + _logger.LogWarning($"Errors found while formatting file: {errorBuilder}"); + return null; + } + + foreach (PSObject resultObj in result.Output) + { + string formatResult = resultObj?.BaseObject as string; + if (formatResult != null) + { + return formatResult; + } + } + + return null; + } + + #endregion // public methods + + #region Private Methods + + private async Task> GetSemanticMarkersAsync( + ScriptFile file, + string[] rules, + TSettings settings) where TSettings : class + { + if (file.IsAnalysisEnabled) + { + return await GetSemanticMarkersAsync( + file.Contents, + rules, + settings); + } + else + { + // Return an empty marker list + return new List(); + } + } + + private async Task> GetSemanticMarkersAsync( + string scriptContent, + string[] rules, + TSettings settings) where TSettings : class + { + if ((typeof(TSettings) == typeof(string) || typeof(TSettings) == typeof(Hashtable)) + && (rules != null || settings != null)) + { + var scriptFileMarkers = await GetDiagnosticRecordsAsync(scriptContent, rules, settings); + return scriptFileMarkers.Select(ScriptFileMarker.FromDiagnosticRecord).ToList(); + } + else + { + // Return an empty marker list + return new List(); + } + } + + /// + /// Log the features available from the PSScriptAnalyzer module that has been imported + /// for use with the AnalysisService. + /// + private void LogAvailablePssaFeatures() + { + // Save ourselves some work here + if (!_logger.IsEnabled(LogLevel.Debug)) + { + return; + } + + // If we already know the module that was imported, save some work + if (_pssaModuleInfo == null) + { + PowerShellResult getModuleResult = InvokePowerShell( + "Get-Module", + new Dictionary{ {"Name", PSSA_MODULE_NAME} }); + + if (getModuleResult == null) + { + throw new AnalysisServiceLoadException("Get-Module call to find PSScriptAnalyzer module failed"); + } + + _pssaModuleInfo = getModuleResult.Output + .Select(m => m.BaseObject) + .OfType() + .FirstOrDefault(); + } + + if (_pssaModuleInfo == null) + { + throw new AnalysisServiceLoadException("Unable to find loaded PSScriptAnalyzer module for logging"); + } + + var sb = new StringBuilder(); + sb.AppendLine("PSScriptAnalyzer successfully imported:"); + + // Log version + sb.Append(" Version: "); + sb.AppendLine(_pssaModuleInfo.Version.ToString()); + + // Log exported cmdlets + sb.AppendLine(" Exported Cmdlets:"); + foreach (string cmdletName in _pssaModuleInfo.ExportedCmdlets.Keys.OrderBy(name => name)) + { + sb.Append(" "); + sb.AppendLine(cmdletName); + } + + // Log available rules + sb.AppendLine(" Available Rules:"); + foreach (string ruleName in GetPSScriptAnalyzerRules()) + { + sb.Append(" "); + sb.AppendLine(ruleName); + } + + _logger.LogDebug(sb.ToString()); + } + + private async Task GetDiagnosticRecordsAsync( + string scriptContent, + string[] rules, + TSettings settings) where TSettings : class + { + var diagnosticRecords = s_emptyDiagnosticResult; + + // When a new, empty file is created there are by definition no issues. + // Furthermore, if you call Invoke-ScriptAnalyzer with an empty ScriptDefinition + // it will generate a ParameterBindingValidationException. + if (string.IsNullOrEmpty(scriptContent)) + { + return diagnosticRecords; + } + + if (typeof(TSettings) == typeof(string) || typeof(TSettings) == typeof(Hashtable)) + { + //Use a settings file if one is provided, otherwise use the default rule list. + string settingParameter; + object settingArgument; + if (settings != null) + { + settingParameter = "Settings"; + settingArgument = settings; + } + else + { + settingParameter = "IncludeRule"; + settingArgument = rules; + } + + PowerShellResult result = await InvokePowerShellAsync( + "Invoke-ScriptAnalyzer", + new Dictionary + { + { "ScriptDefinition", scriptContent }, + { settingParameter, settingArgument }, + // We ignore ParseErrors from PSSA because we already send them when we parse the file. + { "Severity", new [] { ScriptFileMarkerLevel.Error, ScriptFileMarkerLevel.Information, ScriptFileMarkerLevel.Warning }} + }); + + diagnosticRecords = result?.Output; + } + + _logger.LogDebug(String.Format("Found {0} violations", diagnosticRecords.Count())); + + return diagnosticRecords; + } + + private PowerShellResult InvokePowerShell(string command, IDictionary paramArgMap = null) + { + using (var powerShell = System.Management.Automation.PowerShell.Create()) + { + powerShell.RunspacePool = _analysisRunspacePool; + powerShell.AddCommand(command); + if (paramArgMap != null) + { + foreach (KeyValuePair kvp in paramArgMap) + { + powerShell.AddParameter(kvp.Key, kvp.Value); + } + } + + PowerShellResult result = null; + try + { + PSObject[] output = powerShell.Invoke().ToArray(); + ErrorRecord[] errors = powerShell.Streams.Error.ToArray(); + result = new PowerShellResult(output, errors, powerShell.HadErrors); + } + catch (CommandNotFoundException ex) + { + // This exception is possible if the module path loaded + // is wrong even though PSScriptAnalyzer is available as a module + _logger.LogError(ex.Message); + } + catch (CmdletInvocationException ex) + { + // We do not want to crash EditorServices for exceptions caused by cmdlet invocation. + // Two main reasons that cause the exception are: + // * PSCmdlet.WriteOutput being called from another thread than Begin/Process + // * CompositionContainer.ComposeParts complaining that "...Only one batch can be composed at a time" + _logger.LogError(ex.Message); + } + + return result; + } + } + + private async Task InvokePowerShellAsync(string command, IDictionary paramArgMap = null) + { + var task = Task.Run(() => + { + return InvokePowerShell(command, paramArgMap); + }); + + return await task; + } + + /// + /// Create a new runspace pool around a PSScriptAnalyzer module for asynchronous script analysis tasks. + /// This looks for the latest version of PSScriptAnalyzer on the path and loads that. + /// + /// A runspace pool with PSScriptAnalyzer loaded for running script analysis tasks. + private static RunspacePool CreatePssaRunspacePool(out PSModuleInfo pssaModuleInfo) + { + using (var ps = System.Management.Automation.PowerShell.Create()) + { + // Run `Get-Module -ListAvailable -Name "PSScriptAnalyzer"` + ps.AddCommand("Get-Module") + .AddParameter("ListAvailable") + .AddParameter("Name", PSSA_MODULE_NAME); + + try + { + // Get the latest version of PSScriptAnalyzer we can find + pssaModuleInfo = ps.Invoke()? + .Select(psObj => psObj.BaseObject) + .OfType() + .OrderByDescending(moduleInfo => moduleInfo.Version) + .FirstOrDefault(); + } + catch (Exception e) + { + throw new AnalysisServiceLoadException("Unable to find PSScriptAnalyzer module on the module path", e); + } + + if (pssaModuleInfo == null) + { + throw new AnalysisServiceLoadException("Unable to find PSScriptAnalyzer module on the module path"); + } + + // Create a base session state with PSScriptAnalyzer loaded + InitialSessionState sessionState; + if (Environment.GetEnvironmentVariable("PSES_TEST_USE_CREATE_DEFAULT") == "1") { + sessionState = InitialSessionState.CreateDefault(); + } else { + sessionState = InitialSessionState.CreateDefault2(); + } + sessionState.ImportPSModule(new [] { pssaModuleInfo.ModuleBase }); + + // RunspacePool takes care of queuing commands for us so we do not + // need to worry about executing concurrent commands + return RunspaceFactory.CreateRunspacePool(sessionState); + } + } + + #endregion //private methods + + #region IDisposable Support + + private bool _disposedValue = false; // To detect redundant calls + + /// + /// Dispose of this object. + /// + /// True if the method is called by the Dispose method, false if called by the finalizer. + protected virtual void Dispose(bool disposing) + { + if (!_disposedValue) + { + if (disposing) + { + _analysisRunspacePool.Dispose(); + _analysisRunspacePool = null; + } + + _disposedValue = true; + } + } + + /// + /// Clean up all internal resources and dispose of the analysis service. + /// + public void Dispose() + { + // Do not change this code. Put cleanup code in Dispose(bool disposing) above. + Dispose(true); + } + + #endregion + + /// + /// Wraps the result of an execution of PowerShell to send back through + /// asynchronous calls. + /// + private class PowerShellResult + { + public PowerShellResult( + PSObject[] output, + ErrorRecord[] errors, + bool hasErrors) + { + Output = output; + Errors = errors; + HasErrors = hasErrors; + } + + public PSObject[] Output { get; } + + public ErrorRecord[] Errors { get; } + + public bool HasErrors { get; } + } + + internal async Task RunScriptDiagnosticsAsync( + ScriptFile[] filesToAnalyze) + { + // If there's an existing task, attempt to cancel it + try + { + if (s_existingRequestCancellation != null) + { + // Try to cancel the request + s_existingRequestCancellation.Cancel(); + + // If cancellation didn't throw an exception, + // clean up the existing token + s_existingRequestCancellation.Dispose(); + s_existingRequestCancellation = null; + } + } + catch (Exception e) + { + // TODO: Catch a more specific exception! + _logger.LogError( + string.Format( + "Exception while canceling analysis task:\n\n{0}", + e.ToString())); + + TaskCompletionSource cancelTask = new TaskCompletionSource(); + cancelTask.SetCanceled(); + return; + } + + // If filesToAnalzye is empty, nothing to do so return early. + if (filesToAnalyze.Length == 0) + { + return; + } + + // Create a fresh cancellation token and then start the task. + // We create this on a different TaskScheduler so that we + // don't block the main message loop thread. + // TODO: Is there a better way to do this? + s_existingRequestCancellation = new CancellationTokenSource(); + await Task.Factory.StartNew( + () => + DelayThenInvokeDiagnosticsAsync( + 750, + filesToAnalyze, + _configurationService.CurrentSettings.ScriptAnalysis.Enable ?? false, + s_existingRequestCancellation.Token), + CancellationToken.None, + TaskCreationOptions.None, + TaskScheduler.Default); + } + + private async Task DelayThenInvokeDiagnosticsAsync( + int delayMilliseconds, + ScriptFile[] filesToAnalyze, + bool isScriptAnalysisEnabled, + CancellationToken cancellationToken) + { + // First of all, wait for the desired delay period before + // analyzing the provided list of files + try + { + await Task.Delay(delayMilliseconds, cancellationToken); + } + catch (TaskCanceledException) + { + // If the task is cancelled, exit directly + foreach (var script in filesToAnalyze) + { + PublishScriptDiagnostics( + script, + script.DiagnosticMarkers); + } + + return; + } + + // If we've made it past the delay period then we don't care + // about the cancellation token anymore. This could happen + // when the user stops typing for long enough that the delay + // period ends but then starts typing while analysis is going + // on. It makes sense to send back the results from the first + // delay period while the second one is ticking away. + + // Get the requested files + foreach (ScriptFile scriptFile in filesToAnalyze) + { + List semanticMarkers = null; + if (isScriptAnalysisEnabled) + { + semanticMarkers = await GetSemanticMarkersAsync(scriptFile); + } + else + { + // Semantic markers aren't available if the AnalysisService + // isn't available + semanticMarkers = new List(); + } + + scriptFile.DiagnosticMarkers.AddRange(semanticMarkers); + + PublishScriptDiagnostics( + scriptFile, + // Concat script analysis errors to any existing parse errors + scriptFile.DiagnosticMarkers); + } + } + + internal void ClearMarkers(ScriptFile scriptFile) + { + // send empty diagnostic markers to clear any markers associated with the given file + PublishScriptDiagnostics( + scriptFile, + new List()); + } + + private void PublishScriptDiagnostics( + ScriptFile scriptFile, + List markers) + { + List diagnostics = new List(); + + // Hold on to any corrections that may need to be applied later + Dictionary fileCorrections = + new Dictionary(); + + foreach (var marker in markers) + { + // Does the marker contain a correction? + Diagnostic markerDiagnostic = GetDiagnosticFromMarker(marker); + if (marker.Correction != null) + { + string diagnosticId = GetUniqueIdFromDiagnostic(markerDiagnostic); + fileCorrections[diagnosticId] = marker.Correction; + } + + diagnostics.Add(markerDiagnostic); + } + + codeActionsPerFile[scriptFile.DocumentUri] = fileCorrections; + + var uriBuilder = new UriBuilder() + { + Scheme = Uri.UriSchemeFile, + Path = scriptFile.FilePath, + Host = string.Empty, + }; + + // Always send syntax and semantic errors. We want to + // make sure no out-of-date markers are being displayed. + _languageServer.Document.PublishDiagnostics(new PublishDiagnosticsParams() + { + Uri = uriBuilder.Uri, + Diagnostics = new Container(diagnostics), + }); + } + + // Generate a unique id that is used as a key to look up the associated code action (code fix) when + // we receive and process the textDocument/codeAction message. + private static string GetUniqueIdFromDiagnostic(Diagnostic diagnostic) + { + Position start = diagnostic.Range.Start; + Position end = diagnostic.Range.End; + + var sb = new StringBuilder(256) + .Append(diagnostic.Source ?? "?") + .Append("_") + .Append(diagnostic.Code.ToString()) + .Append("_") + .Append(diagnostic.Severity?.ToString() ?? "?") + .Append("_") + .Append(start.Line) + .Append(":") + .Append(start.Character) + .Append("-") + .Append(end.Line) + .Append(":") + .Append(end.Character); + + var id = sb.ToString(); + return id; + } + + private static Diagnostic GetDiagnosticFromMarker(ScriptFileMarker scriptFileMarker) + { + return new Diagnostic + { + Severity = MapDiagnosticSeverity(scriptFileMarker.Level), + Message = scriptFileMarker.Message, + Code = scriptFileMarker.RuleName, + Source = scriptFileMarker.Source, + Range = new Range + { + Start = new Position + { + Line = scriptFileMarker.ScriptRegion.StartLineNumber - 1, + Character = scriptFileMarker.ScriptRegion.StartColumnNumber - 1 + }, + End = new Position + { + Line = scriptFileMarker.ScriptRegion.EndLineNumber - 1, + Character = scriptFileMarker.ScriptRegion.EndColumnNumber - 1 + } + } + }; + } + + private static DiagnosticSeverity MapDiagnosticSeverity(ScriptFileMarkerLevel markerLevel) + { + switch (markerLevel) + { + case ScriptFileMarkerLevel.Error: + return DiagnosticSeverity.Error; + + case ScriptFileMarkerLevel.Warning: + return DiagnosticSeverity.Warning; + + case ScriptFileMarkerLevel.Information: + return DiagnosticSeverity.Information; + + default: + return DiagnosticSeverity.Error; + } + } + } + + /// + /// Class to catch known failure modes for starting the AnalysisService. + /// + public class AnalysisServiceLoadException : Exception + { + /// + /// Instantiate an AnalysisService error based on a simple message. + /// + /// The message to display to the user detailing the error. + public AnalysisServiceLoadException(string message) + : base(message) + { + } + + /// + /// Instantiate an AnalysisService error based on another error that occurred internally. + /// + /// The message to display to the user detailing the error. + /// The inner exception that occurred to trigger this error. + public AnalysisServiceLoadException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/PowerShellContext/Handlers/GetVersionHandler.cs b/src/PowerShellEditorServices.Engine/Services/PowerShellContext/Handlers/GetVersionHandler.cs new file mode 100644 index 000000000..724c921d4 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/PowerShellContext/Handlers/GetVersionHandler.cs @@ -0,0 +1,50 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices; + +namespace PowerShellEditorServices.Engine.Services.Handlers +{ + public class GetVersionHandler : IGetVersionHandler + { + private readonly ILogger _logger; + + public GetVersionHandler(ILoggerFactory factory) + { + _logger = factory.CreateLogger(); + } + + public Task Handle(GetVersionParams request, CancellationToken cancellationToken) + { + var architecture = PowerShellProcessArchitecture.Unknown; + // This should be changed to using a .NET call sometime in the future... but it's just for logging purposes. + string arch = Environment.GetEnvironmentVariable("PROCESSOR_ARCHITECTURE"); + if (arch != null) + { + if (string.Equals(arch, "AMD64", StringComparison.CurrentCultureIgnoreCase)) + { + architecture = PowerShellProcessArchitecture.X64; + } + else if (string.Equals(arch, "x86", StringComparison.CurrentCultureIgnoreCase)) + { + architecture = PowerShellProcessArchitecture.X86; + } + } + + return Task.FromResult(new PowerShellVersionDetails { + Version = VersionUtils.PSVersion.ToString(), + Edition = VersionUtils.PSEdition, + DisplayVersion = VersionUtils.PSVersion.ToString(2), + Architecture = architecture.ToString() + }); + } + + private enum PowerShellProcessArchitecture + { + Unknown, + X86, + X64 + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/PowerShellContext/Handlers/IGetVersionHandler.cs b/src/PowerShellEditorServices.Engine/Services/PowerShellContext/Handlers/IGetVersionHandler.cs new file mode 100644 index 000000000..c4570d8b8 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/PowerShellContext/Handlers/IGetVersionHandler.cs @@ -0,0 +1,17 @@ +using OmniSharp.Extensions.Embedded.MediatR; +using OmniSharp.Extensions.JsonRpc; + +namespace PowerShellEditorServices.Engine.Services.Handlers +{ + [Serial, Method("powerShell/getVersion")] + public interface IGetVersionHandler : IJsonRpcRequestHandler { } + + public class GetVersionParams : IRequest { } + + public class PowerShellVersionDetails { + public string Version { get; set; } + public string DisplayVersion { get; set; } + public string Edition { get; set; } + public string Architecture { get; set; } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/Symbols/FindSymbolVisitor.cs b/src/PowerShellEditorServices.Engine/Services/Symbols/FindSymbolVisitor.cs new file mode 100644 index 000000000..06bdf8235 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/Symbols/FindSymbolVisitor.cs @@ -0,0 +1,145 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Management.Automation.Language; + +namespace Microsoft.PowerShell.EditorServices.Symbols +{ + /// + /// The visitor used to find the the symbol at a specfic location in the AST + /// + internal class FindSymbolVisitor : AstVisitor + { + private int lineNumber; + private int columnNumber; + private bool includeFunctionDefinitions; + + public SymbolReference FoundSymbolReference { get; private set; } + + public FindSymbolVisitor( + int lineNumber, + int columnNumber, + bool includeFunctionDefinitions) + { + this.lineNumber = lineNumber; + this.columnNumber = columnNumber; + this.includeFunctionDefinitions = includeFunctionDefinitions; + } + + /// + /// Checks to see if this command ast is the symbol we are looking for. + /// + /// A CommandAst object in the script's AST + /// A decision to stop searching if the right symbol was found, + /// or a decision to continue if it wasn't found + public override AstVisitAction VisitCommand(CommandAst commandAst) + { + Ast commandNameAst = commandAst.CommandElements[0]; + + if (this.IsPositionInExtent(commandNameAst.Extent)) + { + this.FoundSymbolReference = + new SymbolReference( + SymbolType.Function, + commandNameAst.Extent); + + return AstVisitAction.StopVisit; + } + + return base.VisitCommand(commandAst); + } + + /// + /// Checks to see if this function definition is the symbol we are looking for. + /// + /// A functionDefinitionAst object in the script's AST + /// A decision to stop searching if the right symbol was found, + /// or a decision to continue if it wasn't found + public override AstVisitAction VisitFunctionDefinition(FunctionDefinitionAst functionDefinitionAst) + { + int startColumnNumber = 1; + + if (!this.includeFunctionDefinitions) + { + startColumnNumber = + functionDefinitionAst.Extent.Text.IndexOf( + functionDefinitionAst.Name) + 1; + } + + IScriptExtent nameExtent = new ScriptExtent() + { + Text = functionDefinitionAst.Name, + StartLineNumber = functionDefinitionAst.Extent.StartLineNumber, + EndLineNumber = functionDefinitionAst.Extent.EndLineNumber, + StartColumnNumber = startColumnNumber, + EndColumnNumber = startColumnNumber + functionDefinitionAst.Name.Length + }; + + if (this.IsPositionInExtent(nameExtent)) + { + this.FoundSymbolReference = + new SymbolReference( + SymbolType.Function, + nameExtent); + + return AstVisitAction.StopVisit; + } + + return base.VisitFunctionDefinition(functionDefinitionAst); + } + + /// + /// Checks to see if this command parameter is the symbol we are looking for. + /// + /// A CommandParameterAst object in the script's AST + /// A decision to stop searching if the right symbol was found, + /// or a decision to continue if it wasn't found + public override AstVisitAction VisitCommandParameter(CommandParameterAst commandParameterAst) + { + if (this.IsPositionInExtent(commandParameterAst.Extent)) + { + this.FoundSymbolReference = + new SymbolReference( + SymbolType.Parameter, + commandParameterAst.Extent); + return AstVisitAction.StopVisit; + } + return AstVisitAction.Continue; + } + + /// + /// Checks to see if this variable expression is the symbol we are looking for. + /// + /// A VariableExpressionAst object in the script's AST + /// A decision to stop searching if the right symbol was found, + /// or a decision to continue if it wasn't found + public override AstVisitAction VisitVariableExpression(VariableExpressionAst variableExpressionAst) + { + if (this.IsPositionInExtent(variableExpressionAst.Extent)) + { + this.FoundSymbolReference = + new SymbolReference( + SymbolType.Variable, + variableExpressionAst.Extent); + + return AstVisitAction.StopVisit; + } + + return AstVisitAction.Continue; + } + + /// + /// Is the position of the given location is in the ast's extent + /// + /// The script extent of the element + /// True if the given position is in the range of the element's extent + private bool IsPositionInExtent(IScriptExtent extent) + { + return (extent.StartLineNumber == lineNumber && + extent.StartColumnNumber <= columnNumber && + extent.EndColumnNumber >= columnNumber); + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/Symbols/FindSymbolsVisitor.cs b/src/PowerShellEditorServices.Engine/Services/Symbols/FindSymbolsVisitor.cs new file mode 100644 index 000000000..9b2dc0dc6 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/Symbols/FindSymbolsVisitor.cs @@ -0,0 +1,147 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Collections.Generic; +using System.Management.Automation.Language; + +namespace Microsoft.PowerShell.EditorServices.Symbols +{ + /// + /// The visitor used to find all the symbols (function and class defs) in the AST. + /// + /// + /// Requires PowerShell v3 or higher + /// + internal class FindSymbolsVisitor : AstVisitor + { + public List SymbolReferences { get; private set; } + + public FindSymbolsVisitor() + { + this.SymbolReferences = new List(); + } + + /// + /// Adds each function definition as a + /// + /// A functionDefinitionAst object in the script's AST + /// A decision to stop searching if the right symbol was found, + /// or a decision to continue if it wasn't found + public override AstVisitAction VisitFunctionDefinition(FunctionDefinitionAst functionDefinitionAst) + { + IScriptExtent nameExtent = new ScriptExtent() { + Text = functionDefinitionAst.Name, + StartLineNumber = functionDefinitionAst.Extent.StartLineNumber, + EndLineNumber = functionDefinitionAst.Extent.EndLineNumber, + StartColumnNumber = functionDefinitionAst.Extent.StartColumnNumber, + EndColumnNumber = functionDefinitionAst.Extent.EndColumnNumber + }; + + SymbolType symbolType = + functionDefinitionAst.IsWorkflow ? + SymbolType.Workflow : SymbolType.Function; + + this.SymbolReferences.Add( + new SymbolReference( + symbolType, + nameExtent)); + + return AstVisitAction.Continue; + } + + /// + /// Checks to see if this variable expression is the symbol we are looking for. + /// + /// A VariableExpressionAst object in the script's AST + /// A decision to stop searching if the right symbol was found, + /// or a decision to continue if it wasn't found + public override AstVisitAction VisitVariableExpression(VariableExpressionAst variableExpressionAst) + { + if (!IsAssignedAtScriptScope(variableExpressionAst)) + { + return AstVisitAction.Continue; + } + + this.SymbolReferences.Add( + new SymbolReference( + SymbolType.Variable, + variableExpressionAst.Extent)); + + return AstVisitAction.Continue; + } + + private bool IsAssignedAtScriptScope(VariableExpressionAst variableExpressionAst) + { + Ast parent = variableExpressionAst.Parent; + if (!(parent is AssignmentStatementAst)) + { + return false; + } + + parent = parent.Parent; + if (parent == null || parent.Parent == null || parent.Parent.Parent == null) + { + return true; + } + + return false; + } + } + + /// + /// Visitor to find all the keys in Hashtable AST + /// + internal class FindHashtableSymbolsVisitor : AstVisitor + { + /// + /// List of symbols (keys) found in the hashtable + /// + public List SymbolReferences { get; private set; } + + /// + /// Initializes a new instance of FindHashtableSymbolsVisitor class + /// + public FindHashtableSymbolsVisitor() + { + SymbolReferences = new List(); + } + + /// + /// Adds keys in the input hashtable to the symbol reference + /// + public override AstVisitAction VisitHashtable(HashtableAst hashtableAst) + { + if (hashtableAst.KeyValuePairs == null) + { + return AstVisitAction.Continue; + } + + foreach (var kvp in hashtableAst.KeyValuePairs) + { + if (kvp.Item1 is StringConstantExpressionAst keyStrConstExprAst) + { + IScriptExtent nameExtent = new ScriptExtent() + { + Text = keyStrConstExprAst.Value, + StartLineNumber = kvp.Item1.Extent.StartLineNumber, + EndLineNumber = kvp.Item2.Extent.EndLineNumber, + StartColumnNumber = kvp.Item1.Extent.StartColumnNumber, + EndColumnNumber = kvp.Item2.Extent.EndColumnNumber + }; + + SymbolType symbolType = SymbolType.HashtableKey; + + this.SymbolReferences.Add( + new SymbolReference( + symbolType, + nameExtent)); + + } + } + + return AstVisitAction.Continue; + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/Symbols/FindSymbolsVisitor2.cs b/src/PowerShellEditorServices.Engine/Services/Symbols/FindSymbolsVisitor2.cs new file mode 100644 index 000000000..03628ee3e --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/Symbols/FindSymbolsVisitor2.cs @@ -0,0 +1,80 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Collections.Generic; +using System.Management.Automation.Language; + +namespace Microsoft.PowerShell.EditorServices +{ + // TODO: Restore this when we figure out how to support multiple + // PS versions in the new PSES-as-a-module world (issue #276) + + ///// + ///// The visitor used to find all the symbols (function and class defs) in the AST. + ///// + ///// + ///// Requires PowerShell v5 or higher + ///// + ///// + //internal class FindSymbolsVisitor2 : AstVisitor2 + //{ + // private FindSymbolsVisitor findSymbolsVisitor; + + // public List SymbolReferences + // { + // get + // { + // return this.findSymbolsVisitor.SymbolReferences; + // } + // } + + // public FindSymbolsVisitor2() + // { + // this.findSymbolsVisitor = new FindSymbolsVisitor(); + // } + + // /// + // /// Adds each function definition as a + // /// + // /// A functionDefinitionAst object in the script's AST + // /// A decision to stop searching if the right symbol was found, + // /// or a decision to continue if it wasn't found + // public override AstVisitAction VisitFunctionDefinition(FunctionDefinitionAst functionDefinitionAst) + // { + // return this.findSymbolsVisitor.VisitFunctionDefinition(functionDefinitionAst); + // } + + // /// + // /// Checks to see if this variable expression is the symbol we are looking for. + // /// + // /// A VariableExpressionAst object in the script's AST + // /// A decision to stop searching if the right symbol was found, + // /// or a decision to continue if it wasn't found + // public override AstVisitAction VisitVariableExpression(VariableExpressionAst variableExpressionAst) + // { + // return this.findSymbolsVisitor.VisitVariableExpression(variableExpressionAst); + // } + + // public override AstVisitAction VisitConfigurationDefinition(ConfigurationDefinitionAst configurationDefinitionAst) + // { + // IScriptExtent nameExtent = new ScriptExtent() + // { + // Text = configurationDefinitionAst.InstanceName.Extent.Text, + // StartLineNumber = configurationDefinitionAst.Extent.StartLineNumber, + // EndLineNumber = configurationDefinitionAst.Extent.EndLineNumber, + // StartColumnNumber = configurationDefinitionAst.Extent.StartColumnNumber, + // EndColumnNumber = configurationDefinitionAst.Extent.EndColumnNumber + // }; + + // this.findSymbolsVisitor.SymbolReferences.Add( + // new SymbolReference( + // SymbolType.Configuration, + // nameExtent)); + + // return AstVisitAction.Continue; + // } + //} +} + diff --git a/src/PowerShellEditorServices.Engine/Services/Symbols/IDocumentSymbolProvider.cs b/src/PowerShellEditorServices.Engine/Services/Symbols/IDocumentSymbolProvider.cs new file mode 100644 index 000000000..d971b9b38 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/Symbols/IDocumentSymbolProvider.cs @@ -0,0 +1,24 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Collections.Generic; + +namespace Microsoft.PowerShell.EditorServices.Symbols +{ + /// + /// Specifies the contract for a document symbols provider. + /// + public interface IDocumentSymbolProvider + { + /// + /// Provides a list of symbols for the given document. + /// + /// + /// The document for which SymbolReferences should be provided. + /// + /// An IEnumerable collection of SymbolReferences. + IEnumerable ProvideDocumentSymbols(ScriptFile scriptFile); + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/Symbols/IDocumentSymbols.cs b/src/PowerShellEditorServices.Engine/Services/Symbols/IDocumentSymbols.cs new file mode 100644 index 000000000..42472203e --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/Symbols/IDocumentSymbols.cs @@ -0,0 +1,32 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace Microsoft.PowerShell.EditorServices.Symbols +{ + /// + /// Specifies the contract for an implementation of + /// the IDocumentSymbols component. + /// + public interface IDocumentSymbols + { + /// + /// Gets the collection of IDocumentSymbolsProvider implementations + /// that are registered with this component. + /// + Collection Providers { get; } + + /// + /// Provides a list of symbols for the given document. + /// + /// + /// The document for which SymbolReferences should be provided. + /// + /// An IEnumerable collection of SymbolReferences. + IEnumerable ProvideDocumentSymbols(ScriptFile scriptFile); + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/Symbols/PesterDocumentSymbolProvider.cs b/src/PowerShellEditorServices.Engine/Services/Symbols/PesterDocumentSymbolProvider.cs new file mode 100644 index 000000000..87a19778e --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/Symbols/PesterDocumentSymbolProvider.cs @@ -0,0 +1,223 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation.Language; + +namespace Microsoft.PowerShell.EditorServices.Symbols +{ + /// + /// Provides an IDocumentSymbolProvider implementation for + /// enumerating test symbols in Pester test (tests.ps1) files. + /// + public class PesterDocumentSymbolProvider : IDocumentSymbolProvider + { + + IEnumerable IDocumentSymbolProvider.ProvideDocumentSymbols( + ScriptFile scriptFile) + { + if (!scriptFile.FilePath.EndsWith( + "tests.ps1", + StringComparison.OrdinalIgnoreCase)) + { + return Enumerable.Empty(); + } + + // Find plausible Pester commands + IEnumerable commandAsts = scriptFile.ScriptAst.FindAll(IsNamedCommandWithArguments, true); + + return commandAsts.OfType() + .Where(IsPesterCommand) + .Select(ast => ConvertPesterAstToSymbolReference(scriptFile, ast)); + } + + /// + /// Test if the given Ast is a regular CommandAst with arguments + /// + /// the PowerShell Ast to test + /// true if the Ast represents a PowerShell command with arguments, false otherwise + private static bool IsNamedCommandWithArguments(Ast ast) + { + CommandAst commandAst = ast as CommandAst; + + return commandAst != null && + commandAst.InvocationOperator != TokenKind.Dot && + PesterSymbolReference.GetCommandType(commandAst.GetCommandName()).HasValue && + commandAst.CommandElements.Count >= 2; + } + + /// + /// Test whether the given CommandAst represents a Pester command + /// + /// the CommandAst to test + /// true if the CommandAst represents a Pester command, false otherwise + private static bool IsPesterCommand(CommandAst commandAst) + { + if (commandAst == null) + { + return false; + } + + // Ensure the first word is a Pester keyword + if (!PesterSymbolReference.PesterKeywords.ContainsKey(commandAst.GetCommandName())) + { + return false; + } + + // Ensure that the last argument of the command is a scriptblock + if (!(commandAst.CommandElements[commandAst.CommandElements.Count-1] is ScriptBlockExpressionAst)) + { + return false; + } + + return true; + } + + /// + /// Convert a CommandAst known to represent a Pester command and a reference to the scriptfile + /// it is in into symbol representing a Pester call for code lens + /// + /// the scriptfile the Pester call occurs in + /// the CommandAst representing the Pester call + /// a symbol representing the Pester call containing metadata for CodeLens to use + private static PesterSymbolReference ConvertPesterAstToSymbolReference(ScriptFile scriptFile, CommandAst pesterCommandAst) + { + string testLine = scriptFile.GetLine(pesterCommandAst.Extent.StartLineNumber); + PesterCommandType? commandName = PesterSymbolReference.GetCommandType(pesterCommandAst.GetCommandName()); + if (commandName == null) + { + return null; + } + + // Search for a name for the test + // If the test has more than one argument for names, we set it to null + string testName = null; + bool alreadySawName = false; + for (int i = 1; i < pesterCommandAst.CommandElements.Count; i++) + { + CommandElementAst currentCommandElement = pesterCommandAst.CommandElements[i]; + + // Check for an explicit "-Name" parameter + if (currentCommandElement is CommandParameterAst parameterAst) + { + // Found -Name parameter, move to next element which is the argument for -TestName + i++; + + if (!alreadySawName && TryGetTestNameArgument(pesterCommandAst.CommandElements[i], out testName)) + { + alreadySawName = true; + } + + continue; + } + + // Otherwise, if an argument is given with no parameter, we assume it's the name + // If we've already seen a name, we set the name to null + if (!alreadySawName && TryGetTestNameArgument(pesterCommandAst.CommandElements[i], out testName)) + { + alreadySawName = true; + } + } + + return new PesterSymbolReference( + scriptFile, + commandName.Value, + testLine, + testName, + pesterCommandAst.Extent + ); + } + + private static bool TryGetTestNameArgument(CommandElementAst commandElementAst, out string testName) + { + testName = null; + + if (commandElementAst is StringConstantExpressionAst testNameStrAst) + { + testName = testNameStrAst.Value; + return true; + } + + return (commandElementAst is ExpandableStringExpressionAst); + } + } + + /// + /// Defines command types for Pester test blocks. + /// + public enum PesterCommandType + { + /// + /// Identifies a Describe block. + /// + Describe, + + /// + /// Identifies a Context block. + /// + Context, + + /// + /// Identifies an It block. + /// + It + } + + /// + /// Provides a specialization of SymbolReference containing + /// extra information about Pester test symbols. + /// + public class PesterSymbolReference : SymbolReference + { + /// + /// Lookup for Pester keywords we support. Ideally we could extract these from Pester itself + /// + internal static readonly IReadOnlyDictionary PesterKeywords = + Enum.GetValues(typeof(PesterCommandType)) + .Cast() + .ToDictionary(pct => pct.ToString(), pct => pct, StringComparer.OrdinalIgnoreCase); + + private static char[] DefinitionTrimChars = new char[] { ' ', '{' }; + + /// + /// Gets the name of the test + /// + public string TestName { get; private set; } + + /// + /// Gets the test's command type. + /// + public PesterCommandType Command { get; private set; } + + internal PesterSymbolReference( + ScriptFile scriptFile, + PesterCommandType commandType, + string testLine, + string testName, + IScriptExtent scriptExtent) + : base( + SymbolType.Function, + testLine.TrimEnd(DefinitionTrimChars), + scriptExtent, + scriptFile.FilePath, + testLine) + { + this.Command = commandType; + this.TestName = testName; + } + + internal static PesterCommandType? GetCommandType(string commandName) + { + PesterCommandType pesterCommandType; + if (commandName == null || !PesterKeywords.TryGetValue(commandName, out pesterCommandType)) + { + return null; + } + return pesterCommandType; + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/Symbols/PsdDocumentSymbolProvider.cs b/src/PowerShellEditorServices.Engine/Services/Symbols/PsdDocumentSymbolProvider.cs new file mode 100644 index 000000000..695ed2c02 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/Symbols/PsdDocumentSymbolProvider.cs @@ -0,0 +1,88 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation.Language; + +namespace Microsoft.PowerShell.EditorServices.Symbols +{ + /// + /// Provides an IDocumentSymbolProvider implementation for + /// enumerating symbols in .psd1 files. + /// + public class PsdDocumentSymbolProvider : IDocumentSymbolProvider + { + IEnumerable IDocumentSymbolProvider.ProvideDocumentSymbols( + ScriptFile scriptFile) + { + if ((scriptFile.FilePath != null && + scriptFile.FilePath.EndsWith(".psd1", StringComparison.OrdinalIgnoreCase)) || + IsPowerShellDataFileAst(scriptFile.ScriptAst)) + { + var findHashtableSymbolsVisitor = new FindHashtableSymbolsVisitor(); + scriptFile.ScriptAst.Visit(findHashtableSymbolsVisitor); + return findHashtableSymbolsVisitor.SymbolReferences; + } + + return Enumerable.Empty(); + } + + /// + /// Checks if a given ast represents the root node of a *.psd1 file. + /// + /// The abstract syntax tree of the given script + /// true if the AST represts a *.psd1 file, otherwise false + static public bool IsPowerShellDataFileAst(Ast ast) + { + // sometimes we don't have reliable access to the filename + // so we employ heuristics to check if the contents are + // part of a psd1 file. + return IsPowerShellDataFileAstNode( + new { Item = ast, Children = new List() }, + new Type[] { + typeof(ScriptBlockAst), + typeof(NamedBlockAst), + typeof(PipelineAst), + typeof(CommandExpressionAst), + typeof(HashtableAst) }, + 0); + } + + static private bool IsPowerShellDataFileAstNode(dynamic node, Type[] levelAstMap, int level) + { + var levelAstTypeMatch = node.Item.GetType().Equals(levelAstMap[level]); + if (!levelAstTypeMatch) + { + return false; + } + + if (level == levelAstMap.Length - 1) + { + return levelAstTypeMatch; + } + + var astsFound = (node.Item as Ast).FindAll(a => a is Ast, false); + if (astsFound != null) + { + foreach (var astFound in astsFound) + { + if (!astFound.Equals(node.Item) + && node.Item.Equals(astFound.Parent) + && IsPowerShellDataFileAstNode( + new { Item = astFound, Children = new List() }, + levelAstMap, + level + 1)) + { + return true; + } + } + } + + return false; + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/Symbols/ScriptDocumentSymbolProvider.cs b/src/PowerShellEditorServices.Engine/Services/Symbols/ScriptDocumentSymbolProvider.cs new file mode 100644 index 000000000..2c11747f2 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/Symbols/ScriptDocumentSymbolProvider.cs @@ -0,0 +1,76 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation.Language; + +namespace Microsoft.PowerShell.EditorServices.Symbols +{ + /// + /// Provides an IDocumentSymbolProvider implementation for + /// enumerating symbols in script (.psd1, .psm1) files. + /// + public class ScriptDocumentSymbolProvider : IDocumentSymbolProvider + { + private Version powerShellVersion; + + /// + /// Creates an instance of the ScriptDocumentSymbolProvider to + /// target the specified PowerShell version. + /// + /// The target PowerShell version. + public ScriptDocumentSymbolProvider(Version powerShellVersion) + { + this.powerShellVersion = powerShellVersion; + } + + IEnumerable IDocumentSymbolProvider.ProvideDocumentSymbols( + ScriptFile scriptFile) + { + if (scriptFile != null && + scriptFile.FilePath != null && + (scriptFile.FilePath.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase) || + scriptFile.FilePath.EndsWith(".psm1", StringComparison.OrdinalIgnoreCase))) + { + return + FindSymbolsInDocument( + scriptFile.ScriptAst, + this.powerShellVersion); + } + + return Enumerable.Empty(); + } + + /// + /// Finds all symbols in a script + /// + /// The abstract syntax tree of the given script + /// The PowerShell version the Ast was generated from + /// A collection of SymbolReference objects + static public IEnumerable FindSymbolsInDocument(Ast scriptAst, Version powerShellVersion) + { + IEnumerable symbolReferences = null; + + // TODO: Restore this when we figure out how to support multiple + // PS versions in the new PSES-as-a-module world (issue #276) + // if (powerShellVersion >= new Version(5,0)) + // { + //#if PowerShellv5 + // FindSymbolsVisitor2 findSymbolsVisitor = new FindSymbolsVisitor2(); + // scriptAst.Visit(findSymbolsVisitor); + // symbolReferences = findSymbolsVisitor.SymbolReferences; + //#endif + // } + // else + + FindSymbolsVisitor findSymbolsVisitor = new FindSymbolsVisitor(); + scriptAst.Visit(findSymbolsVisitor); + symbolReferences = findSymbolsVisitor.SymbolReferences; + return symbolReferences; + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/Symbols/ScriptExtent.cs b/src/PowerShellEditorServices.Engine/Services/Symbols/ScriptExtent.cs new file mode 100644 index 000000000..d695de649 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/Symbols/ScriptExtent.cs @@ -0,0 +1,109 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Management.Automation.Language; + +namespace Microsoft.PowerShell.EditorServices.Symbols +{ + /// + /// Provides a default IScriptExtent implementation + /// containing details about a section of script content + /// in a file. + /// + public class ScriptExtent : IScriptExtent + { + #region Properties + + /// + /// Gets the file path of the script file in which this extent is contained. + /// + public string File + { + get; + set; + } + + /// + /// Gets or sets the starting column number of the extent. + /// + public int StartColumnNumber + { + get; + set; + } + + /// + /// Gets or sets the starting line number of the extent. + /// + public int StartLineNumber + { + get; + set; + } + + /// + /// Gets or sets the starting file offset of the extent. + /// + public int StartOffset + { + get; + set; + } + + /// + /// Gets or sets the starting script position of the extent. + /// + public IScriptPosition StartScriptPosition + { + get { throw new NotImplementedException(); } + } + /// + /// Gets or sets the text that is contained within the extent. + /// + public string Text + { + get; + set; + } + + /// + /// Gets or sets the ending column number of the extent. + /// + public int EndColumnNumber + { + get; + set; + } + + /// + /// Gets or sets the ending line number of the extent. + /// + public int EndLineNumber + { + get; + set; + } + + /// + /// Gets or sets the ending file offset of the extent. + /// + public int EndOffset + { + get; + set; + } + + /// + /// Gets the ending script position of the extent. + /// + public IScriptPosition EndScriptPosition + { + get { throw new NotImplementedException(); } + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/Symbols/SymbolReference.cs b/src/PowerShellEditorServices.Engine/Services/Symbols/SymbolReference.cs new file mode 100644 index 000000000..643ab430b --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/Symbols/SymbolReference.cs @@ -0,0 +1,89 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Diagnostics; +using System.Management.Automation.Language; + +namespace Microsoft.PowerShell.EditorServices.Symbols +{ + /// + /// A class that holds the type, name, script extent, and source line of a symbol + /// + [DebuggerDisplay("SymbolType = {SymbolType}, SymbolName = {SymbolName}")] + public class SymbolReference + { + #region Properties + + /// + /// Gets the symbol's type + /// + public SymbolType SymbolType { get; private set; } + + /// + /// Gets the name of the symbol + /// + public string SymbolName { get; private set; } + + /// + /// Gets the script extent of the symbol + /// + public ScriptRegion ScriptRegion { get; private set; } + + /// + /// Gets the contents of the line the given symbol is on + /// + public string SourceLine { get; internal set; } + + /// + /// Gets the path of the file in which the symbol was found. + /// + public string FilePath { get; internal set; } + + #endregion + + /// + /// Constructs and instance of a SymbolReference + /// + /// The higher level type of the symbol + /// The name of the symbol + /// The script extent of the symbol + /// The file path of the symbol + /// The line contents of the given symbol (defaults to empty string) + public SymbolReference( + SymbolType symbolType, + string symbolName, + IScriptExtent scriptExtent, + string filePath = "", + string sourceLine = "") + { + // TODO: Verify params + this.SymbolType = symbolType; + this.SymbolName = symbolName; + this.ScriptRegion = ScriptRegion.Create(scriptExtent); + this.FilePath = filePath; + this.SourceLine = sourceLine; + + // TODO: Make sure end column number usage is correct + + // Build the display string + //this.DisplayString = + // string.Format( + // "{0} {1}") + } + + /// + /// Constructs and instance of a SymbolReference + /// + /// The higher level type of the symbol + /// The script extent of the symbol + /// The file path of the symbol + /// The line contents of the given symbol (defaults to empty string) + public SymbolReference(SymbolType symbolType, IScriptExtent scriptExtent, string filePath = "", string sourceLine = "") + : this(symbolType, scriptExtent.Text, scriptExtent, filePath, sourceLine) + { + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/Symbols/SymbolType.cs b/src/PowerShellEditorServices.Engine/Services/Symbols/SymbolType.cs new file mode 100644 index 000000000..2dba9a0a0 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/Symbols/SymbolType.cs @@ -0,0 +1,48 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.PowerShell.EditorServices +{ + /// + /// A way to define symbols on a higher level + /// + public enum SymbolType + { + /// + /// The symbol type is unknown + /// + Unknown = 0, + + /// + /// The symbol is a vairable + /// + Variable, + + /// + /// The symbol is a function + /// + Function, + + /// + /// The symbol is a parameter + /// + Parameter, + + /// + /// The symbol is a DSC configuration + /// + Configuration, + + /// + /// The symbol is a workflow + /// + Workflow, + + /// + /// The symbol is a hashtable key + /// + HashtableKey + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/Symbols/SymbolsService.cs b/src/PowerShellEditorServices.Engine/Services/Symbols/SymbolsService.cs new file mode 100644 index 000000000..1b4a38b19 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/Symbols/SymbolsService.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Symbols; + +namespace Microsoft.PowerShell.EditorServices +{ + /// + /// Provides a high-level service for performing code completion and + /// navigation operations on PowerShell scripts. + /// + public class SymbolsService + { + #region Private Fields + + const int DefaultWaitTimeoutMilliseconds = 5000; + + private readonly ILogger _logger; + + private readonly IDocumentSymbolProvider[] _documentSymbolProviders; + + #endregion + + #region Constructors + + /// + /// Constructs an instance of the SymbolsService class and uses + /// the given Runspace to execute language service operations. + /// + /// + /// The PowerShellContext in which language service operations will be executed. + /// + /// An ILogger implementation used for writing log messages. + public SymbolsService( + ILoggerFactory factory) + { + _logger = factory.CreateLogger(); + _documentSymbolProviders = new IDocumentSymbolProvider[] + { + new ScriptDocumentSymbolProvider(VersionUtils.PSVersion), + new PsdDocumentSymbolProvider(), + new PesterDocumentSymbolProvider() + }; + } + + #endregion + + + + /// + /// Finds all the symbols in a file. + /// + /// The ScriptFile in which the symbol can be located. + /// + public List FindSymbolsInFile(ScriptFile scriptFile) + { + Validate.IsNotNull(nameof(scriptFile), scriptFile); + + var foundOccurrences = new List(); + foreach (IDocumentSymbolProvider symbolProvider in _documentSymbolProviders) + { + foreach (SymbolReference reference in symbolProvider.ProvideDocumentSymbols(scriptFile)) + { + reference.SourceLine = scriptFile.GetLine(reference.ScriptRegion.StartLineNumber); + reference.FilePath = scriptFile.FilePath; + foundOccurrences.Add(reference); + } + } + + return foundOccurrences; + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/TextDocument/BufferPosition.cs b/src/PowerShellEditorServices.Engine/Services/TextDocument/BufferPosition.cs new file mode 100644 index 000000000..effdd7660 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/TextDocument/BufferPosition.cs @@ -0,0 +1,111 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Diagnostics; + +namespace Microsoft.PowerShell.EditorServices +{ + /// + /// Provides details about a position in a file buffer. All + /// positions are expressed in 1-based positions (i.e. the + /// first line and column in the file is position 1,1). + /// + [DebuggerDisplay("Position = {Line}:{Column}")] + public class BufferPosition + { + #region Properties + + /// + /// Provides an instance that represents a position that has not been set. + /// + public static readonly BufferPosition None = new BufferPosition(-1, -1); + + /// + /// Gets the line number of the position in the buffer. + /// + public int Line { get; private set; } + + /// + /// Gets the column number of the position in the buffer. + /// + public int Column { get; private set; } + + #endregion + + #region Constructors + + /// + /// Creates a new instance of the BufferPosition class. + /// + /// The line number of the position. + /// The column number of the position. + public BufferPosition(int line, int column) + { + this.Line = line; + this.Column = column; + } + + #endregion + + #region Public Methods + + /// + /// Compares two instances of the BufferPosition class. + /// + /// The object to which this instance will be compared. + /// True if the positions are equal, false otherwise. + public override bool Equals(object obj) + { + if (!(obj is BufferPosition)) + { + return false; + } + + BufferPosition other = (BufferPosition)obj; + + return + this.Line == other.Line && + this.Column == other.Column; + } + + /// + /// Calculates a unique hash code that represents this instance. + /// + /// A hash code representing this instance. + public override int GetHashCode() + { + return this.Line.GetHashCode() ^ this.Column.GetHashCode(); + } + + /// + /// Compares two positions to check if one is greater than the other. + /// + /// The first position to compare. + /// The second position to compare. + /// True if positionOne is greater than positionTwo. + public static bool operator >(BufferPosition positionOne, BufferPosition positionTwo) + { + return + (positionOne != null && positionTwo == null) || + (positionOne.Line > positionTwo.Line) || + (positionOne.Line == positionTwo.Line && + positionOne.Column > positionTwo.Column); + } + + /// + /// Compares two positions to check if one is less than the other. + /// + /// The first position to compare. + /// The second position to compare. + /// True if positionOne is less than positionTwo. + public static bool operator <(BufferPosition positionOne, BufferPosition positionTwo) + { + return positionTwo > positionOne; + } + + #endregion + } +} + diff --git a/src/PowerShellEditorServices.Engine/Services/TextDocument/BufferRange.cs b/src/PowerShellEditorServices.Engine/Services/TextDocument/BufferRange.cs new file mode 100644 index 000000000..147eed042 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/TextDocument/BufferRange.cs @@ -0,0 +1,123 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Diagnostics; + +namespace Microsoft.PowerShell.EditorServices +{ + /// + /// Provides details about a range between two positions in + /// a file buffer. + /// + [DebuggerDisplay("Start = {Start.Line}:{Start.Column}, End = {End.Line}:{End.Column}")] + public class BufferRange + { + #region Properties + + /// + /// Provides an instance that represents a range that has not been set. + /// + public static readonly BufferRange None = new BufferRange(0, 0, 0, 0); + + /// + /// Gets the start position of the range in the buffer. + /// + public BufferPosition Start { get; private set; } + + /// + /// Gets the end position of the range in the buffer. + /// + public BufferPosition End { get; private set; } + + /// + /// Returns true if the current range is non-zero, i.e. + /// contains valid start and end positions. + /// + public bool HasRange + { + get + { + return this.Equals(BufferRange.None); + } + } + + #endregion + + #region Constructors + + /// + /// Creates a new instance of the BufferRange class. + /// + /// The start position of the range. + /// The end position of the range. + public BufferRange(BufferPosition start, BufferPosition end) + { + if (start > end) + { + throw new ArgumentException( + string.Format( + "Start position ({0}, {1}) must come before or be equal to the end position ({2}, {3}).", + start.Line, start.Column, + end.Line, end.Column)); + } + + this.Start = start; + this.End = end; + } + + /// + /// Creates a new instance of the BufferRange class. + /// + /// The 1-based starting line number of the range. + /// The 1-based starting column number of the range. + /// The 1-based ending line number of the range. + /// The 1-based ending column number of the range. + public BufferRange( + int startLine, + int startColumn, + int endLine, + int endColumn) + { + this.Start = new BufferPosition(startLine, startColumn); + this.End = new BufferPosition(endLine, endColumn); + } + + #endregion + + #region Public Methods + + /// + /// Compares two instances of the BufferRange class. + /// + /// The object to which this instance will be compared. + /// True if the ranges are equal, false otherwise. + public override bool Equals(object obj) + { + if (!(obj is BufferRange)) + { + return false; + } + + BufferRange other = (BufferRange)obj; + + return + this.Start.Equals(other.Start) && + this.End.Equals(other.End); + } + + /// + /// Calculates a unique hash code that represents this instance. + /// + /// A hash code representing this instance. + public override int GetHashCode() + { + return this.Start.GetHashCode() ^ this.End.GetHashCode(); + } + + #endregion + } +} + diff --git a/src/PowerShellEditorServices.Engine/Services/TextDocument/FileChange.cs b/src/PowerShellEditorServices.Engine/Services/TextDocument/FileChange.cs new file mode 100644 index 000000000..79f6925ea --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/TextDocument/FileChange.cs @@ -0,0 +1,45 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.PowerShell.EditorServices +{ + /// + /// Contains details relating to a content change in an open file. + /// + public class FileChange + { + /// + /// The string which is to be inserted in the file. + /// + public string InsertString { get; set; } + + /// + /// The 1-based line number where the change starts. + /// + public int Line { get; set; } + + /// + /// The 1-based column offset where the change starts. + /// + public int Offset { get; set; } + + /// + /// The 1-based line number where the change ends. + /// + public int EndLine { get; set; } + + /// + /// The 1-based column offset where the change ends. + /// + public int EndOffset { get; set; } + + /// + /// Indicates that the InsertString is an overwrite + /// of the content, and all stale content and metadata + /// should be discarded. + /// + public bool IsReload { get; set; } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/TextDocument/FilePosition.cs b/src/PowerShellEditorServices.Engine/Services/TextDocument/FilePosition.cs new file mode 100644 index 000000000..a7c9036c7 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/TextDocument/FilePosition.cs @@ -0,0 +1,109 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.PowerShell.EditorServices +{ + /// + /// Provides details and operations for a buffer position in a + /// specific file. + /// + public class FilePosition : BufferPosition + { + #region Private Fields + + private ScriptFile scriptFile; + + #endregion + + #region Constructors + + /// + /// Creates a new FilePosition instance for the 1-based line and + /// column numbers in the specified file. + /// + /// The ScriptFile in which the position is located. + /// The 1-based line number in the file. + /// The 1-based column number in the file. + public FilePosition( + ScriptFile scriptFile, + int line, + int column) + : base(line, column) + { + this.scriptFile = scriptFile; + } + + /// + /// Creates a new FilePosition instance for the specified file by + /// copying the specified BufferPosition + /// + /// The ScriptFile in which the position is located. + /// The original BufferPosition from which the line and column will be copied. + public FilePosition( + ScriptFile scriptFile, + BufferPosition copiedPosition) + : this(scriptFile, copiedPosition.Line, copiedPosition.Column) + { + scriptFile.ValidatePosition(copiedPosition); + } + + #endregion + + #region Public Methods + + /// + /// Gets a FilePosition relative to this position by adding the + /// provided line and column offset relative to the contents of + /// the current file. + /// + /// The line offset to add to this position. + /// The column offset to add to this position. + /// A new FilePosition instance for the calculated position. + public FilePosition AddOffset(int lineOffset, int columnOffset) + { + return this.scriptFile.CalculatePosition( + this, + lineOffset, + columnOffset); + } + + /// + /// Gets a FilePosition for the line and column position + /// of the beginning of the current line after any initial + /// whitespace for indentation. + /// + /// A new FilePosition instance for the calculated position. + public FilePosition GetLineStart() + { + string scriptLine = scriptFile.FileLines[this.Line - 1]; + + int lineStartColumn = 1; + for (int i = 0; i < scriptLine.Length; i++) + { + if (!char.IsWhiteSpace(scriptLine[i])) + { + lineStartColumn = i + 1; + break; + } + } + + return new FilePosition(this.scriptFile, this.Line, lineStartColumn); + } + + /// + /// Gets a FilePosition for the line and column position + /// of the end of the current line. + /// + /// A new FilePosition instance for the calculated position. + public FilePosition GetLineEnd() + { + string scriptLine = scriptFile.FileLines[this.Line - 1]; + return new FilePosition(this.scriptFile, this.Line, scriptLine.Length + 1); + } + + #endregion + } +} + diff --git a/src/PowerShellEditorServices.Engine/Services/TextDocument/FoldingReference.cs b/src/PowerShellEditorServices.Engine/Services/TextDocument/FoldingReference.cs new file mode 100644 index 000000000..2bfe4874e --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/TextDocument/FoldingReference.cs @@ -0,0 +1,113 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Collections.Generic; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices +{ + /// + /// A class that holds the information for a foldable region of text in a document + /// + public class FoldingReference: IComparable + { + /// + /// The zero-based line number from where the folded range starts. + /// + public int StartLine { get; set; } + + /// + /// The zero-based character offset from where the folded range starts. If not defined, defaults to the length of the start line. + /// + public int StartCharacter { get; set; } = 0; + + /// + /// The zero-based line number where the folded range ends. + /// + public int EndLine { get; set; } + + /// + /// The zero-based character offset before the folded range ends. If not defined, defaults to the length of the end line. + /// + public int EndCharacter { get; set; } = 0; + + /// + /// Describes the kind of the folding range such as `comment' or 'region'. + /// + public FoldingRangeKind? Kind { get; set; } + + /// + /// A custom comparable method which can properly sort FoldingReference objects + /// + public int CompareTo(FoldingReference that) { + // Initially look at the start line + if (this.StartLine < that.StartLine) { return -1; } + if (this.StartLine > that.StartLine) { return 1; } + + // They have the same start line so now consider the end line. + // The biggest line range is sorted first + if (this.EndLine > that.EndLine) { return -1; } + if (this.EndLine < that.EndLine) { return 1; } + + // They have the same lines, but what about character offsets + if (this.StartCharacter < that.StartCharacter) { return -1; } + if (this.StartCharacter > that.StartCharacter) { return 1; } + if (this.EndCharacter < that.EndCharacter) { return -1; } + if (this.EndCharacter > that.EndCharacter) { return 1; } + + // They're the same range, but what about kind + return that.Kind.Value - this.Kind.Value; + } + } + + /// + /// A class that holds a list of FoldingReferences and ensures that when adding a reference that the + /// folding rules are obeyed, e.g. Only one fold per start line + /// + public class FoldingReferenceList + { + private readonly Dictionary references = new Dictionary(); + + /// + /// Return all references in the list + /// + public IEnumerable References + { + get + { + return references.Values; + } + } + + /// + /// Adds a FoldingReference to the list and enforces ordering rules e.g. Only one fold per start line + /// + public void SafeAdd(FoldingReference item) + { + if (item == null) { return; } + + // Only add the item if it hasn't been seen before or it's the largest range + if (references.TryGetValue(item.StartLine, out FoldingReference currentItem)) + { + if (currentItem.CompareTo(item) == 1) { references[item.StartLine] = item; } + } + else + { + references[item.StartLine] = item; + } + } + + /// + /// Helper method to easily convert the Dictionary Values into an array + /// + public FoldingReference[] ToArray() + { + var result = new FoldingReference[references.Count]; + references.Values.CopyTo(result, 0); + return result; + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/TextDocument/Handlers/FoldingRangeHandler.cs b/src/PowerShellEditorServices.Engine/Services/TextDocument/Handlers/FoldingRangeHandler.cs new file mode 100644 index 000000000..9aee91b0e --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/TextDocument/Handlers/FoldingRangeHandler.cs @@ -0,0 +1,77 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; + +namespace PowerShellEditorServices.Engine.Services.Handlers +{ + public class FoldingRangeHandler : IFoldingRangeHandler + { + private readonly DocumentSelector _documentSelector = new DocumentSelector( + new DocumentFilter() + { + Pattern = "**/*.ps*1" + } + ); + + private readonly ILogger _logger; + private readonly ConfigurationService _configurationService; + private readonly WorkspaceService _workspaceService; + + private FoldingRangeCapability _capability; + + public FoldingRangeHandler(ILoggerFactory factory, ConfigurationService configurationService, WorkspaceService workspaceService) + { + _logger = factory.CreateLogger(); + _configurationService = configurationService; + _workspaceService = workspaceService; + } + public TextDocumentRegistrationOptions GetRegistrationOptions() + { + return new TextDocumentRegistrationOptions() + { + DocumentSelector = _documentSelector, + }; + } + + public Task> Handle(FoldingRangeRequestParam request, CancellationToken cancellationToken) + { + // TODO Should be using dynamic registrations + if (!_configurationService.CurrentSettings.CodeFolding.Enable) { return null; } + + // Avoid crash when using untitled: scheme or any other scheme where the document doesn't + // have a backing file. https://github.com/PowerShell/vscode-powershell/issues/1676 + // Perhaps a better option would be to parse the contents of the document as a string + // as opposed to reading a file but the scenario of "no backing file" probably doesn't + // warrant the extra effort. + if (!_workspaceService.TryGetFile(request.TextDocument.Uri.ToString(), out ScriptFile scriptFile)) { return null; } + + var result = new List(); + + // If we're showing the last line, decrement the Endline of all regions by one. + int endLineOffset = _configurationService.CurrentSettings.CodeFolding.ShowLastLine ? -1 : 0; + + foreach (FoldingReference fold in TokenOperations.FoldableReferences(scriptFile.ScriptTokens).References) + { + result.Add(new FoldingRange { + EndCharacter = fold.EndCharacter, + EndLine = fold.EndLine + endLineOffset, + Kind = fold.Kind, + StartCharacter = fold.StartCharacter, + StartLine = fold.StartLine + }); + } + + return Task.FromResult(new Container(result)); + } + + public void SetCapability(FoldingRangeCapability capability) + { + _capability = capability; + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/TextDocument/Handlers/FormattingHandlers.cs b/src/PowerShellEditorServices.Engine/Services/TextDocument/Handlers/FormattingHandlers.cs new file mode 100644 index 000000000..b66e581de --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/TextDocument/Handlers/FormattingHandlers.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; + +namespace PowerShellEditorServices.Engine.Services.Handlers +{ + public class DocumentFormattingHandler : IDocumentFormattingHandler + { + private readonly DocumentSelector _documentSelector = new DocumentSelector( + new DocumentFilter() + { + Pattern = "**/*.ps*1" + } + ); + + private readonly ILogger _logger; + private readonly AnalysisService _analysisService; + private readonly ConfigurationService _configurationService; + private readonly WorkspaceService _workspaceService; + private DocumentFormattingCapability _capability; + + public DocumentFormattingHandler(ILoggerFactory factory, AnalysisService analysisService, ConfigurationService configurationService, WorkspaceService workspaceService) + { + _logger = factory.CreateLogger(); + _analysisService = analysisService; + _configurationService = configurationService; + _workspaceService = workspaceService; + } + + public TextDocumentRegistrationOptions GetRegistrationOptions() + { + return new TextDocumentRegistrationOptions + { + DocumentSelector = _documentSelector + }; + } + + public async Task Handle(DocumentFormattingParams request, CancellationToken cancellationToken) + { + var scriptFile = _workspaceService.GetFile(request.TextDocument.Uri.ToString()); + var pssaSettings = _configurationService.CurrentSettings.CodeFormatting.GetPSSASettingsHashtable( + (int)request.Options.TabSize, + request.Options.InsertSpaces); + + + // TODO raise an error event in case format returns null; + string formattedScript; + Range editRange; + var extent = scriptFile.ScriptAst.Extent; + + // todo create an extension for converting range to script extent + editRange = new Range + { + Start = new Position + { + Line = extent.StartLineNumber - 1, + Character = extent.StartColumnNumber - 1 + }, + End = new Position + { + Line = extent.EndLineNumber - 1, + Character = extent.EndColumnNumber - 1 + } + }; + + formattedScript = await _analysisService.FormatAsync( + scriptFile.Contents, + pssaSettings, + null); + formattedScript = formattedScript ?? scriptFile.Contents; + + return new TextEditContainer(new TextEdit + { + NewText = formattedScript, + Range = editRange + }); + } + + public void SetCapability(DocumentFormattingCapability capability) + { + _capability = capability; + } + } + + public class DocumentRangeFormattingHandler : IDocumentRangeFormattingHandler + { + private readonly DocumentSelector _documentSelector = new DocumentSelector( + new DocumentFilter() + { + Pattern = "**/*.ps*1" + } + ); + + private readonly ILogger _logger; + private readonly AnalysisService _analysisService; + private readonly ConfigurationService _configurationService; + private readonly WorkspaceService _workspaceService; + private DocumentRangeFormattingCapability _capability; + + public DocumentRangeFormattingHandler(ILoggerFactory factory, AnalysisService analysisService, ConfigurationService configurationService, WorkspaceService workspaceService) + { + _logger = factory.CreateLogger(); + _analysisService = analysisService; + _configurationService = configurationService; + _workspaceService = workspaceService; + } + + public TextDocumentRegistrationOptions GetRegistrationOptions() + { + return new TextDocumentRegistrationOptions + { + DocumentSelector = _documentSelector + }; + } + + public async Task Handle(DocumentRangeFormattingParams request, CancellationToken cancellationToken) + { + var scriptFile = _workspaceService.GetFile(request.TextDocument.Uri.ToString()); + var pssaSettings = _configurationService.CurrentSettings.CodeFormatting.GetPSSASettingsHashtable( + (int)request.Options.TabSize, + request.Options.InsertSpaces); + + // TODO raise an error event in case format returns null; + string formattedScript; + Range editRange; + var extent = scriptFile.ScriptAst.Extent; + + // TODO create an extension for converting range to script extent + editRange = new Range + { + Start = new Position + { + Line = extent.StartLineNumber - 1, + Character = extent.StartColumnNumber - 1 + }, + End = new Position + { + Line = extent.EndLineNumber - 1, + Character = extent.EndColumnNumber - 1 + } + }; + + Range range = request.Range; + var rangeList = range == null ? null : new int[] { + (int)range.Start.Line + 1, + (int)range.Start.Character + 1, + (int)range.End.Line + 1, + (int)range.End.Character + 1}; + + formattedScript = await _analysisService.FormatAsync( + scriptFile.Contents, + pssaSettings, + rangeList); + formattedScript = formattedScript ?? scriptFile.Contents; + + return new TextEditContainer(new TextEdit + { + NewText = formattedScript, + Range = editRange + }); + } + + public void SetCapability(DocumentRangeFormattingCapability capability) + { + _capability = capability; + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/TextDocument/Handlers/TextDocumentHandler.cs b/src/PowerShellEditorServices.Engine/Services/TextDocument/Handlers/TextDocumentHandler.cs new file mode 100644 index 000000000..34f5358b9 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/TextDocument/Handlers/TextDocumentHandler.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices; +using OmniSharp.Extensions.Embedded.MediatR; +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; +using OmniSharp.Extensions.LanguageServer.Protocol.Server.Capabilities; + +namespace PowerShellEditorServices.Engine.Services.Handlers +{ + class TextDocumentHandler : ITextDocumentSyncHandler + { + + private readonly ILogger _logger; + private readonly AnalysisService _analysisService; + private readonly WorkspaceService _workspaceService; + + private readonly DocumentSelector _documentSelector = new DocumentSelector( + new DocumentFilter() + { + Pattern = "**/*.ps*1" + } + ); + + private SynchronizationCapability _capability; + + public TextDocumentSyncKind Change => TextDocumentSyncKind.Incremental; + + public TextDocumentHandler(ILoggerFactory factory, AnalysisService analysisService, WorkspaceService workspaceService) + { + _logger = factory.CreateLogger(); + _analysisService = analysisService; + _workspaceService = workspaceService; + } + + public Task Handle(DidChangeTextDocumentParams notification, CancellationToken token) + { + List changedFiles = new List(); + + // A text change notification can batch multiple change requests + foreach (TextDocumentContentChangeEvent textChange in notification.ContentChanges) + { + ScriptFile changedFile = _workspaceService.GetFile(notification.TextDocument.Uri.ToString()); + + changedFile.ApplyChange( + GetFileChangeDetails( + textChange.Range, + textChange.Text)); + + changedFiles.Add(changedFile); + } + + // TODO: Get all recently edited files in the workspace + _analysisService.RunScriptDiagnosticsAsync(changedFiles.ToArray()); + return Unit.Task; + } + + TextDocumentChangeRegistrationOptions IRegistration.GetRegistrationOptions() + { + return new TextDocumentChangeRegistrationOptions() + { + DocumentSelector = _documentSelector, + SyncKind = Change + }; + } + + public void SetCapability(SynchronizationCapability capability) + { + _capability = capability; + } + + public Task Handle(DidOpenTextDocumentParams notification, CancellationToken token) + { + ScriptFile openedFile = + _workspaceService.GetFileBuffer( + notification.TextDocument.Uri.ToString(), + notification.TextDocument.Text); + + // TODO: Get all recently edited files in the workspace + _analysisService.RunScriptDiagnosticsAsync(new ScriptFile[] { openedFile }); + + _logger.LogTrace("Finished opening document."); + return Unit.Task; + } + + TextDocumentRegistrationOptions IRegistration.GetRegistrationOptions() + { + return new TextDocumentRegistrationOptions() + { + DocumentSelector = _documentSelector, + }; + } + + public Task Handle(DidCloseTextDocumentParams notification, CancellationToken token) + { + // Find and close the file in the current session + var fileToClose = _workspaceService.GetFile(notification.TextDocument.Uri.ToString()); + + if (fileToClose != null) + { + _workspaceService.CloseFile(fileToClose); + _analysisService.ClearMarkers(fileToClose); + } + + _logger.LogTrace("Finished closing document."); + return Unit.Task; + } + + public Task Handle(DidSaveTextDocumentParams notification, CancellationToken token) + { + ScriptFile savedFile = + _workspaceService.GetFile( + notification.TextDocument.Uri.ToString()); + // TODO bring back + // if (savedFile != null) + // { + // if (this.editorSession.RemoteFileManager.IsUnderRemoteTempPath(savedFile.FilePath)) + // { + // await this.editorSession.RemoteFileManager.SaveRemoteFileAsync( + // savedFile.FilePath); + // } + // } + return Unit.Task; + } + + TextDocumentSaveRegistrationOptions IRegistration.GetRegistrationOptions() + { + return new TextDocumentSaveRegistrationOptions() + { + DocumentSelector = _documentSelector, + IncludeText = true + }; + } + public TextDocumentAttributes GetTextDocumentAttributes(Uri uri) + { + return new TextDocumentAttributes(uri, "powershell"); + } + + private static FileChange GetFileChangeDetails(Range changeRange, string insertString) + { + // The protocol's positions are zero-based so add 1 to all offsets + + if (changeRange == null) return new FileChange { InsertString = insertString, IsReload = true }; + + return new FileChange + { + InsertString = insertString, + Line = (int)(changeRange.Start.Line + 1), + Offset = (int)(changeRange.Start.Character + 1), + EndLine = (int)(changeRange.End.Line + 1), + EndOffset = (int)(changeRange.End.Character + 1), + IsReload = false + }; + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/TextDocument/ScriptFile.cs b/src/PowerShellEditorServices.Engine/Services/TextDocument/ScriptFile.cs new file mode 100644 index 000000000..9253b81ad --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/TextDocument/ScriptFile.cs @@ -0,0 +1,678 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Management.Automation; +using System.Management.Automation.Language; +using System.Runtime.InteropServices; + +namespace Microsoft.PowerShell.EditorServices +{ + /// + /// Contains the details and contents of an open script file. + /// + public class ScriptFile + { + #region Private Fields + + private static readonly string[] s_newlines = new[] + { + "\r\n", + "\n" + }; + + private Version powerShellVersion; + + #endregion + + #region Properties + + /// + /// Gets a unique string that identifies this file. At this time, + /// this property returns a normalized version of the value stored + /// in the FilePath property. + /// + public string Id + { + get { return this.FilePath.ToLower(); } + } + + /// + /// Gets the path at which this file resides. + /// + public string FilePath { get; private set; } + + /// + /// Gets the path which the editor client uses to identify this file. + /// + public string ClientFilePath { get; private set; } + + /// + /// Gets the file path in LSP DocumentUri form. The ClientPath property must not be null. + /// + public string DocumentUri + { + get + { + return this.ClientFilePath == null + ? string.Empty + : WorkspaceService.ConvertPathToDocumentUri(this.ClientFilePath); + } + } + + /// + /// Gets or sets a boolean that determines whether + /// semantic analysis should be enabled for this file. + /// For internal use only. + /// + internal bool IsAnalysisEnabled { get; set; } + + /// + /// Gets a boolean that determines whether this file is + /// in-memory or not (either unsaved or non-file content). + /// + public bool IsInMemory { get; private set; } + + /// + /// Gets a string containing the full contents of the file. + /// + public string Contents + { + get + { + return string.Join(Environment.NewLine, this.FileLines); + } + } + + /// + /// Gets a BufferRange that represents the entire content + /// range of the file. + /// + public BufferRange FileRange { get; private set; } + + /// + /// Gets the list of syntax markers found by parsing this + /// file's contents. + /// + public List DiagnosticMarkers + { + get; + private set; + } + + /// + /// Gets the list of strings for each line of the file. + /// + internal List FileLines + { + get; + private set; + } + + /// + /// Gets the ScriptBlockAst representing the parsed script contents. + /// + public ScriptBlockAst ScriptAst + { + get; + private set; + } + + /// + /// Gets the array of Tokens representing the parsed script contents. + /// + public Token[] ScriptTokens + { + get; + private set; + } + + /// + /// Gets the array of filepaths dot sourced in this ScriptFile + /// + public string[] ReferencedFiles + { + get; + private set; + } + + #endregion + + #region Constructors + + /// + /// Creates a new ScriptFile instance by reading file contents from + /// the given TextReader. + /// + /// The path at which the script file resides. + /// The path which the client uses to identify the file. + /// The TextReader to use for reading the file's contents. + /// The version of PowerShell for which the script is being parsed. + public ScriptFile( + string filePath, + string clientFilePath, + TextReader textReader, + Version powerShellVersion) + { + this.FilePath = filePath; + this.ClientFilePath = clientFilePath; + this.IsAnalysisEnabled = true; + this.IsInMemory = WorkspaceService.IsPathInMemory(filePath); + this.powerShellVersion = powerShellVersion; + + // SetFileContents() calls ParseFileContents() which initializes the rest of the properties. + this.SetFileContents(textReader.ReadToEnd()); + } + + /// + /// Creates a new ScriptFile instance with the specified file contents. + /// + /// The path at which the script file resides. + /// The path which the client uses to identify the file. + /// The initial contents of the script file. + /// The version of PowerShell for which the script is being parsed. + public ScriptFile( + string filePath, + string clientFilePath, + string initialBuffer, + Version powerShellVersion) + : this( + filePath, + clientFilePath, + new StringReader(initialBuffer), + powerShellVersion) + { + } + + /// + /// Creates a new ScriptFile instance with the specified filepath. + /// + /// The path at which the script file resides. + /// The path which the client uses to identify the file. + /// The version of PowerShell for which the script is being parsed. + public ScriptFile( + string filePath, + string clientFilePath, + Version powerShellVersion) + : this( + filePath, + clientFilePath, + File.ReadAllText(filePath), + powerShellVersion) + { + } + + #endregion + + #region Public Methods + + /// + /// Get the lines in a string. + /// + /// Input string to be split up into lines. + /// The lines in the string. + [Obsolete("This method is not designed for public exposure and will be retired in later versions of EditorServices")] + public static IList GetLines(string text) + { + return GetLinesInternal(text); + } + + /// + /// Get the lines in a string. + /// + /// Input string to be split up into lines. + /// The lines in the string. + internal static List GetLinesInternal(string text) + { + if (text == null) + { + throw new ArgumentNullException(nameof(text)); + } + + return new List(text.Split(s_newlines, StringSplitOptions.None)); + } + + /// + /// Deterines whether the supplied path indicates the file is an "untitled:Unitled-X" + /// which has not been saved to file. + /// + /// The path to check. + /// True if the path is an untitled file, false otherwise. + public static bool IsUntitledPath(string path) + { + Validate.IsNotNull(nameof(path), path); + + return path.ToLower().StartsWith("untitled:"); + } + + /// + /// Gets a line from the file's contents. + /// + /// The 1-based line number in the file. + /// The complete line at the given line number. + public string GetLine(int lineNumber) + { + Validate.IsWithinRange( + "lineNumber", lineNumber, + 1, this.FileLines.Count + 1); + + return this.FileLines[lineNumber - 1]; + } + + /// + /// Gets a range of lines from the file's contents. + /// + /// The buffer range from which lines will be extracted. + /// An array of strings from the specified range of the file. + public string[] GetLinesInRange(BufferRange bufferRange) + { + this.ValidatePosition(bufferRange.Start); + this.ValidatePosition(bufferRange.End); + + List linesInRange = new List(); + + int startLine = bufferRange.Start.Line, + endLine = bufferRange.End.Line; + + for (int line = startLine; line <= endLine; line++) + { + string currentLine = this.FileLines[line - 1]; + int startColumn = + line == startLine + ? bufferRange.Start.Column + : 1; + int endColumn = + line == endLine + ? bufferRange.End.Column + : currentLine.Length + 1; + + currentLine = + currentLine.Substring( + startColumn - 1, + endColumn - startColumn); + + linesInRange.Add(currentLine); + } + + return linesInRange.ToArray(); + } + + /// + /// Throws ArgumentOutOfRangeException if the given position is outside + /// of the file's buffer extents. + /// + /// The position in the buffer to be validated. + public void ValidatePosition(BufferPosition bufferPosition) + { + this.ValidatePosition( + bufferPosition.Line, + bufferPosition.Column); + } + + /// + /// Throws ArgumentOutOfRangeException if the given position is outside + /// of the file's buffer extents. + /// + /// The 1-based line to be validated. + /// The 1-based column to be validated. + public void ValidatePosition(int line, int column) + { + int maxLine = this.FileLines.Count; + if (line < 1 || line > maxLine) + { + throw new ArgumentOutOfRangeException($"Position {line}:{column} is outside of the line range of 1 to {maxLine}."); + } + + // The maximum column is either **one past** the length of the string + // or 1 if the string is empty. + string lineString = this.FileLines[line - 1]; + int maxColumn = lineString.Length > 0 ? lineString.Length + 1 : 1; + + if (column < 1 || column > maxColumn) + { + throw new ArgumentOutOfRangeException($"Position {line}:{column} is outside of the column range of 1 to {maxColumn}."); + } + } + + + /// + /// Defunct ValidatePosition method call. The isInsertion parameter is ignored. + /// + /// + /// + /// + [Obsolete("Use ValidatePosition(int, int) instead")] + public void ValidatePosition(int line, int column, bool isInsertion) + { + ValidatePosition(line, column); + } + + /// + /// Applies the provided FileChange to the file's contents + /// + /// The FileChange to apply to the file's contents. + public void ApplyChange(FileChange fileChange) + { + // Break up the change lines + string[] changeLines = fileChange.InsertString.Split('\n'); + + if (fileChange.IsReload) + { + this.FileLines.Clear(); + foreach (var changeLine in changeLines) + { + this.FileLines.Add(changeLine); + } + } + else + { + // VSCode sometimes likes to give the change start line as (FileLines.Count + 1). + // This used to crash EditorServices, but we now treat it as an append. + // See https://github.com/PowerShell/vscode-powershell/issues/1283 + if (fileChange.Line == this.FileLines.Count + 1) + { + foreach (string addedLine in changeLines) + { + string finalLine = addedLine.TrimEnd('\r'); + this.FileLines.Add(finalLine); + } + } + // Similarly, when lines are deleted from the end of the file, + // VSCode likes to give the end line as (FileLines.Count + 1). + else if (fileChange.EndLine == this.FileLines.Count + 1 && String.Empty.Equals(fileChange.InsertString)) + { + int lineIndex = fileChange.Line - 1; + this.FileLines.RemoveRange(lineIndex, this.FileLines.Count - lineIndex); + } + // Otherwise, the change needs to go between existing content + else + { + this.ValidatePosition(fileChange.Line, fileChange.Offset); + this.ValidatePosition(fileChange.EndLine, fileChange.EndOffset); + + // Get the first fragment of the first line + string firstLineFragment = + this.FileLines[fileChange.Line - 1] + .Substring(0, fileChange.Offset - 1); + + // Get the last fragment of the last line + string endLine = this.FileLines[fileChange.EndLine - 1]; + string lastLineFragment = + endLine.Substring( + fileChange.EndOffset - 1, + (this.FileLines[fileChange.EndLine - 1].Length - fileChange.EndOffset) + 1); + + // Remove the old lines + for (int i = 0; i <= fileChange.EndLine - fileChange.Line; i++) + { + this.FileLines.RemoveAt(fileChange.Line - 1); + } + + // Build and insert the new lines + int currentLineNumber = fileChange.Line; + for (int changeIndex = 0; changeIndex < changeLines.Length; changeIndex++) + { + // Since we split the lines above using \n, make sure to + // trim the ending \r's off as well. + string finalLine = changeLines[changeIndex].TrimEnd('\r'); + + // Should we add first or last line fragments? + if (changeIndex == 0) + { + // Append the first line fragment + finalLine = firstLineFragment + finalLine; + } + if (changeIndex == changeLines.Length - 1) + { + // Append the last line fragment + finalLine = finalLine + lastLineFragment; + } + + this.FileLines.Insert(currentLineNumber - 1, finalLine); + currentLineNumber++; + } + } + } + + // Parse the script again to be up-to-date + this.ParseFileContents(); + } + + /// + /// Calculates the zero-based character offset of a given + /// line and column position in the file. + /// + /// The 1-based line number from which the offset is calculated. + /// The 1-based column number from which the offset is calculated. + /// The zero-based offset for the given file position. + public int GetOffsetAtPosition(int lineNumber, int columnNumber) + { + Validate.IsWithinRange("lineNumber", lineNumber, 1, this.FileLines.Count); + Validate.IsGreaterThan("columnNumber", columnNumber, 0); + + int offset = 0; + + for (int i = 0; i < lineNumber; i++) + { + if (i == lineNumber - 1) + { + // Subtract 1 to account for 1-based column numbering + offset += columnNumber - 1; + } + else + { + // Add an offset to account for the current platform's newline characters + offset += this.FileLines[i].Length + Environment.NewLine.Length; + } + } + + return offset; + } + + /// + /// Calculates a FilePosition relative to a starting BufferPosition + /// using the given 1-based line and column offset. + /// + /// The original BufferPosition from which an new position should be calculated. + /// The 1-based line offset added to the original position in this file. + /// The 1-based column offset added to the original position in this file. + /// A new FilePosition instance with the resulting line and column number. + public FilePosition CalculatePosition( + BufferPosition originalPosition, + int lineOffset, + int columnOffset) + { + int newLine = originalPosition.Line + lineOffset, + newColumn = originalPosition.Column + columnOffset; + + this.ValidatePosition(newLine, newColumn); + + string scriptLine = this.FileLines[newLine - 1]; + newColumn = Math.Min(scriptLine.Length + 1, newColumn); + + return new FilePosition(this, newLine, newColumn); + } + + /// + /// Calculates the 1-based line and column number position based + /// on the given buffer offset. + /// + /// The buffer offset to convert. + /// A new BufferPosition containing the position of the offset. + public BufferPosition GetPositionAtOffset(int bufferOffset) + { + BufferRange bufferRange = + GetRangeBetweenOffsets( + bufferOffset, bufferOffset); + + return bufferRange.Start; + } + + /// + /// Calculates the 1-based line and column number range based on + /// the given start and end buffer offsets. + /// + /// The start offset of the range. + /// The end offset of the range. + /// A new BufferRange containing the positions in the offset range. + public BufferRange GetRangeBetweenOffsets(int startOffset, int endOffset) + { + bool foundStart = false; + int currentOffset = 0; + int searchedOffset = startOffset; + + BufferPosition startPosition = new BufferPosition(0, 0); + BufferPosition endPosition = startPosition; + + int line = 0; + while (line < this.FileLines.Count) + { + if (searchedOffset <= currentOffset + this.FileLines[line].Length) + { + int column = searchedOffset - currentOffset; + + // Have we already found the start position? + if (foundStart) + { + // Assign the end position and end the search + endPosition = new BufferPosition(line + 1, column + 1); + break; + } + else + { + startPosition = new BufferPosition(line + 1, column + 1); + + // Do we only need to find the start position? + if (startOffset == endOffset) + { + endPosition = startPosition; + break; + } + else + { + // Since the end offset can be on the same line, + // skip the line increment and continue searching + // for the end position + foundStart = true; + searchedOffset = endOffset; + continue; + } + } + } + + // Increase the current offset and include newline length + currentOffset += this.FileLines[line].Length + Environment.NewLine.Length; + line++; + } + + return new BufferRange(startPosition, endPosition); + } + + #endregion + + #region Private Methods + + private void SetFileContents(string fileContents) + { + // Split the file contents into lines and trim + // any carriage returns from the strings. + this.FileLines = GetLinesInternal(fileContents); + + // Parse the contents to get syntax tree and errors + this.ParseFileContents(); + } + + /// + /// Parses the current file contents to get the AST, tokens, + /// and parse errors. + /// + private void ParseFileContents() + { + ParseError[] parseErrors = null; + + // First, get the updated file range + int lineCount = this.FileLines.Count; + if (lineCount > 0) + { + this.FileRange = + new BufferRange( + new BufferPosition(1, 1), + new BufferPosition( + lineCount + 1, + this.FileLines[lineCount - 1].Length + 1)); + } + else + { + this.FileRange = BufferRange.None; + } + + try + { + Token[] scriptTokens; + + // This overload appeared with Windows 10 Update 1 + if (this.powerShellVersion.Major >= 6 || + (this.powerShellVersion.Major == 5 && this.powerShellVersion.Build >= 10586)) + { + // Include the file path so that module relative + // paths are evaluated correctly + this.ScriptAst = + Parser.ParseInput( + this.Contents, + this.FilePath, + out scriptTokens, + out parseErrors); + } + else + { + this.ScriptAst = + Parser.ParseInput( + this.Contents, + out scriptTokens, + out parseErrors); + } + + this.ScriptTokens = scriptTokens; + } + catch (RuntimeException ex) + { + var parseError = + new ParseError( + null, + ex.ErrorRecord.FullyQualifiedErrorId, + ex.Message); + + parseErrors = new[] { parseError }; + this.ScriptTokens = new Token[0]; + this.ScriptAst = null; + } + + // Translate parse errors into syntax markers + this.DiagnosticMarkers = + parseErrors + .Select(ScriptFileMarker.FromParseError) + .ToList(); + + // Untitled files have no directory + // Discussed in https://github.com/PowerShell/PowerShellEditorServices/pull/815. + // Rather than working hard to enable things for untitled files like a phantom directory, + // users should save the file. + if (IsUntitledPath(this.FilePath)) + { + // Need to initialize the ReferencedFiles property to an empty array. + this.ReferencedFiles = new string[0]; + return; + } + + // Get all dot sourced referenced files and store them + //this.ReferencedFiles = AstOperations.FindDotSourcedIncludes(this.ScriptAst, Path.GetDirectoryName(this.FilePath)); + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/TextDocument/ScriptFileMarker.cs b/src/PowerShellEditorServices.Engine/Services/TextDocument/ScriptFileMarker.cs new file mode 100644 index 000000000..6da4086ed --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/TextDocument/ScriptFileMarker.cs @@ -0,0 +1,188 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Management.Automation; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation.Language; + +namespace Microsoft.PowerShell.EditorServices +{ + /// + /// Contains details for a code correction which can be applied from a ScriptFileMarker. + /// + public class MarkerCorrection + { + /// + /// Gets or sets the display name of the code correction. + /// + public string Name { get; set; } + + /// + /// Gets or sets the list of ScriptRegions that define the edits to be made by the correction. + /// + public ScriptRegion[] Edits { get; set; } + } + + /// + /// Defines the message level of a script file marker. + /// + public enum ScriptFileMarkerLevel + { + ///  +        /// Information: This warning is trivial, but may be useful. They are recommended by PowerShell best practice. +        ///  +        Information = 0, +        ///  +        /// WARNING: This warning may cause a problem or does not follow PowerShell's recommended guidelines. +        ///  +        Warning = 1, +        ///  +        /// ERROR: This warning is likely to cause a problem or does not follow PowerShell's required guidelines. +        ///  +        Error = 2, +        ///  +        /// ERROR: This diagnostic is caused by an actual parsing error, and is generated only by the engine. +        ///  +        ParseError = 3 + }; + + /// + /// Contains details about a marker that should be displayed + /// for the a script file. The marker information could come + /// from syntax parsing or semantic analysis of the script. + /// + public class ScriptFileMarker + { + #region Properties + + /// + /// Gets or sets the marker's message string. + /// + public string Message { get; set; } + + /// + /// Gets or sets the ruleName associated with this marker. + /// + public string RuleName { get; set; } + + /// + /// Gets or sets the marker's message level. + /// + public ScriptFileMarkerLevel Level { get; set; } + + /// + /// Gets or sets the ScriptRegion where the marker should appear. + /// + public ScriptRegion ScriptRegion { get; set; } + + /// + /// Gets or sets an optional code correction that can be applied based on this marker. + /// + public MarkerCorrection Correction { get; set; } + + /// + /// Gets or sets the name of the marker's source like "PowerShell" + /// or "PSScriptAnalyzer". + /// + public string Source { get; set; } + + #endregion + + #region Public Methods + + internal static ScriptFileMarker FromParseError( + ParseError parseError) + { + Validate.IsNotNull("parseError", parseError); + + return new ScriptFileMarker + { + Message = parseError.Message, + Level = ScriptFileMarkerLevel.Error, + ScriptRegion = ScriptRegion.Create(parseError.Extent), + Source = "PowerShell" + }; + } + private static string GetIfExistsString(PSObject psobj, string memberName) + { + if (psobj.Members.Match(memberName).Count > 0) + { + return psobj.Members[memberName].Value != null ? (string)psobj.Members[memberName].Value : ""; + } + else + { + return ""; + } + } + + internal static ScriptFileMarker FromDiagnosticRecord(PSObject psObject) + { + Validate.IsNotNull("psObject", psObject); + MarkerCorrection correction = null; + + // make sure psobject is of type DiagnosticRecord + if (!psObject.TypeNames.Contains( + "Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord", + StringComparer.OrdinalIgnoreCase)) + { + throw new ArgumentException("Input PSObject must of DiagnosticRecord type."); + } + + // casting psobject to dynamic allows us to access + // the diagnostic record's properties directly i.e. . + // without having to go through PSObject's Members property. + var diagnosticRecord = psObject as dynamic; + + if (diagnosticRecord.SuggestedCorrections != null) + { + var suggestedCorrections = diagnosticRecord.SuggestedCorrections as dynamic; + List editRegions = new List(); + string correctionMessage = null; + foreach (var suggestedCorrection in suggestedCorrections) + { + editRegions.Add(new ScriptRegion + { + File = diagnosticRecord.ScriptPath, + Text = suggestedCorrection.Text, + StartLineNumber = suggestedCorrection.StartLineNumber, + StartColumnNumber = suggestedCorrection.StartColumnNumber, + EndLineNumber = suggestedCorrection.EndLineNumber, + EndColumnNumber = suggestedCorrection.EndColumnNumber + }); + correctionMessage = suggestedCorrection.Description; + } + + correction = new MarkerCorrection + { + Name = correctionMessage == null ? diagnosticRecord.Message : correctionMessage, + Edits = editRegions.ToArray() + }; + } + + string severity = diagnosticRecord.Severity.ToString(); + if (!Enum.TryParse(severity, out ScriptFileMarkerLevel level)) + { + throw new ArgumentException( + $"The provided DiagnosticSeverity value '{severity}' is unknown.", + "diagnosticSeverity"); + } + + return new ScriptFileMarker + { + Message = $"{diagnosticRecord.Message as string}", + RuleName = $"{diagnosticRecord.RuleName as string}", + Level = level, + ScriptRegion = ScriptRegion.Create(diagnosticRecord.Extent as IScriptExtent), + Correction = correction, + Source = "PSScriptAnalyzer" + }; + } + + #endregion + } +} + diff --git a/src/PowerShellEditorServices.Engine/Services/TextDocument/ScriptRegion.cs b/src/PowerShellEditorServices.Engine/Services/TextDocument/ScriptRegion.cs new file mode 100644 index 000000000..5717b1382 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/TextDocument/ScriptRegion.cs @@ -0,0 +1,112 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Management.Automation.Language; + +namespace Microsoft.PowerShell.EditorServices +{ + /// + /// Contains details about a specific region of text in script file. + /// + public sealed class ScriptRegion : IScriptExtent + { + #region Properties + + /// + /// Gets the file path of the script file in which this region is contained. + /// + public string File { get; set; } + + /// + /// Gets or sets the text that is contained within the region. + /// + public string Text { get; set; } + + /// + /// Gets or sets the starting line number of the region. + /// + public int StartLineNumber { get; set; } + + /// + /// Gets or sets the starting column number of the region. + /// + public int StartColumnNumber { get; set; } + + /// + /// Gets or sets the starting file offset of the region. + /// + public int StartOffset { get; set; } + + /// + /// Gets or sets the ending line number of the region. + /// + public int EndLineNumber { get; set; } + + /// + /// Gets or sets the ending column number of the region. + /// + public int EndColumnNumber { get; set; } + + /// + /// Gets or sets the ending file offset of the region. + /// + public int EndOffset { get; set; } + + /// + /// Gets the starting IScriptPosition in the script. + /// (Currently unimplemented.) + /// + IScriptPosition IScriptExtent.StartScriptPosition => throw new NotImplementedException(); + + /// + /// Gets the ending IScriptPosition in the script. + /// (Currently unimplemented.) + /// + IScriptPosition IScriptExtent.EndScriptPosition => throw new NotImplementedException(); + + #endregion + + #region Constructors + + /// + /// Creates a new instance of the ScriptRegion class from an + /// instance of an IScriptExtent implementation. + /// + /// + /// The IScriptExtent to copy into the ScriptRegion. + /// + /// + /// A new ScriptRegion instance with the same details as the IScriptExtent. + /// + public static ScriptRegion Create(IScriptExtent scriptExtent) + { + // IScriptExtent throws an ArgumentOutOfRange exception if Text is null + string scriptExtentText; + try + { + scriptExtentText = scriptExtent.Text; + } + catch (ArgumentOutOfRangeException) + { + scriptExtentText = string.Empty; + } + + return new ScriptRegion + { + File = scriptExtent.File, + Text = scriptExtentText, + StartLineNumber = scriptExtent.StartLineNumber, + StartColumnNumber = scriptExtent.StartColumnNumber, + StartOffset = scriptExtent.StartOffset, + EndLineNumber = scriptExtent.EndLineNumber, + EndColumnNumber = scriptExtent.EndColumnNumber, + EndOffset = scriptExtent.EndOffset + }; + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/TextDocument/TokenOperations.cs b/src/PowerShellEditorServices.Engine/Services/TextDocument/TokenOperations.cs new file mode 100644 index 000000000..bffd0991a --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/TextDocument/TokenOperations.cs @@ -0,0 +1,216 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// 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 System.Text.RegularExpressions; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices +{ + + /// + /// Provides common operations for the tokens of a parsed script. + /// + internal static class TokenOperations + { + // Region kinds to align with VSCode's region kinds + private const string RegionKindComment = "comment"; + private const string RegionKindRegion = "region"; + private static readonly FoldingRangeKind? RegionKindNone = null; + + // These regular expressions are used to match lines which mark the start and end of region comment in a PowerShell + // script. They are based on the defaults in the VS Code Language Configuration at; + // https://github.com/Microsoft/vscode/blob/64186b0a26/extensions/powershell/language-configuration.json#L26-L31 + // https://github.com/Microsoft/vscode/issues/49070 + static private readonly Regex s_startRegionTextRegex = new Regex( + @"^\s*#[rR]egion\b", RegexOptions.Compiled); + static private readonly Regex s_endRegionTextRegex = new Regex( + @"^\s*#[eE]nd[rR]egion\b", RegexOptions.Compiled); + + /// + /// Extracts all of the unique foldable regions in a script given the list tokens + /// + internal static FoldingReferenceList FoldableReferences( + Token[] tokens) + { + var refList = new FoldingReferenceList(); + + Stack tokenCurlyStack = new Stack(); + Stack tokenParenStack = new Stack(); + foreach (Token token in tokens) + { + switch (token.Kind) + { + // Find matching braces { -> } + // Find matching hashes @{ -> } + case TokenKind.LCurly: + case TokenKind.AtCurly: + tokenCurlyStack.Push(token); + break; + + case TokenKind.RCurly: + if (tokenCurlyStack.Count > 0) + { + refList.SafeAdd(CreateFoldingReference(tokenCurlyStack.Pop(), token, RegionKindNone)); + } + break; + + // Find matching parentheses ( -> ) + // Find matching array literals @( -> ) + // Find matching subexpressions $( -> ) + case TokenKind.LParen: + case TokenKind.AtParen: + case TokenKind.DollarParen: + tokenParenStack.Push(token); + break; + + case TokenKind.RParen: + if (tokenParenStack.Count > 0) + { + refList.SafeAdd(CreateFoldingReference(tokenParenStack.Pop(), token, RegionKindNone)); + } + break; + + // Find contiguous here strings @' -> '@ + // Find unopinionated variable names ${ \n \n } + // Find contiguous expandable here strings @" -> "@ + case TokenKind.HereStringLiteral: + case TokenKind.Variable: + case TokenKind.HereStringExpandable: + if (token.Extent.StartLineNumber != token.Extent.EndLineNumber) + { + refList.SafeAdd(CreateFoldingReference(token, token, RegionKindNone)); + } + break; + } + } + + // Find matching comment regions #region -> #endregion + // Given a list of tokens, find the tokens that are comments and + // the comment text is either `#region` or `#endregion`, and then use a stack to determine + // the ranges they span + // + // Find blocks of line comments # comment1\n# comment2\n... + // Finding blocks of comment tokens is more complicated as the newline characters are not + // classed as comments. To workaround this we search for valid block comments (See IsBlockCmment) + // and then determine contiguous line numbers from there + // + // Find comments regions <# -> #> + // Match the token start and end of kind TokenKind.Comment + var tokenCommentRegionStack = new Stack(); + Token blockStartToken = null; + int blockNextLine = -1; + + for (int index = 0; index < tokens.Length; index++) + { + Token token = tokens[index]; + if (token.Kind != TokenKind.Comment) { continue; } + + // Processing for comment regions <# -> #> + if (token.Extent.StartLineNumber != token.Extent.EndLineNumber) + { + refList.SafeAdd(CreateFoldingReference(token, token, FoldingRangeKind.Comment)); + continue; + } + + if (!IsBlockComment(index, tokens)) { continue; } + + // Regex's are very expensive. Use them sparingly! + // Processing for #region -> #endregion + if (s_startRegionTextRegex.IsMatch(token.Text)) + { + tokenCommentRegionStack.Push(token); + continue; + } + if (s_endRegionTextRegex.IsMatch(token.Text)) + { + // Mismatched regions in the script can cause bad stacks. + if (tokenCommentRegionStack.Count > 0) + { + refList.SafeAdd(CreateFoldingReference(tokenCommentRegionStack.Pop(), token, FoldingRangeKind.Region)); + } + continue; + } + + // If it's neither a start or end region then it could be block line comment + // Processing for blocks of line comments # comment1\n# comment2\n... + int thisLine = token.Extent.StartLineNumber - 1; + if ((blockStartToken != null) && (thisLine != blockNextLine)) + { + refList.SafeAdd(CreateFoldingReference(blockStartToken, blockNextLine - 1, FoldingRangeKind.Comment)); + blockStartToken = token; + } + if (blockStartToken == null) { blockStartToken = token; } + blockNextLine = thisLine + 1; + } + + // If we exit the token array and we're still processing comment lines, then the + // comment block simply ends at the end of document + if (blockStartToken != null) + { + refList.SafeAdd(CreateFoldingReference(blockStartToken, blockNextLine - 1, FoldingRangeKind.Comment)); + } + + return refList; + } + + /// + /// Creates an instance of a FoldingReference object from a start and end langauge Token + /// Returns null if the line range is invalid + /// + static private FoldingReference CreateFoldingReference( + Token startToken, + Token endToken, + FoldingRangeKind? matchKind) + { + if (endToken.Extent.EndLineNumber == startToken.Extent.StartLineNumber) { return null; } + // Extents are base 1, but LSP is base 0, so minus 1 off all lines and character positions + return new FoldingReference { + StartLine = startToken.Extent.StartLineNumber - 1, + StartCharacter = startToken.Extent.StartColumnNumber - 1, + EndLine = endToken.Extent.EndLineNumber - 1, + EndCharacter = endToken.Extent.EndColumnNumber - 1, + Kind = matchKind + }; + } + + /// + /// Creates an instance of a FoldingReference object from a start token and an end line + /// Returns null if the line range is invalid + /// + static private FoldingReference CreateFoldingReference( + Token startToken, + int endLine, + FoldingRangeKind? matchKind) + { + if (endLine == (startToken.Extent.StartLineNumber - 1)) { return null; } + // Extents are base 1, but LSP is base 0, so minus 1 off all lines and character positions + return new FoldingReference { + StartLine = startToken.Extent.StartLineNumber - 1, + StartCharacter = startToken.Extent.StartColumnNumber - 1, + EndLine = endLine, + EndCharacter = 0, + Kind = matchKind + }; + } + + /// + /// Returns true if a Token is a block comment; + /// - Must be a TokenKind.comment + /// - Must be preceeded by TokenKind.NewLine + /// - Token text must start with a '#'.false This is because comment regions + /// start with '<#' but have the same TokenKind + /// + static private bool IsBlockComment(int index, Token[] tokens) { + Token thisToken = tokens[index]; + if (thisToken.Kind != TokenKind.Comment) { return false; } + if (index == 0) { return true; } + if (tokens[index - 1].Kind != TokenKind.NewLine) { return false; } + return thisToken.Text.StartsWith("#"); + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/Workspace/ConfigurationService.cs b/src/PowerShellEditorServices.Engine/Services/Workspace/ConfigurationService.cs new file mode 100644 index 000000000..888b18ebe --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/Workspace/ConfigurationService.cs @@ -0,0 +1,13 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.PowerShell.EditorServices +{ + public class ConfigurationService + { + // This probably needs some sort of lock... or maybe LanguageServerSettings needs it. + public LanguageServerSettings CurrentSettings { get; } = new LanguageServerSettings(); + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/Workspace/Handlers/ConfigurationHandler.cs b/src/PowerShellEditorServices.Engine/Services/Workspace/Handlers/ConfigurationHandler.cs new file mode 100644 index 000000000..b97485dc0 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/Workspace/Handlers/ConfigurationHandler.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using OmniSharp.Extensions.Embedded.MediatR; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; + +namespace Microsoft.PowerShell.EditorServices +{ + public class ConfigurationHandler : IDidChangeConfigurationHandler + { + private readonly ILogger _logger; + private readonly AnalysisService _analysisService; + private readonly WorkspaceService _workspaceService; + private readonly ConfigurationService _configurationService; + private DidChangeConfigurationCapability _capability; + + + public ConfigurationHandler(ILoggerFactory factory, WorkspaceService workspaceService, AnalysisService analysisService, ConfigurationService configurationService) + { + _logger = factory.CreateLogger(); + _workspaceService = workspaceService; + _analysisService = analysisService; + _configurationService = configurationService; + } + + public object GetRegistrationOptions() + { + return null; + } + + public async Task Handle(DidChangeConfigurationParams request, CancellationToken cancellationToken) + { + LanguageServerSettingsWrapper incomingSettings = request.Settings.ToObject(); + if(incomingSettings == null) + { + return await Unit.Task; + } + // TODO ADD THIS BACK IN + // bool oldLoadProfiles = this.currentSettings.EnableProfileLoading; + bool oldScriptAnalysisEnabled = + _configurationService.CurrentSettings.ScriptAnalysis.Enable ?? false; + string oldScriptAnalysisSettingsPath = + _configurationService.CurrentSettings.ScriptAnalysis?.SettingsPath; + + _configurationService.CurrentSettings.Update( + incomingSettings.Powershell, + _workspaceService.WorkspacePath, + _logger); + + // TODO ADD THIS BACK IN + // if (!this.profilesLoaded && + // this.currentSettings.EnableProfileLoading && + // oldLoadProfiles != this.currentSettings.EnableProfileLoading) + // { + // await this.editorSession.PowerShellContext.LoadHostProfilesAsync(); + // this.profilesLoaded = true; + // } + + // // Wait until after profiles are loaded (or not, if that's the + // // case) before starting the interactive console. + // if (!this.consoleReplStarted) + // { + // // Start the interactive terminal + // this.editorSession.HostInput.StartCommandLoop(); + // this.consoleReplStarted = true; + // } + + // If there is a new settings file path, restart the analyzer with the new settigs. + bool settingsPathChanged = false; + string newSettingsPath = _configurationService.CurrentSettings.ScriptAnalysis.SettingsPath; + if (!string.Equals(oldScriptAnalysisSettingsPath, newSettingsPath, StringComparison.OrdinalIgnoreCase)) + { + if (_analysisService != null) + { + _analysisService.SettingsPath = newSettingsPath; + settingsPathChanged = true; + } + } + + // If script analysis settings have changed we need to clear & possibly update the current diagnostic records. + if ((oldScriptAnalysisEnabled != _configurationService.CurrentSettings.ScriptAnalysis?.Enable) || settingsPathChanged) + { + // If the user just turned off script analysis or changed the settings path, send a diagnostics + // event to clear the analysis markers that they already have. + if (!_configurationService.CurrentSettings.ScriptAnalysis.Enable.Value || settingsPathChanged) + { + foreach (var scriptFile in _workspaceService.GetOpenedFiles()) + { + _analysisService.ClearMarkers(scriptFile); + } + } + } + + // Convert the editor file glob patterns into an array for the Workspace + // Both the files.exclude and search.exclude hash tables look like (glob-text, is-enabled): + // "files.exclude" : { + // "Makefile": true, + // "*.html": true, + // "build/*": true + // } + var excludeFilePatterns = new List(); + if (incomingSettings.Files?.Exclude != null) + { + foreach(KeyValuePair patternEntry in incomingSettings.Files.Exclude) + { + if (patternEntry.Value) { excludeFilePatterns.Add(patternEntry.Key); } + } + } + if (incomingSettings.Search?.Exclude != null) + { + foreach(KeyValuePair patternEntry in incomingSettings.Files.Exclude) + { + if (patternEntry.Value && !excludeFilePatterns.Contains(patternEntry.Key)) { excludeFilePatterns.Add(patternEntry.Key); } + } + } + _workspaceService.ExcludeFilesGlob = excludeFilePatterns; + + // Convert the editor file search options to Workspace properties + if (incomingSettings.Search?.FollowSymlinks != null) + { + _workspaceService.FollowSymlinks = incomingSettings.Search.FollowSymlinks; + } + + return await Unit.Task; + } + + public void SetCapability(DidChangeConfigurationCapability capability) + { + _capability = capability; + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/Workspace/Handlers/WorkspaceSymbolsHandler.cs b/src/PowerShellEditorServices.Engine/Services/Workspace/Handlers/WorkspaceSymbolsHandler.cs new file mode 100644 index 000000000..5e9c36752 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/Workspace/Handlers/WorkspaceSymbolsHandler.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices; +using Microsoft.PowerShell.EditorServices.Symbols; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; +using PowerShellEditorServices.Engine.Utility; + +namespace PowerShellEditorServices.Engine.Services.Handlers +{ + public class WorkspaceSymbolsHandler : IWorkspaceSymbolsHandler + { + private readonly ILogger _logger; + private readonly SymbolsService _symbolsService; + private readonly WorkspaceService _workspaceService; + private WorkspaceSymbolCapability _capability; + + public WorkspaceSymbolsHandler(ILoggerFactory loggerFactory, SymbolsService symbols, WorkspaceService workspace) { + _logger = loggerFactory.CreateLogger(); + _symbolsService = symbols; + _workspaceService = workspace; + } + + public object GetRegistrationOptions() + { + return null; + // throw new NotImplementedException(); + } + + public Task Handle(WorkspaceSymbolParams request, CancellationToken cancellationToken) + { + var symbols = new List(); + + foreach (ScriptFile scriptFile in _workspaceService.GetOpenedFiles()) + { + List foundSymbols = + _symbolsService.FindSymbolsInFile( + scriptFile); + + // TODO: Need to compute a relative path that is based on common path for all workspace files + string containerName = Path.GetFileNameWithoutExtension(scriptFile.FilePath); + + foreach (SymbolReference foundOccurrence in foundSymbols) + { + if (!IsQueryMatch(request.Query, foundOccurrence.SymbolName)) + { + continue; + } + + var location = new Location + { + Uri = PathUtils.ToUri(foundOccurrence.FilePath), + Range = GetRangeFromScriptRegion(foundOccurrence.ScriptRegion) + }; + + symbols.Add(new SymbolInformation + { + ContainerName = containerName, + Kind = foundOccurrence.SymbolType == SymbolType.Variable ? SymbolKind.Variable : SymbolKind.Function, + Location = location, + Name = GetDecoratedSymbolName(foundOccurrence) + }); + } + } + _logger.LogWarning("Logging in a handler works now."); + + return Task.FromResult(new SymbolInformationContainer(symbols)); + } + + public void SetCapability(WorkspaceSymbolCapability capability) + { + _capability = capability; + } + + #region private Methods + + private bool IsQueryMatch(string query, string symbolName) + { + return symbolName.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0; + } + + private static string GetFileUri(string filePath) + { + // If the file isn't untitled, return a URI-style path + return + !filePath.StartsWith("untitled") && !filePath.StartsWith("inmemory") + ? new Uri("file://" + filePath).AbsoluteUri + : filePath; + } + + private static Range GetRangeFromScriptRegion(ScriptRegion scriptRegion) + { + return new Range + { + Start = new Position + { + Line = scriptRegion.StartLineNumber - 1, + Character = scriptRegion.StartColumnNumber - 1 + }, + End = new Position + { + Line = scriptRegion.EndLineNumber - 1, + Character = scriptRegion.EndColumnNumber - 1 + } + }; + } + + private static string GetDecoratedSymbolName(SymbolReference symbolReference) + { + string name = symbolReference.SymbolName; + + if (symbolReference.SymbolType == SymbolType.Configuration || + symbolReference.SymbolType == SymbolType.Function || + symbolReference.SymbolType == SymbolType.Workflow) + { + name += " { }"; + } + + return name; + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/Workspace/LanguageServerSettings.cs b/src/PowerShellEditorServices.Engine/Services/Workspace/LanguageServerSettings.cs new file mode 100644 index 000000000..7f2b3dfee --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/Workspace/LanguageServerSettings.cs @@ -0,0 +1,389 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Security; +using Microsoft.Extensions.Logging; + +namespace Microsoft.PowerShell.EditorServices +{ + public class LanguageServerSettings + { + private readonly object updateLock = new object(); + public bool EnableProfileLoading { get; set; } + + public ScriptAnalysisSettings ScriptAnalysis { get; set; } + + public CodeFormattingSettings CodeFormatting { get; set; } + + public CodeFoldingSettings CodeFolding { get; set; } + + public LanguageServerSettings() + { + this.ScriptAnalysis = new ScriptAnalysisSettings(); + this.CodeFormatting = new CodeFormattingSettings(); + this.CodeFolding = new CodeFoldingSettings(); + } + + public void Update( + LanguageServerSettings settings, + string workspaceRootPath, + ILogger logger) + { + if (settings != null) + { + lock (updateLock) + { + this.EnableProfileLoading = settings.EnableProfileLoading; + this.ScriptAnalysis.Update( + settings.ScriptAnalysis, + workspaceRootPath, + logger); + this.CodeFormatting = new CodeFormattingSettings(settings.CodeFormatting); + this.CodeFolding.Update(settings.CodeFolding, logger); + } + } + } + } + + public class ScriptAnalysisSettings + { + private readonly object updateLock = new object(); + + public bool? Enable { get; set; } + + public string SettingsPath { get; set; } + + public ScriptAnalysisSettings() + { + this.Enable = true; + } + + public void Update( + ScriptAnalysisSettings settings, + string workspaceRootPath, + ILogger logger) + { + if (settings != null) + { + lock(updateLock) + { + this.Enable = settings.Enable; + + string settingsPath = settings.SettingsPath; + + try + { + if (string.IsNullOrWhiteSpace(settingsPath)) + { + settingsPath = null; + } + else if (!Path.IsPathRooted(settingsPath)) + { + if (string.IsNullOrEmpty(workspaceRootPath)) + { + // The workspace root path could be an empty string + // when the user has opened a PowerShell script file + // without opening an entire folder (workspace) first. + // In this case we should just log an error and let + // the specified settings path go through even though + // it will fail to load. + logger.LogError( + "Could not resolve Script Analyzer settings path due to null or empty workspaceRootPath."); + } + else + { + settingsPath = Path.GetFullPath(Path.Combine(workspaceRootPath, settingsPath)); + } + } + + this.SettingsPath = settingsPath; + logger.LogTrace($"Using Script Analyzer settings path - '{settingsPath ?? ""}'."); + } + catch (Exception ex) when ( + ex is NotSupportedException || + ex is PathTooLongException || + ex is SecurityException) + { + // Invalid chars in path like ${env:HOME} can cause Path.GetFullPath() to throw, catch such errors here + logger.LogException( + $"Invalid Script Analyzer settings path - '{settingsPath}'.", + ex); + + this.SettingsPath = null; + } + } + } + } + } + + /// + /// Code formatting presets. + /// See https://en.wikipedia.org/wiki/Indent_style for details on indent and brace styles. + /// + public enum CodeFormattingPreset + { + /// + /// Use the formatting settings as-is. + /// + Custom, + + /// + /// Configure the formatting settings to resemble the Allman indent/brace style. + /// + Allman, + + /// + /// Configure the formatting settings to resemble the one true brace style variant of K&R indent/brace style. + /// + OTBS, + + /// + /// Configure the formatting settings to resemble the Stroustrup brace style variant of K&R indent/brace style. + /// + Stroustrup + } + + /// + /// Multi-line pipeline style settings. + /// + public enum PipelineIndentationStyle + { + /// + /// After the indentation level only once after the first pipeline and keep this level for the following pipelines. + /// + IncreaseIndentationForFirstPipeline, + + /// + /// After every pipeline, keep increasing the indentation. + /// + IncreaseIndentationAfterEveryPipeline, + + /// + /// Do not increase indentation level at all after pipeline. + /// + NoIndentation + } + + public class CodeFormattingSettings + { + /// + /// Default constructor. + /// > + public CodeFormattingSettings() + { + } + + /// + /// Copy constructor. + /// + /// An instance of type CodeFormattingSettings. + public CodeFormattingSettings(CodeFormattingSettings codeFormattingSettings) + { + if (codeFormattingSettings == null) + { + throw new ArgumentNullException(nameof(codeFormattingSettings)); + } + + foreach (var prop in this.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + prop.SetValue(this, prop.GetValue(codeFormattingSettings)); + } + } + + public CodeFormattingPreset Preset { get; set; } + public bool OpenBraceOnSameLine { get; set; } + public bool NewLineAfterOpenBrace { get; set; } + public bool NewLineAfterCloseBrace { get; set; } + public PipelineIndentationStyle PipelineIndentationStyle { get; set; } + public bool WhitespaceBeforeOpenBrace { get; set; } + public bool WhitespaceBeforeOpenParen { get; set; } + public bool WhitespaceAroundOperator { get; set; } + public bool WhitespaceAfterSeparator { get; set; } + public bool WhitespaceInsideBrace { get; set; } + public bool WhitespaceAroundPipe { get; set; } + public bool IgnoreOneLineBlock { get; set; } + public bool AlignPropertyValuePairs { get; set; } + public bool UseCorrectCasing { get; set; } + + + /// + /// Get the settings hashtable that will be consumed by PSScriptAnalyzer. + /// + /// The tab size in the number spaces. + /// If true, insert spaces otherwise insert tabs for indentation. + /// + public Hashtable GetPSSASettingsHashtable( + int tabSize, + bool insertSpaces) + { + var settings = GetCustomPSSASettingsHashtable(tabSize, insertSpaces); + var ruleSettings = (Hashtable)(settings["Rules"]); + var closeBraceSettings = (Hashtable)ruleSettings["PSPlaceCloseBrace"]; + var openBraceSettings = (Hashtable)ruleSettings["PSPlaceOpenBrace"]; + switch(Preset) + { + case CodeFormattingPreset.Allman: + openBraceSettings["OnSameLine"] = false; + openBraceSettings["NewLineAfter"] = true; + closeBraceSettings["NewLineAfter"] = true; + break; + + case CodeFormattingPreset.OTBS: + openBraceSettings["OnSameLine"] = true; + openBraceSettings["NewLineAfter"] = true; + closeBraceSettings["NewLineAfter"] = false; + break; + + case CodeFormattingPreset.Stroustrup: + openBraceSettings["OnSameLine"] = true; + openBraceSettings["NewLineAfter"] = true; + closeBraceSettings["NewLineAfter"] = true; + break; + + default: + break; + } + + return settings; + } + + private Hashtable GetCustomPSSASettingsHashtable(int tabSize, bool insertSpaces) + { + return new Hashtable + { + {"IncludeRules", new string[] { + "PSPlaceCloseBrace", + "PSPlaceOpenBrace", + "PSUseConsistentWhitespace", + "PSUseConsistentIndentation", + "PSAlignAssignmentStatement" + }}, + {"Rules", new Hashtable { + {"PSPlaceOpenBrace", new Hashtable { + {"Enable", true}, + {"OnSameLine", OpenBraceOnSameLine}, + {"NewLineAfter", NewLineAfterOpenBrace}, + {"IgnoreOneLineBlock", IgnoreOneLineBlock} + }}, + {"PSPlaceCloseBrace", new Hashtable { + {"Enable", true}, + {"NewLineAfter", NewLineAfterCloseBrace}, + {"IgnoreOneLineBlock", IgnoreOneLineBlock} + }}, + {"PSUseConsistentIndentation", new Hashtable { + {"Enable", true}, + {"IndentationSize", tabSize}, + {"PipelineIndentation", PipelineIndentationStyle }, + {"Kind", insertSpaces ? "space" : "tab"} + }}, + {"PSUseConsistentWhitespace", new Hashtable { + {"Enable", true}, + {"CheckOpenBrace", WhitespaceBeforeOpenBrace}, + {"CheckOpenParen", WhitespaceBeforeOpenParen}, + {"CheckOperator", WhitespaceAroundOperator}, + {"CheckSeparator", WhitespaceAfterSeparator}, + {"CheckInnerBrace", WhitespaceInsideBrace}, + {"CheckPipe", WhitespaceAroundPipe}, + }}, + {"PSAlignAssignmentStatement", new Hashtable { + {"Enable", true}, + {"CheckHashtable", AlignPropertyValuePairs} + }}, + {"PSUseCorrectCasing", new Hashtable { + {"Enable", UseCorrectCasing} + }}, + }} + }; + } + } + + /// + /// Code folding settings + /// + public class CodeFoldingSettings + { + /// + /// Whether the folding is enabled. Default is true as per VSCode + /// + public bool Enable { get; set; } = true; + + /// + /// Whether to show or hide the last line of a folding region. Default is true as per VSCode + /// + public bool ShowLastLine { get; set; } = true; + + /// + /// Update these settings from another settings object + /// + public void Update( + CodeFoldingSettings settings, + ILogger logger) + { + if (settings != null) { + if (this.Enable != settings.Enable) { + this.Enable = settings.Enable; + logger.LogTrace(string.Format("Using Code Folding Enabled - {0}", this.Enable)); + } + if (this.ShowLastLine != settings.ShowLastLine) { + this.ShowLastLine = settings.ShowLastLine; + logger.LogTrace(string.Format("Using Code Folding ShowLastLine - {0}", this.ShowLastLine)); + } + } + } + } + + /// + /// Additional settings from the Language Client that affect Language Server operations but + /// do not exist under the 'powershell' section + /// + public class EditorFileSettings + { + /// + /// Exclude files globs consists of hashtable with the key as the glob and a boolean value to indicate if the + /// the glob is in effect. + /// + public Dictionary Exclude { get; set; } + } + + /// + /// Additional settings from the Language Client that affect Language Server operations but + /// do not exist under the 'powershell' section + /// + public class EditorSearchSettings + { + /// + /// Exclude files globs consists of hashtable with the key as the glob and a boolean value to indicate if the + /// the glob is in effect. + /// + public Dictionary Exclude { get; set; } + /// + /// Whether to follow symlinks when searching + /// + public bool FollowSymlinks { get; set; } = true; + } + + public class LanguageServerSettingsWrapper + { + // NOTE: This property is capitalized as 'Powershell' because the + // mode name sent from the client is written as 'powershell' and + // JSON.net is using camelCasing. + public LanguageServerSettings Powershell { get; set; } + + // NOTE: This property is capitalized as 'Files' because the + // mode name sent from the client is written as 'files' and + // JSON.net is using camelCasing. + public EditorFileSettings Files { get; set; } + + // NOTE: This property is capitalized as 'Search' because the + // mode name sent from the client is written as 'search' and + // JSON.net is using camelCasing. + public EditorSearchSettings Search { get; set; } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/Workspace/WorkspaceFileSystemWrapper.cs b/src/PowerShellEditorServices.Engine/Services/Workspace/WorkspaceFileSystemWrapper.cs new file mode 100644 index 000000000..116f29b38 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/Workspace/WorkspaceFileSystemWrapper.cs @@ -0,0 +1,381 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Collections.Generic; +using System.IO; +using System.Security; +using Microsoft.Extensions.FileSystemGlobbing.Abstractions; +using Microsoft.Extensions.Logging; + +namespace Microsoft.PowerShell.EditorServices +{ + + /// + /// A FileSystem wrapper class which only returns files and directories that the consumer is interested in, + /// with a maximum recursion depth and silently ignores most file system errors. Typically this is used by the + /// Microsoft.Extensions.FileSystemGlobbing library. + /// + public class WorkspaceFileSystemWrapperFactory + { + private readonly DirectoryInfoBase _rootDirectory; + private readonly string[] _allowedExtensions; + private readonly bool _ignoreReparsePoints; + + /// + /// Gets the maximum depth of the directories that will be searched + /// + internal int MaxRecursionDepth { get; } + + /// + /// Gets the logging facility + /// + internal ILogger Logger { get; } + + /// + /// Gets the directory where the factory is rooted. Only files and directories at this level, or deeper, will be visible + /// by the wrapper + /// + public DirectoryInfoBase RootDirectory + { + get { return _rootDirectory; } + } + + /// + /// Creates a new FileWrapper Factory + /// + /// The path to the root directory for the factory. + /// The maximum directory depth. + /// An array of file extensions that will be visible from the factory. For example [".ps1", ".psm1"] + /// Whether objects which are Reparse Points should be ignored. https://docs.microsoft.com/en-us/windows/desktop/fileio/reparse-points + /// An ILogger implementation used for writing log messages. + public WorkspaceFileSystemWrapperFactory(String rootPath, int recursionDepthLimit, string[] allowedExtensions, bool ignoreReparsePoints, ILogger logger) + { + MaxRecursionDepth = recursionDepthLimit; + _rootDirectory = new WorkspaceFileSystemDirectoryWrapper(this, new DirectoryInfo(rootPath), 0); + _allowedExtensions = allowedExtensions; + _ignoreReparsePoints = ignoreReparsePoints; + Logger = logger; + } + + /// + /// Creates a wrapped object from . + /// + internal DirectoryInfoBase CreateDirectoryInfoWrapper(DirectoryInfo dirInfo, int depth) => + new WorkspaceFileSystemDirectoryWrapper(this, dirInfo, depth >= 0 ? depth : 0); + + /// + /// Creates a wrapped object from . + /// + internal FileInfoBase CreateFileInfoWrapper(FileInfo fileInfo, int depth) => + new WorkspaceFileSystemFileInfoWrapper(this, fileInfo, depth >= 0 ? depth : 0); + + /// + /// Enumerates all objects in the specified directory and ignores most errors + /// + internal IEnumerable SafeEnumerateFileSystemInfos(DirectoryInfo dirInfo) + { + // Find the subdirectories + string[] subDirs; + try + { + subDirs = Directory.GetDirectories(dirInfo.FullName, "*", SearchOption.TopDirectoryOnly); + } + catch (DirectoryNotFoundException e) + { + Logger.LogHandledException( + $"Could not enumerate directories in the path '{dirInfo.FullName}' due to it being an invalid path", + e); + + yield break; + } + catch (PathTooLongException e) + { + Logger.LogHandledException( + $"Could not enumerate directories in the path '{dirInfo.FullName}' due to the path being too long", + e); + + yield break; + } + catch (Exception e) when (e is SecurityException || e is UnauthorizedAccessException) + { + Logger.LogHandledException( + $"Could not enumerate directories in the path '{dirInfo.FullName}' due to the path not being accessible", + e); + + yield break; + } + catch (Exception e) + { + Logger.LogHandledException( + $"Could not enumerate directories in the path '{dirInfo.FullName}' due to an exception", + e); + + yield break; + } + foreach (string dirPath in subDirs) + { + var subDirInfo = new DirectoryInfo(dirPath); + if (_ignoreReparsePoints && (subDirInfo.Attributes & FileAttributes.ReparsePoint) != 0) { continue; } + yield return subDirInfo; + } + + // Find the files + string[] filePaths; + try + { + filePaths = Directory.GetFiles(dirInfo.FullName, "*", SearchOption.TopDirectoryOnly); + } + catch (DirectoryNotFoundException e) + { + Logger.LogHandledException( + $"Could not enumerate files in the path '{dirInfo.FullName}' due to it being an invalid path", + e); + + yield break; + } + catch (PathTooLongException e) + { + Logger.LogHandledException( + $"Could not enumerate files in the path '{dirInfo.FullName}' due to the path being too long", + e); + + yield break; + } + catch (Exception e) when (e is SecurityException || e is UnauthorizedAccessException) + { + Logger.LogHandledException( + $"Could not enumerate files in the path '{dirInfo.FullName}' due to the path not being accessible", + e); + + yield break; + } + catch (Exception e) + { + Logger.LogHandledException( + $"Could not enumerate files in the path '{dirInfo.FullName}' due to an exception", + e); + + yield break; + } + foreach (string filePath in filePaths) + { + var fileInfo = new FileInfo(filePath); + if (_allowedExtensions == null || _allowedExtensions.Length == 0) { yield return fileInfo; continue; } + if (_ignoreReparsePoints && (fileInfo.Attributes & FileAttributes.ReparsePoint) != 0) { continue; } + foreach (string extension in _allowedExtensions) + { + if (fileInfo.Extension == extension) { yield return fileInfo; break; } + } + } + } + } + + /// + /// Wraps an instance of and provides implementation of + /// . + /// Based on https://github.com/aspnet/Extensions/blob/c087cadf1dfdbd2b8785ef764e5ef58a1a7e5ed0/src/FileSystemGlobbing/src/Abstractions/DirectoryInfoWrapper.cs + /// + public class WorkspaceFileSystemDirectoryWrapper : DirectoryInfoBase + { + private readonly DirectoryInfo _concreteDirectoryInfo; + private readonly bool _isParentPath; + private readonly WorkspaceFileSystemWrapperFactory _fsWrapperFactory; + private readonly int _depth; + + /// + /// Initializes an instance of . + /// + public WorkspaceFileSystemDirectoryWrapper(WorkspaceFileSystemWrapperFactory factory, DirectoryInfo directoryInfo, int depth) + { + _concreteDirectoryInfo = directoryInfo; + _isParentPath = (depth == 0); + _fsWrapperFactory = factory; + _depth = depth; + } + + /// + public override IEnumerable EnumerateFileSystemInfos() + { + if (!_concreteDirectoryInfo.Exists || _depth >= _fsWrapperFactory.MaxRecursionDepth) { yield break; } + foreach (FileSystemInfo fileSystemInfo in _fsWrapperFactory.SafeEnumerateFileSystemInfos(_concreteDirectoryInfo)) + { + switch (fileSystemInfo) + { + case DirectoryInfo dirInfo: + yield return _fsWrapperFactory.CreateDirectoryInfoWrapper(dirInfo, _depth + 1); + break; + case FileInfo fileInfo: + yield return _fsWrapperFactory.CreateFileInfoWrapper(fileInfo, _depth); + break; + default: + // We should NEVER get here, but if we do just continue on + break; + } + } + } + + /// + /// Returns an instance of that represents a subdirectory. + /// + /// + /// If equals '..', this returns the parent directory. + /// + /// The directory name. + /// The directory + public override DirectoryInfoBase GetDirectory(string name) + { + bool isParentPath = string.Equals(name, "..", StringComparison.Ordinal); + + if (isParentPath) { return ParentDirectory; } + + var dirs = _concreteDirectoryInfo.GetDirectories(name); + + if (dirs.Length == 1) { return _fsWrapperFactory.CreateDirectoryInfoWrapper(dirs[0], _depth + 1); } + if (dirs.Length == 0) { return null; } + // This shouldn't happen. The parameter name isn't supposed to contain wild card. + throw new InvalidOperationException( + string.Format( + System.Globalization.CultureInfo.CurrentCulture, + "More than one sub directories are found under {0} with name {1}.", + _concreteDirectoryInfo.FullName, name)); + } + + /// + public override FileInfoBase GetFile(string name) => _fsWrapperFactory.CreateFileInfoWrapper(new FileInfo(Path.Combine(_concreteDirectoryInfo.FullName, name)), _depth); + + /// + public override string Name => _isParentPath ? ".." : _concreteDirectoryInfo.Name; + + /// + /// Returns the full path to the directory. + /// + public override string FullName => _concreteDirectoryInfo.FullName; + + /// + /// Safely calculates the parent of this directory, swallowing most errors. + /// + private DirectoryInfoBase SafeParentDirectory() + { + try + { + return _fsWrapperFactory.CreateDirectoryInfoWrapper(_concreteDirectoryInfo.Parent, _depth - 1); + } + catch (DirectoryNotFoundException e) + { + _fsWrapperFactory.Logger.LogHandledException( + $"Could not get parent of '{_concreteDirectoryInfo.FullName}' due to it being an invalid path", + e); + } + catch (PathTooLongException e) + { + _fsWrapperFactory.Logger.LogHandledException( + $"Could not get parent of '{_concreteDirectoryInfo.FullName}' due to the path being too long", + e); + } + catch (Exception e) when (e is SecurityException || e is UnauthorizedAccessException) + { + _fsWrapperFactory.Logger.LogHandledException( + $"Could not get parent of '{_concreteDirectoryInfo.FullName}' due to the path not being accessible", + e); + } + catch (Exception e) + { + _fsWrapperFactory.Logger.LogHandledException( + $"Could not get parent of '{_concreteDirectoryInfo.FullName}' due to an exception", + e); + } + return null; + } + + /// + /// Returns the parent directory. (Overrides ). + /// + public override DirectoryInfoBase ParentDirectory + { + get + { + return SafeParentDirectory(); + } + } + } + + /// + /// Wraps an instance of to provide implementation of . + /// + public class WorkspaceFileSystemFileInfoWrapper : FileInfoBase + { + private readonly FileInfo _concreteFileInfo; + private readonly WorkspaceFileSystemWrapperFactory _fsWrapperFactory; + private readonly int _depth; + + /// + /// Initializes instance of to wrap the specified object . + /// + public WorkspaceFileSystemFileInfoWrapper(WorkspaceFileSystemWrapperFactory factory, FileInfo fileInfo, int depth) + { + _fsWrapperFactory = factory; + _concreteFileInfo = fileInfo; + _depth = depth; + } + + /// + /// The file name. (Overrides ). + /// + public override string Name => _concreteFileInfo.Name; + + /// + /// The full path of the file. (Overrides ). + /// + public override string FullName => _concreteFileInfo.FullName; + + /// + /// Safely calculates the parent of this file, swallowing most errors. + /// + private DirectoryInfoBase SafeParentDirectory() + { + try + { + return _fsWrapperFactory.CreateDirectoryInfoWrapper(_concreteFileInfo.Directory, _depth); + } + catch (DirectoryNotFoundException e) + { + _fsWrapperFactory.Logger.LogHandledException( + $"Could not get parent of '{_concreteFileInfo.FullName}' due to it being an invalid path", + e); + } + catch (PathTooLongException e) + { + _fsWrapperFactory.Logger.LogHandledException( + $"Could not get parent of '{_concreteFileInfo.FullName}' due to the path being too long", + e); + } + catch (Exception e) when (e is SecurityException || e is UnauthorizedAccessException) + { + _fsWrapperFactory.Logger.LogHandledException( + $"Could not get parent of '{_concreteFileInfo.FullName}' due to the path not being accessible", + e); + } + catch (Exception e) + { + _fsWrapperFactory.Logger.LogHandledException( + $"Could not get parent of '{_concreteFileInfo.FullName}' due to an exception", + e); + } + return null; + } + + /// + /// The directory containing the file. (Overrides ). + /// + public override DirectoryInfoBase ParentDirectory + { + get + { + return SafeParentDirectory(); + } + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/Workspace/WorkspaceService.cs b/src/PowerShellEditorServices.Engine/Services/Workspace/WorkspaceService.cs new file mode 100644 index 000000000..32cde6f7b --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/Workspace/WorkspaceService.cs @@ -0,0 +1,691 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Collections.Generic; +using System.Linq; +using System.IO; +using System.Security; +using System.Text; +using System.Runtime.InteropServices; +using Microsoft.Extensions.FileSystemGlobbing; +using Microsoft.Extensions.Logging; + +namespace Microsoft.PowerShell.EditorServices +{ + /// + /// Manages a "workspace" of script files that are open for a particular + /// editing session. Also helps to navigate references between ScriptFiles. + /// + public class WorkspaceService + { + #region Private Fields + + // List of all file extensions considered PowerShell files in the .Net Core Framework. + private static readonly string[] s_psFileExtensionsCoreFramework = + { + ".ps1", + ".psm1", + ".psd1" + }; + + // .Net Core doesn't appear to use the same three letter pattern matching rule although the docs + // suggest it should be find the '.ps1xml' files because we search for the pattern '*.ps1'. + // 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_ + private static readonly string[] s_psFileExtensionsFullFramework = + { + ".ps1", + ".psm1", + ".psd1", + ".ps1xml" + }; + + // An array of globs which includes everything. + private static readonly string[] s_psIncludeAllGlob = new [] + { + "**/*" + }; + + private readonly ILogger logger; + private readonly Version powerShellVersion; + private readonly Dictionary workspaceFiles = new Dictionary(); + + #endregion + + #region Properties + + /// + /// Gets or sets the root path of the workspace. + /// + public string WorkspacePath { get; set; } + + /// + /// Gets or sets the default list of file globs to exclude during workspace searches. + /// + public List ExcludeFilesGlob { get; set; } + + /// + /// Gets or sets whether the workspace should follow symlinks in search operations. + /// + public bool FollowSymlinks { get; set; } + + #endregion + + #region Constructors + + /// + /// Creates a new instance of the Workspace class. + /// + /// The version of PowerShell for which scripts will be parsed. + /// An ILogger implementation used for writing log messages. + public WorkspaceService(ILoggerFactory factory) + { + this.powerShellVersion = VersionUtils.PSVersion; + this.logger = factory.CreateLogger(); + this.ExcludeFilesGlob = new List(); + this.FollowSymlinks = true; + } + + #endregion + + #region Public Methods + + /// + /// Creates a new ScriptFile instance which is identified by the given file + /// path and initially contains the given buffer contents. + /// + /// The file path for which a buffer will be retrieved. + /// The initial buffer contents if there is not an existing ScriptFile for this path. + /// A ScriptFile instance for the specified path. + public ScriptFile CreateScriptFileFromFileBuffer(string filePath, string initialBuffer) + { + Validate.IsNotNullOrEmptyString("filePath", filePath); + + // Resolve the full file path + string resolvedFilePath = this.ResolveFilePath(filePath); + string keyName = resolvedFilePath.ToLower(); + + ScriptFile scriptFile = + new ScriptFile( + resolvedFilePath, + filePath, + initialBuffer, + powerShellVersion); + + workspaceFiles[keyName] = scriptFile; + + logger.LogDebug("Opened file as in-memory buffer: " + resolvedFilePath); + + return scriptFile; + } + + /// + /// Gets an open file in the workspace. If the file isn't open but exists on the filesystem, load and return it. + /// IMPORTANT: Not all documents have a backing file e.g. untitled: scheme documents. Consider using + /// instead. + /// + /// The file path at which the script resides. + /// + /// is not found. + /// + /// + /// contains a null or empty string. + /// + public ScriptFile GetFile(string filePath) + { + Validate.IsNotNullOrEmptyString("filePath", filePath); + + // Resolve the full file path + string resolvedFilePath = this.ResolveFilePath(filePath); + string keyName = resolvedFilePath.ToLower(); + + // Make sure the file isn't already loaded into the workspace + if (!this.workspaceFiles.TryGetValue(keyName, out ScriptFile scriptFile)) + { + // This method allows FileNotFoundException to bubble up + // if the file isn't found. + using (FileStream fileStream = new FileStream(resolvedFilePath, FileMode.Open, FileAccess.Read)) + using (StreamReader streamReader = new StreamReader(fileStream, Encoding.UTF8)) + { + scriptFile = + new ScriptFile( + resolvedFilePath, + filePath, + streamReader, + this.powerShellVersion); + + this.workspaceFiles.Add(keyName, scriptFile); + } + + this.logger.LogDebug("Opened file on disk: " + resolvedFilePath); + } + + return scriptFile; + } + + /// + /// Tries to get an open file in the workspace. Returns true if it succeeds, false otherwise. + /// + /// The file path at which the script resides. + /// The out parameter that will contain the ScriptFile object. + public bool TryGetFile(string filePath, out ScriptFile scriptFile) + { + var fileUri = new Uri(filePath); + + switch (fileUri.Scheme) + { + // List supported schemes here + case "file": + case "untitled": + break; + + default: + scriptFile = null; + return false; + } + + try + { + scriptFile = GetFile(filePath); + return true; + } + catch (Exception e) when ( + e is NotSupportedException || + e is FileNotFoundException || + e is DirectoryNotFoundException || + e is PathTooLongException || + e is IOException || + e is SecurityException || + e is UnauthorizedAccessException) + { + this.logger.LogWarning($"Failed to get file for {nameof(filePath)}: '{filePath}'", e); + scriptFile = null; + return false; + } + } + + /// + /// Gets a new ScriptFile instance which is identified by the given file path. + /// + /// The file path for which a buffer will be retrieved. + /// A ScriptFile instance if there is a buffer for the path, null otherwise. + public ScriptFile GetFileBuffer(string filePath) + { + return this.GetFileBuffer(filePath, null); + } + + /// + /// Gets a new ScriptFile instance which is identified by the given file + /// path and initially contains the given buffer contents. + /// + /// The file path for which a buffer will be retrieved. + /// The initial buffer contents if there is not an existing ScriptFile for this path. + /// A ScriptFile instance for the specified path. + public ScriptFile GetFileBuffer(string filePath, string initialBuffer) + { + Validate.IsNotNullOrEmptyString("filePath", filePath); + + // Resolve the full file path + string resolvedFilePath = this.ResolveFilePath(filePath); + string keyName = resolvedFilePath.ToLower(); + + // Make sure the file isn't already loaded into the workspace + if (!this.workspaceFiles.TryGetValue(keyName, out ScriptFile scriptFile) && initialBuffer != null) + { + scriptFile = + new ScriptFile( + resolvedFilePath, + filePath, + initialBuffer, + this.powerShellVersion); + + this.workspaceFiles.Add(keyName, scriptFile); + + this.logger.LogDebug("Opened file as in-memory buffer: " + resolvedFilePath); + } + + return scriptFile; + } + + /// + /// Gets an array of all opened ScriptFiles in the workspace. + /// + /// An array of all opened ScriptFiles in the workspace. + public ScriptFile[] GetOpenedFiles() + { + return workspaceFiles.Values.ToArray(); + } + + /// + /// Closes a currently open script file with the given file path. + /// + /// The file path at which the script resides. + public void CloseFile(ScriptFile scriptFile) + { + Validate.IsNotNull("scriptFile", scriptFile); + + this.workspaceFiles.Remove(scriptFile.Id); + } + + /// + /// Gets all file references by recursively searching + /// through referenced files in a scriptfile + /// + /// Contains the details and contents of an open script file + /// A scriptfile array where the first file + /// in the array is the "root file" of the search + public ScriptFile[] ExpandScriptReferences(ScriptFile scriptFile) + { + Dictionary referencedScriptFiles = new Dictionary(); + List expandedReferences = new List(); + + // add original file so it's not searched for, then find all file references + referencedScriptFiles.Add(scriptFile.Id, scriptFile); + RecursivelyFindReferences(scriptFile, referencedScriptFiles); + + // remove original file from referened file and add it as the first element of the + // expanded referenced list to maintain order so the original file is always first in the list + referencedScriptFiles.Remove(scriptFile.Id); + expandedReferences.Add(scriptFile); + + if (referencedScriptFiles.Count > 0) + { + expandedReferences.AddRange(referencedScriptFiles.Values); + } + + return expandedReferences.ToArray(); + } + + /// + /// Gets the workspace-relative path of the given file path. + /// + /// The original full file path. + /// A relative file path + public string GetRelativePath(string filePath) + { + string resolvedPath = filePath; + + if (!IsPathInMemory(filePath) && !string.IsNullOrEmpty(this.WorkspacePath)) + { + Uri workspaceUri = new Uri(this.WorkspacePath); + Uri fileUri = new Uri(filePath); + + resolvedPath = workspaceUri.MakeRelativeUri(fileUri).ToString(); + + // Convert the directory separators if necessary + if (System.IO.Path.DirectorySeparatorChar == '\\') + { + resolvedPath = resolvedPath.Replace('/', '\\'); + } + } + + return resolvedPath; + } + + /// + /// Enumerate all the PowerShell (ps1, psm1, psd1) files in the workspace in a recursive manner, using default values. + /// + /// An enumerator over the PowerShell files found in the workspace. + public IEnumerable EnumeratePSFiles() + { + return EnumeratePSFiles( + ExcludeFilesGlob.ToArray(), + s_psIncludeAllGlob, + maxDepth: 64, + ignoreReparsePoints: !FollowSymlinks + ); + } + + /// + /// Enumerate all the PowerShell (ps1, psm1, psd1) files in the workspace in a recursive manner. + /// + /// An enumerator over the PowerShell files found in the workspace. + public IEnumerable EnumeratePSFiles( + string[] excludeGlobs, + string[] includeGlobs, + int maxDepth, + bool ignoreReparsePoints + ) + { + if (WorkspacePath == null || !Directory.Exists(WorkspacePath)) + { + yield break; + } + + var matcher = new Matcher(); + foreach (string pattern in includeGlobs) { matcher.AddInclude(pattern); } + foreach (string pattern in excludeGlobs) { matcher.AddExclude(pattern); } + + var fsFactory = new WorkspaceFileSystemWrapperFactory( + WorkspacePath, + maxDepth, + VersionUtils.IsNetCore ? s_psFileExtensionsCoreFramework : s_psFileExtensionsFullFramework, + ignoreReparsePoints, + logger + ); + var fileMatchResult = matcher.Execute(fsFactory.RootDirectory); + foreach (FilePatternMatch item in fileMatchResult.Files) + { + yield return Path.Combine(WorkspacePath, item.Path); + } + } + + #endregion + + #region Private Methods + /// + /// Recursively searches through referencedFiles in scriptFiles + /// and builds a Dictionary of the file references + /// + /// Details an contents of "root" script file + /// A Dictionary of referenced script files + private void RecursivelyFindReferences( + ScriptFile scriptFile, + Dictionary referencedScriptFiles) + { + // Get the base path of the current script for use in resolving relative paths + string baseFilePath = + GetBaseFilePath( + scriptFile.FilePath); + + foreach (string referencedFileName in scriptFile.ReferencedFiles) + { + string resolvedScriptPath = + this.ResolveRelativeScriptPath( + baseFilePath, + referencedFileName); + + // If there was an error resolving the string, skip this reference + if (resolvedScriptPath == null) + { + continue; + } + + this.logger.LogDebug( + string.Format( + "Resolved relative path '{0}' to '{1}'", + referencedFileName, + resolvedScriptPath)); + + // Get the referenced file if it's not already in referencedScriptFiles + if (TryGetFile(resolvedScriptPath, out ScriptFile referencedFile)) + { + // Normalize the resolved script path and add it to the + // referenced files list if it isn't there already + resolvedScriptPath = resolvedScriptPath.ToLower(); + if (!referencedScriptFiles.ContainsKey(resolvedScriptPath)) + { + referencedScriptFiles.Add(resolvedScriptPath, referencedFile); + RecursivelyFindReferences(referencedFile, referencedScriptFiles); + } + } + } + } + + internal string ResolveFilePath(string filePath) + { + if (!IsPathInMemory(filePath)) + { + if (filePath.StartsWith(@"file://")) + { + filePath = WorkspaceService.UnescapeDriveColon(filePath); + // Client sent the path in URI format, extract the local path + filePath = new Uri(filePath).LocalPath; + } + + // Clients could specify paths with escaped space, [ and ] characters which .NET APIs + // will not handle. These paths will get appropriately escaped just before being passed + // into the PowerShell engine. + //filePath = PowerShellContext.UnescapeWildcardEscapedPath(filePath); + + // Get the absolute file path + filePath = Path.GetFullPath(filePath); + } + + this.logger.LogDebug("Resolved path: " + filePath); + + return filePath; + } + + internal static bool IsPathInMemory(string filePath) + { + bool isInMemory = false; + + // In cases where a "virtual" file is displayed in the editor, + // we need to treat the file differently than one that exists + // on disk. A virtual file could be something like a diff + // view of the current file or an untitled file. + try + { + // File system absolute paths will have a URI scheme of file:. + // Other schemes like "untitled:" and "gitlens-git:" will return false for IsFile. + var uri = new Uri(filePath); + isInMemory = !uri.IsFile; + } + catch (UriFormatException) + { + // Relative file paths cause a UriFormatException. + // In this case, fallback to using Path.GetFullPath(). + try + { + Path.GetFullPath(filePath); + } + catch (Exception ex) when (ex is ArgumentException || ex is NotSupportedException) + { + isInMemory = true; + } + catch (PathTooLongException) + { + // If we ever get here, it should be an actual file so, not in memory + } + } + + return isInMemory; + } + + private string GetBaseFilePath(string filePath) + { + if (IsPathInMemory(filePath)) + { + // If the file is in memory, use the workspace path + return this.WorkspacePath; + } + + if (!Path.IsPathRooted(filePath)) + { + // TODO: Assert instead? + throw new InvalidOperationException( + string.Format( + "Must provide a full path for originalScriptPath: {0}", + filePath)); + } + + // Get the directory of the file path + return Path.GetDirectoryName(filePath); + } + + internal string ResolveRelativeScriptPath(string baseFilePath, string relativePath) + { + string combinedPath = null; + Exception resolveException = null; + + try + { + // If the path is already absolute there's no need to resolve it relatively + // to the baseFilePath. + if (Path.IsPathRooted(relativePath)) + { + return relativePath; + } + + // Get the directory of the original script file, combine it + // with the given path and then resolve the absolute file path. + combinedPath = + Path.GetFullPath( + Path.Combine( + baseFilePath, + relativePath)); + } + catch (NotSupportedException e) + { + // Occurs if the path is incorrectly formatted for any reason. One + // instance where this occurred is when a user had curly double-quote + // characters in their source instead of normal double-quotes. + resolveException = e; + } + catch (ArgumentException e) + { + // Occurs if the path contains invalid characters, specifically those + // listed in System.IO.Path.InvalidPathChars. + resolveException = e; + } + + if (resolveException != null) + { + this.logger.LogError( + $"Could not resolve relative script path\r\n" + + $" baseFilePath = {baseFilePath}\r\n " + + $" relativePath = {relativePath}\r\n\r\n" + + $"{resolveException.ToString()}"); + } + + return combinedPath; + } + + /// + /// Takes a file-scheme URI with an escaped colon after the drive letter and unescapes only the colon. + /// VSCode sends escaped colons after drive letters, but System.Uri expects unescaped. + /// + /// The fully-escaped file-scheme URI string. + /// A file-scheme URI string with the drive colon unescaped. + private static string UnescapeDriveColon(string fileUri) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return fileUri; + } + + // Check here that we have something like "file:///C%3A/" as a prefix (caller must check the file:// part) + if (!(fileUri[7] == '/' && + char.IsLetter(fileUri[8]) && + fileUri[9] == '%' && + fileUri[10] == '3' && + fileUri[11] == 'A' && + fileUri[12] == '/')) + { + return fileUri; + } + + var sb = new StringBuilder(fileUri.Length - 2); // We lost "%3A" and gained ":", so length - 2 + sb.Append("file:///"); + sb.Append(fileUri[8]); // The drive letter + sb.Append(':'); + sb.Append(fileUri.Substring(12)); // The rest of the URI after the colon + + return sb.ToString(); + } + + /// + /// Converts a file system path into a DocumentUri required by Language Server Protocol. + /// + /// + /// When sending a document path to a LSP client, the path must be provided as a + /// DocumentUri in order to features like the Problems window or peek definition + /// to be able to open the specified file. + /// + /// + /// A file system path. Note: if the path is already a DocumentUri, it will be returned unmodified. + /// + /// The file system path encoded as a DocumentUri. + public static string ConvertPathToDocumentUri(string path) + { + const string fileUriPrefix = "file:"; + const string untitledUriPrefix = "untitled:"; + + // If path is already in document uri form, there is nothing to convert. + if (path.StartsWith(untitledUriPrefix, StringComparison.Ordinal) || + path.StartsWith(fileUriPrefix, StringComparison.Ordinal)) + { + return path; + } + + string escapedPath = Uri.EscapeDataString(path); + + // Max capacity of the StringBuilder will be the current escapedPath length + // plus extra chars for file:///. + var docUriStrBld = new StringBuilder(escapedPath.Length + fileUriPrefix.Length + 3); + docUriStrBld.Append(fileUriPrefix).Append("//"); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // VSCode file URIs on Windows need the drive letter to be lowercase. Search the + // original path for colon since a char search (no string culture involved) is + // faster than a string search. If found, then lowercase the associated drive letter. + if (path.Contains(':')) + { + // A valid, drive-letter based path converted to URI form needs to be prefixed + // with a / to indicate the path is an absolute path. + docUriStrBld.Append("/"); + int prefixLen = docUriStrBld.Length; + + docUriStrBld.Append(escapedPath); + + // Uri.EscapeDataString goes a bit far, encoding \ chars. Also, VSCode wants / instead of \. + docUriStrBld.Replace("%5C", "/"); + + // Find the first colon after the "file:///" prefix, skipping the first char after + // the prefix since a Windows path cannot start with a colon. End the check at + // less than docUriStrBld.Length - 2 since we need to look-ahead two characters. + for (int i = prefixLen + 1; i < docUriStrBld.Length - 2; i++) + { + if ((docUriStrBld[i] == '%') && (docUriStrBld[i + 1] == '3') && (docUriStrBld[i + 2] == 'A')) + { + int driveLetterIndex = i - 1; + char driveLetter = char.ToLowerInvariant(docUriStrBld[driveLetterIndex]); + docUriStrBld.Replace(docUriStrBld[driveLetterIndex], driveLetter, driveLetterIndex, 1); + break; + } + } + } + else + { + // This is a Windows path without a drive specifier, must be either a relative or UNC path. + int prefixLen = docUriStrBld.Length; + + docUriStrBld.Append(escapedPath); + + // Uri.EscapeDataString goes a bit far, encoding \ chars. Also, VSCode wants / instead of \. + docUriStrBld.Replace("%5C", "/"); + + // The proper URI form for a UNC path is file://server/share. In the case of a UNC + // path, remove the path's leading // because the file:// prefix already provides it. + if ((docUriStrBld.Length > prefixLen + 1) && + (docUriStrBld[prefixLen] == '/') && + (docUriStrBld[prefixLen + 1] == '/')) + { + docUriStrBld.Remove(prefixLen, 2); + } + } + } + else + { + // On non-Windows systems, append the escapedPath and undo the over-aggressive + // escaping of / done by Uri.EscapeDataString. + docUriStrBld.Append(escapedPath).Replace("%2F", "/"); + } + + if (!VersionUtils.IsNetCore) + { + // ' is not encoded by Uri.EscapeDataString in Windows PowerShell 5.x. + // This is apparently a difference between .NET Framework and .NET Core. + docUriStrBld.Replace("'", "%27"); + } + + return docUriStrBld.ToString(); + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices.Engine/Utility/PathUtils.cs b/src/PowerShellEditorServices.Engine/Utility/PathUtils.cs new file mode 100644 index 000000000..da9dfa3ad --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Utility/PathUtils.cs @@ -0,0 +1,33 @@ +using System; + +namespace PowerShellEditorServices.Engine.Utility +{ + internal class PathUtils + { + public string WildcardUnescapePath(string path) + { + throw new NotImplementedException(); + } + + public static Uri ToUri(string fileName) + { + fileName = fileName.Replace(":", "%3A").Replace("\\", "/"); + if (!fileName.StartsWith("/")) return new Uri($"file:///{fileName}"); + return new Uri($"file://{fileName}"); + } + + public static string FromUri(Uri uri) + { + if (uri.Segments.Length > 1) + { + // On windows of the Uri contains %3a local path + // doesn't come out as a proper windows path + if (uri.Segments[1].IndexOf("%3a", StringComparison.OrdinalIgnoreCase) > -1) + { + return FromUri(new Uri(uri.AbsoluteUri.Replace("%3a", ":").Replace("%3A", ":"))); + } + } + return uri.LocalPath; + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Utility/Validate.cs b/src/PowerShellEditorServices.Engine/Utility/Validate.cs new file mode 100644 index 000000000..a595bd6c9 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Utility/Validate.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; + +namespace Microsoft.PowerShell.EditorServices +{ + /// + /// Provides common validation methods to simplify method + /// parameter checks. + /// + public static class Validate + { + /// + /// Throws ArgumentNullException if value is null. + /// + /// The name of the parameter being validated. + /// The value of the parameter being validated. + public static void IsNotNull(string parameterName, object valueToCheck) + { + if (valueToCheck == null) + { + throw new ArgumentNullException(parameterName); + } + } + + /// + /// Throws ArgumentOutOfRangeException if the value is outside + /// of the given lower and upper limits. + /// + /// The name of the parameter being validated. + /// The value of the parameter being validated. + /// The lower limit which the value should not be less than. + /// The upper limit which the value should not be greater than. + public static void IsWithinRange( + string parameterName, + int valueToCheck, + int lowerLimit, + int upperLimit) + { + // TODO: Debug assert here if lowerLimit >= upperLimit + + if (valueToCheck < lowerLimit || valueToCheck > upperLimit) + { + throw new ArgumentOutOfRangeException( + parameterName, + valueToCheck, + string.Format( + "Value is not between {0} and {1}", + lowerLimit, + upperLimit)); + } + } + + /// + /// Throws ArgumentOutOfRangeException if the value is greater than or equal + /// to the given upper limit. + /// + /// The name of the parameter being validated. + /// The value of the parameter being validated. + /// The upper limit which the value should be less than. + public static void IsLessThan( + string parameterName, + int valueToCheck, + int upperLimit) + { + if (valueToCheck >= upperLimit) + { + throw new ArgumentOutOfRangeException( + parameterName, + valueToCheck, + string.Format( + "Value is greater than or equal to {0}", + upperLimit)); + } + } + + /// + /// Throws ArgumentOutOfRangeException if the value is less than or equal + /// to the given lower limit. + /// + /// The name of the parameter being validated. + /// The value of the parameter being validated. + /// The lower limit which the value should be greater than. + public static void IsGreaterThan( + string parameterName, + int valueToCheck, + int lowerLimit) + { + if (valueToCheck < lowerLimit) + { + throw new ArgumentOutOfRangeException( + parameterName, + valueToCheck, + string.Format( + "Value is less than or equal to {0}", + lowerLimit)); + } + } + + /// + /// Throws ArgumentException if the value is equal to the undesired value. + /// + /// The type of value to be validated. + /// The name of the parameter being validated. + /// The value that valueToCheck should not equal. + /// The value of the parameter being validated. + public static void IsNotEqual( + string parameterName, + TValue valueToCheck, + TValue undesiredValue) + { + if (EqualityComparer.Default.Equals(valueToCheck, undesiredValue)) + { + throw new ArgumentException( + string.Format( + "The given value '{0}' should not equal '{1}'", + valueToCheck, + undesiredValue), + parameterName); + } + } + + /// + /// Throws ArgumentException if the value is null, an empty string, + /// or a string containing only whitespace. + /// + /// The name of the parameter being validated. + /// The value of the parameter being validated. + public static void IsNotNullOrEmptyString(string parameterName, string valueToCheck) + { + if (string.IsNullOrWhiteSpace(valueToCheck)) + { + throw new ArgumentException( + "Parameter contains a null, empty, or whitespace string.", + parameterName); + } + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Utility/VersionUtils.cs b/src/PowerShellEditorServices.Engine/Utility/VersionUtils.cs new file mode 100644 index 000000000..da99597b4 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Utility/VersionUtils.cs @@ -0,0 +1,63 @@ +using System; +using System.Reflection; +using System.Runtime.InteropServices; + +namespace Microsoft.PowerShell.EditorServices +{ + /// + /// General purpose common utilities to prevent reimplementation. + /// + internal static class VersionUtils + { + /// + /// True if we are running on .NET Core, false otherwise. + /// + public static bool IsNetCore { get; } = RuntimeInformation.FrameworkDescription.StartsWith(".NET Core", StringComparison.Ordinal); + + /// + /// Get's the Version of PowerShell being used. + /// + public static Version PSVersion { get; } = PowerShellReflectionUtils.PSVersion; + + /// + /// Get's the Edition of PowerShell being used. + /// + public static string PSEdition { get; } = PowerShellReflectionUtils.PSEdition; + + /// + /// True if we are running in Windows PowerShell, false otherwise. + /// + public static bool IsPS5 { get; } = PSVersion.Major == 5; + + /// + /// True if we are running in PowerShell Core 6, false otherwise. + /// + public static bool IsPS6 { get; } = PSVersion.Major == 6; + + /// + /// True if we are running in PowerShell 7, false otherwise. + /// + public static bool IsPS7 { get; } = PSVersion.Major == 7; + } + + internal static class PowerShellReflectionUtils + { + + private static readonly Type s_psVersionInfoType = typeof(System.Management.Automation.Runspaces.Runspace).Assembly.GetType("System.Management.Automation.PSVersionInfo"); + private static readonly PropertyInfo s_psVersionProperty = s_psVersionInfoType + .GetProperty("PSVersion", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static); + private static readonly PropertyInfo s_psEditionProperty = s_psVersionInfoType + .GetProperty("PSEdition", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static); + + /// + /// Get's the Version of PowerShell being used. Note: this will get rid of the SemVer 2.0 suffix because apparently + /// that property is added as a note property and it is not there when we reflect. + /// + public static Version PSVersion { get; } = s_psVersionProperty.GetValue(null) as Version; + + /// + /// Get's the Edition of PowerShell being used. + /// + public static string PSEdition { get; } = s_psEditionProperty.GetValue(null) as string; + } +} diff --git a/src/PowerShellEditorServices.Protocol/LanguageServer/Initialize.cs b/src/PowerShellEditorServices.Protocol/LanguageServer/Initialize.cs index be73b74a0..234b02169 100644 --- a/src/PowerShellEditorServices.Protocol/LanguageServer/Initialize.cs +++ b/src/PowerShellEditorServices.Protocol/LanguageServer/Initialize.cs @@ -49,7 +49,7 @@ public class InitializeParams { /// /// This is defined as `any` type on the client side. /// - public object InitializeOptions { get; set; } + public object InitializationOptions { get; set; } // TODO We need to verify if the deserializer will map the type defined in the client // to an enum. diff --git a/src/PowerShellEditorServices.Protocol/LanguageServer/WorkspaceClientCapabilities.cs b/src/PowerShellEditorServices.Protocol/LanguageServer/WorkspaceClientCapabilities.cs index 3e781a217..e83fdec5a 100644 --- a/src/PowerShellEditorServices.Protocol/LanguageServer/WorkspaceClientCapabilities.cs +++ b/src/PowerShellEditorServices.Protocol/LanguageServer/WorkspaceClientCapabilities.cs @@ -6,7 +6,8 @@ public class WorkspaceClientCapabilities /// The client supports applying batch edits to the workspace by /// by supporting the request `workspace/applyEdit' /// /// - bool? ApplyEdit { get; set; } + public bool? ApplyEdit { get; set; } + /// /// Capabilities specific to `WorkspaceEdit`. @@ -42,6 +43,6 @@ public class WorkspaceEditCapabilities /// /// The client supports versioned document changes in `WorkspaceEdit` /// - bool? DocumentChanges { get; set; } + public bool? DocumentChanges { get; set; } } } diff --git a/src/PowerShellEditorServices/Workspace/Workspace.cs b/src/PowerShellEditorServices/Workspace/Workspace.cs index 34cb0d641..104e58446 100644 --- a/src/PowerShellEditorServices/Workspace/Workspace.cs +++ b/src/PowerShellEditorServices/Workspace/Workspace.cs @@ -174,6 +174,20 @@ public ScriptFile GetFile(string filePath) /// The out parameter that will contain the ScriptFile object. public bool TryGetFile(string filePath, out ScriptFile scriptFile) { + var fileUri = new Uri(filePath); + + switch (fileUri.Scheme) + { + // List supported schemes here + case "file": + case "untitled": + break; + + default: + scriptFile = null; + return false; + } + try { scriptFile = GetFile(filePath); diff --git a/test/Pester/EditorServices.Integration.Tests.ps1 b/test/Pester/EditorServices.Integration.Tests.ps1 index d5ea605c6..2d29d381b 100644 --- a/test/Pester/EditorServices.Integration.Tests.ps1 +++ b/test/Pester/EditorServices.Integration.Tests.ps1 @@ -30,32 +30,237 @@ function ReportLogErrors } } +function CheckErrorResponse +{ + [CmdletBinding()] + param( + $Response + ) + + if (-not ($Response -is [PsesPsClient.LspErrorResponse])) + { + return + } + + $msg = @" +Error Response Received +Code: $($Response.Code) +Message: + $($Response.Message) + +Data: + $($Response.Data) +"@ + + throw $msg +} + +function New-TestFile +{ + param( + [Parameter(Mandatory)] + [string] + $Script + ) + + $file = Set-Content -Path (Join-Path $TestDrive "$([System.IO.Path]::GetRandomFileName()).ps1") -Value $Script -PassThru -Force + + $request = Send-LspDidOpenTextDocumentRequest -Client $client ` + -Uri ([Uri]::new($file.PSPath).AbsoluteUri) ` + -Text ($file[0].ToString()) + + # To give PSScriptAnalyzer a chance to run. + Start-Sleep 1 + + # There's no response for this message, but we need to call Get-LspResponse + # to increment the counter. + Get-LspResponse -Client $client -Id $request.Id | Out-Null + + # Throw out any notifications from the first PSScriptAnalyzer run. + Get-LspNotification -Client $client | Out-Null + + $file.PSPath +} + Describe "Loading and running PowerShellEditorServices" { BeforeAll { Import-Module -Force "$PSScriptRoot/../../module/PowerShellEditorServices" + Import-Module -Force "$PSScriptRoot/../../src/PowerShellEditorServices.Engine/bin/Debug/netstandard2.0/publish/Omnisharp.Extensions.LanguageProtocol.dll" Import-Module -Force "$PSScriptRoot/../../tools/PsesPsClient/out/PsesPsClient" Import-Module -Force "$PSScriptRoot/../../tools/PsesLogAnalyzer" $logIdx = 0 $psesServer = Start-PsesServer - $client = Connect-PsesServer -PipeName $psesServer.SessionDetails.languageServicePipeName + $client = Connect-PsesServer -InPipeName $psesServer.SessionDetails.languageServiceWritePipeName -OutPipeName $psesServer.SessionDetails.languageServiceReadPipeName } # This test MUST be first It "Starts and responds to an initialization request" { $request = Send-LspInitializeRequest -Client $client + $response = Get-LspResponse -Client $client -Id $request.Id #-WaitMillis 99999 + $response.Id | Should -BeExactly $request.Id + + CheckErrorResponse -Response $response + + #ReportLogErrors -LogPath $psesServer.LogPath -FromIndex ([ref]$logIdx) + } + + It "Can handle powerShell/getVersion request" { + $request = Send-LspRequest -Client $client -Method "powerShell/getVersion" $response = Get-LspResponse -Client $client -Id $request.Id + if ($IsCoreCLR) { + $response.Result.edition | Should -Be "Core" + } else { + $response.Result.edition | Should -Be "Desktop" + } + } + + It "Can handle WorkspaceSymbol request" { + New-TestFile -Script " +function Get-Foo { + Write-Host 'hello' +} +" + + $request = Send-LspRequest -Client $client -Method "workspace/symbol" -Parameters @{ + query = "" + } + $response = Get-LspResponse -Client $client -Id $request.Id -WaitMillis 99999 $response.Id | Should -BeExactly $request.Id - ReportLogErrors -LogPath $psesServer.LogPath -FromIndex ([ref]$logIdx) + $response.Result.Count | Should -Be 1 + $response.Result.name | Should -BeLike "Get-Foo*" + CheckErrorResponse -Response $response + + # ReportLogErrors -LogPath $psesServer.LogPath -FromIndex ([ref]$logIdx) + } + + It "Can get Diagnostics after opening a text document" { + $script = '$a = 4' + $file = Set-Content -Path (Join-Path $TestDrive "$([System.IO.Path]::GetRandomFileName()).ps1") -Value $script -PassThru -Force + + $request = Send-LspDidOpenTextDocumentRequest -Client $client ` + -Uri ([Uri]::new($file.PSPath).AbsoluteUri) ` + -Text ($file[0].ToString()) + + # There's no response for this message, but we need to call Get-LspResponse + # to increment the counter. + Get-LspResponse -Client $client -Id $request.Id | Out-Null + + # Grab notifications for just the file opened in this test. + $notifications = Get-LspNotification -Client $client | Where-Object { + $_.Params.uri -match ([System.IO.Path]::GetFileName($file.PSPath)) + } + + $notifications | Should -Not -BeNullOrEmpty + $notifications.Params.diagnostics | Should -Not -BeNullOrEmpty + $notifications.Params.diagnostics.Count | Should -Be 1 + $notifications.Params.diagnostics.code | Should -Be "PSUseDeclaredVarsMoreThanAssignments" + } + + It "Can get Diagnostics after changing settings" { + $file = New-TestFile -Script 'gci | % { $_ }' + + $request = Send-LspDidChangeConfigurationRequest -Client $client -Settings @{ + PowerShell = @{ + ScriptAnalysis = @{ + Enable = $false + } + } + } + + # Grab notifications for just the file opened in this test. + $notifications = Get-LspNotification -Client $client | Where-Object { + $_.Params.uri -match ([System.IO.Path]::GetFileName($file.PSPath)) + } + $notifications | Should -Not -BeNullOrEmpty + $notifications.Params.diagnostics | Should -BeNullOrEmpty + } + + It "Can handle folding request" { + $filePath = New-TestFile -Script 'gci | % { +$_ + +@" + $_ +"@ +}' + + $request = Send-LspRequest -Client $client -Method "textDocument/foldingRange" -Parameters ([Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.FoldingRangeParams] @{ + TextDocument = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.TextDocumentIdentifier] @{ + Uri = ([Uri]::new($filePath).AbsoluteUri) + } + }) + + $response = Get-LspResponse -Client $client -Id $request.Id + + $sortedResults = $response.Result | Sort-Object -Property startLine + $sortedResults[0].startLine | Should -Be 0 + $sortedResults[0].startCharacter | Should -Be 8 + $sortedResults[0].endLine | Should -Be 5 + $sortedResults[0].endCharacter | Should -Be 1 + + $sortedResults[1].startLine | Should -Be 3 + $sortedResults[1].startCharacter | Should -Be 0 + $sortedResults[1].endLine | Should -Be 4 + $sortedResults[1].endCharacter | Should -Be 2 + } + + It "can handle a normal formatting request" { + $filePath = New-TestFile -Script ' +gci | % { +Get-Process +} + +' + + $request = Send-LspFormattingRequest -Client $client ` + -Uri ([Uri]::new($filePath).AbsoluteUri) + + $response = Get-LspResponse -Client $client -Id $request.Id + + # If we have a tab, formatting ran. + $response.Result.newText.Contains("`t") | Should -BeTrue -Because "We expect a tab." + } + + It "can handle a range formatting request" { + $filePath = New-TestFile -Script ' +gci | % { +Get-Process +} + +' + + $range = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.Range]@{ + Start = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.Position]@{ + Line = 2 + Character = 0 + } + End = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.Position]@{ + Line = 3 + Character = 0 + } + } + + $request = Send-LspRangeFormattingRequest -Client $client ` + -Uri ([Uri]::new($filePath).AbsoluteUri) ` + -Range $range + + $response = Get-LspResponse -Client $client -Id $request.Id + + # If we have a tab, formatting ran. + $response.Result.newText.Contains("`t") | Should -BeTrue -Because "We expect a tab." } # This test MUST be last It "Shuts down the process properly" { $request = Send-LspShutdownRequest -Client $client - $response = Get-LspResponse -Client $client -Id $request.Id + $response = Get-LspResponse -Client $client -Id $request.Id #-WaitMillis 99999 $response.Id | Should -BeExactly $request.Id $response.Result | Should -BeNull + + CheckErrorResponse -Response $response + # TODO: The server seems to stay up waiting for the debug connection # $psesServer.PsesProcess.HasExited | Should -BeTrue @@ -78,9 +283,38 @@ Describe "Loading and running PowerShellEditorServices" { finally { $client.Dispose() + $client = $null } } - ReportLogErrors -LogPath $psesServer.LogPath -FromIndex ([ref]$logIdx) + #ReportLogErrors -LogPath $psesServer.LogPath -FromIndex ([ref]$logIdx) + } + + AfterEach { + if($client) { + # Drain notifications + Get-LspNotification -Client $client | Out-Null + } + } + + AfterAll { + if ($psesServer.PsesProcess.HasExited -eq $false) + { + try + { + $psesServer.PsesProcess.Kill() + } + finally + { + try + { + $psesServer.PsesProcess.Dispose() + } + finally + { + $client.Dispose() + } + } + } } } diff --git a/tools/PsesPsClient/Client.cs b/tools/PsesPsClient/Client.cs index bae8c7247..35d86b278 100644 --- a/tools/PsesPsClient/Client.cs +++ b/tools/PsesPsClient/Client.cs @@ -31,18 +31,26 @@ public class PsesLspClient : IDisposable /// /// The name of the named pipe to use. /// A new LspPipe instance around the given named pipe. - public static PsesLspClient Create(string pipeName) + public static PsesLspClient Create(string inPipeName, string outPipeName) { - var pipeClient = new NamedPipeClientStream( - pipeName: pipeName, + var inPipeStream = new NamedPipeClientStream( + pipeName: inPipeName, serverName: ".", - direction: PipeDirection.InOut, + direction: PipeDirection.In, options: PipeOptions.Asynchronous); - return new PsesLspClient(pipeClient); + var outPipeStream = new NamedPipeClientStream( + pipeName: outPipeName, + serverName: ".", + direction: PipeDirection.Out, + options: PipeOptions.Asynchronous); + + return new PsesLspClient(inPipeStream, outPipeStream); } - private readonly NamedPipeClientStream _namedPipeClient; + private readonly NamedPipeClientStream _inPipe; + + private readonly NamedPipeClientStream _outPipe; private readonly JsonSerializerSettings _jsonSettings; @@ -62,13 +70,15 @@ public static PsesLspClient Create(string pipeName) /// Create a new LSP pipe around a named pipe client stream. /// /// The named pipe client stream to use for the LSP pipe. - public PsesLspClient(NamedPipeClientStream namedPipeClient) + public PsesLspClient(NamedPipeClientStream inPipe, NamedPipeClientStream outPipe) { - _namedPipeClient = namedPipeClient; + _inPipe = inPipe; + _outPipe = outPipe; _jsonSettings = new JsonSerializerSettings() { - ContractResolver = new CamelCasePropertyNamesContractResolver() + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Formatting = Formatting.Indented }; _jsonSerializer = JsonSerializer.Create(_jsonSettings); @@ -84,9 +94,10 @@ public PsesLspClient(NamedPipeClientStream namedPipeClient) /// public void Connect() { - _namedPipeClient.Connect(timeout: 1000); - _listener = new MessageStreamListener(new StreamReader(_namedPipeClient, _pipeEncoding)); - _writer = new StreamWriter(_namedPipeClient, _pipeEncoding) + _inPipe.Connect(timeout: 1000); + _outPipe.Connect(timeout: 1000); + _listener = new MessageStreamListener(new StreamReader(_inPipe, _pipeEncoding)); + _writer = new StreamWriter(_outPipe, _pipeEncoding) { AutoFlush = true }; @@ -159,8 +170,10 @@ public void Dispose() { _writer.Dispose(); _listener.Dispose(); - _namedPipeClient.Close(); - _namedPipeClient.Dispose(); + _inPipe.Close(); + _outPipe.Close(); + _inPipe.Dispose(); + _outPipe.Dispose(); } } @@ -319,7 +332,7 @@ private async Task ReadMessage() return new LspNotification(method, msgJson["params"]); } - string id = ((JValue)msgJson["id"]).Value.ToString(); + string id = ((JValue)msgJson["id"])?.Value?.ToString(); if (msgJson.TryGetValue("result", out JToken resultToken)) { @@ -327,7 +340,7 @@ private async Task ReadMessage() } JObject errorBody = (JObject)msgJson["error"]; - JsonRpcErrorCode errorCode = (JsonRpcErrorCode)(int)((JValue)errorBody["code"]).Value; + JsonRpcErrorCode errorCode = (JsonRpcErrorCode)((JValue)errorBody["code"]).Value; string message = (string)((JValue)errorBody["message"]).Value; return new LspErrorResponse(id, errorCode, message, errorBody["data"]); } @@ -564,7 +577,7 @@ public LspErrorResponse( /// /// Error codes used by the Language Server Protocol. /// - public enum JsonRpcErrorCode : int + public enum JsonRpcErrorCode : long { ParseError = -32700, InvalidRequest = -32600, diff --git a/tools/PsesPsClient/PsesPsClient.psd1 b/tools/PsesPsClient/PsesPsClient.psd1 index c102d30c8..1bba35a5d 100644 --- a/tools/PsesPsClient/PsesPsClient.psd1 +++ b/tools/PsesPsClient/PsesPsClient.psd1 @@ -74,7 +74,12 @@ FunctionsToExport = @( 'Connect-PsesServer', 'Send-LspRequest', 'Send-LspInitializeRequest', + 'Send-LspDidOpenTextDocumentRequest', + 'Send-LspDidChangeConfigurationRequest', + 'Send-LspFormattingRequest', + 'Send-LspRangeFormattingRequest', 'Send-LspShutdownRequest', + 'Get-LspNotification', 'Get-LspResponse' ) diff --git a/tools/PsesPsClient/PsesPsClient.psm1 b/tools/PsesPsClient/PsesPsClient.psm1 index a66e5c7d1..bc825a068 100644 --- a/tools/PsesPsClient/PsesPsClient.psm1 +++ b/tools/PsesPsClient/PsesPsClient.psm1 @@ -18,6 +18,7 @@ class PsesStartupOptions [string[]] $AdditionalModules [string] $BundledModulesPath [bool] $EnableConsoleRepl + [switch] $SplitInOutPipes } class PsesServerInfo @@ -124,6 +125,7 @@ function Start-PsesServer AdditionalModules = $AdditionalModules BundledModulesPath = $BundledModulesPath EnableConsoleRepl = $EnableConsoleRepl + SplitInOutPipes = [switch]::Present } $startPsesCommand = Unsplat -Prefix "& '$EditorServicesPath'" -SplatParams $editorServicesOptions @@ -184,16 +186,21 @@ function Connect-PsesServer param( [Parameter(Mandatory)] [string] - $PipeName + $InPipeName, + + [Parameter(Mandatory)] + [string] + $OutPipeName ) - $psesIdx = $PipeName.IndexOf('PSES') + $psesIdx = $InPipeName.IndexOf('PSES') if ($psesIdx -gt 0) { - $PipeName = $PipeName.Substring($psesIdx) + $InPipeName = $InPipeName.Substring($psesIdx) + $OutPipeName = $OutPipeName.Substring($psesIdx) } - $client = [PsesPsClient.PsesLspClient]::Create($PipeName) + $client = [PsesPsClient.PsesLspClient]::Create($InPipeName, $OutPipeName) $client.Connect() return $client } @@ -220,17 +227,17 @@ function Send-LspInitializeRequest [Parameter()] [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.ClientCapabilities] - $ClientCapabilities = ([Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.ClientCapabilities]::new()), + $ClientCapabilities = (Get-ClientCapabilities), [Parameter()] - [hashtable] - $InitializeOptions = $null + [object] + $IntializationOptions = ([object]::new()) ) $parameters = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.InitializeParams]@{ ProcessId = $ProcessId Capabilities = $ClientCapabilities - InitializeOptions = $InitializeOptions + InitializationOptions = $IntializationOptions } if ($RootUri) @@ -239,12 +246,152 @@ function Send-LspInitializeRequest } else { - $parameters.RootPath = $RootPath + $parameters.RootUri = [uri]::new($RootPath) } return Send-LspRequest -Client $Client -Method 'initialize' -Parameters $parameters } +function Send-LspDidOpenTextDocumentRequest +{ + [OutputType([PsesPsClient.LspRequest])] + param( + [Parameter(Position = 0, Mandatory)] + [PsesPsClient.PsesLspClient] + $Client, + + [Parameter(Mandatory)] + [string] + $Uri, + + [Parameter()] + [int] + $Version = 0, + + [Parameter()] + [string] + $LanguageId = "powershell", + + [Parameter()] + [string] + $Text + ) + + $parameters = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.DidOpenTextDocumentParams]@{ + TextDocument = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.TextDocumentItem]@{ + Uri = $Uri + LanguageId = $LanguageId + Text = $Text + Version = $Version + } + } + + $result = Send-LspRequest -Client $Client -Method 'textDocument/didOpen' -Parameters $parameters + + # Give PSScriptAnalyzer enough time to run + Start-Sleep -Seconds 1 + + $result +} + +function Send-LspDidChangeConfigurationRequest +{ + [OutputType([PsesPsClient.LspRequest])] + param( + [Parameter(Position = 0, Mandatory)] + [PsesPsClient.PsesLspClient] + $Client, + + [Parameter(Mandatory)] + [Microsoft.PowerShell.EditorServices.Protocol.Server.LanguageServerSettingsWrapper] + $Settings + ) + + $parameters = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.DidChangeConfigurationParams[Microsoft.PowerShell.EditorServices.Protocol.Server.LanguageServerSettingsWrapper]]@{ + Settings = $Settings + } + + $result = Send-LspRequest -Client $Client -Method 'workspace/didChangeConfiguration' -Parameters $parameters + + # Give PSScriptAnalyzer enough time to run + Start-Sleep -Seconds 1 + + $result +} + +function Send-LspFormattingRequest +{ + [OutputType([PsesPsClient.LspRequest])] + param( + [Parameter(Position = 0, Mandatory)] + [PsesPsClient.PsesLspClient] + $Client, + + [Parameter(Mandatory)] + [string] + $Uri, + + [Parameter()] + [int] + $TabSize = 4, + + [Parameter()] + [switch] + $InsertSpaces + ) + + $params = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.DocumentFormattingParams]@{ + TextDocument = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.TextDocumentIdentifier]@{ + Uri = $Uri + } + options = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.FormattingOptions]@{ + TabSize = $TabSize + InsertSpaces = $InsertSpaces.IsPresent + } + } + + return Send-LspRequest -Client $Client -Method 'textDocument/formatting' -Parameters $params +} + +function Send-LspRangeFormattingRequest +{ + [OutputType([PsesPsClient.LspRequest])] + param( + [Parameter(Position = 0, Mandatory)] + [PsesPsClient.PsesLspClient] + $Client, + + [Parameter(Mandatory)] + [string] + $Uri, + + [Parameter(Mandatory)] + [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.Range] + $Range, + + [Parameter()] + [int] + $TabSize = 4, + + [Parameter()] + [switch] + $InsertSpaces + ) + + $params = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.DocumentRangeFormattingParams]@{ + TextDocument = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.TextDocumentIdentifier]@{ + Uri = $Uri + } + Range = $Range + options = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.FormattingOptions]@{ + TabSize = $TabSize + InsertSpaces = $InsertSpaces.IsPresent + } + } + + return Send-LspRequest -Client $Client -Method 'textDocument/rangeFormatting' -Parameters $params +} + function Send-LspShutdownRequest { [OutputType([PsesPsClient.LspRequest])] @@ -273,7 +420,13 @@ function Send-LspRequest $Parameters = $null ) - return $Client.WriteRequest($Method, $Parameters) + + $result = $Client.WriteRequest($Method, $Parameters) + + # To allow for result/notification queue to fill up + Start-Sleep 1 + + $result } function Get-LspResponse @@ -297,7 +450,29 @@ function Get-LspResponse if ($Client.TryGetResponse($Id, [ref]$lspResponse, $WaitMillis)) { - return $lspResponse + $result = if ($lspResponse.Result) { $lspResponse.Result.ToString() | ConvertFrom-Json } + return [PSCustomObject]@{ + Id = $lspResponse.Id + Result = $result + } + } +} + +function Get-LspNotification +{ + [OutputType([PsesPsClient.LspResponse])] + param( + [Parameter(Position = 0, Mandatory)] + [PsesPsClient.PsesLspClient] + $Client + ) + + $Client.GetNotifications() | ForEach-Object { + $result = if ($_.Params) { $_.Params.ToString() | ConvertFrom-Json } + [PSCustomObject]@{ + Method = $_.Method + Params = $result + } } } @@ -373,3 +548,80 @@ function Get-RandomHexString return $str } + +function Get-ClientCapabilities +{ + return [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.ClientCapabilities]@{ + Workspace = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.WorkspaceClientCapabilities]@{ + ApplyEdit = $true + WorkspaceEdit = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.WorkspaceEditCapabilities]@{ + DocumentChanges = $false + } + DidChangeConfiguration = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.DynamicRegistrationCapability]@{ + DynamicRegistration = $false + } + DidChangeWatchedFiles = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.DynamicRegistrationCapability]@{ + DynamicRegistration = $false + } + Symbol = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.DynamicRegistrationCapability]@{ + DynamicRegistration = $false + } + ExecuteCommand = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.DynamicRegistrationCapability]@{ + DynamicRegistration = $false + } + } + TextDocument = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.TextDocumentClientCapabilities]@{ + Synchronization = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.SynchronizationCapabilities]@{ + WillSave = $true + WillSaveWaitUntil = $true + DidSave = $true + } + Completion = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.CompletionCapabilities]@{ + DynamicRegistration = $false + CompletionItem = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.CompletionItemCapabilities]@{ + SnippetSupport = $true + } + } + Hover = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.DynamicRegistrationCapability]@{ + DynamicRegistration = $false + } + SignatureHelp = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.DynamicRegistrationCapability]@{ + DynamicRegistration = $false + } + References = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.DynamicRegistrationCapability]@{ + DynamicRegistration = $false + } + DocumentHighlight = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.DynamicRegistrationCapability]@{ + DynamicRegistration = $false + } + DocumentSymbol = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.DynamicRegistrationCapability]@{ + DynamicRegistration = $false + } + Formatting = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.DynamicRegistrationCapability]@{ + DynamicRegistration = $false + } + RangeFormatting = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.DynamicRegistrationCapability]@{ + DynamicRegistration = $false + } + OnTypeFormatting = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.DynamicRegistrationCapability]@{ + DynamicRegistration = $false + } + Definition = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.DynamicRegistrationCapability]@{ + DynamicRegistration = $false + } + CodeLens = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.DynamicRegistrationCapability]@{ + DynamicRegistration = $false + } + CodeAction = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.DynamicRegistrationCapability]@{ + DynamicRegistration = $false + } + DocumentLink = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.DynamicRegistrationCapability]@{ + DynamicRegistration = $false + } + Rename = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.DynamicRegistrationCapability]@{ + DynamicRegistration = $false + } + } + Experimental = [System.Object]::new() + } +}