diff --git a/.vsts-ci/templates/ci-general.yml b/.vsts-ci/templates/ci-general.yml index 2bce528c0..664d41d9e 100644 --- a/.vsts-ci/templates/ci-general.yml +++ b/.vsts-ci/templates/ci-general.yml @@ -14,6 +14,11 @@ steps: testRunner: VSTest testResultsFiles: '**/*.trx' condition: succeededOrFailed() + - task: PublishTestResults@2 + inputs: + testRunner: NUnit + testResultsFiles: '**/TestResults.xml' + condition: succeededOrFailed() - task: PublishBuildArtifacts@1 inputs: ArtifactName: PowerShellEditorServices diff --git a/PowerShellEditorServices.build.ps1 b/PowerShellEditorServices.build.ps1 index d09c14323..d28c9ffa8 100644 --- a/PowerShellEditorServices.build.ps1 +++ b/PowerShellEditorServices.build.ps1 @@ -18,11 +18,11 @@ param( #Requires -Modules @{ModuleName="InvokeBuild";ModuleVersion="3.2.1"} -$script:IsCIBuild = $env:TF_BUILD -ne $null $script:IsUnix = $PSVersionTable.PSEdition -and $PSVersionTable.PSEdition -eq "Core" -and !$IsWindows $script:TargetPlatform = "netstandard2.0" $script:TargetFrameworksParam = "/p:TargetFrameworks=`"$script:TargetPlatform`"" $script:RequiredSdkVersion = (Get-Content (Join-Path $PSScriptRoot 'global.json') | ConvertFrom-Json).sdk.version +$script:MinimumPesterVersion = '4.7' $script:NugetApiUriBase = 'https://www.nuget.org/api/v2/package' $script:ModuleBinPath = "$PSScriptRoot/module/PowerShellEditorServices/bin/" $script:VSCodeModuleBinPath = "$PSScriptRoot/module/PowerShellEditorServices.VSCode/bin/" @@ -329,12 +329,17 @@ task Build { exec { & $script:dotnetExe build -c $Configuration .\src\PowerShellEditorServices.VSCode\PowerShellEditorServices.VSCode.csproj $script:TargetFrameworksParam } } +task BuildPsesClientModule SetupDotNet,{ + Write-Verbose 'Building PsesPsClient testing module' + & $PSScriptRoot/tools/PsesPsClient/build.ps1 -DotnetExe $script:dotnetExe +} + function DotNetTestFilter { # Reference https://docs.microsoft.com/en-us/dotnet/core/testing/selective-unit-tests if ($TestFilter) { @("--filter",$TestFilter) } else { "" } } -task Test TestServer,TestProtocol +task Test TestServer,TestProtocol,TestPester task TestServer { Set-Location .\test\PowerShellEditorServices.Test\ @@ -372,6 +377,28 @@ task TestHost { exec { & $script:dotnetExe test -f $script:TestRuntime.Core (DotNetTestFilter) } } +task TestPester Build,BuildPsesClientModule,EnsurePesterInstalled,{ + $testParams = @{} + if ($env:TF_BUILD) + { + $testParams += @{ + OutputFormat = 'NUnitXml' + OutputFile = 'TestResults.xml' + } + } + $result = Invoke-Pester "$PSScriptRoot/test/Pester/" @testParams -PassThru + + if ($result.FailedCount -gt 0) + { + throw "$($result.FailedCount) tests failed." + } +} + +task EnsurePesterInstalled -If (-not (Get-Module Pester -ListAvailable | Where-Object Version -ge $script:MinimumPesterVersion)) { + Write-Warning "Required Pester version not found, installing Pester to current user scope" + Install-Module -Scope CurrentUser Pester -Force -SkipPublisherCheck +} + task LayoutModule -After Build { # Copy Third Party Notices.txt to module folder Copy-Item -Force -Path "$PSScriptRoot\Third Party Notices.txt" -Destination $PSScriptRoot\module\PowerShellEditorServices diff --git a/test/Pester/EditorServices.Integration.Tests.ps1 b/test/Pester/EditorServices.Integration.Tests.ps1 new file mode 100644 index 000000000..d5ea605c6 --- /dev/null +++ b/test/Pester/EditorServices.Integration.Tests.ps1 @@ -0,0 +1,86 @@ + +$script:ExceptionRegex = [regex]::new('\s*Exception: (.*)$', 'Compiled,Multiline,IgnoreCase') +function ReportLogErrors +{ + param( + [Parameter()][string]$LogPath, + + [Parameter()][ref]<#[int]#>$FromIndex = 0, + + [Parameter()][string[]]$IgnoreException = @() + ) + + $logEntries = Parse-PsesLog $LogPath | + Where-Object Index -ge $FromIndex.Value + + # Update the index to the latest in the log + $FromIndex.Value = ($FromIndex.Value,$errorLogs.Index | Measure-Object -Maximum).Maximum + + $errorLogs = $logEntries | + Where-Object LogLevel -eq Error | + Where-Object { + $match = $script:ExceptionRegex.Match($_.Message.Data) + + (-not $match) -or ($match.Groups[1].Value.Trim() -notin $IgnoreException) + } + + if ($errorLogs) + { + $errorLogs | ForEach-Object { Write-Error "ERROR from PSES log: $($_.Message.Data)" } + } +} + +Describe "Loading and running PowerShellEditorServices" { + BeforeAll { + Import-Module -Force "$PSScriptRoot/../../module/PowerShellEditorServices" + 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 + } + + # 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 + $response.Id | Should -BeExactly $request.Id + + ReportLogErrors -LogPath $psesServer.LogPath -FromIndex ([ref]$logIdx) + } + + # 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.Id | Should -BeExactly $request.Id + $response.Result | Should -BeNull + # TODO: The server seems to stay up waiting for the debug connection + # $psesServer.PsesProcess.HasExited | Should -BeTrue + + # We close the process here rather than in an AfterAll + # since errors can occur and we want to test for them. + # Naturally this depends on Pester executing tests in order. + + # We also have to dispose of everything properly, + # which means we have to use these cascading try/finally statements + try + { + $psesServer.PsesProcess.Kill() + } + finally + { + try + { + $psesServer.PsesProcess.Dispose() + } + finally + { + $client.Dispose() + } + } + + ReportLogErrors -LogPath $psesServer.LogPath -FromIndex ([ref]$logIdx) + } +} diff --git a/tools/PsesPsClient/Client.cs b/tools/PsesPsClient/Client.cs new file mode 100644 index 000000000..bae8c7247 --- /dev/null +++ b/tools/PsesPsClient/Client.cs @@ -0,0 +1,581 @@ +// +// 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.IO.Pipes; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using Microsoft.PowerShell.EditorServices.Protocol.MessageProtocol.Serializers; +using System.Text; +using System.IO; +using Newtonsoft.Json.Linq; +using Microsoft.PowerShell.EditorServices.Protocol.MessageProtocol; +using System.Collections.Generic; +using Microsoft.PowerShell.EditorServices.Protocol.LanguageServer; +using System.Collections.Concurrent; +using System.Threading.Tasks; +using System.Threading; +using System.Linq; + +namespace PsesPsClient +{ + /// + /// A Language Server Protocol named pipe connection. + /// + public class PsesLspClient : IDisposable + { + /// + /// Create a new LSP pipe around a given named pipe. + /// + /// The name of the named pipe to use. + /// A new LspPipe instance around the given named pipe. + public static PsesLspClient Create(string pipeName) + { + var pipeClient = new NamedPipeClientStream( + pipeName: pipeName, + serverName: ".", + direction: PipeDirection.InOut, + options: PipeOptions.Asynchronous); + + return new PsesLspClient(pipeClient); + } + + private readonly NamedPipeClientStream _namedPipeClient; + + private readonly JsonSerializerSettings _jsonSettings; + + private readonly JsonSerializer _jsonSerializer; + + private readonly JsonRpcMessageSerializer _jsonRpcSerializer; + + private readonly Encoding _pipeEncoding; + + private int _msgId; + + private StreamWriter _writer; + + private MessageStreamListener _listener; + + /// + /// 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) + { + _namedPipeClient = namedPipeClient; + + _jsonSettings = new JsonSerializerSettings() + { + ContractResolver = new CamelCasePropertyNamesContractResolver() + }; + + _jsonSerializer = JsonSerializer.Create(_jsonSettings); + + // Reuse the PSES JSON RPC serializer + _jsonRpcSerializer = new JsonRpcMessageSerializer(); + + _pipeEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + } + + /// + /// Connect to the named pipe server. + /// + public void Connect() + { + _namedPipeClient.Connect(timeout: 1000); + _listener = new MessageStreamListener(new StreamReader(_namedPipeClient, _pipeEncoding)); + _writer = new StreamWriter(_namedPipeClient, _pipeEncoding) + { + AutoFlush = true + }; + + _listener.Start(); + } + + /// + /// Write a request to the LSP pipe. + /// + /// The method of the request. + /// The parameters of the request. May be null. + /// A representation of the request sent. + public LspRequest WriteRequest( + string method, + object parameters) + { + _msgId++; + + Message msg = Message.Request( + _msgId.ToString(), + method, + parameters != null ? JToken.FromObject(parameters, _jsonSerializer) : JValue.CreateNull()); + + JObject msgJson = _jsonRpcSerializer.SerializeMessage(msg); + string msgString = JsonConvert.SerializeObject(msgJson, _jsonSettings); + byte[] msgBytes = _pipeEncoding.GetBytes(msgString); + + string header = "Content-Length: " + msgBytes.Length + "\r\n\r\n"; + + _writer.Write(header + msgString); + _writer.Flush(); + + return new LspRequest(msg.Id, method, msgJson["params"]); + } + + /// + /// Get all the pending notifications from the server. + /// + /// Any pending notifications from the server. + public IEnumerable GetNotifications() + { + return _listener.DrainNotifications(); + } + + /// + /// Get all the pending requests from the server. + /// + /// Any pending requests from the server. + public IEnumerable GetRequests() + { + return _listener.DrainRequests(); + } + + /// + /// Get the next response from the server, if one is available within the given time. + /// + /// The next response from the server. + /// How long to wait for a response. + /// True if there is a next response, false if it timed out. + public bool TryGetResponse(string id, out LspResponse response, int millisTimeout) + { + return _listener.TryGetResponse(id, out response, millisTimeout); + } + + /// + /// Dispose of the pipe. This will also close the pipe. + /// + public void Dispose() + { + _writer.Dispose(); + _listener.Dispose(); + _namedPipeClient.Close(); + _namedPipeClient.Dispose(); + } + } + + /// + /// A dedicated listener to run a thread for receiving pipe messages, + /// so the the pipe is not blocked. + /// + public class MessageStreamListener : IDisposable + { + private readonly StreamReader _stream; + + private readonly StringBuilder _headerBuffer; + + private readonly ConcurrentQueue _requestQueue; + + private readonly ConcurrentQueue _notificationQueue; + + private readonly ConcurrentDictionary _responses; + + private readonly CancellationTokenSource _cancellationSource; + + private readonly BlockingCollection _responseReceivedChannel; + + private char[] _readerBuffer; + + /// + /// Create a listener around a stream. + /// + /// The stream to listen for messages on. + public MessageStreamListener(StreamReader stream) + { + _stream = stream; + _readerBuffer = new char[1024]; + _headerBuffer = new StringBuilder(128); + _notificationQueue = new ConcurrentQueue(); + _requestQueue = new ConcurrentQueue(); + _responses = new ConcurrentDictionary(); + _cancellationSource = new CancellationTokenSource(); + _responseReceivedChannel = new BlockingCollection(); + } + + /// + /// Get all pending notifications. + /// + public IEnumerable DrainNotifications() + { + return DrainQueue(_notificationQueue); + } + + /// + /// Get all pending requests. + /// + public IEnumerable DrainRequests() + { + return DrainQueue(_requestQueue); + } + + /// + /// Get the next response if there is one, otherwise instantly return false. + /// + /// The first response in the response queue if any, otherwise null. + /// True if there was a response to get, false otherwise. + public bool TryGetResponse(string id, out LspResponse response) + { + _responseReceivedChannel.TryTake(out bool _, millisecondsTimeout: 0); + return _responses.TryRemove(id, out response); + } + + /// + /// Get the next response within the given timeout. + /// + /// The first response in the queue, if any. + /// The maximum number of milliseconds to wait for a response. + /// True if there was a response to get, false otherwise. + public bool TryGetResponse(string id, out LspResponse response, int millisTimeout) + { + if (_responses.TryRemove(id, out response)) + { + return true; + } + + if (_responseReceivedChannel.TryTake(out bool _, millisTimeout)) + { + return _responses.TryRemove(id, out response); + } + + response = null; + return false; + } + + /// + /// Start the pipe listener on its own thread. + /// + public void Start() + { + Task.Run(() => RunListenLoop()); + } + + /// + /// End the pipe listener loop. + /// + public void Stop() + { + _cancellationSource.Cancel(); + } + + /// + /// Stops and disposes the pipe listener. + /// + public void Dispose() + { + Stop(); + _stream.Dispose(); + } + + private async Task RunListenLoop() + { + CancellationToken cancellationToken = _cancellationSource.Token; + while (!cancellationToken.IsCancellationRequested) + { + LspMessage msg; + msg = await ReadMessage().ConfigureAwait(false); + switch (msg) + { + case LspNotification notification: + _notificationQueue.Enqueue(notification); + continue; + + case LspResponse response: + _responses[response.Id] = response; + _responseReceivedChannel.Add(true); + continue; + + case LspRequest request: + _requestQueue.Enqueue(request); + continue; + } + } + } + + private async Task ReadMessage() + { + int contentLength = GetContentLength(); + string msgString = await ReadString(contentLength).ConfigureAwait(false); + JObject msgJson = JObject.Parse(msgString); + + if (msgJson.TryGetValue("method", out JToken methodToken)) + { + string method = ((JValue)methodToken).Value.ToString(); + if (msgJson.TryGetValue("id", out JToken idToken)) + { + string requestId = ((JValue)idToken).Value.ToString(); + return new LspRequest(requestId, method, msgJson["params"]); + } + + return new LspNotification(method, msgJson["params"]); + } + + string id = ((JValue)msgJson["id"]).Value.ToString(); + + if (msgJson.TryGetValue("result", out JToken resultToken)) + { + return new LspSuccessfulResponse(id, resultToken); + } + + JObject errorBody = (JObject)msgJson["error"]; + JsonRpcErrorCode errorCode = (JsonRpcErrorCode)(int)((JValue)errorBody["code"]).Value; + string message = (string)((JValue)errorBody["message"]).Value; + return new LspErrorResponse(id, errorCode, message, errorBody["data"]); + } + + private async Task ReadString(int bytesToRead) + { + if (bytesToRead > _readerBuffer.Length) + { + Array.Resize(ref _readerBuffer, _readerBuffer.Length * 2); + } + + int readLen = await _stream.ReadAsync(_readerBuffer, 0, bytesToRead).ConfigureAwait(false); + + return new string(_readerBuffer, 0, readLen); + } + + private int GetContentLength() + { + _headerBuffer.Clear(); + int endHeaderState = 0; + int currChar; + while ((currChar = _stream.Read()) >= 0) + { + char c = (char)currChar; + _headerBuffer.Append(c); + switch (c) + { + case '\r': + if (endHeaderState == 2) + { + endHeaderState = 3; + continue; + } + + if (endHeaderState == 0) + { + endHeaderState = 1; + continue; + } + + endHeaderState = 0; + continue; + + case '\n': + if (endHeaderState == 1) + { + endHeaderState = 2; + continue; + } + + if (endHeaderState == 3) + { + return ParseContentLength(_headerBuffer.ToString()); + } + + endHeaderState = 0; + continue; + + default: + endHeaderState = 0; + continue; + } + } + + throw new InvalidDataException("Buffer emptied before end of headers"); + } + + private static int ParseContentLength(string headers) + { + const string clHeaderPrefix = "Content-Length: "; + + int clIdx = headers.IndexOf(clHeaderPrefix, StringComparison.Ordinal); + if (clIdx < 0) + { + throw new InvalidDataException("No Content-Length header found"); + } + + int endIdx = headers.IndexOf("\r\n", clIdx, StringComparison.Ordinal); + if (endIdx < 0) + { + throw new InvalidDataException("Header CRLF terminator not found"); + } + + int numStartIdx = clIdx + clHeaderPrefix.Length; + int numLength = endIdx - numStartIdx; + + return int.Parse(headers.Substring(numStartIdx, numLength)); + } + + private static IEnumerable DrainQueue(ConcurrentQueue queue) + { + if (queue.IsEmpty) + { + return Enumerable.Empty(); + } + + var list = new List(); + while (queue.TryDequeue(out TElement element)) + { + list.Add(element); + } + return list; + } + + } + + /// + /// Represents a Language Server Protocol message. + /// + public abstract class LspMessage + { + protected LspMessage() + { + } + } + + /// + /// A Language Server Protocol notifcation or event. + /// + public class LspNotification : LspMessage + { + public LspNotification(string method, JToken parameters) + { + Method = method; + Params = parameters; + } + + /// + /// The notification method. + /// + public string Method { get; } + + /// + /// Any parameters for the notification. + /// + public JToken Params { get; } + } + + /// + /// A Language Server Protocol request. + /// May be a client -> server or a server -> client request. + /// + public class LspRequest : LspMessage + { + public LspRequest(string id, string method, JToken parameters) + { + Id = id; + Method = method; + Params = parameters; + } + + /// + /// The ID of the request. Usually an integer. + /// + public string Id { get; } + + /// + /// The method of the request. + /// + public string Method { get; } + + /// + /// Any parameters of the request. + /// + public JToken Params { get; } + } + + /// + /// A Language Server Protocol response message. + /// + public abstract class LspResponse : LspMessage + { + protected LspResponse(string id) + { + Id = id; + } + + /// + /// The ID of the response. Will match the ID of the request triggering it. + /// + public string Id { get; } + } + + /// + /// A successful Language Server Protocol response message. + /// + public class LspSuccessfulResponse : LspResponse + { + public LspSuccessfulResponse(string id, JToken result) + : base(id) + { + Result = result; + } + + /// + /// The result field of the response. + /// + public JToken Result { get; } + } + + /// + /// A Language Server Protocol error response message. + /// + public class LspErrorResponse : LspResponse + { + public LspErrorResponse( + string id, + JsonRpcErrorCode code, + string message, + JToken data) + : base(id) + { + Code = code; + Message = message; + Data = data; + } + + /// + /// The error code sent by the server, may not correspond to a known enum type. + /// + public JsonRpcErrorCode Code { get; } + + /// + /// The error message. + /// + public string Message { get; } + + /// + /// Extra error data. + /// + public JToken Data { get; } + } + + /// + /// Error codes used by the Language Server Protocol. + /// + public enum JsonRpcErrorCode : int + { + ParseError = -32700, + InvalidRequest = -32600, + MethodNotFound = -32601, + InvalidParams = -32602, + InternalError = -32603, + ServerErrorStart = -32099, + ServerErrorEnd = -32000, + ServerNotInitialized = -32002, + UnknownErrorCode = -32001, + RequestCancelled = -32800, + ContentModified = -32801, + } +} diff --git a/tools/PsesPsClient/PsesPsClient.csproj b/tools/PsesPsClient/PsesPsClient.csproj new file mode 100644 index 000000000..6b30f989b --- /dev/null +++ b/tools/PsesPsClient/PsesPsClient.csproj @@ -0,0 +1,16 @@ + + + + netstandard2.0 + + + + + + + + + + + + diff --git a/tools/PsesPsClient/PsesPsClient.psd1 b/tools/PsesPsClient/PsesPsClient.psd1 new file mode 100644 index 000000000..c102d30c8 --- /dev/null +++ b/tools/PsesPsClient/PsesPsClient.psd1 @@ -0,0 +1,130 @@ +# +# Module manifest for module 'PsesPsClient' +# +# Generated by: Microsoft Corporation +# +# Generated on: 26/4/19 +# + +@{ + +# Script module or binary module file associated with this manifest. +RootModule = 'PsesPsClient.psm1' + +# Version number of this module. +ModuleVersion = '0.0.1' + +# Supported PSEditions +CompatiblePSEditions = 'Core', 'Desktop' + +# ID used to uniquely identify this module +GUID = 'ce491ff9-3eab-443c-b3a2-cc412ddeef65' + +# Author of this module +Author = 'Microsoft Corporation' + +# Company or vendor of this module +CompanyName = 'Microsoft Corporation' + +# Copyright statement for this module +Copyright = '(c) Microsoft Corporation' + +# Description of the functionality provided by this module +# Description = '' + +# Minimum version of the PowerShell engine required by this module +PowerShellVersion = '5.1' + +# Name of the PowerShell host required by this module +# PowerShellHostName = '' + +# Minimum version of the PowerShell host required by this module +# PowerShellHostVersion = '' + +# Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# DotNetFrameworkVersion = '' + +# Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# CLRVersion = '' + +# Processor architecture (None, X86, Amd64) required by this module +# ProcessorArchitecture = '' + +# Modules that must be imported into the global environment prior to importing this module +# RequiredModules = @() + +# Assemblies that must be loaded prior to importing this module +# RequiredAssemblies = @() + +# Script files (.ps1) that are run in the caller's environment prior to importing this module. +# ScriptsToProcess = @() + +# Type files (.ps1xml) to be loaded when importing this module +# TypesToProcess = @() + +# Format files (.ps1xml) to be loaded when importing this module +# FormatsToProcess = @() + +# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess +NestedModules = @('PsesPsClient.dll') + +# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. +FunctionsToExport = @( + 'Start-PsesServer', + 'Connect-PsesServer', + 'Send-LspRequest', + 'Send-LspInitializeRequest', + 'Send-LspShutdownRequest', + 'Get-LspResponse' +) + +# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. +CmdletsToExport = '*' + +# Variables to export from this module +VariablesToExport = '*' + +# Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. +AliasesToExport = '*' + +# DSC resources to export from this module +# DscResourcesToExport = @() + +# List of all modules packaged with this module +# ModuleList = @() + +# List of all files packaged with this module +# FileList = @() + +# Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. +PrivateData = @{ + + PSData = @{ + + # Tags applied to this module. These help with module discovery in online galleries. + # Tags = @() + + # A URL to the license for this module. + # LicenseUri = '' + + # A URL to the main website for this project. + # ProjectUri = '' + + # A URL to an icon representing this module. + # IconUri = '' + + # ReleaseNotes of this module + # ReleaseNotes = '' + + } # End of PSData hashtable + +} # End of PrivateData hashtable + +# HelpInfo URI of this module +# HelpInfoURI = '' + +# Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. +# DefaultCommandPrefix = '' + +} + diff --git a/tools/PsesPsClient/PsesPsClient.psm1 b/tools/PsesPsClient/PsesPsClient.psm1 new file mode 100644 index 000000000..a66e5c7d1 --- /dev/null +++ b/tools/PsesPsClient/PsesPsClient.psm1 @@ -0,0 +1,375 @@ +# +# Copyright (c) Microsoft. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# + +$script:PsesBundledModulesDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath( + "$PSScriptRoot/../../../../module") + +class PsesStartupOptions +{ + [string] $LogPath + [string] $LogLevel + [string] $SessionDetailsPath + [string[]] $FeatureFlags + [string] $HostName + [string] $HostProfileId + [version] $HostVersion + [string[]] $AdditionalModules + [string] $BundledModulesPath + [bool] $EnableConsoleRepl +} + +class PsesServerInfo +{ + [pscustomobject]$SessionDetails + [System.Diagnostics.Process]$PsesProcess + [PsesStartupOptions]$StartupOptions + [string]$LogPath +} + +function Start-PsesServer +{ + [CmdletBinding(SupportsShouldProcess)] + [OutputType([PsesServerInfo])] + param( + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] + $EditorServicesPath = "$script:PsesBundledModulesDir/PowerShellEditorServices/Start-EditorServices.ps1", + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] + $LogPath, + + [Parameter()] + [ValidateSet("Diagnostic", "Normal", "Verbose", "Error")] + [string] + $LogLevel = 'Diagnostic', + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] + $SessionDetailsPath, + + [Parameter()] + [ValidateNotNull()] + [string[]] + $FeatureFlags = @('PSReadLine'), + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] + $HostName = 'PSES Test Host', + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] + $HostProfileId = 'TestHost', + + [Parameter()] + [ValidateNotNull()] + [version] + $HostVersion = '1.99', + + [Parameter()] + [ValidateNotNull()] + [string[]] + $AdditionalModules = @('PowerShellEditorServices.VSCode'), + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] + $BundledModulesPath, + + [Parameter()] + [switch] + $EnableConsoleRepl, + + [Parameter()] + [string] + $ErrorFile + ) + + $EditorServicesPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($EditorServicesPath) + + $instanceId = Get-RandomHexString + + $tempDir = [System.IO.Path]::GetTempPath() + + if (-not $LogPath) + { + $LogPath = Join-Path $tempDir "pseslogs_$instanceId.log" + } + + if (-not $SessionDetailsPath) + { + $SessionDetailsPath = Join-Path $tempDir "psessession_$instanceId.log" + } + + if (-not $BundledModulesPath) + { + $BundledModulesPath = $script:PsesBundledModulesDir + } + + $editorServicesOptions = @{ + LogPath = $LogPath + LogLevel = $LogLevel + SessionDetailsPath = $SessionDetailsPath + FeatureFlags = $FeatureFlags + HostName = $HostName + HostProfileId = $HostProfileId + HostVersion = $HostVersion + AdditionalModules = $AdditionalModules + BundledModulesPath = $BundledModulesPath + EnableConsoleRepl = $EnableConsoleRepl + } + + $startPsesCommand = Unsplat -Prefix "& '$EditorServicesPath'" -SplatParams $editorServicesOptions + + $pwshPath = (Get-Process -Id $PID).Path + + if (-not $PSCmdlet.ShouldProcess("& '$pwshPath' -Command '$startPsesCommand'")) + { + return + } + + $startArgs = @( + '-NoLogo', + '-NoProfile', + '-NoExit', + '-Command', + $startPsesCommand + ) + + $startProcParams = @{ + PassThru = $true + FilePath = $pwshPath + ArgumentList = $startArgs + } + + if ($ErrorFile) + { + $startProcParams.RedirectStandardError = $ErrorFile + } + + $serverProcess = Start-Process @startProcParams + + $sessionPath = $editorServicesOptions.SessionDetailsPath + + $i = 0 + while (-not (Test-Path $sessionPath)) + { + if ($i -ge 10) + { + throw "No session file found - server failed to start" + } + + Start-Sleep 1 + $null = $i++ + } + + return [PsesServerInfo]@{ + PsesProcess = $serverProcess + SessionDetails = Get-Content -Raw $editorServicesOptions.SessionDetailsPath | ConvertFrom-Json + StartupOptions = $editorServicesOptions + LogPath = $LogPath + } +} + +function Connect-PsesServer +{ + [OutputType([PsesPsClient.PsesLspClient])] + param( + [Parameter(Mandatory)] + [string] + $PipeName + ) + + $psesIdx = $PipeName.IndexOf('PSES') + if ($psesIdx -gt 0) + { + $PipeName = $PipeName.Substring($psesIdx) + } + + $client = [PsesPsClient.PsesLspClient]::Create($PipeName) + $client.Connect() + return $client +} + +function Send-LspInitializeRequest +{ + [OutputType([PsesPsClient.LspRequest])] + param( + [Parameter(Position = 0, Mandatory)] + [PsesPsClient.PsesLspClient] + $Client, + + [Parameter()] + [int] + $ProcessId = $PID, + + [Parameter()] + [string] + $RootPath = (Get-Location), + + [Parameter()] + [string] + $RootUri, + + [Parameter()] + [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.ClientCapabilities] + $ClientCapabilities = ([Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.ClientCapabilities]::new()), + + [Parameter()] + [hashtable] + $InitializeOptions = $null + ) + + $parameters = [Microsoft.PowerShell.EditorServices.Protocol.LanguageServer.InitializeParams]@{ + ProcessId = $ProcessId + Capabilities = $ClientCapabilities + InitializeOptions = $InitializeOptions + } + + if ($RootUri) + { + $parameters.RootUri = $RootUri + } + else + { + $parameters.RootPath = $RootPath + } + + return Send-LspRequest -Client $Client -Method 'initialize' -Parameters $parameters +} + +function Send-LspShutdownRequest +{ + [OutputType([PsesPsClient.LspRequest])] + param( + [Parameter(Position = 0, Mandatory)] + [PsesPsClient.PsesLspClient] + $Client + ) + + return Send-LspRequest -Client $Client -Method 'shutdown' +} + +function Send-LspRequest +{ + [OutputType([PsesPsClient.LspRequest])] + param( + [Parameter(Position = 0, Mandatory)] + [PsesPsClient.PsesLspClient] + $Client, + + [Parameter(Position = 1, Mandatory)] + [string] + $Method, + + [Parameter(Position = 2)] + $Parameters = $null + ) + + return $Client.WriteRequest($Method, $Parameters) +} + +function Get-LspResponse +{ + [OutputType([PsesPsClient.LspResponse])] + param( + [Parameter(Position = 0, Mandatory)] + [PsesPsClient.PsesLspClient] + $Client, + + [Parameter(Position = 1, Mandatory)] + [string] + $Id, + + [Parameter()] + [int] + $WaitMillis = 5000 + ) + + $lspResponse = $null + + if ($Client.TryGetResponse($Id, [ref]$lspResponse, $WaitMillis)) + { + return $lspResponse + } +} + +function Unsplat +{ + param( + [string]$Prefix, + [hashtable]$SplatParams) + + $sb = New-Object 'System.Text.StringBuilder' ($Prefix) + + foreach ($key in $SplatParams.get_Keys()) + { + $val = $SplatParams[$key] + + if (-not $val) + { + continue + } + + $null = $sb.Append(" -$key") + + if ($val -is [switch]) + { + continue + } + + if ($val -is [array]) + { + $null = $sb.Append(' @(') + for ($i = 0; $i -lt $val.Count; $i++) + { + $null = $sb.Append("'").Append($val[$i]).Append("'") + if ($i -lt $val.Count - 1) + { + $null = $sb.Append(',') + } + } + $null = $sb.Append(')') + continue + } + + if ($val -is [version]) + { + $val = [string]$val + } + + if ($val -is [string]) + { + $null = $sb.Append(" '$val'") + continue + } + + throw "Bad value '$val' of type $($val.GetType())" + } + + return $sb.ToString() +} + +$script:Random = [System.Random]::new() +function Get-RandomHexString +{ + param([int]$Length = 10) + + $buffer = [byte[]]::new($Length / 2) + $script:Random.NextBytes($buffer) + $str = ($buffer | ForEach-Object { "{0:x02}" -f $_ }) -join '' + + if ($Length % 2 -ne 0) + { + $str += ($script:Random.Next() | ForEach-Object { "{0:02}" -f $_ }) + } + + return $str +} diff --git a/tools/PsesPsClient/build.ps1 b/tools/PsesPsClient/build.ps1 new file mode 100644 index 000000000..a9da9a778 --- /dev/null +++ b/tools/PsesPsClient/build.ps1 @@ -0,0 +1,49 @@ +# +# Copyright (c) Microsoft. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# + +param( + [Parameter()] + [string] + $DotnetExe = 'dotnet' +) + +$ErrorActionPreference = 'Stop' + +$script:OutDir = "$PSScriptRoot/out" +$script:OutModDir = "$script:OutDir/PsesPsClient" + +$script:ModuleComponents = @{ + "bin/Debug/netstandard2.0/publish/PsesPsClient.dll" = "PsesPsClient.dll" + "bin/Debug/netstandard2.0/publish/Newtonsoft.Json.dll" = "Newtonsoft.Json.dll" + "PsesPsClient.psm1" = "PsesPsClient.psm1" + "PsesPsClient.psd1" = "PsesPsClient.psd1" +} + +$binDir = "$PSScriptRoot/bin" +$objDir = "$PSScriptRoot/obj" +foreach ($dir in $binDir,$objDir,$script:OutDir) +{ + if (Test-Path $dir) + { + Remove-Item -Force -Recurse $dir + } +} + +Push-Location $PSScriptRoot +try +{ + & $DotnetExe publish --framework 'netstandard2.0' + + New-Item -Path $script:OutModDir -ItemType Directory + foreach ($key in $script:ModuleComponents.get_Keys()) + { + $val = $script:ModuleComponents[$key] + Copy-Item -Path "$PSScriptRoot/$key" -Destination "$script:OutModDir/$val" + } +} +finally +{ + Pop-Location +}