Skip to content

Commit

Permalink
Initial language server implementation (#2711)
Browse files Browse the repository at this point in the history
  • Loading branch information
BernieWhite authored Jan 10, 2025
1 parent f33846b commit 87f2dc8
Show file tree
Hide file tree
Showing 22 changed files with 737 additions and 20 deletions.
7 changes: 7 additions & 0 deletions PSRule.sln
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PSRule.Types.Tests", "tests
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PSRule.EditorServices", "src\PSRule.EditorServices\PSRule.EditorServices.csproj", "{061DD38A-B9E9-4EF1-B5B7-D0A484DB74D1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PSRule.EditorServices.Tests", "tests\PSRule.EditorServices.Tests\PSRule.EditorServices.Tests.csproj", "{89D6AAAA-2AD9-4899-935E-5330EFB3383E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -100,6 +102,10 @@ Global
{061DD38A-B9E9-4EF1-B5B7-D0A484DB74D1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{061DD38A-B9E9-4EF1-B5B7-D0A484DB74D1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{061DD38A-B9E9-4EF1-B5B7-D0A484DB74D1}.Release|Any CPU.Build.0 = Release|Any CPU
{89D6AAAA-2AD9-4899-935E-5330EFB3383E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{89D6AAAA-2AD9-4899-935E-5330EFB3383E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{89D6AAAA-2AD9-4899-935E-5330EFB3383E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{89D6AAAA-2AD9-4899-935E-5330EFB3383E}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -109,6 +115,7 @@ Global
{DA46C891-08F1-4D01-9F98-1F8BB10CAFEC} = {E0EA0CBA-96C5-4447-8B69-BC13EF0D7A4A}
{C25E2FC1-E306-4D99-925C-15E5DD51F6A2} = {E0EA0CBA-96C5-4447-8B69-BC13EF0D7A4A}
{34095F78-CDA3-4E72-B64C-6366EA4B3EAF} = {E0EA0CBA-96C5-4447-8B69-BC13EF0D7A4A}
{89D6AAAA-2AD9-4899-935E-5330EFB3383E} = {E0EA0CBA-96C5-4447-8B69-BC13EF0D7A4A}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {533491EB-BAE9-472E-B57F-A675ECD335B5}
Expand Down
3 changes: 3 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,6 @@ The following table lists exit codes that may be returned by the PSRule language
Exit code | Description | Notes
--------- | ----------- | -----
0 | Success | The language server exited during normal operation.
901 | The language server was unable to start due to missing or invalid configuration. | Unexpected. Please report this issue.
902 | The language server encountered an unexpected exception and stopped. | Unexpected. Please report this issue.
903 | A debugger failed to attach to the language server. | When debugging the language server, ensure the debugger is attached within 5 minutes.
24 changes: 13 additions & 11 deletions src/PSRule.CommandLine/Commands/ModuleCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ namespace PSRule.CommandLine.Commands;
/// </summary>
public sealed class ModuleCommand
{
private const int ERROR_SUCCESS = 0;

/// <summary>
/// Failed to install a module.
/// </summary>
Expand All @@ -46,7 +48,7 @@ public sealed class ModuleCommand
/// </summary>
public static async Task<int> ModuleRestoreAsync(RestoreOptions operationOptions, ClientContext clientContext, CancellationToken cancellationToken = default)
{
var exitCode = 0;
var exitCode = ERROR_SUCCESS;
var requires = clientContext.Option.Requires.ToDictionary();
var file = LockFile.Read(null);

Expand Down Expand Up @@ -135,7 +137,7 @@ public static async Task<int> ModuleRestoreAsync(RestoreOptions operationOptions
}
}

if (exitCode == 0)
if (exitCode == ERROR_SUCCESS)
{
clientContext.LogVerbose("[PSRule][M] -- All modules are restored and up-to-date.");
}
Expand All @@ -152,7 +154,7 @@ public static async Task<int> ModuleRestoreAsync(RestoreOptions operationOptions
/// </summary>
public static async Task<int> ModuleInitAsync(ModuleOptions operationOptions, ClientContext clientContext, CancellationToken cancellationToken = default)
{
var exitCode = 0;
var exitCode = ERROR_SUCCESS;
var requires = clientContext.Option.Requires.ToDictionary();
var existingFile = LockFile.Read(null);
var file = !operationOptions.Force ? existingFile : new LockFile();
Expand Down Expand Up @@ -218,12 +220,12 @@ public static async Task<int> ModuleInitAsync(ModuleOptions operationOptions, Cl
public static async Task<int> ModuleListAsync(ModuleOptions operationOptions, ClientContext clientContext, CancellationToken cancellationToken = default)
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
{
var exitCode = 0;
var exitCode = ERROR_SUCCESS;
var requires = clientContext.Option.Requires.ToDictionary();
var file = LockFile.Read(null);
var pwsh = CreatePowerShell();

if (exitCode == 0)
if (exitCode == ERROR_SUCCESS)
{
ListModules(clientContext, GetModules(pwsh, file, clientContext.Option));
}
Expand All @@ -235,7 +237,7 @@ public static async Task<int> ModuleListAsync(ModuleOptions operationOptions, Cl
/// </summary>
public static async Task<int> ModuleAddAsync(ModuleOptions operationOptions, ClientContext clientContext, CancellationToken cancellationToken = default)
{
var exitCode = 0;
var exitCode = ERROR_SUCCESS;
if (operationOptions.Module == null || operationOptions.Module.Length == 0) return exitCode;

var requires = clientContext.Option.Requires.ToDictionary();
Expand Down Expand Up @@ -296,7 +298,7 @@ public static async Task<int> ModuleAddAsync(ModuleOptions operationOptions, Cli

file.Write(null);

if (exitCode == 0)
if (exitCode == ERROR_SUCCESS)
{
ListModules(clientContext, GetModules(pwsh, file, clientContext.Option));
}
Expand All @@ -310,7 +312,7 @@ public static async Task<int> ModuleAddAsync(ModuleOptions operationOptions, Cli
public static async Task<int> ModuleRemoveAsync(ModuleOptions operationOptions, ClientContext clientContext, CancellationToken cancellationToken = default)
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
{
var exitCode = 0;
var exitCode = ERROR_SUCCESS;
if (operationOptions.Module == null || operationOptions.Module.Length == 0) return exitCode;

var file = LockFile.Read(null);
Expand All @@ -330,7 +332,7 @@ public static async Task<int> ModuleRemoveAsync(ModuleOptions operationOptions,

file.Write(null);

if (exitCode == 0)
if (exitCode == ERROR_SUCCESS)
{
ListModules(clientContext, GetModules(pwsh, file, clientContext.Option));
}
Expand All @@ -342,7 +344,7 @@ public static async Task<int> ModuleRemoveAsync(ModuleOptions operationOptions,
/// </summary>
public static async Task<int> ModuleUpgradeAsync(ModuleOptions operationOptions, ClientContext clientContext, CancellationToken cancellationToken = default)
{
var exitCode = 0;
var exitCode = ERROR_SUCCESS;
var requires = clientContext.Option.Requires.ToDictionary();
var file = LockFile.Read(null);
var filteredModules = operationOptions.Module != null && operationOptions.Module.Length > 0 ? new HashSet<string>(operationOptions.Module, StringComparer.OrdinalIgnoreCase) : null;
Expand Down Expand Up @@ -379,7 +381,7 @@ public static async Task<int> ModuleUpgradeAsync(ModuleOptions operationOptions,

file.Write(null);

if (exitCode == 0)
if (exitCode == ERROR_SUCCESS)
{
ListModules(clientContext, GetModules(pwsh, file, clientContext.Option));
}
Expand Down
2 changes: 1 addition & 1 deletion src/PSRule.CommandLine/Models/ModuleOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
namespace PSRule.CommandLine.Models;

/// <summary>
/// Options for the module command.
/// Options for the <c>module</c> command.
/// </summary>
public sealed class ModuleOptions
{
Expand Down
36 changes: 36 additions & 0 deletions src/PSRule.EditorServices/ClientBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
using PSRule.CommandLine;
using PSRule.CommandLine.Commands;
using PSRule.CommandLine.Models;
using PSRule.EditorServices.Commands;
using PSRule.EditorServices.Models;
using PSRule.EditorServices.Resources;
using PSRule.Pipeline;

Expand Down Expand Up @@ -37,6 +39,8 @@ internal sealed class ClientBuilder
private readonly Option<string> _Run_Baseline;
private readonly Option<string[]> _Run_Outcome;
private readonly Option<bool> _Run_NoRestore;
private readonly Option<bool> _Listen_Stdio;
private readonly Option<string> _Listen_Pipe;

private ClientBuilder(RootCommand cmd)
{
Expand Down Expand Up @@ -115,6 +119,17 @@ private ClientBuilder(RootCommand cmd)
description: CmdStrings.Module_Restore_Force_Description
);

// Options for the listen command.
_Listen_Stdio = new Option<bool>(
["--stdio"],
description: CmdStrings.Listen_Stdio_Description
);

_Listen_Pipe = new Option<string>(
["--pipe"],
description: CmdStrings.Listen_Pipe_Description
);

cmd.AddGlobalOption(_Global_Option);
cmd.AddGlobalOption(_Global_Verbose);
cmd.AddGlobalOption(_Global_Debug);
Expand All @@ -132,6 +147,7 @@ public static Command New()
var builder = new ClientBuilder(cmd);
builder.AddRun();
builder.AddModule();
builder.AddListen();
return builder.Command;
}

Expand Down Expand Up @@ -322,6 +338,26 @@ private void AddModule()
Command.AddCommand(cmd);
}

private void AddListen()
{
var cmd = new Command("listen", CmdStrings.Listen_Description);
cmd.AddOption(_Global_Path);
cmd.AddOption(_Listen_Stdio);
cmd.AddOption(_Listen_Pipe);
cmd.SetHandler(async (invocation) =>
{
var option = new ListenOptions
{
Path = invocation.ParseResult.GetValueForOption(_Global_Path),
Pipe = invocation.ParseResult.GetValueForOption(_Listen_Pipe),
Stdio = invocation.ParseResult.GetValueForOption(_Listen_Stdio),
};
var client = GetClientContext(invocation);
invocation.ExitCode = await ListenCommand.ListenAsync(option, client);
});
Command.AddCommand(cmd);
}

private ClientContext GetClientContext(InvocationContext invocation)
{
var option = invocation.ParseResult.GetValueForOption(_Global_Option).TrimQuotes();
Expand Down
119 changes: 119 additions & 0 deletions src/PSRule.EditorServices/Commands/ListenCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.IO.Pipes;
using PSRule.CommandLine;
using PSRule.EditorServices.Hosting;
using PSRule.EditorServices.Models;

namespace PSRule.EditorServices.Commands;

/// <summary>
/// The listen command for running the persistent language server.
/// </summary>
internal sealed class ListenCommand
{
private const string LOCAL_PIPE_SERVER_NAME = ".";
private const string WINDOWS_PIPE_PREFIX = @"\\.\pipe\";

private const int ERROR_SUCCESS = 0;
private const int ERROR_INVALID_CONFIGURATION = 901;
private const int ERROR_SERVER_EXCEPTION = 902;
private const int ERROR_DEBUGGER_ATTACH_TIMEOUT = 903;

/// <summary>
/// Run the listen command.
/// This command will start a language server and block until exit.
/// </summary>
public static async Task<int> ListenAsync(ListenOptions operationOptions, ClientContext clientContext, CancellationToken cancellationToken = default)
{
if (operationOptions.WaitForDebugger)
{
if (!await WaitForDebuggerAsync(cancellationToken))
return ERROR_DEBUGGER_ATTACH_TIMEOUT;

System.Diagnostics.Debugger.Break();
}

var server = await GetServerAsync(operationOptions, cancellationToken);
if (server == null)
return ERROR_INVALID_CONFIGURATION;

try
{
await server.RunAsync(cancellationToken);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
return ERROR_SERVER_EXCEPTION;
}

return ERROR_SUCCESS;
}

private static async Task<LspServer?> GetServerAsync(ListenOptions operationOptions, CancellationToken cancellationToken)
{
// Create a server with a named pipe client stream.
if (operationOptions.Pipe is { } pipeName)
{
var clientPipe = await ConnectNamedPipeClientStreamAsync(pipeName, cancellationToken);

return new LspServer(options =>
{
options.WithInput(clientPipe)
.WithOutput(clientPipe)
.RegisterForDisposal(clientPipe);
});
}
else if (operationOptions.Stdio)
{
return new LspServer(options =>
{
options.WithInput(Console.OpenStandardInput())
.WithOutput(Console.OpenStandardOutput());
});
}

return null;
}

/// <summary>
/// Connect to a named pipe client stream.
/// </summary>
private static async Task<NamedPipeClientStream> ConnectNamedPipeClientStreamAsync(string pipeName, CancellationToken cancellationToken)
{
pipeName = pipeName.StartsWith(WINDOWS_PIPE_PREFIX) ? pipeName[WINDOWS_PIPE_PREFIX.Length..] : pipeName;

var clientPipe = new NamedPipeClientStream(LOCAL_PIPE_SERVER_NAME, pipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
await clientPipe.ConnectAsync(cancellationToken);

return clientPipe;
}

/// <summary>
/// Wait up to 5 minutes for the debugger to attach.
/// </summary>
private static async Task<bool> WaitForDebuggerAsync(CancellationToken cancellationToken)
{
try
{
var debuggerTimeoutToken = CancellationTokenSource.CreateLinkedTokenSource
(
cancellationToken,
new CancellationTokenSource(TimeSpan.FromMinutes(5)).Token
).Token;

while (!System.Diagnostics.Debugger.IsAttached)
{
await Task.Delay(500, debuggerTimeoutToken);
}
}
catch
{
return false;
}

return true;
}
}
24 changes: 24 additions & 0 deletions src/PSRule.EditorServices/Hosting/ILanguageServerExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Newtonsoft.Json.Linq;
using OmniSharp.Extensions.LanguageServer.Protocol;
using OmniSharp.Extensions.LanguageServer.Protocol.Models;
using OmniSharp.Extensions.LanguageServer.Protocol.Server;

namespace PSRule.EditorServices.Hosting;

/// <summary>
/// Extension methods for working with <see cref="ILanguageServer"/>.
/// </summary>
internal static class ILanguageServerExtensions
{
public static void SendServerReady(this ILanguageServer server)
{
server.SendProgress(new ProgressParams
{
Token = new ProgressToken("server/ready"),
Value = new JValue(Engine.GetVersion()),
});
}
}
Loading

0 comments on commit 87f2dc8

Please sign in to comment.