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
+}