From 091ba7716873b3b0fd082de76654b0f784c0e5f7 Mon Sep 17 00:00:00 2001 From: Bernie White Date: Fri, 10 Jan 2025 04:01:55 +1000 Subject: [PATCH 1/2] Initial language server implementation --- PSRule.sln | 7 + docs/troubleshooting.md | 3 + .../Commands/ModuleCommand.cs | 24 ++-- .../Models/ModuleOptions.cs | 2 +- src/PSRule.EditorServices/ClientBuilder.cs | 36 +++++ .../Commands/ListenCommand.cs | 119 ++++++++++++++++ .../Hosting/ILanguageServerExtensions.cs | 24 ++++ .../Hosting/LspServer.cs | 78 +++++++++++ .../Hosting/ServerHostContext.cs | 78 +++++++++++ .../Models/ListenOptions.cs | 30 ++++ .../PSRule.EditorServices.csproj | 1 + .../Resources/CmdStrings.Designer.cs | 27 ++++ .../Resources/CmdStrings.resx | 9 ++ src/PSRule.EditorServices/packages.lock.json | 120 +++++++++++++++- .../Generators/EngineVersionGenerator.cs | 2 +- src/PSRule/Common/Engine.cs | 14 +- src/vscode-ps-rule/client.ts | 128 ++++++++++++++++++ src/vscode-ps-rule/extension.ts | 10 +- src/vscode-ps-rule/logger.ts | 6 + .../PSRule.EditorServices.Tests.csproj | 31 +++++ tests/PSRule.EditorServices.Tests/Usings.cs | 4 + .../xunit.runner.json | 4 + 22 files changed, 737 insertions(+), 20 deletions(-) create mode 100644 src/PSRule.EditorServices/Commands/ListenCommand.cs create mode 100644 src/PSRule.EditorServices/Hosting/ILanguageServerExtensions.cs create mode 100644 src/PSRule.EditorServices/Hosting/LspServer.cs create mode 100644 src/PSRule.EditorServices/Hosting/ServerHostContext.cs create mode 100644 src/PSRule.EditorServices/Models/ListenOptions.cs create mode 100644 src/vscode-ps-rule/client.ts create mode 100644 tests/PSRule.EditorServices.Tests/PSRule.EditorServices.Tests.csproj create mode 100644 tests/PSRule.EditorServices.Tests/Usings.cs create mode 100644 tests/PSRule.EditorServices.Tests/xunit.runner.json diff --git a/PSRule.sln b/PSRule.sln index 61f1f9d895..ccd72cf6c6 100644 --- a/PSRule.sln +++ b/PSRule.sln @@ -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 @@ -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 @@ -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} diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 3011338891..f25e171ac0 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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. diff --git a/src/PSRule.CommandLine/Commands/ModuleCommand.cs b/src/PSRule.CommandLine/Commands/ModuleCommand.cs index a3d44c745e..a7141dea7d 100644 --- a/src/PSRule.CommandLine/Commands/ModuleCommand.cs +++ b/src/PSRule.CommandLine/Commands/ModuleCommand.cs @@ -25,6 +25,8 @@ namespace PSRule.CommandLine.Commands; /// public sealed class ModuleCommand { + private const int ERROR_SUCCESS = 0; + /// /// Failed to install a module. /// @@ -46,7 +48,7 @@ public sealed class ModuleCommand /// public static async Task 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); @@ -135,7 +137,7 @@ public static async Task ModuleRestoreAsync(RestoreOptions operationOptions } } - if (exitCode == 0) + if (exitCode == ERROR_SUCCESS) { clientContext.LogVerbose("[PSRule][M] -- All modules are restored and up-to-date."); } @@ -152,7 +154,7 @@ public static async Task ModuleRestoreAsync(RestoreOptions operationOptions /// public static async Task 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(); @@ -218,12 +220,12 @@ public static async Task ModuleInitAsync(ModuleOptions operationOptions, Cl public static async Task 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)); } @@ -235,7 +237,7 @@ public static async Task ModuleListAsync(ModuleOptions operationOptions, Cl /// public static async Task 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(); @@ -296,7 +298,7 @@ public static async Task ModuleAddAsync(ModuleOptions operationOptions, Cli file.Write(null); - if (exitCode == 0) + if (exitCode == ERROR_SUCCESS) { ListModules(clientContext, GetModules(pwsh, file, clientContext.Option)); } @@ -310,7 +312,7 @@ public static async Task ModuleAddAsync(ModuleOptions operationOptions, Cli public static async Task 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); @@ -330,7 +332,7 @@ public static async Task ModuleRemoveAsync(ModuleOptions operationOptions, file.Write(null); - if (exitCode == 0) + if (exitCode == ERROR_SUCCESS) { ListModules(clientContext, GetModules(pwsh, file, clientContext.Option)); } @@ -342,7 +344,7 @@ public static async Task ModuleRemoveAsync(ModuleOptions operationOptions, /// public static async Task 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(operationOptions.Module, StringComparer.OrdinalIgnoreCase) : null; @@ -379,7 +381,7 @@ public static async Task ModuleUpgradeAsync(ModuleOptions operationOptions, file.Write(null); - if (exitCode == 0) + if (exitCode == ERROR_SUCCESS) { ListModules(clientContext, GetModules(pwsh, file, clientContext.Option)); } diff --git a/src/PSRule.CommandLine/Models/ModuleOptions.cs b/src/PSRule.CommandLine/Models/ModuleOptions.cs index 0fcbddf1d5..630788660c 100644 --- a/src/PSRule.CommandLine/Models/ModuleOptions.cs +++ b/src/PSRule.CommandLine/Models/ModuleOptions.cs @@ -4,7 +4,7 @@ namespace PSRule.CommandLine.Models; /// -/// Options for the module command. +/// Options for the module command. /// public sealed class ModuleOptions { diff --git a/src/PSRule.EditorServices/ClientBuilder.cs b/src/PSRule.EditorServices/ClientBuilder.cs index 92c2748036..b67e40b139 100644 --- a/src/PSRule.EditorServices/ClientBuilder.cs +++ b/src/PSRule.EditorServices/ClientBuilder.cs @@ -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; @@ -37,6 +39,8 @@ internal sealed class ClientBuilder private readonly Option _Run_Baseline; private readonly Option _Run_Outcome; private readonly Option _Run_NoRestore; + private readonly Option _Listen_Stdio; + private readonly Option _Listen_Pipe; private ClientBuilder(RootCommand cmd) { @@ -115,6 +119,17 @@ private ClientBuilder(RootCommand cmd) description: CmdStrings.Module_Restore_Force_Description ); + // Options for the listen command. + _Listen_Stdio = new Option( + ["--stdio"], + description: CmdStrings.Listen_Stdio_Description + ); + + _Listen_Pipe = new Option( + ["--pipe"], + description: CmdStrings.Listen_Pipe_Description + ); + cmd.AddGlobalOption(_Global_Option); cmd.AddGlobalOption(_Global_Verbose); cmd.AddGlobalOption(_Global_Debug); @@ -132,6 +147,7 @@ public static Command New() var builder = new ClientBuilder(cmd); builder.AddRun(); builder.AddModule(); + builder.AddListen(); return builder.Command; } @@ -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(); diff --git a/src/PSRule.EditorServices/Commands/ListenCommand.cs b/src/PSRule.EditorServices/Commands/ListenCommand.cs new file mode 100644 index 0000000000..3d2b0668aa --- /dev/null +++ b/src/PSRule.EditorServices/Commands/ListenCommand.cs @@ -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; + +/// +/// The listen command for running the persistent language server. +/// +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; + + /// + /// Run the listen command. + /// This command will start a language server and block until exit. + /// + public static async Task 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 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; + } + + /// + /// Connect to a named pipe client stream. + /// + private static async Task 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; + } + + /// + /// Wait up to 5 minutes for the debugger to attach. + /// + private static async Task 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; + } +} diff --git a/src/PSRule.EditorServices/Hosting/ILanguageServerExtensions.cs b/src/PSRule.EditorServices/Hosting/ILanguageServerExtensions.cs new file mode 100644 index 0000000000..7c5725eaab --- /dev/null +++ b/src/PSRule.EditorServices/Hosting/ILanguageServerExtensions.cs @@ -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; + +/// +/// Extension methods for working with . +/// +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()), + }); + } +} diff --git a/src/PSRule.EditorServices/Hosting/LspServer.cs b/src/PSRule.EditorServices/Hosting/LspServer.cs new file mode 100644 index 0000000000..1ce95e5d57 --- /dev/null +++ b/src/PSRule.EditorServices/Hosting/LspServer.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using OmniSharp.Extensions.LanguageServer.Protocol.Window; +using OmniSharp.Extensions.LanguageServer.Server; + +namespace PSRule.EditorServices.Hosting; + +/// +/// The LSP server for PSRule integration. +/// +internal sealed class LspServer(Action configure) : IDisposable +{ + private readonly LanguageServer _Server = LanguageServer.PreInit(options => + { + WithHandlers(options); + WithEvents(options); + + configure(options); + }); + + private bool _Disposed; + + /// + /// Run the language server and block until exit. + /// + public async Task RunAsync(CancellationToken cancellationToken) + { + await _Server.Initialize(cancellationToken); + + _Server.LogInfo($"Language server running on processId: {System.Environment.ProcessId}"); + + await _Server.WaitForExit; + } + + /// + /// Register request handlers. + /// + private static void WithHandlers(LanguageServerOptions options) + { + // options.WithHandler(); + } + + /// + /// Hook up event listeners. + /// + private static void WithEvents(LanguageServerOptions options) + { + options.OnInitialized((server, request, response, token) => + { + server.SendServerReady(); + return Task.CompletedTask; + }); + } + + #region IDisposable + + private void Dispose(bool disposing) + { + if (!_Disposed) + { + if (disposing) + { + _Server.Dispose(); + } + _Disposed = true; + } + } + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + #endregion IDisposable +} diff --git a/src/PSRule.EditorServices/Hosting/ServerHostContext.cs b/src/PSRule.EditorServices/Hosting/ServerHostContext.cs new file mode 100644 index 0000000000..97d9070cf4 --- /dev/null +++ b/src/PSRule.EditorServices/Hosting/ServerHostContext.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.CommandLine.Invocation; +using System.CommandLine.IO; +using System.Management.Automation; +using PSRule.Pipeline; + +namespace PSRule.EditorServices.Hosting; + +/// +/// A host context for running PSRule in the language server. +/// +internal sealed class ServerHostContext : HostContext +{ + private readonly InvocationContext _Invocation; + private readonly bool _Verbose; + private readonly bool _Debug; + + public ServerHostContext(InvocationContext invocation, bool verbose, bool debug) + { + _Invocation = invocation; + _Verbose = verbose; + _Debug = debug; + + Verbose($"Using working path: {Directory.GetCurrentDirectory()}"); + } + + public override ActionPreference GetPreferenceVariable(string variableName) + { + if (variableName == "VerbosePreference") + return _Verbose ? ActionPreference.Continue : ActionPreference.SilentlyContinue; + + if (variableName == "DebugPreference") + return _Debug ? ActionPreference.Continue : ActionPreference.SilentlyContinue; + + return base.GetPreferenceVariable(variableName); + } + + public override void Error(ErrorRecord errorRecord) + { + _Invocation.Console.Error.WriteLine(errorRecord.Exception.Message); + base.Error(errorRecord); + } + + public override void Warning(string text) + { + _Invocation.Console.WriteLine(text); + } + + public override bool ShouldProcess(string target, string action) + { + return true; + } + + public override void Information(InformationRecord informationRecord) + { + if (informationRecord?.MessageData is HostInformationMessage info) + _Invocation.Console.WriteLine(info.Message); + } + + public override void Verbose(string text) + { + if (!_Verbose) + return; + + _Invocation.Console.WriteLine(text); + } + + public override void Debug(string text) + { + if (!_Debug) + return; + + _Invocation.Console.WriteLine(text); + } +} diff --git a/src/PSRule.EditorServices/Models/ListenOptions.cs b/src/PSRule.EditorServices/Models/ListenOptions.cs new file mode 100644 index 0000000000..7c381dbba4 --- /dev/null +++ b/src/PSRule.EditorServices/Models/ListenOptions.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace PSRule.EditorServices.Models; + +/// +/// Options for the listen command. +/// +internal sealed class ListenOptions +{ + /// + /// A specific path to use for the operation. + /// + public string[]? Path { get; set; } + + /// + /// The name of a client named pipe to connect to. + /// + public string? Pipe { get; set; } + + /// + /// Determine if the client will use stdio for communication. + /// + public bool Stdio { get; set; } = false; + + /// + /// Determine if the listen command should wait for a debugger to attach. + /// + public bool WaitForDebugger { get; set; } = false; +} diff --git a/src/PSRule.EditorServices/PSRule.EditorServices.csproj b/src/PSRule.EditorServices/PSRule.EditorServices.csproj index 5a324fc937..e9b28b052e 100644 --- a/src/PSRule.EditorServices/PSRule.EditorServices.csproj +++ b/src/PSRule.EditorServices/PSRule.EditorServices.csproj @@ -20,6 +20,7 @@ + all diff --git a/src/PSRule.EditorServices/Resources/CmdStrings.Designer.cs b/src/PSRule.EditorServices/Resources/CmdStrings.Designer.cs index 462264a5ab..2221afe928 100644 --- a/src/PSRule.EditorServices/Resources/CmdStrings.Designer.cs +++ b/src/PSRule.EditorServices/Resources/CmdStrings.Designer.cs @@ -105,6 +105,33 @@ internal static string Global_Verbose_Description { } } + /// + /// Looks up a localized string similar to Run the language server.. + /// + internal static string Listen_Description { + get { + return ResourceManager.GetString("Listen_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The named pipe to connect to for LSP communication.. + /// + internal static string Listen_Pipe_Description { + get { + return ResourceManager.GetString("Listen_Pipe_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Use stdin/stdout for LSP communication.. + /// + internal static string Listen_Stdio_Description { + get { + return ResourceManager.GetString("Listen_Stdio_Description", resourceCulture); + } + } + /// /// Looks up a localized string similar to Add one or more modules to the lock file.. /// diff --git a/src/PSRule.EditorServices/Resources/CmdStrings.resx b/src/PSRule.EditorServices/Resources/CmdStrings.resx index 8964becd37..622de676e8 100644 --- a/src/PSRule.EditorServices/Resources/CmdStrings.resx +++ b/src/PSRule.EditorServices/Resources/CmdStrings.resx @@ -195,4 +195,13 @@ Do not restore modules before running rules. + + Use stdin/stdout for LSP communication. + + + The named pipe to connect to for LSP communication. + + + Run the language server. + \ No newline at end of file diff --git a/src/PSRule.EditorServices/packages.lock.json b/src/PSRule.EditorServices/packages.lock.json index bbe19f1fd8..803648cb61 100644 --- a/src/PSRule.EditorServices/packages.lock.json +++ b/src/PSRule.EditorServices/packages.lock.json @@ -49,6 +49,18 @@ "Microsoft.SourceLink.Common": "8.0.0" } }, + "OmniSharp.Extensions.LanguageServer": { + "type": "Direct", + "requested": "[0.19.9, )", + "resolved": "0.19.9", + "contentHash": "g09wOOCQ/oFqtZ47Q5R9E78tz2a5ODEB+V+S65wAiiRskR7xwL78Tse4/8ToBc8G/ZgQgqLtAOPo/BSPmHNlbw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "6.0.1", + "OmniSharp.Extensions.JsonRpc": "0.19.9", + "OmniSharp.Extensions.LanguageProtocol": "0.19.9", + "OmniSharp.Extensions.LanguageServer.Shared": "0.19.9" + } + }, "System.Text.Json": { "type": "Direct", "requested": "[8.0.5, )", @@ -98,6 +110,11 @@ "resolved": "0.33.0", "contentHash": "/BE/XANxmocgEqajbWB/ur4Jei+j1FkXppWH9JFmEuoq8T3xJndkQKZVCW/7lTdc9Ru6kfEAkwSXFOv30EkU2Q==" }, + "MediatR": { + "type": "Transitive", + "resolved": "8.1.0", + "contentHash": "KJFnA0MV83bNOhvYbjIX1iDykhwFXoQu0KV7E1SVbNA/CmO2I7SAm2Baly0eS7VJ2GwlmStLajBfeiNgTpvYzQ==" + }, "Microsoft.ApplicationInsights": { "type": "Transitive", "resolved": "2.21.0", @@ -538,13 +555,35 @@ "resolved": "8.0.0", "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" }, + "Microsoft.VisualStudio.Threading": { + "type": "Transitive", + "resolved": "17.6.40", + "contentHash": "hLa/0xargG7p3bF7aeq2/lRYn/bVnfZXurUWVHx+MNqxxAUjIDMKi4OIOWbYQ/DTkbn9gv8TLvgso+6EtHVQQg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "7.0.0", + "Microsoft.VisualStudio.Threading.Analyzers": "17.6.40", + "Microsoft.VisualStudio.Validation": "17.0.71", + "Microsoft.Win32.Registry": "5.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.VisualStudio.Threading.Analyzers": { + "type": "Transitive", + "resolved": "17.6.40", + "contentHash": "uU8vYr/Nx3ldEWcsbiHiyAX1G7od3eFK1+Aga6ZvgCvU+nQkcXYVkIMcSEkIDWkFaldx1dkoVvX3KRNQD0R7dw==" + }, + "Microsoft.VisualStudio.Validation": { + "type": "Transitive", + "resolved": "17.6.11", + "contentHash": "J+9L/iac6c8cwcgVSCMuoIYOlD1Jw4mbZ8XMe1IZVj8p8+3dJ46LnnkIkTRMjK7xs9UtU9MoUp1JGhWoN6fAEw==" + }, "Microsoft.Win32.Registry": { "type": "Transitive", - "resolved": "4.7.0", - "contentHash": "KSrRMb5vNi0CWSGG1++id2ZOs/1QhRqROt+qgbEAdQuGjGrFcl4AOl4/exGPUYz2wUnU42nvJqon1T3U0kPXLA==", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", "dependencies": { - "System.Security.AccessControl": "4.7.0", - "System.Security.Principal.Windows": "4.7.0" + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" } }, "Microsoft.Win32.Registry.AccessControl": { @@ -614,6 +653,18 @@ "resolved": "7.4.6", "contentHash": "pnNVcVUT+CP7r23Ju/+nhp0fLSdwevAZwe3qe8XQEahYOUv9ACIP29GijRsjdOwIL8+7DaLUQF5jjh+P/ZjGTQ==" }, + "Nerdbank.Streams": { + "type": "Transitive", + "resolved": "2.10.69", + "contentHash": "YIudzeVyQRJAqytjpo1jdHkh2t+vqQqyusBqb2sFSOAOGEnyOXhcHx/rQqSuCIXUDr50a3XuZnamGRfQVBOf4g==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "7.0.0", + "Microsoft.VisualStudio.Threading": "17.6.40", + "Microsoft.VisualStudio.Validation": "17.6.11", + "System.IO.Pipelines": "7.0.0", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, "Newtonsoft.Json": { "type": "Transitive", "resolved": "13.0.3", @@ -666,6 +717,47 @@ "resolved": "6.12.1", "contentHash": "fJ6rFYANDnohFsdpaY79FvrJxI6murmoOxXz6nZlf819F48+IBKMnAIg3oIBRtZq5y498ObMtKnro5IitvizUg==" }, + "OmniSharp.Extensions.JsonRpc": { + "type": "Transitive", + "resolved": "0.19.9", + "contentHash": "utFvrx9OYXhCS5rnfWAVeedJCrucuDLAOrKXjohf/NOjG9FFVbcp+hLqj9Ng+AxoADRD+rSJYHfBOeqGl5zW0A==", + "dependencies": { + "MediatR": "8.1.0", + "Microsoft.Extensions.DependencyInjection": "6.0.1", + "Microsoft.Extensions.Logging": "6.0.0", + "Nerdbank.Streams": "2.10.69", + "Newtonsoft.Json": "13.0.3", + "OmniSharp.Extensions.JsonRpc.Generators": "0.19.9", + "System.Collections.Immutable": "5.0.0", + "System.Reactive": "6.0.0", + "System.Threading.Channels": "6.0.0" + } + }, + "OmniSharp.Extensions.JsonRpc.Generators": { + "type": "Transitive", + "resolved": "0.19.9", + "contentHash": "hiWC0yGcKM+K00fgiL7KBmlvULmkKNhm40ZSzxqT+jNV21r+YZgKzEREhQe40ufb4tjcIxdYkif++IzGl/3H/Q==" + }, + "OmniSharp.Extensions.LanguageProtocol": { + "type": "Transitive", + "resolved": "0.19.9", + "contentHash": "d0crY6w5SyunGlERP27YeUeJnJfUjvJoALFlPMU4CHu3jovG1Y8RxLpihCPX8fKdjzgy7Ii+VjFYtIpDEEQqYQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "6.0.1", + "Microsoft.Extensions.Configuration.Binder": "6.0.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "6.0.0", + "OmniSharp.Extensions.JsonRpc": "0.19.9", + "OmniSharp.Extensions.JsonRpc.Generators": "0.19.9" + } + }, + "OmniSharp.Extensions.LanguageServer.Shared": { + "type": "Transitive", + "resolved": "0.19.9", + "contentHash": "+p+py79MrNG3QnqRrBp5J7Wc810HFFczMH8/WLIiUqih1bqmKPFY9l/uzBvq1Ko8+YO/8tzI7BDffHvaguISEw==", + "dependencies": { + "OmniSharp.Extensions.LanguageProtocol": "0.19.9" + } + }, "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": { "type": "Transitive", "resolved": "4.3.2", @@ -1074,6 +1166,11 @@ "resolved": "8.0.1", "contentHash": "KYkIOAvPexQOLDxPO2g0BVoWInnQhPpkFzRqvNrNrMhVT6kqhVr0zEb6KCHlptLFukxnZrjuMVAnxK7pOGUYrw==" }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "jRn6JYnNPW6xgQazROBLSfpdoczRw694vO5kKvMcNnpXuolEixUyw6IBuBs2Y2mlSX/LdLvyyWmfXhaI3ND1Yg==" + }, "System.IO.Ports": { "type": "Transitive", "resolved": "8.0.0", @@ -1192,6 +1289,11 @@ "System.Security.Principal.Windows": "5.0.0" } }, + "System.Reactive": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "31kfaW4ZupZzPsI5PVe77VhnvFF55qgma7KZr/E0iFTs6fmdhhG8j0mgEx620iLTey1EynOkEfnyTjtNEpJzGw==" + }, "System.Reflection": { "type": "Transitive", "resolved": "4.3.0", @@ -1608,6 +1710,11 @@ "resolved": "9.0.0-preview.6.24327.7", "contentHash": "t7e5cLBMvBx9/YhNsCp8W8iUw7geh08y0GKFawfJUD5YLgx6AjO2D497+0qHbXRQGpl2uxBGmkWKnCZ5azILZQ==" }, + "System.Threading.Channels": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "TY8/9+tI0mNaUMgntOxxaq2ndTkdXqLSxvPmas7XEqOlv9lQtB7wLjYGd756lOaO7Dvb5r/WXhluM+0Xe87v5Q==" + }, "System.Threading.Tasks": { "type": "Transitive", "resolved": "4.3.0", @@ -1618,6 +1725,11 @@ "System.Runtime": "4.3.0" } }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==" + }, "System.ValueTuple": { "type": "Transitive", "resolved": "4.5.0", diff --git a/src/PSRule.MSBuild/Generators/EngineVersionGenerator.cs b/src/PSRule.MSBuild/Generators/EngineVersionGenerator.cs index 9f6d9bf84d..90028d1b54 100644 --- a/src/PSRule.MSBuild/Generators/EngineVersionGenerator.cs +++ b/src/PSRule.MSBuild/Generators/EngineVersionGenerator.cs @@ -37,7 +37,7 @@ private static string GetPartialContent(GeneratorExecutionContext context) namespace PSRule {{ - internal static partial class Engine + public static partial class Engine {{ private const string _Version = ""{productVersion}""; }} diff --git a/src/PSRule/Common/Engine.cs b/src/PSRule/Common/Engine.cs index d23a6765ed..8f38f1a9ed 100644 --- a/src/PSRule/Common/Engine.cs +++ b/src/PSRule/Common/Engine.cs @@ -3,18 +3,28 @@ namespace PSRule; -internal static partial class Engine +/// +/// The PSRule engine. +/// +public static partial class Engine { private static readonly string[] _Capabilities = [ "api-v1", "api-2025-01-01" ]; - internal static string GetVersion() + /// + /// The version of PSRule. + /// + public static string GetVersion() { return _Version; } + /// + /// Get the intrinsic capabilities of the engine. + /// + /// Returns a list of capability identifiers. internal static string[] GetIntrinsicCapability() { return _Capabilities; diff --git a/src/vscode-ps-rule/client.ts b/src/vscode-ps-rule/client.ts new file mode 100644 index 0000000000..8f9eeba671 --- /dev/null +++ b/src/vscode-ps-rule/client.ts @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +'use strict'; + +import * as vscode from 'vscode'; +import * as lsp from 'vscode-languageclient/node'; +import { logger } from './logger'; +import { ext } from './extension'; +import { getActiveOrFirstWorkspace } from './utils'; + +// export const GetVersionRequestType = new lsp.RequestType('ps-rule/getVersion'); + +export const StartProgressNotificationType = new lsp.ProgressType(); + +/** + * Implements a language server client for communication with PSRule runtime. + */ +export class PSRuleClient implements vscode.Disposable { + public async configure( + context: vscode.ExtensionContext, + ): Promise { + // Prepare options for the language server. + const serverOptions = this.getServerOptions(context); + if (!serverOptions) { + logger.error('Failed to configure server options.'); + return Promise.reject('Failed to configure server options.'); + } + + const clientOptions = this.getClientOptions(); + + // Create the language client. + const client = new lsp.LanguageClient( + 'ps-rule', + 'PSRule Language Server', + serverOptions, + clientOptions, + ); + + // Register proposed features + client.registerProposedFeatures(); + + // Setup event handlers. + client.onNotification(lsp.LogTraceNotification.type, (params) => { + if (params.message === undefined) { + return; + } + + if (params.verbose) { + logger.verbose(params.message); + } + else { + logger.log(params.message); + } + }); + + client.onProgress(StartProgressNotificationType, 'server/ready', (arg1) => { + logger.verbose(`Connected to client v${arg1}.`); + }); + + // Start the server and return the client. + client.start(); + return client; + } + + private getClientOptions(): lsp.LanguageClientOptions { + const outputChannel = logger.channel; + const clientOptions: lsp.LanguageClientOptions = { + documentSelector: [{ language: 'yaml' }], + progressOnInitialization: true, + outputChannel, + // middleware: { + // provideDocumentFormattingEdits: (document, options, token, next) => + // next( + // document, + // { + // ...options, + // insertFinalNewline: + // vscode.workspace + // .getConfiguration('files') + // .get('insertFinalNewline') ?? false, + // }, + // token + // ), + // }, + synchronize: { + // Configure glob patterns to monitor for changes. + fileEvents: [ + vscode.workspace.createFileSystemWatcher('**/'), // folder changes + vscode.workspace.createFileSystemWatcher('**/*.Rule.yaml'), // Rule file changes + ], + }, + }; + return clientOptions; + } + + private getServerOptions( + context: vscode.ExtensionContext, + ): lsp.ServerOptions | undefined { + const binPath = ext.server?.binPath; + const languageServerPath = ext.server?.languageServerPath; + + if (!binPath || !languageServerPath) { + return undefined; + } + + const cwd = getActiveOrFirstWorkspace()?.uri.fsPath; + const serverExecutable: lsp.Executable = { + command: binPath, + args: [languageServerPath, 'listen'], + options: { cwd }, + transport: lsp.TransportKind.pipe, + }; + + const serverOptions: lsp.ServerOptions = { + run: serverExecutable, + debug: serverExecutable, + }; + + return serverOptions; + } + + public run(): void { } + + public dispose(): void { } +} + +export const client = new PSRuleClient(); diff --git a/src/vscode-ps-rule/extension.ts b/src/vscode-ps-rule/extension.ts index fe4ed98ac8..4b2df68193 100644 --- a/src/vscode-ps-rule/extension.ts +++ b/src/vscode-ps-rule/extension.ts @@ -20,6 +20,8 @@ import { showTasks } from './commands/showTasks'; import { PSRuleLanguageServer, getActiveOrFirstWorkspace, getLanguageServer } from './utils'; import { restore } from './commands/restore'; import { initLock } from './commands/initLock'; +import { client } from './client'; +import { LanguageClient } from 'vscode-languageclient/node'; export let taskManager: PSRuleTaskProvider | undefined; export let docLensProvider: DocumentationLensProvider | undefined; @@ -36,6 +38,7 @@ export class ExtensionManager implements vscode.Disposable { private _info!: ExtensionInfo; private _context!: vscode.ExtensionContext; private _server!: PSRuleLanguageServer | undefined; + private _client!: LanguageClient; constructor() { } @@ -90,6 +93,10 @@ export class ExtensionManager implements vscode.Disposable { if (logger) { logger.dispose(); } + if (this._client) { + this._client.stop(); + this._client.dispose(); + } } private async activateFeatures(): Promise { @@ -140,7 +147,6 @@ export class ExtensionManager implements vscode.Disposable { initLock(); }) ); - } } @@ -169,6 +175,8 @@ export class ExtensionManager implements vscode.Disposable { this._server = await getLanguageServer(this._context); await this.restoreOnActivation() + + this._client = await client.configure(this._context); } } diff --git a/src/vscode-ps-rule/logger.ts b/src/vscode-ps-rule/logger.ts index 69bab8510d..ed3274e816 100644 --- a/src/vscode-ps-rule/logger.ts +++ b/src/vscode-ps-rule/logger.ts @@ -21,6 +21,8 @@ export interface ILogger { log(message: string, ...additionalMessages: string[]): void; dispose(): void; + + get channel(): vscode.OutputChannel; } export class Logger implements ILogger { @@ -47,6 +49,10 @@ export class Logger implements ILogger { this.write(LogLevel.Normal, message, ...additionalMessages); } + public get channel(): vscode.OutputChannel { + return this._Output; + } + private write(logLevel: LogLevel, message: string, ...additionalMessages: string[]): void { if (logLevel >= this._LogLevel) { this.writeLine(message, logLevel); diff --git a/tests/PSRule.EditorServices.Tests/PSRule.EditorServices.Tests.csproj b/tests/PSRule.EditorServices.Tests/PSRule.EditorServices.Tests.csproj new file mode 100644 index 0000000000..f0394bc1d3 --- /dev/null +++ b/tests/PSRule.EditorServices.Tests/PSRule.EditorServices.Tests.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + {89d6aaaa-2ad9-4899-935e-5330efb3383e} + true + false + PSRule.EditorServices + Full + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/tests/PSRule.EditorServices.Tests/Usings.cs b/tests/PSRule.EditorServices.Tests/Usings.cs new file mode 100644 index 0000000000..8c07c6cf4c --- /dev/null +++ b/tests/PSRule.EditorServices.Tests/Usings.cs @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +global using Xunit; diff --git a/tests/PSRule.EditorServices.Tests/xunit.runner.json b/tests/PSRule.EditorServices.Tests/xunit.runner.json new file mode 100644 index 0000000000..2fe8c33bcc --- /dev/null +++ b/tests/PSRule.EditorServices.Tests/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "diagnosticMessages": true +} From f3de135afaabc3c5d1eadd4ef5a3033242434cad Mon Sep 17 00:00:00 2001 From: Bernie White Date: Fri, 10 Jan 2025 14:59:29 +1000 Subject: [PATCH 2/2] Fix --- tests/PSRule.EditorServices.Tests/Usings.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PSRule.EditorServices.Tests/Usings.cs b/tests/PSRule.EditorServices.Tests/Usings.cs index 8c07c6cf4c..0ebd54d508 100644 --- a/tests/PSRule.EditorServices.Tests/Usings.cs +++ b/tests/PSRule.EditorServices.Tests/Usings.cs @@ -1,4 +1,4 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -global using Xunit; +// global using Xunit;