+ {{^_disableToc}} + {{>partials/toc}} +
+ {{/_disableToc}} + {{#_disableToc}} +
+ {{/_disableToc}} + {{#_disableAffix}} +
+ {{/_disableAffix}} + {{^_disableAffix}} +
+ {{/_disableAffix}} +
+ {{^_disableContribution}} + {{#docurl}} + Improve this Doc + {{/docurl}} + {{/_disableContribution}} + {{{rawTitle}}} + {{{conceptual}}} +
+
+ {{^_disableAffix}} + {{>partials/affix}} + {{/_disableAffix}} +
+
+ {{^_disableFooter}} + {{>partials/footer}} + {{/_disableFooter}} +
+ {{>partials/scripts}} + + diff --git a/docs/template/partials/class.tmpl.partial b/docs/template/partials/class.tmpl.partial new file mode 100644 index 0000000..b103082 --- /dev/null +++ b/docs/template/partials/class.tmpl.partial @@ -0,0 +1,135 @@ +{{!Copyright (c) Microsoft Corporation. Licensed under the MIT License.}} + +{{^_disableContribution}} +{{#docurl}}{{__global.improveThisDoc}}{{/docurl}} +{{#sourceurl}}{{__global.viewSource}}{{/sourceurl}} +{{/_disableContribution}} +

{{>partials/title}}

+
{{{summary}}}
+
{{{conceptual}}}
+{{#inheritance.0}} +
+
{{__global.inheritance}}
+{{#inheritance}} +
{{{specName.0.value}}}
+{{/inheritance}} +
{{item.name.0.value}}
+
+{{/inheritance.0}} +
{{__global.namespace}}:{{namespace}}
+
{{__global.assembly}}:{{assemblies.0}}.dll
+
{{__global.syntax}}
+
+
{{syntax.content.0.value}}
+
+{{#remarks}} +
{{__global.remarks}}
+
{{{remarks}}}
+{{/remarks}} +{{#children}} +

{{>partials/classSubtitle}}

+{{#children}} +{{^_disableContribution}} +{{#docurl}} + + | + {{__global.improveThisDoc}} +{{/docurl}} +{{#sourceurl}} + + {{__global.viewSource}} +{{/sourceurl}} +{{/_disableContribution}} +

{{name.0.value}}

+
{{{summary}}}
+
{{{conceptual}}}
+{{#remarks}} +
{{__global.remarks}}
+
{{{remarks}}}
+{{/remarks}} +
{{__global.declaration}}
+{{#syntax}} +
+
{{syntax.content.0.value}}
+
+{{#parameters.0}} +
{{__global.parameters}}
+ + + + + + + + + +{{/parameters.0}} +{{#parameters}} + + + + + + {{/parameters}} + {{#parameters.0}} + +
{{__global.type}}{{__global.name}}{{__global.description}}
{{{type.specName.0.value}}}{{{id}}}{{{description}}}
+{{/parameters.0}} +{{#return}} +
{{__global.returns}}
+ + + + + + + + + + + + + +
{{__global.type}}{{__global.description}}
{{{type.specName.0.value}}}{{{description}}}
+{{/return}} +{{#propertyValue}} +
{{__global.provertyValue}}
+ + + + + + + + + + + + + +
{{__global.type}}{{__global.description}}
{{{type.specName.0.value}}}{{{description}}}
+{{/propertyValue}} +{{/syntax}} +{{#exceptions.0}} +
{{__global.exceptions}}
+ + + + + + + + +{{/exceptions.0}} +{{#exceptions}} + + + + +{{/exceptions}} +{{#exceptions.0}} + +
{{__global.type}}{{__global.condition}}
{{{type.specName.0.value}}}{{{description}}}
+{{/exceptions.0}} +{{/children}} +{{/children}} diff --git a/docs/template/partials/footer.tmpl.partial b/docs/template/partials/footer.tmpl.partial new file mode 100644 index 0000000..0dd1e2d --- /dev/null +++ b/docs/template/partials/footer.tmpl.partial @@ -0,0 +1,7 @@ +{{!Copyright (c) Microsoft Corporation. Licensed under the MIT License.}} + +
+ +
diff --git a/docs/template/partials/head.tmpl.partial b/docs/template/partials/head.tmpl.partial new file mode 100644 index 0000000..f2b29a6 --- /dev/null +++ b/docs/template/partials/head.tmpl.partial @@ -0,0 +1,28 @@ +{{!Copyright (c) Microsoft Corporation. Licensed under the MIT License.}} + + + + + {{#title}}{{title}}{{/title}}{{^title}}{{>partials/title}}{{/title}} {{#_appTitle}}| {{_appTitle}} {{/_appTitle}} + + + {{#_description}}{{/_description}} + + + + + + + + + diff --git a/docs/template/partials/namespace.tmpl.partial b/docs/template/partials/namespace.tmpl.partial new file mode 100644 index 0000000..b1cc5c2 --- /dev/null +++ b/docs/template/partials/namespace.tmpl.partial @@ -0,0 +1,21 @@ +{{!Copyright (c) Microsoft Corporation. Licensed under the MIT License.}} + +{{^_disableContribution}} +{{#docurl}} +{{__global.improveThisDoc}} +{{/docurl}} +{{#sourceurl}} +{{__global.viewSource}} +{{/sourceurl}} +{{/_disableContribution}} +

{{>partials/title}}

+
{{{summary}}}
+
{{{conceptual}}}
+
{{{remarks}}}
+{{#children}} +

{{>partials/namespaceSubtitle}}

+ {{#children}} +

{{{specName.0.value}}}

+
{{{summary}}}
+ {{/children}} +{{/children}} diff --git a/docs/template/partials/navbar.tmpl.partial b/docs/template/partials/navbar.tmpl.partial new file mode 100644 index 0000000..719c100 --- /dev/null +++ b/docs/template/partials/navbar.tmpl.partial @@ -0,0 +1,18 @@ +{{!Copyright (c) Microsoft Corporation. Licensed under the MIT License.}} + + diff --git a/docs/template/styles/main.css b/docs/template/styles/main.css new file mode 100644 index 0000000..f9c1549 --- /dev/null +++ b/docs/template/styles/main.css @@ -0,0 +1,268 @@ +@import url(//fonts.googleapis.com/css?family=Roboto+Condensed:700); +@import url(//fonts.googleapis.com/css?family=Open+Sans); + +/* Main styles */ +body { + font-family: "Open Sans", "Segoe UI", sans-serif; + font-size: 15px; + padding-top: 50px; +} +ul { + list-style-image: url("../../images/core/list-bullet.png"); +} +nav { + font-size: 14px; +} +.navbar-nav > li > a.nav-active, .navbar-nav > li > a.nav-active:hover { + background-color: #333; + color: #fff; +} + +h1, h2, h3, h4, h5 { + font-family: "Roboto Condensed", "Segoe UI", sans-serif; + font-weight: bold; +} + + +footer { + text-align: center; + width: 100%; + margin-top: 50px; + color: #c0c0c0; +} +footer > .inner-footer a { + color: #c0c0c0; + text-decoration: none; +} +footer > .inner-footer a:hover { + color: #32145a; + text-decoration: none; +} +.content a { + /*color: #A979B3;*/ + color: #A356B3; + text-decoration: none; + outline: 0; +} +.content a:hover { + /*transition: color .15s cubic-bezier(.33, .66, .66, 1);*/ + text-decoration: none; + color: #682079; +} + + +/* End of main styles */ + +/* Index page styles */ +.btn-hero-core { + padding: 15px 25px; + background-color: #32145a; + color: #d89ae4; + display: inline-block; + font-family: "Open Sans", sans-serif; + font-size: 20px; + font-weight: bold; + margin-left: 20px; + -webkit-box-shadow: 2px 2px 3px 0px #2C0D33; /* Safari 3-4, iOS 4.0.2 - 4.2, Android 2.3+ */ + -moz-box-shadow: 2px 2px 3px 0px #2C0D33; /* Firefox 3.5 - 3.6 */ + box-shadow: 2px 2px 3px 0px #2C0D33; /* Opera 10.5, IE 9, Firefox 4+, Chrome 6+, iOS 5 */ +} +.btn-hero-core:hover { + color: #d89ae4; + text-decoration: none; +} +.hero { + background-color: #682079; + width: inherit; + color: #fff; +} +.starter-template { + padding: 40px 15px; + text-align: center; +} +.dotnet { + color: #fff; +} +#rest-vps { + display: none; +} +.value-prop-heading { + margin-top: 0px; +} +.value-props { + margin-top: 40px; + margin-bottom: 40px; +} + +.intro-image { + text-align: center; +} +.intro-image > img { + margin-top: 20px; +} + +/* End of index page styles */ + +/* Getting started page styles */ +.getting-started-intro { + text-align: center; + margin-top: 40px; + margin-bottom: 40px; +} +.getting-started-intro > h2, h4 { + margin-bottom: 30px; +} +.btn-gs { + width: 150px; +} +.btn-gs:hover, .btn-gs:active, .btn-gs:focus, .jquery-active { + color: #fff; + background-color: #682079; + outline: 0 !important; +} + + +.step { + width: 100%; + margin: 50px auto; + padding: 20px 0px; + text-align: center; + font-size: 16px; + border: solid 1px #c0c0c0; + min-height: 300px; + background-color: #fff; + border-radius: 10px; +} +.step-block { + display: block; +} +.step-none { + display: none; +} +.step-number { + position: relative; + top: -40px; + background-color: #32145a; + color: #fff; + font-weight: bold; + font-size: 24px; + z-index: 999; + margin-left: auto; + margin-right: auto; + width: 80px; + padding: 10px; + border: solid 1px #c0c0c0; + border-radius: 10px; +} + +.step > h3 { + margin: 0; + margin-bottom: 30px; + font-size: 30px; +} +.step > p { + margin-top: 10px; + margin-bottom: 20px; + width: 70%; + text-align: center; + margin-left: auto; + margin-right: auto; +} +.code-sample { + white-space: pre; +} + + +/* Terminal backgrounds */ +.terminal { + display: block; + width: 850px; + margin-left: auto; + margin-right: auto; +} +.terminal-titlebar { + background-color: #c0c0c0; + height: 30px; + border-top-left-radius: 5px; + border-top-right-radius: 5px; +} + +.terminal-body { + background-color: #000; + color: #fff; + font-family: "Consolas", "Monaco", monospace; + font-size: 16px; + font-weight: bold; + padding: 15px; + text-align: left; + height: auto; + overflow: auto; + word-wrap: break-word; + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; +} +.prompt { + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + color: #c0c0c0; +} +.windows-prompt:after { + content: 'PS > '; +} +.unix-prompt:after { + content: '~$ '; +} + +@media (max-device-width: 480px) and (orientation: portrait), (max-device-width: 700px) and (orientation: landscape){ + /* Index page overrides */ + .btn-hero-core { + padding: 10px 15px; + margin-left: 0px; + font-size: 16px; + } + .intro-image > img { + display: none; + } + + /* Overview overrides */ + img[src*="10kft_view"] { + width: 100%; + height: 100%; + } + + /* Getting started overrides */ + .btn-gs { + width: auto; + } + + .btn-gs:hover, .btn-gs:active, .btn-gs:focus, .jquery-active { + width: auto; + } + + .step { + width: 90%; + font-size: 14px; + } + .step > h3 { + font-size: 24px; + } + .step-number { + width: 40px; + font-size: 18px; + padding: 5px; + } + .terminal { + width: 95%; + } + .terminal-titlebar { + height: 20px; + } + .terminal-body { + font-size: 12px; + padding: 5px; + } +} diff --git a/docs/template/styles/style.css b/docs/template/styles/style.css new file mode 100644 index 0000000..2fd826e --- /dev/null +++ b/docs/template/styles/style.css @@ -0,0 +1,43 @@ +body { + font-family: "Open Sans", "Segoe UI", sans-serif; + padding-top: 0px; +} +footer { + z-index: 0; +} +.navbar-brand { + font-size: 18px; + padding: 15px; +} +.toc .level3 { + font-weight: normal; + margin-top: 5px; + margin-left: 10px; +} +a.pull-right { + margin-left: 10px; + padding-top: 5px; +} +article.content > h1 { + word-break: break-word; +} +@media only screen and (max-width: 768px) { + .toc .level3 > li { + display: inline-block; + } + .toc .level3 > li:after { + margin-left: -3px; + margin-right: 5px; + content: ", "; + color: #666666; + } +} +@media (max-width: 260px) { + .toc .level3 > li { + display: block; + } + + .toc .level3 > li:after { + display: none; + } +} diff --git a/docs/toc.yml b/docs/toc.yml new file mode 100644 index 0000000..01f0d40 --- /dev/null +++ b/docs/toc.yml @@ -0,0 +1,5 @@ +- name: User Guide + href: guide/ + homepage: guide/introduction.md +- name: API Reference + href: api/ diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..3e9ddc9 --- /dev/null +++ b/flake.nix @@ -0,0 +1,80 @@ +{ + description = "PowerShellEditorServices (PSES)"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = + { self, nixpkgs }: + let + system = "x86_64-linux"; + pkgs = import nixpkgs { inherit system; }; + + psreadline = pkgs.fetchzip { + url = "https://www.powershellgallery.com/api/v2/package/PSReadLine/2.4.5"; + sha256 = "12pwy5b426lv7cmlasa7ajy202a0vjjs9drfyg2q5zwqxhj8g8cz"; + stripRoot = false; + }; + + psscriptanalyzer = pkgs.fetchzip { + url = "https://www.powershellgallery.com/api/v2/package/PSScriptAnalyzer/1.24.0"; + sha256 = "03bz9f5rcx749758h6l9wsv86300s0a34qgd522v6166crbpvg2p"; + stripRoot = false; + }; + + invokebuild = pkgs.fetchzip { + url = "https://www.powershellgallery.com/api/v2/package/InvokeBuild/5.12.2"; + sha256 = "0cry70c6ndlv4y0fmlg5iip0ix1r51p7i6dxx3y2zzczbkz2mn6m"; + stripRoot = false; + }; + + platyps = pkgs.fetchzip { + url = "https://www.powershellgallery.com/api/v2/package/platyPS/0.14.2"; + sha256 = "13m11mf42blcn2327m9a4dqfg8sgjjg1vg9dmiwvnbq7wpam260q"; + stripRoot = false; + }; + in + { + packages.${system}.pses = pkgs.stdenv.mkDerivation { + pname = "powershell-editor-services"; + version = "4.4.0"; + src = ./.; + + nativeBuildInputs = [ + pkgs.dotnet-sdk_8 + pkgs.git + pkgs.powershell + ]; + + buildPhase = '' + export HOME=$TMPDIR + export DOTNET_CLI_TELEMETRY_OPTOUT=1 + export NUGET_PACKAGES=$TMPDIR/nuget + + git init -q + git config user.email "nix@local" + git config user.name "nix" + git add -A + git commit -qm "nix build" + + mkdir -p module + cp -r ${psreadline} module/PSReadLine + cp -r ${psscriptanalyzer} module/PSScriptAnalyzer + + mkdir -p $TMPDIR/psmodules + cp -r ${invokebuild} $TMPDIR/psmodules/InvokeBuild + cp -r ${platyps} $TMPDIR/psmodules/platyPS + export PSModulePath="$TMPDIR/psmodules${"PSModulePath:+:$PSModulePath"}" + + pwsh -NoLogo -NoProfile -Command \ + "Import-Module InvokeBuild; Invoke-Build -File PowerShellEditorServices.build.ps1 -Task AssembleModule -Configuration Release" + ''; + + installPhase = '' + mkdir -p $out/share/powershell/Modules + cp -r module/* $out/share/powershell/Modules/ + ''; + }; + + packages.${system}.default = self.packages.${system}.pses; + }; +} diff --git a/global.json b/global.json new file mode 100644 index 0000000..910363a --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "8.0.416", + "rollForward": "latestFeature", + "allowPrerelease": false + } +} diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..f003b0f --- /dev/null +++ b/nuget.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/PowerShellEditorServices.Hosting/BuildInfo.cs b/src/PowerShellEditorServices.Hosting/BuildInfo.cs new file mode 100644 index 0000000..23eeb7b --- /dev/null +++ b/src/PowerShellEditorServices.Hosting/BuildInfo.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; + +namespace Microsoft.PowerShell.EditorServices.Hosting +{ + public static class BuildInfo + { + public static readonly string BuildVersion = ""; + public static readonly string BuildOrigin = ""; + public static readonly string BuildCommit = ""; + public static readonly System.DateTime? BuildTime = System.DateTime.Parse("2019-12-06T21:43:41", CultureInfo.InvariantCulture.DateTimeFormat); + } +} diff --git a/src/PowerShellEditorServices.Hosting/Commands/StartEditorServicesCommand.cs b/src/PowerShellEditorServices.Hosting/Commands/StartEditorServicesCommand.cs new file mode 100644 index 0000000..6f2ec88 --- /dev/null +++ b/src/PowerShellEditorServices.Hosting/Commands/StartEditorServicesCommand.cs @@ -0,0 +1,517 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.Commands; +using System; +using System.Collections.Generic; +using System.IO; +using System.Management.Automation; +using System.Reflection; +using SMA = System.Management.Automation; +using System.Management.Automation.Runspaces; +using Microsoft.PowerShell.EditorServices.Hosting; +using System.Diagnostics; +using System.Globalization; + +#if DEBUG +using System.Threading; +using Debugger = System.Diagnostics.Debugger; +#endif + +namespace Microsoft.PowerShell.EditorServices.Commands +{ + /// + /// The Start-EditorServices command, the conventional entrypoint for PowerShell Editor Services. + /// + [Cmdlet(VerbsLifecycle.Start, "EditorServices", DefaultParameterSetName = "NamedPipe")] + public sealed class StartEditorServicesCommand : PSCmdlet + { + private readonly List _disposableResources; + + private readonly List _loggerUnsubscribers; + + private HostLogger _logger; + + // NOTE: Ignore the suggestion to use Environment.ProcessId as it doesn't work for + // .NET 4.6.2 (for Windows PowerShell), and this won't be caught in CI. + private static readonly int s_currentPID = Process.GetCurrentProcess().Id; + + public StartEditorServicesCommand() + { + // Sets the distribution channel to "PSES" so starts can be distinguished in PS7+ telemetry + Environment.SetEnvironmentVariable("POWERSHELL_DISTRIBUTION_CHANNEL", "PSES"); + _disposableResources = new List(); + _loggerUnsubscribers = new List(); + } + + /// + /// The name of the EditorServices host to report. + /// + [Parameter] + [ValidateNotNullOrEmpty] + public string HostName { get; set; } = "PSES"; + + /// + /// The ID to give to the host's profile. + /// + [Parameter] + [ValidateNotNullOrEmpty] + public string HostProfileId { get; set; } = "PSES"; + + /// + /// The version to report for the host. + /// + [Parameter] + [ValidateNotNullOrEmpty] + public Version HostVersion { get; set; } = new Version(0, 0, 0); + + /// + /// Path to the session file to create on startup or startup failure. + /// + [Parameter] + [ValidateNotNullOrEmpty] + public string SessionDetailsPath { get; set; } = "PowerShellEditorServices.json"; + + /// + /// The name of the named pipe to use for the LSP transport. + /// If left unset and named pipes are used as transport, a new name will be generated. + /// + [Parameter(ParameterSetName = "NamedPipe")] + public string LanguageServicePipeName { get; set; } + + /// + /// The name of the named pipe to use for the debug adapter transport. + /// If left unset and named pipes are used as a transport, a new name will be generated. + /// + [Parameter(ParameterSetName = "NamedPipe")] + public string DebugServicePipeName { get; set; } + + /// + /// The name of the input named pipe to use for the LSP transport. + /// + [Parameter(ParameterSetName = "NamedPipeSimplex")] + public string LanguageServiceInPipeName { get; set; } + + /// + /// The name of the output named pipe to use for the LSP transport. + /// + [Parameter(ParameterSetName = "NamedPipeSimplex")] + public string LanguageServiceOutPipeName { get; set; } + + /// + /// The name of the input pipe to use for the debug adapter transport. + /// + [Parameter(ParameterSetName = "NamedPipeSimplex")] + public string DebugServiceInPipeName { get; set; } + + /// + /// The name of the output pipe to use for the debug adapter transport. + /// + [Parameter(ParameterSetName = "NamedPipeSimplex")] + public string DebugServiceOutPipeName { get; set; } + + /// + /// If set, uses standard input/output as the LSP transport. + /// When is set with this, standard input/output + /// is used as the debug adapter transport. + /// + [Parameter(ParameterSetName = "Stdio")] + public SwitchParameter Stdio { get; set; } + + /// + /// The path to where PowerShellEditorServices and its bundled modules are. + /// + [Parameter] + [ValidateNotNullOrEmpty] + public string BundledModulesPath { get; set; } = Path.GetFullPath(Path.Combine( + Path.GetDirectoryName(typeof(StartEditorServicesCommand).Assembly.Location), + "..", "..", "..")); + + /// + /// The absolute path to the folder where logs will be saved. + /// + [Parameter] + [ValidateNotNullOrEmpty] + public string LogPath { get; set; } = Path.Combine(Path.GetTempPath(), "PowerShellEditorServices"); + + /// + /// The minimum log level that should be emitted. + /// + [Parameter] + public string LogLevel { get; set; } = PsesLogLevel.Warning.ToString(); + + /// + /// Paths to additional PowerShell modules to be imported at startup. + /// + [Parameter] + public string[] AdditionalModules { get; set; } + + /// + /// Any feature flags to enable in EditorServices. + /// + [Parameter] + public string[] FeatureFlags { get; set; } + + /// + /// When set, enables the Extension Terminal. + /// + [Parameter] + public SwitchParameter EnableConsoleRepl { get; set; } + + /// + /// When set and the console is enabled, the legacy lightweight + /// readline implementation will be used instead of PSReadLine. + /// + [Parameter] + public SwitchParameter UseLegacyReadLine { get; set; } + + /// + /// When set, do not enable LSP service, only the debug adapter. + /// + [Parameter] + public SwitchParameter DebugServiceOnly { get; set; } + + /// + /// When set, do not enable debug adapter, only the language service. + /// + [Parameter] + public SwitchParameter LanguageServiceOnly { get; set; } + + /// + /// When set with a debug build, startup will wait for a debugger to attach. + /// + [Parameter] + public SwitchParameter WaitForDebugger { get; set; } + + /// + /// When set, will generate two simplex named pipes using a single named pipe name. + /// + [Parameter] + public SwitchParameter SplitInOutPipes { get; set; } + + /// + /// The banner/logo to display when the extension terminal is first started. + /// + [Parameter] + public string StartupBanner { get; set; } + + /// + /// Compatibility to store the currently supported PSESLogLevel Enum Value + /// + private PsesLogLevel _psesLogLevel = PsesLogLevel.Warning; + +#pragma warning disable IDE0022 + protected override void BeginProcessing() + { +#if DEBUG + if (WaitForDebugger) + { + // NOTE: Ignore the suggestion to use Environment.ProcessId as it doesn't work for + // .NET 4.6.2 (for Windows PowerShell), and this won't be caught in CI. + Console.WriteLine($"Waiting for debugger to attach, PID: {s_currentPID}"); + while (!Debugger.IsAttached) + { + Thread.Sleep(1000); + } + } +#endif + // Set up logging now for use throughout startup + StartLogging(); + } +#pragma warning restore IDE0022 + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "We have to wait here, it's the whole program.")] + protected override void EndProcessing() + { + _logger.Log(PsesLogLevel.Trace, "Beginning EndProcessing block"); + try + { + // First try to remove PSReadLine to decomplicate startup + // If PSReadLine is enabled, it will be re-imported later + RemovePSReadLineForStartup(); + + // Create the configuration from parameters + EditorServicesConfig editorServicesConfig = CreateConfigObject(); + + using EditorServicesLoader psesLoader = EditorServicesLoader.Create(_logger, editorServicesConfig, SessionDetailsPath, _loggerUnsubscribers); + _logger.Log(PsesLogLevel.Debug, "Loading EditorServices"); + // Synchronously start editor services and wait here until it shuts down. + psesLoader.LoadAndRunEditorServicesAsync().GetAwaiter().GetResult(); + } + catch (Exception e) + { + _logger.LogException("Exception encountered starting EditorServices", e); + + // Give the user a chance to read the message if they have a console + if (!Stdio) + { + Host.UI.WriteLine("\n== Press any key to close terminal =="); + Console.ReadKey(); + } + + ThrowTerminatingError(new ErrorRecord(e, "PowerShellEditorServicesError", ErrorCategory.NotSpecified, this)); + } + finally + { + foreach (IDisposable disposableResource in _disposableResources) + { + disposableResource.Dispose(); + } + } + } + + private void StartLogging() + { + bool isLegacyPsesLogLevel = false; + if (!Enum.TryParse(LogLevel, true, out _psesLogLevel)) + { + // PSES used to have log levels that didn't match MEL levels, this is an adapter for those types and may eventually be removed once people migrate their settings. + isLegacyPsesLogLevel = true; + _psesLogLevel = LogLevel switch + { + "Diagnostic" => PsesLogLevel.Trace, + "Verbose" => PsesLogLevel.Debug, + "Normal" => PsesLogLevel.Information, + _ => PsesLogLevel.Trace + }; + } + + _logger = new HostLogger(_psesLogLevel); + if (isLegacyPsesLogLevel) + { + _logger.Log(PsesLogLevel.Warning, $"The log level '{LogLevel}' is deprecated and will be removed in a future release. Please update your settings or command line options to use one of the following options: 'Trace', 'Debug', 'Information', 'Warning', 'Error', 'Critical'."); + } + + // We need to not write log messages to Stdio + // if it's being used as a protocol transport + if (!Stdio) + { + PSHostLogger hostLogger = new(Host.UI); + _loggerUnsubscribers.Add(_logger.Subscribe(hostLogger)); + } + + string logDirPath = GetLogDirPath(); + string logPath = Path.Combine(logDirPath, $"StartEditorServices-{s_currentPID}.log"); + + if (File.Exists(logPath)) + { + int randomInt = new Random().Next(); + logPath = Path.Combine(logDirPath, $"StartEditorServices-{s_currentPID}-{randomInt.ToString("X", CultureInfo.InvariantCulture.NumberFormat)}.log"); + } + + StreamLogger fileLogger = StreamLogger.CreateWithNewFile(logPath); + _disposableResources.Add(fileLogger); + IDisposable fileLoggerUnsubscriber = _logger.Subscribe(fileLogger); + fileLogger.AddUnsubscriber(fileLoggerUnsubscriber); + _loggerUnsubscribers.Add(fileLoggerUnsubscriber); + _logger.Log(PsesLogLevel.Trace, "Logging started"); + } + + // Sanitizes user input and ensures the directory is created. + private string GetLogDirPath() + { + string logDir = LogPath; + if (string.IsNullOrEmpty(logDir)) + { + logDir = Path.Combine(Path.GetTempPath(), "PowerShellEditorServices"); + } + + Directory.CreateDirectory(logDir); + return logDir; + } + + private void RemovePSReadLineForStartup() + { + _logger.Log(PsesLogLevel.Debug, "Removing PSReadLine"); + using SMA.PowerShell pwsh = SMA.PowerShell.Create(RunspaceMode.CurrentRunspace); + bool hasPSReadLine = pwsh.AddCommand(new CmdletInfo(@"Microsoft.PowerShell.Core\Get-Module", typeof(GetModuleCommand))) + .AddParameter("Name", "PSReadLine") + .Invoke() + .Count > 0; + + if (hasPSReadLine) + { + pwsh.Commands.Clear(); + + pwsh.AddCommand(new CmdletInfo(@"Microsoft.PowerShell.Core\Remove-Module", typeof(RemoveModuleCommand))) + .AddParameter("Name", "PSReadLine") + .AddParameter("ErrorAction", "SilentlyContinue"); + + _logger.Log(PsesLogLevel.Debug, "Removed PSReadLine"); + } + } + + private EditorServicesConfig CreateConfigObject() + { + _logger.Log(PsesLogLevel.Trace, "Creating host configuration"); + + string bundledModulesPath = BundledModulesPath; + if (!Path.IsPathRooted(bundledModulesPath)) + { + // For compatibility, the bundled modules path is relative to the PSES bin directory + // Ideally it should be one level up, the PSES module root + bundledModulesPath = Path.GetFullPath( + Path.Combine( + Assembly.GetExecutingAssembly().Location, + "..", + bundledModulesPath)); + } + + PSObject profile = (PSObject)GetVariableValue("profile"); + + HostInfo hostInfo = new(HostName, HostProfileId, HostVersion); + + InitialSessionState initialSessionState = Runspace.DefaultRunspace.InitialSessionState; + initialSessionState.LanguageMode = Runspace.DefaultRunspace.SessionStateProxy.LanguageMode; + + EditorServicesConfig editorServicesConfig = new( + hostInfo, + Host, + SessionDetailsPath, + bundledModulesPath, + LogPath) + { + FeatureFlags = FeatureFlags, + LogLevel = _psesLogLevel, + ConsoleRepl = GetReplKind(), + UseNullPSHostUI = Stdio, // If Stdio is used we can't write anything else out + AdditionalModules = AdditionalModules, + LanguageServiceTransport = GetLanguageServiceTransport(), + DebugServiceTransport = GetDebugServiceTransport(), + InitialSessionState = initialSessionState, + ProfilePaths = new ProfilePathConfig + { + AllUsersAllHosts = GetProfilePathFromProfileObject(profile, ProfileUserKind.AllUsers, ProfileHostKind.AllHosts), + AllUsersCurrentHost = GetProfilePathFromProfileObject(profile, ProfileUserKind.AllUsers, ProfileHostKind.CurrentHost), + CurrentUserAllHosts = GetProfilePathFromProfileObject(profile, ProfileUserKind.CurrentUser, ProfileHostKind.AllHosts), + CurrentUserCurrentHost = GetProfilePathFromProfileObject(profile, ProfileUserKind.CurrentUser, ProfileHostKind.CurrentHost), + }, + }; + + if (StartupBanner != null) + { + editorServicesConfig.StartupBanner = StartupBanner; + } + + return editorServicesConfig; + } + + private string GetProfilePathFromProfileObject(PSObject profileObject, ProfileUserKind userKind, ProfileHostKind hostKind) + { + string profilePathName = $"{userKind}{hostKind}"; + if (profileObject is null) + { + return null; + } + string pwshProfilePath = (string)profileObject.Properties[profilePathName].Value; + + if (hostKind == ProfileHostKind.AllHosts) + { + return pwshProfilePath; + } + + return Path.Combine( + Path.GetDirectoryName(pwshProfilePath), + $"{HostProfileId}_profile.ps1"); + } + + // We should only use PSReadLine if we specified that we want a console repl + // and we have not explicitly said to use the legacy ReadLine. + // We also want it if we are either: + // * On Windows on any version OR + // * On Linux or macOS on any version greater than or equal to 7 + private ConsoleReplKind GetReplKind() + { + _logger.Log(PsesLogLevel.Trace, "Determining REPL kind"); + + if (Stdio || !EnableConsoleRepl) + { + _logger.Log(PsesLogLevel.Trace, "REPL configured as None"); + return ConsoleReplKind.None; + } + + if (UseLegacyReadLine) + { + _logger.Log(PsesLogLevel.Trace, "REPL configured as Legacy"); + return ConsoleReplKind.LegacyReadLine; + } + + _logger.Log(PsesLogLevel.Trace, "REPL configured as PSReadLine"); + return ConsoleReplKind.PSReadLine; + } + + private ITransportConfig GetLanguageServiceTransport() + { + _logger.Log(PsesLogLevel.Trace, "Configuring LSP transport"); + + if (DebugServiceOnly) + { + _logger.Log(PsesLogLevel.Trace, "No LSP transport: PSES is debug only"); + return null; + } + + if (Stdio) + { + return new StdioTransportConfig(_logger); + } + + if (LanguageServiceInPipeName != null && LanguageServiceOutPipeName != null) + { + return SimplexNamedPipeTransportConfig.Create(_logger, LanguageServiceInPipeName, LanguageServiceOutPipeName); + } + + if (SplitInOutPipes) + { + return SimplexNamedPipeTransportConfig.Create(_logger, LanguageServicePipeName); + } + + return DuplexNamedPipeTransportConfig.Create(_logger, LanguageServicePipeName); + } + + private ITransportConfig GetDebugServiceTransport() + { + _logger.Log(PsesLogLevel.Trace, "Configuring debug transport"); + + if (LanguageServiceOnly) + { + _logger.Log(PsesLogLevel.Trace, "No Debug transport: PSES is language service only"); + return null; + } + + if (Stdio) + { + if (DebugServiceOnly) + { + return new StdioTransportConfig(_logger); + } + + _logger.Log(PsesLogLevel.Trace, "No debug transport: Transport is Stdio with debug disabled"); + return null; + } + + if (DebugServiceInPipeName != null && DebugServiceOutPipeName != null) + { + return SimplexNamedPipeTransportConfig.Create(_logger, DebugServiceInPipeName, DebugServiceOutPipeName); + } + + if (SplitInOutPipes) + { + return SimplexNamedPipeTransportConfig.Create(_logger, DebugServicePipeName); + } + + return DuplexNamedPipeTransportConfig.Create(_logger, DebugServicePipeName); + } + + private enum ProfileHostKind + { + AllHosts, + CurrentHost, + } + + private enum ProfileUserKind + { + AllUsers, + CurrentUser, + } + } +} diff --git a/src/PowerShellEditorServices.Hosting/Configuration/EditorServicesConfig.cs b/src/PowerShellEditorServices.Hosting/Configuration/EditorServicesConfig.cs new file mode 100644 index 0000000..66bc7b1 --- /dev/null +++ b/src/PowerShellEditorServices.Hosting/Configuration/EditorServicesConfig.cs @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Management.Automation.Host; +using System.Management.Automation.Runspaces; + +namespace Microsoft.PowerShell.EditorServices.Hosting +{ + /// + /// Describes the desired console REPL for the Extension Terminal. + /// + public enum ConsoleReplKind + { + /// No console REPL - there will be no interactive console available. + None = 0, + /// Use a REPL with the legacy readline implementation. This is generally used when PSReadLine is unavailable. + LegacyReadLine = 1, + /// Use a REPL with the PSReadLine module for console interaction. + PSReadLine = 2, + } + + /// + /// Configuration for editor services startup. + /// + public sealed class EditorServicesConfig + { + /// + /// Create a new editor services config object, + /// with all required fields. + /// + /// The host description object. + /// The PowerShell host to use in Editor Services. + /// The path to use for the session details file. + /// The path to the modules bundled with Editor Services. + /// The path to be used for Editor Services' logging. + public EditorServicesConfig( + HostInfo hostInfo, + PSHost psHost, + string sessionDetailsPath, + string bundledModulePath, + string logPath) + { + HostInfo = hostInfo; + PSHost = psHost; + SessionDetailsPath = sessionDetailsPath; + BundledModulePath = bundledModulePath; + LogPath = logPath; + } + + /// + /// The host description object. + /// + public HostInfo HostInfo { get; } + + /// + /// The PowerShell host used by Editor Services. + /// + public PSHost PSHost { get; } + + /// + /// The path to use for the session details file. + /// + public string SessionDetailsPath { get; } + + /// + /// The path to the modules bundled with EditorServices. + /// + public string BundledModulePath { get; } + + /// + /// The path to use for logging for Editor Services. + /// + public string LogPath { get; } + + /// + /// Names of or paths to any additional modules to load on startup. + /// + public IReadOnlyList AdditionalModules { get; set; } + + /// + /// Flags of features to enable on startup. + /// + public IReadOnlyList FeatureFlags { get; set; } + + /// + /// The console REPL experience to use in the Extension Terminal + /// (including none to disable the Extension Terminal). + /// + public ConsoleReplKind ConsoleRepl { get; set; } = ConsoleReplKind.None; + + /// + /// Will suppress messages to PSHost (to prevent Stdio clobbering) + /// + public bool UseNullPSHostUI { get; set; } + + /// + /// The minimum log level to log events with. Defaults to warning but is usually overriden by the startup process. + /// + public PsesLogLevel LogLevel { get; set; } = PsesLogLevel.Warning; + + /// + /// Configuration for the language server protocol transport to use. + /// + public ITransportConfig LanguageServiceTransport { get; set; } + + /// + /// Configuration for the debug adapter protocol transport to use. + /// + public ITransportConfig DebugServiceTransport { get; set; } + + /// + /// PowerShell profile locations for Editor Services to use for its profiles. + /// If none are provided, these will be generated from the hosting PowerShell's profile paths. + /// + public ProfilePathConfig ProfilePaths { get; set; } + + /// + /// The InitialSessionState to use when creating runspaces. LanguageMode can be set here. + /// + public InitialSessionState InitialSessionState { get; internal set; } + + public string StartupBanner { get; set; } = @" + + =====> PowerShell Editor Services <===== + +"; + } + + /// + /// Configuration for Editor Services' PowerShell profile paths. + /// + public struct ProfilePathConfig + { + /// + /// The path to the profile shared by all users across all PowerShell hosts. + /// + public string AllUsersAllHosts { get; set; } + + /// + /// The path to the profile shared by all users specific to this PSES host. + /// + public string AllUsersCurrentHost { get; set; } + + /// + /// The path to the profile specific to the current user across all hosts. + /// + public string CurrentUserAllHosts { get; set; } + + /// + /// The path to the profile specific to the current user and to this PSES host. + /// + public string CurrentUserCurrentHost { get; set; } + } +} diff --git a/src/PowerShellEditorServices.Hosting/Configuration/HostInfo.cs b/src/PowerShellEditorServices.Hosting/Configuration/HostInfo.cs new file mode 100644 index 0000000..f7af248 --- /dev/null +++ b/src/PowerShellEditorServices.Hosting/Configuration/HostInfo.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; + +namespace Microsoft.PowerShell.EditorServices.Hosting +{ + /// + /// A simple readonly object to describe basic host metadata. + /// + public class HostInfo + { + /// + /// Create a new host info object. + /// + /// The name of the host. + /// The profile ID of the host. + /// The version of the host. + public HostInfo(string name, string profileId, Version version) + { + Name = name; + ProfileId = profileId; + Version = version; + } + + /// + /// The name of the host. + /// + public string Name { get; } + + /// + /// The profile ID of the host. + /// + public string ProfileId { get; } + + /// + /// The version of the host. + /// + public Version Version { get; } + } +} diff --git a/src/PowerShellEditorServices.Hosting/Configuration/HostLogger.cs b/src/PowerShellEditorServices.Hosting/Configuration/HostLogger.cs new file mode 100644 index 0000000..1c86173 --- /dev/null +++ b/src/PowerShellEditorServices.Hosting/Configuration/HostLogger.cs @@ -0,0 +1,356 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Management.Automation.Host; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; + +namespace Microsoft.PowerShell.EditorServices.Hosting +{ + /// + /// Log Level for HostLogger. This is a direct copy of LogLevel from Microsoft.Extensions.Logging, and will map to + /// MEL.LogLevel once MEL is bootstrapped, but we don't want to load any MEL assemblies until the Assembly Load + /// Context is set up. + /// + public enum PsesLogLevel + { + /// + /// Logs that contain the most detailed messages. These messages may contain sensitive application data. + /// These messages are disabled by default and should never be enabled in a production environment. + /// + Trace = 0, + + /// + /// Logs that are used for interactive investigation during development. These logs should primarily contain + /// information useful for debugging and have no long-term value. + /// + Debug = 1, + + /// + /// Logs that track the general flow of the application. These logs should have long-term value. + /// + Information = 2, + + /// + /// Logs that highlight an abnormal or unexpected event in the application flow, but do not otherwise cause the + /// application execution to stop. + /// + Warning = 3, + + /// + /// Logs that highlight when the current flow of execution is stopped due to a failure. These should indicate a + /// failure in the current activity, not an application-wide failure. + /// + Error = 4, + + /// + /// Logs that describe an unrecoverable application or system crash, or a catastrophic failure that requires + /// immediate attention. + /// + Critical = 5, + + /// + /// Not used for writing log messages. Specifies that a logging category should not write any messages. + /// + None = 6, + } + + /// + /// A logging front-end for host startup allowing handover to the backend and decoupling from + /// the host's particular logging sink. + /// + /// + /// This custom logger exists to allow us to log during startup, which is vital information for + /// debugging, but happens before we can load any logger library. This is because startup + /// happens in our isolated assembly environment. See #2292 for more information. + /// + public sealed class HostLogger : + IObservable<(PsesLogLevel logLevel, string message)>, + IObservable<(int logLevel, string message)> + { + /// + /// A simple translation struct to convert PsesLogLevel to an int for backend passthrough. + /// + private class LogObserver : IObserver<(PsesLogLevel logLevel, string message)> + { + private readonly IObserver<(int logLevel, string message)> _observer; + + public LogObserver(IObserver<(int logLevel, string message)> observer) => _observer = observer; + + public void OnCompleted() => _observer.OnCompleted(); + + public void OnError(Exception error) => _observer.OnError(error); + + public void OnNext((PsesLogLevel logLevel, string message) value) => _observer.OnNext(((int)value.logLevel, value.message)); + } + + /// + /// Simple unsubscriber that allows subscribers to remove themselves from the observer list later. + /// + private class Unsubscriber : IDisposable + { + private readonly ConcurrentDictionary, bool> _subscribedObservers; + + private readonly IObserver<(PsesLogLevel, string)> _thisSubscriber; + + public Unsubscriber(ConcurrentDictionary, bool> subscribedObservers, IObserver<(PsesLogLevel, string)> thisSubscriber) + { + _subscribedObservers = subscribedObservers; + _thisSubscriber = thisSubscriber; + } + + public void Dispose() => _subscribedObservers.TryRemove(_thisSubscriber, out bool _); + } + + private readonly PsesLogLevel _minimumLogLevel; + + private readonly ConcurrentQueue<(PsesLogLevel logLevel, string message)> _logMessages; + + // The bool value here is meaningless and ignored, + // the ConcurrentDictionary just provides a way to efficiently keep track of subscribers across threads + private readonly ConcurrentDictionary, bool> _observers; + + /// + /// Construct a new logger in the host. + /// + /// The minimum log level to log. + public HostLogger(PsesLogLevel minimumLogLevel) + { + _minimumLogLevel = minimumLogLevel; + _logMessages = new ConcurrentQueue<(PsesLogLevel logLevel, string message)>(); + _observers = new ConcurrentDictionary, bool>(); + } + + /// + /// Subscribe a new log sink. + /// + /// The log sink to subscribe. + /// A disposable unsubscribe object. + public IDisposable Subscribe(IObserver<(PsesLogLevel logLevel, string message)> observer) + { + if (observer == null) + { + throw new ArgumentNullException(nameof(observer)); + } + + _observers[observer] = true; + + // Catch up a late subscriber to messages already logged + foreach ((PsesLogLevel logLevel, string message) entry in _logMessages) + { + observer.OnNext(entry); + } + + return new Unsubscriber(_observers, observer); + } + + /// + /// Subscribe a new log sink. + /// + /// The log sink to subscribe. + /// A disposable unsubscribe object. + public IDisposable Subscribe(IObserver<(int logLevel, string message)> observer) + { + if (observer == null) + { + throw new ArgumentNullException(nameof(observer)); + } + + return Subscribe(new LogObserver(observer)); + } + + /// + /// Log a message to log sinks. + /// + /// The log severity level of message to log. + /// The message to log. + public void Log(PsesLogLevel logLevel, string message) + { + // Do nothing if the severity is lower than the minimum + if (logLevel < _minimumLogLevel) + { + return; + } + + // Remember this for later subscriptions + _logMessages.Enqueue((logLevel, message)); + + // Send this log to all observers + foreach (IObserver<(PsesLogLevel logLevel, string message)> observer in _observers.Keys) + { + observer.OnNext((logLevel, message)); + } + } + + /// + /// Convenience method for logging exceptions. + /// + /// The human-directed message to accompany the exception. + /// The actual exception to log. + /// The name of the calling method. + /// The name of the file where this is logged. + /// The line in the file where this is logged. + public void LogException( + string message, + Exception exception, + [CallerMemberName] string callerName = null, + [CallerFilePath] string callerSourceFile = null, + [CallerLineNumber] int callerLineNumber = -1) => Log(PsesLogLevel.Error, $"{message}. Exception logged in {callerSourceFile} on line {callerLineNumber} in {callerName}:\n{exception}"); + } + + /// + /// A log sink to direct log messages back to the PowerShell host. + /// + /// + /// Note that calling this through the cmdlet causes an error, + /// so instead we log directly to the host. + /// Since it's likely that the process will end when PSES shuts down, + /// there's no good reason to need objects rather than writing directly to the host. + /// + /// The PowerShell host user interface object to log output to. + internal class PSHostLogger(PSHostUserInterface ui) : IObserver<(PsesLogLevel logLevel, string message)> + { + public void OnCompleted() + { + // No-op since there's nothing to close or dispose, + // we just stop writing to the host + } + + public void OnError(Exception error) => OnNext((PsesLogLevel.Error, $"Error occurred while logging: {error}")); + + public void OnNext((PsesLogLevel logLevel, string message) value) + { + (PsesLogLevel logLevel, string message) = value; + switch (logLevel) + { + case PsesLogLevel.Trace: + ui.WriteDebugLine("[Trace] " + message); + break; + case PsesLogLevel.Debug: + ui.WriteDebugLine(message); + break; + case PsesLogLevel.Information: + ui.WriteVerboseLine(message); + break; + case PsesLogLevel.Warning: + ui.WriteWarningLine(message); + break; + case PsesLogLevel.Error: + case PsesLogLevel.Critical: + ui.WriteErrorLine(message); + break; + default: + ui.WriteDebugLine("UNKNOWN:" + message); + break; + } + } + } + + /// + /// A simple log sink that logs to a stream, typically used to log to a file. + /// + internal class StreamLogger : IObserver<(PsesLogLevel logLevel, string message)>, IDisposable + { + public static StreamLogger CreateWithNewFile(string path) + { + FileStream fileStream = new( + path, + FileMode.Create, + FileAccess.Write, + FileShare.Read, + bufferSize: 4096, + FileOptions.SequentialScan); + + return new StreamLogger(new StreamWriter(fileStream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true))); + } + + private readonly StreamWriter _fileWriter; + + private readonly BlockingCollection _messageQueue; + + private readonly CancellationTokenSource _cancellationSource; + + private readonly Thread _writerThread; + + // This cannot be a bool + // See https://stackoverflow.com/q/6164751 + private int _hasCompleted; + + private IDisposable _unsubscriber; + + public StreamLogger(StreamWriter streamWriter) + { + streamWriter.AutoFlush = true; + _fileWriter = streamWriter; + _hasCompleted = 0; + _cancellationSource = new CancellationTokenSource(); + _messageQueue = new BlockingCollection(); + + // Start writer listening to queue + _writerThread = new Thread(RunWriter) + { + Name = "PSES Stream Logger Thread", + }; + _writerThread.Start(); + } + + public void OnCompleted() + { + // Ensure we only complete once + if (Interlocked.Exchange(ref _hasCompleted, 1) != 0) + { + return; + } + + _cancellationSource.Cancel(); + _writerThread.Join(); + _unsubscriber.Dispose(); + _fileWriter.Flush(); + _fileWriter.Close(); + _fileWriter.Dispose(); + _cancellationSource.Dispose(); + _messageQueue.Dispose(); + } + + public void OnError(Exception error) => OnNext((PsesLogLevel.Error, $"Error occurred while logging: {error}")); + + public void OnNext((PsesLogLevel logLevel, string message) value) + { + string message = value.logLevel switch + { + // String interpolation often considered a logging sin is OK here because our filtering happens before. + PsesLogLevel.Trace => $"[TRC]: {value.message}", + PsesLogLevel.Debug => $"[DBG]: {value.message}", + PsesLogLevel.Information => $"[INF]: {value.message}", + PsesLogLevel.Warning => $"[WRN]: {value.message}", + PsesLogLevel.Error => $"[ERR]: {value.message}", + PsesLogLevel.Critical => $"[CRT]: {value.message}", + _ => value.message, + }; + + _messageQueue.Add(message); + } + + public void AddUnsubscriber(IDisposable unsubscriber) => _unsubscriber = unsubscriber; + + public void Dispose() => OnCompleted(); + + private void RunWriter() + { + try + { + foreach (string logMessage in _messageQueue.GetConsumingEnumerable(_cancellationSource.Token)) + { + _fileWriter.WriteLine(logMessage); + } + } + catch (OperationCanceledException) + { + } + } + } +} diff --git a/src/PowerShellEditorServices.Hosting/Configuration/SessionFileWriter.cs b/src/PowerShellEditorServices.Hosting/Configuration/SessionFileWriter.cs new file mode 100644 index 0000000..bca1acd --- /dev/null +++ b/src/PowerShellEditorServices.Hosting/Configuration/SessionFileWriter.cs @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Management.Automation; +using System.Text; +using SMA = System.Management.Automation; + +namespace Microsoft.PowerShell.EditorServices.Hosting +{ + /// + /// Writes the session file when the server is ready for a connection, + /// so that the client can connect. + /// + public interface ISessionFileWriter + { + /// + /// Write a session file describing a failed startup. + /// + /// The reason for the startup failure. + void WriteSessionFailure(string reason); + + /// + /// Write a session file describing a successful startup. + /// + /// The transport configuration for the LSP service. + /// The transport configuration for the debug adapter service. + void WriteSessionStarted(ITransportConfig languageServiceTransport, ITransportConfig debugAdapterTransport); + } + + /// + /// The default session file writer, which uses PowerShell to write a session file. + /// + public sealed class SessionFileWriter : ISessionFileWriter + { + // Use BOM-less UTF-8 for session file + private static readonly Encoding s_sessionFileEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + + private readonly HostLogger _logger; + + private readonly string _sessionFilePath; + + private readonly Version _powerShellVersion; + + /// + /// Construct a new session file writer for the given session file path. + /// + /// The logger to log actions with. + /// The path to write the session file path to. + /// The process's PowerShell version object. + public SessionFileWriter(HostLogger logger, string sessionFilePath, Version powerShellVersion) + { + _logger = logger; + _sessionFilePath = sessionFilePath; + _powerShellVersion = powerShellVersion; + } + + /// + /// Write a startup failure to the session file. + /// + /// The reason for the startup failure. + public void WriteSessionFailure(string reason) + { + _logger.Log(PsesLogLevel.Trace, "Writing session failure"); + + Dictionary sessionObject = new() + { + { "status", "failed" }, + { "reason", reason }, + }; + + WriteSessionObject(sessionObject); + } + + /// + /// Write a successful server startup to the session file. + /// + /// The LSP service transport configuration. + /// The debug adapter transport configuration. + public void WriteSessionStarted(ITransportConfig languageServiceTransport, ITransportConfig debugAdapterTransport) + { + _logger.Log(PsesLogLevel.Trace, "Writing session started"); + + Dictionary sessionObject = new() + { + { "status", "started" }, + }; + + if (languageServiceTransport is not null) + { + sessionObject["languageServiceTransport"] = languageServiceTransport.SessionFileTransportName; + + if (languageServiceTransport.SessionFileEntries is not null) + { + foreach (KeyValuePair sessionEntry in languageServiceTransport.SessionFileEntries) + { + sessionObject[$"languageService{sessionEntry.Key}"] = sessionEntry.Value; + } + } + } + + if (debugAdapterTransport is not null) + { + sessionObject["debugServiceTransport"] = debugAdapterTransport.SessionFileTransportName; + + if (debugAdapterTransport.SessionFileEntries != null) + { + foreach (KeyValuePair sessionEntry in debugAdapterTransport.SessionFileEntries) + { + sessionObject[$"debugService{sessionEntry.Key}"] = sessionEntry.Value; + } + } + } + + WriteSessionObject(sessionObject); + } + + /// + /// Write the object representing the session file to the file by serializing it as JSON. + /// + /// The dictionary representing the session file. + private void WriteSessionObject(Dictionary sessionObject) + { + sessionObject["powerShellVersion"] = _powerShellVersion.ToString(); + + string psModulePath = Environment.GetEnvironmentVariable("PSModulePath"); + string content = null; + using (SMA.PowerShell pwsh = SMA.PowerShell.Create(RunspaceMode.NewRunspace)) + { + content = pwsh.AddCommand("ConvertTo-Json") + .AddParameter("InputObject", sessionObject) + .AddParameter("Depth", 10) + .AddParameter("Compress") + .Invoke()[0]; + + // Runspace creation has a bug where it resets the PSModulePath, + // which we must correct for + Environment.SetEnvironmentVariable("PSModulePath", psModulePath); + + File.WriteAllText(_sessionFilePath, content, s_sessionFileEncoding); + } + + _logger.Log(PsesLogLevel.Debug, $"Session file written to {_sessionFilePath} with content:\n{content}"); + } + } +} diff --git a/src/PowerShellEditorServices.Hosting/Configuration/TransportConfig.cs b/src/PowerShellEditorServices.Hosting/Configuration/TransportConfig.cs new file mode 100644 index 0000000..db64a27 --- /dev/null +++ b/src/PowerShellEditorServices.Hosting/Configuration/TransportConfig.cs @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Pipes; +using System.Threading.Tasks; + +namespace Microsoft.PowerShell.EditorServices.Hosting +{ + /// + /// Configuration specifying an editor services protocol transport stream configuration. + /// + public interface ITransportConfig + { + /// + /// Create, connect and return the configured transport streams. + /// + /// The connected transport streams. inStream and outStream may be the same stream for duplex streams. + Task<(Stream inStream, Stream outStream)> ConnectStreamsAsync(); + + /// + /// The name of the transport endpoint for logging. + /// + string EndpointDetails { get; } + + /// + /// The name of the transport to record in the session file. + /// + string SessionFileTransportName { get; } + + /// + /// Extra entries to record in the session file. + /// + IReadOnlyDictionary SessionFileEntries { get; } + } + + /// + /// Configuration for the standard input/output transport. + /// + public sealed class StdioTransportConfig : ITransportConfig + { + private readonly HostLogger _logger; + + public StdioTransportConfig(HostLogger logger) => _logger = logger; + + public string EndpointDetails => ""; + + public string SessionFileTransportName => "Stdio"; + + public IReadOnlyDictionary SessionFileEntries { get; } + + public Task<(Stream inStream, Stream outStream)> ConnectStreamsAsync() + { + _logger.Log(PsesLogLevel.Trace, "Connecting stdio streams"); + return Task.FromResult((Console.OpenStandardInput(), Console.OpenStandardOutput())); + } + } + + /// + /// Configuration for a full duplex named pipe. + /// + public sealed class DuplexNamedPipeTransportConfig : ITransportConfig + { + /// + /// Create a duplex named pipe transport config with an automatically generated pipe name. + /// + /// A new duplex named pipe transport configuration. + public static DuplexNamedPipeTransportConfig Create(HostLogger logger) => new(logger, NamedPipeUtils.GenerateValidNamedPipeName()); + + /// + /// Create a duplex named pipe transport config with the given pipe name. + /// + /// A new duplex named pipe transport configuration. + public static DuplexNamedPipeTransportConfig Create(HostLogger logger, string pipeName) + { + if (pipeName == null) + { + return DuplexNamedPipeTransportConfig.Create(logger); + } + + return new DuplexNamedPipeTransportConfig(logger, pipeName); + } + + private readonly HostLogger _logger; + + private readonly string _pipeName; + + private DuplexNamedPipeTransportConfig(HostLogger logger, string pipeName) + { + _logger = logger; + _pipeName = pipeName; + SessionFileEntries = new Dictionary { { "PipeName", NamedPipeUtils.GetNamedPipePath(pipeName) } }; + } + + public string EndpointDetails => $"InOut pipe: {_pipeName}"; + + public string SessionFileTransportName => "NamedPipe"; + + public IReadOnlyDictionary SessionFileEntries { get; } + + public async Task<(Stream inStream, Stream outStream)> ConnectStreamsAsync() + { + _logger.Log(PsesLogLevel.Trace, "Creating named pipe"); + NamedPipeServerStream namedPipe = NamedPipeUtils.CreateNamedPipe(_pipeName, PipeDirection.InOut); + _logger.Log(PsesLogLevel.Trace, "Waiting for named pipe connection"); + await namedPipe.WaitForConnectionAsync().ConfigureAwait(false); + _logger.Log(PsesLogLevel.Trace, "Named pipe connected"); + return (namedPipe, namedPipe); + } + } + + /// + /// Configuration for two simplex named pipes. + /// + public sealed class SimplexNamedPipeTransportConfig : ITransportConfig + { + private const string InPipePrefix = "in"; + private const string OutPipePrefix = "out"; + + /// + /// Create a pair of simplex named pipes using generated names. + /// + /// A new simplex named pipe transport config. + public static SimplexNamedPipeTransportConfig Create(HostLogger logger) => SimplexNamedPipeTransportConfig.Create(logger, NamedPipeUtils.GenerateValidNamedPipeName(new[] { InPipePrefix, OutPipePrefix })); + + /// + /// Create a pair of simplex named pipes using the given name as a base. + /// + /// A new simplex named pipe transport config. + public static SimplexNamedPipeTransportConfig Create(HostLogger logger, string pipeNameBase) + { + if (pipeNameBase == null) + { + return SimplexNamedPipeTransportConfig.Create(logger); + } + + string inPipeName = $"{InPipePrefix}_{pipeNameBase}"; + string outPipeName = $"{OutPipePrefix}_{pipeNameBase}"; + + return SimplexNamedPipeTransportConfig.Create(logger, inPipeName, outPipeName); + } + + /// + /// Create a pair of simplex named pipes using the given names. + /// + /// A new simplex named pipe transport config. + public static SimplexNamedPipeTransportConfig Create(HostLogger logger, string inPipeName, string outPipeName) => new(logger, inPipeName, outPipeName); + + private readonly HostLogger _logger; + private readonly string _inPipeName; + private readonly string _outPipeName; + + private SimplexNamedPipeTransportConfig(HostLogger logger, string inPipeName, string outPipeName) + { + _logger = logger; + _inPipeName = inPipeName; + _outPipeName = outPipeName; + + SessionFileEntries = new Dictionary + { + { "ReadPipeName", NamedPipeUtils.GetNamedPipePath(inPipeName) }, + { "WritePipeName", NamedPipeUtils.GetNamedPipePath(outPipeName) }, + }; + } + + public string EndpointDetails => $"In pipe: {_inPipeName} Out pipe: {_outPipeName}"; + + public string SessionFileTransportName => "NamedPipeSimplex"; + + public IReadOnlyDictionary SessionFileEntries { get; } + + public async Task<(Stream inStream, Stream outStream)> ConnectStreamsAsync() + { + _logger.Log(PsesLogLevel.Trace, "Starting in pipe connection"); + NamedPipeServerStream inPipe = NamedPipeUtils.CreateNamedPipe(_inPipeName, PipeDirection.InOut); + Task inPipeConnected = inPipe.WaitForConnectionAsync(); + + _logger.Log(PsesLogLevel.Trace, "Starting out pipe connection"); + NamedPipeServerStream outPipe = NamedPipeUtils.CreateNamedPipe(_outPipeName, PipeDirection.Out); + Task outPipeConnected = outPipe.WaitForConnectionAsync(); + + _logger.Log(PsesLogLevel.Trace, "Wating for pipe connections"); + await Task.WhenAll(inPipeConnected, outPipeConnected).ConfigureAwait(false); + + _logger.Log(PsesLogLevel.Trace, "Simplex named pipe transport connected"); + return (inPipe, outPipe); + } + } +} diff --git a/src/PowerShellEditorServices.Hosting/EditorServicesLoader.cs b/src/PowerShellEditorServices.Hosting/EditorServicesLoader.cs new file mode 100644 index 0000000..eea2335 --- /dev/null +++ b/src/PowerShellEditorServices.Hosting/EditorServicesLoader.cs @@ -0,0 +1,399 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Reflection; +using System.Threading.Tasks; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +using SMA = System.Management.Automation; +using System.Management.Automation; +using System.Management.Automation.Runspaces; + +#if ASSEMBLY_LOAD_STACKTRACE +using System.Diagnostics; +#endif + +#if CoreCLR +using System.Runtime.Loader; +#else +using Microsoft.Win32; +#endif + +namespace Microsoft.PowerShell.EditorServices.Hosting +{ + /// + /// Class to contain the loading behavior of Editor Services. + /// In particular, this class wraps the point where Editor Services is safely loaded + /// in a way that separates its dependencies from the calling context. + /// + public sealed class EditorServicesLoader : IDisposable + { +#if !CoreCLR + // TODO: Well, we're saying we need 4.8 here but we're building for 4.6.2... + // See https://docs.microsoft.com/en-us/dotnet/framework/migration-guide/how-to-determine-which-versions-are-installed + private const int Net48Version = 528040; + + private static readonly string s_psesBaseDirPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); +#endif + + private static readonly string s_psesDependencyDirPath = Path.GetFullPath( + Path.Combine( + Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), + "..", + "Common")); + + /// + /// Create a new Editor Services loader. + /// + /// The host logger to use. + /// The host configuration to start editor services with. + /// Path to the session file to create on startup or startup failure. + /// The loggers to unsubscribe form writing to the terminal. + public static EditorServicesLoader Create( + HostLogger logger, + EditorServicesConfig hostConfig, + string sessionDetailsPath, + IReadOnlyCollection loggersToUnsubscribe) + { + if (logger is null) + { + throw new ArgumentNullException(nameof(logger)); + } + + if (hostConfig is null) + { + throw new ArgumentNullException(nameof(hostConfig)); + } + + Version powerShellVersion = GetPSVersion(); + SessionFileWriter sessionFileWriter = new(logger, sessionDetailsPath, powerShellVersion); + logger.Log(PsesLogLevel.Trace, "Session file writer created"); + +#if CoreCLR + // In .NET Core, we add an event here to redirect dependency loading to the new AssemblyLoadContext we load PSES' dependencies into + logger.Log(PsesLogLevel.Debug, "Adding AssemblyResolve event handler for new AssemblyLoadContext dependency loading"); + + PsesLoadContext psesLoadContext = new(s_psesDependencyDirPath); + + if (hostConfig.LogLevel == PsesLogLevel.Trace) + { + AppDomain.CurrentDomain.AssemblyLoad += (object sender, AssemblyLoadEventArgs args) => + { + logger.Log( + PsesLogLevel.Trace, + $"Loaded into load context {AssemblyLoadContext.GetLoadContext(args.LoadedAssembly)}: {args.LoadedAssembly}"); + }; + } + + AssemblyLoadContext.Default.Resolving += (AssemblyLoadContext _, AssemblyName asmName) => + { +#if ASSEMBLY_LOAD_STACKTRACE + logger.Log(PsesLogLevel.Trace, $"Assembly resolve event fired for {asmName}. Stacktrace:\n{new StackTrace()}"); +#else + logger.Log(PsesLogLevel.Trace, $"Assembly resolve event fired for {asmName}"); +#endif + + // We only want the Editor Services DLL; the new ALC will lazily load its dependencies automatically + if (!string.Equals(asmName.Name, "Microsoft.PowerShell.EditorServices", StringComparison.Ordinal)) + { + return null; + } + + string asmPath = Path.Combine(s_psesDependencyDirPath, $"{asmName.Name}.dll"); + + logger.Log(PsesLogLevel.Debug, "Loading PSES DLL using new assembly load context"); + + return psesLoadContext.LoadFromAssemblyPath(asmPath); + }; +#else + // In .NET Framework we add an event here to redirect dependency loading in the current AppDomain for PSES' dependencies + logger.Log(PsesLogLevel.Debug, "Adding AssemblyResolve event handler for dependency loading"); + + if (hostConfig.LogLevel == PsesLogLevel.Trace) + { + AppDomain.CurrentDomain.AssemblyLoad += (object sender, AssemblyLoadEventArgs args) => + { + if (args.LoadedAssembly.IsDynamic) + { + return; + } + + logger.Log( + PsesLogLevel.Trace, + $"Loaded '{args.LoadedAssembly.GetName()}' from '{args.LoadedAssembly.Location}'"); + }; + } + + // Unlike in .NET Core, we need to be look for all dependencies in .NET Framework, not just PSES.dll + AppDomain.CurrentDomain.AssemblyResolve += (object sender, ResolveEventArgs args) => + { +#if ASSEMBLY_LOAD_STACKTRACE + logger.Log(PsesLogLevel.Trace, $"Assembly resolve event fired for {args.Name}. Stacktrace:\n{new StackTrace()}"); +#else + logger.Log(PsesLogLevel.Trace, $"Assembly resolve event fired for {args.Name}"); +#endif + + AssemblyName asmName = new(args.Name); + string dllName = $"{asmName.Name}.dll"; + + // First look for the required assembly in the .NET Framework DLL dir + string baseDirAsmPath = Path.Combine(s_psesBaseDirPath, dllName); + if (File.Exists(baseDirAsmPath)) + { + logger.Log(PsesLogLevel.Trace, $"Loading {args.Name} from PSES base dir into LoadFile context"); + return Assembly.LoadFile(baseDirAsmPath); + } + + // Then look in the shared .NET Standard directory + string asmPath = Path.Combine(s_psesDependencyDirPath, dllName); + if (File.Exists(asmPath)) + { + logger.Log(PsesLogLevel.Trace, $"Loading {args.Name} from PSES dependency dir into LoadFile context"); + return Assembly.LoadFile(asmPath); + } + + return null; + }; +#endif + + return new EditorServicesLoader(logger, hostConfig, sessionFileWriter, loggersToUnsubscribe, powerShellVersion); + } + + private readonly EditorServicesConfig _hostConfig; + + private readonly ISessionFileWriter _sessionFileWriter; + + private readonly HostLogger _logger; + + private readonly IReadOnlyCollection _loggersToUnsubscribe; + + private readonly Version _powerShellVersion; + + private EditorServicesRunner _editorServicesRunner; + + private EditorServicesLoader( + HostLogger logger, + EditorServicesConfig hostConfig, + ISessionFileWriter sessionFileWriter, + IReadOnlyCollection loggersToUnsubscribe, + Version powerShellVersion) + { + _logger = logger; + _hostConfig = hostConfig; + _sessionFileWriter = sessionFileWriter; + _loggersToUnsubscribe = loggersToUnsubscribe; + _powerShellVersion = powerShellVersion; + } + + /// + /// Load Editor Services and its dependencies in an isolated way and start it. + /// This method's returned task will end when Editor Services shuts down. + /// + public Task LoadAndRunEditorServicesAsync() + { + // Log important host information here + LogHostInformation(); + + CheckPowerShellVersion(); + +#if !CoreCLR + // Make sure the .NET Framework version supports .NET Standard 2.0 + CheckDotNetVersion(); +#endif + + // Add the bundled modules to the PSModulePath + // TODO: Why do we do this in addition to passing the bundled module path to the host? + UpdatePSModulePath(); + + // Check to see if the configuration we have is valid + ValidateConfiguration(); + + // Method with no implementation that forces the PSES assembly to load, triggering an AssemblyResolve event + _logger.Log(PsesLogLevel.Information, "Loading PowerShell Editor Services Assemblies"); + LoadEditorServices(); + + _logger.Log(PsesLogLevel.Information, "Starting PowerShell Editor Services"); + + _editorServicesRunner = new EditorServicesRunner(_logger, _hostConfig, _sessionFileWriter, _loggersToUnsubscribe); + + // The trigger method for Editor Services + return Task.Run(_editorServicesRunner.RunUntilShutdown); + } + + public void Dispose() + { + _logger.Log(PsesLogLevel.Trace, "Loader disposed"); + _editorServicesRunner?.Dispose(); + + // TODO: + // Remove assembly resolve events + // This is not high priority, since the PSES process shouldn't be reused + } + + private static void LoadEditorServices() => + // This must be in its own method, since the actual load happens when the calling method is called + // The call within this method is therefore a total no-op + EditorServicesLoading.LoadEditorServicesForHost(); + + private void CheckPowerShellVersion() + { + PSLanguageMode languageMode = Runspace.DefaultRunspace.SessionStateProxy.LanguageMode; + + _logger.Log(PsesLogLevel.Trace, $@" +== PowerShell Details == +- PowerShell version: {_powerShellVersion} +- Language mode: {languageMode} +"); + + if ((_powerShellVersion < new Version(5, 1)) + || (_powerShellVersion >= new Version(6, 0) && _powerShellVersion < new Version(7, 2))) + { + _logger.Log(PsesLogLevel.Error, $"PowerShell {_powerShellVersion} is not supported, please update!"); + _sessionFileWriter.WriteSessionFailure("powerShellVersion"); + } + + // TODO: Check if language mode still matters for support. + } + +#if !CoreCLR + private void CheckDotNetVersion() + { + _logger.Log(PsesLogLevel.Debug, "Checking that .NET Framework version is at least 4.8"); + using RegistryKey key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Net Framework Setup\NDP\v4\Full"); + object netFxValue = key?.GetValue("Release"); + if (netFxValue == null || netFxValue is not int netFxVersion) + { + return; + } + + _logger.Log(PsesLogLevel.Debug, $".NET registry version: {netFxVersion}"); + + if (netFxVersion < Net48Version) + { + _logger.Log(PsesLogLevel.Error, $".NET Framework {netFxVersion} is out-of-date, please install at least 4.8: https://dotnet.microsoft.com/en-us/download/dotnet-framework"); + _sessionFileWriter.WriteSessionFailure("dotNetVersion"); + } + } +#endif + + private void UpdatePSModulePath() + { + if (string.IsNullOrEmpty(_hostConfig.BundledModulePath)) + { + _logger.Log(PsesLogLevel.Trace, "BundledModulePath not set, skipping"); + return; + } + + string psModulePath = Environment.GetEnvironmentVariable("PSModulePath").TrimEnd(Path.PathSeparator); + if ($"{psModulePath}{Path.PathSeparator}".Contains($"{_hostConfig.BundledModulePath}{Path.PathSeparator}")) + { + _logger.Log(PsesLogLevel.Trace, "BundledModulePath already set, skipping"); + return; + } + psModulePath = $"{psModulePath}{Path.PathSeparator}{_hostConfig.BundledModulePath}"; + Environment.SetEnvironmentVariable("PSModulePath", psModulePath); + _logger.Log(PsesLogLevel.Trace, $"Updated PSModulePath to: '{psModulePath}'"); + } + + private void LogHostInformation() + { + _logger.Log(PsesLogLevel.Trace, $"PID: {System.Diagnostics.Process.GetCurrentProcess().Id}"); + + _logger.Log(PsesLogLevel.Debug, $@" +== Build Details == +- Editor Services version: {BuildInfo.BuildVersion} +- Build origin: {BuildInfo.BuildOrigin} +- Build commit: {BuildInfo.BuildCommit} +- Build time: {BuildInfo.BuildTime} +"); + + _logger.Log(PsesLogLevel.Debug, $@" +== Host Startup Configuration Details == + - Host name: {_hostConfig.HostInfo.Name} + - Host version: {_hostConfig.HostInfo.Version} + - Host profile ID: {_hostConfig.HostInfo.ProfileId} + - PowerShell host type: {_hostConfig.PSHost.GetType()} + + - REPL setting: {_hostConfig.ConsoleRepl} + - Session details path: {_hostConfig.SessionDetailsPath} + - Bundled modules path: {_hostConfig.BundledModulePath} + - Additional modules: {(_hostConfig.AdditionalModules == null ? "" : string.Join(", ", _hostConfig.AdditionalModules))} + - Feature flags: {(_hostConfig.FeatureFlags == null ? "" : string.Join(", ", _hostConfig.FeatureFlags))} + + - Log path: {_hostConfig.LogPath} + - Minimum log level: {_hostConfig.LogLevel} + + - Profile paths: + + AllUsersAllHosts: {_hostConfig.ProfilePaths.AllUsersAllHosts ?? ""} + + AllUsersCurrentHost: {_hostConfig.ProfilePaths.AllUsersCurrentHost ?? ""} + + CurrentUserAllHosts: {_hostConfig.ProfilePaths.CurrentUserAllHosts ?? ""} + + CurrentUserCurrentHost: {_hostConfig.ProfilePaths.CurrentUserCurrentHost ?? ""} +"); + + _logger.Log(PsesLogLevel.Debug, $@" +== Console Details == + - Console input encoding: {Console.InputEncoding.EncodingName} + - Console output encoding: {Console.OutputEncoding.EncodingName} + - PowerShell output encoding: {GetPSOutputEncoding()} +"); + + _logger.Log(PsesLogLevel.Debug, $@" +== Environment Details == + - OS description: {RuntimeInformation.OSDescription} + - OS architecture: {RuntimeInformation.OSArchitecture} + - Process bitness: {(Environment.Is64BitProcess ? "64" : "32")} +"); + } + + private static string GetPSOutputEncoding() + { + using SMA.PowerShell pwsh = SMA.PowerShell.Create(); + return pwsh.AddScript( + "[System.Diagnostics.DebuggerHidden()]param() $OutputEncoding.EncodingName", + useLocalScope: true).Invoke()[0]; + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2208:Instantiate argument exceptions correctly", Justification = "Checking user-defined configuration")] + private void ValidateConfiguration() + { + _logger.Log(PsesLogLevel.Debug, "Validating configuration"); + + bool lspUsesStdio = _hostConfig.LanguageServiceTransport is StdioTransportConfig; + bool debugUsesStdio = _hostConfig.DebugServiceTransport is StdioTransportConfig; + + // Ensure LSP and Debug are not both Stdio + if (lspUsesStdio && debugUsesStdio) + { + throw new ArgumentException("LSP and Debug transports cannot both use Stdio"); + } + + if (_hostConfig.ConsoleRepl != ConsoleReplKind.None + && (lspUsesStdio || debugUsesStdio)) + { + throw new ArgumentException("Cannot use the REPL with a Stdio protocol transport"); + } + + if (_hostConfig.PSHost == null) + { + throw new ArgumentNullException(nameof(_hostConfig.PSHost)); + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1825:Avoid zero-length array allocations", Justification = "Cannot use Array.Empty, since it must work in net452")] + private static Version GetPSVersion() + { + // In order to read the $PSVersionTable variable, + // we are forced to create a new runspace to avoid concurrency issues, + // which is expensive. + // Rather than do that, we instead go straight to the source, + // which is a static property, internal in WinPS and public in PS 6+ + return typeof(PSObject).Assembly + .GetType("System.Management.Automation.PSVersionInfo") + .GetMethod("get_PSVersion", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic) + .Invoke(null, new object[0]) as Version; + } + } +} diff --git a/src/PowerShellEditorServices.Hosting/GlobalSuppressions.cs b/src/PowerShellEditorServices.Hosting/GlobalSuppressions.cs new file mode 100644 index 0000000..3c0be15 --- /dev/null +++ b/src/PowerShellEditorServices.Hosting/GlobalSuppressions.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "PSES is not localized", Scope = "module")] diff --git a/src/PowerShellEditorServices.Hosting/Internal/EditorServicesRunner.cs b/src/PowerShellEditorServices.Hosting/Internal/EditorServicesRunner.cs new file mode 100644 index 0000000..1c9de26 --- /dev/null +++ b/src/PowerShellEditorServices.Hosting/Internal/EditorServicesRunner.cs @@ -0,0 +1,320 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Server; + +namespace Microsoft.PowerShell.EditorServices.Hosting +{ + /// + /// Class to manage the startup of PowerShell Editor Services. + /// + /// + /// This should be called by only after Editor Services has + /// been loaded. It relies on to indirectly load and . + /// + internal class EditorServicesRunner : IDisposable + { + private readonly HostLogger _logger; + + private readonly EditorServicesConfig _config; + + private readonly ISessionFileWriter _sessionFileWriter; + + private readonly EditorServicesServerFactory _serverFactory; + + private readonly IReadOnlyCollection _loggersToUnsubscribe; + + private bool _alreadySubscribedDebug; + + public EditorServicesRunner( + HostLogger logger, + EditorServicesConfig config, + ISessionFileWriter sessionFileWriter, + IReadOnlyCollection loggersToUnsubscribe) + { + _logger = logger; + _config = config; + _sessionFileWriter = sessionFileWriter; + // NOTE: This factory helps to isolate `Microsoft.Extensions.Logging/DependencyInjection`. + _serverFactory = new(logger); + _alreadySubscribedDebug = false; + _loggersToUnsubscribe = loggersToUnsubscribe; + } + + /// + /// Start and run Editor Services and then wait for shutdown. + /// + /// + /// TODO: Use "Async" suffix in names of methods that return an awaitable type. + /// + /// A task that ends when Editor Services shuts down. + public Task RunUntilShutdown() + { + // Start Editor Services (see function below) + Task runAndAwaitShutdown = CreateEditorServicesAndRunUntilShutdown(); + + // Now write the session file + _logger.Log(PsesLogLevel.Trace, "Writing session file"); + _sessionFileWriter.WriteSessionStarted(_config.LanguageServiceTransport, _config.DebugServiceTransport); + + // Finally, wait for Editor Services to shut down + _logger.Log(PsesLogLevel.Debug, "Waiting on PSES run/shutdown"); + return runAndAwaitShutdown; + } + + /// + /// TODO: This class probably should not be as the primary + /// intention of that interface is to provide cleanup of unmanaged resources, which the + /// logger certainly is not. Nor is this class used with a . It is + /// only because of the use of that this class is also + /// disposable, and instead that class should be fixed. + /// + public void Dispose() => _serverFactory.Dispose(); + + /// + /// This is the servers' entry point, e.g. main, as it instantiates, runs and waits + /// for the LSP and debug servers at the heart of Editor Services. Uses . + /// + /// + /// The logical stack of the program is: + /// + /// + /// Symbol + /// Description + /// + /// + /// + /// + /// The StartEditorServicesCommand PSCmdlet, our PowerShell cmdlet written in C# and + /// shipped in the module. + /// + /// + /// + /// + /// + /// As a cmdlet, this is the end of its "process" block, and it instantiates . + /// + /// + /// + /// + /// + /// Loads isolated dependencies then runs and returns the next task. + /// + /// + /// + /// + /// Task which opens a logfile then returns this task. + /// + /// + /// + /// This task! + /// + /// + /// + /// A task that ends when Editor Services shuts down. + private async Task CreateEditorServicesAndRunUntilShutdown() + { + try + { + _logger.Log(PsesLogLevel.Debug, "Creating/running editor services"); + + bool creatingLanguageServer = _config.LanguageServiceTransport != null; + bool creatingDebugServer = _config.DebugServiceTransport != null; + bool isTempDebugSession = creatingDebugServer && !creatingLanguageServer; + + // Set up information required to instantiate servers + HostStartupInfo hostStartupInfo = CreateHostStartupInfo(); + + // If we just want a temp debug session, run that and do nothing else + if (isTempDebugSession) + { + await RunTempDebugSessionAsync(hostStartupInfo).ConfigureAwait(false); + return; + } + + _logger.Log(PsesLogLevel.Information, "PSES Startup Completed. Starting Language Server."); + _logger.Log(PsesLogLevel.Information, "Please check the LSP log file in your client for further messages. In VSCode, this is the 'PowerShell' output pane"); + + // We want LSP and maybe debugging + // To do that we: + // - Create the LSP server + // - Possibly kick off the debug server creation + // - Start the LSP server + // - Possibly start the debug server + // - Wait for the LSP server to finish + + // Unsubscribe the host logger here so that the Extension Terminal is not polluted with input after the first prompt + if (_loggersToUnsubscribe != null) + { + foreach (IDisposable loggerToUnsubscribe in _loggersToUnsubscribe) + { + loggerToUnsubscribe.Dispose(); + } + } + + WriteStartupBanner(); + + PsesLanguageServer languageServer = await CreateLanguageServerAsync(hostStartupInfo).ConfigureAwait(false); + + Task debugServerCreation = null; + if (creatingDebugServer) + { + debugServerCreation = CreateDebugServerWithLanguageServerAsync(languageServer); + } + + Task languageServerStart = languageServer.StartAsync(); + + Task debugServerStart = null; + if (creatingDebugServer) + { + // We don't need to wait for this to start, since we instead wait for it to complete later + debugServerStart = StartDebugServer(debugServerCreation); + } + + await languageServerStart.ConfigureAwait(false); + if (debugServerStart != null) + { + await debugServerStart.ConfigureAwait(false); + } + await languageServer.WaitForShutdown().ConfigureAwait(false); + } + finally + { + // Resubscribe host logger to log shutdown events to the console + _logger.Subscribe(new PSHostLogger(_config.PSHost.UI)); + } + } + + private async Task RunTempDebugSessionAsync(HostStartupInfo hostDetails) + { + _logger.Log(PsesLogLevel.Information, "Starting temporary debug session"); + PsesDebugServer debugServer = await CreateDebugServerForTempSessionAsync(hostDetails).ConfigureAwait(false); + _logger.Log(PsesLogLevel.Debug, "Debug server created"); + await debugServer.StartAsync().ConfigureAwait(false); + _logger.Log(PsesLogLevel.Debug, "Debug server started"); + await debugServer.WaitForShutdown().ConfigureAwait(false); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "It's a wrapper.")] + private async Task StartDebugServer(Task debugServerCreation) + { + PsesDebugServer debugServer = await debugServerCreation.ConfigureAwait(false); + + // When the debug server shuts down, we want it to automatically restart + // To do this, we set an event to allow it to create a new debug server as its session ends + if (!_alreadySubscribedDebug) + { + _logger.Log(PsesLogLevel.Trace, "Subscribing debug server for session ended event"); + _alreadySubscribedDebug = true; + debugServer.SessionEnded += DebugServer_OnSessionEnded; + } + + _logger.Log(PsesLogLevel.Trace, "Starting debug server"); + + await debugServer.StartAsync().ConfigureAwait(false); + } + + private Task RestartDebugServerAsync(PsesDebugServer debugServer) + { + _logger.Log(PsesLogLevel.Debug, "Restarting debug server"); + Task debugServerCreation = RecreateDebugServerAsync(debugServer); + return StartDebugServer(debugServerCreation); + } + + private async Task CreateLanguageServerAsync(HostStartupInfo hostDetails) + { + _logger.Log(PsesLogLevel.Trace, $"Creating LSP transport with endpoint {_config.LanguageServiceTransport.EndpointDetails}"); + (Stream inStream, Stream outStream) = await _config.LanguageServiceTransport.ConnectStreamsAsync().ConfigureAwait(false); + + _logger.Log(PsesLogLevel.Debug, "Creating language server"); + return _serverFactory.CreateLanguageServer(inStream, outStream, hostDetails); + } + + private async Task CreateDebugServerWithLanguageServerAsync(PsesLanguageServer languageServer) + { + _logger.Log(PsesLogLevel.Trace, $"Creating debug adapter transport with endpoint {_config.DebugServiceTransport.EndpointDetails}"); + (Stream inStream, Stream outStream) = await _config.DebugServiceTransport.ConnectStreamsAsync().ConfigureAwait(false); + + _logger.Log(PsesLogLevel.Debug, "Creating debug adapter"); + return _serverFactory.CreateDebugServerWithLanguageServer(inStream, outStream, languageServer); + } + + private async Task RecreateDebugServerAsync(PsesDebugServer debugServer) + { + _logger.Log(PsesLogLevel.Debug, "Recreating debug adapter transport"); + (Stream inStream, Stream outStream) = await _config.DebugServiceTransport.ConnectStreamsAsync().ConfigureAwait(false); + + _logger.Log(PsesLogLevel.Debug, "Recreating debug adapter"); + return _serverFactory.RecreateDebugServer(inStream, outStream, debugServer); + } + + private async Task CreateDebugServerForTempSessionAsync(HostStartupInfo hostDetails) + { + (Stream inStream, Stream outStream) = await _config.DebugServiceTransport.ConnectStreamsAsync().ConfigureAwait(false); + + return _serverFactory.CreateDebugServerForTempSession(inStream, outStream, hostDetails); + } + + private HostStartupInfo CreateHostStartupInfo() + { + _logger.Log(PsesLogLevel.Debug, "Creating startup info object"); + + ProfilePathInfo profilePaths = null; + if (_config.ProfilePaths.AllUsersAllHosts != null + || _config.ProfilePaths.AllUsersCurrentHost != null + || _config.ProfilePaths.CurrentUserAllHosts != null + || _config.ProfilePaths.CurrentUserCurrentHost != null) + { + profilePaths = new ProfilePathInfo( + _config.ProfilePaths.CurrentUserAllHosts, + _config.ProfilePaths.CurrentUserCurrentHost, + _config.ProfilePaths.AllUsersAllHosts, + _config.ProfilePaths.AllUsersCurrentHost); + } + + return new HostStartupInfo( + _config.HostInfo.Name, + _config.HostInfo.ProfileId, + _config.HostInfo.Version, + _config.PSHost, + profilePaths, + _config.FeatureFlags, + _config.AdditionalModules, + _config.InitialSessionState, + _config.LogPath, + (int)_config.LogLevel, //This maps to MEL log levels, we use int so this is easily supplied externally. + consoleReplEnabled: _config.ConsoleRepl != ConsoleReplKind.None, + useNullPSHostUI: _config.UseNullPSHostUI, + usesLegacyReadLine: _config.ConsoleRepl == ConsoleReplKind.LegacyReadLine, + bundledModulePath: _config.BundledModulePath); + } + + private void WriteStartupBanner() + { + if (_config.ConsoleRepl == ConsoleReplKind.None) + { + return; + } + + _config.PSHost.UI.WriteLine(_config.StartupBanner); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD110:Observe result of async calls", Justification = "Intentionally fire and forget.")] + private void DebugServer_OnSessionEnded(object sender, EventArgs args) + { + _logger.Log(PsesLogLevel.Debug, "Debug session ended, restarting debug service..."); + PsesDebugServer oldServer = (PsesDebugServer)sender; + oldServer.Dispose(); + _alreadySubscribedDebug = false; + Task.Run(() => RestartDebugServerAsync(oldServer)); + } + } +} diff --git a/src/PowerShellEditorServices.Hosting/Internal/NamedPipeUtils.cs b/src/PowerShellEditorServices.Hosting/Internal/NamedPipeUtils.cs new file mode 100644 index 0000000..7908442 --- /dev/null +++ b/src/PowerShellEditorServices.Hosting/Internal/NamedPipeUtils.cs @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.IO; +using System.IO.Pipes; + +#if !CoreCLR +using System.Security.Principal; +using System.Security.AccessControl; +#else +using System.Runtime.InteropServices; +#endif + +namespace Microsoft.PowerShell.EditorServices.Hosting +{ + /// + /// Utility class for handling named pipe creation in .NET Core and .NET Framework. + /// + internal static class NamedPipeUtils + { +#if !CoreCLR + // .NET Framework requires the buffer size to be specified + private const int PipeBufferSize = 1024; +#endif + + internal static NamedPipeServerStream CreateNamedPipe( + string pipeName, + PipeDirection pipeDirection) + { +#if CoreCLR + return new NamedPipeServerStream( + pipeName: pipeName, + direction: pipeDirection, + maxNumberOfServerInstances: 1, + transmissionMode: PipeTransmissionMode.Byte, + options: PipeOptions.CurrentUserOnly | PipeOptions.Asynchronous); +#else + + // In .NET Framework, we must manually ACL the named pipes we create + + PipeSecurity pipeSecurity = new(); + + WindowsIdentity identity = WindowsIdentity.GetCurrent(); + WindowsPrincipal principal = new(identity); + + if (principal.IsInRole(WindowsBuiltInRole.Administrator)) + { + // Allow the Administrators group full access to the pipe. + pipeSecurity.AddAccessRule( + new PipeAccessRule( + new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, domainSid: null).Translate(typeof(NTAccount)), + PipeAccessRights.FullControl, AccessControlType.Allow)); + } + else + { + // Allow the current user read/write access to the pipe. + pipeSecurity.AddAccessRule(new PipeAccessRule( + WindowsIdentity.GetCurrent().User, + PipeAccessRights.ReadWrite, AccessControlType.Allow)); + } + + return new NamedPipeServerStream( + pipeName, + pipeDirection, + maxNumberOfServerInstances: 1, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous, + inBufferSize: PipeBufferSize, + outBufferSize: PipeBufferSize, + pipeSecurity); +#endif + } + + /// + /// Generate a named pipe name known to not already be in use. + /// + /// Prefix variants of the pipename to test, if any. + /// A named pipe name or name suffix that is safe to you. + public static string GenerateValidNamedPipeName(IReadOnlyCollection prefixes = null) + { + for (int i = 0; i < 10; i++) + { + string pipeName = $"PSES_{Path.GetRandomFileName()}"; + + // In the simple prefix-less case, just test the pipe name + if (prefixes == null) + { + if (!IsPipeNameValid(pipeName)) + { + continue; + } + + return pipeName; + } + + // If we have prefixes, test that all prefix/pipename combinations are valid + bool allPipeNamesValid = true; + foreach (string prefix in prefixes) + { + string prefixedPipeName = $"{prefix}_{pipeName}"; + if (!IsPipeNameValid(prefixedPipeName)) + { + allPipeNamesValid = false; + break; + } + } + + if (allPipeNamesValid) + { + return pipeName; + } + } + + throw new IOException("Unable to create named pipe; no available names"); + } + + /// + /// Validate that a named pipe file name is a legitimate named pipe file name and is not already in use. + /// + /// The named pipe name to validate. This should be a simple name rather than a path. + /// True if the named pipe name is valid, false otherwise. + public static bool IsPipeNameValid(string pipeName) + { + if (string.IsNullOrEmpty(pipeName)) + { + return false; + } + + return !File.Exists(GetNamedPipePath(pipeName)); + } + + /// + /// Get the path of a named pipe given its name. + /// + /// The simple name of the named pipe. + /// The full path of the named pipe. +#pragma warning disable IDE0022 + public static string GetNamedPipePath(string pipeName) + { +#if CoreCLR + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return Path.Combine(Path.GetTempPath(), $"CoreFxPipe_{pipeName}"); + } +#endif + return $@"\\.\pipe\{pipeName}"; + } + } +#pragma warning restore IDE0022 +} diff --git a/src/PowerShellEditorServices.Hosting/Internal/PsesLoadContext.cs b/src/PowerShellEditorServices.Hosting/Internal/PsesLoadContext.cs new file mode 100644 index 0000000..da6588f --- /dev/null +++ b/src/PowerShellEditorServices.Hosting/Internal/PsesLoadContext.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Reflection; +using System.Runtime.Loader; + +namespace Microsoft.PowerShell.EditorServices.Hosting +{ + /// + /// An AssemblyLoadContext (ALC) designed to find PSES' dependencies in the given directory. + /// This class only exists in .NET Core, where the ALC is used to isolate PSES' dependencies + /// from the PowerShell assembly load context so that modules can import their own dependencies + /// without issue in PSES. + /// + internal class PsesLoadContext : AssemblyLoadContext + { + private static readonly string s_psHome = Path.GetDirectoryName( + Assembly.GetEntryAssembly().Location); + + private readonly string _dependencyDirPath; + + public PsesLoadContext(string dependencyDirPath) + { + _dependencyDirPath = dependencyDirPath; + + // Try and set our name in .NET Core 3+ for logging niceness + TrySetName("PsesLoadContext"); + } + + protected override Assembly Load(AssemblyName assemblyName) + { + // Since this class is responsible for loading any DLLs in .NET Core, + // we must restrict the code in here to only use core types, + // otherwise we may depend on assembly that we are trying to load and cause a StackOverflowException + + // If we find the required assembly in $PSHOME, let another mechanism load the assembly + string psHomeAsmPath = Path.Join(s_psHome, $"{assemblyName.Name}.dll"); + if (IsSatisfyingAssembly(assemblyName, psHomeAsmPath)) + { + return null; + } + + string asmPath = Path.Join(_dependencyDirPath, $"{assemblyName.Name}.dll"); + if (IsSatisfyingAssembly(assemblyName, asmPath)) + { + return LoadFromAssemblyPath(asmPath); + } + + return null; + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Best effort; we must not throw if we fail")] + private void TrySetName(string name) + { + try + { + // This field only exists in .NET Core 3+, but helps logging + FieldInfo nameBackingField = typeof(AssemblyLoadContext).GetField( + "_name", + BindingFlags.NonPublic | BindingFlags.Instance); + + nameBackingField?.SetValue(this, name); + } + catch + { + // Do nothing -- we did our best + } + } + + private static bool IsSatisfyingAssembly(AssemblyName requiredAssemblyName, string assemblyPath) + { + if (!File.Exists(assemblyPath)) + { + return false; + } + + AssemblyName asmToLoadName = AssemblyName.GetAssemblyName(assemblyPath); + + return string.Equals(asmToLoadName.Name, requiredAssemblyName.Name, StringComparison.OrdinalIgnoreCase) + && asmToLoadName.Version >= requiredAssemblyName.Version; + } + } +} diff --git a/src/PowerShellEditorServices.Hosting/PowerShellEditorServices.Hosting.csproj b/src/PowerShellEditorServices.Hosting/PowerShellEditorServices.Hosting.csproj new file mode 100644 index 0000000..1c30a93 --- /dev/null +++ b/src/PowerShellEditorServices.Hosting/PowerShellEditorServices.Hosting.csproj @@ -0,0 +1,36 @@ + + + + + net8.0;net462 + Microsoft.PowerShell.EditorServices.Hosting + + + + $(DefineConstants);CoreCLR + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/PowerShellEditorServices/Extensions/Api/EditorContextService.cs b/src/PowerShellEditorServices/Extensions/Api/EditorContextService.cs new file mode 100644 index 0000000..b3ba874 --- /dev/null +++ b/src/PowerShellEditorServices/Extensions/Api/EditorContextService.cs @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Services.Extension; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; + +namespace Microsoft.PowerShell.EditorServices.Extensions.Services +{ + /// + /// Service for managing the editor context from PSES extensions. + /// + public interface IEditorContextService + { + /// + /// Get the file context of the currently open file. + /// + /// The file context of the currently open file. + Task GetCurrentLspFileContextAsync(); + + /// + /// Open a fresh untitled file in the editor. + /// + /// A task that resolves when the file has been opened. + Task OpenNewUntitledFileAsync(); + + /// + /// Open the given file in the editor. + /// + /// The absolute URI to the file to open. + /// A task that resolves when the file has been opened. + Task OpenFileAsync(Uri fileUri); + + /// + /// Open the given file in the editor. + /// + /// The absolute URI to the file to open. + /// If true, open the file as a preview. + /// A task that resolves when the file is opened. + Task OpenFileAsync(Uri fileUri, bool preview); + + /// + /// Close the given file in the editor. + /// + /// The absolute URI to the file to close. + /// A task that resolves when the file has been closed. + Task CloseFileAsync(Uri fileUri); + + /// + /// Save the given file in the editor. + /// + /// The absolute URI of the file to save. + /// A task that resolves when the file has been saved. + Task SaveFileAsync(Uri fileUri); + + /// + /// Save the given file under a new name in the editor. + /// + /// The absolute URI of the file to save. + /// The absolute URI of the location to save the file. + /// + Task SaveFileAsync(Uri oldFileUri, Uri newFileUri); + + /// + /// Set the selection in the currently focused editor window. + /// + /// The range in the file to select. + /// A task that resolves when the range has been selected. + Task SetSelectionAsync(ILspFileRange range); + + /// + /// Insert text into a given file. + /// + /// The absolute URI of the file to insert text into. + /// The text to insert into the file. + /// The range over which to insert the given text. + /// A task that resolves when the text has been inserted. + Task InsertTextAsync(Uri fileUri, string text, ILspFileRange range); + } + + internal class EditorContextService : IEditorContextService + { + private readonly ILanguageServerFacade _languageServer; + + internal EditorContextService( + ILanguageServerFacade languageServer) => _languageServer = languageServer; + + public async Task GetCurrentLspFileContextAsync() + { + ClientEditorContext clientContext = + await _languageServer.SendRequest( + "editor/getEditorContext", + new GetEditorContextRequest()) + .Returning(CancellationToken.None) + .ConfigureAwait(false); + + return new LspCurrentFileContext(clientContext); + } + + public Task OpenNewUntitledFileAsync() => _languageServer.SendRequest("editor/newFile", null).ReturningVoid(CancellationToken.None); + + public Task OpenFileAsync(Uri fileUri) => OpenFileAsync(fileUri, preview: false); + + public Task OpenFileAsync(Uri fileUri, bool preview) + { + return _languageServer.SendRequest("editor/openFile", new OpenFileDetails + { + FilePath = fileUri.LocalPath, + Preview = preview, + }).ReturningVoid(CancellationToken.None); + } + + public Task CloseFileAsync(Uri fileUri) => _languageServer.SendRequest("editor/closeFile", fileUri.LocalPath).ReturningVoid(CancellationToken.None); + + public Task SaveFileAsync(Uri fileUri) => SaveFileAsync(fileUri, null); + + public Task SaveFileAsync(Uri oldFileUri, Uri newFileUri) + { + return _languageServer.SendRequest("editor/saveFile", new SaveFileDetails + { + FilePath = oldFileUri.LocalPath, + NewPath = newFileUri?.LocalPath, + }).ReturningVoid(CancellationToken.None); + } + + public Task SetSelectionAsync(ILspFileRange range) + { + return _languageServer.SendRequest("editor/setSelection", new SetSelectionRequest + { + SelectionRange = range.ToOmnisharpRange() + }).ReturningVoid(CancellationToken.None); + } + + public Task InsertTextAsync(Uri fileUri, string text, ILspFileRange range) + { + return _languageServer.SendRequest("editor/insertText", new InsertTextRequest + { + FilePath = fileUri.LocalPath, + InsertText = text, + InsertRange = range.ToOmnisharpRange(), + }).ReturningVoid(CancellationToken.None); + } + } +} diff --git a/src/PowerShellEditorServices/Extensions/Api/EditorExtensionServiceProvider.cs b/src/PowerShellEditorServices/Extensions/Api/EditorExtensionServiceProvider.cs new file mode 100644 index 0000000..d750923 --- /dev/null +++ b/src/PowerShellEditorServices/Extensions/Api/EditorExtensionServiceProvider.cs @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Linq.Expressions; +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.PowerShell.EditorServices.Services.Extension; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; + +using InternalServices = Microsoft.PowerShell.EditorServices.Services; + +namespace Microsoft.PowerShell.EditorServices.Extensions.Services +{ + /// + /// Object to provide extension service APIs to extensions to PSES. + /// + public class EditorExtensionServiceProvider + { + private static readonly Assembly s_psesAsm = typeof(EditorExtensionServiceProvider).Assembly; + + private static readonly Lazy s_psesAsmLoadContextLazy = new(GetPsesAsmLoadContext); + + private static readonly Lazy s_asmLoadContextType = new(() => Type.GetType("System.Runtime.Loader.AssemblyLoadContext")); + + private static readonly Lazy> s_enterPsesReflectionContextLazy = new(GetPsesAlcReflectionContextEntryFunc); + + private static readonly Lazy> s_loadAssemblyInPsesAlc = new(GetPsesAlcLoadAsmFunc); + + private static Type AsmLoadContextType => s_asmLoadContextType.Value; + + private static object PsesAssemblyLoadContext => s_psesAsmLoadContextLazy.Value; + + private static Func EnterPsesAlcReflectionContext => s_enterPsesReflectionContextLazy.Value; + + private static Func LoadAssemblyInPsesAlc => s_loadAssemblyInPsesAlc.Value; + + private readonly IServiceProvider _serviceProvider; + + internal EditorExtensionServiceProvider(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + LanguageServer = new LanguageServerService(_serviceProvider.GetService()); + ExtensionCommands = new ExtensionCommandService(_serviceProvider.GetService()); + Workspace = new WorkspaceService(_serviceProvider.GetService()); + EditorContext = new EditorContextService(_serviceProvider.GetService()); + EditorUI = new EditorUIService(_serviceProvider.GetService()); + } + + /// + /// A service wrapper around the language server allowing sending notifications and requests to the LSP client. + /// + public ILanguageServerService LanguageServer { get; } + + /// + /// Service providing extension command registration and functionality. + /// + public IExtensionCommandService ExtensionCommands { get; } + + /// + /// Service providing editor workspace functionality. + /// + public IWorkspaceService Workspace { get; } + + /// + /// Service providing current editor context functionality. + /// + public IEditorContextService EditorContext { get; } + + /// + /// Service providing editor UI functionality. + /// + public IEditorUIService EditorUI { get; } + + /// + /// Get an underlying service object from PSES by type name. + /// + /// The full type name of the service to get. + /// The service object requested, or null if no service of that type name exists. + /// + /// This method is intended as a trapdoor and should not be used in the first instance. + /// Consider using the public extension services if possible. + /// + public object GetService(string psesServiceFullTypeName) => GetService(psesServiceFullTypeName, "Microsoft.PowerShell.EditorServices"); + + /// + /// Get an underlying service object from PSES by type name. + /// + /// The full type name of the service to get. + /// The assembly name from which the service comes. + /// The service object requested, or null if no service of that type name exists. + /// + /// This method is intended as a trapdoor and should not be used in the first instance. + /// Consider using the public extension services if possible. + /// + public object GetService(string fullTypeName, string assemblyName) + { + string asmQualifiedName = $"{fullTypeName}, {assemblyName}"; + return GetServiceByAssemblyQualifiedName(asmQualifiedName); + } + + /// + /// Get a PSES service by its fully assembly qualified name. + /// + /// The fully assembly qualified name of the service type to load. + /// The service corresponding to the given type, or null if none was found. + /// + /// It's not recommended to run this method in parallel with anything, + /// since the global reflection context change may have side effects in other threads. + /// + public object GetServiceByAssemblyQualifiedName(string asmQualifiedTypeName) + { + Type serviceType; + if (VersionUtils.IsNetCore) + { + using (EnterPsesAlcReflectionContext()) + { + serviceType = s_psesAsm.GetType(asmQualifiedTypeName); + } + } + else + { + serviceType = Type.GetType(asmQualifiedTypeName); + } + + return GetService(serviceType); + } + + /// + /// Get an underlying service object from PSES by type name. + /// + /// The type of the service to fetch. + /// The service object requested, or null if no service of that type name exists. + /// + /// This method is intended as a trapdoor and should not be used in the first instance. + /// Consider using the public extension services if possible. + /// Also note that services in PSES may live in a separate assembly load context, + /// meaning that a type of the seemingly correct name may fail to fetch to a service + /// that is known under a type of the same name but loaded in a different context. + /// + public object GetService(Type serviceType) => _serviceProvider.GetService(serviceType); + + /// + /// Get the assembly load context the PSES loads its dependencies into. + /// In .NET Framework, this returns null. + /// + /// The assembly load context used for loading PSES, or null in .NET Framework. + public static object GetPsesAssemblyLoadContext() + { + if (!VersionUtils.IsNetCore) + { + return null; + } + + return PsesAssemblyLoadContext; + } + + /// + /// Load the given assembly in the PSES assembly load context. + /// In .NET Framework, this simple loads the assembly in the LoadFrom context. + /// + /// The absolute path of the assembly to load. + /// The loaded assembly object. + public static Assembly LoadAssemblyInPsesLoadContext(string assemblyPath) + { + if (!VersionUtils.IsNetCore) + { + return Assembly.LoadFrom(assemblyPath); + } + + return LoadAssemblyInPsesAlc(assemblyPath); + } + + private static Func GetPsesAlcReflectionContextEntryFunc() + { + MethodInfo enterReflectionContextMethod = AsmLoadContextType.GetMethod("EnterContextualReflection", BindingFlags.Public | BindingFlags.Instance); + + return Expression.Lambda>( + Expression.Convert( + Expression.Call(Expression.Constant(PsesAssemblyLoadContext), enterReflectionContextMethod), + typeof(IDisposable))).Compile(); + } + + private static Func GetPsesAlcLoadAsmFunc() + { + MethodInfo loadFromAssemblyPathMethod = AsmLoadContextType.GetMethod("LoadFromAssemblyPath", BindingFlags.Public | BindingFlags.Instance); + return (Func)loadFromAssemblyPathMethod.CreateDelegate(typeof(Func), PsesAssemblyLoadContext); + } + + private static object GetPsesAsmLoadContext() + { + if (!VersionUtils.IsNetCore) + { + return null; + } + + Type alcType = Type.GetType("System.Runtime.Loader.AssemblyLoadContext"); + MethodInfo getAlcMethod = alcType.GetMethod("GetLoadContext", BindingFlags.Public | BindingFlags.Static); + return getAlcMethod.Invoke(obj: null, new object[] { s_psesAsm }); + } + } +} diff --git a/src/PowerShellEditorServices/Extensions/Api/EditorUIService.cs b/src/PowerShellEditorServices/Extensions/Api/EditorUIService.cs new file mode 100644 index 0000000..a62131d --- /dev/null +++ b/src/PowerShellEditorServices/Extensions/Api/EditorUIService.cs @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Services.Extension; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; + +namespace Microsoft.PowerShell.EditorServices.Extensions.Services +{ + /// + /// Object specifying a UI prompt option to display to the user. + /// + public sealed class PromptChoiceDetails + { + /// + /// Construct a prompt choice object for display in a prompt to the user. + /// + /// The label to identify this prompt choice. May not contain commas (','). + /// The message to display to users. + public PromptChoiceDetails(string label, string helpMessage) + { + if (label == null) + { + throw new ArgumentNullException(nameof(label)); + } + + // Currently VSCode sends back selected labels as a single string concatenated with ',' + // When this is fixed, we'll be able to allow commas in labels + if (label.Contains(",")) + { + throw new ArgumentException($"Labels may not contain ','. Label: '{label}'", nameof(label)); + } + + Label = label; + HelpMessage = helpMessage; + } + + /// + /// The label to identify this prompt message. + /// + public string Label { get; } + + /// + /// The message to display to users in the UI for this prompt choice. + /// + public string HelpMessage { get; } + } + + /// + /// A service to manipulate the editor user interface. + /// + public interface IEditorUIService + { + /// + /// Prompt input after displaying the given message. + /// + /// The message to display with the prompt. + /// The input entered by the user, or null if the prompt was canceled. + Task PromptInputAsync(string message); + + /// + /// Prompt a single selection from a set of choices. + /// + /// The message to display for the prompt. + /// The choices to give the user. + /// The label of the selected choice, or null if the prompt was canceled. + Task PromptSelectionAsync(string message, IReadOnlyList choices); + + /// + /// Prompt a single selection from a set of choices. + /// + /// The message to display for the prompt. + /// The choices to give the user. + /// The index in the choice list of the default choice. + /// The label of the selected choice, or null if the prompt was canceled. + Task PromptSelectionAsync(string message, IReadOnlyList choices, int defaultChoiceIndex); + + /// + /// Prompt a set of selections from a list of choices. + /// + /// The message to display for the prompt. + /// The choices to give the user. + /// A list of the labels of selected choices, or null if the prompt was canceled. + Task> PromptMultipleSelectionAsync(string message, IReadOnlyList choices); + + /// + /// Prompt a set of selections from a list of choices. + /// + /// The message to display for the prompt. + /// The choices to give the user. + /// A list of the indexes of choices to mark as default. + /// A list of the labels of selected choices, or null if the prompt was canceled. + Task> PromptMultipleSelectionAsync(string message, IReadOnlyList choices, IReadOnlyList defaultChoiceIndexes); + } + + internal class EditorUIService : IEditorUIService + { + private static readonly string[] s_choiceResponseLabelSeparators = new[] { ", " }; + + private readonly ILanguageServerFacade _languageServer; + + public EditorUIService(ILanguageServerFacade languageServer) => _languageServer = languageServer; + + public async Task PromptInputAsync(string message) + { + // The VSCode client currently doesn't use the Label field, so we ignore it + ShowInputPromptResponse response = await _languageServer.SendRequest( + "powerShell/showInputPrompt", + new ShowInputPromptRequest + { + Name = message, + }) + .Returning(CancellationToken.None) + .ConfigureAwait(false); + + if (response.PromptCancelled) + { + return null; + } + + return response.ResponseText; + } + + public Task> PromptMultipleSelectionAsync(string message, IReadOnlyList choices) => + PromptMultipleSelectionAsync(message, choices, defaultChoiceIndexes: null); + + public async Task> PromptMultipleSelectionAsync(string message, IReadOnlyList choices, IReadOnlyList defaultChoiceIndexes) + { + ChoiceDetails[] choiceDetails = GetChoiceDetails(choices); + + ShowChoicePromptResponse response = await _languageServer.SendRequest( + "powerShell/showChoicePrompt", + new ShowChoicePromptRequest + { + IsMultiChoice = true, + Caption = string.Empty, + Message = message, + Choices = choiceDetails, + DefaultChoices = defaultChoiceIndexes?.ToArray(), + }) + .Returning(CancellationToken.None) + .ConfigureAwait(false); + + if (response.PromptCancelled) + { + return null; + } + + return response.ResponseText.Split(s_choiceResponseLabelSeparators, StringSplitOptions.None); + } + + public Task PromptSelectionAsync(string message, IReadOnlyList choices) => + PromptSelectionAsync(message, choices, defaultChoiceIndex: -1); + + public async Task PromptSelectionAsync(string message, IReadOnlyList choices, int defaultChoiceIndex) + { + ChoiceDetails[] choiceDetails = GetChoiceDetails(choices); + + ShowChoicePromptResponse response = await _languageServer.SendRequest( + "powerShell/showChoicePrompt", + new ShowChoicePromptRequest + { + IsMultiChoice = false, + Caption = string.Empty, + Message = message, + Choices = choiceDetails, + DefaultChoices = defaultChoiceIndex > -1 ? new[] { defaultChoiceIndex } : Array.Empty(), + }) + .Returning(CancellationToken.None) + .ConfigureAwait(false); + + if (response.PromptCancelled) + { + return null; + } + + return response.ResponseText; + } + + private static ChoiceDetails[] GetChoiceDetails(IReadOnlyList promptChoiceDetails) + { + ChoiceDetails[] choices = new ChoiceDetails[promptChoiceDetails.Count]; + for (int i = 0; i < promptChoiceDetails.Count; i++) + { + choices[i] = new ChoiceDetails + { + Label = promptChoiceDetails[i].Label, + HelpMessage = promptChoiceDetails[i].HelpMessage, + // There were intended to enable hotkey use for choice selections, + // but currently VSCode does not do anything with them. + // They can be exposed once VSCode supports them. + HotKeyIndex = -1, + HotKeyCharacter = null, + }; + } + return choices; + } + } +} diff --git a/src/PowerShellEditorServices/Extensions/Api/ExtensionCommandService.cs b/src/PowerShellEditorServices/Extensions/Api/ExtensionCommandService.cs new file mode 100644 index 0000000..9a3b4ca --- /dev/null +++ b/src/PowerShellEditorServices/Extensions/Api/ExtensionCommandService.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.Extension; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.PowerShell.EditorServices.Extensions.Services +{ + /// + /// Service for registration and invocation of extension commands. + /// + public interface IExtensionCommandService + { + /// + /// Invoke an extension command asynchronously. + /// + /// The name of the extension command to invoke. + /// The editor context in which to invoke the command. + /// A task that resolves when the command has been run. + Task InvokeCommandAsync(string commandName, EditorContext editorContext); + + /// + /// Registers a new EditorCommand with the ExtensionService and + /// causes its details to be sent to the host editor. + /// + /// The details about the editor command to be registered. + /// True if the command is newly registered, false if the command already exists. + bool RegisterCommand(EditorCommand editorCommand); + + /// + /// Unregisters an existing EditorCommand based on its registered name. + /// + /// The name of the command to be unregistered. + void UnregisterCommand(string commandName); + + /// + /// Returns all registered EditorCommands. + /// + /// A list of all registered EditorCommands. + IReadOnlyList GetCommands(); + + /// + /// Raised when a new editor command is added. + /// + event EventHandler CommandAdded; + + /// + /// Raised when an existing editor command is updated. + /// + event EventHandler CommandUpdated; + + /// + /// Raised when an existing editor command is removed. + /// + event EventHandler CommandRemoved; + } + + internal class ExtensionCommandService : IExtensionCommandService + { + private readonly ExtensionService _extensionService; + + public ExtensionCommandService(ExtensionService extensionService) + { + _extensionService = extensionService; + + _extensionService.CommandAdded += OnCommandAdded; + _extensionService.CommandUpdated += OnCommandUpdated; + _extensionService.CommandRemoved += OnCommandRemoved; + } + + public event EventHandler CommandAdded; + + public event EventHandler CommandUpdated; + + public event EventHandler CommandRemoved; + + public IReadOnlyList GetCommands() => _extensionService.GetCommands(); + + public Task InvokeCommandAsync(string commandName, EditorContext editorContext) => _extensionService.InvokeCommandAsync(commandName, editorContext, CancellationToken.None); + + public bool RegisterCommand(EditorCommand editorCommand) => _extensionService.RegisterCommand(editorCommand); + + public void UnregisterCommand(string commandName) => _extensionService.UnregisterCommand(commandName); + + private void OnCommandAdded(object sender, EditorCommand editorCommand) => CommandAdded?.Invoke(this, editorCommand); + + private void OnCommandUpdated(object sender, EditorCommand editorCommand) => CommandUpdated?.Invoke(this, editorCommand); + + private void OnCommandRemoved(object sender, EditorCommand editorCommand) => CommandRemoved?.Invoke(this, editorCommand); + } +} diff --git a/src/PowerShellEditorServices/Extensions/Api/LanguageServerService.cs b/src/PowerShellEditorServices/Extensions/Api/LanguageServerService.cs new file mode 100644 index 0000000..4d0de1a --- /dev/null +++ b/src/PowerShellEditorServices/Extensions/Api/LanguageServerService.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using OmniSharp.Extensions.LanguageServer.Protocol.Server; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.PowerShell.EditorServices.Extensions.Services +{ + /// + /// Service allowing the sending of notifications and requests to the PowerShell LSP language client from the server. + /// + public interface ILanguageServerService + { + /// + /// Send a parameterless notification. + /// + /// The method to send. + void SendNotification(string method); + + /// + /// Send a notification with parameters. + /// + /// The type of the parameter object. + /// The method to send. + /// The parameters to send. + void SendNotification(string method, T parameters); + + /// + /// Send a parameterless request with no response output. + /// + /// The method to send. + /// A task that resolves when the request is acknowledged. + Task SendRequestAsync(string method); + + /// + /// Send a request with no response output. + /// + /// The type of the request parameter object. + /// The method to send. + /// The request parameter object/body. + /// A task that resolves when the request is acknowledged. + Task SendRequestAsync(string method, T parameters); + + /// + /// Send a parameterless request and get its response. + /// + /// The type of the response expected. + /// The method to send. + /// A task that resolves to the response sent by the server. + Task SendRequestAsync(string method); + + /// + /// Send a request and get its response. + /// + /// The type of the parameter object. + /// The type of the response expected. + /// The method to send. + /// The parameters to send. + /// A task that resolves to the response sent by the server. + Task SendRequestAsync(string method, T parameters); + } + + internal class LanguageServerService : ILanguageServerService + { + private readonly ILanguageServerFacade _languageServer; + + internal LanguageServerService(ILanguageServerFacade languageServer) => _languageServer = languageServer; + + public void SendNotification(string method) => _languageServer.SendNotification(method); + + public void SendNotification(string method, T parameters) => _languageServer.SendNotification(method, parameters); + + public void SendNotification(string method, object parameters) => _languageServer.SendNotification(method, parameters); + + public Task SendRequestAsync(string method) => _languageServer.SendRequest(method).ReturningVoid(CancellationToken.None); + + public Task SendRequestAsync(string method, T parameters) => _languageServer.SendRequest(method, parameters).ReturningVoid(CancellationToken.None); + + public Task SendRequestAsync(string method) => _languageServer.SendRequest(method).Returning(CancellationToken.None); + + public Task SendRequestAsync(string method, T parameters) => _languageServer.SendRequest(method, parameters).Returning(CancellationToken.None); + + public Task SendRequestAsync(string method, object parameters) => _languageServer.SendRequest(method, parameters).Returning(CancellationToken.None); + } +} diff --git a/src/PowerShellEditorServices/Extensions/Api/WorkspaceService.cs b/src/PowerShellEditorServices/Extensions/Api/WorkspaceService.cs new file mode 100644 index 0000000..8e54726 --- /dev/null +++ b/src/PowerShellEditorServices/Extensions/Api/WorkspaceService.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using System; +using System.Collections.Generic; +using System.Management.Automation.Language; + +namespace Microsoft.PowerShell.EditorServices.Extensions.Services +{ + /// + /// A script file in the current editor workspace. + /// + public interface IEditorScriptFile + { + /// + /// The URI of the script file. + /// + Uri Uri { get; } + + /// + /// The text content of the file. + /// + string Content { get; } + + /// + /// The lines of the file. + /// + IReadOnlyList Lines { get; } + + /// + /// The PowerShell AST of the script in the file. + /// + ScriptBlockAst Ast { get; } + + /// + /// The PowerShell syntactic tokens of the script in the file. + /// + IReadOnlyList Tokens { get; } + } + + /// + /// A service for querying and manipulating the editor workspace. + /// + public interface IWorkspaceService + { + /// + /// The root path of the workspace for the current editor. + /// + string WorkspacePath { get; } + + /// + /// Indicates whether the editor is configured to follow symlinks. + /// + bool FollowSymlinks { get; } + + /// + /// The list of file globs to exclude from workspace management. + /// + IReadOnlyList ExcludedFileGlobs { get; } + + /// + /// Get a file within the workspace. + /// + /// The absolute URI of the file to get. + /// A representation of the file. + IEditorScriptFile GetFile(Uri fileUri); + + /// + /// Attempt to get a file within the workspace. + /// + /// The absolute URI of the file to get. + /// The file, if it was found. + /// True if the file was found, false otherwise. + bool TryGetFile(Uri fileUri, out IEditorScriptFile file); + + /// + /// Get all the open files in the editor workspace. + /// The result is not kept up to date as files are opened or closed. + /// + /// All open files in the editor workspace. + IReadOnlyList GetOpenedFiles(); + } + + internal class EditorScriptFile : IEditorScriptFile + { + private readonly ScriptFile _scriptFile; + + internal EditorScriptFile( + ScriptFile scriptFile) + { + _scriptFile = scriptFile; + Uri = scriptFile.DocumentUri.ToUri(); + Lines = _scriptFile.FileLines.AsReadOnly(); + } + + public Uri Uri { get; } + + public IReadOnlyList Lines { get; } + + public string Content => _scriptFile.Contents; + + public ScriptBlockAst Ast => _scriptFile.ScriptAst; + + public IReadOnlyList Tokens => _scriptFile.ScriptTokens; + } + + internal class WorkspaceService : IWorkspaceService + { + private readonly EditorServices.Services.WorkspaceService _workspaceService; + + internal WorkspaceService( + EditorServices.Services.WorkspaceService workspaceService) + { + _workspaceService = workspaceService; + ExcludedFileGlobs = _workspaceService.ExcludeFilesGlob.AsReadOnly(); + } + + // TODO: This needs to use the associated EditorContext to get the workspace for the current + // editor instead of the initial working directory. + public string WorkspacePath => _workspaceService.InitialWorkingDirectory; + + public bool FollowSymlinks => _workspaceService.FollowSymlinks; + + public IReadOnlyList ExcludedFileGlobs { get; } + + public IEditorScriptFile GetFile(Uri fileUri) => GetEditorFileFromScriptFile(_workspaceService.GetFile(fileUri)); + + public bool TryGetFile(Uri fileUri, out IEditorScriptFile file) + { + if (!_workspaceService.TryGetFile(fileUri.LocalPath, out ScriptFile scriptFile)) + { + file = null; + return false; + } + + file = GetEditorFileFromScriptFile(scriptFile); + return true; + } + + public IReadOnlyList GetOpenedFiles() + { + List files = new(); + foreach (ScriptFile openedFile in _workspaceService.GetOpenedFiles()) + { + files.Add(GetEditorFileFromScriptFile(openedFile)); + } + return files.AsReadOnly(); + } + + private static IEditorScriptFile GetEditorFileFromScriptFile(ScriptFile file) => new EditorScriptFile(file); + } +} diff --git a/src/PowerShellEditorServices/Extensions/EditorCommand.cs b/src/PowerShellEditorServices/Extensions/EditorCommand.cs new file mode 100644 index 0000000..f330b4a --- /dev/null +++ b/src/PowerShellEditorServices/Extensions/EditorCommand.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Management.Automation; + +namespace Microsoft.PowerShell.EditorServices.Extensions +{ + /// + /// Provides details about a command that has been registered + /// with the editor. + /// + public sealed class EditorCommand + { + #region Properties + + /// + /// Gets the name which uniquely identifies the command. + /// + public string Name { get; } + + /// + /// Gets the display name for the command. + /// + public string DisplayName { get; } + + /// + /// Gets the boolean which determines whether this command's + /// output should be suppressed. + /// + public bool SuppressOutput { get; } + + /// + /// Gets the ScriptBlock which can be used to execute the command. + /// + public ScriptBlock ScriptBlock { get; } + + #endregion + + #region Constructors + + /// + /// Creates a new EditorCommand instance that invokes a cmdlet or + /// function by name. + /// + /// The unique identifier name for the command. + /// The display name for the command. + /// If true, causes output to be suppressed for this command. + /// The name of the cmdlet or function which will be invoked by this command. + public EditorCommand( + string commandName, + string displayName, + bool suppressOutput, + string cmdletName) + : this( + commandName, + displayName, + suppressOutput, + ScriptBlock.Create( + string.Format( + "param($context) {0} $context", + cmdletName))) + { + } + + /// + /// Creates a new EditorCommand instance that invokes a ScriptBlock. + /// + /// The unique identifier name for the command. + /// The display name for the command. + /// If true, causes output to be suppressed for this command. + /// The ScriptBlock which will be invoked by this command. + public EditorCommand( + string commandName, + string displayName, + bool suppressOutput, + ScriptBlock scriptBlock) + { + Name = commandName; + DisplayName = displayName; + SuppressOutput = suppressOutput; + ScriptBlock = scriptBlock; + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Extensions/EditorCommandAttribute.cs b/src/PowerShellEditorServices/Extensions/EditorCommandAttribute.cs new file mode 100644 index 0000000..ad1cc95 --- /dev/null +++ b/src/PowerShellEditorServices/Extensions/EditorCommandAttribute.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; + +namespace Microsoft.PowerShell.EditorServices.Extensions +{ + /// + /// Provides an attribute that can be used to target PowerShell + /// commands for import as editor commands. + /// + [AttributeUsage(AttributeTargets.Class)] + public sealed class EditorCommandAttribute : Attribute + { + #region Properties + + /// + /// Gets or sets the name which uniquely identifies the command. + /// + public string Name { get; set; } + + /// + /// Gets or sets the display name for the command. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets a value indicating whether this command's output + /// should be suppressed. + /// + public bool SuppressOutput { get; set; } + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Extensions/EditorContext.cs b/src/PowerShellEditorServices/Extensions/EditorContext.cs new file mode 100644 index 0000000..e79eb8c --- /dev/null +++ b/src/PowerShellEditorServices/Extensions/EditorContext.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Extensions +{ + /// + /// Provides context for the host editor at the time of creation. + /// + public sealed class EditorContext + { + #region Private Fields + + private readonly IEditorOperations editorOperations; + + #endregion + + #region Properties + + /// + /// Gets the FileContext for the active file. + /// + public FileContext CurrentFile { get; } + + /// + /// Gets the BufferRange representing the current selection in the file. + /// + public IFileRange SelectedRange { get; } + + /// + /// Gets the FilePosition representing the current cursor position. + /// + public IFilePosition CursorPosition { get; } + + #endregion + + #region Constructors + + /// + /// Creates a new instance of the EditorContext class. + /// + /// An IEditorOperations implementation which performs operations in the editor. + /// The ScriptFile that is in the active editor buffer. + /// The position of the user's cursor in the active editor buffer. + /// The range of the user's selection in the active editor buffer. + /// Determines the language of the file.false If it is not specified, then it defaults to "Unknown" + internal EditorContext( + IEditorOperations editorOperations, + ScriptFile currentFile, + BufferPosition cursorPosition, + BufferRange selectedRange, + string language = "Unknown") + { + this.editorOperations = editorOperations; + CurrentFile = new FileContext(currentFile, this, editorOperations, language); + SelectedRange = new BufferFileRange(selectedRange); + CursorPosition = new BufferFilePosition(cursorPosition); + } + + #endregion + + #region Public Methods + + /// + /// Sets a selection in the host editor's active buffer. + /// + /// The 1-based starting line of the selection. + /// The 1-based starting column of the selection. + /// The 1-based ending line of the selection. + /// The 1-based ending column of the selection. + public void SetSelection( + int startLine, + int startColumn, + int endLine, + int endColumn) + { + SetSelection( + new FileRange( + new FilePosition(startLine, startColumn), + new FilePosition(endLine, endColumn))); + } + + /// + /// Sets a selection in the host editor's active buffer. + /// + /// The starting position of the selection. + /// The ending position of the selection. + public void SetSelection(FilePosition startPosition, FilePosition endPosition) => SetSelection(new FileRange(startPosition, endPosition)); + + /// + /// Sets a selection in the host editor's active buffer. + /// + /// The range of the selection. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Supporting synchronous API.")] + public void SetSelection(FileRange selectionRange) => editorOperations.SetSelectionAsync(selectionRange.ToBufferRange()).Wait(); + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Extensions/EditorFileRanges.cs b/src/PowerShellEditorServices/Extensions/EditorFileRanges.cs new file mode 100644 index 0000000..901307a --- /dev/null +++ b/src/PowerShellEditorServices/Extensions/EditorFileRanges.cs @@ -0,0 +1,482 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.Extension; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using System; +using System.Management.Automation.Language; + +namespace Microsoft.PowerShell.EditorServices.Extensions +{ + public class FileScriptPosition : IScriptPosition, IFilePosition + { + public static FileScriptPosition Empty { get; } = new FileScriptPosition(null, 0, 0, 0); + + public static FileScriptPosition FromPosition(FileContext file, int lineNumber, int columnNumber) + { + if (file is null) + { + throw new ArgumentNullException(nameof(file)); + } + + int offset = 0; + int currLine = 1; + string fileText = file.Ast.Extent.Text; + while (offset < fileText.Length && currLine < lineNumber) + { + offset = fileText.IndexOf('\n', offset) + 1; + if (offset is 0) + { + // Line and column passed were not valid and the offset can not be determined. + return new FileScriptPosition(file, lineNumber, columnNumber, offset); + } + + currLine++; + } + + offset += columnNumber - 1; + + return new FileScriptPosition(file, lineNumber, columnNumber, offset); + } + + public static FileScriptPosition FromOffset(FileContext file, int offset) + { + if (file is null) + { + throw new ArgumentNullException(nameof(file)); + } + + int line = 1; + string fileText = file.Ast.Extent.Text; + + if (offset >= fileText.Length) + { + throw new ArgumentException("Offset greater than file length", nameof(offset)); + } + + int lastLineOffset = -1; + for (int i = 0; i < offset; i++) + { + if (fileText[i] == '\n') + { + lastLineOffset = i; + line++; + } + } + + int column = offset - lastLineOffset; + + return new FileScriptPosition(file, line, column, offset); + } + + private readonly FileContext _file; + + internal FileScriptPosition(FileContext file, int lineNumber, int columnNumber, int offset) + { + _file = file; + Line = file?.GetTextLines()?[lineNumber - 1] ?? string.Empty; + ColumnNumber = columnNumber; + LineNumber = lineNumber; + Offset = offset; + } + + public int ColumnNumber { get; } + + public string File { get; } + + public string Line { get; } + + public int LineNumber { get; } + + public int Offset { get; } + + int IFilePosition.Column => ColumnNumber; + + int IFilePosition.Line => LineNumber; + + public string GetFullScript() => _file?.GetText() ?? string.Empty; + } + + public class FileScriptExtent : IScriptExtent, IFileRange + { + public static bool IsEmpty(FileScriptExtent extent) + { + return extent == Empty + || (extent.StartOffset == 0 && extent.EndOffset == 0); + } + + public static FileScriptExtent Empty { get; } = new FileScriptExtent(null, FileScriptPosition.Empty, FileScriptPosition.Empty); + + public static FileScriptExtent FromOffsets(FileContext file, int startOffset, int endOffset) + { + if (file is null) + { + throw new ArgumentNullException(nameof(file)); + } + + return new FileScriptExtent( + file, + FileScriptPosition.FromOffset(file, startOffset), + FileScriptPosition.FromOffset(file, endOffset)); + } + + public static FileScriptExtent FromPositions(FileContext file, int startLine, int startColumn, int endLine, int endColumn) + { + if (file is null) + { + throw new ArgumentNullException(nameof(file)); + } + + return new FileScriptExtent( + file, + FileScriptPosition.FromPosition(file, startLine, startColumn), + FileScriptPosition.FromPosition(file, endLine, endColumn)); + } + + private readonly FileContext _file; + private readonly FileScriptPosition _start; + private readonly FileScriptPosition _end; + + public FileScriptExtent(FileContext file, FileScriptPosition start, FileScriptPosition end) + { + _file = file; + _start = start; + _end = end; + } + + public int EndColumnNumber => _end.ColumnNumber; + + public int EndLineNumber => _end.LineNumber; + + public int EndOffset => _end.Offset; + + public IScriptPosition EndScriptPosition => _end; + + public string File => _file?.Path ?? string.Empty; + + public int StartColumnNumber => _start.ColumnNumber; + + public int StartLineNumber => _start.LineNumber; + + public int StartOffset => _start.Offset; + + public IScriptPosition StartScriptPosition => _start; + + public string Text => _file?.GetText()?.Substring(_start.Offset, _end.Offset - _start.Offset) ?? string.Empty; + + IFilePosition IFileRange.Start => _start; + + IFilePosition IFileRange.End => _end; + } + + /// + /// A 1-based file position, referring to a point in a file. + /// + public interface IFilePosition + { + /// + /// The line number of the file position. + /// + int Line { get; } + + /// + /// The column number of the file position. + /// + int Column { get; } + } + + /// + /// A 1-based file range, referring to a range within a file. + /// + public interface IFileRange + { + /// + /// The start position of the range. + /// + IFilePosition Start { get; } + + /// + /// The end position of the range. + /// + IFilePosition End { get; } + } + + /// + /// A snapshot of a file, including the URI of the file + /// and its textual contents when accessed. + /// + public interface IFileContext + { + /// + /// The URI of the file. + /// + Uri Uri { get; } + + /// + /// The content of the file when it was accessed. + /// + string Content { get; } + } + + /// + /// 0-based position within a file, conformant with the Language Server Protocol. + /// + public interface ILspFilePosition + { + /// + /// The line index of the position within the file. + /// + int Line { get; } + + /// + /// The character offset from the line of the position. + /// + int Character { get; } + } + + /// + /// 0-based range within a file, conformant with the Language Server Protocol. + /// + public interface ILspFileRange + { + /// + /// The start position of the range. + /// + ILspFilePosition Start { get; } + + /// + /// The end position of the range. + /// + ILspFilePosition End { get; } + } + + /// + /// Snapshot of a file in focus in the editor. + /// + public interface ILspCurrentFileContext : IFileContext + { + /// + /// The language the editor associates with this file. + /// + string Language { get; } + + /// + /// The position of the cursor within the file when it was accessed. + /// If the cursor is not in the file, values may be negative. + /// + ILspFilePosition CursorPosition { get; } + + /// + /// The currently selected range when the file was accessed. + /// If no selection is made, values may be negative. + /// + ILspFileRange SelectionRange { get; } + } + + internal readonly struct OmnisharpLspPosition : ILspFilePosition, IEquatable + { + private readonly Position _position; + + public OmnisharpLspPosition(Position position) => _position = position; + + public int Line => _position.Line; + + public int Character => _position.Character; + + public bool Equals(OmnisharpLspPosition other) => _position == other._position; + } + + internal readonly struct OmnisharpLspRange : ILspFileRange, IEquatable + { + private readonly Range _range; + + public OmnisharpLspRange(Range range) => _range = range; + + public ILspFilePosition Start => new OmnisharpLspPosition(_range.Start); + + public ILspFilePosition End => new OmnisharpLspPosition(_range.End); + + public bool Equals(OmnisharpLspRange other) => _range == other._range; + } + + internal readonly struct BufferFilePosition : IFilePosition, IEquatable + { + private readonly BufferPosition _position; + + public BufferFilePosition(BufferPosition position) => _position = position; + + public int Line => _position.Line; + + public int Column => _position.Column; + + public bool Equals(BufferFilePosition other) + { + return _position == other._position + || _position.Equals(other._position); + } + } + + internal readonly struct BufferFileRange : IFileRange, IEquatable + { + private readonly BufferRange _range; + + public BufferFileRange(BufferRange range) => _range = range; + + public IFilePosition Start => new BufferFilePosition(_range.Start); + + public IFilePosition End => new BufferFilePosition(_range.End); + + public bool Equals(BufferFileRange other) + { + return _range == other._range + || _range.Equals(other._range); + } + } + + /// + /// A 1-based file position. + /// + public class FilePosition : IFilePosition + { + public FilePosition(int line, int column) + { + Line = line; + Column = column; + } + + public int Line { get; } + + public int Column { get; } + } + + /// + /// A 0-based file position. + /// + public class LspFilePosition : ILspFilePosition + { + public LspFilePosition(int line, int column) + { + Line = line; + Character = column; + } + + public int Line { get; } + + public int Character { get; } + } + + /// + /// A 1-based file range. + /// + public class FileRange : IFileRange + { + public FileRange(IFilePosition start, IFilePosition end) + : this(start, end, file: null) + { + } + + public FileRange(IFilePosition start, IFilePosition end, string file) + { + Start = start; + End = end; + File = file; + } + + public IFilePosition Start { get; } + + public IFilePosition End { get; } + + public string File { get; } + } + + /// + /// A 0-based file range. + /// + public class LspFileRange : ILspFileRange + { + public LspFileRange(ILspFilePosition start, ILspFilePosition end) + { + Start = start; + End = end; + } + + public ILspFilePosition Start { get; } + + public ILspFilePosition End { get; } + } + + internal class LspCurrentFileContext : ILspCurrentFileContext + { + private readonly ClientEditorContext _editorContext; + + public LspCurrentFileContext(ClientEditorContext editorContext) + { + _editorContext = editorContext; + Uri = new Uri(editorContext.CurrentFilePath); + } + + public string Language => _editorContext.CurrentFileLanguage; + + public ILspFilePosition CursorPosition => new OmnisharpLspPosition(_editorContext.CursorPosition); + + public ILspFileRange SelectionRange => new OmnisharpLspRange(_editorContext.SelectionRange); + + public Uri Uri { get; } + + public string Content => _editorContext.CurrentFileContent; + } + + /// + /// Extension methods to conveniently convert between file position and range types. + /// + public static class FileObjectExtensionMethods + { + /// + /// Convert a 1-based file position to a 0-based file position. + /// + /// The 1-based file position to convert. + /// An equivalent 0-based file position. + public static ILspFilePosition ToLspPosition(this IFilePosition position) => new LspFilePosition(position.Line - 1, position.Column - 1); + + /// + /// Convert a 1-based file range to a 0-based file range. + /// + /// The 1-based file range to convert. + /// An equivalent 0-based file range. + public static ILspFileRange ToLspRange(this IFileRange range) => new LspFileRange(range.Start.ToLspPosition(), range.End.ToLspPosition()); + + /// + /// Convert a 0-based file position to a 1-based file position. + /// + /// The 0-based file position to convert. + /// An equivalent 1-based file position. + public static IFilePosition ToFilePosition(this ILspFilePosition position) => new FilePosition(position.Line + 1, position.Character + 1); + + /// + /// Convert a 0-based file range to a 1-based file range. + /// + /// The 0-based file range to convert. + /// An equivalent 1-based file range. + public static IFileRange ToFileRange(this ILspFileRange range) => new FileRange(range.Start.ToFilePosition(), range.End.ToFilePosition()); + + internal static bool HasRange(this IFileRange range) + { + return range.Start.Line != 0 + && range.Start.Column != 0 + && range.End.Line != 0 + && range.End.Column != 0; + } + internal static ILspFilePosition ToLspPosition(this Position position) => new OmnisharpLspPosition(position); + + internal static ILspFileRange ToLspRange(this Range range) => new OmnisharpLspRange(range); + + internal static Position ToOmnisharpPosition(this ILspFilePosition position) => new(position.Line, position.Character); + + internal static Range ToOmnisharpRange(this ILspFileRange range) => new(range.Start.ToOmnisharpPosition(), range.End.ToOmnisharpPosition()); + + internal static BufferPosition ToBufferPosition(this IFilePosition position) => new(position.Line, position.Column); + + internal static BufferRange ToBufferRange(this IFileRange range) => new(range.Start.ToBufferPosition(), range.End.ToBufferPosition()); + } +} diff --git a/src/PowerShellEditorServices/Extensions/EditorObject.cs b/src/PowerShellEditorServices/Extensions/EditorObject.cs new file mode 100644 index 0000000..a43ab29 --- /dev/null +++ b/src/PowerShellEditorServices/Extensions/EditorObject.cs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Extensions.Services; +using Microsoft.PowerShell.EditorServices.Services.Extension; + +namespace Microsoft.PowerShell.EditorServices.Extensions +{ + /// + /// Extension class to access the editor API with. + /// This is done so that the async/ALC APIs aren't exposed to PowerShell, where they're likely only to cause problems. + /// + public static class EditorObjectExtensions + { + /// + /// Get the provider of extension services for .NET extension tooling. + /// + /// The editor object ($psEditor). + /// The extension services provider. + public static EditorExtensionServiceProvider GetExtensionServiceProvider(this EditorObject editorObject) => editorObject.Api; + } + + /// + /// Provides the entry point of the extensibility API, inserted into + /// the PowerShell session as the "$psEditor" variable. + /// + public class EditorObject + { + private static readonly TaskCompletionSource s_editorObjectReady = new(); + + /// + /// A reference to the editor object instance. Only valid when completes. + /// + public static EditorObject Instance { get; private set; } + + /// + /// A task that completes when the editor object static instance has been set. + /// + public static Task EditorObjectReady => s_editorObjectReady.Task; + + #region Private Fields + + private readonly ExtensionService _extensionService; + private readonly IEditorOperations _editorOperations; + private readonly Lazy _apiLazy; + + #endregion + + #region Properties + + internal EditorExtensionServiceProvider Api => _apiLazy.Value; + + /// + /// Gets the version of PowerShell Editor Services. + /// + public Version EditorServicesVersion => GetType().GetTypeInfo().Assembly.GetName().Version; + + /// + /// Gets the workspace interface for the editor API. + /// + public EditorWorkspace Workspace { get; } + + /// + /// Gets the window interface for the editor API. + /// + public EditorWindow Window { get; } + + #endregion + + /// + /// Creates a new instance of the EditorObject class. + /// + /// The service provider? + /// An ExtensionService which handles command registration. + /// An IEditorOperations implementation which handles operations in the host editor. + internal EditorObject( + IServiceProvider serviceProvider, + ExtensionService extensionService, + IEditorOperations editorOperations) + { + _extensionService = extensionService; + _editorOperations = editorOperations; + + // Create API area objects + Workspace = new EditorWorkspace(_editorOperations); + Window = new EditorWindow(_editorOperations); + + // Create this lazily so that dependency injection does not have a circular call dependency + _apiLazy = new Lazy(() => new EditorExtensionServiceProvider(serviceProvider)); + } + + /// + /// Registers a new command in the editor. + /// + /// The EditorCommand to be registered. + /// True if the command is newly registered, false if the command already exists. + public bool RegisterCommand(EditorCommand editorCommand) => _extensionService.RegisterCommand(editorCommand); + + /// + /// Unregisters an existing EditorCommand based on its registered name. + /// + /// The name of the command to be unregistered. + public void UnregisterCommand(string commandName) => _extensionService.UnregisterCommand(commandName); + + /// + /// Returns all registered EditorCommands. + /// + /// An Array of all registered EditorCommands. + public EditorCommand[] GetCommands() => _extensionService.GetCommands(); + /// + /// Gets the EditorContext which contains the state of the editor + /// at the time this method is invoked. + /// + /// A instance of the EditorContext class. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Supporting synchronous API.")] + public EditorContext GetEditorContext() => _editorOperations.GetEditorContextAsync().Result; + + internal void SetAsStaticInstance() + { + Instance = this; + s_editorObjectReady.TrySetResult(true); + } + } +} diff --git a/src/PowerShellEditorServices/Extensions/EditorRequests.cs b/src/PowerShellEditorServices/Extensions/EditorRequests.cs new file mode 100644 index 0000000..34dca29 --- /dev/null +++ b/src/PowerShellEditorServices/Extensions/EditorRequests.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Extensions +{ + internal class ExtensionCommandAddedNotification + { + public string Name { get; set; } + + public string DisplayName { get; set; } + } + + internal class ExtensionCommandUpdatedNotification + { + public string Name { get; set; } + } + + internal class ExtensionCommandRemovedNotification + { + public string Name { get; set; } + } + + internal class GetEditorContextRequest + { } + + internal enum EditorOperationResponse + { + Completed, + Failed + } + + internal class InsertTextRequest + { + public string FilePath { get; set; } + + public string InsertText { get; set; } + + public Range InsertRange { get; set; } + } + + internal class SetSelectionRequest + { + public Range SelectionRange { get; set; } + } + + internal class SetCursorPositionRequest + { + public Position CursorPosition { get; set; } + } + + internal class OpenFileDetails + { + public string FilePath { get; set; } + + public bool Preview { get; set; } + } + + internal class SaveFileDetails + { + public string FilePath { get; set; } + + public string NewPath { get; set; } + } + + internal class StatusBarMessageDetails + { + public string Message { get; set; } + + public int? Timeout { get; set; } + } +} diff --git a/src/PowerShellEditorServices/Extensions/EditorTerminal.cs b/src/PowerShellEditorServices/Extensions/EditorTerminal.cs new file mode 100644 index 0000000..b3c1c31 --- /dev/null +++ b/src/PowerShellEditorServices/Extensions/EditorTerminal.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.PowerShell.EditorServices.Extensions +{ + /// + /// Provides a PowerShell-facing API which allows scripts to + /// interact with the editor's terminal. + /// + public class EditorTerminal + { + #region Private Fields + + private readonly IEditorOperations editorOperations; + + #endregion + + #region Constructors + + /// + /// Creates a new instance of the EditorTerminal class. + /// + /// An IEditorOperations implementation which handles operations in the host editor. + internal EditorTerminal(IEditorOperations editorOperations) => this.editorOperations = editorOperations; + + #endregion + + #region Public Methods + + /// + /// Triggers to the editor to clear the terminal. + /// + public void Clear() => editorOperations.ClearTerminal(); + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Extensions/EditorWindow.cs b/src/PowerShellEditorServices/Extensions/EditorWindow.cs new file mode 100644 index 0000000..6d9d4c3 --- /dev/null +++ b/src/PowerShellEditorServices/Extensions/EditorWindow.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.PowerShell.EditorServices.Extensions +{ + /// + /// Provides a PowerShell-facing API which allows scripts to + /// interact with the editor's window. + /// + public sealed class EditorWindow + { + #region Private Fields + + private readonly IEditorOperations editorOperations; + + #endregion + + #region Public Properties + + /// + /// Gets the terminal interface for the editor API. + /// + public EditorTerminal Terminal { get; } + + #endregion + + #region Constructors + + /// + /// Creates a new instance of the EditorWindow class. + /// + /// An IEditorOperations implementation which handles operations in the host editor. + internal EditorWindow(IEditorOperations editorOperations) + { + this.editorOperations = editorOperations; + Terminal = new EditorTerminal(editorOperations); + } + + #endregion + + #region Public Methods + /// + /// Shows an informational message to the user. + /// + /// The message to be shown. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Supporting synchronous API.")] + public void ShowInformationMessage(string message) => editorOperations.ShowInformationMessageAsync(message).Wait(); + + /// + /// Shows an error message to the user. + /// + /// The message to be shown. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Supporting synchronous API.")] + public void ShowErrorMessage(string message) => editorOperations.ShowErrorMessageAsync(message).Wait(); + + /// + /// Shows a warning message to the user. + /// + /// The message to be shown. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Supporting synchronous API.")] + public void ShowWarningMessage(string message) => editorOperations.ShowWarningMessageAsync(message).Wait(); + + /// + /// Sets the status bar message in the editor UI (if applicable). + /// + /// The message to be shown. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Supporting synchronous API.")] + public void SetStatusBarMessage(string message) => editorOperations.SetStatusBarMessageAsync(message, null).Wait(); + + /// + /// Sets the status bar message in the editor UI (if applicable). + /// + /// The message to be shown. + /// A timeout in milliseconds for how long the message should remain visible. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Supporting synchronous API.")] + public void SetStatusBarMessage(string message, int timeout) => editorOperations.SetStatusBarMessageAsync(message, timeout).Wait(); + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Extensions/EditorWorkspace.cs b/src/PowerShellEditorServices/Extensions/EditorWorkspace.cs new file mode 100644 index 0000000..e1e0824 --- /dev/null +++ b/src/PowerShellEditorServices/Extensions/EditorWorkspace.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.PowerShell.EditorServices.Extensions +{ + /// + /// Provides a PowerShell-facing API which allows scripts to + /// interact with the editor's workspace. + /// + public sealed class EditorWorkspace + { + #region Private Fields + + private readonly IEditorOperations editorOperations; + + #endregion + + #region Properties + + /// + /// Gets the server's initial working directory, since the extension API doesn't have a + /// multi-root workspace concept. + /// + public string Path => editorOperations.GetWorkspacePath(); + + /// + /// Get all the workspace folders' paths. + /// + public string[] Paths => editorOperations.GetWorkspacePaths(); + + #endregion + + #region Constructors + + internal EditorWorkspace(IEditorOperations editorOperations) => this.editorOperations = editorOperations; + + #endregion + + #region Public Methods + // TODO: Consider returning bool instead of void to indicate success? + + /// + /// Creates a new file in the editor. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Supporting synchronous API.")] + public void NewFile() => editorOperations.NewFileAsync(string.Empty).Wait(); + + /// + /// Creates a new file in the editor. + /// + /// The content to place in the new file. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Supporting synchronous API.")] + public void NewFile(string content) => editorOperations.NewFileAsync(content).Wait(); + + /// + /// Opens a file in the workspace. If the file is already open + /// its buffer will be made active. + /// + /// The path to the file to be opened. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Supporting synchronous API.")] + public void OpenFile(string filePath) => editorOperations.OpenFileAsync(filePath).Wait(); + + /// + /// Opens a file in the workspace. If the file is already open + /// its buffer will be made active. + /// You can specify whether the file opens as a preview or as a durable editor. + /// + /// The path to the file to be opened. + /// Determines wether the file is opened as a preview or as a durable editor. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Supporting synchronous API.")] + public void OpenFile(string filePath, bool preview) => editorOperations.OpenFileAsync(filePath, preview).Wait(); + + /// + /// Closes a file in the workspace. + /// + /// The path to the file to be closed. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Supporting synchronous API.")] + public void CloseFile(string filePath) => editorOperations.CloseFileAsync(filePath).Wait(); + + /// + /// Saves an open file in the workspace. + /// + /// The path to the file to be saved. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Supporting synchronous API.")] + public void SaveFile(string filePath) => editorOperations.SaveFileAsync(filePath).Wait(); + + /// + /// Saves a file with a new name AKA a copy. + /// + /// The file to copy. + /// The file to create. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Supporting synchronous API.")] + public void SaveFile(string oldFilePath, string newFilePath) => editorOperations.SaveFileAsync(oldFilePath, newFilePath).Wait(); + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Extensions/FileContext.cs b/src/PowerShellEditorServices/Extensions/FileContext.cs new file mode 100644 index 0000000..39a4a27 --- /dev/null +++ b/src/PowerShellEditorServices/Extensions/FileContext.cs @@ -0,0 +1,254 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Management.Automation.Language; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Extensions +{ + /// + /// Provides context for a file that is open in the editor. + /// + public sealed class FileContext + { + #region Private Fields + + private readonly ScriptFile scriptFile; + private readonly EditorContext editorContext; + private readonly IEditorOperations editorOperations; + + #endregion + + #region Properties + + /// + /// Gets the parsed abstract syntax tree for the file. + /// + public Ast Ast => scriptFile.ScriptAst; + + /// + /// Gets a BufferRange which represents the entire content + /// range of the file. + /// + public IFileRange FileRange => new BufferFileRange(scriptFile.FileRange); + + /// + /// Gets the language of the file. + /// + public string Language { get; } + + /// + /// Gets the filesystem path of the file. + /// + public string Path => scriptFile.FilePath; + + /// + /// Gets the URI of the file. + /// + public Uri Uri { get; } + + /// + /// Gets the parsed token list for the file. + /// + public IReadOnlyList Tokens => scriptFile.ScriptTokens; + + /// + /// Gets the workspace-relative path of the file. + /// + public string WorkspacePath => editorOperations.GetWorkspaceRelativePath(scriptFile); + + #endregion + + #region Constructors + + /// + /// Creates a new instance of the FileContext class. + /// + /// The ScriptFile to which this file refers. + /// The EditorContext to which this file relates. + /// An IEditorOperations implementation which performs operations in the editor. + /// Determines the language of the file.false If it is not specified, then it defaults to "Unknown" + internal FileContext( + ScriptFile scriptFile, + EditorContext editorContext, + IEditorOperations editorOperations, + string language = "Unknown") + { + if (string.IsNullOrWhiteSpace(language)) + { + language = "Unknown"; + } + + this.scriptFile = scriptFile; + this.editorContext = editorContext; + this.editorOperations = editorOperations; + Language = language; + Uri = scriptFile.DocumentUri.ToUri(); + } + + #endregion + + #region Text Accessors + + /// + /// Gets the complete file content as a string. + /// + /// A string containing the complete file content. + public string GetText() => scriptFile.Contents; + + /// + /// Gets the file content in the specified range as a string. + /// + /// The buffer range for which content will be extracted. + /// A string with the specified range of content. + public string GetText(FileRange bufferRange) + { + return + string.Join( + Environment.NewLine, + GetTextLines(bufferRange)); + } + + /// + /// Gets the complete file content as an array of strings. + /// + /// An array of strings, each representing a line in the file. + public string[] GetTextLines() => scriptFile.FileLines.ToArray(); + + /// + /// Gets the file content in the specified range as an array of strings. + /// + /// The buffer range for which content will be extracted. + /// An array of strings, each representing a line in the file within the specified range. + public string[] GetTextLines(FileRange fileRange) => scriptFile.GetLinesInRange(fileRange.ToBufferRange()); + + #endregion + + #region Text Manipulation + + /// + /// Inserts a text string at the current cursor position represented by + /// the parent EditorContext's CursorPosition property. + /// + /// The text string to insert. + public void InsertText(string textToInsert) + { + // Is there a selection? + if (editorContext.SelectedRange.HasRange()) + { + InsertText( + textToInsert, + editorContext.SelectedRange); + } + else + { + InsertText( + textToInsert, + editorContext.CursorPosition); + } + } + + /// + /// Inserts a text string at the specified buffer position. + /// + /// The text string to insert. + /// The position at which the text will be inserted. + public void InsertText(string textToInsert, IFilePosition insertPosition) + { + InsertText( + textToInsert, + new FileRange(insertPosition, insertPosition)); + } + + /// + /// Inserts a text string at the specified line and column numbers. + /// + /// The text string to insert. + /// The 1-based line number at which the text will be inserted. + /// The 1-based column number at which the text will be inserted. + public void InsertText(string textToInsert, int insertLine, int insertColumn) + { + InsertText( + textToInsert, + new FilePosition(insertLine, insertColumn)); + } + + /// + /// Inserts a text string to replace the specified range, represented + /// by starting and ending line and column numbers. Can be used to + /// insert, replace, or delete text depending on the specified range + /// and text to insert. + /// + /// The text string to insert. + /// The 1-based starting line number where text will be replaced. + /// The 1-based starting column number where text will be replaced. + /// The 1-based ending line number where text will be replaced. + /// The 1-based ending column number where text will be replaced. + public void InsertText( + string textToInsert, + int startLine, + int startColumn, + int endLine, + int endColumn) + { + InsertText( + textToInsert, + new FileRange( + new FilePosition(startLine, startColumn), + new FilePosition(endLine, endColumn))); + } + + /// + /// Inserts a text string to replace the specified range. Can be + /// used to insert, replace, or delete text depending on the specified + /// range and text to insert. + /// + /// The text string to insert. + /// The buffer range which will be replaced by the string. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Supporting synchronous API.")] + public void InsertText(string textToInsert, IFileRange insertRange) + { + editorOperations + .InsertTextAsync(scriptFile.DocumentUri.ToString(), textToInsert, insertRange.ToBufferRange()) + .Wait(); + } + + #endregion + + #region File Manipulation + + /// + /// Saves this file. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD110:Observe result of async calls", Justification = "Supporting synchronous API.")] + public void Save() => editorOperations.SaveFileAsync(scriptFile.FilePath); + + /// + /// Save this file under a new path and open a new editor window on that file. + /// + /// + /// the path where the file should be saved, + /// including the file name with extension as the leaf + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Supporting synchronous API.")] + public void SaveAs(string newFilePath) + { + // Do some validation here so that we can provide a helpful error if the path won't work + string absolutePath = System.IO.Path.IsPathRooted(newFilePath) ? + newFilePath : + System.IO.Path.GetFullPath(System.IO.Path.Combine(System.IO.Path.GetDirectoryName(scriptFile.FilePath), newFilePath)); + + if (File.Exists(absolutePath)) + { + throw new IOException(string.Format("The file '{0}' already exists", absolutePath)); + } + + editorOperations.SaveFileAsync(scriptFile.FilePath, newFilePath).Wait(); + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Extensions/IEditorOperations.cs b/src/PowerShellEditorServices/Extensions/IEditorOperations.cs new file mode 100644 index 0000000..3ec33eb --- /dev/null +++ b/src/PowerShellEditorServices/Extensions/IEditorOperations.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Extensions +{ + /// + /// Provides an interface that must be implemented by an editor + /// host to perform operations invoked by extensions written in + /// PowerShell. + /// + internal interface IEditorOperations + { + /// + /// Gets the EditorContext for the editor's current state. + /// + /// A new EditorContext object. + Task GetEditorContextAsync(); + + /// + /// Gets the server's initial working directory, since the extension API doesn't have a + /// multi-root workspace concept. + /// + /// The server's initial working directory. + string GetWorkspacePath(); + + /// + /// Get all the workspace folders' paths. + /// + /// + string[] GetWorkspacePaths(); + + /// + /// Resolves the given file path relative to the current workspace path. + /// + /// The resolved file path. + string GetWorkspaceRelativePath(ScriptFile scriptFile); + + /// + /// Causes a new untitled file to be created in the editor. + /// + /// A task that can be awaited for completion. + Task NewFileAsync(); + + /// + /// Causes a new untitled file to be created in the editor. + /// + /// The content to insert into the new file. + /// A task that can be awaited for completion. + Task NewFileAsync(string content); + + /// + /// Causes a file to be opened in the editor. If the file is + /// already open, the editor must switch to the file. + /// + /// The path of the file to be opened. + /// A Task that can be tracked for completion. + Task OpenFileAsync(string filePath); + + /// + /// Causes a file to be opened in the editor. If the file is + /// already open, the editor must switch to the file. + /// You can specify whether the file opens as a preview or as a durable editor. + /// + /// The path of the file to be opened. + /// Determines wether the file is opened as a preview or as a durable editor. + /// A Task that can be tracked for completion. + Task OpenFileAsync(string filePath, bool preview); + + /// + /// Causes a file to be closed in the editor. + /// + /// The path of the file to be closed. + /// A Task that can be tracked for completion. + Task CloseFileAsync(string filePath); + + /// + /// Causes a file to be saved in the editor. + /// + /// The path of the file to be saved. + /// A Task that can be tracked for completion. + Task SaveFileAsync(string filePath); + + /// + /// Causes a file to be saved as a new file in a new editor window. + /// + /// the path of the current file being saved + /// the path of the new file where the current window content will be saved + /// + Task SaveFileAsync(string oldFilePath, string newFilePath); + + /// + /// Inserts text into the specified range for the file at the specified path. + /// + /// The path of the file which will have text inserted. + /// The text to insert into the file. + /// The range in the file to be replaced. + /// A Task that can be tracked for completion. + Task InsertTextAsync(string filePath, string insertText, BufferRange insertRange); + + /// + /// Causes the selection to be changed in the editor's active file buffer. + /// + /// The range over which the selection will be made. + /// A Task that can be tracked for completion. + Task SetSelectionAsync(BufferRange selectionRange); + + /// + /// Shows an informational message to the user. + /// + /// The message to be shown. + /// A Task that can be tracked for completion. + Task ShowInformationMessageAsync(string message); + + /// + /// Shows an error message to the user. + /// + /// The message to be shown. + /// A Task that can be tracked for completion. + Task ShowErrorMessageAsync(string message); + + /// + /// Shows a warning message to the user. + /// + /// The message to be shown. + /// A Task that can be tracked for completion. + Task ShowWarningMessageAsync(string message); + + /// + /// Sets the status bar message in the editor UI (if applicable). + /// + /// The message to be shown. + /// If non-null, a timeout in milliseconds for how long the message should remain visible. + /// A Task that can be tracked for completion. + Task SetStatusBarMessageAsync(string message, int? timeout); + + /// + /// Triggers to the editor to clear the terminal. + /// + void ClearTerminal(); + } +} diff --git a/src/PowerShellEditorServices/Hosting/EditorServicesLoading.cs b/src/PowerShellEditorServices/Hosting/EditorServicesLoading.cs new file mode 100644 index 0000000..f4b4d38 --- /dev/null +++ b/src/PowerShellEditorServices/Hosting/EditorServicesLoading.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.PowerShell.EditorServices.Hosting +{ + /// + /// Implementation-free class designed to safely allow PowerShell Editor Services to be loaded in an obvious way. + /// Referencing this class will force looking for and loading the PSES assembly if it's not already loaded. + /// + internal static class EditorServicesLoading + { + internal static void LoadEditorServicesForHost() + { + // No-op that forces loading this assembly + } + } +} diff --git a/src/PowerShellEditorServices/Hosting/EditorServicesServerFactory.cs b/src/PowerShellEditorServices/Hosting/EditorServicesServerFactory.cs new file mode 100644 index 0000000..b981ea4 --- /dev/null +++ b/src/PowerShellEditorServices/Hosting/EditorServicesServerFactory.cs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Server; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; +using Microsoft.PowerShell.EditorServices.Services.Extension; + +// The HostLogger type isn't directly referenced from this assembly, however it uses a common IObservable interface and this alias helps make it more clear the purpose. We can use Microsoft.Extensions.Logging from this point because the ALC should be loaded, but we need to only expose the IObservable to the Hosting assembly so it doesn't try to load MEL before the ALC is ready. +using HostLogger = System.IObservable<(int logLevel, string message)>; + +namespace Microsoft.PowerShell.EditorServices.Hosting +{ + /// + /// Factory for creating the LSP server and debug server instances. + /// + internal sealed class EditorServicesServerFactory : IDisposable + { + private readonly HostLogger _hostLogger; + + /// + /// Creates a loggerfactory for this instance + /// + /// The hostLogger that will be provided to the language services for logging handoff + internal EditorServicesServerFactory(HostLogger hostLogger) => _hostLogger = hostLogger; + + /// + /// Create the LSP server. + /// + /// + /// This is only called once and that's in . + /// + /// The protocol transport input stream. + /// The protocol transport output stream. + /// The host details configuration for Editor Services + /// instantiation. + /// A new, unstarted language server instance. + public PsesLanguageServer CreateLanguageServer( + Stream inputStream, + Stream outputStream, + HostStartupInfo hostStartupInfo) => new(_hostLogger, inputStream, outputStream, hostStartupInfo); + + /// + /// Create the debug server given a language server instance. + /// + /// + /// This is only called once and that's in . + /// + /// The protocol transport input stream. + /// The protocol transport output stream. + /// + /// A new, unstarted debug server instance. + public PsesDebugServer CreateDebugServerWithLanguageServer( + Stream inputStream, + Stream outputStream, + PsesLanguageServer languageServer) + { + return new PsesDebugServer( + _hostLogger, + inputStream, + outputStream, + languageServer.LanguageServer.Services); + } + + /// + /// Create a new debug server based on an old one in an ended session. + /// + /// + /// This is only called once and that's in . + /// + /// The protocol transport input stream. + /// The protocol transport output stream. + /// The old debug server to recreate. + /// + public PsesDebugServer RecreateDebugServer( + Stream inputStream, + Stream outputStream, + PsesDebugServer debugServer) + { + return new PsesDebugServer( + _hostLogger, + inputStream, + outputStream, + debugServer.ServiceProvider); + } + + /// + /// Create a standalone debug server for temp sessions. + /// + /// The protocol transport input stream. + /// The protocol transport output stream. + /// The host startup configuration to create the server session with. + /// + public PsesDebugServer CreateDebugServerForTempSession( + Stream inputStream, + Stream outputStream, + HostStartupInfo hostStartupInfo) + { + ServiceProvider serviceProvider = new ServiceCollection() + .AddLogging(builder => builder + .ClearProviders() + .SetMinimumLevel(LogLevel.Trace)) // TODO: Why randomly set to trace? + .AddSingleton(_ => null) + // TODO: Why add these for a debug server?! + .AddPsesLanguageServices(hostStartupInfo) + // For a Temp session, there is no LanguageServer so just set it to null + .AddSingleton( + typeof(ILanguageServerFacade), + _ => null) + .BuildServiceProvider(); + + // This gets the ExtensionService which triggers the creation of the `$psEditor` variable. + // (because services are created only when they are first retrieved) + // Keep in mind, for Temp sessions, the `$psEditor` API is a no-op and the user is warned + // to run the command in the main extension terminal. + serviceProvider.GetService(); + + return new PsesDebugServer( + _hostLogger, + inputStream, + outputStream, + serviceProvider, + isTemp: true); + } + + // TODO: Clean up host logger? Shouldn't matter since we start a new process after shutdown. + public void Dispose() { } + } +} diff --git a/src/PowerShellEditorServices/Hosting/HostStartupInfo.cs b/src/PowerShellEditorServices/Hosting/HostStartupInfo.cs new file mode 100644 index 0000000..d1f1b27 --- /dev/null +++ b/src/PowerShellEditorServices/Hosting/HostStartupInfo.cs @@ -0,0 +1,221 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Management.Automation.Host; +using System.Management.Automation.Runspaces; + +namespace Microsoft.PowerShell.EditorServices.Hosting +{ + /// + /// Contains details about the host as well as any other information needed by Editor Services + /// at startup time. + /// + /// + /// TODO: Simplify this as a . + /// + public sealed class HostStartupInfo + { + #region Constants + + /// + /// The default host name for PowerShell Editor Services. Used + /// if no host name is specified by the host application. + /// + private const string DefaultHostName = "PowerShell Editor Services Host"; + + /// + /// The default host ID for PowerShell Editor Services. Used + /// for the host-specific profile path if no host ID is specified. + /// + private const string DefaultHostProfileId = "Microsoft.PowerShellEditorServices"; + + /// + /// The default host version for PowerShell Editor Services. If + /// no version is specified by the host application, we use 0.0.0 + /// to indicate a lack of version. + /// + private static readonly Version s_defaultHostVersion = new(0, 0, 0); + + #endregion + + #region Properties + + /// + /// Gets the name of the host. + /// + public string Name { get; } + + /// + /// Gets the profile ID of the host, used to determine the + /// host-specific profile path. + /// + public string ProfileId { get; } + + /// + /// Gets the version of the host. + /// + public Version Version { get; } + + public ProfilePathInfo ProfilePaths { get; } + + /// + /// Any feature flags enabled at startup. + /// + public IReadOnlyList FeatureFlags { get; } + + /// + /// Names or paths of any additional modules to import on startup. + /// + public IReadOnlyList AdditionalModules { get; } + + /// + /// True if the Extension Terminal is to be enabled. + /// + public bool ConsoleReplEnabled { get; } + + /// + /// True if we want to suppress messages to PSHost (to prevent Stdio clobbering) + /// + public bool UseNullPSHostUI { get; } + + /// + /// If true, the legacy PSES readline implementation will be used. Otherwise PSReadLine will be used. + /// If the console REPL is not enabled, this setting will be ignored. + /// + public bool UsesLegacyReadLine { get; } + + /// + /// The PowerShell host to use with Editor Services. + /// + public PSHost PSHost { get; } + + /// + /// The path of the log file Editor Services should log to. + /// + public string LogPath { get; } + + /// + /// The InitialSessionState will be inherited from the original PowerShell process. This will + /// be used when creating runspaces so that we honor the same InitialSessionState. + /// + public InitialSessionState InitialSessionState { get; } + + /// + /// The minimum log level of log events to be logged. + /// + /// + /// This primitive maps to and + /// + public int LogLevel { get; } + + /// + /// The path to find the bundled modules. User configurable for advanced usage. + /// + public string BundledModulePath { get; } + + #endregion + + #region Constructors + + /// + /// Creates an instance of the HostDetails class. + /// + /// + /// The display name for the host, typically in the form of + /// "[Application Name] Host". + /// + /// + /// The identifier of the PowerShell host to use for its profile path. + /// loaded. Used to resolve a profile path of the form 'X_profile.ps1' + /// where 'X' represents the value of hostProfileId. If null, a default + /// will be used. + /// + /// The host application's version. + /// The PowerShell host to use. + /// The set of profile paths. + /// Flags of features to enable. + /// Names or paths of additional modules to import. + /// The language mode inherited from the orginal PowerShell process. This will be used when creating runspaces so that we honor the same initialSessionState including allowed modules, cmdlets and language mode. + /// The path to log to. + /// The minimum log event level. + /// Enable console if true. + /// Whether or not to use the Null UI. + /// Use PSReadLine if false, otherwise use the legacy readline implementation. + /// A custom path to the expected bundled modules. + public HostStartupInfo( + string name, + string profileId, + Version version, + PSHost psHost, + ProfilePathInfo profilePaths, + IReadOnlyList featureFlags, + IReadOnlyList additionalModules, + InitialSessionState initialSessionState, + string logPath, + int logLevel, + bool consoleReplEnabled, + bool useNullPSHostUI, + bool usesLegacyReadLine, + string bundledModulePath) + { + Name = name ?? DefaultHostName; + ProfileId = profileId ?? DefaultHostProfileId; + Version = version ?? s_defaultHostVersion; + PSHost = psHost; + ProfilePaths = profilePaths; + FeatureFlags = featureFlags ?? Array.Empty(); + AdditionalModules = additionalModules ?? Array.Empty(); + InitialSessionState = initialSessionState; + LogPath = logPath; + LogLevel = logLevel; + ConsoleReplEnabled = consoleReplEnabled; + UseNullPSHostUI = useNullPSHostUI; + UsesLegacyReadLine = usesLegacyReadLine; + + // Respect a user provided bundled module path. + BundledModulePath = Directory.Exists(bundledModulePath) + ? bundledModulePath + : Path.GetFullPath(Path.Combine( + Path.GetDirectoryName(typeof(HostStartupInfo).Assembly.Location), + "..", + "..", + "..")); + } + + #endregion + } + + /// + /// This is a strange class that is generally null or otherwise just has a single path + /// set. It is eventually parsed one-by-one when setting up the PowerShell runspace. + /// + /// + /// TODO: Simplify this as a . + /// + public sealed class ProfilePathInfo + { + public ProfilePathInfo( + string currentUserAllHosts, + string currentUserCurrentHost, + string allUsersAllHosts, + string allUsersCurrentHost) + { + CurrentUserAllHosts = currentUserAllHosts; + CurrentUserCurrentHost = currentUserCurrentHost; + AllUsersAllHosts = allUsersAllHosts; + AllUsersCurrentHost = allUsersCurrentHost; + } + + public string CurrentUserAllHosts { get; } + + public string CurrentUserCurrentHost { get; } + + public string AllUsersAllHosts { get; } + + public string AllUsersCurrentHost { get; } + } +} diff --git a/src/PowerShellEditorServices/IsExternalInit.cs b/src/PowerShellEditorServices/IsExternalInit.cs new file mode 100644 index 0000000..c690a18 --- /dev/null +++ b/src/PowerShellEditorServices/IsExternalInit.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#if NET5_0_OR_GREATER +[assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Runtime.CompilerServices.IsExternalInit))] +#else + +using System.ComponentModel; + +namespace System.Runtime.CompilerServices +{ + /// + /// Reserved to be used by the compiler for tracking metadata. + /// This class should not be used by developers in source code. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + internal static class IsExternalInit + { + } +} +#endif diff --git a/src/PowerShellEditorServices/Logging/HostLoggerAdapter.cs b/src/PowerShellEditorServices/Logging/HostLoggerAdapter.cs new file mode 100644 index 0000000..9ffe3c9 --- /dev/null +++ b/src/PowerShellEditorServices/Logging/HostLoggerAdapter.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using System; + +namespace Microsoft.PowerShell.EditorServices.Logging +{ + /// + /// Adapter class to allow logging events sent by the host to be recorded by PSES' logging infrastructure. + /// + internal class HostLoggerAdapter(ILogger logger) : IObserver<(int logLevel, string message)> + { + public void OnError(Exception error) => logger.LogError(error, "Error in host logger"); + + /// + /// Log the message received from the host into MEL. + /// + public void OnNext((int logLevel, string message) value) => logger.Log((LogLevel)value.logLevel, value.message); + + public void OnCompleted() + { + // Nothing to do; we simply don't send more log messages + } + } +} diff --git a/src/PowerShellEditorServices/Logging/LanguageServerLogger.cs b/src/PowerShellEditorServices/Logging/LanguageServerLogger.cs new file mode 100644 index 0000000..5d368f9 --- /dev/null +++ b/src/PowerShellEditorServices/Logging/LanguageServerLogger.cs @@ -0,0 +1,184 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Disposables; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; +using OmniSharp.Extensions.LanguageServer.Protocol.Window; + +namespace Microsoft.PowerShell.EditorServices.Logging; + +internal class LanguageServerLogger(ILanguageServerFacade responseRouter, string categoryName) : ILogger +{ + public IDisposable? BeginScope(TState state) where TState : notnull => Disposable.Empty; + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter + ) + { + if (responseRouter is null) + { + throw new InvalidOperationException("Log received without a valid responseRouter dependency. This is a bug, please report it."); + } + // Any Omnisharp or trace logs are directly LSP protocol related and we send them to the trace channel + // TODO: Dynamically adjust if SetTrace is reported + // BUG: There is an omnisharp filter incorrectly filtering this. As a workaround we will use logMessage for now. + // https://github.com/OmniSharp/csharp-language-server-protocol/issues/1390 + // + // { + // // Everything with omnisharp goes directly to trace + // string eventMessage = string.Empty; + // string exceptionName = exception?.GetType().Name ?? string.Empty; + // if (eventId.Name is not null) + // { + // eventMessage = eventId.Id == 0 ? eventId.Name : $"{eventId.Name} [{eventId.Id}] "; + // } + + // LogTraceParams trace = new() + // { + // Message = categoryName + ": " + eventMessage + exceptionName, + // Verbose = formatter(state, exception) + // }; + // responseRouter.Client.LogTrace(trace); + // } + + // Drop all omnisharp messages to trace. This isn't a MEL filter because it's specific only to this provider. + if (categoryName.StartsWith("OmniSharp.", StringComparison.OrdinalIgnoreCase)) + { + logLevel = LogLevel.Trace; + } + + (MessageType messageType, string messagePrepend) = GetMessageInfo(logLevel); + + // The vscode-languageserver-node client doesn't support LogOutputChannel as of 2024-11-24 and also doesn't + // provide a way to middleware the incoming log messages, so our output channel has no idea what the logLevel + // is. As a workaround, we send the severity in-line with the message for the client to parse. + // BUG: https://github.com/microsoft/vscode-languageserver-node/issues/1116 + if (responseRouter.Client?.ClientSettings?.ClientInfo?.Name == "Visual Studio Code") + { + messagePrepend = logLevel switch + { + LogLevel.Critical => "CRITICAL: ", + LogLevel.Error => "", + LogLevel.Warning => "", + LogLevel.Information => "", + LogLevel.Debug => "", + LogLevel.Trace => "", + _ => string.Empty + }; + + // The vscode formatter prepends some extra stuff to Info specifically, so we drop Info to Log, but it will get logged correctly on the other side thanks to our inline indicator that our custom parser on the other side will pick up and process. + if (messageType == MessageType.Info) + { + messageType = MessageType.Log; + } + } + + LogMessageParams logMessage = new() + { + Type = messageType, + Message = messagePrepend + categoryName + ": " + formatter(state, exception) + + (exception != null ? " - " + exception : "") + " | " + + //Hopefully this isn't too expensive in the long run + FormatState(state, exception) + }; + responseRouter.Window.Log(logMessage); + } + + /// + /// Formats the state object into a string for logging. + /// + /// + /// This is copied from Omnisharp, we can probably do better. + /// + /// + /// + /// + /// + private static string FormatState(TState state, Exception? exception) + { + return state switch + { + IEnumerable> dict => string.Join(" ", dict.Where(z => z.Key != "{OriginalFormat}").Select(z => $"{z.Key}='{z.Value}'")), + _ => JsonConvert.SerializeObject(state).Replace("\"", "'") + }; + } + + /// + /// Maps MEL log levels to LSP message types + /// + private static (MessageType messageType, string messagePrepend) GetMessageInfo(LogLevel logLevel) + => logLevel switch + { + LogLevel.Critical => (MessageType.Error, "Critical: "), + LogLevel.Error => (MessageType.Error, string.Empty), + LogLevel.Warning => (MessageType.Warning, string.Empty), + LogLevel.Information => (MessageType.Info, string.Empty), + LogLevel.Debug => (MessageType.Log, string.Empty), + LogLevel.Trace => (MessageType.Log, "Trace: "), + _ => throw new ArgumentOutOfRangeException(nameof(logLevel), logLevel, null) + }; +} + +internal class LanguageServerLoggerProvider(ILanguageServerFacade languageServer) : ILoggerProvider +{ + public ILogger CreateLogger(string categoryName) => new LanguageServerLogger(languageServer, categoryName); + + public void Dispose() { } +} + +public static class LanguageServerLoggerExtensions +{ + /// + /// Adds a custom logger provider for PSES LSP, that provides more granular categorization than the default Omnisharp logger, such as separating Omnisharp and PSES messages to different channels. + /// + public static ILoggingBuilder AddPsesLanguageServerLogging(this ILoggingBuilder builder) + { + builder.Services.AddSingleton(); + return builder; + } + + public static ILoggingBuilder AddLspClientConfigurableMinimumLevel( + this ILoggingBuilder builder, + LogLevel initialLevel = LogLevel.Trace + ) + { + builder.Services.AddOptions(); + builder.Services.AddSingleton(sp => + { + IOptionsMonitor optionsMonitor = sp.GetRequiredService>(); + return new(initialLevel, optionsMonitor); + }); + builder.Services.AddSingleton>(sp => + sp.GetRequiredService()); + + return builder; + } +} + +internal class DynamicLogLevelOptions( + LogLevel initialLevel, + IOptionsMonitor optionsMonitor) : IConfigureOptions +{ + private LogLevel _currentLevel = initialLevel; + private readonly IOptionsMonitor _optionsMonitor = optionsMonitor; + + public void Configure(LoggerFilterOptions options) => options.MinLevel = _currentLevel; + + public void SetLogLevel(LogLevel level) + { + _currentLevel = level; + // Trigger reload of options to apply new log level + _optionsMonitor.CurrentValue.MinLevel = level; + } +} diff --git a/src/PowerShellEditorServices/Logging/LoggerExtensions.cs b/src/PowerShellEditorServices/Logging/LoggerExtensions.cs new file mode 100644 index 0000000..94c821e --- /dev/null +++ b/src/PowerShellEditorServices/Logging/LoggerExtensions.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Logging; + +namespace Microsoft.PowerShell.EditorServices.Logging +{ + internal static class LoggerExtensions + { + // TODO: These need to be fixed (and used consistently). + public static void LogException( + this ILogger logger, + string message, + Exception exception, + [CallerMemberName] string callerName = null, + [CallerFilePath] string callerSourceFile = null, + [CallerLineNumber] int callerLineNumber = -1) => logger.LogError(message, exception); + + public static void LogHandledException( + this ILogger logger, + string message, + Exception exception, + [CallerMemberName] string callerName = null, + [CallerFilePath] string callerSourceFile = null, + [CallerLineNumber] int callerLineNumber = -1) => logger.LogWarning(message, exception); + } +} diff --git a/src/PowerShellEditorServices/Logging/PsesTelemetryEvent.cs b/src/PowerShellEditorServices/Logging/PsesTelemetryEvent.cs new file mode 100644 index 0000000..079b8b4 --- /dev/null +++ b/src/PowerShellEditorServices/Logging/PsesTelemetryEvent.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using Newtonsoft.Json.Linq; + +namespace Microsoft.PowerShell.EditorServices.Logging +{ + // This inherits from Dictionary so that it can be passed in to SendTelemetryEvent() + // which takes in an IDictionary + // However, I wanted creation to be easy so you can do + // new PsesTelemetryEvent { EventName = "eventName", Data = data } + internal class PsesTelemetryEvent : Dictionary + { + public string EventName + { + get => this["EventName"].ToString() ?? "PsesEvent"; + set => this["EventName"] = value; + } + + public JObject Data + { + get => this["Data"] as JObject ?? new JObject(); + set => this["Data"] = value; + } + } +} diff --git a/src/PowerShellEditorServices/PowerShellEditorServices.csproj b/src/PowerShellEditorServices/PowerShellEditorServices.csproj new file mode 100644 index 0000000..18948aa --- /dev/null +++ b/src/PowerShellEditorServices/PowerShellEditorServices.csproj @@ -0,0 +1,65 @@ + + + + + PowerShell Editor Services + Provides common PowerShell editor capabilities as a .NET library. + netstandard2.0 + Microsoft.PowerShell.EditorServices + Debug;Release + + + + + + + <_Parameter1>Microsoft.PowerShell.EditorServices.Hosting + + + <_Parameter1>Microsoft.PowerShell.EditorServices.Test + + + <_Parameter1>PowerShellEditorServices.Test.E2E + + + <_Parameter1>Microsoft.PowerShell.EditorServices.Test.Shared + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/PowerShellEditorServices/Server/PsesDebugServer.cs b/src/PowerShellEditorServices/Server/PsesDebugServer.cs new file mode 100644 index 0000000..9f48a0f --- /dev/null +++ b/src/PowerShellEditorServices/Server/PsesDebugServer.cs @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.PowerShell.EditorServices.Handlers; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; +using OmniSharp.Extensions.DebugAdapter.Server; +using OmniSharp.Extensions.LanguageServer.Server; + +// See EditorServicesServerFactory.cs for the explanation of this alias. +using HostLogger = System.IObservable<(int logLevel, string message)>; + +namespace Microsoft.PowerShell.EditorServices.Server +{ + /// + /// Server for hosting debug sessions. + /// + internal class PsesDebugServer : IDisposable + { + private readonly Stream _inputStream; + private readonly Stream _outputStream; + private readonly TaskCompletionSource _serverStopped; + private DebugAdapterServer _debugAdapterServer; + private PsesInternalHost _psesHost; + private bool _startedPses; + private readonly bool _isTemp; + // FIXME: This was never actually used in the debug server. Since we never have a debug server without an LSP, we could probably remove this and either reuse the MEL from the LSP, or create a new one here. It is probably best to only use this for exceptions that we can't reasonably send via the DAP protocol, which should only be anything before the initialize request. + protected readonly HostLogger _hostLogger; + + public PsesDebugServer( + HostLogger hostLogger, + Stream inputStream, + Stream outputStream, + IServiceProvider serviceProvider, + bool isTemp = false) + { + _hostLogger = hostLogger; + _inputStream = inputStream; + _outputStream = outputStream; + ServiceProvider = serviceProvider; + _isTemp = isTemp; + _serverStopped = new TaskCompletionSource(); + } + + internal IServiceProvider ServiceProvider { get; } + + /// + /// Start the debug server listening. + /// + /// A task that completes when the server is ready. + public async Task StartAsync() + { + _debugAdapterServer = await DebugAdapterServer.From(options => + { + // We need to let the PowerShell Context Service know that we are in a debug session + // so that it doesn't send the powerShell/startDebugger message. + _psesHost = ServiceProvider.GetService(); + _psesHost.DebugContext.IsDebugServerActive = true; + + options + .WithInput(_inputStream) + .WithOutput(_outputStream) + .WithServices(serviceCollection => + serviceCollection + .AddOptions() + .AddPsesDebugServices(ServiceProvider, this)) + // TODO: Consider replacing all WithHandler with AddSingleton + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + // The OnInitialize delegate gets run when we first receive the _Initialize_ request: + // https://microsoft.github.io/debug-adapter-protocol/specification#Requests_Initialize + .OnInitialize(async (server, _, cancellationToken) => + { + // Start the host if not already started, and enable debug mode (required + // for remote debugging). + // + // TODO: We might need to fill in HostStartOptions here. + _startedPses = !await _psesHost.TryStartAsync(new HostStartOptions(), cancellationToken).ConfigureAwait(false); + _psesHost.DebugContext.EnableDebugMode(); + + // We need to give the host a handle to the DAP so it can register + // notifications (specifically for sendKeyPress). + if (_isTemp) + { + _psesHost.DebugServer = server; + } + + // Clear any existing breakpoints before proceeding. + BreakpointService breakpointService = server.GetService(); + await breakpointService.RemoveAllBreakpointsAsync().ConfigureAwait(false); + }) + // The OnInitialized delegate gets run right before the server responds to the _Initialize_ request: + // https://microsoft.github.io/debug-adapter-protocol/specification#Requests_Initialize + .OnInitialized((_, _, response, _) => + { + response.SupportsConditionalBreakpoints = true; + response.SupportsConfigurationDoneRequest = true; + response.SupportsFunctionBreakpoints = true; + response.SupportsHitConditionalBreakpoints = true; + response.SupportsLogPoints = true; + response.SupportsSetVariable = true; + response.SupportsDelayedStackTraceLoading = true; + + return Task.CompletedTask; + }) + ; + }).ConfigureAwait(false); + } + + public void Dispose() + { + // Note that the lifetime of the DebugContext is longer than the debug server; + // It represents the debugger on the PowerShell process we're in, + // while a new debug server is spun up for every debugging session + _psesHost.DebugContext.IsDebugServerActive = false; + _debugAdapterServer?.Dispose(); + _inputStream.Dispose(); + _outputStream.Dispose(); + _serverStopped.SetResult(true); + // TODO: If the debugger has stopped, should we clear the breakpoints? + } + + public async Task WaitForShutdown() + { + await _serverStopped.Task.ConfigureAwait(false); + + // If we started the host, we need to ensure any errors are marshalled back to us like this + if (_startedPses) + { + _psesHost.TriggerShutdown(); + await _psesHost.Shutdown.ConfigureAwait(false); + } + } + + #region Events + + public event EventHandler SessionEnded; + + internal void OnSessionEnded() => SessionEnded?.Invoke(this, null); + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Server/PsesLanguageServer.cs b/src/PowerShellEditorServices/Server/PsesLanguageServer.cs new file mode 100644 index 0000000..c106d34 --- /dev/null +++ b/src/PowerShellEditorServices/Server/PsesLanguageServer.cs @@ -0,0 +1,193 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Handlers; +using Microsoft.PowerShell.EditorServices.Hosting; +using Microsoft.PowerShell.EditorServices.Logging; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.Extension; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; +using Newtonsoft.Json.Linq; +using OmniSharp.Extensions.JsonRpc; +using OmniSharp.Extensions.LanguageServer.Protocol.General; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; +using OmniSharp.Extensions.LanguageServer.Server; + +// See EditorServicesServerFactory.cs for the explanation of this alias. +using HostLogger = System.IObservable<(int logLevel, string message)>; + +namespace Microsoft.PowerShell.EditorServices.Server +{ + /// + /// Server runner class for handling LSP messages for Editor Services. + /// + internal class PsesLanguageServer + { + internal HostLogger HostLogger { get; } + internal ILanguageServer LanguageServer { get; private set; } + private readonly LogLevel _minimumLogLevel; + private readonly Stream _inputStream; + private readonly Stream _outputStream; + private readonly HostStartupInfo _hostDetails; + private readonly TaskCompletionSource _serverStart; + private PsesInternalHost _psesHost; + private IDisposable hostLoggerSubscription; + + /// + /// Create a new language server instance. + /// + /// + /// This class is only ever instantiated via . It is essentially a + /// singleton. The factory hides the logger. + /// + /// The host logger to hand off for monitoring. + /// Protocol transport input stream. + /// Protocol transport output stream. + /// Host configuration to instantiate the server and services + /// with. + public PsesLanguageServer( + HostLogger hostLogger, + Stream inputStream, + Stream outputStream, + HostStartupInfo hostStartupInfo) + { + HostLogger = hostLogger; + _minimumLogLevel = (LogLevel)hostStartupInfo.LogLevel; + _inputStream = inputStream; + _outputStream = outputStream; + _hostDetails = hostStartupInfo; + _serverStart = new TaskCompletionSource(); + } + + /// + /// Start the server listening for input. + /// + /// + /// For the services (including the + /// context wrapper around PowerShell itself) see . + /// + /// A task that completes when the server is ready and listening. + public async Task StartAsync() + { + LanguageServer = await OmniSharp.Extensions.LanguageServer.Server.LanguageServer.From(options => + { + options + .WithInput(_inputStream) + .WithOutput(_outputStream) + .WithServices(serviceCollection => + { + // NOTE: This adds a lot of services! + serviceCollection.AddPsesLanguageServices(_hostDetails); + }) + .ConfigureLogging(builder => builder + .ClearProviders() + .AddPsesLanguageServerLogging() + .SetMinimumLevel(_minimumLogLevel)) + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + // If PsesCompletionHandler is not marked as serial, then DidChangeTextDocument + // notifications will end up cancelling completion. So quickly typing `Get-` + // would result in no completions. + // + // This also lets completion requests interrupt time consuming background tasks + // like the references code lens. + .WithHandler( + new JsonRpcHandlerOptions() { RequestProcessType = RequestProcessType.Serial }) + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + // NOTE: The OnInitialize delegate gets run when we first receive the + // _Initialize_ request: + // https://microsoft.github.io/language-server-protocol/specifications/specification-current/#initialize + .OnInitialize( + (languageServer, initializeParams, cancellationToken) => + { + // Wire up the HostLogger to the LanguageServer's logger once we are initialized, so that any messages still logged to the HostLogger get sent across the LSP channel. There is no more logging to disk at this point. + hostLoggerSubscription = HostLogger.Subscribe(new HostLoggerAdapter( + languageServer.Services.GetService>() + )); + + // Set the workspace path from the parameters. + WorkspaceService workspaceService = languageServer.Services.GetService(); + if (initializeParams.WorkspaceFolders is not null) + { + workspaceService.WorkspaceFolders.AddRange(initializeParams.WorkspaceFolders); + } + + // Parse initialization options. + JObject initializationOptions = initializeParams.InitializationOptions as JObject; + HostStartOptions hostStartOptions = new() + { + // TODO: We need to synchronize our "default" settings as specified + // in the VS Code extension's package.json with the actual default + // values in this project. For now, this is going to be the most + // annoying setting, so we're defaulting this to true. + // + // NOTE: The keys start with a lowercase because OmniSharp's client + // (used for testing) forces it to be that way. + LoadProfiles = initializationOptions?.GetValue("enableProfileLoading")?.Value() + ?? true, + // First check the setting, then use the first workspace folder, + // finally fall back to CWD. + InitialWorkingDirectory = initializationOptions?.GetValue("initialWorkingDirectory")?.Value() + ?? workspaceService.WorkspaceFolders.FirstOrDefault()?.Uri.GetFileSystemPath() + ?? Directory.GetCurrentDirectory(), + // If a shell integration script path is provided, that implies the feature is enabled. + ShellIntegrationScript = initializationOptions?.GetValue("shellIntegrationScript")?.Value() + ?? "", + }; + + workspaceService.InitialWorkingDirectory = hostStartOptions.InitialWorkingDirectory; + + _psesHost = languageServer.Services.GetService(); + return _psesHost.TryStartAsync(hostStartOptions, cancellationToken); + } + ) + .OnShutdown(_ => hostLoggerSubscription.Dispose()); + }).ConfigureAwait(false); + + _serverStart.SetResult(true); + } + + /// + /// Get a task that completes when the server is shut down. + /// + /// A task that completes when the server is shut down. + public async Task WaitForShutdown() + { + await _serverStart.Task.ConfigureAwait(false); + await LanguageServer.WaitForExit.ConfigureAwait(false); + + // Doing this means we're able to route through any exceptions experienced on the pipeline thread + _psesHost.TriggerShutdown(); + await _psesHost.Shutdown.ConfigureAwait(false); + } + } +} diff --git a/src/PowerShellEditorServices/Server/PsesServiceCollectionExtensions.cs b/src/PowerShellEditorServices/Server/PsesServiceCollectionExtensions.cs new file mode 100644 index 0000000..82081c3 --- /dev/null +++ b/src/PowerShellEditorServices/Server/PsesServiceCollectionExtensions.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.PowerShell.EditorServices.Hosting; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.Extension; +using Microsoft.PowerShell.EditorServices.Services.PowerShell; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Debugging; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; + +namespace Microsoft.PowerShell.EditorServices.Server +{ + internal static class PsesServiceCollectionExtensions + { + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD110:Observe result of async calls", Justification = "Using lazy initialization.")] + public static IServiceCollection AddPsesLanguageServices( + this IServiceCollection collection, + HostStartupInfo hostStartupInfo) + { + return collection + .AddSingleton(hostStartupInfo) + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton( + (provider) => provider.GetService()) + .AddSingleton( + (provider) => provider.GetService()) + .AddSingleton() + .AddSingleton( + (provider) => provider.GetService().DebugContext) + .AddSingleton() + .AddSingleton() + .AddSingleton((provider) => + { + ExtensionService extensionService = new( + provider.GetService(), + provider, + provider.GetService(), + provider.GetService()); + + // This is where we create the $psEditor variable so that when the console + // is ready, it will be available. NOTE: We cannot await this because it + // uses a lazy initialization to avoid a race with the dependency injection + // framework, see the EditorObject class for that! + extensionService.InitializeAsync(); + return extensionService; + }) + .AddSingleton(); + } + + public static IServiceCollection AddPsesDebugServices( + this IServiceCollection collection, + IServiceProvider languageServiceProvider, + PsesDebugServer psesDebugServer) + { + PsesInternalHost internalHost = languageServiceProvider.GetService(); + + return collection + .AddSingleton(internalHost) + .AddSingleton(internalHost) + .AddSingleton(internalHost.DebugContext) + .AddSingleton(languageServiceProvider.GetService()) + .AddSingleton(languageServiceProvider.GetService()) + .AddSingleton(languageServiceProvider.GetService()) + .AddSingleton(psesDebugServer) + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton(); + } + } +} diff --git a/src/PowerShellEditorServices/Services/Analysis/AnalysisService.cs b/src/PowerShellEditorServices/Services/Analysis/AnalysisService.cs new file mode 100644 index 0000000..f346cb6 --- /dev/null +++ b/src/PowerShellEditorServices/Services/Analysis/AnalysisService.cs @@ -0,0 +1,519 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Hosting; +using Microsoft.PowerShell.EditorServices.Services.Analysis; +using Microsoft.PowerShell.EditorServices.Services.Configuration; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Document; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; + +namespace Microsoft.PowerShell.EditorServices.Services +{ + /// + /// Provides a high-level service for performing semantic analysis + /// of PowerShell scripts. + /// + internal class AnalysisService : IDisposable + { + /// + /// Reliably generate an ID for a diagnostic record to track it. + /// + /// The diagnostic to generate an ID for. + /// A string unique to this diagnostic given where and what kind it is. + internal static string GetUniqueIdFromDiagnostic(Diagnostic diagnostic) + { + Position start = diagnostic.Range.Start; + Position end = diagnostic.Range.End; + + StringBuilder sb = new StringBuilder(256) + .Append(diagnostic.Source ?? "?") + .Append('_') + .Append(diagnostic.Code?.IsString ?? true ? diagnostic.Code?.String : diagnostic.Code?.Long.ToString()) + .Append('_') + .Append(diagnostic.Severity?.ToString() ?? "?") + .Append('_') + .Append(start.Line) + .Append(':') + .Append(start.Character) + .Append('-') + .Append(end.Line) + .Append(':') + .Append(end.Character); + + return sb.ToString(); + } + + /// + /// Defines the list of Script Analyzer rules to include by default if + /// no settings file is specified. + /// + internal static readonly string[] s_defaultRules = { + "PSAvoidAssignmentToAutomaticVariable", + "PSUseToExportFieldsInManifest", + "PSMisleadingBacktick", + "PSAvoidUsingCmdletAliases", + "PSUseApprovedVerbs", + "PSAvoidUsingPlainTextForPassword", + "PSReservedCmdletChar", + "PSReservedParams", + "PSShouldProcess", + "PSMissingModuleManifestField", + "PSAvoidDefaultValueSwitchParameter", + "PSUseDeclaredVarsMoreThanAssignments", + "PSPossibleIncorrectComparisonWithNull", + "PSAvoidDefaultValueForMandatoryParameter", + "PSPossibleIncorrectUsageOfRedirectionOperator" + }; + + private readonly ILoggerFactory _loggerFactory; + + private readonly ILogger _logger; + + private readonly ILanguageServerFacade _languageServer; + + private readonly ConfigurationService _configurationService; + + private readonly WorkspaceService _workspaceService; + + private readonly int _analysisDelayMillis = 750; + + private readonly ConcurrentDictionary _mostRecentCorrectionsByFile = new(); + + private Lazy _analysisEngineLazy; + + private CancellationTokenSource _diagnosticsCancellationTokenSource; + + private readonly string _pssaModulePath; + + private string _pssaSettingsFilePath; + + public AnalysisService( + ILoggerFactory loggerFactory, + ILanguageServerFacade languageServer, + ConfigurationService configurationService, + WorkspaceService workspaceService, + HostStartupInfo hostInfo) + { + _loggerFactory = loggerFactory; + _logger = loggerFactory.CreateLogger(); + _languageServer = languageServer; + _configurationService = configurationService; + _workspaceService = workspaceService; + _analysisEngineLazy = new Lazy(InstantiateAnalysisEngine); + _pssaModulePath = Path.Combine(hostInfo.BundledModulePath, "PSScriptAnalyzer"); + } + + /// + /// The analysis engine to use for running script analysis. + /// + internal PssaCmdletAnalysisEngine AnalysisEngine => _analysisEngineLazy?.Value; + + /// + /// Sets up a script analysis run, eventually returning the result. + /// + /// The files to run script analysis on. + /// A task that finishes when script diagnostics have been published. + public void StartScriptDiagnostics(ScriptFile[] filesToAnalyze) + { + if (!_configurationService.CurrentSettings.ScriptAnalysis.Enable) + { + return; + } + + EnsureEngineSettingsCurrent(); + + // If there's an existing task, we want to cancel it here; + CancellationTokenSource cancellationSource = new(); + CancellationTokenSource oldTaskCancellation = Interlocked.Exchange(ref _diagnosticsCancellationTokenSource, cancellationSource); + if (oldTaskCancellation is not null) + { + try + { + oldTaskCancellation.Cancel(); + oldTaskCancellation.Dispose(); + } + catch (Exception e) + { + _logger.LogError(e, "Exception occurred while cancelling analysis task"); + } + } + + if (filesToAnalyze.Length == 0) + { + return; + } + + Task analysisTask = Task.Run(() => DelayThenInvokeDiagnosticsAsync(filesToAnalyze, _diagnosticsCancellationTokenSource.Token), _diagnosticsCancellationTokenSource.Token); + + // Ensure that any next corrections request will wait for this diagnostics publication + foreach (ScriptFile file in filesToAnalyze) + { + CorrectionTableEntry fileCorrectionsEntry = _mostRecentCorrectionsByFile.GetOrAdd( + file, + CorrectionTableEntry.CreateForFile); + + fileCorrectionsEntry.DiagnosticPublish = analysisTask; + } + } + + /// + /// Formats a PowerShell script with the given settings. + /// + /// The script to format. + /// The settings to use with the formatter. + /// Optionally, the range that should be formatted. + /// The text of the formatted PowerShell script. + public Task FormatAsync(string scriptFileContents, Hashtable formatSettings, int[] formatRange = null) + { + EnsureEngineSettingsCurrent(); + return AnalysisEngine.FormatAsync(scriptFileContents, formatSettings, formatRange); + } + + /// + /// Get comment help text for a PowerShell function definition. + /// + /// The text of the function to get comment help for. + /// A string referring to which location comment help should be placed around the function. + /// If true, block comment help will be supplied. + /// + public async Task GetCommentHelpText(string functionText, string helpLocation, bool forBlockComment) + { + if (AnalysisEngine is null) + { + return null; + } + + Hashtable commentHelpSettings = GetCommentHelpRuleSettings(helpLocation, forBlockComment); + + ScriptFileMarker[] analysisResults = await AnalysisEngine.AnalyzeScriptAsync(functionText, commentHelpSettings).ConfigureAwait(false); + + if (analysisResults.Length == 0 || !analysisResults[0].Corrections.Any()) + { + return null; + } + + return analysisResults[0].Corrections.First().Edit.Text; + } + + /// + /// Get the most recent corrections computed for a given script file. + /// + /// The URI string of the file to get code actions for. + /// A thread-safe readonly dictionary of the code actions of the particular file. + public async Task>> GetMostRecentCodeActionsForFileAsync(DocumentUri uri) + { + if (!_workspaceService.TryGetFile(uri, out ScriptFile file) + || !_mostRecentCorrectionsByFile.TryGetValue(file, out CorrectionTableEntry corrections)) + { + return null; + } + + // Wait for diagnostics to be published for this file + await corrections.DiagnosticPublish.ConfigureAwait(false); + + return corrections.Corrections; + } + + /// + /// Clear all diagnostic markers for a given file. + /// + /// The file to clear markers in. + /// A task that ends when all markers in the file have been cleared. + public void ClearMarkers(ScriptFile file) => PublishScriptDiagnostics(file, new List()); + + /// + /// Event subscription method to be run when PSES configuration has been updated. + /// + /// The sender of the configuration update event. + /// The new language server settings. + public void OnConfigurationUpdated(object _, LanguageServerSettings settings) + { + if (settings.ScriptAnalysis.Enable) + { + InitializeAnalysisEngineToCurrentSettings(); + } + } + + private void EnsureEngineSettingsCurrent() + { + if (_analysisEngineLazy is null + || (_pssaSettingsFilePath is not null + && !File.Exists(_pssaSettingsFilePath))) + { + InitializeAnalysisEngineToCurrentSettings(); + } + } + + private void InitializeAnalysisEngineToCurrentSettings() + { + // We may be triggered after the lazy factory is set, + // but before it's been able to instantiate + if (_analysisEngineLazy is null) + { + _analysisEngineLazy = new Lazy(InstantiateAnalysisEngine); + return; + } + else if (!_analysisEngineLazy.IsValueCreated) + { + return; + } + + // Retrieve the current script analysis engine so we can recreate it after we've overridden it + PssaCmdletAnalysisEngine currentAnalysisEngine = AnalysisEngine; + + // Clear the open file markers and set the new engine factory + ClearOpenFileMarkers(); + _analysisEngineLazy = new Lazy(() => RecreateAnalysisEngine(currentAnalysisEngine)); + } + + internal PssaCmdletAnalysisEngine InstantiateAnalysisEngine() + { + PssaCmdletAnalysisEngine.Builder pssaCmdletEngineBuilder = new(_loggerFactory); + + // If there's a settings file use that + if (TryFindSettingsFile(out string settingsFilePath)) + { + _logger.LogInformation($"Configuring PSScriptAnalyzer with rules at '{settingsFilePath}'"); + _pssaSettingsFilePath = settingsFilePath; + pssaCmdletEngineBuilder.WithSettingsFile(settingsFilePath); + } + else + { + _logger.LogInformation("PSScriptAnalyzer settings file not found. Falling back to default rules"); + pssaCmdletEngineBuilder.WithIncludedRules(s_defaultRules); + } + + return pssaCmdletEngineBuilder.Build(_pssaModulePath); + } + + private PssaCmdletAnalysisEngine RecreateAnalysisEngine(PssaCmdletAnalysisEngine oldAnalysisEngine) + { + if (TryFindSettingsFile(out string settingsFilePath)) + { + _logger.LogInformation($"Recreating analysis engine with rules at '{settingsFilePath}'"); + _pssaSettingsFilePath = settingsFilePath; + return oldAnalysisEngine.RecreateWithNewSettings(settingsFilePath); + } + + _logger.LogInformation("PSScriptAnalyzer settings file not found. Falling back to default rules"); + return oldAnalysisEngine.RecreateWithRules(s_defaultRules); + } + + private bool TryFindSettingsFile(out string settingsFilePath) + { + string configuredPath = _configurationService?.CurrentSettings.ScriptAnalysis.SettingsPath; + + if (string.IsNullOrEmpty(configuredPath)) + { + settingsFilePath = null; + return false; + } + + settingsFilePath = _workspaceService?.FindFileInWorkspace(configuredPath); + + if (settingsFilePath is null + || !File.Exists(settingsFilePath)) + { + _logger.LogInformation($"Unable to find PSSA settings file at '{configuredPath}'. Loading default rules."); + settingsFilePath = null; + return false; + } + + _logger.LogInformation($"Found PSSA settings file at '{settingsFilePath}'"); + + return true; + } + + private void ClearOpenFileMarkers() + { + foreach (ScriptFile file in _workspaceService.GetOpenedFiles()) + { + ClearMarkers(file); + } + } + + internal async Task DelayThenInvokeDiagnosticsAsync(ScriptFile[] filesToAnalyze, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + try + { + await Task.Delay(_analysisDelayMillis, cancellationToken).ConfigureAwait(false); + } + catch (TaskCanceledException) + { + return; + } + + // If we've made it past the delay period then we don't care + // about the cancellation token anymore. This could happen + // when the user stops typing for long enough that the delay + // period ends but then starts typing while analysis is going + // on. It makes sense to send back the results from the first + // delay period while the second one is ticking away. + + foreach (ScriptFile scriptFile in filesToAnalyze) + { + ScriptFileMarker[] semanticMarkers = await AnalysisEngine.AnalyzeScriptAsync(scriptFile.Contents).ConfigureAwait(false); + + // Clear existing PSScriptAnalyzer markers (but keep parser errors where the source is "PowerShell") + // so that they are not duplicated when re-opening files. + scriptFile.DiagnosticMarkers.RemoveAll(m => m.Source == "PSScriptAnalyzer"); + scriptFile.DiagnosticMarkers.AddRange(semanticMarkers); + + PublishScriptDiagnostics(scriptFile); + } + } + + private void PublishScriptDiagnostics(ScriptFile scriptFile) => PublishScriptDiagnostics(scriptFile, scriptFile.DiagnosticMarkers); + + private void PublishScriptDiagnostics(ScriptFile scriptFile, List markers) + { + // NOTE: Sometimes we have null markers for reasons we don't yet know, but we need to + // remove them. + _ = markers.RemoveAll(m => m is null); + Diagnostic[] diagnostics = new Diagnostic[markers.Count]; + + CorrectionTableEntry fileCorrections = _mostRecentCorrectionsByFile.GetOrAdd( + scriptFile, + CorrectionTableEntry.CreateForFile); + + fileCorrections.Corrections.Clear(); + + for (int i = 0; i < markers.Count; i++) + { + ScriptFileMarker marker = markers[i]; + + Diagnostic diagnostic = GetDiagnosticFromMarker(marker); + + if (marker.Corrections is not null) + { + string diagnosticId = GetUniqueIdFromDiagnostic(diagnostic); + fileCorrections.Corrections[diagnosticId] = marker.Corrections; + } + + diagnostics[i] = diagnostic; + } + + _languageServer?.TextDocument.PublishDiagnostics(new PublishDiagnosticsParams + { + Uri = scriptFile.DocumentUri, + Diagnostics = new Container(diagnostics) + }); + } + + private static Diagnostic GetDiagnosticFromMarker(ScriptFileMarker scriptFileMarker) + { + return new Diagnostic + { + Severity = MapDiagnosticSeverity(scriptFileMarker.Level), + Message = scriptFileMarker.Message, + Code = scriptFileMarker.RuleName, + Source = scriptFileMarker.Source, + Range = new Range + { + Start = new Position + { + Line = scriptFileMarker.ScriptRegion.StartLineNumber - 1, + Character = scriptFileMarker.ScriptRegion.StartColumnNumber - 1 + }, + End = new Position + { + Line = scriptFileMarker.ScriptRegion.EndLineNumber - 1, + Character = scriptFileMarker.ScriptRegion.EndColumnNumber - 1 + } + } + }; + } + + private static DiagnosticSeverity MapDiagnosticSeverity(ScriptFileMarkerLevel markerLevel) + { + return markerLevel switch + { + ScriptFileMarkerLevel.Error => DiagnosticSeverity.Error, + ScriptFileMarkerLevel.Warning => DiagnosticSeverity.Warning, + ScriptFileMarkerLevel.Information => DiagnosticSeverity.Information, + _ => DiagnosticSeverity.Error, + }; + } + + private static Hashtable GetCommentHelpRuleSettings(string helpLocation, bool forBlockComment) + { + return new Hashtable { + { "Rules", new Hashtable { + { "PSProvideCommentHelp", new Hashtable { + { "Enable", true }, + { "ExportedOnly", false }, + { "BlockComment", forBlockComment }, + { "VSCodeSnippetCorrection", true }, + { "Placement", helpLocation }} + }}}, + { "IncludeRules", "PSProvideCommentHelp" }}; + } + + #region IDisposable Support + private bool disposedValue; // To detect redundant calls + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + if (_analysisEngineLazy?.IsValueCreated == true) + { + _analysisEngineLazy.Value.Dispose(); + } + + _diagnosticsCancellationTokenSource?.Dispose(); + } + + disposedValue = true; + } + } + + // This code added to correctly implement the disposable pattern. + public void Dispose() => + // Do not change this code. Put cleanup code in Dispose(bool disposing) above. + Dispose(true); + #endregion + + /// + /// Tracks corrections suggested by PSSA for a given file, + /// so that after a diagnostics request has fired, + /// a code action request can look up that file, + /// wait for analysis to finish if needed, + /// and then fetch the corrections set in the table entry by PSSA. + /// + private class CorrectionTableEntry + { + public static CorrectionTableEntry CreateForFile(ScriptFile _) => new(); + + public CorrectionTableEntry() + { + Corrections = new ConcurrentDictionary>(); + DiagnosticPublish = Task.CompletedTask; + } + + public ConcurrentDictionary> Corrections { get; } + + public Task DiagnosticPublish { get; set; } + } + } +} diff --git a/src/PowerShellEditorServices/Services/Analysis/PssaCmdletAnalysisEngine.cs b/src/PowerShellEditorServices/Services/Analysis/PssaCmdletAnalysisEngine.cs new file mode 100644 index 0000000..8875835 --- /dev/null +++ b/src/PowerShellEditorServices/Services/Analysis/PssaCmdletAnalysisEngine.cs @@ -0,0 +1,413 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Utility; + +namespace Microsoft.PowerShell.EditorServices.Services.Analysis +{ + using System.Management.Automation; + using System.Management.Automation.Runspaces; + + /// + /// PowerShell script analysis engine that uses PSScriptAnalyzer + /// cmdlets run through a PowerShell API to drive analysis. + /// + internal class PssaCmdletAnalysisEngine : IDisposable + { + /// + /// Builder for the PssaCmdletAnalysisEngine allowing settings configuration. + /// + public class Builder + { + private readonly ILoggerFactory _loggerFactory; + + private object _settingsParameter; + + private string[] _rules; + + /// + /// Create a builder for PssaCmdletAnalysisEngine construction. + /// + /// The logger to use. + public Builder(ILoggerFactory loggerFactory) => _loggerFactory = loggerFactory; + + /// + /// Uses a settings file for PSSA rule configuration. + /// + /// The absolute path to the settings file. + /// The builder for chaining. + public Builder WithSettingsFile(string settingsPath) + { + _settingsParameter = settingsPath; + return this; + } + + /// + /// Uses a set of unconfigured rules for PSSA configuration. + /// + /// The rules for PSSA to run. + /// The builder for chaining. + public Builder WithIncludedRules(string[] rules) + { + _rules = rules; + return this; + } + + /// + /// Attempts to build a PssaCmdletAnalysisEngine with the given configuration. + /// If PSScriptAnalyzer cannot be found, this will return null. + /// + /// A newly configured PssaCmdletAnalysisEngine, or null if PSScriptAnalyzer cannot be found. + public PssaCmdletAnalysisEngine Build(string pssaModulePath) + { + // RunspacePool takes care of queuing commands for us so we do not + // need to worry about executing concurrent commands + ILogger logger = _loggerFactory.CreateLogger(); + + logger.LogDebug("Creating PSScriptAnalyzer runspace with module at: '{Path}'", pssaModulePath); + RunspacePool pssaRunspacePool = CreatePssaRunspacePool(pssaModulePath); + PssaCmdletAnalysisEngine cmdletAnalysisEngine = new(logger, pssaRunspacePool, _rules, _settingsParameter); + cmdletAnalysisEngine.LogAvailablePssaFeatures(); + return cmdletAnalysisEngine; + } + } + + /// + /// The indentation to add when the logger lists errors. + /// + private static readonly string s_indentJoin = Environment.NewLine + " "; + + private static readonly IReadOnlyCollection s_emptyDiagnosticResult = new Collection(); + + private static readonly ScriptFileMarkerLevel[] s_scriptMarkerLevels = new[] + { + ScriptFileMarkerLevel.Error, + ScriptFileMarkerLevel.Warning, + ScriptFileMarkerLevel.Information + }; + + private readonly ILogger _logger; + + private readonly RunspacePool _analysisRunspacePool; + + internal readonly object _settingsParameter; + + internal readonly string[] _rulesToInclude; + + private PssaCmdletAnalysisEngine( + ILogger logger, + RunspacePool analysisRunspacePool, + string[] rulesToInclude = default, + object analysisSettingsParameter = default) + { + _logger = logger; + _analysisRunspacePool = analysisRunspacePool; + _rulesToInclude = rulesToInclude; + _settingsParameter = analysisSettingsParameter; + } + + /// + /// Format a script given its contents. + /// TODO: This needs to be cancellable. + /// + /// The full text of a script. + /// The formatter settings to use. + /// A possible range over which to run the formatter. + /// Formatted script as string + public async Task FormatAsync(string scriptDefinition, Hashtable formatSettings, int[] rangeList) + { + // We cannot use Range type therefore this workaround of using -1 default value. + // Invoke-Formatter throws a ParameterBinderValidationException if the ScriptDefinition is an empty string. + if (string.IsNullOrEmpty(scriptDefinition)) + { + _logger.LogDebug("Script Definition was: " + scriptDefinition is null ? "null" : "empty string"); + return scriptDefinition; + } + + PSCommand psCommand = new PSCommand() + .AddCommand("Invoke-Formatter") + .AddParameter("ScriptDefinition", scriptDefinition) + .AddParameter("Settings", formatSettings); + + if (rangeList is not null) + { + psCommand.AddParameter("Range", rangeList); + } + + PowerShellResult result = await InvokePowerShellAsync(psCommand).ConfigureAwait(false); + + if (result is null) + { + _logger.LogError("Formatter returned null result"); + return null; + } + + if (result.HasErrors) + { + StringBuilder errorBuilder = new StringBuilder().Append(s_indentJoin); + foreach (ErrorRecord err in result.Errors) + { + errorBuilder.Append(err).Append(s_indentJoin); + } + _logger.LogWarning($"Errors found while formatting file: {errorBuilder}"); + return null; + } + + foreach (PSObject resultObj in result.Output) + { + if (resultObj?.BaseObject is string formatResult) + { + return formatResult; + } + } + + _logger.LogError("Couldn't get result from output. Returning null."); + return null; + } + + /// + /// Analyze a given script using PSScriptAnalyzer. + /// + /// The contents of the script to analyze. + /// An array of markers indicating script analysis diagnostics. + public Task AnalyzeScriptAsync(string scriptContent) => AnalyzeScriptAsync(scriptContent, settings: null); + + /// + /// Analyze a given script using PSScriptAnalyzer. + /// + /// The contents of the script to analyze. + /// The settings file to use in this instance of analysis. + /// An array of markers indicating script analysis diagnostics. + public Task AnalyzeScriptAsync(string scriptContent, Hashtable settings) + { + // When a new, empty file is created there are by definition no issues. + // Furthermore, if you call Invoke-ScriptAnalyzer with an empty ScriptDefinition + // it will generate a ParameterBindingValidationException. + if (string.IsNullOrEmpty(scriptContent)) + { + return Task.FromResult(Array.Empty()); + } + + PSCommand command = new PSCommand() + .AddCommand("Invoke-ScriptAnalyzer") + .AddParameter("ScriptDefinition", scriptContent) + .AddParameter("Severity", s_scriptMarkerLevels); + + object settingsValue = settings ?? _settingsParameter; + if (settingsValue is not null) + { + command.AddParameter("Settings", settingsValue); + } + else + { + command.AddParameter("IncludeRule", _rulesToInclude); + } + + return GetSemanticMarkersFromCommandAsync(command); + } + + public PssaCmdletAnalysisEngine RecreateWithNewSettings(string settingsPath) => new( + _logger, + _analysisRunspacePool, + rulesToInclude: null, + analysisSettingsParameter: settingsPath); + + public PssaCmdletAnalysisEngine RecreateWithRules(string[] rules) => new( + _logger, + _analysisRunspacePool, + rulesToInclude: rules, + analysisSettingsParameter: null); + + #region IDisposable Support + private bool disposedValue; // To detect redundant calls + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + _analysisRunspacePool.Dispose(); + } + + disposedValue = true; + } + } + + // This code added to correctly implement the disposable pattern. + public void Dispose() => + // Do not change this code. Put cleanup code in Dispose(bool disposing) above. + Dispose(true); + + #endregion + + private async Task GetSemanticMarkersFromCommandAsync(PSCommand command) + { + PowerShellResult result = await InvokePowerShellAsync(command).ConfigureAwait(false); + + IReadOnlyCollection diagnosticResults = result?.Output ?? s_emptyDiagnosticResult; + _logger.LogDebug(string.Format("Found {0} violations", diagnosticResults.Count)); + + ScriptFileMarker[] scriptMarkers = new ScriptFileMarker[diagnosticResults.Count]; + int i = 0; + foreach (PSObject diagnostic in diagnosticResults) + { + scriptMarkers[i] = ScriptFileMarker.FromDiagnosticRecord(diagnostic); + i++; + } + + return scriptMarkers; + } + + // TODO: Deduplicate this logic and cleanup using lessons learned from pipeline rewrite. + private Task InvokePowerShellAsync(PSCommand command) => Task.Run(() => InvokePowerShell(command)); + + private PowerShellResult InvokePowerShell(PSCommand command) + { + using PowerShell pwsh = PowerShell.Create(RunspaceMode.NewRunspace); + pwsh.RunspacePool = _analysisRunspacePool; + pwsh.Commands = command; + PowerShellResult result = null; + try + { + Collection output = pwsh.Invoke(); + PSDataCollection errors = pwsh.Streams.Error; + result = new PowerShellResult(output, errors, pwsh.HadErrors); + } + catch (CommandNotFoundException ex) + { + // This exception is possible if the module path loaded + // is wrong even though PSScriptAnalyzer is available as a module + _logger.LogError(ex.Message); + } + catch (CmdletInvocationException ex) + { + // We do not want to crash EditorServices for exceptions caused by cmdlet invocation. + // The main reasons that cause the exception are: + // * PSCmdlet.WriteOutput being called from another thread than Begin/Process + // * CompositionContainer.ComposeParts complaining that "...Only one batch can be composed at a time" + // * PSScriptAnalyzer not being able to find its PSScriptAnalyzer.psd1 because we are hosted by an Assembly other than pwsh.exe + string message = ex.Message; + if (!string.IsNullOrEmpty(ex.ErrorRecord.FullyQualifiedErrorId)) + { + // Microsoft.PowerShell.EditorServices.Services.Analysis.PssaCmdletAnalysisEngine: Exception of type 'System.Exception' was thrown. | + message += $" | {ex.ErrorRecord.FullyQualifiedErrorId}"; + } + _logger.LogError(message); + } + + return result; + } + + /// + /// Log the features available from the PSScriptAnalyzer module that has been imported + /// for use with the AnalysisService. + /// + private void LogAvailablePssaFeatures() + { + // Save ourselves some work here + if (!_logger.IsEnabled(LogLevel.Debug)) + { + return; + } + + StringBuilder sb = new(); + sb.AppendLine("PSScriptAnalyzer successfully imported:").AppendLine(" Available Rules:"); + + // Log available rules + foreach (string ruleName in GetPSScriptAnalyzerRules()) + { + sb.Append(" ").AppendLine(ruleName); + } + + _logger.LogDebug(sb.ToString()); + } + + /// + /// Returns a list of builtin-in PSScriptAnalyzer rules + /// + private IEnumerable GetPSScriptAnalyzerRules() + { + PowerShellResult getRuleResult = InvokePowerShell(new PSCommand().AddCommand("Get-ScriptAnalyzerRule")); + if (getRuleResult is null) + { + _logger.LogWarning("Get-ScriptAnalyzerRule returned null result"); + return Enumerable.Empty(); + } + + List ruleNames = new(getRuleResult.Output.Count); + foreach (PSObject rule in getRuleResult.Output) + { + ruleNames.Add((string)rule.Members["RuleName"].Value); + } + + return ruleNames; + } + + /// + /// Create a new runspace pool around a PSScriptAnalyzer module for asynchronous script analysis tasks. + /// This looks for the latest version of PSScriptAnalyzer on the path and loads that. + /// + /// A runspace pool with PSScriptAnalyzer loaded for running script analysis tasks. + private static RunspacePool CreatePssaRunspacePool(string pssaModulePath) + { + using PowerShell pwsh = PowerShell.Create(RunspaceMode.NewRunspace); + + // Now that we know where the PSScriptAnalyzer we want to use is, create a base + // session state with PSScriptAnalyzer loaded + // + // We intentionally use `CreateDefault2()` as it loads `Microsoft.PowerShell.Core` + // only, which is a more minimal and therefore safer state. + InitialSessionState sessionState = InitialSessionState.CreateDefault2(); + + // We set the runspace's execution policy `Bypass` so we can always import our bundled + // PSScriptAnalyzer module. + if (VersionUtils.IsWindows) + { + sessionState.ExecutionPolicy = ExecutionPolicy.Bypass; + } + + sessionState.ImportPSModulesFromPath(pssaModulePath); + + RunspacePool runspacePool = RunspaceFactory.CreateRunspacePool(sessionState); + + runspacePool.SetMaxRunspaces(1); + runspacePool.ThreadOptions = PSThreadOptions.ReuseThread; + runspacePool.Open(); + + return runspacePool; + } + + /// + /// Wraps the result of an execution of PowerShell to send back through + /// asynchronous calls. + /// + private class PowerShellResult + { + public PowerShellResult( + Collection output, + PSDataCollection errors, + bool hasErrors) + { + Output = output; + Errors = errors; + HasErrors = hasErrors; + } + + public Collection Output { get; } + + public PSDataCollection Errors { get; } + + public bool HasErrors { get; } + } + } +} diff --git a/src/PowerShellEditorServices/Services/CodeLens/CodeLensData.cs b/src/PowerShellEditorServices/Services/CodeLens/CodeLensData.cs new file mode 100644 index 0000000..e236cf7 --- /dev/null +++ b/src/PowerShellEditorServices/Services/CodeLens/CodeLensData.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.PowerShell.EditorServices.CodeLenses +{ + /// + /// Represents data expected back in an LSP CodeLens response. + /// + internal class CodeLensData + { + public string Uri { get; set; } + + public string ProviderId { get; set; } + } +} diff --git a/src/PowerShellEditorServices/Services/CodeLens/ICodeLensProvider.cs b/src/PowerShellEditorServices/Services/CodeLens/ICodeLensProvider.cs new file mode 100644 index 0000000..8ee6bc8 --- /dev/null +++ b/src/PowerShellEditorServices/Services/CodeLens/ICodeLensProvider.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.CodeLenses +{ + /// + /// Specifies the contract for a Code Lens provider. + /// + internal interface ICodeLensProvider + { + /// + /// Specifies a unique identifier for the feature provider, typically a + /// fully-qualified name like "Microsoft.PowerShell.EditorServices.MyProvider" + /// + string ProviderId { get; } + + /// + /// Provides a collection of CodeLenses for the given + /// document. + /// + /// + /// The document for which CodeLenses should be provided. + /// + /// An IEnumerable of CodeLenses. + IEnumerable ProvideCodeLenses(ScriptFile scriptFile); + + /// + /// Resolves a CodeLens that was created without a Command. + /// + /// + /// The CodeLens to resolve. + /// + /// + /// The ScriptFile to resolve it in (sometimes unused). + /// + /// + /// + /// A Task which returns the resolved CodeLens when completed. + /// + Task ResolveCodeLens( + CodeLens codeLens, + ScriptFile scriptFile, + CancellationToken cancellationToken); + } +} diff --git a/src/PowerShellEditorServices/Services/CodeLens/PesterCodeLensProvider.cs b/src/PowerShellEditorServices/Services/CodeLens/PesterCodeLensProvider.cs new file mode 100644 index 0000000..22b563b --- /dev/null +++ b/src/PowerShellEditorServices/Services/CodeLens/PesterCodeLensProvider.cs @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.Symbols; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Newtonsoft.Json.Linq; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Serialization; + +namespace Microsoft.PowerShell.EditorServices.CodeLenses +{ + internal class PesterCodeLensProvider : ICodeLensProvider + { + private readonly ConfigurationService _configurationService; + + /// + /// The symbol provider to get symbols from to build code lenses with. + /// + private readonly IDocumentSymbolProvider _symbolProvider; + + /// + /// Specifies a unique identifier for the feature provider, typically a + /// fully-qualified name like "Microsoft.PowerShell.EditorServices.MyProvider" + /// + public string ProviderId => nameof(PesterCodeLensProvider); + + /// + /// Create a new Pester CodeLens provider for a given editor session. + /// + public PesterCodeLensProvider(ConfigurationService configurationService) + { + _configurationService = configurationService; + _symbolProvider = new PesterDocumentSymbolProvider(); + } + + /// + /// Get the Pester CodeLenses for a given Pester symbol. + /// + /// The Pester symbol to get CodeLenses for. + /// The script file the Pester symbol comes from. + /// All CodeLenses for the given Pester symbol. + private static CodeLens[] GetPesterLens(PesterSymbolReference pesterSymbol, ScriptFile scriptFile) + { + string word = pesterSymbol.Command == PesterCommandType.It ? "test" : "tests"; + return new CodeLens[] + { + new CodeLens() + { + Range = pesterSymbol.ScriptRegion.ToRange(), + Data = JToken.FromObject(new { + Uri = scriptFile.DocumentUri, + ProviderId = nameof(PesterCodeLensProvider) + }, LspSerializer.Instance.JsonSerializer), + Command = new Command() + { + Name = "PowerShell.RunPesterTests", + Title = $"Run {word}", + Arguments = JArray.FromObject(new object[] + { + scriptFile.DocumentUri, + false /* No debug */, + pesterSymbol.TestName, + pesterSymbol.ScriptRegion?.StartLineNumber + }, LspSerializer.Instance.JsonSerializer) + } + }, + + new CodeLens() + { + Range = pesterSymbol.ScriptRegion.ToRange(), + Data = JToken.FromObject(new { + Uri = scriptFile.DocumentUri, + ProviderId = nameof(PesterCodeLensProvider) + }, LspSerializer.Instance.JsonSerializer), + Command = new Command() + { + Name = "PowerShell.RunPesterTests", + Title = $"Debug {word}", + Arguments = JArray.FromObject(new object[] + { + scriptFile.DocumentUri, + true /* No debug */, + pesterSymbol.TestName, + pesterSymbol.ScriptRegion?.StartLineNumber + }, + LspSerializer.Instance.JsonSerializer) + } + } + }; + } + + /// + /// Get all Pester CodeLenses for a given script file. + /// + /// The script file to get Pester CodeLenses for. + /// All Pester CodeLenses for the given script file. + public IEnumerable ProvideCodeLenses(ScriptFile scriptFile) + { + // Don't return anything if codelens setting is disabled + if (!_configurationService.CurrentSettings.Pester.CodeLens) + { + yield break; + } + + foreach (SymbolReference symbol in _symbolProvider.ProvideDocumentSymbols(scriptFile)) + { + if (symbol is not PesterSymbolReference pesterSymbol) + { + continue; + } + + // Skip CodeLens for setup/teardown block + if (!PesterSymbolReference.IsPesterTestCommand(pesterSymbol.Command)) + { + continue; + } + + if (_configurationService.CurrentSettings.Pester.UseLegacyCodeLens + && pesterSymbol.Command != PesterCommandType.Describe) + { + continue; + } + + foreach (CodeLens codeLens in GetPesterLens(pesterSymbol, scriptFile)) + { + yield return codeLens; + } + } + } + + /// + /// Resolve the CodeLens provision asynchronously -- just wraps the CodeLens argument in a task. + /// + /// The code lens to resolve. + /// The script file. + /// + /// The given CodeLens, wrapped in a task. + public Task ResolveCodeLens(CodeLens codeLens, ScriptFile scriptFile, CancellationToken cancellationToken) => + // This provider has no specific behavior for + // resolving CodeLenses. + Task.FromResult(codeLens); + } +} diff --git a/src/PowerShellEditorServices/Services/CodeLens/ReferencesCodeLensProvider.cs b/src/PowerShellEditorServices/Services/CodeLens/ReferencesCodeLensProvider.cs new file mode 100644 index 0000000..0307163 --- /dev/null +++ b/src/PowerShellEditorServices/Services/CodeLens/ReferencesCodeLensProvider.cs @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.Symbols; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Newtonsoft.Json.Linq; +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Serialization; + +namespace Microsoft.PowerShell.EditorServices.CodeLenses +{ + /// + /// Provides the "reference" code lens by extracting document symbols. + /// + internal class ReferencesCodeLensProvider : ICodeLensProvider + { + /// + /// The document symbol provider to supply symbols to generate the code lenses. + /// + private readonly IDocumentSymbolProvider _symbolProvider; + private readonly SymbolsService _symbolsService; + private readonly WorkspaceService _workspaceService; + + public static string Id => nameof(ReferencesCodeLensProvider); + + /// + /// Specifies a unique identifier for the feature provider, typically a + /// fully-qualified name like "Microsoft.PowerShell.EditorServices.MyProvider" + /// + public string ProviderId => Id; + + /// + /// Construct a new ReferencesCodeLensProvider for a given EditorSession. + /// + /// + /// + public ReferencesCodeLensProvider(WorkspaceService workspaceService, SymbolsService symbolsService) + { + _workspaceService = workspaceService; + _symbolsService = symbolsService; + // TODO: Pull this from components + _symbolProvider = new ScriptDocumentSymbolProvider(); + } + + /// + /// Get all reference code lenses for a given script file. + /// + /// The PowerShell script file to get code lenses for. + /// An IEnumerable of CodeLenses describing all functions, classes and enums in the given script file. + public IEnumerable ProvideCodeLenses(ScriptFile scriptFile) + { + foreach (SymbolReference symbol in _symbolProvider.ProvideDocumentSymbols(scriptFile)) + { + // TODO: Can we support more here? + if (symbol.IsDeclaration && + symbol.Type is + SymbolType.Function or + SymbolType.Class or + SymbolType.Enum) + { + yield return new CodeLens + { + Data = JToken.FromObject(new + { + Uri = scriptFile.DocumentUri, + ProviderId = nameof(ReferencesCodeLensProvider) + }, LspSerializer.Instance.JsonSerializer), + Range = symbol.NameRegion.ToRange(), + }; + } + } + } + + /// + /// Take a CodeLens and create a new CodeLens object with updated references. + /// + /// The old code lens to get updated references for. + /// + /// + /// A new CodeLens object describing the same data as the old one but with updated references. + public async Task ResolveCodeLens( + CodeLens codeLens, + ScriptFile scriptFile, + CancellationToken cancellationToken) + { + SymbolReference foundSymbol = SymbolsService.FindSymbolDefinitionAtLocation( + scriptFile, + codeLens.Range.Start.Line + 1, + codeLens.Range.Start.Character + 1); + + List acc = new(); + foreach (SymbolReference foundReference in await _symbolsService.ScanForReferencesOfSymbolAsync( + foundSymbol, cancellationToken).ConfigureAwait(false)) + { + // We only show lenses on declarations, so we exclude those from the references. + if (foundReference.IsDeclaration) + { + continue; + } + + DocumentUri uri = DocumentUri.From(foundReference.FilePath); + // For any vscode-notebook-cell, we need to ignore the backing file on disk. + if (uri.Scheme == "file" && + scriptFile.DocumentUri.Scheme == "vscode-notebook-cell" && + uri.Path == scriptFile.DocumentUri.Path) + { + continue; + } + + acc.Add(new Location + { + Uri = uri, + Range = foundReference.NameRegion.ToRange() + }); + } + + Location[] referenceLocations = acc.ToArray(); + return new CodeLens + { + Data = codeLens.Data, + Range = codeLens.Range, + Command = new Command + { + Name = "editor.action.showReferences", + Title = GetReferenceCountHeader(referenceLocations.Length), + Arguments = JArray.FromObject(new object[] + { + scriptFile.DocumentUri, + codeLens.Range.Start, + referenceLocations + }, + LspSerializer.Instance.JsonSerializer) + } + }; + } + + /// + /// Get the code lens header for the number of references on a definition, + /// given the number of references. + /// + /// The number of references found for a given definition. + /// The header string for the reference code lens. + private static string GetReferenceCountHeader(int referenceCount) + { + if (referenceCount == 1) + { + return "1 reference"; + } + + StringBuilder sb = new(14); // "100 references".Length = 14 + sb.Append(referenceCount); + sb.Append(" references"); + return sb.ToString(); + } + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs new file mode 100644 index 0000000..6d7e0c3 --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs @@ -0,0 +1,387 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Logging; +using Microsoft.PowerShell.EditorServices.Services.DebugAdapter; +using Microsoft.PowerShell.EditorServices.Services.PowerShell; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; + +namespace Microsoft.PowerShell.EditorServices.Services +{ + internal class BreakpointService + { + /// + /// Code used on WinPS 5.1 to set breakpoints without Script path validation. + /// It uses reflection because the APIs were not public until 7.0 but just in + /// case something changes it has a fallback to Set-PSBreakpoint. + /// + private const string _setPSBreakpointLegacy = @" + [CmdletBinding(DefaultParameterSetName = 'Line')] + param ( + [Parameter()] + [ScriptBlock] + $Action, + + [Parameter(ParameterSetName = 'Command')] + [Parameter(ParameterSetName = 'Line', Mandatory = $true)] + [string] + $Script, + + [Parameter(ParameterSetName = 'Line')] + [int] + $Line, + + [Parameter(ParameterSetName = 'Line')] + [int] + $Column, + + [Parameter(ParameterSetName = 'Command', Mandatory = $true)] + [string] + $Command + ) + + if ($Script) { + # If using Set-PSBreakpoint we need to escape any wildcard patterns. + $PSBoundParameters['Script'] = [WildcardPattern]::Escape($Script) + } + else { + # WinPS must use null for the Script if unset. + $Script = [NullString]::Value + } + + if ($PSCmdlet.ParameterSetName -eq 'Command') { + $cmdCtor = [System.Management.Automation.CommandBreakpoint].GetConstructor( + [System.Reflection.BindingFlags]'NonPublic, Public, Instance', + $null, + [type[]]@([string], [System.Management.Automation.WildcardPattern], [string], [ScriptBlock]), + $null) + + if (-not $cmdCtor) { + Microsoft.PowerShell.Utility\Set-PSBreakpoint @PSBoundParameters + return + } + + $pattern = [System.Management.Automation.WildcardPattern]::Get( + $Command, + [System.Management.Automation.WildcardOptions]'Compiled, IgnoreCase') + $b = $cmdCtor.Invoke(@($Script, $pattern, $Command, $Action)) + } + else { + $lineCtor = [System.Management.Automation.LineBreakpoint].GetConstructor( + [System.Reflection.BindingFlags]'NonPublic, Public, Instance', + $null, + [type[]]@([string], [int], [int], [ScriptBlock]), + $null) + + if (-not $lineCtor) { + Microsoft.PowerShell.Utility\Set-PSBreakpoint @PSBoundParameters + return + } + + $b = $lineCtor.Invoke(@($Script, $Line, $Column, $Action)) + } + + [Runspace]::DefaultRunspace.Debugger.SetBreakpoints( + [System.Management.Automation.Breakpoint[]]@($b)) + + $b + "; + + private readonly ILogger _logger; + private readonly IInternalPowerShellExecutionService _executionService; + private readonly PsesInternalHost _editorServicesHost; + private readonly DebugStateService _debugStateService; + + // TODO: This needs to be managed per nested session + internal readonly Dictionary> BreakpointsPerFile = new(); + + internal readonly HashSet CommandBreakpoints = new(); + + public BreakpointService( + ILoggerFactory factory, + IInternalPowerShellExecutionService executionService, + PsesInternalHost editorServicesHost, + DebugStateService debugStateService) + { + _logger = factory.CreateLogger(); + _executionService = executionService; + _editorServicesHost = editorServicesHost; + _debugStateService = debugStateService; + } + + public async Task> GetBreakpointsAsync() + { + if (BreakpointApiUtils.SupportsBreakpointApis(_editorServicesHost.CurrentRunspace)) + { + _editorServicesHost.Runspace.ThrowCancelledIfUnusable(); + return BreakpointApiUtils.GetBreakpoints( + _editorServicesHost.Runspace.Debugger, + _debugStateService.RunspaceId); + } + + // Legacy behavior + PSCommand psCommand = new PSCommand().AddCommand(@"Microsoft.PowerShell.Utility\Get-PSBreakpoint"); + return await _executionService + .ExecutePSCommandAsync(psCommand, CancellationToken.None) + .ConfigureAwait(false); + } + + public async Task> SetBreakpointsAsync(IReadOnlyList breakpoints) + { + if (BreakpointApiUtils.SupportsBreakpointApis(_editorServicesHost.CurrentRunspace)) + { + foreach (BreakpointDetails breakpointDetails in breakpoints) + { + try + { + BreakpointApiUtils.SetBreakpoint(_editorServicesHost.Runspace.Debugger, breakpointDetails, _debugStateService.RunspaceId); + } + catch (InvalidOperationException e) + { + breakpointDetails.Message = e.Message; + breakpointDetails.Verified = false; + } + } + return breakpoints; + } + + // Legacy behavior + PSCommand psCommand = null; + List configuredBreakpoints = new(); + foreach (BreakpointDetails breakpoint in breakpoints) + { + ScriptBlock actionScriptBlock = null; + + // Check if this is a "conditional" line breakpoint. + if (!string.IsNullOrWhiteSpace(breakpoint.Condition) || + !string.IsNullOrWhiteSpace(breakpoint.HitCondition) || + !string.IsNullOrWhiteSpace(breakpoint.LogMessage)) + { + actionScriptBlock = BreakpointApiUtils.GetBreakpointActionScriptBlock( + breakpoint.Condition, + breakpoint.HitCondition, + breakpoint.LogMessage, + out string errorMessage); + + if (!string.IsNullOrEmpty(errorMessage)) + { + breakpoint.Verified = false; + breakpoint.Message = errorMessage; + configuredBreakpoints.Add(breakpoint); + continue; + } + } + + // On first iteration psCommand will be null, every subsequent + // iteration will need to start a new statement. + if (psCommand is null) + { + psCommand = new PSCommand(); + } + else + { + psCommand.AddStatement(); + } + + // Don't use Set-PSBreakpoint as that will try and validate the Script + // path which may or may not exist. + psCommand + .AddScript(_setPSBreakpointLegacy, useLocalScope: true) + .AddParameter("Script", breakpoint.MappedSource ?? breakpoint.Source) + .AddParameter("Line", breakpoint.LineNumber); + + // Check if the user has specified the column number for the breakpoint. + if (breakpoint.ColumnNumber > 0) + { + // It bums me out that PowerShell will silently ignore a breakpoint + // where either the line or the column is invalid. I'd rather have an + // error or warning message I could relay back to the client. + psCommand.AddParameter("Column", breakpoint.ColumnNumber.Value); + } + + if (actionScriptBlock is not null) + { + psCommand.AddParameter("Action", actionScriptBlock); + } + } + + // If no PSCommand was created then there are no breakpoints to set. + if (psCommand is not null) + { + IEnumerable setBreakpoints = await _executionService + .ExecutePSCommandAsync(psCommand, CancellationToken.None) + .ConfigureAwait(false); + + int bpIdx = 0; + foreach (Breakpoint setBp in setBreakpoints) + { + BreakpointDetails setBreakpoint = BreakpointDetails.Create( + setBp, + sourceBreakpoint: breakpoints[bpIdx]); + configuredBreakpoints.Add(setBreakpoint); + bpIdx++; + } + } + return configuredBreakpoints; + } + + public async Task> SetCommandBreakpointsAsync(IReadOnlyList breakpoints) + { + if (BreakpointApiUtils.SupportsBreakpointApis(_editorServicesHost.CurrentRunspace)) + { + foreach (CommandBreakpointDetails commandBreakpointDetails in breakpoints) + { + try + { + BreakpointApiUtils.SetBreakpoint( + _editorServicesHost.Runspace.Debugger, + commandBreakpointDetails, + _debugStateService.RunspaceId); + } + catch (InvalidOperationException e) + { + commandBreakpointDetails.Message = e.Message; + commandBreakpointDetails.Verified = false; + } + } + return breakpoints; + } + + // Legacy behavior + PSCommand psCommand = null; + List configuredBreakpoints = new(); + foreach (CommandBreakpointDetails breakpoint in breakpoints) + { + // On first iteration psCommand will be null, every subsequent + // iteration will need to start a new statement. + if (psCommand is null) + { + psCommand = new PSCommand(); + } + else + { + psCommand.AddStatement(); + } + + psCommand + .AddScript(_setPSBreakpointLegacy, useLocalScope: true) + .AddParameter("Command", breakpoint.Name); + + // Check if this is a "conditional" line breakpoint. + if (!string.IsNullOrWhiteSpace(breakpoint.Condition) || + !string.IsNullOrWhiteSpace(breakpoint.HitCondition)) + { + ScriptBlock actionScriptBlock = + BreakpointApiUtils.GetBreakpointActionScriptBlock( + breakpoint.Condition, + breakpoint.HitCondition, + logMessage: null, + out string errorMessage); + + // If there was a problem with the condition string, + // move onto the next breakpoint. + if (!string.IsNullOrEmpty(errorMessage)) + { + breakpoint.Verified = false; + breakpoint.Message = errorMessage; + configuredBreakpoints.Add(breakpoint); + continue; + } + psCommand.AddParameter("Action", actionScriptBlock); + } + } + + // If no PSCommand was created then there are no breakpoints to set. + if (psCommand is not null) + { + IReadOnlyList setBreakpoints = await _executionService + .ExecutePSCommandAsync(psCommand, CancellationToken.None) + .ConfigureAwait(false); + configuredBreakpoints.AddRange(setBreakpoints.Select(CommandBreakpointDetails.Create)); + } + return configuredBreakpoints; + } + + /// + /// Clears all breakpoints in the current session. + /// + public async Task RemoveAllBreakpointsAsync(string scriptPath = null) + { + try + { + if (BreakpointApiUtils.SupportsBreakpointApis(_editorServicesHost.CurrentRunspace)) + { + foreach (Breakpoint breakpoint in BreakpointApiUtils.GetBreakpoints( + _editorServicesHost.Runspace.Debugger, + _debugStateService.RunspaceId)) + { + if (scriptPath is null || scriptPath == breakpoint.Script) + { + BreakpointApiUtils.RemoveBreakpoint( + _editorServicesHost.Runspace.Debugger, + breakpoint, + _debugStateService.RunspaceId); + } + } + return; + } + + // Legacy behavior + PSCommand psCommand = new PSCommand().AddCommand(@"Microsoft.PowerShell.Utility\Get-PSBreakpoint"); + + if (!string.IsNullOrEmpty(scriptPath)) + { + psCommand.AddParameter("Script", scriptPath); + } + + psCommand.AddCommand(@"Microsoft.PowerShell.Utility\Remove-PSBreakpoint"); + await _executionService.ExecutePSCommandAsync(psCommand, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception e) + { + _logger.LogException("Caught exception while clearing breakpoints from session", e); + } + } + + public async Task RemoveBreakpointsAsync(IEnumerable breakpoints) + { + if (BreakpointApiUtils.SupportsBreakpointApis(_editorServicesHost.CurrentRunspace)) + { + foreach (Breakpoint breakpoint in breakpoints) + { + BreakpointApiUtils.RemoveBreakpoint( + _editorServicesHost.Runspace.Debugger, + breakpoint, + _debugStateService.RunspaceId); + + _ = breakpoint switch + { + CommandBreakpoint commandBreakpoint => CommandBreakpoints.Remove(commandBreakpoint), + LineBreakpoint lineBreakpoint => + BreakpointsPerFile.TryGetValue(lineBreakpoint.Script, out HashSet bps) && bps.Remove(lineBreakpoint), + _ => throw new NotImplementedException("Other breakpoints not supported yet"), + }; + } + return; + } + + // Legacy behavior + IEnumerable breakpointIds = breakpoints.Select(b => b.Id); + if (breakpointIds.Any()) + { + PSCommand psCommand = new PSCommand() + .AddCommand(@"Microsoft.PowerShell.Utility\Remove-PSBreakpoint") + .AddParameter("Id", breakpoints.Select(b => b.Id).ToArray()); + await _executionService.ExecutePSCommandAsync(psCommand, CancellationToken.None).ConfigureAwait(false); + } + } + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/DebugEventHandlerService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/DebugEventHandlerService.cs new file mode 100644 index 0000000..e3237a3 --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/DebugEventHandlerService.cs @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Management.Automation; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Handlers; +using Microsoft.PowerShell.EditorServices.Services.DebugAdapter; +using Microsoft.PowerShell.EditorServices.Services.PowerShell; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Debugging; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.DebugAdapter.Protocol.Events; +using OmniSharp.Extensions.DebugAdapter.Protocol.Server; + +namespace Microsoft.PowerShell.EditorServices.Services +{ + internal class DebugEventHandlerService + { + private readonly ILogger _logger; + private readonly IInternalPowerShellExecutionService _executionService; + private readonly DebugService _debugService; + private readonly DebugStateService _debugStateService; + private readonly IDebugAdapterServerFacade _debugAdapterServer; + + private readonly IPowerShellDebugContext _debugContext; + + public DebugEventHandlerService( + ILoggerFactory factory, + IInternalPowerShellExecutionService executionService, + DebugService debugService, + DebugStateService debugStateService, + IDebugAdapterServerFacade debugAdapterServer, + IPowerShellDebugContext debugContext) + { + _logger = factory.CreateLogger(); + _executionService = executionService; + _debugService = debugService; + _debugStateService = debugStateService; + _debugAdapterServer = debugAdapterServer; + _debugContext = debugContext; + } + + internal void RegisterEventHandlers() + { + _executionService.RunspaceChanged += OnRunspaceChanged; + _debugService.BreakpointUpdated += OnBreakpointUpdated; + _debugService.DebuggerStopped += OnDebuggerStopped; + _debugContext.DebuggerResuming += OnDebuggerResuming; + } + + internal void UnregisterEventHandlers() + { + _executionService.RunspaceChanged -= OnRunspaceChanged; + _debugService.BreakpointUpdated -= OnBreakpointUpdated; + _debugService.DebuggerStopped -= OnDebuggerStopped; + _debugContext.DebuggerResuming -= OnDebuggerResuming; + } + + #region Public methods + + internal void TriggerDebuggerStopped(DebuggerStoppedEventArgs e) => OnDebuggerStopped(null, e); + + #endregion + + #region Event Handlers + + private void OnDebuggerStopped(object sender, DebuggerStoppedEventArgs e) + { + // Provide the reason for why the debugger has stopped script execution. + // See https://github.com/Microsoft/vscode/issues/3648 + // The reason is displayed in the breakpoints viewlet. Some recommended reasons are: + // "step", "breakpoint", "function breakpoint", "exception" and "pause". + // We don't support exception breakpoints and for "pause", we can't distinguish + // between stepping and the user pressing the pause/break button in the debug toolbar. + string debuggerStoppedReason = "step"; + if (e.OriginalEvent.Breakpoints.Count > 0) + { + debuggerStoppedReason = + e.OriginalEvent.Breakpoints[0] is CommandBreakpoint + ? "function breakpoint" + : "breakpoint"; + } + + _debugAdapterServer.SendNotification(EventNames.Stopped, + new StoppedEvent + { + ThreadId = 1, + AllThreadsStopped = true, + Reason = debuggerStoppedReason + }); + } + + private void OnRunspaceChanged(object sender, RunspaceChangedEventArgs e) + { + switch (e.ChangeAction) + { + case RunspaceChangeAction.Enter: + if (_debugStateService.WaitingForAttach + && e.NewRunspace.RunspaceOrigin == RunspaceOrigin.DebuggedRunspace) + { + // Sends the InitializedEvent so that the debugger will continue + // sending configuration requests + _debugStateService.WaitingForAttach = false; + _debugStateService.ServerStarted.TrySetResult(true); + } + return; + + case RunspaceChangeAction.Exit: + if (_debugContext.IsStopped) + { + // Exited the session while the debugger is stopped, + // send a ContinuedEvent so that the client changes the + // UI to appear to be running again + _debugAdapterServer.SendNotification( + EventNames.Continued, + new ContinuedEvent + { + ThreadId = ThreadsHandler.PipelineThread.Id, + AllThreadsContinued = true, + }); + } + return; + } + } + + private void OnDebuggerResuming(object sender, DebuggerResumingEventArgs e) + { + _debugAdapterServer.SendNotification(EventNames.Continued, + new ContinuedEvent + { + ThreadId = ThreadsHandler.PipelineThread.Id, + AllThreadsContinued = true, + }); + } + + private void OnBreakpointUpdated(object sender, BreakpointUpdatedEventArgs e) + { + // Don't send breakpoint update notifications when setting + // breakpoints on behalf of the client. + if (_debugStateService.IsSetBreakpointInProgress) + { + return; + } + + if (e.Breakpoint is LineBreakpoint) + { + OmniSharp.Extensions.DebugAdapter.Protocol.Models.Breakpoint breakpoint = LspDebugUtils.CreateBreakpoint( + BreakpointDetails.Create(e.Breakpoint, e.UpdateType) + ); + + string reason = e.UpdateType switch + { + BreakpointUpdateType.Set => BreakpointEventReason.New, + BreakpointUpdateType.Removed => BreakpointEventReason.Removed, + BreakpointUpdateType.Enabled => BreakpointEventReason.Changed, + BreakpointUpdateType.Disabled => BreakpointEventReason.Changed, + _ => "InvalidBreakpointUpdateTypeEnum" + }; + + _debugAdapterServer.SendNotification( + EventNames.Breakpoint, + new BreakpointEvent { Breakpoint = breakpoint, Reason = reason } + ); + } + else if (e.Breakpoint is CommandBreakpoint) + { + _logger.LogTrace("Function breakpoint updated event is not supported yet"); + } + else + { + _logger.LogError($"Unrecognized breakpoint type {e.Breakpoint.GetType().FullName}"); + } + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs new file mode 100644 index 0000000..a32cd48 --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs @@ -0,0 +1,1147 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services.DebugAdapter; +using Microsoft.PowerShell.EditorServices.Services.PowerShell; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Debugging; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; +using Microsoft.PowerShell.EditorServices.Utility; + +namespace Microsoft.PowerShell.EditorServices.Services +{ + /// + /// Provides a high-level service for interacting with the + /// PowerShell debugger in the runspace managed by a PowerShellContext. + /// + internal class DebugService + { + #region Fields + + internal const string PsesGlobalVariableNamePrefix = "__psEditorServices_"; + internal const string PsesGlobalVariableDebugServerName = $"{PsesGlobalVariableNamePrefix}DebugServer"; + private const string TemporaryScriptFileName = "Script Listing.ps1"; + + private readonly ILogger _logger; + private readonly IInternalPowerShellExecutionService _executionService; + private readonly BreakpointService _breakpointService; + private readonly RemoteFileManagerService _remoteFileManager; + + private readonly PsesInternalHost _psesHost; + + private readonly IPowerShellDebugContext _debugContext; + + // The LSP protocol refers to variables by individual IDs, this is an iterator for that purpose. + private int nextVariableId; + private string temporaryScriptListingPath; + private List variables; + private VariableContainerDetails globalScopeVariables; + private VariableContainerDetails scriptScopeVariables; + private VariableContainerDetails localScopeVariables; + private StackFrameDetails[] stackFrameDetails; + + private readonly SemaphoreSlim debugInfoHandle = AsyncUtils.CreateSimpleLockingSemaphore(); + #endregion + + #region Properties + + /// + /// Gets or sets a boolean that indicates whether a debugger client is + /// currently attached to the debugger. + /// + public bool IsClientAttached { get; set; } + + /// + /// Gets a boolean that indicates whether the debugger is currently + /// stopped at a breakpoint. + /// + public bool IsDebuggerStopped => _debugContext.IsStopped; + + /// + /// Gets the current DebuggerStoppedEventArgs when the debugger + /// is stopped. + /// + public DebuggerStoppedEventArgs CurrentDebuggerStoppedEventArgs { get; private set; } + + /// + /// Returns a task that completes when script frames and variables have completed population + /// + public Task StackFramesAndVariablesFetched { get; private set; } + + /// + /// Tracks whether we are running Debug-Runspace in an out-of-process runspace. + /// + public bool IsDebuggingRemoteRunspace + { + get => _debugContext.IsDebuggingRemoteRunspace; + set => _debugContext.IsDebuggingRemoteRunspace = value; + } + + /// + /// Gets or sets an array of path mappings for the current debug session. + /// + public PathMapping[] PathMappings { get; set; } = []; + + #endregion + + #region Constructors + + /// + /// Initializes a new instance of the DebugService class and uses + /// the given execution service for all future operations. + /// + public DebugService( + IInternalPowerShellExecutionService executionService, + IPowerShellDebugContext debugContext, + RemoteFileManagerService remoteFileManager, + BreakpointService breakpointService, + PsesInternalHost psesHost, + ILoggerFactory factory) + { + Validate.IsNotNull(nameof(executionService), executionService); + + _logger = factory.CreateLogger(); + _executionService = executionService; + _breakpointService = breakpointService; + _psesHost = psesHost; + _debugContext = debugContext; + _debugContext.DebuggerStopped += OnDebuggerStopAsync; + _debugContext.DebuggerResuming += OnDebuggerResuming; + _debugContext.BreakpointUpdated += OnBreakpointUpdated; + _remoteFileManager = remoteFileManager; + } + + #endregion + + #region Public Methods + + /// + /// Sets the list of line breakpoints for the current debugging session. + /// + /// The path in which breakpoints will be set. + /// BreakpointDetails for each breakpoint that will be set. + /// If true, causes all existing breakpoints to be cleared before setting new ones. + /// If true, skips the remote file manager mapping of the script path. + /// An awaitable Task that will provide details about the breakpoints that were set. + public async Task> SetLineBreakpointsAsync( + string scriptPath, + IReadOnlyList breakpoints, + bool clearExisting = true, + bool skipRemoteMapping = false) + { + DscBreakpointCapability dscBreakpoints = await _debugContext.GetDscBreakpointCapabilityAsync().ConfigureAwait(false); + + _psesHost.Runspace.ThrowCancelledIfUnusable(); + // Make sure we're using the remote script path + if (!skipRemoteMapping && _psesHost.CurrentRunspace.IsOnRemoteMachine && _remoteFileManager is not null) + { + if (!_remoteFileManager.IsUnderRemoteTempPath(scriptPath)) + { + _logger.LogTrace($"Could not set breakpoints for local path '{scriptPath}' in a remote session."); + return Array.Empty(); + } + + scriptPath = _remoteFileManager.GetMappedPath(scriptPath, _psesHost.CurrentRunspace); + } + else if (temporaryScriptListingPath?.Equals(scriptPath, StringComparison.CurrentCultureIgnoreCase) == true) + { + _logger.LogTrace($"Could not set breakpoint on temporary script listing path '{scriptPath}'."); + return Array.Empty(); + } + + // Fix for issue #123 - file paths that contain wildcard chars [ and ] need to + // quoted and have those wildcard chars escaped. + string escapedScriptPath = PathUtils.WildcardEscapePath(scriptPath); + + if (dscBreakpoints?.IsDscResourcePath(escapedScriptPath) != true) + { + if (clearExisting) + { + await _breakpointService.RemoveAllBreakpointsAsync(scriptPath).ConfigureAwait(false); + } + + return await _breakpointService.SetBreakpointsAsync(breakpoints).ConfigureAwait(false); + } + + return await dscBreakpoints + .SetLineBreakpointsAsync(_executionService, escapedScriptPath, breakpoints) + .ConfigureAwait(false); + } + + /// + /// Sets the list of command breakpoints for the current debugging session. + /// + /// CommandBreakpointDetails for each command breakpoint that will be set. + /// If true, causes all existing function breakpoints to be cleared before setting new ones. + /// An awaitable Task that will provide details about the breakpoints that were set. + public async Task> SetCommandBreakpointsAsync( + IReadOnlyList breakpoints, + bool clearExisting = true) + { + if (clearExisting) + { + // Flatten dictionary values into one list and remove them all. + IReadOnlyList existingBreakpoints = await _breakpointService.GetBreakpointsAsync().ConfigureAwait(false); + await _breakpointService.RemoveBreakpointsAsync(existingBreakpoints.OfType()).ConfigureAwait(false); + } + + return breakpoints.Count > 0 + ? await _breakpointService.SetCommandBreakpointsAsync(breakpoints).ConfigureAwait(false) + : Array.Empty(); + } + + /// + /// Sends a "continue" action to the debugger when stopped. + /// + public void Continue() => _debugContext.Continue(); + + /// + /// Sends a "step over" action to the debugger when stopped. + /// + public void StepOver() => _debugContext.StepOver(); + + /// + /// Sends a "step in" action to the debugger when stopped. + /// + public void StepIn() => _debugContext.StepInto(); + + /// + /// Sends a "step out" action to the debugger when stopped. + /// + public void StepOut() => _debugContext.StepOut(); + + /// + /// Causes the debugger to break execution wherever it currently + /// is at the time. This is equivalent to clicking "Pause" in a + /// debugger UI. + /// + public void Break() => _debugContext.BreakExecution(); + + /// + /// Aborts execution of the debugger while it is running, even while + /// it is stopped. Equivalent to calling PowerShellContext.AbortExecution. + /// + public void Abort() => _debugContext.Abort(); + + /// + /// Gets the list of variables that are children of the scope or variable + /// that is identified by the given referenced ID. + /// + /// + /// + /// An array of VariableDetails instances which describe the requested variables. + public async Task GetVariables(int variableReferenceId, CancellationToken cancellationToken) + { + VariableDetailsBase[] childVariables; + await debugInfoHandle.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if ((variableReferenceId < 0) || (variableReferenceId >= variables.Count)) + { + _logger.LogWarning($"Received request for variableReferenceId {variableReferenceId} that is out of range of valid indices."); + return Array.Empty(); + } + + VariableDetailsBase parentVariable = variables[variableReferenceId]; + if (parentVariable.IsExpandable) + { + // We execute this on the pipeline thread so the expansion of child variables works. + childVariables = await _executionService.ExecuteDelegateAsync( + $"Getting children of variable ${parentVariable.Name}", + new ExecutionOptions { Priority = ExecutionPriority.Next }, + (_, _) => parentVariable.GetChildren(_logger), cancellationToken).ConfigureAwait(false); + + foreach (VariableDetailsBase child in childVariables) + { + // Only add child if it hasn't already been added. + if (child.Id < 0) + { + child.Id = nextVariableId++; + variables.Add(child); + } + } + } + else + { + childVariables = Array.Empty(); + } + + return childVariables; + } + finally + { + debugInfoHandle.Release(); + } + } + + /// + /// Evaluates a variable expression in the context of the stopped + /// debugger. This method decomposes the variable expression to + /// walk the cached variable data for the specified stack frame. + /// + /// The variable expression string to evaluate. + /// + /// A VariableDetailsBase object containing the result. + public async Task GetVariableFromExpression(string variableExpression, CancellationToken cancellationToken) + { + // NOTE: From a watch we will get passed expressions that are not naked variables references. + // Probably the right way to do this would be to examine the AST of the expr before calling + // this method to make sure it is a VariableReference. But for the most part, non-naked variable + // references are very unlikely to find a matching variable e.g. "$i+5.2" will find no var matching "$i+5". + + // Break up the variable path + string[] variablePathParts = variableExpression.Split('.'); + + VariableDetailsBase resolvedVariable = null; + IEnumerable variableList; + + // Ensure debug info isn't currently being built. + await debugInfoHandle.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + variableList = variables; + } + finally + { + debugInfoHandle.Release(); + } + + foreach (string variableName in variablePathParts) + { + if (variableList is null) + { + // If there are no children left to search, break out early. + return null; + } + + resolvedVariable = + variableList.FirstOrDefault( + v => + string.Equals( + v.Name, + variableName, + StringComparison.CurrentCultureIgnoreCase)); + + if (resolvedVariable?.IsExpandable == true) + { + // Continue by searching in this variable's children. + variableList = await GetVariables(resolvedVariable.Id, cancellationToken).ConfigureAwait(false); + } + } + + return resolvedVariable; + } + + /// + /// Sets the specified variable by container variableReferenceId and variable name to the + /// specified new value. If the variable cannot be set or converted to that value this + /// method will throw InvalidPowerShellExpressionException, ArgumentTransformationMetadataException, or + /// SessionStateUnauthorizedAccessException. + /// + /// The container (Autos, Local, Script, Global) that holds the variable. + /// The name of the variable prefixed with $. + /// The new string value. This value must not be null. If you want to set the variable to $null + /// pass in the string "$null". + /// The string representation of the value the variable was set to. + /// + public async Task SetVariableAsync(int variableContainerReferenceId, string name, string value) + { + Validate.IsNotNull(nameof(name), name); + Validate.IsNotNull(nameof(value), value); + + _logger.LogTrace($"SetVariableRequest for '{name}' to value string (pre-quote processing): '{value}'"); + + // An empty or whitespace only value is not a valid expression for SetVariable. + if (value.Trim().Length == 0) + { + throw new InvalidPowerShellExpressionException("Expected an expression."); + } + + // Evaluate the expression to get back a PowerShell object from the expression string. + // This may throw, in which case the exception is propagated to the caller + PSCommand evaluateExpressionCommand = new PSCommand().AddScript($"[System.Diagnostics.DebuggerHidden()]param() {value}"); + IReadOnlyList expressionResults = await _executionService.ExecutePSCommandAsync(evaluateExpressionCommand, CancellationToken.None).ConfigureAwait(false); + if (expressionResults.Count == 0) + { + throw new InvalidPowerShellExpressionException("Expected an expression result."); + } + object expressionResult = expressionResults[0]; + + // If PowerShellContext.ExecuteCommand returns an ErrorRecord as output, the expression failed evaluation. + // Ideally we would have a separate means from communicating error records apart from normal output. + if (expressionResult is ErrorRecord errorRecord) + { + throw new InvalidPowerShellExpressionException(errorRecord.ToString()); + } + + await debugInfoHandle.WaitAsync().ConfigureAwait(false); + try + { + // OK, now we have a PS object from the supplied value string (expression) to assign to a variable. + // Get the variable referenced by variableContainerReferenceId and variable name. + VariableContainerDetails variableContainer = (VariableContainerDetails)variables[variableContainerReferenceId]; + } + finally + { + debugInfoHandle.Release(); + } + + // Determine scope in which the variable lives so we can pass it to `Get-Variable + // -Scope`. The default is scope 0 which is safe because if a user is able to see a + // variable in the debugger and so change it through this interface, it's either in the + // top-most scope or in one of the following named scopes. The default scope is most + // likely in the case of changing from the "auto variables" container. + string scope = "0"; + // NOTE: This can't use a switch because the IDs aren't constant. + if (variableContainerReferenceId == localScopeVariables.Id) + { + scope = VariableContainerDetails.LocalScopeName; + } + else if (variableContainerReferenceId == scriptScopeVariables.Id) + { + scope = VariableContainerDetails.ScriptScopeName; + } + else if (variableContainerReferenceId == globalScopeVariables.Id) + { + scope = VariableContainerDetails.GlobalScopeName; + } + + // Now that we have the scope, get the associated PSVariable object for the variable to be set. + PSCommand getVariableCommand = new PSCommand() + .AddCommand(@"Microsoft.PowerShell.Utility\Get-Variable") + .AddParameter("Name", name.TrimStart('$')) + .AddParameter("Scope", scope); + + IReadOnlyList psVariables = await _executionService.ExecutePSCommandAsync(getVariableCommand, CancellationToken.None).ConfigureAwait(false); + if (psVariables.Count == 0) + { + throw new Exception("Failed to retrieve PSVariables"); + } + + PSVariable psVariable = psVariables[0]; + if (psVariable is null) + { + throw new Exception($"Failed to retrieve PSVariable object for '{name}' from scope '{scope}'."); + } + + // We have the PSVariable object for the variable the user wants to set and an object to assign to that variable. + // The last step is to determine whether the PSVariable is "strongly typed" which may require a conversion. + // If it is not strongly typed, we simply assign the object directly to the PSVariable potentially changing its type. + // Turns out ArgumentTypeConverterAttribute is not public. So we call the attribute through it's base class - + // ArgumentTransformationAttribute. + ArgumentTransformationAttribute argTypeConverterAttr = null; + foreach (Attribute variableAttribute in psVariable.Attributes) + { + if (variableAttribute is ArgumentTransformationAttribute argTransformAttr + && argTransformAttr.GetType().Name.Equals("ArgumentTypeConverterAttribute")) + { + argTypeConverterAttr = argTransformAttr; + break; + } + } + + if (argTypeConverterAttr is not null) + { + // PSVariable *is* strongly typed, so we have to convert it. + _logger.LogTrace($"Setting variable '{name}' using conversion to value: {expressionResult ?? ""}"); + + // NOTE: We use 'Get-Variable' here instead of 'SessionStateProxy.GetVariable()' + // because we already have a pipeline running (the debugger) and the latter cannot + // run concurrently (threw 'NoSessionStateProxyWhenPipelineInProgress'). + IReadOnlyList results = await _executionService.ExecutePSCommandAsync( + new PSCommand() + .AddCommand(@"Microsoft.PowerShell.Utility\Get-Variable") + .AddParameter("Name", "ExecutionContext") + .AddParameter("ValueOnly"), + CancellationToken.None).ConfigureAwait(false); + EngineIntrinsics engineIntrinsics = results.Count > 0 + ? results[0] + : throw new Exception("Couldn't get EngineIntrinsics!"); + + // TODO: This is almost (but not quite) the same as 'LanguagePrimitives.Convert()', + // which does not require the pipeline thread. We should investigate changing it. + psVariable.Value = argTypeConverterAttr.Transform(engineIntrinsics, expressionResult); + } + else + { + // PSVariable is *not* strongly typed. In this case, whack the old value with the new value. + _logger.LogTrace($"Setting variable '{name}' directly to value: {expressionResult ?? ""} - previous type was {psVariable.Value?.GetType().Name ?? ""}"); + psVariable.Value = expressionResult; + } + + // Use the VariableDetails.ValueString functionality to get the string representation for client debugger. + // This makes the returned string consistent with the strings normally displayed for variables in the debugger. + VariableDetails tempVariable = new(psVariable); + _logger.LogTrace($"Set variable '{name}' to: {tempVariable.ValueString ?? ""}"); + return tempVariable.ValueString; + } + + /// + /// Evaluates an expression in the context of the stopped + /// debugger. This method will execute the specified expression + /// PowerShellContext. + /// + /// The expression string to execute. + /// + /// If true, writes the expression result as host output rather than returning the results. + /// In this case, the return value of this function will be null. + /// + /// A VariableDetails object containing the result. + public async Task EvaluateExpressionAsync( + string expressionString, + bool writeResultAsOutput, + CancellationToken cancellationToken) + { + PSCommand command = new PSCommand().AddScript($"[System.Diagnostics.DebuggerHidden()]param() {expressionString}"); + IReadOnlyList results; + try + { + results = await _executionService.ExecutePSCommandAsync( + command, + cancellationToken, + new PowerShellExecutionOptions { WriteOutputToHost = writeResultAsOutput, ThrowOnError = !writeResultAsOutput }).ConfigureAwait(false); + } + catch (Exception e) + { + // If a watch expression throws we want to show the exception. + // TODO: Show the exception as an expandable object. + return new VariableDetails( + expressionString, + $"{e.GetType().Name}: {e.Message}"); + } + + // Since this method should only be getting invoked in the debugger, + // we can assume that Out-String will be getting used to format results + // of command executions into string output. However, if null is returned + // then return null so that no output gets displayed. + if (writeResultAsOutput || results is null || results.Count == 0) + { + return null; + } + + // If we didn't write output, return a VariableDetails instance. + return new VariableDetails( + expressionString, + // If there's only one result, we want its raw value (especially if it's null). For + // a collection, since we're displaying these, we want to concatenante them. + // However, doing that for one result caused null to be turned into an empty string. + results.Count == 1 + ? results[0] + : string.Join(Environment.NewLine, results)); + } + + /// + /// Gets the list of stack frames at the point where the + /// debugger sf stopped. + /// + /// + /// An array of StackFrameDetails instances that contain the stack trace. + /// + public StackFrameDetails[] GetStackFrames() + { + debugInfoHandle.Wait(); + try + { + return stackFrameDetails; + } + finally + { + debugInfoHandle.Release(); + } + } + + internal async Task GetStackFramesAsync() + { + await debugInfoHandle.WaitAsync().ConfigureAwait(false); + try + { + return stackFrameDetails; + } + finally + { + debugInfoHandle.Release(); + } + } + + internal async Task GetStackFramesAsync(CancellationToken cancellationToken) + { + await debugInfoHandle.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return stackFrameDetails; + } + finally + { + debugInfoHandle.Release(); + } + } + + /// + /// Gets the list of variable scopes for the stack frame that + /// is identified by the given ID. + /// + /// The ID of the stack frame at which variable scopes should be retrieved. + /// The list of VariableScope instances which describe the available variable scopes. + public VariableScope[] GetVariableScopes(int stackFrameId) + { + StackFrameDetails[] stackFrames = GetStackFrames(); + int autoVariablesId = stackFrames[stackFrameId].AutoVariables.Id; + int commandVariablesId = stackFrames[stackFrameId].CommandVariables.Id; + + return new VariableScope[] + { + new VariableScope(autoVariablesId, VariableContainerDetails.AutoVariablesName), + new VariableScope(commandVariablesId, VariableContainerDetails.CommandVariablesName), + new VariableScope(localScopeVariables.Id, VariableContainerDetails.LocalScopeName), + new VariableScope(scriptScopeVariables.Id, VariableContainerDetails.ScriptScopeName), + new VariableScope(globalScopeVariables.Id, VariableContainerDetails.GlobalScopeName), + }; + } + + internal bool TryGetMappedLocalPath(string remotePath, out string localPath) + { + foreach (PathMapping mapping in PathMappings) + { + if (string.IsNullOrWhiteSpace(mapping.LocalRoot) || string.IsNullOrWhiteSpace(mapping.RemoteRoot)) + { + // If either path mapping is null, we can't map the path. + continue; + } + + if (remotePath.StartsWith(mapping.RemoteRoot, StringComparison.OrdinalIgnoreCase)) + { + localPath = mapping.LocalRoot + remotePath.Substring(mapping.RemoteRoot.Length); + return true; + } + } + + localPath = null; + return false; + } + + internal bool TryGetMappedRemotePath(string localPath, out string remotePath) + { + foreach (PathMapping mapping in PathMappings) + { + if (string.IsNullOrWhiteSpace(mapping.LocalRoot) || string.IsNullOrWhiteSpace(mapping.RemoteRoot)) + { + // If either path mapping is null, we can't map the path. + continue; + } + + if (localPath.StartsWith(mapping.LocalRoot, StringComparison.OrdinalIgnoreCase)) + { + // If the local path starts with the local path mapping, we can replace it with the remote path. + remotePath = mapping.RemoteRoot + localPath.Substring(mapping.LocalRoot.Length); + return true; + } + } + + remotePath = null; + return false; + } + + #endregion + + #region Private Methods + + private async Task FetchStackFramesAndVariablesAsync(string scriptNameOverride) + { + await debugInfoHandle.WaitAsync().ConfigureAwait(false); + try + { + nextVariableId = VariableDetailsBase.FirstVariableId; + variables = new List + { + // Create a dummy variable for index 0, should never see this. + new VariableDetails("Dummy", null) + }; + + // Must retrieve in order of broadest to narrowest scope for efficient + // deduplication: global, script, local. + globalScopeVariables = await FetchVariableContainerAsync(VariableContainerDetails.GlobalScopeName).ConfigureAwait(false); + scriptScopeVariables = await FetchVariableContainerAsync(VariableContainerDetails.ScriptScopeName).ConfigureAwait(false); + localScopeVariables = await FetchVariableContainerAsync(VariableContainerDetails.LocalScopeName).ConfigureAwait(false); + + await FetchStackFramesAsync(scriptNameOverride).ConfigureAwait(false); + } + finally + { + debugInfoHandle.Release(); + } + } + + private Task FetchVariableContainerAsync(string scope) => FetchVariableContainerAsync(scope, autoVarsOnly: false); + + private async Task FetchVariableContainerAsync(string scope, bool autoVarsOnly) + { + PSCommand psCommand = new PSCommand().AddCommand(@"Microsoft.PowerShell.Utility\Get-Variable").AddParameter("Scope", scope); + + VariableContainerDetails scopeVariableContainer = new(nextVariableId++, "Scope: " + scope); + variables.Add(scopeVariableContainer); + + IReadOnlyList results; + try + { + results = await _executionService.ExecutePSCommandAsync(psCommand, CancellationToken.None).ConfigureAwait(false); + } + // It's possible to be asked to run `Get-Variable -Scope N` where N is a number that + // exceeds the available scopes. In this case, the command throws this exception, but + // there's nothing we can do about it, nor can we know the number of scopes that exist, + // and we shouldn't crash the debugger, so we just return no results instead. All other + // exceptions should be thrown again. + catch (CmdletInvocationException ex) when (ex.ErrorRecord.CategoryInfo.Reason.Equals("PSArgumentOutOfRangeException")) + { + results = null; + } + + if (results is not null) + { + foreach (PSObject psVariableObject in results) + { + // Under some circumstances, we seem to get variables back with no "Name" field + // We skip over those here. + if (psVariableObject.Properties["Name"] is null) + { + continue; + } + VariableInfo variableInfo = TryVariableInfo(psVariableObject); + if (variableInfo is null || !ShouldAddAsVariable(variableInfo)) + { + continue; + } + if (autoVarsOnly && !ShouldAddToAutoVariables(variableInfo)) + { + continue; + } + + VariableDetails variableDetails = new(variableInfo.Variable) { Id = nextVariableId++ }; + variables.Add(variableDetails); + scopeVariableContainer.Children.Add(variableDetails.Name, variableDetails); + } + } + + return scopeVariableContainer; + } + + // This is a helper type for FetchStackFramesAsync to preserve the variable Type after deserialization. + private record VariableInfo(string[] Types, PSVariable Variable); + + // Create a VariableInfo for both serialized and deserialized variables. + private static VariableInfo TryVariableInfo(PSObject psObject) + { + if (psObject.TypeNames.Contains("System.Management.Automation.PSVariable")) + { + return new VariableInfo(psObject.TypeNames.ToArray(), psObject.BaseObject as PSVariable); + } + if (psObject.TypeNames.Contains("Deserialized.System.Management.Automation.PSVariable")) + { + // Rehydrate the relevant variable properties and recreate it. + ScopedItemOptions options = (ScopedItemOptions)Enum.Parse(typeof(ScopedItemOptions), psObject.Properties["Options"].Value.ToString()); + PSVariable reconstructedVar = new( + psObject.Properties["Name"].Value.ToString(), + psObject.Properties["Value"].Value, + options + ); + return new VariableInfo(psObject.TypeNames.ToArray(), reconstructedVar); + } + + return null; + } + + /// + /// Filters out variables we don't care about such as built-ins + /// + private static bool ShouldAddAsVariable(VariableInfo variableInfo) + { + // Filter built-in constant or readonly variables like $true, $false, $null, etc. + ScopedItemOptions variableScope = variableInfo.Variable.Options; + const ScopedItemOptions constantAllScope = ScopedItemOptions.AllScope | ScopedItemOptions.Constant; + const ScopedItemOptions readonlyAllScope = ScopedItemOptions.AllScope | ScopedItemOptions.ReadOnly; + if (((variableScope & constantAllScope) == constantAllScope) + || ((variableScope & readonlyAllScope) == readonlyAllScope)) + { + return false; + } + + if (variableInfo.Variable.Name switch { "null" => true, _ => false }) + { + return false; + } + + return true; + } + + // This method curates variables that should be added to the "auto" view, which we define as variables that are + // very likely to be contextually relevant to the user, in an attempt to reduce noise when debugging. + // Variables not listed here can still be found in the other containers like local and script, this is + // provided as a convenience. + private bool ShouldAddToAutoVariables(VariableInfo variableInfo) + { + PSVariable variableToAdd = variableInfo.Variable; + if (!ShouldAddAsVariable(variableInfo)) + { + return false; + } + + // Filter internal variables created by Powershell Editor Services. + if (variableToAdd.Name.StartsWith(PsesGlobalVariableNamePrefix) + || variableToAdd.Name.Equals("PSDebugContext")) + { + return false; + } + + // Filter Global-Scoped variables. We first cast to VariableDetails to ensure the prefix + // is added for purposes of comparison. + VariableDetails variableToAddDetails = new(variableToAdd); + if (globalScopeVariables.Children.ContainsKey(variableToAddDetails.Name)) + { + return false; + } + + // We curate a list of LocalVariables that, if they exist, should be displayed by default. + if (variableInfo.Types[0].EndsWith("LocalVariable")) + { + return variableToAdd.Name switch + { + "PSItem" or "_" or "" => true, + "args" or "input" => variableToAdd.Value is Array array && array.Length > 0, + "PSBoundParameters" => variableToAdd.Value is IDictionary dict && dict.Count > 0, + _ => false + }; + } + + // Any other PSVariables that survive the above criteria should be included. + return variableInfo.Types[0].EndsWith("PSVariable"); + } + + private async Task FetchStackFramesAsync(string scriptNameOverride) + { + // This glorious hack ensures that Get-PSCallStack returns a list of CallStackFrame + // objects (or "deserialized" CallStackFrames) when attached to a runspace in another + // process. Without the intermediate variable Get-PSCallStack inexplicably returns an + // array of strings containing the formatted output of the CallStackFrame list. So we + // run a script that builds the list of CallStackFrames and their variables. + const string callStackVarName = $"$global:{PsesGlobalVariableNamePrefix}CallStack"; + const string getPSCallStack = $"Get-PSCallStack | ForEach-Object {{ [void]{callStackVarName}.Add(@($PSItem, $PSItem.GetFrameVariables())) }}"; + + _psesHost.Runspace.ThrowCancelledIfUnusable(); + // If we're attached to a remote runspace, we need to serialize the list prior to + // transport because the default depth is too shallow. From testing, we determined the + // correct depth is 3. The script always calls `Get-PSCallStack`. In a local runspace, we + // just return its results. In a remote runspace we serialize it first and then later + // deserialize it. + bool isRemoteRunspace = _psesHost.CurrentRunspace.Runspace.RunspaceIsRemote; + string returnSerializedIfInRemoteRunspace = isRemoteRunspace + ? $"[Management.Automation.PSSerializer]::Serialize({callStackVarName}, 3)" + : callStackVarName; + + // PSObject is used here instead of the specific type because we get deserialized + // objects from remote sessions and want a common interface. + PSCommand psCommand = new PSCommand().AddScript($"[System.Diagnostics.DebuggerHidden()]param() [Collections.ArrayList]{callStackVarName} = @(); {getPSCallStack}; {returnSerializedIfInRemoteRunspace}"); + IReadOnlyList results = await _executionService.ExecutePSCommandAsync(psCommand, CancellationToken.None).ConfigureAwait(false); + + IEnumerable callStack = isRemoteRunspace + ? (PSSerializer.Deserialize(results[0].BaseObject as string) as PSObject)?.BaseObject as IList + : results; + + List stackFrameDetailList = new(); + bool isTopStackFrame = true; + foreach (object callStackFrameItem in callStack) + { + // We have to use reflection to get the variable dictionary. + IList callStackFrameComponents = (callStackFrameItem as PSObject)?.BaseObject as IList; + PSObject callStackFrame = callStackFrameComponents[0] as PSObject; + IDictionary callStackVariables = isRemoteRunspace + ? (callStackFrameComponents[1] as PSObject)?.BaseObject as IDictionary + : callStackFrameComponents[1] as IDictionary; + + VariableContainerDetails autoVariables = new( + nextVariableId++, + VariableContainerDetails.AutoVariablesName); + + variables.Add(autoVariables); + + VariableContainerDetails commandVariables = new( + nextVariableId++, + VariableContainerDetails.CommandVariablesName); + + variables.Add(commandVariables); + + foreach (DictionaryEntry entry in callStackVariables) + { + VariableInfo psVarInfo = TryVariableInfo(new PSObject(entry.Value)); + if (psVarInfo is null) + { + _logger.LogError("A object was received that is not a PSVariable object"); + continue; + } + + VariableDetails variableDetails = new(psVarInfo.Variable) { Id = nextVariableId++ }; + variables.Add(variableDetails); + + commandVariables.Children.Add(variableDetails.Name, variableDetails); + + if (ShouldAddToAutoVariables(psVarInfo)) + { + autoVariables.Children.Add(variableDetails.Name, variableDetails); + } + } + + // If this is the top stack frame, we also want to add relevant local variables to + // the "Auto" container (not to be confused with Automatic PowerShell variables). + // + // TODO: We can potentially use `Get-Variable -Scope x` to add relevant local + // variables to other frames but frames and scopes are not perfectly analogous and + // we'd need a way to detect things such as module borders and dot-sourced files. + if (isTopStackFrame) + { + VariableContainerDetails localScopeAutoVariables = await FetchVariableContainerAsync(VariableContainerDetails.LocalScopeName, autoVarsOnly: true).ConfigureAwait(false); + foreach (KeyValuePair entry in localScopeAutoVariables.Children) + { + // NOTE: `TryAdd` doesn't work on `IDictionary`. + if (!autoVariables.Children.ContainsKey(entry.Key)) + { + autoVariables.Children.Add(entry.Key, entry.Value); + } + } + isTopStackFrame = false; + } + + StackFrameDetails stackFrameDetailsEntry = StackFrameDetails.Create(callStackFrame, autoVariables, commandVariables); + string stackFrameScriptPath = stackFrameDetailsEntry.ScriptPath; + + bool isNoScriptPath = string.Equals(stackFrameScriptPath, StackFrameDetails.NoFileScriptPath); + if (scriptNameOverride is not null && isNoScriptPath) + { + stackFrameDetailsEntry.ScriptPath = scriptNameOverride; + } + else if (TryGetMappedLocalPath(stackFrameScriptPath, out string localMappedPath) + && !isNoScriptPath) + { + stackFrameDetailsEntry.ScriptPath = localMappedPath; + } + else if (_psesHost.CurrentRunspace.IsOnRemoteMachine + && _remoteFileManager is not null + && !isNoScriptPath) + { + stackFrameDetailsEntry.ScriptPath = + _remoteFileManager.GetMappedPath(stackFrameScriptPath, _psesHost.CurrentRunspace); + } + + stackFrameDetailList.Add(stackFrameDetailsEntry); + } + + stackFrameDetails = stackFrameDetailList.ToArray(); + } + + private static string TrimScriptListingLine(PSObject scriptLineObj, ref int prefixLength) + { + string scriptLine = scriptLineObj.ToString(); + + if (!string.IsNullOrWhiteSpace(scriptLine)) + { + if (prefixLength == 0) + { + // The prefix is a padded integer ending with ':', an asterisk '*' + // if this is the current line, and one character of padding + prefixLength = scriptLine.IndexOf(':') + 2; + } + + return scriptLine.Substring(prefixLength); + } + + return null; + } + + #endregion + + #region Events + + /// + /// Raised when the debugger stops execution at a breakpoint or when paused. + /// + public event EventHandler DebuggerStopped; + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "It has to be async.")] + internal async void OnDebuggerStopAsync(object sender, DebuggerStopEventArgs e) + { + try + { + bool noScriptName = false; + string localScriptPath = e.InvocationInfo.ScriptName; + + // If there's no ScriptName, get the "list" of the current source + if (_remoteFileManager is not null && string.IsNullOrEmpty(localScriptPath)) + { + // Get the current script listing and create the buffer + IReadOnlyList scriptListingLines; + await debugInfoHandle.WaitAsync().ConfigureAwait(false); + try + { + // This command must be run through `ExecuteInDebugger`! + PSCommand psCommand = new PSCommand().AddScript($"list 1 {int.MaxValue}"); + + scriptListingLines = + await _executionService.ExecutePSCommandAsync( + psCommand, + CancellationToken.None).ConfigureAwait(false); + } + finally + { + debugInfoHandle.Release(); + } + + if (scriptListingLines.Count > 0) + { + int linePrefixLength = 0; + + string scriptListing = + string.Join( + Environment.NewLine, + scriptListingLines + .Select(o => TrimScriptListingLine(o, ref linePrefixLength)) + .Where(s => s is not null)); + + temporaryScriptListingPath = + _remoteFileManager.CreateTemporaryFile( + $"[{_psesHost.CurrentRunspace.SessionDetails.ComputerName}] {TemporaryScriptFileName}", + scriptListing, + _psesHost.CurrentRunspace); + + localScriptPath = + temporaryScriptListingPath + ?? StackFrameDetails.NoFileScriptPath; + + noScriptName = localScriptPath is not null; + } + else + { + _logger.LogWarning("Could not load script context"); + } + } + + // Begin call stack and variables fetch. We don't need to block here. + StackFramesAndVariablesFetched = FetchStackFramesAndVariablesAsync(noScriptName ? localScriptPath : null); + + if (!noScriptName && TryGetMappedLocalPath(e.InvocationInfo.ScriptName, out string mappedLocalPath)) + { + localScriptPath = mappedLocalPath; + } + // If this is a remote connection and the debugger stopped at a line + // in a script file, get the file contents + else if (_psesHost.CurrentRunspace.IsOnRemoteMachine + && _remoteFileManager is not null + && !noScriptName) + { + localScriptPath = + await _remoteFileManager.FetchRemoteFileAsync( + e.InvocationInfo.ScriptName, + _psesHost.CurrentRunspace).ConfigureAwait(false); + } + + CurrentDebuggerStoppedEventArgs = + new DebuggerStoppedEventArgs( + e, + _psesHost.CurrentRunspace, + localScriptPath); + + // Notify the host that the debugger is stopped. + DebuggerStopped?.Invoke(sender, CurrentDebuggerStoppedEventArgs); + } + catch (OperationCanceledException) + { + // Ignore, likely means that a remote runspace has closed. + } + catch (Exception exception) + { + // Log in a catch all so we don't crash the process. + _logger.LogError( + exception, + "Error occurred while obtaining debug info. Message: {message}", + exception.Message); + } + } + + private void OnDebuggerResuming(object sender, DebuggerResumingEventArgs debuggerResumingEventArgs) => CurrentDebuggerStoppedEventArgs = null; + + /// + /// Raised when a breakpoint is added/removed/updated in the debugger. + /// + public event EventHandler BreakpointUpdated; + + private void OnBreakpointUpdated(object sender, BreakpointUpdatedEventArgs e) + { + // This event callback also gets called when a CommandBreakpoint is modified. + // Only execute the following code for LineBreakpoint so we can keep track + // of which line breakpoints exist per script file. We use this later when + // we need to clear all breakpoints in a script file. We do not need to do + // this for CommandBreakpoint, as those span all script files. + if (e.Breakpoint is LineBreakpoint lineBreakpoint) + { + // TODO: This could be either a path or a script block! + string scriptPath = lineBreakpoint.Script; + if (TryGetMappedLocalPath(scriptPath, out string mappedLocalPath)) + { + scriptPath = mappedLocalPath; + } + else if (_psesHost.CurrentRunspace.IsOnRemoteMachine + && _remoteFileManager is not null) + { + string mappedPath = _remoteFileManager.GetMappedPath(scriptPath, _psesHost.CurrentRunspace); + + if (mappedPath is null) + { + _logger.LogError($"Could not map remote path '{scriptPath}' to a local path."); + return; + } + + scriptPath = mappedPath; + } + + // TODO: It is very strange that we use the path as the key, which it could also be + // a script block. + Validate.IsNotNullOrEmptyString(nameof(scriptPath), scriptPath); + + // Normalize the script filename for proper indexing + string normalizedScriptName = scriptPath.ToLower(); + + // Get the list of breakpoints for this file + if (!_breakpointService.BreakpointsPerFile.TryGetValue(normalizedScriptName, out HashSet breakpoints)) + { + breakpoints = new HashSet(); + _breakpointService.BreakpointsPerFile.Add( + normalizedScriptName, + breakpoints); + } + + // Add or remove the breakpoint based on the update type + if (e.UpdateType == BreakpointUpdateType.Set) + { + breakpoints.Add(e.Breakpoint); + } + else if (e.UpdateType == BreakpointUpdateType.Removed) + { + breakpoints.Remove(e.Breakpoint); + } + else + { + // TODO: Do I need to switch out instances for updated breakpoints? + } + } + + BreakpointUpdated?.Invoke(sender, e); + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/DebugStateService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/DebugStateService.cs new file mode 100644 index 0000000..9736b3e --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/DebugStateService.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Utility; + +namespace Microsoft.PowerShell.EditorServices.Services +{ + internal class DebugStateService + { + private readonly SemaphoreSlim _setBreakpointInProgressHandle = AsyncUtils.CreateSimpleLockingSemaphore(); + + internal bool NoDebug { get; set; } + + internal string[] Arguments { get; set; } + + internal bool IsRemoteAttach { get; set; } + + internal int? RunspaceId { get; set; } + + internal bool IsAttachSession { get; set; } + + internal bool WaitingForAttach { get; set; } + + internal string ScriptToLaunch { get; set; } + + internal bool ExecutionCompleted { get; set; } + + internal bool IsInteractiveDebugSession { get; set; } + + // If the CurrentCount is equal to zero, then we have some thread using the handle. + internal bool IsSetBreakpointInProgress => _setBreakpointInProgressHandle.CurrentCount == 0; + + internal bool IsUsingTempIntegratedConsole { get; set; } + + internal string ExecuteMode { get; set; } + + // This gets set at the end of the Launch/Attach handler which set debug state. + internal TaskCompletionSource ServerStarted { get; set; } + + internal int ReleaseSetBreakpointHandle() => _setBreakpointInProgressHandle.Release(); + + internal Task WaitForSetBreakpointHandleAsync() => _setBreakpointInProgressHandle.WaitAsync(); + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointApiUtils.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointApiUtils.cs new file mode 100644 index 0000000..ebb0646 --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointApiUtils.cs @@ -0,0 +1,319 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Management.Automation; +using System.Management.Automation.Language; +using System.Reflection; +using System.Text; +using System.Threading; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace; +using Microsoft.PowerShell.EditorServices.Utility; + +namespace Microsoft.PowerShell.EditorServices.Services.DebugAdapter + +{ + internal static class BreakpointApiUtils + { + #region Private Static Fields + + private static readonly Lazy> s_setLineBreakpointLazy; + + private static readonly Lazy> s_setCommandBreakpointLazy; + + private static readonly Lazy>> s_getBreakpointsLazy; + + private static readonly Lazy> s_removeBreakpointLazy; + + private static readonly Version s_minimumBreakpointApiPowerShellVersion = new(7, 0, 0, 0); + + private static int breakpointHitCounter; + + #endregion + + #region Static Constructor + + static BreakpointApiUtils() + { + // If this version of PowerShell does not support the new Breakpoint APIs introduced in PowerShell 7.0.0, + // do nothing as this class will not get used. + if (!VersionUtils.IsPS7OrGreater) + { + return; + } + + s_setLineBreakpointLazy = new Lazy>(() => + { + Type[] setLineBreakpointParameters = new[] { typeof(string), typeof(int), typeof(int), typeof(ScriptBlock), typeof(int?) }; + MethodInfo setLineBreakpointMethod = typeof(Debugger).GetMethod("SetLineBreakpoint", setLineBreakpointParameters); + + return (Func)Delegate.CreateDelegate( + typeof(Func), + firstArgument: null, + setLineBreakpointMethod); + }); + + s_setCommandBreakpointLazy = new Lazy>(() => + { + Type[] setCommandBreakpointParameters = new[] { typeof(string), typeof(ScriptBlock), typeof(string), typeof(int?) }; + MethodInfo setCommandBreakpointMethod = typeof(Debugger).GetMethod("SetCommandBreakpoint", setCommandBreakpointParameters); + + return (Func)Delegate.CreateDelegate( + typeof(Func), + firstArgument: null, + setCommandBreakpointMethod); + }); + + s_getBreakpointsLazy = new Lazy>>(() => + { + Type[] getBreakpointsParameters = new[] { typeof(int?) }; + MethodInfo getBreakpointsMethod = typeof(Debugger).GetMethod("GetBreakpoints", getBreakpointsParameters); + + return (Func>)Delegate.CreateDelegate( + typeof(Func>), + firstArgument: null, + getBreakpointsMethod); + }); + + s_removeBreakpointLazy = new Lazy>(() => + { + Type[] removeBreakpointParameters = new[] { typeof(Breakpoint), typeof(int?) }; + MethodInfo removeBreakpointMethod = typeof(Debugger).GetMethod("RemoveBreakpoint", removeBreakpointParameters); + + return (Func)Delegate.CreateDelegate( + typeof(Func), + firstArgument: null, + removeBreakpointMethod); + }); + } + + #endregion + + #region Private Static Properties + + private static Func SetLineBreakpointDelegate => s_setLineBreakpointLazy.Value; + + private static Func SetCommandBreakpointDelegate => s_setCommandBreakpointLazy.Value; + + private static Func> GetBreakpointsDelegate => s_getBreakpointsLazy.Value; + + private static Func RemoveBreakpointDelegate => s_removeBreakpointLazy.Value; + + #endregion + + #region Public Static Properties + + public static bool SupportsBreakpointApis(IRunspaceInfo targetRunspace) => targetRunspace.PowerShellVersionDetails.Version >= s_minimumBreakpointApiPowerShellVersion; + + #endregion + + #region Public Static Methods + + public static Breakpoint SetBreakpoint(Debugger debugger, BreakpointDetailsBase breakpoint, int? runspaceId = null) + { + ScriptBlock actionScriptBlock = null; + string logMessage = breakpoint is BreakpointDetails bd ? bd.LogMessage : null; + // Check if this is a "conditional" line breakpoint. + if (!string.IsNullOrWhiteSpace(breakpoint.Condition) || + !string.IsNullOrWhiteSpace(breakpoint.HitCondition) || + !string.IsNullOrWhiteSpace(logMessage)) + { + actionScriptBlock = GetBreakpointActionScriptBlock( + breakpoint.Condition, + breakpoint.HitCondition, + logMessage, + out string errorMessage); + + if (!string.IsNullOrEmpty(errorMessage)) + { + // This is handled by the caller where it will set the 'Message' and 'Verified' on the BreakpointDetails + throw new InvalidOperationException(errorMessage); + } + } + + return breakpoint switch + { + BreakpointDetails lineBreakpoint => SetLineBreakpointDelegate( + debugger, + lineBreakpoint.MappedSource ?? lineBreakpoint.Source, + lineBreakpoint.LineNumber, + lineBreakpoint.ColumnNumber ?? 0, + actionScriptBlock, + runspaceId), + + CommandBreakpointDetails commandBreakpoint => SetCommandBreakpointDelegate(debugger, + commandBreakpoint.Name, + null, + null, + runspaceId), + + _ => throw new NotImplementedException("Other breakpoints not supported yet"), + }; + } + + public static List GetBreakpoints(Debugger debugger, int? runspaceId = null) => GetBreakpointsDelegate(debugger, runspaceId); + + public static bool RemoveBreakpoint(Debugger debugger, Breakpoint breakpoint, int? runspaceId = null) => RemoveBreakpointDelegate(debugger, breakpoint, runspaceId); + + /// + /// Inspects the condition, putting in the appropriate scriptblock template + /// "if (expression) { break }". If errors are found in the condition, the + /// breakpoint passed in is updated to set Verified to false and an error + /// message is put into the breakpoint.Message property. + /// + /// The expression that needs to be true for the breakpoint to be triggered. + /// The amount of times this line should be hit til the breakpoint is triggered. + /// The log message to write instead of calling 'break'. In VS Code, this is called a 'logPoint'. + /// The error message we might return. + /// ScriptBlock + public static ScriptBlock GetBreakpointActionScriptBlock(string condition, string hitCondition, string logMessage, out string errorMessage) + { + errorMessage = null; + + try + { + StringBuilder builder = new( + string.IsNullOrEmpty(logMessage) + ? "break" + : $"Microsoft.PowerShell.Utility\\Write-Host \"{logMessage.Replace("\"", "`\"")}\""); + + // If HitCondition specified, parse and verify it. + if (!string.IsNullOrWhiteSpace(hitCondition)) + { + if (!int.TryParse(hitCondition, out int parsedHitCount)) + { + throw new InvalidOperationException("Hit Count was not a valid integer."); + } + + if (string.IsNullOrWhiteSpace(condition)) + { + // In the HitCount only case, this is simple as we can just use the HitCount + // property on the breakpoint object which is represented by $_. + builder.Insert(0, $"if ($_.HitCount -eq {parsedHitCount}) {{ ") + .Append(" }"); + } + + int incrementResult = Interlocked.Increment(ref breakpointHitCounter); + + string globalHitCountVarName = + $"$global:{DebugService.PsesGlobalVariableNamePrefix}BreakHitCounter_{incrementResult}"; + + builder.Insert(0, $"if (++{globalHitCountVarName} -eq {parsedHitCount}) {{ ") + .Append(" }"); + } + + if (!string.IsNullOrWhiteSpace(condition)) + { + ScriptBlock parsed = ScriptBlock.Create(condition); + + // Check for simple, common errors that ScriptBlock parsing will not catch + // e.g. $i == 3 and $i > 3 + if (!ValidateBreakpointConditionAst(parsed.Ast, out string message)) + { + throw new InvalidOperationException(message); + } + + // Check for "advanced" condition syntax i.e. if the user has specified + // a "break" or "continue" statement anywhere in their scriptblock, + // pass their scriptblock through to the Action parameter as-is. + if (parsed.Ast.Find(ast => ast is BreakStatementAst or ContinueStatementAst, true) is not null) + { + return parsed; + } + + builder.Insert(0, $"if ({condition}) {{ ") + .Append(" }"); + } + + return ScriptBlock.Create(builder.ToString()); + } + catch (ParseException e) + { + errorMessage = ExtractAndScrubParseExceptionMessage(e, condition); + return null; + } + catch (InvalidOperationException e) + { + errorMessage = e.Message; + return null; + } + } + + private static bool ValidateBreakpointConditionAst(Ast conditionAst, out string message) + { + message = string.Empty; + + // We are only inspecting a few simple scenarios in the EndBlock only. + if (conditionAst is ScriptBlockAst scriptBlockAst && + scriptBlockAst.BeginBlock is null && + scriptBlockAst.ProcessBlock is null && + scriptBlockAst.EndBlock?.Statements.Count == 1) + { + StatementAst statementAst = scriptBlockAst.EndBlock.Statements[0]; + string condition = statementAst.Extent.Text; + + if (statementAst is AssignmentStatementAst) + { + message = FormatInvalidBreakpointConditionMessage(condition, "Use '-eq' instead of '=='."); + return false; + } + + if (statementAst is PipelineAst pipelineAst + && pipelineAst.PipelineElements.Count == 1 + && pipelineAst.PipelineElements[0].Redirections.Count > 0) + { + message = FormatInvalidBreakpointConditionMessage(condition, "Use '-gt' instead of '>'."); + return false; + } + } + + return true; + } + + private static string ExtractAndScrubParseExceptionMessage(ParseException parseException, string condition) + { + string[] messageLines = parseException.Message.Split('\n'); + + // Skip first line - it is a location indicator "At line:1 char: 4" + for (int i = 1; i < messageLines.Length; i++) + { + string line = messageLines[i]; + if (line.StartsWith("+")) + { + continue; + } + + if (!string.IsNullOrWhiteSpace(line)) + { + // Note '==' and '>" do not generate parse errors + if (line.Contains("'!='")) + { + line += " Use operator '-ne' instead of '!='."; + } + else if (line.Contains("'<'") && condition.Contains("<=")) + { + line += " Use operator '-le' instead of '<='."; + } + else if (line.Contains("'<'")) + { + line += " Use operator '-lt' instead of '<'."; + } + else if (condition.Contains(">=")) + { + line += " Use operator '-ge' instead of '>='."; + } + + return FormatInvalidBreakpointConditionMessage(condition, line); + } + } + + // If the message format isn't in a form we expect, just return the whole message. + return FormatInvalidBreakpointConditionMessage(condition, parseException.Message); + } + + private static string FormatInvalidBreakpointConditionMessage(string condition, string message) => $"'{condition}' is not a valid PowerShell expression. {message}"; + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointDetails.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointDetails.cs new file mode 100644 index 0000000..4177b38 --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointDetails.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Management.Automation; +using Microsoft.PowerShell.EditorServices.Utility; + +namespace Microsoft.PowerShell.EditorServices.Services.DebugAdapter +{ + /// + /// Provides details about a breakpoint that is set in the + /// PowerShell debugger. + /// + internal sealed class BreakpointDetails : BreakpointDetailsBase + { + /// + /// Gets the unique ID of the breakpoint. + /// + /// + public int Id { get; private set; } + + /// + /// Gets the source where the breakpoint is located. Used only for debug purposes. + /// + public string Source { get; private set; } + + /// + /// Gets the source where the breakpoint is mapped to, will be null if no mapping exists. Used only for debug purposes. + /// + public string MappedSource { get; private set; } + + /// + /// Gets the line number at which the breakpoint is set. + /// + public int LineNumber { get; private set; } + + /// + /// Gets the column number at which the breakpoint is set. + /// + public int? ColumnNumber { get; private set; } + + public string LogMessage { get; private set; } + + private BreakpointDetails() + { + } + + /// + /// Creates an instance of the BreakpointDetails class from the individual + /// pieces of breakpoint information provided by the client. + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static BreakpointDetails Create( + string source, + int line, + int? column = null, + string condition = null, + string hitCondition = null, + string logMessage = null, + string mappedSource = null) + { + Validate.IsNotNullOrEmptyString(nameof(source), source); + + return new BreakpointDetails + { + Verified = true, + Source = source, + LineNumber = line, + ColumnNumber = column, + Condition = condition, + HitCondition = hitCondition, + LogMessage = logMessage, + MappedSource = mappedSource + }; + } + + /// + /// Creates an instance of the BreakpointDetails class from a + /// PowerShell Breakpoint object. + /// + /// The Breakpoint instance from which details will be taken. + /// The BreakpointUpdateType to determine if the breakpoint is verified. + /// /// The breakpoint source from the debug client, if any. + /// A new instance of the BreakpointDetails class. + internal static BreakpointDetails Create( + Breakpoint breakpoint, + BreakpointUpdateType updateType = BreakpointUpdateType.Set, + BreakpointDetails sourceBreakpoint = null) + { + Validate.IsNotNull(nameof(breakpoint), breakpoint); + + if (breakpoint is not LineBreakpoint lineBreakpoint) + { + throw new ArgumentException( + "Unexpected breakpoint type: " + breakpoint.GetType().Name); + } + + BreakpointDetails breakpointDetails = new() + { + Id = breakpoint.Id, + Verified = updateType != BreakpointUpdateType.Disabled, + Source = sourceBreakpoint?.MappedSource is not null ? sourceBreakpoint.Source : lineBreakpoint.Script, + LineNumber = lineBreakpoint.Line, + ColumnNumber = lineBreakpoint.Column, + Condition = lineBreakpoint.Action?.ToString(), + MappedSource = sourceBreakpoint?.MappedSource, + }; + + if (lineBreakpoint.Column > 0) + { + breakpointDetails.ColumnNumber = lineBreakpoint.Column; + } + + return breakpointDetails; + } + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointDetailsBase.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointDetailsBase.cs new file mode 100644 index 0000000..78a7ce4 --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointDetailsBase.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.PowerShell.EditorServices.Services.DebugAdapter +{ + /// + /// Provides details about a breakpoint that is set in the + /// PowerShell debugger. + /// + internal abstract class BreakpointDetailsBase + { + /// + /// Gets or sets a boolean indicator that if true, breakpoint could be set + /// (but not necessarily at the desired location). + /// + public bool Verified { get; set; } + + /// + /// Gets or set an optional message about the state of the breakpoint. This is shown to the user + /// and can be used to explain why a breakpoint could not be verified. + /// + public string Message { get; set; } + + /// + /// Gets the breakpoint condition string. + /// + public string Condition { get; protected set; } + + /// + /// Gets the breakpoint hit condition string. + /// + public string HitCondition { get; protected set; } + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/CommandBreakpointDetails.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/CommandBreakpointDetails.cs new file mode 100644 index 0000000..09dd168 --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/CommandBreakpointDetails.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Management.Automation; +using Microsoft.PowerShell.EditorServices.Utility; + +namespace Microsoft.PowerShell.EditorServices.Services.DebugAdapter +{ + /// + /// Provides details about a command breakpoint that is set in the PowerShell debugger. + /// + internal sealed class CommandBreakpointDetails : BreakpointDetailsBase + { + /// + /// Gets the name of the command on which the command breakpoint has been set. + /// + public string Name { get; private set; } + + private CommandBreakpointDetails() + { + } + + /// + /// Creates an instance of the class from the individual + /// pieces of breakpoint information provided by the client. + /// + /// The name of the command to break on. + /// Condition string that would be applied to the breakpoint Action parameter. + /// + internal static CommandBreakpointDetails Create(string name, string condition = null) + { + Validate.IsNotNull(nameof(name), name); + + return new CommandBreakpointDetails + { + Name = name, + Condition = condition + }; + } + + /// + /// Creates an instance of the class from a + /// PowerShell CommandBreakpoint object. + /// + /// The Breakpoint instance from which details will be taken. + /// A new instance of the BreakpointDetails class. + internal static CommandBreakpointDetails Create(Breakpoint breakpoint) + { + Validate.IsNotNull(nameof(breakpoint), breakpoint); + + if (breakpoint is not CommandBreakpoint commandBreakpoint) + { + throw new ArgumentException( + "Unexpected breakpoint type: " + breakpoint.GetType().Name); + } + + return new() + { + Verified = true, + Name = commandBreakpoint.Command, + Condition = commandBreakpoint.Action?.ToString() + }; + } + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/DebuggerStoppedEventArgs.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/DebuggerStoppedEventArgs.cs new file mode 100644 index 0000000..dd94d92 --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/DebuggerStoppedEventArgs.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Management.Automation; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace; +using Microsoft.PowerShell.EditorServices.Utility; + +namespace Microsoft.PowerShell.EditorServices.Services.DebugAdapter +{ + /// + /// Provides event arguments for the DebugService.DebuggerStopped event. + /// + internal class DebuggerStoppedEventArgs + { + #region Properties + + /// + /// Gets the path of the script where the debugger has stopped execution. + /// If 'IsRemoteSession' returns true, this path will be a local filesystem + /// path containing the contents of the script that is executing remotely. + /// + public string ScriptPath { get; } + + /// + /// Returns true if the breakpoint was raised from a remote debugging session. + /// + public bool IsRemoteSession => RunspaceInfo.RunspaceOrigin != RunspaceOrigin.Local; + + /// + /// Gets the original script path if 'IsRemoteSession' returns true. + /// + public string RemoteScriptPath { get; } + + /// + /// Gets the RunspaceDetails for the current runspace. + /// + public IRunspaceInfo RunspaceInfo { get; } + + /// + /// Gets the line number at which the debugger stopped execution. + /// + public int LineNumber => OriginalEvent.InvocationInfo.ScriptLineNumber; + + /// + /// Gets the column number at which the debugger stopped execution. + /// + public int ColumnNumber => OriginalEvent.InvocationInfo.OffsetInLine; + + /// + /// Gets the original DebuggerStopEventArgs from the PowerShell engine. + /// + public DebuggerStopEventArgs OriginalEvent { get; } + + #endregion + + #region Constructors + + /// + /// Creates a new instance of the DebuggerStoppedEventArgs class. + /// + /// The original DebuggerStopEventArgs instance from which this instance is based. + /// The RunspaceDetails of the runspace which raised this event. + public DebuggerStoppedEventArgs( + DebuggerStopEventArgs originalEvent, + IRunspaceInfo runspaceInfo) + : this(originalEvent, runspaceInfo, null) + { + } + + /// + /// Creates a new instance of the DebuggerStoppedEventArgs class. + /// + /// The original DebuggerStopEventArgs instance from which this instance is based. + /// The RunspaceDetails of the runspace which raised this event. + /// The local path of the remote script being debugged. + public DebuggerStoppedEventArgs( + DebuggerStopEventArgs originalEvent, + IRunspaceInfo runspaceInfo, + string localScriptPath) + { + Validate.IsNotNull(nameof(originalEvent), originalEvent); + Validate.IsNotNull(nameof(runspaceInfo), runspaceInfo); + + if (!string.IsNullOrEmpty(localScriptPath)) + { + ScriptPath = localScriptPath; + RemoteScriptPath = originalEvent.InvocationInfo.ScriptName; + } + else + { + ScriptPath = originalEvent.InvocationInfo.ScriptName; + } + + OriginalEvent = originalEvent; + RunspaceInfo = runspaceInfo; + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/InvalidPowerShellExpressionException.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/InvalidPowerShellExpressionException.cs new file mode 100644 index 0000000..e044786 --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/InvalidPowerShellExpressionException.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; + +namespace Microsoft.PowerShell.EditorServices.Services.DebugAdapter +{ + /// + /// Represents the exception that is thrown when an invalid expression is provided to the DebugService's SetVariable method. + /// + public class InvalidPowerShellExpressionException : Exception + { + /// + /// Initializes a new instance of the SetVariableExpressionException class. + /// + /// Message indicating why the expression is invalid. + public InvalidPowerShellExpressionException(string message) : base(message) { } + + public InvalidPowerShellExpressionException() { } + + public InvalidPowerShellExpressionException(string message, Exception innerException) : base(message, innerException) { } + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/StackFrameDetails.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/StackFrameDetails.cs new file mode 100644 index 0000000..c188f33 --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/StackFrameDetails.cs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Management.Automation; + +namespace Microsoft.PowerShell.EditorServices.Services.DebugAdapter +{ + /// + /// Contains details pertaining to a single stack frame in + /// the current debugging session. + /// + internal class StackFrameDetails + { + #region Fields + + /// + /// A constant string used in the ScriptPath field to represent a + /// stack frame with no associated script file. + /// + public const string NoFileScriptPath = ""; + + #endregion + + #region Properties + + /// + /// Gets the path to the script where the stack frame occurred. + /// + public string ScriptPath { get; internal set; } + + /// + /// Gets the name of the function where the stack frame occurred. + /// + public string FunctionName { get; internal init; } + + /// + /// Gets the start line number of the script where the stack frame occurred. + /// + public int StartLineNumber { get; internal set; } + + /// + /// Gets the line number of the script where the stack frame occurred. + /// + public int? EndLineNumber { get; internal set; } + + /// + /// Gets the start column number of the line where the stack frame occurred. + /// + public int StartColumnNumber { get; internal set; } + + /// + /// Gets the end column number of the line where the stack frame occurred. + /// + public int? EndColumnNumber { get; internal set; } + + /// + /// Gets a boolean value indicating whether or not the stack frame is executing + /// in script external to the current workspace root. + /// + public bool IsExternalCode { get; internal set; } + + /// + /// Gets or sets the VariableContainerDetails that contains the auto variables. + /// + public VariableContainerDetails AutoVariables { get; internal init; } + + /// + /// Gets or sets the VariableContainerDetails that contains the call stack frame variables. + /// + public VariableContainerDetails CommandVariables { get; internal init; } + + #endregion + + #region Constructors + + /// + /// Creates an instance of the StackFrameDetails class from a + /// CallStackFrame instance provided by the PowerShell engine. + /// + /// + /// A PSObject representing the CallStackFrame instance from which details will be obtained. + /// + /// + /// A variable container with all the filtered, auto variables for this stack frame. + /// + /// + /// A variable container with all the command variables for this stack frame. + /// + /// A new instance of the StackFrameDetails class. + internal static StackFrameDetails Create( + PSObject callStackFrameObject, + VariableContainerDetails autoVariables, + VariableContainerDetails commandVariables) + { + string scriptPath = (callStackFrameObject.Properties["ScriptName"].Value as string) ?? NoFileScriptPath; + int startLineNumber = (int)(callStackFrameObject.Properties["ScriptLineNumber"].Value ?? 0); + + return new StackFrameDetails + { + ScriptPath = scriptPath, + FunctionName = callStackFrameObject.Properties["FunctionName"].Value as string, + StartLineNumber = startLineNumber, + EndLineNumber = startLineNumber, // End line number isn't given in PowerShell stack frames + StartColumnNumber = 0, // Column number isn't given in PowerShell stack frames + EndColumnNumber = 0, + AutoVariables = autoVariables, + CommandVariables = commandVariables, + // TODO: Re-enable `isExternal` detection along with a setting. Will require + // `workspaceRootPath`, see Git blame. + IsExternalCode = false + }; + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/VariableContainerDetails.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/VariableContainerDetails.cs new file mode 100644 index 0000000..2ac7b8c --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/VariableContainerDetails.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Utility; + +namespace Microsoft.PowerShell.EditorServices.Services.DebugAdapter +{ + /// + /// Container for variables that is not itself a variable per se. However given how + /// VSCode uses an integer variable reference id for every node under the "Variables" tool + /// window, it is useful to treat containers, typically scope containers, as a variable. + /// Note that these containers are not necessarily always a scope container. Consider a + /// container such as "Auto" or "My". These aren't scope related but serve as just another + /// way to organize variables into a useful UI structure. + /// + [DebuggerDisplay("Name = {Name}, Id = {Id}, Count = {Children.Count}")] + internal class VariableContainerDetails : VariableDetailsBase + { + /// + /// Provides a constant for the name of the filtered auto variables. + /// + public const string AutoVariablesName = "Auto"; + + /// + /// Provides a constant for the name of the current stack frame variables. + /// + public const string CommandVariablesName = "Command"; + + /// + /// Provides a constant for the name of the global scope variables. + /// + public const string GlobalScopeName = "Global"; + + /// + /// Provides a constant for the name of the local scope variables. + /// + public const string LocalScopeName = "Local"; + + /// + /// Provides a constant for the name of the Script scope. + /// + public const string ScriptScopeName = "Script"; + + private readonly Dictionary children; + + /// + /// Instantiates an instance of VariableScopeDetails. + /// + /// The variable reference id for this scope. + /// The name of the variable scope. + public VariableContainerDetails(int id, string name) + { + Validate.IsNotNull(name, "name"); + + Id = id; + Name = name; + IsExpandable = true; + ValueString = " "; // An empty string isn't enough due to a temporary bug in VS Code. + + children = new Dictionary(); + } + + /// + /// Gets the collection of child variables. + /// + public IDictionary Children => children; + + /// + /// Returns the details of the variable container's children. If empty, returns an empty array. + /// + /// + public override VariableDetailsBase[] GetChildren(ILogger logger) + { + VariableDetailsBase[] variablesArray = new VariableDetailsBase[children.Count]; + children.Values.CopyTo(variablesArray, 0); + return variablesArray; + } + + /// + /// Determines whether this variable container contains the specified variable by its referenceId. + /// + /// The variableReferenceId to search for. + /// Returns true if this variable container directly contains the specified variableReferenceId, false otherwise. + public bool ContainsVariable(int variableReferenceId) + { + foreach (VariableDetailsBase value in children.Values) + { + if (value.Id == variableReferenceId) + { + return true; + } + } + + return false; + } + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/VariableDetails.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/VariableDetails.cs new file mode 100644 index 0000000..f67230a --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/VariableDetails.cs @@ -0,0 +1,413 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Management.Automation; +using System.Reflection; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Utility; + +namespace Microsoft.PowerShell.EditorServices.Services.DebugAdapter +{ + /// + /// Contains details pertaining to a variable in the current + /// debugging session. + /// + [DebuggerDisplay("Name = {Name}, Id = {Id}, Value = {ValueString}")] + internal class VariableDetails : VariableDetailsBase + { + #region Fields + + /// + /// Provides a constant for the dollar sign variable prefix string. + /// + public const string DollarPrefix = "$"; + protected object ValueObject { get; } + private VariableDetails[] cachedChildren; + + #endregion + + #region Constructors + + /// + /// Initializes an instance of the VariableDetails class from + /// the details contained in a PSVariable instance. + /// + /// + /// The PSVariable instance from which variable details will be obtained. + /// + public VariableDetails(PSVariable psVariable) + : this(DollarPrefix + psVariable.Name, psVariable.Value) + { + } + + /// + /// Initializes an instance of the VariableDetails class from + /// the name and value pair stored inside of a PSObject which + /// represents a PSVariable. + /// + /// + /// The PSObject which represents a PSVariable. + /// + public VariableDetails(PSObject psVariableObject) + : this( + DollarPrefix + psVariableObject.Properties["Name"].Value, + psVariableObject.Properties["Value"].Value) + { + } + + /// + /// Initializes an instance of the VariableDetails class from + /// the details contained in a PSPropertyInfo instance. + /// + /// + /// The PSPropertyInfo instance from which variable details will be obtained. + /// + public VariableDetails(PSPropertyInfo psProperty) + : this(psProperty.Name, SafeGetValue(psProperty)) + { + } + + /// + /// Initializes an instance of the VariableDetails class from + /// a given name/value pair. + /// + /// The variable's name. + /// The variable's value. + public VariableDetails(string name, object value) + { + ValueObject = value; + + Id = -1; // Not been assigned a variable reference id yet + Name = name; + IsExpandable = GetIsExpandable(value); + + ValueString = GetValueStringAndType(value, IsExpandable, out string typeName); + Type = typeName; + } + + #endregion + + #region Public Methods + + /// + /// If this variable instance is expandable, this method returns the + /// details of its children. Otherwise it returns an empty array. + /// + public override VariableDetailsBase[] GetChildren(ILogger logger) + { + if (IsExpandable) + { + cachedChildren ??= GetChildren(ValueObject, logger); + return cachedChildren; + } + + return Array.Empty(); + } + + #endregion + + #region Private Methods + + private static object SafeGetValue(PSPropertyInfo psProperty) + { + try + { + return psProperty.Value; + } + catch (GetValueInvocationException ex) + { + // Sometimes we can't get the value, like ExitCode, for reasons beyond our control, + // so just return the message from the exception that arises. + return new UnableToRetrievePropertyMessage { Name = psProperty.Name, Message = ex.Message }; + } + } + + private static bool GetIsExpandable(object valueObject) + { + if (valueObject == null) + { + return false; + } + + // If a PSObject, unwrap it + if (valueObject is PSObject psobject) + { + valueObject = psobject.BaseObject; + } + + Type valueType = valueObject?.GetType(); + TypeInfo valueTypeInfo = valueType.GetTypeInfo(); + + return + valueObject != null && + !valueTypeInfo.IsPrimitive && + !valueTypeInfo.IsEnum && // Enums don't have any properties + valueObject is not string && // Strings get treated as IEnumerables + valueObject is not decimal && + valueObject is not UnableToRetrievePropertyMessage; + } + + private static string GetValueStringAndType(object value, bool isExpandable, out string typeName) + { + typeName = null; + + if (value == null) + { + // Set to identifier recognized by PowerShell to make setVariable from the debug UI more natural. + return "$null"; + } + + Type objType = value.GetType(); + + // This is the type format PowerShell users expect and will appear when you hover a variable name + typeName = '[' + objType.FullName + ']'; + + string valueString; + if (value is bool x) + { + // Set to identifier recognized by PowerShell to make setVariable from the debug UI more natural. + valueString = x ? "$true" : "$false"; + + // We need to use this "magic value" to highlight in vscode properly + // These "magic values" are analogous to TypeScript and are visible in VSCode here: + // https://github.com/microsoft/vscode/blob/57ca9b99d5b6a59f2d2e0f082ae186559f45f1d8/src/vs/workbench/contrib/debug/browser/baseDebugView.ts#L68-L78 + // NOTE: we don't do numbers and strings since they (so far) seem to get detected properly by + // serialization, and the original .NET type can be preserved so it shows up in the variable name + // type hover as the original .NET type. + typeName = "boolean"; + } + else if (isExpandable) + { + // For DictionaryEntry - display the key/value as the value. + // Get the "value" for an expandable object. + if (value is DictionaryEntry entry) + { + valueString = GetValueStringAndType(entry.Value, GetIsExpandable(entry.Value), out typeName); + } + else + { + string valueToString = value.SafeToString(); + if (valueToString?.Equals(objType.ToString()) != false) + { + // If the ToString() matches the type name or is null, then display the type + // name in PowerShell format. + string shortTypeName = objType.Name; + + // For arrays and ICollection, display the number of contained items. + if (value is Array) + { + Array arr = value as Array; + if (arr.Rank == 1) + { + shortTypeName = InsertDimensionSize(shortTypeName, arr.Length); + } + } + else if (value is ICollection collection) + { + shortTypeName = InsertDimensionSize(shortTypeName, collection.Count); + } + + valueString = $"[{shortTypeName}]"; + } + else + { + valueString = valueToString; + } + } + } + else + { + // Value is a scalar (not expandable). If it's a string, display it directly otherwise use SafeToString() + valueString = value is string ? "\"" + value + "\"" : value.SafeToString(); + } + + return valueString; + } + + private static string InsertDimensionSize(string value, int dimensionSize) + { + int indexLastRBracket = value.LastIndexOf("]"); + if (indexLastRBracket > 0) + { + return value.Substring(0, indexLastRBracket) + + dimensionSize + + value.Substring(indexLastRBracket); + } + // Types like ArrayList don't use [] in type name so + // display value like so - [ArrayList: 5] + return value + ": " + dimensionSize; + } + + private static VariableDetails[] GetChildren(object obj, ILogger logger) + { + List childVariables = new(); + + if (obj == null) + { + return childVariables.ToArray(); + } + + // NOTE: Variable expansion now takes place on the pipeline thread as an async delegate, + // so expansion of children that cause PowerShell script code to execute should + // generally work. However, we might need more error handling. + PSObject psObject = obj as PSObject; + + if ((psObject != null) && + (psObject.TypeNames[0] == typeof(PSCustomObject).ToString())) + { + // PowerShell PSCustomObject's properties are completely defined by the ETS type system. + logger.LogDebug("PSObject was a PSCustomObject"); + childVariables.AddRange( + psObject + .Properties + .Select(p => new VariableDetails(p))); + } + else + { + // If a PSObject other than a PSCustomObject, unwrap it. + if (psObject != null) + { + // First add the PSObject's ETS properties + logger.LogDebug("PSObject was something else, first getting ETS properties"); + childVariables.AddRange( + psObject + .Properties + // Here we check the object's MemberType against the `Properties` + // bit-mask to determine if this is a property. Hence the selection + // will only include properties. + .Where(p => (PSMemberTypes.Properties & p.MemberType) is not 0) + .Select(p => new VariableDetails(p))); + + obj = psObject.BaseObject; + } + + // We're in the realm of regular, unwrapped .NET objects + if (obj is IDictionary dictionary) + { + logger.LogDebug("PSObject was an IDictionary"); + // Buckle up kids, this is a bit weird. We could not use the LINQ + // operator OfType. Even though R# will squiggle the + // "foreach" keyword below and offer to convert to a LINQ-expression - DON'T DO IT! + // The reason is that LINQ extension methods work with objects of type + // IEnumerable. Objects of type Dictionary<,>, respond to iteration via + // IEnumerable by returning KeyValuePair<,> objects. Unfortunately non-generic + // dictionaries like HashTable return DictionaryEntry objects. + // It turns out that iteration via C#'s foreach loop, operates on the variable's + // type which in this case is IDictionary. IDictionary was designed to always + // return DictionaryEntry objects upon iteration and the Dictionary<,> implementation + // honors that when the object is reinterpreted as an IDictionary object. + // FYI, a test case for this is to open $PSBoundParameters when debugging a + // function that defines parameters and has been passed parameters. + // If you open the $PSBoundParameters variable node in this scenario and see nothing, + // this code is broken. + foreach (DictionaryEntry entry in dictionary) + { + childVariables.Add( + new VariableDetails( + "[" + entry.Key + "]", + entry)); + } + } + else if (obj is IEnumerable enumerable and not string) + { + logger.LogDebug("PSObject was an IEnumerable"); + int i = 0; + foreach (object item in enumerable) + { + childVariables.Add( + new VariableDetails( + "[" + i++ + "]", + item)); + } + } + + logger.LogDebug("Adding .NET properties to PSObject"); + AddDotNetProperties(obj, childVariables); + } + + return childVariables.ToArray(); + } + + protected static void AddDotNetProperties(object obj, List childVariables, bool noRawView = false) + { + Type objectType = obj.GetType(); + + // For certain array or dictionary types, we want to hide additional properties under a "raw view" header + // to reduce noise. This is inspired by the C# vscode extension. + if (!noRawView && obj is IEnumerable) + { + childVariables.Add(new VariableDetailsRawView(obj)); + return; + } + + // Search all the public instance properties and add those missing. + foreach (PropertyInfo property in objectType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + // Don't display indexer properties, it causes an exception anyway. + if (property.GetIndexParameters().Length > 0) + { + continue; + } + + try + { + // Only add unique properties because we may have already added some. + if (!childVariables.Exists(p => p.Name == property.Name)) + { + childVariables.Add(new VariableDetails(property.Name, property.GetValue(obj))); + } + } + catch (Exception ex) + { + // Some properties can throw exceptions, add the property + // name and info about the error. + if (ex is TargetInvocationException) + { + ex = ex.InnerException; + } + + childVariables.Add( + new VariableDetails( + property.Name, + new UnableToRetrievePropertyMessage { Name = property.Name, Message = ex.Message })); + } + } + } + + #endregion + + private record UnableToRetrievePropertyMessage + { + public string Name { get; init; } + public string Message { get; init; } + + public override string ToString() => $"Error retrieving property '${Name}': ${Message}"; + } + } + + /// + /// A VariableDetails that only returns the raw view properties of the object, rather than its values. + /// + internal sealed class VariableDetailsRawView : VariableDetails + { + private const string RawViewName = "Raw View"; + + public VariableDetailsRawView(object value) : base(RawViewName, value) + { + ValueString = ""; + Type = ""; + } + + public override VariableDetailsBase[] GetChildren(ILogger logger) + { + List childVariables = new(); + AddDotNetProperties(ValueObject, childVariables, noRawView: true); + return childVariables.ToArray(); + } + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/VariableDetailsBase.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/VariableDetailsBase.cs new file mode 100644 index 0000000..ea4f14a --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/VariableDetailsBase.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.PowerShell.EditorServices.Services.DebugAdapter +{ + /// + /// Defines the common details between a variable and a variable container such as a scope + /// in the current debugging session. + /// + internal abstract class VariableDetailsBase + { + /// + /// Provides a constant that is used as the starting variable ID for all. + /// Avoid 0 as it indicates a variable node with no children. + /// variables. + /// + public const int FirstVariableId = 1; + + /// + /// Gets the numeric ID of the variable which can be used to refer + /// to it in future requests. + /// + public int Id { get; set; } + + /// + /// Gets the variable's name. + /// + public string Name { get; protected set; } + + /// + /// Gets the string representation of the variable's value. + /// If the variable is an expandable object, this string + /// will be empty. + /// + public string ValueString { get; protected set; } + + /// + /// Gets the type of the variable's value. + /// + public string Type { get; protected set; } + + /// + /// Returns true if the variable's value is expandable, meaning + /// that it has child properties or its contents can be enumerated. + /// + public bool IsExpandable { get; protected set; } + + /// + /// If this variable instance is expandable, this method returns the + /// details of its children. Otherwise it returns an empty array. + /// + public abstract VariableDetailsBase[] GetChildren(ILogger logger); + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/VariableScope.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/VariableScope.cs new file mode 100644 index 0000000..bda463d --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/VariableScope.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.PowerShell.EditorServices.Services.DebugAdapter +{ + /// + /// Contains details pertaining to a variable scope in the current + /// debugging session. + /// + internal class VariableScope + { + /// + /// Gets a numeric ID that can be used in future operations + /// relating to this scope. + /// + public int Id { get; } + + /// + /// Gets a name that describes the variable scope. + /// + public string Name { get; } + + /// + /// Initializes a new instance of the VariableScope class with + /// the given ID and name. + /// + /// The variable scope's ID. + /// The variable scope's name. + public VariableScope(int id, string name) + { + Id = id; + Name = name; + } + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs new file mode 100644 index 0000000..1c26c48 --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Logging; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.DebugAdapter; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.DebugAdapter.Protocol.Models; +using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + internal class BreakpointHandlers : ISetFunctionBreakpointsHandler, ISetBreakpointsHandler, ISetExceptionBreakpointsHandler + { + private static readonly string[] s_supportedDebugFileExtensions = new[] + { + ".ps1", + ".psm1" + }; + + private readonly ILogger _logger; + private readonly DebugService _debugService; + private readonly DebugStateService _debugStateService; + private readonly WorkspaceService _workspaceService; + + public BreakpointHandlers( + ILoggerFactory loggerFactory, + DebugService debugService, + DebugStateService debugStateService, + WorkspaceService workspaceService) + { + _logger = loggerFactory.CreateLogger(); + _debugService = debugService; + _debugStateService = debugStateService; + _workspaceService = workspaceService; + } + + public async Task Handle(SetBreakpointsArguments request, CancellationToken cancellationToken) + { + if (!_workspaceService.TryGetFile(request.Source.Path, out ScriptFile scriptFile)) + { + string message = _debugStateService.NoDebug ? string.Empty : "Source file could not be accessed, breakpoint not set."; + IEnumerable srcBreakpoints = request.Breakpoints + .Select(srcBkpt => LspDebugUtils.CreateBreakpoint( + srcBkpt, request.Source.Path, message, verified: _debugStateService.NoDebug)); + + // Return non-verified breakpoint message. + return new SetBreakpointsResponse + { + Breakpoints = new Container(srcBreakpoints) + }; + } + + // Verify source file is a PowerShell script file. + if (!IsFileSupportedForBreakpoints(request.Source.Path, scriptFile)) + { + _logger.LogWarning( + $"Attempted to set breakpoints on a non-PowerShell file: {request.Source.Path}"); + + string message = _debugStateService.NoDebug ? string.Empty : "Source is not a PowerShell script, breakpoint not set."; + + IEnumerable srcBreakpoints = request.Breakpoints + .Select(srcBkpt => LspDebugUtils.CreateBreakpoint( + srcBkpt, request.Source.Path, message, verified: _debugStateService.NoDebug)); + + // Return non-verified breakpoint message. + return new SetBreakpointsResponse + { + Breakpoints = new Container(srcBreakpoints) + }; + } + + // At this point, the source file has been verified as a PowerShell script. + string mappedSource = null; + if (_debugService.TryGetMappedRemotePath(scriptFile.FilePath, out string remoteMappedPath)) + { + mappedSource = remoteMappedPath; + } + IReadOnlyList breakpointDetails = request.Breakpoints + .Select((srcBreakpoint) => BreakpointDetails.Create( + scriptFile.FilePath, + srcBreakpoint.Line, + srcBreakpoint.Column, + srcBreakpoint.Condition, + srcBreakpoint.HitCondition, + srcBreakpoint.LogMessage, + mappedSource: mappedSource)).ToList(); + + // If this is a "run without debugging (Ctrl+F5)" session ignore requests to set breakpoints. + IReadOnlyList updatedBreakpointDetails = breakpointDetails; + if (!_debugStateService.NoDebug) + { + await _debugStateService.WaitForSetBreakpointHandleAsync().ConfigureAwait(false); + + try + { + updatedBreakpointDetails = + await _debugService.SetLineBreakpointsAsync( + mappedSource ?? scriptFile.FilePath, + breakpointDetails, + skipRemoteMapping: mappedSource is not null).ConfigureAwait(false); + } + catch (Exception e) + { + // Log whatever the error is + _logger.LogException($"Caught error while setting breakpoints in SetBreakpoints handler for file {scriptFile?.FilePath}", e); + } + finally + { + _debugStateService.ReleaseSetBreakpointHandle(); + } + } + + return new SetBreakpointsResponse + { + Breakpoints = new Container(updatedBreakpointDetails + .Select(LspDebugUtils.CreateBreakpoint)) + }; + } + + public async Task Handle(SetFunctionBreakpointsArguments request, CancellationToken cancellationToken) + { + IReadOnlyList breakpointDetails = request.Breakpoints + .Select((funcBreakpoint) => CommandBreakpointDetails.Create( + funcBreakpoint.Name, + funcBreakpoint.Condition)).ToList(); + + // If this is a "run without debugging (Ctrl+F5)" session ignore requests to set breakpoints. + IReadOnlyList updatedBreakpointDetails = breakpointDetails; + if (!_debugStateService.NoDebug) + { + await _debugStateService.WaitForSetBreakpointHandleAsync().ConfigureAwait(false); + + try + { + updatedBreakpointDetails = await _debugService.SetCommandBreakpointsAsync(breakpointDetails).ConfigureAwait(false); + } + catch (Exception e) + { + // Log whatever the error is + _logger.LogException("Caught error while setting command breakpoints", e); + } + finally + { + _debugStateService.ReleaseSetBreakpointHandle(); + } + } + + return new SetFunctionBreakpointsResponse + { + Breakpoints = updatedBreakpointDetails.Select(LspDebugUtils.CreateBreakpoint).ToList() + }; + } + + public Task Handle(SetExceptionBreakpointsArguments request, CancellationToken cancellationToken) => + // TODO: When support for exception breakpoints (unhandled and/or first chance) + // is added to the PowerShell engine, wire up the VSCode exception + // breakpoints here using the pattern below to prevent bug regressions. + //if (!noDebug) + //{ + // setBreakpointInProgress = true; + + // try + // { + // // Set exception breakpoints in DebugService + // } + // catch (Exception e) + // { + // // Log whatever the error is + // Logger.WriteException($"Caught error while setting exception breakpoints", e); + // } + // finally + // { + // setBreakpointInProgress = false; + // } + //} + + Task.FromResult(new SetExceptionBreakpointsResponse()); + + private static bool IsFileSupportedForBreakpoints(string requestedPath, ScriptFile resolvedScriptFile) + { + if (ScriptFile.IsUntitledPath(requestedPath)) + { + return true; + } + + if (string.IsNullOrEmpty(resolvedScriptFile?.FilePath)) + { + return false; + } + + string fileExtension = Path.GetExtension(resolvedScriptFile.FilePath); + return s_supportedDebugFileExtensions.Contains(fileExtension, StringComparer.OrdinalIgnoreCase); + } + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs new file mode 100644 index 0000000..146bbea --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Management.Automation; +using System.Management.Automation.Language; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.PowerShell; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Debugging; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.DebugAdapter.Protocol.Events; +using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; +using OmniSharp.Extensions.DebugAdapter.Protocol.Server; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + internal class ConfigurationDoneHandler : IConfigurationDoneHandler + { + // TODO: We currently set `WriteInputToHost` as true, which writes our debugged commands' + // `GetInvocationText` and that reveals some obscure implementation details we should + // instead hide from the user with pretty strings (or perhaps not write out at all). + // + // This API is mostly used for F5 execution so it requires the foreground. + private static readonly PowerShellExecutionOptions s_debuggerExecutionOptions = new() + { + RequiresForeground = true, + WriteInputToHost = true, + WriteOutputToHost = true, + ThrowOnError = false, + AddToHistory = true, + }; + + private readonly ILogger _logger; + private readonly IDebugAdapterServerFacade _debugAdapterServer; + private readonly DebugService _debugService; + private readonly DebugStateService _debugStateService; + private readonly DebugEventHandlerService _debugEventHandlerService; + private readonly IInternalPowerShellExecutionService _executionService; + private readonly WorkspaceService _workspaceService; + private readonly IPowerShellDebugContext _debugContext; + + // TODO: Decrease these arguments since they're a bunch of interfaces that can be simplified + // (i.e., `IRunspaceContext` should just be available on `IPowerShellExecutionService`). + public ConfigurationDoneHandler( + ILoggerFactory loggerFactory, + IDebugAdapterServerFacade debugAdapterServer, + DebugService debugService, + DebugStateService debugStateService, + DebugEventHandlerService debugEventHandlerService, + IInternalPowerShellExecutionService executionService, + WorkspaceService workspaceService, + IPowerShellDebugContext debugContext) + { + _logger = loggerFactory.CreateLogger(); + _debugAdapterServer = debugAdapterServer; + _debugService = debugService; + _debugStateService = debugStateService; + _debugEventHandlerService = debugEventHandlerService; + _executionService = executionService; + _workspaceService = workspaceService; + _debugContext = debugContext; + } + + public Task Handle(ConfigurationDoneArguments request, CancellationToken cancellationToken) + { + _debugService.IsClientAttached = true; + + if (!string.IsNullOrEmpty(_debugStateService.ScriptToLaunch)) + { + // NOTE: This is an unawaited task because responding to "configuration done" means + // setting up the debugger, and in our case that means starting the script but not + // waiting for it to finish. + Task _ = LaunchScriptAsync(_debugStateService.ScriptToLaunch).HandleErrorsAsync(_logger); + } + + if (_debugStateService.IsInteractiveDebugSession && _debugService.IsDebuggerStopped) + { + if (_debugService.CurrentDebuggerStoppedEventArgs is not null) + { + // If this is an interactive session and there's a pending breakpoint, send that + // information along to the debugger client. + _debugEventHandlerService.TriggerDebuggerStopped(_debugService.CurrentDebuggerStoppedEventArgs); + } + else + { + // If this is an interactive session and there's a pending breakpoint that has + // not been propagated through the debug service, fire the debug service's + // OnDebuggerStop event. + _debugService.OnDebuggerStopAsync(null, _debugContext.LastStopEventArgs); + } + } + + return Task.FromResult(new ConfigurationDoneResponse()); + } + + // NOTE: We test this function in `DebugServiceTests` so it both needs to be internal, and + // use conditional-access on `_debugStateService` and `_debugAdapterServer` as its not set + // by tests. + internal async Task LaunchScriptAsync(string scriptToLaunch) + { + PSCommand command; + if (System.IO.File.Exists(scriptToLaunch)) + { + // For a saved file we just execute its path (after escaping it), with the configured operator + // (which can't be called that because it's a reserved keyword in C#). + string executeMode = _debugStateService?.ExecuteMode == "Call" ? "&" : "."; + command = PSCommandHelpers.BuildDotSourceCommandWithArguments( + PSCommandHelpers.EscapeScriptFilePath(scriptToLaunch), _debugStateService?.Arguments, executeMode); + } + else // It's a URI to an untitled script, or a raw script. + { + bool isScriptFile = _workspaceService.TryGetFile(scriptToLaunch, out ScriptFile untitledScript); + if (isScriptFile) + { + // Parse untitled files with their `Untitled:` URI as the filename which will + // cache the URI and contents within the PowerShell parser. By doing this, we + // light up the ability to debug untitled files with line breakpoints. + ScriptBlockAst ast = Parser.ParseInput( + untitledScript.Contents, + untitledScript.DocumentUri.ToString(), + out Token[] _, + out ParseError[] _); + + // In order to use utilize the parser's cache (and therefore hit line + // breakpoints) we need to use the AST's `ScriptBlock` object. Due to + // limitations in PowerShell's public API, this means we must use the + // `PSCommand.AddArgument(object)` method, hence this hack where we dot-source + // `$args[0]. Fortunately the dot-source operator maintains a stack of arguments + // on each invocation, so passing the user's arguments directly in the initial + // `AddScript` surprisingly works. + command = PSCommandHelpers + .BuildDotSourceCommandWithArguments("$args[0]", _debugStateService?.Arguments) + .AddArgument(ast.GetScriptBlock()); + } + else + { + // Without the new APIs we can only execute the untitled script's contents. + // Command breakpoints and `Wait-Debugger` will work. We must wrap the script + // with newlines so that any included comments don't break the command. + command = PSCommandHelpers.BuildDotSourceCommandWithArguments( + string.Concat( + "{" + System.Environment.NewLine, + isScriptFile ? untitledScript.Contents : scriptToLaunch, + System.Environment.NewLine + "}"), + _debugStateService?.Arguments); + } + } + + await _executionService.ExecutePSCommandAsync( + command, + CancellationToken.None, + s_debuggerExecutionOptions).ConfigureAwait(false); + + _debugAdapterServer?.SendNotification(EventNames.Terminated); + } + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/DebugEvaluateHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/DebugEvaluateHandler.cs new file mode 100644 index 0000000..78c55c1 --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/DebugEvaluateHandler.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Management.Automation; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.DebugAdapter; +using Microsoft.PowerShell.EditorServices.Services.PowerShell; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Debugging; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution; +using Microsoft.PowerShell.EditorServices.Utility; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + internal class DebugEvaluateHandler : IEvaluateHandler + { + private readonly ILogger _logger; + private readonly IPowerShellDebugContext _debugContext; + private readonly IInternalPowerShellExecutionService _executionService; + private readonly DebugService _debugService; + + // AKA Watch Variables + public DebugEvaluateHandler( + ILoggerFactory factory, + IPowerShellDebugContext debugContext, + IInternalPowerShellExecutionService executionService, + DebugService debugService) + { + _logger = factory.CreateLogger(); + _debugContext = debugContext; + _executionService = executionService; + _debugService = debugService; + } + + public async Task Handle(EvaluateRequestArguments request, CancellationToken cancellationToken) + { + string valueString = ""; + int variableId = 0; + + bool isFromRepl = + string.Equals( + request.Context, + "repl", + StringComparison.CurrentCultureIgnoreCase); + + if (isFromRepl) + { + await _executionService.ExecutePSCommandAsync( + new PSCommand().AddScript($"[System.Diagnostics.DebuggerHidden()]param() {request.Expression}"), + cancellationToken, + new PowerShellExecutionOptions { WriteOutputToHost = true, ThrowOnError = false, AddToHistory = true }).HandleErrorsAsync(_logger).ConfigureAwait(false); + } + else + { + VariableDetailsBase result = null; + + // VS Code might send this request after the debugger + // has been resumed, return an empty result in this case. + if (_debugContext.IsStopped) + { + // First check to see if the watch expression refers to a naked variable reference. + result = await _debugService.GetVariableFromExpression(request.Expression, cancellationToken).ConfigureAwait(false); + + // If the expression is not a naked variable reference, then evaluate the expression. + result ??= await _debugService.EvaluateExpressionAsync( + request.Expression, + isFromRepl, + cancellationToken).ConfigureAwait(false); + } + + if (result != null) + { + valueString = result.ValueString; + variableId = result.IsExpandable ? result.Id : 0; + } + } + + return new EvaluateResponseBody + { + Result = valueString, + VariablesReference = variableId + }; + } + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/DebuggerActionHandlers.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/DebuggerActionHandlers.cs new file mode 100644 index 0000000..1e830a2 --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/DebuggerActionHandlers.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Services; +using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; +using OmniSharp.Extensions.JsonRpc; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + internal class ContinueHandler : ContinueHandlerBase + { + private readonly DebugService _debugService; + + public ContinueHandler(DebugService debugService) => _debugService = debugService; + + public override Task Handle(ContinueArguments request, CancellationToken cancellationToken) + { + _debugService.Continue(); + return Task.FromResult(new ContinueResponse()); + } + } + + internal class NextHandler : NextHandlerBase + { + private readonly DebugService _debugService; + + public NextHandler(DebugService debugService) => _debugService = debugService; + + public override Task Handle(NextArguments request, CancellationToken cancellationToken) + { + _debugService.StepOver(); + return Task.FromResult(new NextResponse()); + } + } + + internal class PauseHandler : PauseHandlerBase + { + private readonly DebugService _debugService; + + public PauseHandler(DebugService debugService) => _debugService = debugService; + + public override Task Handle(PauseArguments request, CancellationToken cancellationToken) + { + try + { + _debugService.Break(); + return Task.FromResult(new PauseResponse()); + } + catch (NotSupportedException e) + { + throw new RpcErrorException(0, null, e.Message); + } + } + } + + internal class StepInHandler : StepInHandlerBase + { + private readonly DebugService _debugService; + + public StepInHandler(DebugService debugService) => _debugService = debugService; + + public override Task Handle(StepInArguments request, CancellationToken cancellationToken) + { + _debugService.StepIn(); + return Task.FromResult(new StepInResponse()); + } + } + + internal class StepOutHandler : StepOutHandlerBase + { + private readonly DebugService _debugService; + + public StepOutHandler(DebugService debugService) => _debugService = debugService; + + public override Task Handle(StepOutArguments request, CancellationToken cancellationToken) + { + _debugService.StepOut(); + return Task.FromResult(new StepOutResponse()); + } + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/DisconnectHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/DisconnectHandler.cs new file mode 100644 index 0000000..b042f91 --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/DisconnectHandler.cs @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Management.Automation; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Logging; +using Microsoft.PowerShell.EditorServices.Server; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.PowerShell; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace; +using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + internal class DisconnectHandler : IDisconnectHandler + { + private readonly ILogger _logger; + private readonly IInternalPowerShellExecutionService _executionService; + private readonly DebugService _debugService; + private readonly DebugStateService _debugStateService; + private readonly DebugEventHandlerService _debugEventHandlerService; + private readonly PsesDebugServer _psesDebugServer; + private readonly IRunspaceContext _runspaceContext; + + public DisconnectHandler( + ILoggerFactory factory, + PsesDebugServer psesDebugServer, + IRunspaceContext runspaceContext, + IInternalPowerShellExecutionService executionService, + DebugService debugService, + DebugStateService debugStateService, + DebugEventHandlerService debugEventHandlerService) + { + _logger = factory.CreateLogger(); + _psesDebugServer = psesDebugServer; + _runspaceContext = runspaceContext; + _executionService = executionService; + _debugService = debugService; + _debugStateService = debugStateService; + _debugEventHandlerService = debugEventHandlerService; + } + + public async Task Handle(DisconnectArguments request, CancellationToken cancellationToken) + { + // TODO: We need to sort out the proper order of operations here. + // Currently we just tear things down in some order without really checking what the debugger is doing. + // We should instead ensure that the debugger is in some valid state, lock it and then tear things down + + _debugEventHandlerService.UnregisterEventHandlers(); + _debugService.PathMappings = []; + + if (!_debugStateService.ExecutionCompleted) + { + _debugStateService.ExecutionCompleted = true; + _debugService.Abort(); + + if (!_debugStateService.IsAttachSession && !_debugStateService.IsUsingTempIntegratedConsole) + { + await _executionService.ExecutePSCommandAsync( + new PSCommand().AddCommand("Remove-Variable") + .AddParameter("Name", DebugService.PsesGlobalVariableDebugServerName) + .AddParameter("Force", true), + cancellationToken).ConfigureAwait(false); + } + + if (_debugStateService.IsInteractiveDebugSession && _debugStateService.IsRemoteAttach) + { + // Pop the sessions + if (_runspaceContext.CurrentRunspace.RunspaceOrigin == RunspaceOrigin.EnteredProcess) + { + try + { + await _executionService.ExecutePSCommandAsync( + new PSCommand().AddCommand("Exit-PSHostProcess"), + CancellationToken.None).ConfigureAwait(false); + + if (_debugStateService.IsRemoteAttach) + { + await _executionService.ExecutePSCommandAsync( + new PSCommand().AddCommand("Exit-PSSession"), + CancellationToken.None).ConfigureAwait(false); + } + } + catch (Exception e) + { + _logger.LogException("Caught exception while popping attached process after debugging", e); + } + } + } + _debugService.IsClientAttached = false; + } + + _logger.LogInformation("Debug adapter is shutting down..."); + +#pragma warning disable CS4014 + // Trigger the clean up of the debugger. No need to wait for it nor cancel it. + Task.Run(_psesDebugServer.OnSessionEnded, CancellationToken.None); +#pragma warning restore CS4014 + + return new DisconnectResponse(); + } + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs new file mode 100644 index 0000000..200dcc3 --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs @@ -0,0 +1,560 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Management.Automation; +using System.Management.Automation.Remoting; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Logging; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.PowerShell; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Context; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using OmniSharp.Extensions.DebugAdapter.Protocol.Events; +using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; +using OmniSharp.Extensions.DebugAdapter.Protocol.Server; +using OmniSharp.Extensions.JsonRpc; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + internal record PsesLaunchRequestArguments : LaunchRequestArguments + { + /// + /// Gets or sets the absolute path to the script to debug. + /// + public string Script { get; set; } + + /// + /// Gets or sets a boolean value that determines whether to automatically stop + /// target after launch. If not specified, target does not stop. + /// + public bool StopOnEntry { get; set; } + + /// + /// Gets or sets optional arguments passed to the debuggee. + /// + public string[] Args { get; set; } + + /// + /// Gets or sets the working directory of the launched debuggee (specified as an absolute path). + /// If omitted the debuggee is launched in its own directory. + /// + public string Cwd { get; set; } + + /// + /// Gets or sets a boolean value that determines whether to create a temporary + /// Extension Terminal for the debug session. Default is false. + /// + public bool CreateTemporaryIntegratedConsole { get; set; } + + /// + /// Gets or sets the absolute path to the runtime executable to be used. + /// Default is the runtime executable on the PATH. + /// + public string RuntimeExecutable { get; set; } + + /// + /// Gets or sets the optional arguments passed to the runtime executable. + /// + public string[] RuntimeArgs { get; set; } + + /// + /// Gets or sets the script execution mode, either "DotSource" or "Call". + /// + public string ExecuteMode { get; set; } + + /// + /// Gets or sets optional environment variables to pass to the debuggee. The string valued + /// properties of the 'environmentVariables' are used as key/value pairs. + /// + public Dictionary Env { get; set; } + + /// + /// Gets or sets the path mappings for the debugging session. This is + /// only used when the current runspace is remote. + /// + public PathMapping[] PathMappings { get; set; } = []; + } + + internal record PsesAttachRequestArguments : AttachRequestArguments + { + public string ComputerName { get; set; } + + public int ProcessId { get; set; } + + public int RunspaceId { get; set; } + + public string RunspaceName { get; set; } + + public string CustomPipeName { get; set; } + + /// + /// Gets or sets the path mappings for the remote debugging session. + /// + public PathMapping[] PathMappings { get; set; } = []; + } + + internal class LaunchAndAttachHandler : ILaunchHandler, IAttachHandler, IOnDebugAdapterServerStarted + { + private static readonly int s_currentPID = System.Diagnostics.Process.GetCurrentProcess().Id; + private static readonly Version s_minVersionForCustomPipeName = new(6, 2); + private readonly ILogger _logger; + private readonly BreakpointService _breakpointService; + private readonly DebugService _debugService; + private readonly IRunspaceContext _runspaceContext; + private readonly IInternalPowerShellExecutionService _executionService; + private readonly DebugStateService _debugStateService; + private readonly DebugEventHandlerService _debugEventHandlerService; + private readonly IDebugAdapterServerFacade _debugAdapterServer; + private readonly RemoteFileManagerService _remoteFileManagerService; + + public LaunchAndAttachHandler( + ILoggerFactory factory, + IDebugAdapterServerFacade debugAdapterServer, + BreakpointService breakpointService, + DebugEventHandlerService debugEventHandlerService, + DebugService debugService, + IRunspaceContext runspaceContext, + IInternalPowerShellExecutionService executionService, + DebugStateService debugStateService, + RemoteFileManagerService remoteFileManagerService) + { + _logger = factory.CreateLogger(); + _debugAdapterServer = debugAdapterServer; + _breakpointService = breakpointService; + _debugEventHandlerService = debugEventHandlerService; + _debugService = debugService; + _runspaceContext = runspaceContext; + _executionService = executionService; + _debugStateService = debugStateService; + _debugStateService.ServerStarted = new TaskCompletionSource(); + _remoteFileManagerService = remoteFileManagerService; + } + + public async Task Handle(PsesLaunchRequestArguments request, CancellationToken cancellationToken) + { + _debugService.PathMappings = request.PathMappings; + try + { + return await HandleImpl(request, cancellationToken).ConfigureAwait(false); + } + catch + { + _debugService.PathMappings = []; + throw; + } + } + + public async Task HandleImpl(PsesLaunchRequestArguments request, CancellationToken cancellationToken) + { + // The debugger has officially started. We use this to later check if we should stop it. + ((PsesInternalHost)_executionService).DebugContext.IsActive = true; + + _debugEventHandlerService.RegisterEventHandlers(); + + // Determine whether or not the working directory should be set in the PowerShellContext. + if (_runspaceContext.CurrentRunspace.RunspaceOrigin == RunspaceOrigin.Local + && !_debugService.IsDebuggerStopped) + { + // Get the working directory that was passed via the debug config + // (either via launch.json or generated via no-config debug). + string workingDir = request.Cwd; + + // Assuming we have a non-empty/null working dir, unescape the path and verify + // the path exists and is a directory. + if (!string.IsNullOrEmpty(workingDir)) + { + try + { + if ((File.GetAttributes(workingDir) & FileAttributes.Directory) != FileAttributes.Directory) + { + workingDir = Path.GetDirectoryName(workingDir); + } + } + catch (Exception ex) + { + workingDir = null; + _logger.LogError( + $"The specified 'cwd' path is invalid: '{request.Cwd}'. Error: {ex.Message}"); + } + } + + // If we have no working dir by this point and we are running in a temp console, + // pick some reasonable default. + if (string.IsNullOrEmpty(workingDir) && request.CreateTemporaryIntegratedConsole) + { + workingDir = Environment.CurrentDirectory; + } + + // At this point, we will either have a working dir that should be set to cwd in + // the PowerShellContext or the user has requested (via an empty/null cwd) that + // the working dir should not be changed. + if (!string.IsNullOrEmpty(workingDir)) + { + PSCommand setDirCommand = new PSCommand().AddCommand("Set-Location").AddParameter("LiteralPath", workingDir); + await _executionService.ExecutePSCommandAsync(setDirCommand, cancellationToken).ConfigureAwait(false); + } + + _logger.LogTrace("Working dir " + (string.IsNullOrEmpty(workingDir) ? "not set." : $"set to '{workingDir}'")); + + if (!request.CreateTemporaryIntegratedConsole) + { + // Start-DebugAttachSession attaches in a new temp console + // so we cannot set this var if already running in that + // console. + PSCommand setVariableCmd = new PSCommand().AddCommand("Set-Variable") + .AddParameter("Name", DebugService.PsesGlobalVariableDebugServerName) + .AddParameter("Value", _debugAdapterServer) + .AddParameter("Description", "DO NOT USE: for internal use only.") + .AddParameter("Scope", "Global") + .AddParameter("Option", "ReadOnly"); + + await _executionService.ExecutePSCommandAsync( + setVariableCmd, + cancellationToken).ConfigureAwait(false); + } + } + + // Prepare arguments to the script - if specified + if (request.Args?.Length > 0) + { + _logger.LogTrace($"Script arguments are: {string.Join(" ", request.Args)}"); + } + + // Store the launch parameters so that they can be used later + _debugStateService.NoDebug = request.NoDebug; + _debugStateService.ScriptToLaunch = GetLaunchScript(request); + _debugStateService.Arguments = request.Args; + _debugStateService.IsUsingTempIntegratedConsole = request.CreateTemporaryIntegratedConsole; + _debugStateService.ExecuteMode = request.ExecuteMode; + + // If no script is being launched, mark this as an interactive + // debugging session + _debugStateService.IsInteractiveDebugSession = string.IsNullOrEmpty(_debugStateService.ScriptToLaunch); + + // Sends the InitializedEvent so that the debugger will continue + // sending configuration requests + _debugStateService.ServerStarted.TrySetResult(true); + + return new LaunchResponse(); + } + + public async Task Handle(PsesAttachRequestArguments request, CancellationToken cancellationToken) + { + // We want to set this as early as possible to avoid an early `StopDebugging` call in + // DoOneRepl. There's too many places to reset this if it fails so we're wrapping the + // entire method in a try here to reset it if failed. + // + // TODO: Ideally DoOneRepl would be paused until the attach is fully initialized, though + // the current architecture makes that challenging. + _debugService.IsDebuggingRemoteRunspace = true; + try + { + _debugService.PathMappings = request.PathMappings; + return await HandleImpl(request, cancellationToken).ConfigureAwait(false); + } + catch + { + _debugService.IsDebuggingRemoteRunspace = false; + _debugService.PathMappings = []; + throw; + } + } + + private async Task HandleImpl(PsesAttachRequestArguments request, CancellationToken cancellationToken) + { + // The debugger has officially started. We use this to later check if we should stop it. + ((PsesInternalHost)_executionService).DebugContext.IsActive = true; + _debugStateService.IsAttachSession = true; + _debugEventHandlerService.RegisterEventHandlers(); + + bool processIdIsSet = request.ProcessId != 0; + bool customPipeNameIsSet = !string.IsNullOrEmpty(request.CustomPipeName) && request.CustomPipeName != "undefined"; + + // If there are no host processes to attach to or the user cancels selection, we get a null for the process id. + // This is not an error, just a request to stop the original "attach to" request. + // Testing against "undefined" is a HACK because I don't know how to make "Cancel" on quick pick loading + // to cancel on the VSCode side without sending an attachRequest with processId set to "undefined". + if (!processIdIsSet && !customPipeNameIsSet) + { + string msg = $"User aborted attach to PowerShell host process: {request.ProcessId}"; + _logger.LogTrace(msg); + throw new RpcErrorException(0, null, msg); + } + + if (!string.IsNullOrEmpty(request.ComputerName)) + { + await AttachToComputer(request.ComputerName, cancellationToken).ConfigureAwait(false); + } + + // Set up a temporary runspace changed event handler so we can ensure + // that the context switch is complete before attempting to debug + // a runspace in the target. + TaskCompletionSource runspaceChanged = new(); + + void RunspaceChangedHandler(object s, RunspaceChangedEventArgs _) + { + ((IInternalPowerShellExecutionService)s).RunspaceChanged -= RunspaceChangedHandler; + runspaceChanged.TrySetResult(true); + } + + if (processIdIsSet) + { + if (request.ProcessId == s_currentPID) + { + throw new RpcErrorException(0, null, $"Attaching to the Extension Terminal is not supported!"); + } + + _executionService.RunspaceChanged += RunspaceChangedHandler; + await AttachToProcess(request.ProcessId, cancellationToken).ConfigureAwait(false); + await runspaceChanged.Task.ConfigureAwait(false); + } + else if (customPipeNameIsSet) + { + _executionService.RunspaceChanged += RunspaceChangedHandler; + await AttachToPipe(request.CustomPipeName, cancellationToken).ConfigureAwait(false); + await runspaceChanged.Task.ConfigureAwait(false); + } + else + { + throw new RpcErrorException(0, null, "Invalid configuration with no process ID nor custom pipe name!"); + } + + // Execute the Debug-Runspace command but don't await it because it + // will block the debug adapter initialization process. The + // InitializedEvent will be sent as soon as the RunspaceChanged + // event gets fired with the attached runspace. + PSCommand debugRunspaceCmd = new PSCommand().AddCommand("Debug-Runspace"); + if (!string.IsNullOrEmpty(request.RunspaceName)) + { + PSCommand psCommand = new PSCommand() + .AddCommand(@"Microsoft.PowerShell.Utility\Get-Runspace") + .AddParameter("Name", request.RunspaceName) + .AddCommand(@"Microsoft.PowerShell.Utility\Select-Object") + .AddParameter("ExpandProperty", "Id"); + + IReadOnlyList results = await _executionService.ExecutePSCommandAsync(psCommand, cancellationToken).ConfigureAwait(false); + + if (results.Count == 0) + { + throw new RpcErrorException(0, null, $"Could not find ID of runspace: {request.RunspaceName}"); + } + + // Translate the runspace name to the runspace ID. + request.RunspaceId = results[0]; + } + + if (request.RunspaceId < 1) + { + throw new RpcErrorException(0, null, "A positive integer must be specified for the RunspaceId!"); + } + + _debugStateService.RunspaceId = request.RunspaceId; + debugRunspaceCmd.AddParameter("Id", request.RunspaceId); + + // Clear any existing breakpoints before proceeding + await _breakpointService.RemoveAllBreakpointsAsync().ConfigureAwait(continueOnCapturedContext: false); + + _debugStateService.WaitingForAttach = true; + Task nonAwaitedTask = _executionService + .ExecutePSCommandAsync(debugRunspaceCmd, CancellationToken.None, PowerShellExecutionOptions.ImmediateInteractive) + .ContinueWith(OnExecutionCompletedAsync, TaskScheduler.Default); + + _debugStateService.ServerStarted.TrySetResult(true); + + return new AttachResponse(); + } + + private async Task AttachToComputer(string computerName, CancellationToken cancellationToken) + { + _debugStateService.IsRemoteAttach = true; + + if (_runspaceContext.CurrentRunspace.RunspaceOrigin != RunspaceOrigin.Local) + { + throw new RpcErrorException(0, null, "Cannot attach to a process in a remote session when already in a remote session!"); + } + + PSCommand psCommand = new PSCommand() + .AddCommand("Enter-PSSession") + .AddParameter("ComputerName", computerName); + + try + { + await _executionService.ExecutePSCommandAsync( + psCommand, + cancellationToken, + PowerShellExecutionOptions.ImmediateInteractive) + .ConfigureAwait(false); + } + catch (Exception e) + { + string msg = $"Could not establish remote session to computer: {computerName}"; + _logger.LogError(e, msg); + throw new RpcErrorException(0, null, msg); + } + } + + private async Task AttachToProcess(int processId, CancellationToken cancellationToken) + { + PSCommand enterPSHostProcessCommand = new PSCommand() + .AddCommand(@"Microsoft.PowerShell.Core\Enter-PSHostProcess") + .AddParameter("Id", processId); + + try + { + await _executionService.ExecutePSCommandAsync( + enterPSHostProcessCommand, + cancellationToken, + PowerShellExecutionOptions.ImmediateInteractive) + .ConfigureAwait(false); + } + catch (Exception e) + { + string msg = $"Could not attach to process with ID: {processId}"; + _logger.LogError(e, msg); + throw new RpcErrorException(0, null, msg); + } + } + + private async Task AttachToPipe(string pipeName, CancellationToken cancellationToken) + { + PowerShellVersionDetails runspaceVersion = _runspaceContext.CurrentRunspace.PowerShellVersionDetails; + + if (runspaceVersion.Version < s_minVersionForCustomPipeName) + { + throw new RpcErrorException(0, null, $"Attaching to a process with CustomPipeName is only available with PowerShell 6.2 and higher. Current session is: {runspaceVersion.Version}"); + } + + PSCommand enterPSHostProcessCommand = new PSCommand() + .AddCommand(@"Microsoft.PowerShell.Core\Enter-PSHostProcess") + .AddParameter("CustomPipeName", pipeName); + + try + { + await _executionService.ExecutePSCommandAsync( + enterPSHostProcessCommand, + cancellationToken, + PowerShellExecutionOptions.ImmediateInteractive) + .ConfigureAwait(false); + } + catch (Exception e) + { + string msg = $"Could not attach to process with CustomPipeName: {pipeName}"; + _logger.LogError(e, msg); + throw new RpcErrorException(0, null, msg); + } + } + + // PSES follows the following flow: + // Receive a Initialize request + // Run Initialize handler and send response back + // Receive a Launch/Attach request + // Run Launch/Attach handler and send response back + // PSES sends the initialized event at the end of the Launch/Attach handler + + // The way that the Omnisharp server works is that this OnStarted handler runs after OnInitialized + // (after the Initialize DAP response is sent to the client) but before the _Initialized_ DAP event + // gets sent to the client. Because of the way PSES handles breakpoints, + // we can't send the Initialized event until _after_ we finish the Launch/Attach handler. + // The flow above depicts this. To achieve this, we wait until _debugStateService.ServerStarted + // is set, which will be done by the Launch/Attach handlers. + public async Task OnStarted(IDebugAdapterServer server, CancellationToken cancellationToken) => + // We wait for this task to be finished before triggering the initialized message to + // be sent to the client. + await _debugStateService.ServerStarted.Task.ConfigureAwait(false); + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "It's a wrapper.")] + private async Task OnExecutionCompletedAsync(Task executeTask) + { + bool isRunspaceClosed = false; + try + { + await executeTask.ConfigureAwait(false); + } + catch (PSRemotingTransportException) + { + isRunspaceClosed = true; + } + catch (Exception e) + { + _logger.LogError( + "Exception occurred while awaiting debug launch task.\n\n" + e.ToString()); + } + + _logger.LogTrace("Execution completed, terminating..."); + + _debugStateService.ExecutionCompleted = true; + + _debugEventHandlerService.UnregisterEventHandlers(); + + _debugService.IsDebuggingRemoteRunspace = false; + _debugService.PathMappings = []; + + if (!isRunspaceClosed && _debugStateService.IsAttachSession) + { + // Pop the sessions + if (_runspaceContext.CurrentRunspace.RunspaceOrigin == RunspaceOrigin.EnteredProcess) + { + try + { + await _executionService.ExecutePSCommandAsync( + new PSCommand().AddCommand("Exit-PSHostProcess"), + CancellationToken.None, + PowerShellExecutionOptions.ImmediateInteractive) + .ConfigureAwait(false); + + if (_debugStateService.IsRemoteAttach) + { + await _executionService.ExecutePSCommandAsync(new PSCommand().AddCommand("Exit-PSSession"), CancellationToken.None).ConfigureAwait(false); + } + } + catch (Exception e) + { + _logger.LogException("Caught exception while popping attached process after debugging", e); + } + } + } + + _debugService.IsClientAttached = false; + _debugAdapterServer.SendNotification(EventNames.Terminated); + } + + private string GetLaunchScript(PsesLaunchRequestArguments request) + { + string scriptToLaunch = request.Script; + if (request.CreateTemporaryIntegratedConsole + && !string.IsNullOrEmpty(scriptToLaunch) + && ScriptFile.IsUntitledPath(scriptToLaunch)) + { + throw new RpcErrorException(0, null, "Running an Untitled file in a temporary Extension Terminal is currently not supported!"); + } + + // If the current session is remote, map the script path to the remote + // machine if necessary + if (scriptToLaunch is not null && _runspaceContext.CurrentRunspace.IsOnRemoteMachine) + { + if (_debugService.TryGetMappedRemotePath(scriptToLaunch, out string remoteMappedPath)) + { + scriptToLaunch = remoteMappedPath; + } + else + { + // If the script is not mapped, we will map it to the remote path + // using the RemoteFileManagerService. + scriptToLaunch = _remoteFileManagerService.GetMappedPath( + scriptToLaunch, + _runspaceContext.CurrentRunspace); + } + } + + return scriptToLaunch; + } + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ScopesHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ScopesHandler.cs new file mode 100644 index 0000000..daa3d8e --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ScopesHandler.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.DebugAdapter; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.DebugAdapter.Protocol.Models; +using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + internal class ScopesHandler : IScopesHandler + { + private readonly DebugService _debugService; + + public ScopesHandler(DebugService debugService) => _debugService = debugService; + + /// + /// Retrieves the variable scopes (containers) for the currently selected stack frame. Variables details are fetched via a separate request. + /// + public Task Handle(ScopesArguments request, CancellationToken cancellationToken) + { + // HACK: The StackTraceHandler injects an artificial label frame as the first frame as a performance optimization, so when scopes are requested by the client, we need to adjust the frame index accordingly to match the underlying PowerShell frame, so when the client clicks on the label (or hit the default breakpoint), they get variables populated from the top of the PowerShell stackframe. If the client dives deeper, we need to reflect that as well (though 90% of debug users don't actually investigate this) + // VSCode Frame 0 (Label) -> PowerShell StackFrame 0 (for convenience) + // VSCode Frame 1 (First Real PS Frame) -> Also PowerShell StackFrame 0 + // VSCode Frame 2 -> PowerShell StackFrame 1 + // VSCode Frame 3 -> PowerShell StackFrame 2 + // etc. + int powershellFrameId = request.FrameId == 0 ? 0 : (int)request.FrameId - 1; + + VariableScope[] variableScopes = _debugService.GetVariableScopes(powershellFrameId); + + return Task.FromResult(new ScopesResponse + { + Scopes = new Container( + variableScopes + .Select(LspDebugUtils.CreateScope) + ) + }); + } + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/SetVariableHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/SetVariableHandler.cs new file mode 100644 index 0000000..75005dd --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/SetVariableHandler.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Management.Automation; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.DebugAdapter; +using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; +using OmniSharp.Extensions.JsonRpc; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + internal class SetVariableHandler : ISetVariableHandler + { + private readonly ILogger _logger; + private readonly DebugService _debugService; + + public SetVariableHandler( + ILoggerFactory loggerFactory, + DebugService debugService) + { + _logger = loggerFactory.CreateLogger(); + _debugService = debugService; + } + + public async Task Handle(SetVariableArguments request, CancellationToken cancellationToken) + { + try + { + string updatedValue = + await _debugService.SetVariableAsync( + (int)request.VariablesReference, + request.Name, + request.Value).ConfigureAwait(false); + + return new SetVariableResponse { Value = updatedValue }; + } + catch (Exception e) when (e is ArgumentTransformationMetadataException or + InvalidPowerShellExpressionException or + SessionStateUnauthorizedAccessException) + { + // Catch common, innocuous errors caused by the user supplying a value that can't be converted or the variable is not settable. + string msg = $"Failed to set variable: {e.Message}"; + _logger.LogTrace(msg); + throw new RpcErrorException(0, null, msg); + } + catch (Exception e) + { + string msg = $"Unexpected error setting variable: {e.Message}"; + _logger.LogError(msg); + throw new RpcErrorException(0, null, msg); + } + } + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/SourceHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/SourceHandler.cs new file mode 100644 index 0000000..399c677 --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/SourceHandler.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Threading; +using System.Threading.Tasks; +using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + internal class SourceHandler : ISourceHandler + { + public Task Handle(SourceArguments request, CancellationToken cancellationToken) => + // TODO: Implement this message. For now, doesn't seem to + // be a problem that it's missing. + Task.FromResult(new SourceResponse()); + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/StackTraceHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/StackTraceHandler.cs new file mode 100644 index 0000000..735d672 --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/StackTraceHandler.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#nullable enable + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using System.Management.Automation; +using Microsoft.PowerShell.EditorServices.Services; +using OmniSharp.Extensions.DebugAdapter.Protocol.Models; +using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; +using Microsoft.PowerShell.EditorServices.Services.DebugAdapter; +using System.Linq; +using OmniSharp.Extensions.JsonRpc; + +namespace Microsoft.PowerShell.EditorServices.Handlers; + +internal class StackTraceHandler(DebugService debugService) : IStackTraceHandler +{ + /// + /// Because we don't know the size of the stacktrace beforehand, we will tell the client that there are more frames available, this is effectively a paging size, as the client should request this many frames after the first one. + /// + private const int INITIAL_PAGE_SIZE = 20; + + public async Task Handle(StackTraceArguments request, CancellationToken cancellationToken) + { + if (!debugService.IsDebuggerStopped) + { + throw new RpcErrorException(0, null!, "Stacktrace was requested while we are not stopped at a breakpoint. This is a violation of the DAP protocol, and is probably a bug."); + } + + // Adapting to int to let us use LINQ, realistically if you have a stacktrace larger than this that the client is requesting, you have bigger problems... + int skip = Convert.ToInt32(request.StartFrame ?? 0); + int take = Convert.ToInt32(request.Levels ?? 0); + + // We generate a label for the breakpoint and can return that immediately if the client is supporting DelayedStackTraceLoading. + InvocationInfo invocationInfo = debugService.CurrentDebuggerStoppedEventArgs?.OriginalEvent?.InvocationInfo + ?? throw new RpcErrorException(0, null!, "InvocationInfo was not available on CurrentDebuggerStoppedEvent args. This is a bug and you should report it."); + + string? scriptNameOverride = null; + if (debugService.TryGetMappedLocalPath(invocationInfo.ScriptName, out string mappedLocalPath)) + { + scriptNameOverride = mappedLocalPath; + } + StackFrame breakpointLabel = CreateBreakpointLabel(invocationInfo, scriptNameOverride: scriptNameOverride); + + if (skip == 0 && take == 1) // This indicates the client is doing an initial fetch, so we want to return quickly to unblock the UI and wait on the remaining stack frames for the subsequent requests. + { + return new StackTraceResponse() + { + StackFrames = new StackFrame[] { breakpointLabel }, + TotalFrames = INITIAL_PAGE_SIZE //Indicate to the client that there are more frames available + }; + } + + // Wait until the stack frames and variables have been fetched. + await debugService.StackFramesAndVariablesFetched.ConfigureAwait(false); + + StackFrameDetails[] stackFrameDetails = await debugService.GetStackFramesAsync(cancellationToken) + .ConfigureAwait(false); + + // Handle a rare race condition where the adapter requests stack frames before they've + // begun building. + if (stackFrameDetails is null) + { + return new StackTraceResponse + { + StackFrames = Array.Empty(), + TotalFrames = 0 + }; + } + + List newStackFrames = new(); + if (skip == 0) + { + newStackFrames.Add(breakpointLabel); + } + + newStackFrames.AddRange( + stackFrameDetails + .Skip(skip != 0 ? skip - 1 : skip) + .Take(take != 0 ? take - 1 : take) + .Select((frame, index) => CreateStackFrame(frame, index + 1)) + ); + + return new StackTraceResponse + { + StackFrames = newStackFrames, + TotalFrames = newStackFrames.Count + }; + } + + public static StackFrame CreateStackFrame(StackFrameDetails stackFrame, long id) + { + SourcePresentationHint sourcePresentationHint = + stackFrame.IsExternalCode ? SourcePresentationHint.Deemphasize : SourcePresentationHint.Normal; + + // When debugging an interactive session, the ScriptPath is which is not a valid source file. + // We need to make sure the user can't open the file associated with this stack frame. + // It will generate a VSCode error in this case. + Source? source = null; + if (!stackFrame.ScriptPath.Contains("")) + { + source = new Source + { + Path = stackFrame.ScriptPath, + PresentationHint = sourcePresentationHint + }; + } + + return new StackFrame + { + Id = id, + Name = (source is not null) ? stackFrame.FunctionName : "Interactive Session", + Line = (source is not null) ? stackFrame.StartLineNumber : 0, + EndLine = stackFrame.EndLineNumber, + Column = (source is not null) ? stackFrame.StartColumnNumber : 0, + EndColumn = stackFrame.EndColumnNumber, + Source = source + }; + } + + public static StackFrame CreateBreakpointLabel(InvocationInfo invocationInfo, int id = 0, string? scriptNameOverride = null) => new() + { + Name = "", + Id = id, + Source = new() + { + Path = scriptNameOverride ?? invocationInfo.ScriptName + }, + Line = invocationInfo.ScriptLineNumber, + Column = invocationInfo.OffsetInLine, + PresentationHint = StackFramePresentationHint.Label + }; +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ThreadsHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ThreadsHandler.cs new file mode 100644 index 0000000..ca6b41e --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ThreadsHandler.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Threading; +using System.Threading.Tasks; +using OmniSharp.Extensions.DebugAdapter.Protocol.Models; +using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; +using Thread = OmniSharp.Extensions.DebugAdapter.Protocol.Models.Thread; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + internal class ThreadsHandler : IThreadsHandler + { + internal static Thread PipelineThread { get; } = + new Thread { Id = 1, Name = "PowerShell Pipeline Thread" }; + + public Task Handle(ThreadsArguments request, CancellationToken cancellationToken) + { + return Task.FromResult(new ThreadsResponse + { + // TODO: OmniSharp supports multithreaded debugging (where + // multiple threads can be debugged at once), but we don't. This + // means we always need to set AllThreadsStopped and + // AllThreadsContinued in our events. But if we one day support + // multithreaded debugging, we'd need a way to associate + // debugged runspaces with .NET threads in a consistent way. + Threads = new Container(PipelineThread) + }); + } + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/VariablesHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/VariablesHandler.cs new file mode 100644 index 0000000..55c5f85 --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/VariablesHandler.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.DebugAdapter; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + internal class VariablesHandler : IVariablesHandler + { + private readonly DebugService _debugService; + + public VariablesHandler(DebugService debugService) => _debugService = debugService; + + public async Task Handle(VariablesArguments request, CancellationToken cancellationToken) + { + VariableDetailsBase[] variables = await _debugService.GetVariables((int)request.VariablesReference, cancellationToken).ConfigureAwait(false); + + VariablesResponse variablesResponse = null; + + try + { + variablesResponse = new VariablesResponse + { + Variables = + variables + .Select(LspDebugUtils.CreateVariable) + .ToArray() + }; + } + #pragma warning disable RCS1075 + catch (Exception) + { + // TODO: This shouldn't be so broad + } + #pragma warning restore RCS1075 + + return variablesResponse; + } + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/PathMapping.cs b/src/PowerShellEditorServices/Services/DebugAdapter/PathMapping.cs new file mode 100644 index 0000000..6bf6a6a --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/PathMapping.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +namespace Microsoft.PowerShell.EditorServices.Services; + +/// +/// Used for attach requests to map a local and remote path together. +/// +internal record PathMapping +{ + /// + /// Gets or sets the local root of this mapping entry. + /// + public string? LocalRoot { get; set; } + + /// + /// Gets or sets the remote root of this mapping entry. + /// + public string? RemoteRoot { get; set; } +} + +#nullable disable diff --git a/src/PowerShellEditorServices/Services/Extension/ChoiceDetails.cs b/src/PowerShellEditorServices/Services/Extension/ChoiceDetails.cs new file mode 100644 index 0000000..82d239b --- /dev/null +++ b/src/PowerShellEditorServices/Services/Extension/ChoiceDetails.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Management.Automation.Host; + +namespace Microsoft.PowerShell.EditorServices.Services.Extension +{ + /// + /// Contains the details about a choice that should be displayed + /// to the user. This class is meant to be serializable to the + /// user's UI. + /// + internal class ChoiceDetails + { + #region Private Fields + + private readonly string hotKeyString; + + #endregion + + #region Properties + + /// + /// Gets the label for the choice. + /// + public string Label { get; set; } + + /// + /// Gets the index of the hot key character for the choice. + /// + public int HotKeyIndex { get; set; } + + /// + /// Gets the hot key character. + /// + public char? HotKeyCharacter { get; set; } + + /// + /// Gets the help string that describes the choice. + /// + public string HelpMessage { get; set; } + + #endregion + + #region Constructors + + /// + /// Creates an instance of the ChoiceDetails class with + /// the provided details. + /// + public ChoiceDetails() + { + // Parameterless constructor for deserialization. + } + + /// + /// Creates an instance of the ChoiceDetails class with + /// the provided details. + /// + /// + /// The label of the choice. An ampersand '&' may be inserted + /// before the character that will used as a hot key for the + /// choice. + /// + /// + /// A help message that describes the purpose of the choice. + /// + public ChoiceDetails(string label, string helpMessage) + { + HelpMessage = helpMessage; + + HotKeyIndex = label.IndexOf('&'); + if (HotKeyIndex >= 0) + { + Label = label.Remove(HotKeyIndex, 1); + + if (HotKeyIndex < Label.Length) + { + hotKeyString = Label[HotKeyIndex].ToString().ToUpper(); + HotKeyCharacter = hotKeyString[0]; + } + } + else + { + Label = label; + } + } + + /// + /// Creates a new instance of the ChoicePromptDetails class + /// based on a ChoiceDescription from the PowerShell layer. + /// + /// + /// A ChoiceDescription on which this instance will be based. + /// + /// A new ChoicePromptDetails instance. + public static ChoiceDetails Create(ChoiceDescription choiceDescription) + { + return new ChoiceDetails( + choiceDescription.Label, + choiceDescription.HelpMessage); + } + + #endregion + + #region Public Methods + + /// + /// Compares an input string to this choice to determine + /// whether the input string is a match. + /// + /// + /// The input string to compare to the choice. + /// + /// True if the input string is a match for the choice. + public bool MatchesInput(string inputString) + { + // Make sure the input string is trimmed of whitespace + inputString = inputString.Trim(); + + // Is it the hotkey? + return + string.Equals(inputString, hotKeyString, StringComparison.CurrentCultureIgnoreCase) || + string.Equals(inputString, Label, StringComparison.CurrentCultureIgnoreCase); + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Services/Extension/EditorOperationsService.cs b/src/PowerShellEditorServices/Services/Extension/EditorOperationsService.cs new file mode 100644 index 0000000..607d387 --- /dev/null +++ b/src/PowerShellEditorServices/Services/Extension/EditorOperationsService.cs @@ -0,0 +1,279 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Extensions; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.PowerShell.EditorServices.Services.Extension +{ + internal class EditorOperationsService : IEditorOperations + { + private readonly PsesInternalHost _psesHost; + private readonly WorkspaceService _workspaceService; + private readonly ILanguageServerFacade _languageServer; + + public EditorOperationsService( + PsesInternalHost psesHost, + WorkspaceService workspaceService, + ILanguageServerFacade languageServer) + { + _psesHost = psesHost; + _workspaceService = workspaceService; + _languageServer = languageServer; + } + + public async Task GetEditorContextAsync() + { + if (!TestHasLanguageServer()) + { + return null; + } + + ClientEditorContext clientContext = + await _languageServer.SendRequest( + "editor/getEditorContext", + new GetEditorContextRequest()) + .Returning(CancellationToken.None) + .ConfigureAwait(false); + + return ConvertClientEditorContext(clientContext); + } + + public async Task InsertTextAsync(string filePath, string text, BufferRange insertRange) + { + if (!TestHasLanguageServer()) + { + return; + } + + await _languageServer.SendRequest("editor/insertText", new InsertTextRequest + { + FilePath = filePath, + InsertText = text, + InsertRange = + new Range + { + Start = new Position + { + Line = insertRange.Start.Line - 1, + Character = insertRange.Start.Column - 1 + }, + End = new Position + { + Line = insertRange.End.Line - 1, + Character = insertRange.End.Column - 1 + } + } + }).Returning(CancellationToken.None).ConfigureAwait(false); + } + + public async Task SetSelectionAsync(BufferRange selectionRange) + { + if (!TestHasLanguageServer()) + { + return; + } + + await _languageServer.SendRequest("editor/setSelection", new SetSelectionRequest + { + SelectionRange = + new Range + { + Start = new Position + { + Line = selectionRange.Start.Line - 1, + Character = selectionRange.Start.Column - 1 + }, + End = new Position + { + Line = selectionRange.End.Line - 1, + Character = selectionRange.End.Column - 1 + } + } + }).Returning(CancellationToken.None).ConfigureAwait(false); + } + + public EditorContext ConvertClientEditorContext( + ClientEditorContext clientContext) + { + ScriptFile scriptFile = _workspaceService.GetFileBuffer( + clientContext.CurrentFilePath, + clientContext.CurrentFileContent); + + return + new EditorContext( + this, + scriptFile, + new BufferPosition( + clientContext.CursorPosition.Line + 1, + clientContext.CursorPosition.Character + 1), + new BufferRange( + clientContext.SelectionRange.Start.Line + 1, + clientContext.SelectionRange.Start.Character + 1, + clientContext.SelectionRange.End.Line + 1, + clientContext.SelectionRange.End.Character + 1), + clientContext.CurrentFileLanguage); + } + + public async Task NewFileAsync() => await NewFileAsync(string.Empty).ConfigureAwait(false); + + public async Task NewFileAsync(string content) + { + if (!TestHasLanguageServer()) + { + return; + } + + await _languageServer.SendRequest("editor/newFile", content) + .Returning(CancellationToken.None) + .ConfigureAwait(false); + } + + public async Task OpenFileAsync(string filePath) + { + if (!TestHasLanguageServer()) + { + return; + } + + await _languageServer.SendRequest("editor/openFile", new OpenFileDetails + { + FilePath = filePath, + Preview = true + }).Returning(CancellationToken.None).ConfigureAwait(false); + } + + public async Task OpenFileAsync(string filePath, bool preview) + { + if (!TestHasLanguageServer()) + { + return; + } + + await _languageServer.SendRequest("editor/openFile", new OpenFileDetails + { + FilePath = filePath, + Preview = preview + }).Returning(CancellationToken.None).ConfigureAwait(false); + } + + public async Task CloseFileAsync(string filePath) + { + if (!TestHasLanguageServer()) + { + return; + } + + await _languageServer.SendRequest("editor/closeFile", filePath) + .Returning(CancellationToken.None) + .ConfigureAwait(false); + } + + public Task SaveFileAsync(string filePath) => SaveFileAsync(filePath, null); + + public async Task SaveFileAsync(string currentPath, string newSavePath) + { + if (!TestHasLanguageServer()) + { + return; + } + + await _languageServer.SendRequest("editor/saveFile", new SaveFileDetails + { + FilePath = currentPath, + NewPath = newSavePath + }).Returning(CancellationToken.None).ConfigureAwait(false); + } + + // NOTE: This name is now outdated since we don't have a way to distinguish one workspace + // from another for the extension API. TODO: Should this be an empty string if we have no + // workspaces? + public string GetWorkspacePath() => _workspaceService.InitialWorkingDirectory; + + public string[] GetWorkspacePaths() => _workspaceService.WorkspacePaths.ToArray(); + + public string GetWorkspaceRelativePath(ScriptFile scriptFile) => _workspaceService.GetRelativePath(scriptFile); + + public async Task ShowInformationMessageAsync(string message) + { + if (!TestHasLanguageServer()) + { + return; + } + + await _languageServer.SendRequest("editor/showInformationMessage", message) + .Returning(CancellationToken.None) + .ConfigureAwait(false); + } + + public async Task ShowErrorMessageAsync(string message) + { + if (!TestHasLanguageServer()) + { + return; + } + + await _languageServer.SendRequest("editor/showErrorMessage", message) + .Returning(CancellationToken.None) + .ConfigureAwait(false); + } + + public async Task ShowWarningMessageAsync(string message) + { + if (!TestHasLanguageServer()) + { + return; + } + + await _languageServer.SendRequest("editor/showWarningMessage", message) + .Returning(CancellationToken.None) + .ConfigureAwait(false); + } + + public async Task SetStatusBarMessageAsync(string message, int? timeout) + { + if (!TestHasLanguageServer()) + { + return; + } + + await _languageServer.SendRequest("editor/setStatusBarMessage", new StatusBarMessageDetails + { + Message = message, + Timeout = timeout + }).Returning(CancellationToken.None).ConfigureAwait(false); + } + + public void ClearTerminal() + { + if (!TestHasLanguageServer(warnUser: false)) + { + return; + } + + _languageServer.SendNotification("editor/clearTerminal"); + } + + private bool TestHasLanguageServer(bool warnUser = true) + { + if (_languageServer != null) + { + return true; + } + + if (warnUser) + { + _psesHost.UI.WriteWarningLine( + "Editor operations are not supported in temporary consoles. Re-run the command in the main Extension Terminal."); + } + + return false; + } + } +} diff --git a/src/PowerShellEditorServices/Services/Extension/ExtensionService.cs b/src/PowerShellEditorServices/Services/Extension/ExtensionService.cs new file mode 100644 index 0000000..028fe0f --- /dev/null +++ b/src/PowerShellEditorServices/Services/Extension/ExtensionService.cs @@ -0,0 +1,256 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Management.Automation; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Extensions; +using Microsoft.PowerShell.EditorServices.Services.PowerShell; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; + +namespace Microsoft.PowerShell.EditorServices.Services.Extension +{ + /// + /// Provides a high-level service which enables PowerShell scripts + /// and modules to extend the behavior of the host editor. + /// + internal sealed class ExtensionService + { + public const string PSEditorVariableName = "psEditor"; + + #region Fields + + private readonly Dictionary editorCommands = new(); + + private readonly ILanguageServerFacade _languageServer; + + private readonly IdempotentLatch _initializedLatch = new(); + + #endregion + + #region Properties + + /// + /// Gets the IEditorOperations implementation used to invoke operations + /// in the host editor. + /// + public IEditorOperations EditorOperations { get; } + + /// + /// Gets the EditorObject which exists in the PowerShell session as the + /// '$psEditor' variable. + /// + public EditorObject EditorObject { get; } + + /// + /// Gets the PowerShellContext in which extension code will be executed. + /// + internal IInternalPowerShellExecutionService ExecutionService { get; } + + #endregion + + #region Constructors + + /// + /// Creates a new instance of the ExtensionService which uses the provided + /// PowerShellContext for loading and executing extension code. + /// + /// The PSES language server instance. + /// Services for dependency injection into the editor object. + /// The interface for operating an editor. + /// PowerShell execution service to run PowerShell execution requests. + internal ExtensionService( + ILanguageServerFacade languageServer, + IServiceProvider serviceProvider, + IEditorOperations editorOperations, + IInternalPowerShellExecutionService executionService) + { + ExecutionService = executionService; + _languageServer = languageServer; + + EditorObject = new EditorObject( + serviceProvider, + this, + editorOperations); + + // Attach to ExtensionService events + CommandAdded += ExtensionService_ExtensionAdded; + CommandUpdated += ExtensionService_ExtensionUpdated; + CommandRemoved += ExtensionService_ExtensionRemoved; + } + + #endregion + + #region Public Methods + + /// + /// Initializes this ExtensionService using the provided IEditorOperations + /// implementation for future interaction with the host editor. + /// + /// A Task that can be awaited for completion. + internal Task InitializeAsync() + { + if (!_initializedLatch.TryEnter()) + { + return Task.CompletedTask; + } + + // Assign the new EditorObject to be the static instance available to binary APIs + EditorObject.SetAsStaticInstance(); + // This is constant so Remove-Variable cannot remove it. + PSVariable psEditor = new(PSEditorVariableName, EditorObject, ScopedItemOptions.Constant); + + // NOTE: This is a special task run on startup! Register the editor object in the + // runspace. It has priority next so it goes before LoadProfiles. + return ExecutionService.ExecuteDelegateAsync( + $"Create ${PSEditorVariableName} object", + new ExecutionOptions { Priority = ExecutionPriority.Next }, + (pwsh, _) => pwsh.Runspace.SessionStateProxy.PSVariable.Set(psEditor), + CancellationToken.None); + } + + /// + /// Invokes the specified editor command against the provided EditorContext. + /// + /// The unique name of the command to be invoked. + /// The context in which the command is being invoked. + /// The token used to cancel this. + /// A Task that can be awaited for completion. + /// The command being invoked was not registered. + public Task InvokeCommandAsync(string commandName, EditorContext editorContext, CancellationToken cancellationToken) + { + if (editorCommands.TryGetValue(commandName, out EditorCommand editorCommand)) + { + PSCommand executeCommand = new PSCommand() + .AddCommand("Invoke-Command") + .AddParameter("ScriptBlock", editorCommand.ScriptBlock) + .AddParameter("ArgumentList", new object[] { editorContext }); + + // This API is used for editor command execution so it requires the foreground. + return ExecutionService.ExecutePSCommandAsync( + executeCommand, + cancellationToken, + new PowerShellExecutionOptions + { + RequiresForeground = true, + WriteOutputToHost = !editorCommand.SuppressOutput, + AddToHistory = !editorCommand.SuppressOutput, + ThrowOnError = false, + }); + } + + throw new KeyNotFoundException($"Editor command not found: '{commandName}'"); + } + + /// + /// Registers a new EditorCommand with the ExtensionService and + /// causes its details to be sent to the host editor. + /// + /// The details about the editor command to be registered. + /// True if the command is newly registered, false if the command already exists. + public bool RegisterCommand(EditorCommand editorCommand) + { + Validate.IsNotNull(nameof(editorCommand), editorCommand); + + bool commandExists = editorCommands.ContainsKey(editorCommand.Name); + + // Add or replace the editor command + editorCommands[editorCommand.Name] = editorCommand; + + if (!commandExists) + { + OnCommandAdded(editorCommand); + } + else + { + OnCommandUpdated(editorCommand); + } + + return !commandExists; + } + + /// + /// Unregisters an existing EditorCommand based on its registered name. + /// + /// The name of the command to be unregistered. + /// The command being unregistered was not registered. + public void UnregisterCommand(string commandName) + { + if (editorCommands.TryGetValue(commandName, out EditorCommand existingCommand)) + { + editorCommands.Remove(commandName); + OnCommandRemoved(existingCommand); + } + else + { + throw new KeyNotFoundException($"Command '{commandName}' is not registered"); + } + } + + /// + /// Returns all registered EditorCommands. + /// + /// An Array of all registered EditorCommands. + public EditorCommand[] GetCommands() + { + EditorCommand[] commands = new EditorCommand[editorCommands.Count]; + editorCommands.Values.CopyTo(commands, 0); + return commands; + } + + #endregion + + #region Events + + /// + /// Raised when a new editor command is added. + /// + public event EventHandler CommandAdded; + + private void OnCommandAdded(EditorCommand command) => CommandAdded?.Invoke(this, command); + + /// + /// Raised when an existing editor command is updated. + /// + public event EventHandler CommandUpdated; + + private void OnCommandUpdated(EditorCommand command) => CommandUpdated?.Invoke(this, command); + + /// + /// Raised when an existing editor command is removed. + /// + public event EventHandler CommandRemoved; + + private void OnCommandRemoved(EditorCommand command) => CommandRemoved?.Invoke(this, command); + + private void ExtensionService_ExtensionAdded(object sender, EditorCommand e) + { + _languageServer?.SendNotification( + "powerShell/extensionCommandAdded", + new ExtensionCommandAddedNotification + { Name = e.Name, DisplayName = e.DisplayName }); + } + + private void ExtensionService_ExtensionUpdated(object sender, EditorCommand e) + { + _languageServer?.SendNotification( + "powerShell/extensionCommandUpdated", + new ExtensionCommandUpdatedNotification + { Name = e.Name, }); + } + + private void ExtensionService_ExtensionRemoved(object sender, EditorCommand e) + { + _languageServer?.SendNotification( + "powerShell/extensionCommandRemoved", + new ExtensionCommandRemovedNotification + { Name = e.Name, }); + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Services/Extension/Handlers/IInvokeExtensionCommandHandler.cs b/src/PowerShellEditorServices/Services/Extension/Handlers/IInvokeExtensionCommandHandler.cs new file mode 100644 index 0000000..fb0ac50 --- /dev/null +++ b/src/PowerShellEditorServices/Services/Extension/Handlers/IInvokeExtensionCommandHandler.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using MediatR; +using OmniSharp.Extensions.JsonRpc; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Services.Extension +{ + [Serial, Method("powerShell/invokeExtensionCommand")] + internal interface IInvokeExtensionCommandHandler : IJsonRpcNotificationHandler { } + + internal class InvokeExtensionCommandParams : IRequest + { + public string Name { get; set; } + + public ClientEditorContext Context { get; set; } + } + + internal class ClientEditorContext + { + public string CurrentFileContent { get; set; } + + public string CurrentFileLanguage { get; set; } + + public string CurrentFilePath { get; set; } + + public Position CursorPosition { get; set; } + + public Range SelectionRange { get; set; } + } +} diff --git a/src/PowerShellEditorServices/Services/Extension/Handlers/InvokeExtensionCommandHandler.cs b/src/PowerShellEditorServices/Services/Extension/Handlers/InvokeExtensionCommandHandler.cs new file mode 100644 index 0000000..7055726 --- /dev/null +++ b/src/PowerShellEditorServices/Services/Extension/Handlers/InvokeExtensionCommandHandler.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Microsoft.PowerShell.EditorServices.Extensions; + +namespace Microsoft.PowerShell.EditorServices.Services.Extension +{ + internal class InvokeExtensionCommandHandler : IInvokeExtensionCommandHandler + { + private readonly ExtensionService _extensionService; + private readonly EditorOperationsService _editorOperationsService; + + public InvokeExtensionCommandHandler( + ExtensionService extensionService, + EditorOperationsService editorOperationsService) + { + _extensionService = extensionService; + _editorOperationsService = editorOperationsService; + } + + public async Task Handle(InvokeExtensionCommandParams request, CancellationToken cancellationToken) + { + EditorContext editorContext = _editorOperationsService.ConvertClientEditorContext(request.Context); + await _extensionService.InvokeCommandAsync(request.Name, editorContext, cancellationToken).ConfigureAwait(false); + return Unit.Value; + } + } +} diff --git a/src/PowerShellEditorServices/Services/Extension/PromptEvents.cs b/src/PowerShellEditorServices/Services/Extension/PromptEvents.cs new file mode 100644 index 0000000..ebe595e --- /dev/null +++ b/src/PowerShellEditorServices/Services/Extension/PromptEvents.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.PowerShell.EditorServices.Services.Extension +{ + internal class ShowChoicePromptRequest + { + public bool IsMultiChoice { get; set; } + + public string Caption { get; set; } + + public string Message { get; set; } + + public ChoiceDetails[] Choices { get; set; } + + public int[] DefaultChoices { get; set; } + } + + internal class ShowChoicePromptResponse + { + public bool PromptCancelled { get; set; } + + public string ResponseText { get; set; } + } + + internal class ShowInputPromptRequest + { + /// + /// Gets or sets the name of the field. + /// + public string Name { get; set; } + + /// + /// Gets or sets the descriptive label for the field. + /// + public string Label { get; set; } + } + + internal class ShowInputPromptResponse + { + public bool PromptCancelled { get; set; } + + public string ResponseText { get; set; } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Console/IReadLine.cs b/src/PowerShellEditorServices/Services/PowerShell/Console/IReadLine.cs new file mode 100644 index 0000000..83828aa --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Console/IReadLine.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Threading; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Console +{ + // TODO: Do we really need a whole interface for this? + internal interface IReadLine + { + string ReadLine(CancellationToken cancellationToken); + + void AddToHistory(string historyEntry); + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Console/LegacyReadLine.cs b/src/PowerShellEditorServices/Services/PowerShell/Console/LegacyReadLine.cs new file mode 100644 index 0000000..28d29ba --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Console/LegacyReadLine.cs @@ -0,0 +1,532 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Console +{ + using System; + + internal class LegacyReadLine : TerminalReadLine + { + private readonly PsesInternalHost _psesHost; + + private readonly Task[] _readKeyTasks; + + private readonly Func _readKeyFunc; + + private readonly Action _onIdleAction; + + public LegacyReadLine( + PsesInternalHost psesHost, + Func readKeyFunc, + Action onIdleAction) + { + _psesHost = psesHost; + _readKeyTasks = new Task[2]; + _readKeyFunc = readKeyFunc; + _onIdleAction = onIdleAction; + } + + public override string ReadLine(CancellationToken cancellationToken) + { + string inputBeforeCompletion = null; + string inputAfterCompletion = null; + CommandCompletion currentCompletion = null; + + int historyIndex = -1; + IReadOnlyList currentHistory = null; + + StringBuilder inputLine = new(); + + int initialCursorCol = Console.CursorLeft; + int initialCursorRow = Console.CursorTop; + + int currentCursorIndex = 0; + + Console.TreatControlCAsInput = true; + + try + { + while (!cancellationToken.IsCancellationRequested) + { + ConsoleKeyInfo keyInfo = ReadKey(cancellationToken); + + // Do final position calculation after the key has been pressed + // because the window could have been resized before then + int promptStartCol = initialCursorCol; + int promptStartRow = initialCursorRow; + int consoleWidth = Console.WindowWidth; + + switch (keyInfo.Key) + { + case ConsoleKey.Tab: + if (currentCompletion is null) + { + inputBeforeCompletion = inputLine.ToString(); + inputAfterCompletion = null; + + // TODO: This logic should be moved to AstOperations or similar! + + if (_psesHost.DebugContext.IsStopped) + { + PSCommand command = new PSCommand() + .AddCommand("TabExpansion2") + .AddParameter("InputScript", inputBeforeCompletion) + .AddParameter("CursorColumn", currentCursorIndex) + .AddParameter("Options", null); + + currentCompletion = _psesHost.InvokePSCommand(command, executionOptions: null, cancellationToken).FirstOrDefault(); + } + else + { + currentCompletion = _psesHost.InvokePSDelegate( + "Legacy readline inline command completion", + executionOptions: null, + (pwsh, _) => CommandCompletion.CompleteInput(inputAfterCompletion, currentCursorIndex, options: null, pwsh), + cancellationToken); + + if (currentCompletion.CompletionMatches.Count > 0) + { + int replacementEndIndex = + currentCompletion.ReplacementIndex + + currentCompletion.ReplacementLength; + + inputAfterCompletion = + inputLine.ToString( + replacementEndIndex, + inputLine.Length - replacementEndIndex); + } + else + { + currentCompletion = null; + } + } + } + + CompletionResult completion = + currentCompletion?.GetNextResult( + !keyInfo.Modifiers.HasFlag(ConsoleModifiers.Shift)); + + if (completion is not null) + { + currentCursorIndex = + InsertInput( + inputLine, + promptStartCol, + promptStartRow, + completion.CompletionText + inputAfterCompletion, + currentCursorIndex, + insertIndex: currentCompletion.ReplacementIndex, + replaceLength: inputLine.Length - currentCompletion.ReplacementIndex, + finalCursorIndex: currentCompletion.ReplacementIndex + completion.CompletionText.Length); + } + + continue; + + case ConsoleKey.LeftArrow: + currentCompletion = null; + + if (currentCursorIndex > 0) + { + currentCursorIndex = + MoveCursorToIndex( + promptStartCol, + promptStartRow, + consoleWidth, + currentCursorIndex - 1); + } + + continue; + + case ConsoleKey.Home: + currentCompletion = null; + + currentCursorIndex = + MoveCursorToIndex( + promptStartCol, + promptStartRow, + consoleWidth, + 0); + + continue; + + case ConsoleKey.RightArrow: + currentCompletion = null; + + if (currentCursorIndex < inputLine.Length) + { + currentCursorIndex = + MoveCursorToIndex( + promptStartCol, + promptStartRow, + consoleWidth, + currentCursorIndex + 1); + } + + continue; + + case ConsoleKey.End: + currentCompletion = null; + + currentCursorIndex = + MoveCursorToIndex( + promptStartCol, + promptStartRow, + consoleWidth, + inputLine.Length); + + continue; + + case ConsoleKey.UpArrow: + currentCompletion = null; + + // TODO: Ctrl+Up should allow navigation in multi-line input + if (currentHistory is null) + { + historyIndex = -1; + + PSCommand command = new PSCommand() + .AddCommand("Get-History"); + + currentHistory = _psesHost.InvokePSCommand(command, executionOptions: null, cancellationToken); + + if (currentHistory is not null) + { + historyIndex = currentHistory.Count; + } + } + + if (currentHistory?.Count > 0 && historyIndex > 0) + { + historyIndex--; + + currentCursorIndex = + InsertInput( + inputLine, + promptStartCol, + promptStartRow, + (string)currentHistory[historyIndex].Properties["CommandLine"].Value, + currentCursorIndex, + insertIndex: 0, + replaceLength: inputLine.Length); + } + + continue; + + case ConsoleKey.DownArrow: + currentCompletion = null; + + // The down arrow shouldn't cause history to be loaded, + // it's only for navigating an active history array + if (historyIndex > -1 && historyIndex < currentHistory.Count && + currentHistory?.Count > 0) + { + historyIndex++; + + if (historyIndex < currentHistory.Count) + { + currentCursorIndex = + InsertInput( + inputLine, + promptStartCol, + promptStartRow, + (string)currentHistory[historyIndex].Properties["CommandLine"].Value, + currentCursorIndex, + insertIndex: 0, + replaceLength: inputLine.Length); + } + else if (historyIndex == currentHistory.Count) + { + currentCursorIndex = + InsertInput( + inputLine, + promptStartCol, + promptStartRow, + string.Empty, + currentCursorIndex, + insertIndex: 0, + replaceLength: inputLine.Length); + } + } + + continue; + + case ConsoleKey.Escape: + currentCompletion = null; + historyIndex = currentHistory != null ? currentHistory.Count : -1; + + currentCursorIndex = + InsertInput( + inputLine, + promptStartCol, + promptStartRow, + string.Empty, + currentCursorIndex, + insertIndex: 0, + replaceLength: inputLine.Length); + + continue; + + case ConsoleKey.Backspace: + currentCompletion = null; + + if (currentCursorIndex > 0) + { + currentCursorIndex = + InsertInput( + inputLine, + promptStartCol, + promptStartRow, + string.Empty, + currentCursorIndex, + insertIndex: currentCursorIndex - 1, + replaceLength: 1, + finalCursorIndex: currentCursorIndex - 1); + } + + continue; + + case ConsoleKey.Delete: + currentCompletion = null; + + if (currentCursorIndex < inputLine.Length) + { + currentCursorIndex = + InsertInput( + inputLine, + promptStartCol, + promptStartRow, + string.Empty, + currentCursorIndex, + replaceLength: 1, + finalCursorIndex: currentCursorIndex); + } + + continue; + + case ConsoleKey.Enter: + string completedInput = inputLine.ToString(); + currentCompletion = null; + currentHistory = null; + + // TODO: Add line continuation support: + // - When shift+enter is pressed, or + // - When the parse indicates incomplete input + + //if ((keyInfo.Modifiers & ConsoleModifiers.Shift) == ConsoleModifiers.Shift) + //{ + // // TODO: Start a new line! + // continue; + //} + //Parser.ParseInput( + // completedInput, + // out Token[] tokens, + // out ParseError[] parseErrors); + //if (parseErrors.Any(e => e.IncompleteInput)) + //{ + // // TODO: Start a new line! + // continue; + //} + + return completedInput; + + default: + if (keyInfo.IsCtrlC()) + { + throw new PipelineStoppedException(); + } + + // Normal character input + if (keyInfo.KeyChar != 0 && !char.IsControl(keyInfo.KeyChar)) + { + currentCompletion = null; + + currentCursorIndex = + InsertInput( + inputLine, + promptStartCol, + promptStartRow, + keyInfo.KeyChar.ToString(), // TODO: Determine whether this should take culture into account + currentCursorIndex, + finalCursorIndex: currentCursorIndex + 1); + } + + continue; + } + } + } + catch (OperationCanceledException) + { + // We've broken out of the loop + } + finally + { + Console.TreatControlCAsInput = false; + } + + // If we break out of the loop without returning (because of the Enter key) + // then the readline has been aborted in some way and we should return nothing + return null; + } + + protected override ConsoleKeyInfo ReadKey(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + return _onIdleAction is null + ? InvokeReadKeyFunc() + : ReadKeyWithIdleSupport(cancellationToken); + } + finally + { + cancellationToken.ThrowIfCancellationRequested(); + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "This is a legacy implementation.")] + private ConsoleKeyInfo ReadKeyWithIdleSupport(CancellationToken cancellationToken) + { + // We run the readkey function on another thread so we can run an idle handler + Task readKeyTask = Task.Run(InvokeReadKeyFunc); + + _readKeyTasks[0] = readKeyTask; + _readKeyTasks[1] = Task.Delay(millisecondsDelay: 300, cancellationToken); + + while (true) + { + switch (Task.WaitAny(_readKeyTasks, cancellationToken)) + { + // ReadKey returned + case 0: + return readKeyTask.Result; + + // The idle timed out + case 1: + _onIdleAction(cancellationToken); + _readKeyTasks[1] = Task.Delay(millisecondsDelay: 300, cancellationToken); + continue; + } + } + } + + private ConsoleKeyInfo InvokeReadKeyFunc() => _readKeyFunc(/* intercept */ false); + + private static int InsertInput( + StringBuilder inputLine, + int promptStartCol, + int promptStartRow, + string insertedInput, + int cursorIndex, + int insertIndex = -1, + int replaceLength = 0, + int finalCursorIndex = -1) + { + int consoleWidth = Console.WindowWidth; + int previousInputLength = inputLine.Length; + + if (insertIndex == -1) + { + insertIndex = cursorIndex; + } + + // Move the cursor to the new insertion point + MoveCursorToIndex( + promptStartCol, + promptStartRow, + consoleWidth, + insertIndex); + + // Edit the input string based on the insertion + if (insertIndex < inputLine.Length) + { + if (replaceLength > 0) + { + inputLine.Remove(insertIndex, replaceLength); + } + + inputLine.Insert(insertIndex, insertedInput); + } + else + { + inputLine.Append(insertedInput); + } + + // Re-render affected section + Console.Write( + inputLine.ToString( + insertIndex, + inputLine.Length - insertIndex)); + + if (inputLine.Length < previousInputLength) + { + Console.Write( + new string( + ' ', + previousInputLength - inputLine.Length)); + } + + // Automatically set the final cursor position to the end + // of the new input string. This is needed if the previous + // input string is longer than the new one and needed to have + // its old contents overwritten. This will position the cursor + // back at the end of the new text + if (finalCursorIndex == -1 && inputLine.Length < previousInputLength) + { + finalCursorIndex = inputLine.Length; + } + + if (finalCursorIndex > -1) + { + // Move the cursor to the final position + return MoveCursorToIndex( + promptStartCol, + promptStartRow, + consoleWidth, + finalCursorIndex); + } + + return inputLine.Length; + } + + private static int MoveCursorToIndex( + int promptStartCol, + int promptStartRow, + int consoleWidth, + int newCursorIndex) + { + CalculateCursorFromIndex( + promptStartCol, + promptStartRow, + consoleWidth, + newCursorIndex, + out int newCursorCol, + out int newCursorRow); + + Console.SetCursorPosition(newCursorCol, newCursorRow); + + return newCursorIndex; + } + + private static void CalculateCursorFromIndex( + int promptStartCol, + int promptStartRow, + int consoleWidth, + int inputIndex, + out int cursorCol, + out int cursorRow) + { + cursorCol = promptStartCol + inputIndex; + cursorRow = promptStartRow + (cursorCol / consoleWidth); + cursorCol %= consoleWidth; + } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Console/PSReadLineProxy.cs b/src/PowerShellEditorServices/Services/PowerShell/Console/PSReadLineProxy.cs new file mode 100644 index 0000000..a166fcf --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Console/PSReadLineProxy.cs @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Linq; +using System.Management.Automation; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; +using SMA = System.Management.Automation; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Console +{ + using System.Management.Automation.Runspaces; + + internal class PSReadLineProxy + { + private const string FieldMemberType = "field"; + + private const string MethodMemberType = "method"; + + private const string AddToHistoryMethodName = "AddToHistory"; + + private const string SetKeyHandlerMethodName = "SetKeyHandler"; + + private const string ReadLineMethodName = "ReadLine"; + + private const string ReadKeyOverrideFieldName = "_readKeyOverride"; + + private const string HandleIdleOverrideName = "_handleIdleOverride"; + + private const string VirtualTerminalTypeName = "Microsoft.PowerShell.Internal.VirtualTerminal"; + + private static readonly Type[] s_setKeyHandlerTypes = + { + typeof(string[]), + typeof(Action), + typeof(string), + typeof(string) + }; + + private static readonly Type[] s_addToHistoryTypes = { typeof(string) }; + + public static PSReadLineProxy LoadAndCreate( + ILoggerFactory loggerFactory, + string bundledModulePath, + SMA.PowerShell pwsh) + { + pwsh.ImportModule(Path.Combine(bundledModulePath, "PSReadLine")); + Type psConsoleReadLineType = pwsh.AddScript("return [Microsoft.PowerShell.PSConsoleReadLine]") + .InvokeAndClear().FirstOrDefault(); + + RuntimeHelpers.RunClassConstructor(psConsoleReadLineType.TypeHandle); + + return new PSReadLineProxy(loggerFactory, psConsoleReadLineType); + } + + private readonly FieldInfo _readKeyOverrideField; + + private readonly FieldInfo _handleIdleOverrideField; + + private readonly ILogger _logger; + + public PSReadLineProxy( + ILoggerFactory loggerFactory, + Type psConsoleReadLine) + { + _logger = loggerFactory.CreateLogger(); + + ReadLine = (Func)psConsoleReadLine.GetMethod( + ReadLineMethodName, + new[] { typeof(Runspace), typeof(EngineIntrinsics), typeof(CancellationToken), typeof(bool?) }) + ?.CreateDelegate(typeof(Func)); + + AddToHistory = (Action)psConsoleReadLine.GetMethod( + AddToHistoryMethodName, + s_addToHistoryTypes) + ?.CreateDelegate(typeof(Action)); + + SetKeyHandler = (Action, string, string>)psConsoleReadLine.GetMethod( + SetKeyHandlerMethodName, + s_setKeyHandlerTypes) + ?.CreateDelegate(typeof(Action, string, string>)); + + _handleIdleOverrideField = psConsoleReadLine.GetField(HandleIdleOverrideName, BindingFlags.Static | BindingFlags.NonPublic); + + _readKeyOverrideField = psConsoleReadLine.GetTypeInfo().Assembly + .GetType(VirtualTerminalTypeName) + ?.GetField(ReadKeyOverrideFieldName, BindingFlags.Static | BindingFlags.NonPublic); + + if (_readKeyOverrideField is null) + { + throw NewInvalidPSReadLineVersionException( + FieldMemberType, + ReadKeyOverrideFieldName, + _logger); + } + + if (_handleIdleOverrideField is null) + { + throw NewInvalidPSReadLineVersionException( + FieldMemberType, + HandleIdleOverrideName, + _logger); + } + + if (ReadLine is null) + { + throw NewInvalidPSReadLineVersionException( + MethodMemberType, + ReadLineMethodName, + _logger); + } + + if (SetKeyHandler is null) + { + throw NewInvalidPSReadLineVersionException( + MethodMemberType, + SetKeyHandlerMethodName, + _logger); + } + + if (AddToHistory is null) + { + throw NewInvalidPSReadLineVersionException( + MethodMemberType, + AddToHistoryMethodName, + _logger); + } + } + + internal Action AddToHistory { get; } + + internal Action, string, string> SetKeyHandler { get; } + + internal Action ForcePSEventHandling { get; } + + internal Func ReadLine { get; } + + internal void OverrideReadKey(Func readKeyFunc) => _readKeyOverrideField.SetValue(null, readKeyFunc); + + internal void OverrideIdleHandler(Action idleAction) => _handleIdleOverrideField.SetValue(null, idleAction); + + private static InvalidOperationException NewInvalidPSReadLineVersionException( + string memberType, + string memberName, + ILogger logger) + { + logger.LogError( + $"The loaded version of PSReadLine is not supported. The {memberType} \"{memberName}\" was not found."); + + return new InvalidOperationException(); + } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Console/PsrlReadLine.cs b/src/PowerShellEditorServices/Services/PowerShell/Console/PsrlReadLine.cs new file mode 100644 index 0000000..c9d6434 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Console/PsrlReadLine.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; +using System.Management.Automation; +using System.Threading; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Console +{ + using System; + + internal class PsrlReadLine : TerminalReadLine + { + private readonly PSReadLineProxy _psrlProxy; + + private readonly PsesInternalHost _psesHost; + + private readonly EngineIntrinsics _engineIntrinsics; + + public PsrlReadLine( + PSReadLineProxy psrlProxy, + PsesInternalHost psesHost, + EngineIntrinsics engineIntrinsics, + Func readKeyFunc, + Action onIdleAction) + { + _psrlProxy = psrlProxy; + _psesHost = psesHost; + _engineIntrinsics = engineIntrinsics; + _psrlProxy.OverrideReadKey(readKeyFunc); + _psrlProxy.OverrideIdleHandler(onIdleAction); + } + + public override string ReadLine(CancellationToken cancellationToken) => _psesHost.InvokeDelegate( + representation: "ReadLine", + new ExecutionOptions { RequiresForeground = true }, + InvokePSReadLine, + cancellationToken); + + public override void AddToHistory(string historyEntry) => _psrlProxy.AddToHistory(historyEntry); + + protected override ConsoleKeyInfo ReadKey(CancellationToken cancellationToken) => _psesHost.ReadKey(intercept: true, cancellationToken); + + private string InvokePSReadLine(CancellationToken cancellationToken) + { + EngineIntrinsics engineIntrinsics = _psesHost.IsRunspacePushed ? null : _engineIntrinsics; + return _psrlProxy.ReadLine(_psesHost.Runspace, engineIntrinsics, cancellationToken, /* lastExecutionStatus */ null); + } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Console/README.md b/src/PowerShellEditorServices/Services/PowerShell/Console/README.md new file mode 100644 index 0000000..8aa01e1 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Console/README.md @@ -0,0 +1,166 @@ +# How We Handle `Console.ReadKey()` Being Uncancellable + +The C# API `Console.ReadKey()` is synchronous and uncancellable. This is problematic in a +asynchronous application that needs to cancel it. + +## The Problem + +We host a multi-threaded application. One thread is always servicing the REPL, which runs +PSReadLine, which loops on a `ReadKey` call. Other threads handle various PowerShell +requests via LSP, some of which necessitate interrupting PSReadLine and taking over the +foreground (such as: start debugging, run code). While we have a smart task queue which +correctly handles the cancellation of tasks, including the delegate calling PSReadLine, we +cannot cancel `ReadKey` because as a synchronous .NET API, it is uncancellable. + +So, no matter what, _at least one key must be consumed_ before PSReadLine's call to +`ReadKey` is actually "canceled" (in this case, returned). This leads to bugs like [#3881] +since the executed code is now using PowerShell's own prompting to get input from the +user. Until our consumer (the `ReadKey` call) returns, the code behind the scenes of +`$Host.UI.PromptForChoice()` won't get input. The actual fix would be for our `ReadKey` +call to return without having received input after we canceled it (but we can't, so it +doesn't). + +[#3881]: https://github.com/PowerShell/vscode-powershell/issues/3881 + +A non-exhaustive list of known issues likely caused by this: + +- [#3881](https://github.com/PowerShell/vscode-powershell/issues/3881) +- [#3756](https://github.com/PowerShell/vscode-powershell/issues/3756) +- [#2741](https://github.com/PowerShell/vscode-powershell/issues/2741) +- [#3876](https://github.com/PowerShell/vscode-powershell/issues/3876) +- [#2832](https://github.com/PowerShell/vscode-powershell/issues/2832) +- [#2169](https://github.com/PowerShell/vscode-powershell/issues/2169) +- [#1753](https://github.com/PowerShell/vscode-powershell/issues/1753) +- [#3225](https://github.com/PowerShell/vscode-powershell/issues/3225) + +For what it's worth, Tyler and have had conversations with the .NET team about making +`ReadKey` cancelable. [#801] is an ancient GitHub issue with .NET, and we have had +internal conversations. + +[#801]: https://github.com/dotnet/runtime/issues/801 + +## Previous Workaround(s) + +A previous workaround for this was to reinvent PowerShell's prompt handlers so they use +our `ReadKey` call, see [#1583]. This is awful! It duplicates a lot of code when +everything works so almost right without any of this. Except a key needs to be entered to +"cancel" `ReadKey`. + +[#1583]: https://github.com/PowerShell/PowerShellEditorServices/issues/1583 + +Now when I say "our `ReadKey`" call that's because we _already_ had some other workaround +in place for this. Once upon a time (in the days of PowerShell 6 with older .NET Core +versions), on macOS and Linux, if a thread was sitting in a `Console.ReadKey` loop, other +`System.Console` APIs could not safely be called. For instance, `Console.CursorTop` is +readily queried other events (such as the window resizing) and would deadlock, see +[#1748]. So on these OS's we actually didn't use `Console.ReadKey` at all, but implemented +a fake "`ReadKey`" that sits in a loop polling `Console.KeyAvailable`, see the +[`ConsolePal.Unix`] implementation. + +[#1748]: https://github.com/PowerShell/PowerShellEditorServices/pull/1748#issuecomment-1079055612 +[`ConsolePal.Unix`]: https://github.com/dotnet/runtime/blob/3ff8d262e504d03977edeb67da2b83d01c9ed2db/src/libraries/System.Console/src/System/ConsolePal.Unix.cs#L121-L138 + +This workaround led to other terrible behaviors, like the "typewriter" effect when pasting +into our terminal, see [#3756]. Note that this issue only occurred on macOS and Linux, +because on Windows we were still calling `Console.ReadKey`, but with a buffer to make it +sort of cancellable, see [`ConsolePal.Windows`]. This is also the reason that [#3881] is +Windows-specific. This makes pasting no macOS and Linux almost unusable, it takes minutes +if you're pasting in a script to run. + +[#3756]: https://github.com/PowerShell/vscode-powershell/issues/3756 +[`ConsolePal.Windows`]: https://github.com/dotnet/runtime/blob/3ff8d262e504d03977edeb67da2b83d01c9ed2db/src/libraries/System.Console/src/System/ConsolePal.Windows.cs#L307-L400 + +Another issue that is probably caused by these alternative "`ReadKey`" implementations is +[#2741] where pasting totally fails. It seems like this has appeared before, and was +previously fixed in [#2291]. + +[#2741]: https://github.com/PowerShell/vscode-powershell/issues/2741 +[#2291]: https://github.com/PowerShell/vscode-powershell/issues/2291 + +As an aside, but important note: these custom "`ReadKey`" implementations are the reason +we have a private [contract] with PSReadLine, where we literally override the `ReadKey` +method in that library when we load it, because PSReadLine is what is actually looping +over `ReadKey`. + +[contract]: https://github.com/PowerShell/PSReadLine/blob/dc38b451bee4bdf07f7200026be02516807faa09/PSReadLine/ConsoleLib.cs#L12-L17 + +## Explored But Inviable Workarounds + +Back to [#3881] ("PowerShell prompts ignore the first input character"): one workaround +could be to use the macOS/Linux `KeyAvailable`-based `ReadKey` alternative. But this +should be avoided for several reasons (typewriter effect, battery drain, kind of just +plain awful). It could be better if we improved the polling logic to slow way down after +no input and speed up to instantaneous with input (like when pasting), but it would still +be just a workaround. + +An option I explored was to send a known ASCII control character every time the integrated +console _received focus_ and have our `ReadKey` implementation ignore it (but return, +since it received the key its stuck waiting for). This seemed like an ingenious solution, +but unfortunately Visual Studio Code does not have an API for "on terminal focus" and it +won't be getting one any time soon (I explored all the options in the [window] API, and +confirmed with Tyler Leonhardt and Johannes Rieken, two VS Code developers). Theoretically +we could have instead sent the character when our `RunCode` command is called but that +only solves the problem some of the time. However, through this experiment I discovered +that there is now an API to send arbitrary text over `stdin` to our extension-owned +terminal, which is going to useful. + +[window]: https://code.visualstudio.com/api/references/vscode-api#window + +Another option explored was a custom `CancelReadKey` function that manually wrote a +character to the PSES's own process's `stdin` in order to get `ReadKey` to return. While I +was able to write the character (after using a P/Invoke to libc's `write()` function, +because C#'s own `stdin` stream is opened, aptly, in read-only mode), it was not +sufficient. For some reason, although the data was sent, `ReadKey` ignored it. Maybe +`stdin` is redirected, or something else is going on, unfortunately I'm not sure. However, +this exploration gave me the idea to hook up an LSP notification and have Code send a +non-printing character when `CancelReadKey` is called, since Code is already hooked up to +PSES's `stdin` and now has an API to directly write to it. More on this later. + +Another workaround for all these issues is to write our own `ReadKey`, in native code +instead of C# (so as to avoid all the issues with the `System.Console` APIs). +Theoretically, we could write a cross-platform Rust app that simply reads `stdin`, +translates input to `KeyInfo` structures, and passes that over a named pipe back PSES, +which consumes that input in a "`ReadKey`" delegate using a channel pattern queue. This +delegate would spawn the subprocess and hook the parent process's `stdin` to its own +`stdin` (just pipe it across), and when the delegate is canceled, it kills the child +process and unhooks the `stdin` redirect, essentially making this native app a +"cancellable `ReadKey`." We did not end up trying this approach due to the cost involved +in prototype it, as we would essentially be writing our own native replacement for: +[`Console`]. We'd also need to deal with the fact that `Console` doesn't like `stdin` +being [redirected], as PSReadLine indicates is already an issue. + +[`Console`]: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Console/src/System/Console.cs +[redirected]: https://github.com/PowerShell/PSReadLine/blob/f46f15d2d634e2060bc0eabe4c81fc13a5a64a3a/PSReadLine/ReadLine.cs#L343-L356 + +## Current Working Solution + +After trying a few different workarounds, something finally clicked and I combined several +of the ideas. I realized that we already have an external process writing to PSES's +`stdin`, and that's VS Code itself. Moreover, it now has a `SendText` API for the object +representing the extension owned terminal (which is hosting PSES). So in [#1751], I wired +up the cancellation token in our "safe, cancellable" `ReadKey` to send an LSP notification +to the client called `sendKeyPress`, which on the client side simply uses that API to send +a character (we eventually chose `p` because it's easy to see if something has gone wrong) +to the terminal, _just as if the user had pressed a key_. This causes `Console.ReadKey` to +return, since it received the input it was waiting on, and because we know that we +requested a cancellation (through the token), we can ignore that input and move forward +just as if the .NET API itself were canceled. Several things came together to make this +solution viable: + +- The pipeline execution threading rewrite meant that we don't have race conditions around + processing input +- VS Code added an API for us to write directly to our process's `stdin`. +- We dropped support for PowerShell 6 meaning that the .NET `System.Console` APIs on macOS + and Linux no longer deadlock each other. + +This workaround resolved many issues, and the same workaround could be able applied to +other LSP clients that host a terminal (like Vim). Moreover, we deleted over a thousand +lines of code and added less than eighty! We did have to bake this workaround for a while, +and it required PR [#3274] to PSReadLine, as well as a later race condition fix in PR +[#3294]. I still hope that one day we can have an asynchronous `Console.ReadKey` API that +accepts a cancellation token, and so does not require this fake input to return and free +up our thread. + +[#1751]: https://github.com/PowerShell/PowerShellEditorServices/pull/1751 +[#3274]: https://github.com/PowerShell/PSReadLine/pull/3274 +[#3294]: https://github.com/PowerShell/PSReadLine/pull/3294 diff --git a/src/PowerShellEditorServices/Services/PowerShell/Console/ReadLineProvider.cs b/src/PowerShellEditorServices/Services/PowerShell/Console/ReadLineProvider.cs new file mode 100644 index 0000000..be4b6d4 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Console/ReadLineProvider.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Console +{ + internal interface IReadLineProvider + { + IReadLine ReadLine { get; } + } + + internal class ReadLineProvider : IReadLineProvider + { + private readonly ILogger _logger; + + public ReadLineProvider(ILoggerFactory loggerFactory) => _logger = loggerFactory.CreateLogger(); + + public IReadLine ReadLine { get; internal set; } + + public void OverrideReadLine(IReadLine readLine) + { + _logger.LogInformation($"ReadLine overridden with '{readLine.GetType()}'"); + ReadLine = readLine; + } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Console/TerminalReadLine.cs b/src/PowerShellEditorServices/Services/PowerShell/Console/TerminalReadLine.cs new file mode 100644 index 0000000..7d5f823 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Console/TerminalReadLine.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Threading; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Console +{ + using System; + + internal abstract class TerminalReadLine : IReadLine + { + public virtual void AddToHistory(string historyEntry) + { + // No-op by default. If the ReadLine provider is not PSRL then history is automatically + // added as part of the invocation process. + } + + public abstract string ReadLine(CancellationToken cancellationToken); + + protected abstract ConsoleKeyInfo ReadKey(CancellationToken cancellationToken); + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Context/PowerShellContextFrame.cs b/src/PowerShellEditorServices/Services/PowerShell/Context/PowerShellContextFrame.cs new file mode 100644 index 0000000..f75027b --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Context/PowerShellContextFrame.cs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; +using SMA = System.Management.Automation; + +#if DEBUG +using System.Text; +#endif + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Context +{ + [DebuggerDisplay("{ToDebuggerDisplayString()}")] + internal class PowerShellContextFrame : IDisposable + { + public static PowerShellContextFrame CreateForPowerShellInstance( + ILogger logger, + SMA.PowerShell pwsh, + PowerShellFrameType frameType, + string localComputerName) + { + RunspaceInfo runspaceInfo = RunspaceInfo.CreateFromPowerShell(logger, pwsh, localComputerName); + return new PowerShellContextFrame(pwsh, runspaceInfo, frameType); + } + + private bool disposedValue; + + public PowerShellContextFrame(SMA.PowerShell powerShell, RunspaceInfo runspaceInfo, PowerShellFrameType frameType) + { + PowerShell = powerShell; + RunspaceInfo = runspaceInfo; + FrameType = frameType; + } + + public SMA.PowerShell PowerShell { get; } + + public RunspaceInfo RunspaceInfo { get; } + + public PowerShellFrameType FrameType { get; } + + public bool IsRepl => (FrameType & PowerShellFrameType.Repl) is not 0; + + public bool IsRemote => (FrameType & PowerShellFrameType.Remote) is not 0; + + public bool IsNested => (FrameType & PowerShellFrameType.Nested) is not 0; + + public bool IsDebug => (FrameType & PowerShellFrameType.Debug) is not 0; + + public bool IsAwaitingPop { get; set; } + + public bool SessionExiting { get; set; } + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + // When runspace is popping from `Exit-PSHostProcess` or similar, attempting + // to dispose directly in the same frame would dead lock. + if (SessionExiting) + { + PowerShell.DisposeWhenCompleted(); + } + else + { + PowerShell.Dispose(); + } + } + + disposedValue = true; + } + } + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(true); + GC.SuppressFinalize(this); + } + +#if DEBUG + private string ToDebuggerDisplayString() + { + StringBuilder text = new(); + + if ((FrameType & PowerShellFrameType.Nested) is not 0) + { + text.Append("Ne-"); + } + + if ((FrameType & PowerShellFrameType.Debug) is not 0) + { + text.Append("De-"); + } + + if ((FrameType & PowerShellFrameType.Remote) is not 0) + { + text.Append("Rem-"); + } + + if ((FrameType & PowerShellFrameType.NonInteractive) is not 0) + { + text.Append("NI-"); + } + + if ((FrameType & PowerShellFrameType.Repl) is not 0) + { + text.Append("Repl-"); + } + + text.Append(PowerShellDebugDisplay.ToDebuggerString(PowerShell)); + return text.ToString(); + } +#endif + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Context/PowerShellFrameType.cs b/src/PowerShellEditorServices/Services/PowerShell/Context/PowerShellFrameType.cs new file mode 100644 index 0000000..9bf2fca --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Context/PowerShellFrameType.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Context +{ + [Flags] + internal enum PowerShellFrameType + { + Normal = 0 << 0, + Nested = 1 << 0, + Debug = 1 << 1, + Remote = 1 << 2, + NonInteractive = 1 << 3, + Repl = 1 << 4, + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Context/PowerShellVersionDetails.cs b/src/PowerShellEditorServices/Services/PowerShell/Context/PowerShellVersionDetails.cs new file mode 100644 index 0000000..d236f23 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Context/PowerShellVersionDetails.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections; +using System.Linq; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Context +{ + using System.Management.Automation; + + /// + /// Provides details about the version of the PowerShell runtime. + /// + internal class PowerShellVersionDetails + { + /// + /// Gets the version of the PowerShell runtime. + /// + public Version Version { get; } + + /// + /// Gets the PowerShell edition (generally Desktop or Core). + /// + public string Edition { get; } + + /// + /// Creates an instance of the PowerShellVersionDetails class. + /// + /// The version of the PowerShell runtime. + /// The string representation of the PowerShell edition. + public PowerShellVersionDetails( + Version version, + string editionString) + { + Version = version; + Edition = editionString; + } + + /// + /// Gets the PowerShell version details for the given runspace. This doesn't use + /// VersionUtils because we may be remoting, and therefore want the remote runspace's + /// version, not the local process. + /// + /// An ILogger implementation used for writing log messages. + /// The PowerShell instance for which to get the version. + /// A new PowerShellVersionDetails instance. + public static PowerShellVersionDetails GetVersionDetails(ILogger logger, PowerShell pwsh) + { + Version powerShellVersion = new(5, 0); + string powerShellEdition = "Desktop"; + + try + { + Hashtable psVersionTable = pwsh + .AddScript("[System.Diagnostics.DebuggerHidden()]param() $PSVersionTable", useLocalScope: true) + .InvokeAndClear() + .FirstOrDefault(); + + if (psVersionTable != null) + { + if (psVersionTable["PSEdition"] is string edition) + { + powerShellEdition = edition; + } + + // The PSVersion value will either be of Version or SemanticVersion. + // In the former case, take the value directly. In the latter case, + // generate a Version from its string representation. + object version = psVersionTable["PSVersion"]; + if (version is Version version2) + { + powerShellVersion = version2; + } + else if (version != null) + { + // Expected version string format is 6.0.0-alpha so build a simpler version from that + powerShellVersion = new Version(version.ToString().Split('-')[0]); + } + } + } + catch (Exception ex) + { + logger.LogWarning( + "Failed to look up PowerShell version, defaulting to version 5.\r\n\r\n" + ex.ToString()); + } + + return new PowerShellVersionDetails(powerShellVersion, powerShellEdition); + } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Debugging/DebuggerResumingEventArgs.cs b/src/PowerShellEditorServices/Services/PowerShell/Debugging/DebuggerResumingEventArgs.cs new file mode 100644 index 0000000..59c8cc9 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Debugging/DebuggerResumingEventArgs.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Management.Automation; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Debugging +{ + internal record DebuggerResumingEventArgs( + DebuggerResumeAction ResumeAction); +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Debugging/DscBreakpointCapability.cs b/src/PowerShellEditorServices/Services/PowerShell/Debugging/DscBreakpointCapability.cs new file mode 100644 index 0000000..344b798 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Debugging/DscBreakpointCapability.cs @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services.DebugAdapter; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Debugging +{ + internal class DscBreakpointCapability + { + private static bool? isDscInstalled; + private string[] dscResourceRootPaths = Array.Empty(); + private readonly Dictionary breakpointsPerFile = new(); + + public async Task> SetLineBreakpointsAsync( + IInternalPowerShellExecutionService executionService, + string scriptPath, + IReadOnlyList breakpoints) + { + // We always get the latest array of breakpoint line numbers + // so store that for future use + int[] lineNumbers = breakpoints.Select(b => b.LineNumber).ToArray(); + if (lineNumbers.Length > 0) + { + // Set the breakpoints for this scriptPath + breakpointsPerFile[scriptPath] = lineNumbers; + } + else + { + // No more breakpoints for this scriptPath, remove it + breakpointsPerFile.Remove(scriptPath); + } + + string hashtableString = + string.Join( + ", ", + breakpointsPerFile + .Select(file => $"@{{Path=\"{file.Key}\";Line=@({string.Join(",", file.Value)})}}")); + + // Run Enable-DscDebug as a script because running it as a PSCommand + // causes an error which states that the Breakpoint parameter has not + // been passed. + PSCommand dscCommand = new PSCommand().AddScript( + hashtableString.Length > 0 + ? $"Enable-DscDebug -Breakpoint {hashtableString}" + : "Disable-DscDebug"); + + await executionService.ExecutePSCommandAsync(dscCommand, CancellationToken.None).ConfigureAwait(false); + + // Verify all the breakpoints and return them + foreach (BreakpointDetails breakpoint in breakpoints) + { + breakpoint.Verified = true; + } + + return breakpoints; + } + + public bool IsDscResourcePath(string scriptPath) + { + return dscResourceRootPaths.Any( + dscResourceRootPath => + scriptPath.StartsWith( + dscResourceRootPath, + StringComparison.CurrentCultureIgnoreCase)); + } + + public static async Task GetDscCapabilityAsync( + ILogger logger, + IRunspaceInfo currentRunspace, + IInternalPowerShellExecutionService executionService) + { + // DSC support is enabled only for Windows PowerShell. + if ((currentRunspace.PowerShellVersionDetails.Version.Major >= 6) && + (currentRunspace.RunspaceOrigin != RunspaceOrigin.DebuggedRunspace)) + { + return null; + } + + if (!isDscInstalled.HasValue) + { + PSCommand psCommand = new PSCommand() + .AddCommand(@"Microsoft.PowerShell.Core\Import-Module") + .AddParameter("Name", "PSDesiredStateConfiguration") + .AddParameter("PassThru") + .AddParameter("ErrorAction", ActionPreference.Ignore) + .AddCommand(@"Microsoft.PowerShell.Utility\Select-Object") + .AddParameter("ExpandProperty", "Name"); + + IReadOnlyList dscModule = + await executionService.ExecutePSCommandAsync( + psCommand, + CancellationToken.None, + new PowerShellExecutionOptions { ThrowOnError = false }) + .ConfigureAwait(false); + + isDscInstalled = dscModule.Count > 0; + logger.LogTrace("Side-by-side DSC module found: " + isDscInstalled.Value); + } + + if (isDscInstalled.Value) + { + // Note that __psEditorServices_ is DebugService.PsesGlobalVariableNamePrefix but + // it's notoriously difficult to interpolate it in this script, which has to be a + // single script to guarantee everything is run at once. + PSCommand psCommand = new PSCommand().AddScript( + """ + try { + $global:__psEditorServices_prevProgressPreference = $ProgressPreference + $global:ProgressPreference = 'SilentlyContinue' + return Get-DscResource | Select-Object -ExpandProperty ParentPath + } finally { + $ProgressPreference = $global:__psEditorServices_prevProgressPreference + } + """); + + IReadOnlyList resourcePaths = + await executionService.ExecutePSCommandAsync( + psCommand, + CancellationToken.None, + new PowerShellExecutionOptions { ThrowOnError = false } + ).ConfigureAwait(false); + + logger.LogTrace($"DSC resources found: {resourcePaths.Count}"); + + return new DscBreakpointCapability + { + dscResourceRootPaths = resourcePaths.ToArray() + }; + } + + return null; + } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Debugging/IPowerShellDebugContext.cs b/src/PowerShellEditorServices/Services/PowerShell/Debugging/IPowerShellDebugContext.cs new file mode 100644 index 0000000..506109b --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Debugging/IPowerShellDebugContext.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Management.Automation; +using System.Threading.Tasks; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Debugging +{ + internal interface IPowerShellDebugContext + { + bool IsStopped { get; } + + DebuggerStopEventArgs LastStopEventArgs { get; } + + public bool IsDebuggingRemoteRunspace { get; set; } + + public event Action DebuggerStopped; + + public event Action DebuggerResuming; + + public event Action BreakpointUpdated; + + void Continue(); + + void StepOver(); + + void StepInto(); + + void StepOut(); + + void BreakExecution(); + + void Abort(); + + Task GetDscBreakpointCapabilityAsync(); + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Debugging/PowerShellDebugContext.cs b/src/PowerShellEditorServices/Services/PowerShell/Debugging/PowerShellDebugContext.cs new file mode 100644 index 0000000..f88aa38 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Debugging/PowerShellDebugContext.cs @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Management.Automation; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Context; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Debugging +{ + /// + /// Handles the state of the PowerShell debugger. + /// + /// + /// + /// Debugging through a PowerShell Host is implemented by registering a handler for the event. Registering that handler causes debug actions in + /// PowerShell like Set-PSBreakpoint and Wait-Debugger to drop into the debugger and trigger the + /// handler. The handler is passed a mutable object and the + /// debugger stop lasts for the duration of the handler call. The handler sets the property when after it returns, the PowerShell + /// debugger uses that as the direction on how to proceed. + /// + /// + /// When we handle the event, we drop into a nested debug + /// prompt and execute things in the debugger with , which enables debugger commands like l, c, + /// s, etc. saves the event args object in its + /// state, and when one of the debugger commands is used, the result returned is used to set + /// on the saved event args object so that when + /// the event handler returns, the PowerShell debugger takes the correct action. + /// + /// + internal class PowerShellDebugContext : IPowerShellDebugContext + { + private readonly ILogger _logger; + + private readonly PsesInternalHost _psesHost; + + public PowerShellDebugContext( + ILoggerFactory loggerFactory, + PsesInternalHost psesHost) + { + _logger = loggerFactory.CreateLogger(); + _psesHost = psesHost; + } + + /// + /// Tracks if the debugger is currently stopped at a breakpoint. + /// + public bool IsStopped { get; private set; } + + /// + /// Tracks the state of the PowerShell debugger. This is NOT the same as , which is true whenever breakpoints are set. Instead, this is + /// set to true when the first event is + /// fired, and set to false in when is false. This is used to send the + /// 'powershell/stopDebugger' notification to the LSP debug server in the cases where the + /// server was started or ended by the PowerShell session instead of by Code's GUI. + /// + public bool IsActive { get; set; } + + /// + /// Tracks the state of the LSP debug server (not the PowerShell debugger). + /// + public bool IsDebugServerActive { get; set; } + + /// + /// Tracks whether we are running Debug-Runspace in an out-of-process runspace. + /// + public bool IsDebuggingRemoteRunspace { get; set; } + + public DebuggerStopEventArgs LastStopEventArgs { get; private set; } + + public event Action DebuggerStopped; + public event Action DebuggerResuming; + public event Action BreakpointUpdated; + + public Task GetDscBreakpointCapabilityAsync() + { + _psesHost.Runspace.ThrowCancelledIfUnusable(); + return _psesHost.CurrentRunspace.GetDscBreakpointCapabilityAsync(_logger, _psesHost); + } + + // This is required by the PowerShell API so that remote debugging works. Without it, a + // runspace may not have these options set and attempting to set breakpoints remotely fails. + public void EnableDebugMode() + { + _psesHost.Runspace.ThrowCancelledIfUnusable(); + _psesHost.Runspace.Debugger.SetDebugMode(DebugModes.LocalScript | DebugModes.RemoteScript); + } + + public void Abort() => SetDebugResuming(DebuggerResumeAction.Stop); + + public void BreakExecution() => _psesHost.Runspace.Debugger.SetDebuggerStepMode(enabled: true); + + public void Continue() => SetDebugResuming(DebuggerResumeAction.Continue); + + public void StepInto() => SetDebugResuming(DebuggerResumeAction.StepInto); + + public void StepOut() => SetDebugResuming(DebuggerResumeAction.StepOut); + + public void StepOver() => SetDebugResuming(DebuggerResumeAction.StepOver); + + public void SetDebugResuming(DebuggerResumeAction debuggerResumeAction) + { + // We exit because the paused/stopped debugger is currently in a prompt REPL, and to + // resume the debugger we must exit that REPL. If we're continued from 'c' or 's', this + // is already set and so is a no-op; but if the user clicks the continue or step button, + // then this came over LSP and we need to set it. + _psesHost.SetExit(); + + if (LastStopEventArgs is not null) + { + LastStopEventArgs.ResumeAction = debuggerResumeAction; + } + + // We need to tell whatever is happening right now in the debug prompt to wrap up so we + // can continue. However, if the host was initialized with the console REPL disabled, + // then we'd accidentally cancel the debugged task since no prompt is running. We can + // test this by checking if the UI's type is NullPSHostUI which is used specifically in + // this scenario. This mostly applies to unit tests. + if (_psesHost.UI is NullPSHostUI) + { + return; + } + + // If we're stopping (or disconnecting, which is the same thing in LSP-land), then we + // want to cancel any debug prompts, remote prompts, debugged scripts, etc. However, if + // the debugged script has exited normally (or was quit with 'q'), we still get an LSP + // notification that eventually lands here with a stop event. In this case, the debug + // context is NOT active and we do not want to cancel the regular REPL. + if (!_psesHost.DebugContext.IsActive) + { + return; + } + + // If the debugger is active and we're stopping, we need to unwind everything. + if (debuggerResumeAction is DebuggerResumeAction.Stop) + { + // TODO: We need to assign cancellation tokens to each frame, because the current + // logic results in a deadlock here when we try to cancel the scopes...which + // includes ourself (hence running it in a separate thread). + _ = Task.Run(_psesHost.UnwindCallStack); + return; + } + + // Otherwise we're continuing or stepping (i.e. resuming) so we need to cancel the + // debugger REPL. + PowerShellFrameType frameType = _psesHost.CurrentFrame.FrameType; + if (frameType.HasFlag(PowerShellFrameType.Repl)) + { + _psesHost.CancelIdleParentTask(); + return; + } + + // If the user is running something via the REPL like `while ($true) { sleep 1 }` + // and then tries to step, we want to stop that so that execution can resume. + // + // This also applies to anything we're running on debugger stop like watch variables. + if (frameType.HasFlag(PowerShellFrameType.Debug | PowerShellFrameType.Nested)) + { + _psesHost.ForceSetExit(); + _psesHost.CancelIdleParentTask(); + } + } + + // This must be called AFTER the new PowerShell has been pushed + public void EnterDebugLoop() => RaiseDebuggerStoppedEvent(); + + // This must be called BEFORE the debug PowerShell has been popped + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "This method may acquire an implementation later, at which point it will need instance data")] + public void ExitDebugLoop() { } + + public void SetDebuggerStopped(DebuggerStopEventArgs args) + { + IsStopped = true; + LastStopEventArgs = args; + } + + public void SetDebuggerResumed() => IsStopped = false; + + public void ProcessDebuggerResult(DebuggerCommandResults debuggerResult) + { + if (debuggerResult?.ResumeAction is not null) + { + // Since we're processing a command like 'c' or 's' remotely, we need to tell the + // host to stop the remote REPL loop. + if (debuggerResult.ResumeAction is not DebuggerResumeAction.Stop || _psesHost.CurrentFrame.IsRemote) + { + _psesHost.ForceSetExit(); + } + + SetDebugResuming(debuggerResult.ResumeAction.Value); + RaiseDebuggerResumingEvent(new DebuggerResumingEventArgs(debuggerResult.ResumeAction.Value)); + + // The Terminate exception is used by the engine for flow control + // when it needs to unwind the call stack out of the debugger. + if (debuggerResult.ResumeAction is DebuggerResumeAction.Stop) + { + throw new TerminateException(); + } + } + } + + public void HandleBreakpointUpdated(BreakpointUpdatedEventArgs args) => BreakpointUpdated?.Invoke(this, args); + + private void RaiseDebuggerStoppedEvent() => DebuggerStopped?.Invoke(this, LastStopEventArgs); + + private void RaiseDebuggerResumingEvent(DebuggerResumingEventArgs args) => DebuggerResuming?.Invoke(this, args); + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Execution/BlockingConcurrentDeque.cs b/src/PowerShellEditorServices/Services/PowerShell/Execution/BlockingConcurrentDeque.cs new file mode 100644 index 0000000..2e5b4f7 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Execution/BlockingConcurrentDeque.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Concurrent; +using System.Threading; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution +{ + /// + /// Implements a concurrent deque that supplies: + /// - Non-blocking prepend and append operations + /// - Blocking and non-blocking take calls + /// - The ability to block consumers, so that can also guarantee the state of the consumer + /// + /// The type of item held by this collection. + /// + /// The prepend/append semantics of this class depend on the implementation semantics of + /// and its overloads checking the supplied array in order. + /// This behavior is unlikely to change and ensuring its correctness at our layer is likely to be costly. + /// See https://stackoverflow.com/q/26472251. + /// + internal class BlockingConcurrentDeque : IDisposable + { + private readonly ManualResetEventSlim _blockConsumersEvent; + + private readonly BlockingCollection[] _queues; + + public BlockingConcurrentDeque() + { + // Initialize in the "set" state, meaning unblocked + _blockConsumersEvent = new ManualResetEventSlim(initialState: true); + + _queues = new[] + { + // The high priority section is FIFO so that "prepend" always puts elements first + new BlockingCollection(new ConcurrentStack()), + new BlockingCollection(new ConcurrentQueue()), + }; + } + + public bool IsEmpty => _queues[0].Count == 0 && _queues[1].Count == 0; + + public void Prepend(T item) => _queues[0].Add(item); + + public void Append(T item) => _queues[1].Add(item); + + public T Take(CancellationToken cancellationToken) + { + _blockConsumersEvent.Wait(cancellationToken); + BlockingCollection.TakeFromAny(_queues, out T result, cancellationToken); + return result; + } + + public bool TryTake(out T item) + { + if (!_blockConsumersEvent.IsSet) + { + item = default; + return false; + } + + return BlockingCollection.TryTakeFromAny(_queues, out item) >= 0; + } + + public IDisposable BlockConsumers() => PriorityQueueBlockLifetime.StartBlocking(_blockConsumersEvent); + + public void Dispose() => _blockConsumersEvent.Dispose(); + + private class PriorityQueueBlockLifetime : IDisposable + { + public static PriorityQueueBlockLifetime StartBlocking(ManualResetEventSlim blockEvent) + { + blockEvent.Reset(); + return new PriorityQueueBlockLifetime(blockEvent); + } + + private readonly ManualResetEventSlim _blockEvent; + + private PriorityQueueBlockLifetime(ManualResetEventSlim blockEvent) => _blockEvent = blockEvent; + + public void Dispose() => _blockEvent.Set(); + } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Execution/ExecutionOptions.cs b/src/PowerShellEditorServices/Services/PowerShell/Execution/ExecutionOptions.cs new file mode 100644 index 0000000..10bef62 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Execution/ExecutionOptions.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution +{ + public enum ExecutionPriority + { + Normal, + Next, + } + + // Some of the fields of this class are not orthogonal, + // so it's possible to construct self-contradictory execution options. + // We should see if it's possible to rework this class to make the options less misconfigurable. + // Generally the executor will do the right thing though; some options just priority over others. + public record ExecutionOptions + { + // This determines which underlying queue the task is added to. + public ExecutionPriority Priority { get; init; } = ExecutionPriority.Normal; + // This implies `ExecutionPriority.Next` because foreground tasks are prepended. + public bool RequiresForeground { get; init; } + } + + public record PowerShellExecutionOptions : ExecutionOptions + { + // TODO: Because of the above, this is actually unnecessary. + internal static PowerShellExecutionOptions ImmediateInteractive = new() + { + Priority = ExecutionPriority.Next, + RequiresForeground = true, + }; + + public bool WriteOutputToHost { get; init; } + public bool WriteInputToHost { get; init; } + public bool ThrowOnError { get; init; } = true; + public bool AddToHistory { get; init; } + internal bool FromRepl { get; init; } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Execution/SynchronousDelegateTask.cs b/src/PowerShellEditorServices/Services/PowerShell/Execution/SynchronousDelegateTask.cs new file mode 100644 index 0000000..2d40681 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Execution/SynchronousDelegateTask.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Threading; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; +using SMA = System.Management.Automation; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution +{ + internal class SynchronousDelegateTask : SynchronousTask + { + private readonly Action _action; + + private readonly string _representation; + + public SynchronousDelegateTask( + ILogger logger, + string representation, + ExecutionOptions executionOptions, + Action action, + CancellationToken cancellationToken) + : base(logger, cancellationToken) + { + ExecutionOptions = executionOptions ?? s_defaultExecutionOptions; + _representation = representation; + _action = action; + } + + public override ExecutionOptions ExecutionOptions { get; } + + public override object Run(CancellationToken cancellationToken) + { + _action(cancellationToken); + return null; + } + + public override string ToString() => _representation; + } + + internal class SynchronousDelegateTask : SynchronousTask + { + private readonly Func _func; + + private readonly string _representation; + + public SynchronousDelegateTask( + ILogger logger, + string representation, + ExecutionOptions executionOptions, + Func func, + CancellationToken cancellationToken) + : base(logger, cancellationToken) + { + _func = func; + _representation = representation; + ExecutionOptions = executionOptions ?? s_defaultExecutionOptions; + } + + public override ExecutionOptions ExecutionOptions { get; } + + public override TResult Run(CancellationToken cancellationToken) => _func(cancellationToken); + + public override string ToString() => _representation; + } + + internal class SynchronousPSDelegateTask : SynchronousTask + { + private readonly Action _action; + + private readonly string _representation; + + private readonly PsesInternalHost _psesHost; + + public SynchronousPSDelegateTask( + ILogger logger, + PsesInternalHost psesHost, + string representation, + ExecutionOptions executionOptions, + Action action, + CancellationToken cancellationToken) + : base(logger, cancellationToken) + { + _psesHost = psesHost; + _action = action; + _representation = representation; + ExecutionOptions = executionOptions ?? s_defaultExecutionOptions; + } + + public override ExecutionOptions ExecutionOptions { get; } + + public override object Run(CancellationToken cancellationToken) + { + _action(_psesHost.CurrentPowerShell, cancellationToken); + return null; + } + + public override string ToString() => _representation; + } + + internal class SynchronousPSDelegateTask : SynchronousTask + { + private readonly Func _func; + + private readonly string _representation; + + private readonly PsesInternalHost _psesHost; + + public SynchronousPSDelegateTask( + ILogger logger, + PsesInternalHost psesHost, + string representation, + ExecutionOptions executionOptions, + Func func, + CancellationToken cancellationToken) + : base(logger, cancellationToken) + { + _psesHost = psesHost; + _func = func; + _representation = representation; + ExecutionOptions = executionOptions ?? s_defaultExecutionOptions; + } + + public override ExecutionOptions ExecutionOptions { get; } + + public override TResult Run(CancellationToken cancellationToken) => _func(_psesHost.CurrentPowerShell, cancellationToken); + + public override string ToString() => _representation; + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Execution/SynchronousPowerShellTask.cs b/src/PowerShellEditorServices/Services/PowerShell/Execution/SynchronousPowerShellTask.cs new file mode 100644 index 0000000..7c05b3d --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Execution/SynchronousPowerShellTask.cs @@ -0,0 +1,416 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Management.Automation; +using System.Management.Automation.Remoting; +using System.Threading; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Context; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; +using Microsoft.PowerShell.EditorServices.Utility; +using SMA = System.Management.Automation; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution +{ + internal interface ISynchronousPowerShellTask + { + PowerShellExecutionOptions PowerShellExecutionOptions { get; } + + void MaybeAddToHistory(); + } + + internal class SynchronousPowerShellTask : SynchronousTask>, ISynchronousPowerShellTask + { + private static readonly PowerShellExecutionOptions s_defaultPowerShellExecutionOptions = new(); + + private readonly ILogger _logger; + + private readonly PsesInternalHost _psesHost; + + private readonly PSCommand _psCommand; + + private SMA.PowerShell _pwsh; + + private PowerShellContextFrame _frame; + + public SynchronousPowerShellTask( + ILogger logger, + PsesInternalHost psesHost, + PSCommand command, + PowerShellExecutionOptions executionOptions, + CancellationToken cancellationToken) + : base(logger, cancellationToken) + { + _logger = logger; + _psesHost = psesHost; + _psCommand = command; + PowerShellExecutionOptions = executionOptions ?? s_defaultPowerShellExecutionOptions; + } + + public PowerShellExecutionOptions PowerShellExecutionOptions { get; } + + public override ExecutionOptions ExecutionOptions => PowerShellExecutionOptions; + + public override IReadOnlyList Run(CancellationToken cancellationToken) + { + _psesHost.Runspace.ThrowCancelledIfUnusable(); + PowerShellContextFrame frame = _psesHost.PushPowerShellForExecution(); + try + { + _pwsh = _psesHost.CurrentPowerShell; + + if (PowerShellExecutionOptions.WriteInputToHost) + { + _psesHost.WriteWithPrompt(_psCommand, cancellationToken); + } + + // If we're in a breakpoint it means we're executing either interactive commands in + // a debug prompt, or our own special commands to query the PowerShell debugger for + // state that we sync with the LSP debugger. The former commands we want to send + // through PowerShell's `Debugger.ProcessCommand` so that they work as expected, but + // the latter we must not send through it else they pollute the history as this + // PowerShell API does not let us exclude them from it. Notably we also need to send + // the `prompt` command and our special `list 1 ` through the debugger too. + // The former needs the context in order to show `DBG 1>` etc., and the latter is + // used to gather the lines when debugging a script that isn't in a file. + return _pwsh.Runspace.Debugger.InBreakpoint + && (PowerShellExecutionOptions.AddToHistory || IsPromptOrListCommand(_psCommand) || _pwsh.Runspace.RunspaceIsRemote) + ? ExecuteInDebugger(cancellationToken) + : ExecuteNormally(cancellationToken); + } + finally + { + _psesHost.PopPowerShellForExecution(frame); + } + } + + public override string ToString() => _psCommand.GetInvocationText(); + + private static bool IsPromptOrListCommand(PSCommand command) + { + if (command.Commands.Count is not 1 + || command.Commands[0] is { IsScript: false } or { Parameters.Count: > 0 }) + { + return false; + } + + string commandText = command.Commands[0].CommandText; + return commandText.Equals("prompt", StringComparison.OrdinalIgnoreCase) + || commandText.Equals($"list 1 {int.MaxValue}", StringComparison.OrdinalIgnoreCase); + } + + private IReadOnlyList ExecuteNormally(CancellationToken cancellationToken) + { + _frame = _psesHost.CurrentFrame; + if (PowerShellExecutionOptions.WriteOutputToHost) + { + _psCommand.AddOutputCommand(); + + // Fix the transcription bug! Here we're fixing immediately before the invocation of + // our command, that has had `Out-Default` added to it. + if (!_pwsh.Runspace.RunspaceIsRemote) + { + _psesHost.DisableTranscribeOnly(); + } + } + + cancellationToken.Register(CancelNormalExecution); + + Collection result = null; + try + { + PSInvocationSettings invocationSettings = new() + { + AddToHistory = PowerShellExecutionOptions.AddToHistory, + }; + + if (PowerShellExecutionOptions.ThrowOnError) + { + invocationSettings.ErrorActionPreference = ActionPreference.Stop; + } + + result = _pwsh.InvokeCommand(_psCommand, invocationSettings); + cancellationToken.ThrowIfCancellationRequested(); + } + // Allow terminate exceptions to propagate for flow control. + catch (TerminateException) + { + throw; + } + // Test if we've been cancelled. If we're remoting, PSRemotingDataStructureException + // effectively means the pipeline was stopped. + catch (Exception e) when (cancellationToken.IsCancellationRequested || e is PipelineStoppedException || e is PSRemotingDataStructureException) + { + // ExecuteNormally handles user commands in a debug session. Perhaps we should clean all this up somehow. + if (_pwsh.Runspace.Debugger.InBreakpoint) + { + StopDebuggerIfRemoteDebugSessionFailed(); + } + throw new OperationCanceledException(); + } + // We only catch RuntimeExceptions here in case writing errors to output was requested + // Other errors are bubbled up to the caller + catch (RuntimeException e) + { + if (e is PSRemotingTransportException) + { + _ = System.Threading.Tasks.Task.Run( + _psesHost.UnwindCallStack, + CancellationToken.None) + .HandleErrorsAsync(_logger); + + _psesHost.WaitForExternalDebuggerStops(); + throw new OperationCanceledException("The operation was canceled.", e); + } + + Logger.LogWarning($"Runtime exception occurred while executing command:{Environment.NewLine}{Environment.NewLine}{e}"); + + if (PowerShellExecutionOptions.ThrowOnError) + { + throw; + } + + PSCommand command = new PSCommand() + .AddOutputCommand() + .AddParameter("InputObject", e.ErrorRecord.AsPSObject()); + + if (_pwsh.Runspace.RunspaceStateInfo.IsUsable()) + { + _pwsh.InvokeCommand(command); + } + else + { + _psesHost.UI.WriteErrorLine(e.ToString()); + } + } + finally + { + if (_pwsh.HadErrors) + { + _pwsh.Streams.Error.Clear(); + } + } + + return result; + } + + private IReadOnlyList ExecuteInDebugger(CancellationToken cancellationToken) + { + cancellationToken.Register(CancelDebugExecution); + + PSDataCollection outputCollection = new(); + + // Out-Default doesn't work as needed in the debugger + // Instead we add Out-String to the command and collect results in a PSDataCollection + // and use the event handler to print output to the UI as its added to that collection + if (PowerShellExecutionOptions.WriteOutputToHost) + { + _psCommand.AddDebugOutputCommand(); + + // Use an inline delegate here, since otherwise we need a cast -- allocation < cast + outputCollection.DataAdded += (object sender, DataAddedEventArgs args) => + { + for (int i = args.Index; i < outputCollection.Count; i++) + { + _psesHost.UI.WriteLine(outputCollection[i].ToString()); + } + }; + } + + DebuggerCommandResults debuggerResult = null; + try + { + // In the PowerShell debugger, intrinsic debugger commands are made available, like + // "l", "s", "c", etc. Executing those commands produces a result that needs to be + // set on the debugger stop event args. So we use the Debugger.ProcessCommand() API + // to properly execute commands in the debugger and then call + // DebugContext.ProcessDebuggerResult() later to handle the command appropriately + // + // Unfortunately, this API does not allow us to pass in the InvocationSettings, + // which means (for instance) that we cannot instruct it to avoid adding our + // debugger implementation's commands to the history. So instead we now only call + // `ExecuteInDebugger` for PowerShell's own intrinsic debugger commands. + debuggerResult = _pwsh.Runspace.Debugger.ProcessCommand(_psCommand, outputCollection); + cancellationToken.ThrowIfCancellationRequested(); + } + // Allow terminate exceptions to propagate for flow control. + catch (TerminateException) + { + throw; + } + // Test if we've been cancelled. If we're remoting, PSRemotingDataStructureException + // effectively means the pipeline was stopped. + catch (Exception e) when (cancellationToken.IsCancellationRequested || e is PipelineStoppedException || e is PSRemotingDataStructureException) + { + StopDebuggerIfRemoteDebugSessionFailed(); + throw new OperationCanceledException(); + } + // We only catch RuntimeExceptions here in case writing errors to output was requested + // Other errors are bubbled up to the caller + catch (RuntimeException e) + { + if (e is PSRemotingTransportException) + { + _ = System.Threading.Tasks.Task.Run( + _psesHost.UnwindCallStack, + CancellationToken.None) + .HandleErrorsAsync(_logger); + + _psesHost.WaitForExternalDebuggerStops(); + throw new OperationCanceledException("The operation was canceled.", e); + } + + Logger.LogWarning($"Runtime exception occurred while executing command:{Environment.NewLine}{Environment.NewLine}{e}"); + + if (PowerShellExecutionOptions.ThrowOnError) + { + throw; + } + + using PSDataCollection errorOutputCollection = new(); + errorOutputCollection.DataAdding += (object sender, DataAddingEventArgs args) + => _psesHost.UI.WriteLine(args.ItemAdded?.ToString()); + + PSCommand command = new PSCommand() + .AddDebugOutputCommand() + .AddParameter("InputObject", e.ErrorRecord.AsPSObject()); + + _pwsh.Runspace.Debugger.ProcessCommand(command, errorOutputCollection); + } + finally + { + if (_pwsh.HadErrors) + { + _pwsh.Streams.Error.Clear(); + } + + // Fix the transcription bug! Since we don't depend on `Out-Default` for + // `ExecuteDebugger`, we fix the bug here so the original invocation (before the + // script is executed) is good to go. + if (!_pwsh.Runspace.RunspaceIsRemote) + { + _psesHost.DisableTranscribeOnly(); + } + } + + _psesHost.DebugContext.ProcessDebuggerResult(debuggerResult); + + // Optimization to save wasted computation if we're going to throw the output away anyway + if (PowerShellExecutionOptions.WriteOutputToHost) + { + return Array.Empty(); + } + + // If we've been asked for a PSObject, no need to convert + if (typeof(TResult) == typeof(PSObject)) + { + return new List(outputCollection) as IReadOnlyList; + } + + // Otherwise, convert things over + List results = new(outputCollection.Count); + foreach (PSObject outputResult in outputCollection) + { + if (LanguagePrimitives.TryConvertTo(outputResult, typeof(TResult), out object result)) + { + results.Add((TResult)result); + } + } + return results; + } + + private void StopDebuggerIfRemoteDebugSessionFailed() + { + // When remoting to Windows PowerShell, + // command cancellation may cancel the remote debug session in a way that the local debug session doesn't detect. + // Instead we have to query the remote directly + if (_pwsh.Runspace.RunspaceIsRemote) + { + _pwsh.Runspace.ThrowCancelledIfUnusable(); + PSCommand assessDebuggerCommand = new PSCommand().AddScript("$Host.Runspace.Debugger.InBreakpoint"); + + PSDataCollection outputCollection = new(); + _pwsh.Runspace.Debugger.ProcessCommand(assessDebuggerCommand, outputCollection); + + foreach (PSObject output in outputCollection) + { + if (Equals(output?.BaseObject, false)) + { + _psesHost.DebugContext.ProcessDebuggerResult(new DebuggerCommandResults(DebuggerResumeAction.Stop, evaluatedByDebugger: true)); + _logger.LogWarning("Cancelling debug session due to remote command cancellation causing the end of remote debugging session"); + _psesHost.UI.WriteWarningLine("Debug session aborted by command cancellation. This is a known issue in the Windows PowerShell 5.1 remoting system."); + } + } + } + } + + private void CancelNormalExecution() + { + if (!_pwsh.Runspace.RunspaceStateInfo.IsUsable()) + { + return; + } + + // If we're signaled to exit a runspace then that'll trigger a stop, + // if we block on that stop we'll never exit the runspace ( + // and essentially deadlock). + if (_frame?.SessionExiting is true) + { + _pwsh.BeginStop(null, null); + return; + } + + try + { + _pwsh.Stop(); + } + catch (NullReferenceException nre) + { + _logger.LogError( + nre, + "Null reference exception from PowerShell.Stop received."); + } + } + + public void MaybeAddToHistory() + { + // Do not add PSES internal commands to history. Also exclude input that came from the + // REPL (e.g. PSReadLine) as it handles history itself in that scenario. + if (PowerShellExecutionOptions is { AddToHistory: false } or { FromRepl: true }) + { + return; + } + + // Only add pure script commands with no arguments to interactive history. + if (_psCommand.Commands is { Count: not 1 } + || _psCommand.Commands[0] is { Parameters.Count: not 0 } or { IsScript: false }) + { + return; + } + + try + { + _psesHost.AddToHistory(_psCommand.Commands[0].CommandText); + } + catch + { + // Ignore exceptions as the user can register a script-block predicate that + // determines if the command should be added to history. + } + } + + private void CancelDebugExecution() + { + if (!_pwsh.Runspace.RunspaceStateInfo.IsUsable()) + { + return; + } + + _pwsh.Runspace.Debugger.StopProcessCommand(); + } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Execution/SynchronousTask.cs b/src/PowerShellEditorServices/Services/PowerShell/Execution/SynchronousTask.cs new file mode 100644 index 0000000..fc7f97c --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Execution/SynchronousTask.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using System; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution +{ + internal interface ISynchronousTask + { + bool IsCanceled { get; } + + void ExecuteSynchronously(CancellationToken threadCancellationToken); + + ExecutionOptions ExecutionOptions { get; } + } + + internal abstract class SynchronousTask : ISynchronousTask + { + private readonly TaskCompletionSource _taskCompletionSource; + + private readonly CancellationToken _taskRequesterCancellationToken; + + private bool _executionCanceled; + + private TResult _result; + + private ExceptionDispatchInfo _exceptionInfo; + + protected SynchronousTask( + ILogger logger, + CancellationToken cancellationToken) + { + Logger = logger; + _taskCompletionSource = new TaskCompletionSource(); + _taskRequesterCancellationToken = cancellationToken; + _executionCanceled = false; + } + + protected ILogger Logger { get; } + + public Task Task => _taskCompletionSource.Task; + + // Sometimes we need the result of task run on the same thread, + // which this property allows us to do. + public TResult Result + { + get + { + if (_executionCanceled) + { + throw new OperationCanceledException(); + } + + _exceptionInfo?.Throw(); + + return _result; + } + } + + public bool IsCanceled => _executionCanceled || _taskRequesterCancellationToken.IsCancellationRequested; + + public abstract ExecutionOptions ExecutionOptions { get; } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "RCS1158", Justification = "Field is not type-dependent")] + internal static readonly ExecutionOptions s_defaultExecutionOptions = new(); + + public abstract TResult Run(CancellationToken cancellationToken); + + public abstract override string ToString(); + + public void ExecuteSynchronously(CancellationToken executorCancellationToken) + { + if (IsCanceled) + { + SetCanceled(); + return; + } + + using CancellationTokenSource cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(_taskRequesterCancellationToken, executorCancellationToken); + if (cancellationSource.IsCancellationRequested) + { + SetCanceled(); + return; + } + + try + { + TResult result = Run(cancellationSource.Token); + SetResult(result); + } + catch (OperationCanceledException) + { + SetCanceled(); + } + catch (Exception e) + { + SetException(e); + } + } + + public TResult ExecuteAndGetResult(CancellationToken cancellationToken) + { + ExecuteSynchronously(cancellationToken); + return Result; + } + + private void SetCanceled() + { + _executionCanceled = true; + _taskCompletionSource.SetCanceled(); + } + + private void SetException(Exception e) + { + // We use this to capture the original stack trace so that exceptions will be useful later + _exceptionInfo = ExceptionDispatchInfo.Capture(e); + _taskCompletionSource.SetException(e); + } + + private void SetResult(TResult result) + { + _result = result; + _taskCompletionSource.SetResult(result); + } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Handlers/EvaluateHandler.cs b/src/PowerShellEditorServices/Services/PowerShell/Handlers/EvaluateHandler.cs new file mode 100644 index 0000000..55a60be --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Handlers/EvaluateHandler.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Management.Automation; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Services.PowerShell; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + /// + /// Handler for a custom request type for evaluating PowerShell. + /// This is generally for F8 support, to allow execution of a highlighted code snippet in the console as if it were copy-pasted. + /// + internal class EvaluateHandler : IEvaluateHandler + { + private readonly IInternalPowerShellExecutionService _executionService; + + public EvaluateHandler(IInternalPowerShellExecutionService executionService) => _executionService = executionService; + + public async Task Handle(EvaluateRequestArguments request, CancellationToken cancellationToken) + { + // This API is mostly used for F8 execution so it requires the foreground. + await _executionService.ExecutePSCommandAsync( + new PSCommand().AddScript(request.Expression), + CancellationToken.None, + new PowerShellExecutionOptions + { + RequiresForeground = true, + WriteInputToHost = true, + WriteOutputToHost = true, + AddToHistory = true, + ThrowOnError = false, + }).ConfigureAwait(false); + + // TODO: Should we return a more informative result? + return new EvaluateResponseBody + { + Result = "", + VariablesReference = 0 + }; + } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Handlers/ExpandAliasHandler.cs b/src/PowerShellEditorServices/Services/PowerShell/Handlers/ExpandAliasHandler.cs new file mode 100644 index 0000000..8390bbd --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Handlers/ExpandAliasHandler.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Management.Automation; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using OmniSharp.Extensions.JsonRpc; +using Microsoft.PowerShell.EditorServices.Services.PowerShell; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + [Serial, Method("powerShell/expandAlias")] + internal interface IExpandAliasHandler : IJsonRpcRequestHandler { } + + internal class ExpandAliasParams : IRequest + { + public string Text { get; set; } + } + + internal class ExpandAliasResult + { + public string Text { get; set; } + } + + internal class ExpandAliasHandler : IExpandAliasHandler + { + private readonly IInternalPowerShellExecutionService _executionService; + + public ExpandAliasHandler(IInternalPowerShellExecutionService executionService) => _executionService = executionService; + + public async Task Handle(ExpandAliasParams request, CancellationToken cancellationToken) + { + const string script = @" +function __Expand-Alias { + [System.Diagnostics.DebuggerHidden()] + param($targetScript) + + [ref]$errors=$null + + $tokens = [System.Management.Automation.PsParser]::Tokenize($targetScript, $errors).Where({$_.type -eq 'command'}) | + Sort-Object Start -Descending + + foreach ($token in $tokens) { + $definition=(Get-Command ('`'+$token.Content) -CommandType Alias -ErrorAction SilentlyContinue).Definition + + if($definition) { + $lhs=$targetScript.Substring(0, $token.Start) + $rhs=$targetScript.Substring($token.Start + $token.Length) + + $targetScript=$lhs + $definition + $rhs + } + } + + $targetScript +}"; + + // TODO: Refactor to not rerun the function definition every time. + PSCommand psCommand = new(); + psCommand + .AddScript(script) + .AddStatement() + .AddCommand("__Expand-Alias") + .AddArgument(request.Text); + System.Collections.Generic.IReadOnlyList result = await _executionService.ExecutePSCommandAsync(psCommand, cancellationToken).ConfigureAwait(false); + + return new ExpandAliasResult + { + Text = result[0] + }; + } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Handlers/GetCommandHandler.cs b/src/PowerShellEditorServices/Services/PowerShell/Handlers/GetCommandHandler.cs new file mode 100644 index 0000000..a9f48ce --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Handlers/GetCommandHandler.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Management.Automation; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using OmniSharp.Extensions.JsonRpc; +using Microsoft.PowerShell.EditorServices.Services.PowerShell; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + [Serial, Method("powerShell/getCommand", Direction.ClientToServer)] + internal interface IGetCommandHandler : IJsonRpcRequestHandler> { } + + internal class GetCommandParams : IRequest> { } + + /// + /// Describes the message to get the details for a single PowerShell Command + /// from the current session + /// + internal class PSCommandMessage + { + public string Name { get; set; } + public string ModuleName { get; set; } + public string DefaultParameterSet { get; set; } + public Dictionary Parameters { get; set; } + public System.Collections.ObjectModel.ReadOnlyCollection ParameterSets { get; set; } + } + + internal class GetCommandHandler : IGetCommandHandler + { + private readonly IInternalPowerShellExecutionService _executionService; + + public GetCommandHandler(IInternalPowerShellExecutionService executionService) => _executionService = executionService; + + public async Task> Handle(GetCommandParams request, CancellationToken cancellationToken) + { + PSCommand psCommand = new(); + + // Executes the following: + // Get-Command -CommandType Function,Cmdlet,ExternalScript | Sort-Object -Property Name + psCommand + .AddCommand(@"Microsoft.PowerShell.Core\Get-Command") + .AddParameter("CommandType", new[] { "Function", "Cmdlet", "ExternalScript" }) + .AddCommand(@"Microsoft.PowerShell.Utility\Sort-Object") + .AddParameter("Property", "Name"); + + IEnumerable result = await _executionService.ExecutePSCommandAsync(psCommand, cancellationToken).ConfigureAwait(false); + + List commandList = new(); + if (result != null) + { + foreach (CommandInfo command in result) + { + // Some info objects have a quicker way to get the DefaultParameterSet. These + // are also the most likely to show up so win-win. + string defaultParameterSet = null; + switch (command) + { + case CmdletInfo info: + defaultParameterSet = info.DefaultParameterSet; + break; + case FunctionInfo info: + defaultParameterSet = info.DefaultParameterSet; + break; + } + + if (defaultParameterSet == null) + { + // Try to get the default ParameterSet if it isn't streamlined (ExternalScriptInfo for example) + foreach (CommandParameterSetInfo parameterSetInfo in command.ParameterSets) + { + if (parameterSetInfo.IsDefault) + { + defaultParameterSet = parameterSetInfo.Name; + break; + } + } + } + + commandList.Add(new PSCommandMessage + { + Name = command.Name, + ModuleName = command.ModuleName, + Parameters = command.Parameters, + ParameterSets = command.ParameterSets, + DefaultParameterSet = defaultParameterSet + }); + } + } + + return commandList; + } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Handlers/GetCommentHelpHandler.cs b/src/PowerShellEditorServices/Services/PowerShell/Handlers/GetCommentHelpHandler.cs new file mode 100644 index 0000000..10013a0 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Handlers/GetCommentHelpHandler.cs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Management.Automation.Language; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + internal class GetCommentHelpHandler : IGetCommentHelpHandler + { + private readonly WorkspaceService _workspaceService; + private readonly AnalysisService _analysisService; + + public GetCommentHelpHandler( + WorkspaceService workspaceService, + AnalysisService analysisService) + { + _workspaceService = workspaceService; + _analysisService = analysisService; + } + + public async Task Handle(CommentHelpRequestParams request, CancellationToken cancellationToken) + { + CommentHelpRequestResult result = new(); + + if (!_workspaceService.TryGetFile(request.DocumentUri, out ScriptFile scriptFile)) + { + return result; + } + + int triggerLine = request.TriggerPosition.Line + 1; + + FunctionDefinitionAst functionDefinitionAst = SymbolsService.GetFunctionDefinitionForHelpComment( + scriptFile, + triggerLine, + out string helpLocation); + + if (functionDefinitionAst == null) + { + return result; + } + + IScriptExtent funcExtent = functionDefinitionAst.Extent; + string funcText = funcExtent.Text; + if (helpLocation.Equals("begin")) + { + // check if the previous character is `<` because it invalidates + // the param block the follows it. + IList lines = ScriptFile.GetLines(funcText); + int relativeTriggerLine0b = triggerLine - funcExtent.StartLineNumber; + if (relativeTriggerLine0b > 0 && lines[relativeTriggerLine0b].IndexOf("<", StringComparison.OrdinalIgnoreCase) > -1) + { + lines[relativeTriggerLine0b] = string.Empty; + } + + funcText = string.Join("\n", lines); + } + + string helpText = await _analysisService.GetCommentHelpText(funcText, helpLocation, forBlockComment: request.BlockComment).ConfigureAwait(false); + + if (helpText == null) + { + return result; + } + + List helpLines = ScriptFile.GetLines(helpText); + + if (helpLocation?.Equals("before", StringComparison.OrdinalIgnoreCase) == false) + { + // we need to trim the leading `{` and newline when helpLocation=="begin" + helpLines.RemoveAt(helpLines.Count - 1); + + // we also need to trim the leading newline when helpLocation=="end" + helpLines.RemoveAt(0); + } + + // Trim trailing newline from help text. + if (string.IsNullOrEmpty(helpLines[helpLines.Count - 1])) + { + helpLines.RemoveAt(helpLines.Count - 1); + } + + result.Content = helpLines.ToArray(); + return result; + } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Handlers/GetVersionHandler.cs b/src/PowerShellEditorServices/Services/PowerShell/Handlers/GetVersionHandler.cs new file mode 100644 index 0000000..f6aaf17 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Handlers/GetVersionHandler.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Services.PowerShell; +using Microsoft.PowerShell.EditorServices.Utility; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + internal class GetVersionHandler : IGetVersionHandler + { + public Task Handle(GetVersionParams request, CancellationToken cancellationToken) + { + return Task.FromResult(new PowerShellVersion + { + Version = VersionUtils.PSVersionString, + Edition = VersionUtils.PSEdition, + Commit = VersionUtils.GitCommitId, + Architecture = VersionUtils.Architecture + }); + } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Handlers/IEvaluateHandler.cs b/src/PowerShellEditorServices/Services/PowerShell/Handlers/IEvaluateHandler.cs new file mode 100644 index 0000000..21808e4 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Handlers/IEvaluateHandler.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using MediatR; +using OmniSharp.Extensions.JsonRpc; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + [Serial, Method("evaluate")] + internal interface IEvaluateHandler : IJsonRpcRequestHandler { } + + internal class EvaluateRequestArguments : IRequest + { + /// + /// The expression to evaluate. + /// + public string Expression { get; set; } + + /// + /// The context in which the evaluate request is run. Possible + /// values are 'watch' if evaluate is run in a watch or 'repl' + /// if run from the REPL console. + /// + public string Context { get; set; } + + /// + /// Evaluate the expression in the context of this stack frame. + /// If not specified, the top most frame is used. + /// + public int FrameId { get; set; } + } + + internal class EvaluateResponseBody + { + /// + /// The evaluation result. + /// + public string Result { get; set; } + + /// + /// If variablesReference is > 0, the evaluate result is + /// structured and its children can be retrieved by passing + /// variablesReference to the VariablesRequest + /// + public int VariablesReference { get; set; } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Handlers/IGetCommentHelpHandler.cs b/src/PowerShellEditorServices/Services/PowerShell/Handlers/IGetCommentHelpHandler.cs new file mode 100644 index 0000000..040fb37 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Handlers/IGetCommentHelpHandler.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using MediatR; +using OmniSharp.Extensions.JsonRpc; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + [Serial, Method("powerShell/getCommentHelp")] + internal interface IGetCommentHelpHandler : IJsonRpcRequestHandler { } + + internal class CommentHelpRequestResult + { + public string[] Content { get; set; } + } + + internal class CommentHelpRequestParams : IRequest + { + public string DocumentUri { get; set; } + public Position TriggerPosition { get; set; } + public bool BlockComment { get; set; } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Handlers/IGetPSHostProcessesHandler.cs b/src/PowerShellEditorServices/Services/PowerShell/Handlers/IGetPSHostProcessesHandler.cs new file mode 100644 index 0000000..0772f43 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Handlers/IGetPSHostProcessesHandler.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using MediatR; +using OmniSharp.Extensions.JsonRpc; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + [Serial, Method("powerShell/getPSHostProcesses")] + internal interface IGetPSHostProcessesHandler : IJsonRpcRequestHandler { } + + internal class GetPSHostProcessesParams : IRequest { } + + internal class PSHostProcessResponse + { + public string ProcessName { get; set; } + + public int ProcessId { get; set; } + + public string AppDomainName { get; set; } + + public string MainWindowTitle { get; set; } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Handlers/IGetRunspaceHandler.cs b/src/PowerShellEditorServices/Services/PowerShell/Handlers/IGetRunspaceHandler.cs new file mode 100644 index 0000000..7ca2c86 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Handlers/IGetRunspaceHandler.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using MediatR; +using OmniSharp.Extensions.JsonRpc; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + [Serial, Method("powerShell/getRunspace")] + internal interface IGetRunspaceHandler : IJsonRpcRequestHandler { } + + internal class GetRunspaceParams : IRequest + { + public int ProcessId { get; set; } + } + + internal class RunspaceResponse + { + public int Id { get; set; } + + public string Name { get; set; } + + public string Availability { get; set; } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Handlers/IGetVersionHandler.cs b/src/PowerShellEditorServices/Services/PowerShell/Handlers/IGetVersionHandler.cs new file mode 100644 index 0000000..ac82d85 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Handlers/IGetVersionHandler.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using MediatR; +using OmniSharp.Extensions.JsonRpc; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell +{ + [Serial, Method("powerShell/getVersion")] + internal interface IGetVersionHandler : IJsonRpcRequestHandler { } + + internal class GetVersionParams : IRequest { } + + internal record PowerShellVersion + { + public string Version { get; init; } + public string Edition { get; init; } + public string Commit { get; init; } + public string Architecture { get; init; } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Handlers/PSHostProcessAndRunspaceHandlers.cs b/src/PowerShellEditorServices/Services/PowerShell/Handlers/PSHostProcessAndRunspaceHandlers.cs new file mode 100644 index 0000000..c14e533 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Handlers/PSHostProcessAndRunspaceHandlers.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Management.Automation.Runspaces; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + using System.Management.Automation; + using Microsoft.PowerShell.EditorServices.Services.PowerShell; + using Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace; + using OmniSharp.Extensions.JsonRpc; + + internal class PSHostProcessAndRunspaceHandlers : IGetPSHostProcessesHandler, IGetRunspaceHandler + { + private readonly ILogger _logger; + private readonly IInternalPowerShellExecutionService _executionService; + private readonly IRunspaceContext _runspaceContext; + private static readonly int s_currentPID = System.Diagnostics.Process.GetCurrentProcess().Id; + + public PSHostProcessAndRunspaceHandlers( + ILoggerFactory factory, + IInternalPowerShellExecutionService executionService, + IRunspaceContext runspaceContext) + { + _logger = factory.CreateLogger(); + _executionService = executionService; + _runspaceContext = runspaceContext; + } + + public async Task Handle(GetPSHostProcessesParams request, CancellationToken cancellationToken) + { + PSCommand psCommand = new PSCommand().AddCommand(@"Microsoft.PowerShell.Core\Get-PSHostProcessInfo"); + IReadOnlyList processes = await _executionService.ExecutePSCommandAsync( + psCommand, cancellationToken).ConfigureAwait(false); + + List psHostProcesses = []; + foreach (dynamic p in processes) + { + PSHostProcessResponse response = new() + { + ProcessName = p.ProcessName, + ProcessId = p.ProcessId, + AppDomainName = p.AppDomainName, + MainWindowTitle = p.MainWindowTitle + }; + + // NOTE: We do not currently support attaching to ourself in this manner, so we + // exclude our process. When we maybe eventually do, we should name it. + if (response.ProcessId == s_currentPID) + { + continue; + } + + psHostProcesses.Add(response); + } + + return psHostProcesses.ToArray(); + } + + public async Task Handle(GetRunspaceParams request, CancellationToken cancellationToken) + { + if (request.ProcessId == s_currentPID) + { + throw new RpcErrorException(0, null, $"Attaching to the Extension Terminal is not supported!"); + } + + // Create a remote runspace that we will invoke Get-Runspace in. + IReadOnlyList runspaces = []; + using (Runspace runspace = RunspaceFactory.CreateRunspace(new NamedPipeConnectionInfo(request.ProcessId))) + { + using PowerShell pwsh = PowerShell.Create(); + runspace.Open(); + pwsh.Runspace = runspace; + // Returns deserialized Runspaces. For simpler code, we use PSObject and rely on dynamic later. + runspaces = pwsh.AddCommand(@"Microsoft.PowerShell.Utility\Get-Runspace").Invoke(); + } + + List runspaceResponses = []; + foreach (dynamic runspace in runspaces) + { + // This is the special runspace used for debugging, we can't attach to it. + if (runspace.Name == "PSAttachRunspace") + { + continue; + } + + runspaceResponses.Add( + new RunspaceResponse + { + Id = runspace.Id, + Name = runspace.Name, + Availability = runspace.RunspaceAvailability.ToString() + }); + } + + return runspaceResponses.ToArray(); + } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Handlers/ShowHelpHandler.cs b/src/PowerShellEditorServices/Services/PowerShell/Handlers/ShowHelpHandler.cs new file mode 100644 index 0000000..43fcdfc --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Handlers/ShowHelpHandler.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Management.Automation; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using OmniSharp.Extensions.JsonRpc; +using Microsoft.PowerShell.EditorServices.Services.PowerShell; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + [Serial, Method("powerShell/showHelp")] + internal interface IShowHelpHandler : IJsonRpcNotificationHandler { } + + internal class ShowHelpParams : IRequest + { + public string Text { get; set; } + } + + internal class ShowHelpHandler : IShowHelpHandler + { + private readonly IInternalPowerShellExecutionService _executionService; + + public ShowHelpHandler(IInternalPowerShellExecutionService executionService) => _executionService = executionService; + + public async Task Handle(ShowHelpParams request, CancellationToken cancellationToken) + { + // TODO: Refactor to not rerun the function definition every time. + const string CheckHelpScript = @" + [System.Diagnostics.DebuggerHidden()] + [CmdletBinding()] + param ( + [String]$CommandName + ) + try { + $command = Microsoft.PowerShell.Core\Get-Command $CommandName -ErrorAction Stop + } catch [System.Management.Automation.CommandNotFoundException] { + $PSCmdlet.ThrowTerminatingError($PSItem) + } + try { + $helpUri = [Microsoft.PowerShell.Commands.GetHelpCodeMethods]::GetHelpUri($command) + + $oldSslVersion = [System.Net.ServicePointManager]::SecurityProtocol + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 + + # HEAD means we don't need the content itself back, just the response header + $status = (Microsoft.PowerShell.Utility\Invoke-WebRequest -Method Head -Uri $helpUri -TimeoutSec 5 -ErrorAction Stop).StatusCode + if ($status -lt 400) { + $null = Microsoft.PowerShell.Core\Get-Help $CommandName -Online + return + } + } catch { + # Ignore - we want to drop out to Get-Help -Full + } finally { + [System.Net.ServicePointManager]::SecurityProtocol = $oldSslVersion + } + + return Microsoft.PowerShell.Core\Get-Help $CommandName -Full + "; + + string helpParams = request.Text; + if (string.IsNullOrEmpty(helpParams)) { helpParams = "Get-Help"; } + + PSCommand checkHelpPSCommand = new PSCommand() + .AddScript(CheckHelpScript, useLocalScope: true) + .AddArgument(helpParams); + + // TODO: Rather than print the help in the console, we should send the string back + // to VSCode to display in a help pop-up (or similar) + await _executionService.ExecutePSCommandAsync( + checkHelpPSCommand, + cancellationToken, + new PowerShellExecutionOptions + { + RequiresForeground = true, + WriteOutputToHost = true, + ThrowOnError = false + }).ConfigureAwait(false); + return Unit.Value; + } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Host/EditorServicesConsolePSHost.cs b/src/PowerShellEditorServices/Services/PowerShell/Host/EditorServicesConsolePSHost.cs new file mode 100644 index 0000000..06371b6 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Host/EditorServicesConsolePSHost.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Globalization; +using System.Management.Automation.Host; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Host +{ + public class EditorServicesConsolePSHost : PSHost, IHostSupportsInteractiveSession + { + private readonly PsesInternalHost _internalHost; + + internal EditorServicesConsolePSHost( + PsesInternalHost internalHost) => _internalHost = internalHost; + + public override CultureInfo CurrentCulture => _internalHost.CurrentCulture; + + public override CultureInfo CurrentUICulture => _internalHost.CurrentUICulture; + + public override Guid InstanceId => _internalHost.InstanceId; + + public override string Name => _internalHost.Name; + + public override System.Management.Automation.PSObject PrivateData => _internalHost.PrivateData; + + public override PSHostUserInterface UI => _internalHost.UI; + + public override Version Version => _internalHost.Version; + + public bool IsRunspacePushed => _internalHost.IsRunspacePushed; + + public System.Management.Automation.Runspaces.Runspace Runspace => _internalHost.Runspace; + + public override void EnterNestedPrompt() => _internalHost.EnterNestedPrompt(); + + public override void ExitNestedPrompt() => _internalHost.ExitNestedPrompt(); + + public override void NotifyBeginApplication() => _internalHost.NotifyBeginApplication(); + + public override void NotifyEndApplication() => _internalHost.NotifyEndApplication(); + + public void PopRunspace() => _internalHost.PopRunspace(); + + public void PushRunspace(System.Management.Automation.Runspaces.Runspace runspace) => _internalHost.PushRunspace(runspace); + + public override void SetShouldExit(int exitCode) => _internalHost.SetShouldExit(exitCode); + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Host/EditorServicesConsolePSHostRawUserInterface.cs b/src/PowerShellEditorServices/Services/PowerShell/Host/EditorServicesConsolePSHostRawUserInterface.cs new file mode 100644 index 0000000..8a90f05 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Host/EditorServicesConsolePSHostRawUserInterface.cs @@ -0,0 +1,228 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Management.Automation.Host; +using Microsoft.Extensions.Logging; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Host +{ + internal class EditorServicesConsolePSHostRawUserInterface : PSHostRawUserInterface + { + #region Private Fields + + private readonly PSHostRawUserInterface _internalRawUI; + private readonly ILogger _logger; + + #endregion + + #region Constructors + + /// + /// Creates a new instance of the TerminalPSHostRawUserInterface + /// class with the given IConsoleHost implementation. + /// + public EditorServicesConsolePSHostRawUserInterface( + ILoggerFactory loggerFactory, + PSHostRawUserInterface internalRawUI) + { + _logger = loggerFactory.CreateLogger(); + _internalRawUI = internalRawUI; + } + + #endregion + + #region PSHostRawUserInterface Implementation + + /// + /// Gets or sets the background color of the console. + /// + public override ConsoleColor BackgroundColor + { + get => System.Console.BackgroundColor; + set => System.Console.BackgroundColor = value; + } + + /// + /// Gets or sets the foreground color of the console. + /// + public override ConsoleColor ForegroundColor + { + get => System.Console.ForegroundColor; + set => System.Console.ForegroundColor = value; + } + + /// + /// Gets or sets the size of the console buffer. + /// + public override Size BufferSize + { + get => _internalRawUI.BufferSize; + set => _internalRawUI.BufferSize = value; + } + + /// + /// Gets or sets the cursor's position in the console buffer. + /// + public override Coordinates CursorPosition + { + get => _internalRawUI.CursorPosition; + set => _internalRawUI.CursorPosition = value; + } + + /// + /// Gets or sets the size of the cursor in the console buffer. + /// + public override int CursorSize + { + get => _internalRawUI.CursorSize; + set => _internalRawUI.CursorSize = value; + } + + /// + /// Gets or sets the position of the console's window. + /// + public override Coordinates WindowPosition + { + get => _internalRawUI.WindowPosition; + set => _internalRawUI.WindowPosition = value; + } + + /// + /// Gets or sets the size of the console's window. + /// + public override Size WindowSize + { + get => _internalRawUI.WindowSize; + set => _internalRawUI.WindowSize = value; + } + + /// + /// Gets or sets the console window's title. + /// + public override string WindowTitle + { + get => _internalRawUI.WindowTitle; + set => _internalRawUI.WindowTitle = value; + } + + /// + /// Gets a boolean that determines whether a keypress is available. + /// + public override bool KeyAvailable => _internalRawUI.KeyAvailable; + + /// + /// Gets the maximum physical size of the console window. + /// + public override Size MaxPhysicalWindowSize => _internalRawUI.MaxPhysicalWindowSize; + + /// + /// Gets the maximum size of the console window. + /// + public override Size MaxWindowSize => _internalRawUI.MaxWindowSize; + + /// + /// Reads the current key pressed in the console. + /// + /// Options for reading the current keypress. + /// A KeyInfo struct with details about the current keypress. + public override KeyInfo ReadKey(ReadKeyOptions options) => _internalRawUI.ReadKey(options); + + /// + /// Flushes the current input buffer. + /// + public override void FlushInputBuffer() => _logger.LogWarning("PSHostRawUserInterface.FlushInputBuffer was called"); + + /// + /// Gets the contents of the console buffer in a rectangular area. + /// + /// The rectangle inside which buffer contents will be accessed. + /// A BufferCell array with the requested buffer contents. + public override BufferCell[,] GetBufferContents(Rectangle rectangle) => _internalRawUI.GetBufferContents(rectangle); + + /// + /// Scrolls the contents of the console buffer. + /// + /// The source rectangle to scroll. + /// The destination coordinates by which to scroll. + /// The rectangle inside which the scrolling will be clipped. + /// The cell with which the buffer will be filled. + public override void ScrollBufferContents( + Rectangle source, + Coordinates destination, + Rectangle clip, + BufferCell fill) => _internalRawUI.ScrollBufferContents(source, destination, clip, fill); + + /// + /// Sets the contents of the buffer inside the specified rectangle. + /// + /// The rectangle inside which buffer contents will be filled. + /// The BufferCell which will be used to fill the requested space. + public override void SetBufferContents( + Rectangle rectangle, + BufferCell fill) + { + // If the rectangle is all -1s then it means clear the visible buffer + if (rectangle.Top == -1 && + rectangle.Bottom == -1 && + rectangle.Left == -1 && + rectangle.Right == -1) + { + System.Console.Clear(); + return; + } + + _internalRawUI.SetBufferContents(rectangle, fill); + } + + /// + /// Sets the contents of the buffer at the given coordinate. + /// + /// The coordinate at which the buffer will be changed. + /// The new contents for the buffer at the given coordinate. + public override void SetBufferContents( + Coordinates origin, + BufferCell[,] contents) => _internalRawUI.SetBufferContents(origin, contents); + + /// + /// Determines the number of BufferCells a character occupies. + /// + /// + /// The character whose length we want to know. + /// + /// + /// The length in buffer cells according to the original host + /// implementation for the process. + /// + public override int LengthInBufferCells(char source) => _internalRawUI.LengthInBufferCells(source); + + /// + /// Determines the number of BufferCells a string occupies. + /// + /// + /// The string whose length we want to know. + /// + /// + /// The length in buffer cells according to the original host + /// implementation for the process. + /// + public override int LengthInBufferCells(string source) => _internalRawUI.LengthInBufferCells(source); + + /// + /// Determines the number of BufferCells a substring of a string occupies. + /// + /// + /// The string whose substring length we want to know. + /// + /// + /// Offset where the substring begins in + /// + /// + /// The length in buffer cells according to the original host + /// implementation for the process. + /// + public override int LengthInBufferCells(string source, int offset) => _internalRawUI.LengthInBufferCells(source, offset); + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Host/EditorServicesConsolePSHostUserInterface.cs b/src/PowerShellEditorServices/Services/PowerShell/Host/EditorServicesConsolePSHostUserInterface.cs new file mode 100644 index 0000000..55ab29e --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Host/EditorServicesConsolePSHostUserInterface.cs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Management.Automation; +using System.Management.Automation.Host; +using System.Security; +using Microsoft.Extensions.Logging; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Host +{ + internal class EditorServicesConsolePSHostUserInterface : PSHostUserInterface, IHostUISupportsMultipleChoiceSelection + { + private readonly PSHostUserInterface _underlyingHostUI; + + /// + /// We use a ConcurrentDictionary because ConcurrentHashSet does not exist, hence the value + /// is never actually used, and `WriteProgress` must be thread-safe. + /// + private readonly ConcurrentDictionary<(long, int), object> _currentProgressRecords = new(); + + public EditorServicesConsolePSHostUserInterface( + ILoggerFactory loggerFactory, + PSHostUserInterface underlyingHostUI) + { + _underlyingHostUI = underlyingHostUI; + RawUI = new EditorServicesConsolePSHostRawUserInterface(loggerFactory, underlyingHostUI.RawUI); + } + + public override bool SupportsVirtualTerminal => _underlyingHostUI.SupportsVirtualTerminal; + + public override PSHostRawUserInterface RawUI { get; } + + public override Dictionary Prompt(string caption, string message, Collection descriptions) => _underlyingHostUI.Prompt(caption, message, descriptions); + + public override int PromptForChoice(string caption, string message, Collection choices, int defaultChoice) => _underlyingHostUI.PromptForChoice(caption, message, choices, defaultChoice); + + public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName, PSCredentialTypes allowedCredentialTypes, PSCredentialUIOptions options) => _underlyingHostUI.PromptForCredential(caption, message, userName, targetName, allowedCredentialTypes, options); + + public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName) => _underlyingHostUI.PromptForCredential(caption, message, userName, targetName); + + public override string ReadLine() => _underlyingHostUI.ReadLine(); + + public override SecureString ReadLineAsSecureString() => _underlyingHostUI.ReadLineAsSecureString(); + + public override void Write(ConsoleColor foregroundColor, ConsoleColor backgroundColor, string value) => _underlyingHostUI.Write(foregroundColor, backgroundColor, value); + + public override void Write(string value) => _underlyingHostUI.Write(value); + + public override void WriteDebugLine(string message) => _underlyingHostUI.WriteDebugLine(message); + + public override void WriteErrorLine(string value) => _underlyingHostUI.WriteErrorLine(value); + + public override void WriteInformation(InformationRecord record) => _underlyingHostUI.WriteInformation(record); + + public override void WriteLine() => _underlyingHostUI.WriteLine(); + + public override void WriteLine(ConsoleColor foregroundColor, ConsoleColor backgroundColor, string value) => _underlyingHostUI.WriteLine(foregroundColor, backgroundColor, value); + + public override void WriteLine(string value) => _underlyingHostUI.WriteLine(value); + + public override void WriteProgress(long sourceId, ProgressRecord record) + { + _ = record.RecordType == ProgressRecordType.Completed + ? _currentProgressRecords.TryRemove((sourceId, record.ActivityId), out _) + : _currentProgressRecords.TryAdd((sourceId, record.ActivityId), null); + _underlyingHostUI.WriteProgress(sourceId, record); + } + + internal void ResetProgress() + { + // Mark all processed progress records as completed. + foreach ((long sourceId, int activityId) in _currentProgressRecords.Keys) + { + // NOTE: This initializer checks that string is not null nor empty, so it must have + // some text in it. + ProgressRecord record = new(activityId, "0", "0") + { + RecordType = ProgressRecordType.Completed + }; + _underlyingHostUI.WriteProgress(sourceId, record); + _currentProgressRecords.Clear(); + } + // TODO: Maybe send the OSC sequence to turn off progress indicator. + } + + public override void WriteVerboseLine(string message) => _underlyingHostUI.WriteVerboseLine(message); + + public override void WriteWarningLine(string message) => _underlyingHostUI.WriteWarningLine(message); + + public Collection PromptForChoice( + string caption, + string message, + Collection choices, + IEnumerable defaultChoices) + => ((IHostUISupportsMultipleChoiceSelection)_underlyingHostUI).PromptForChoice(caption, message, choices, defaultChoices); + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Host/HostStartOptions.cs b/src/PowerShellEditorServices/Services/PowerShell/Host/HostStartOptions.cs new file mode 100644 index 0000000..7de16fd --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Host/HostStartOptions.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Host +{ + internal struct HostStartOptions + { + public bool LoadProfiles { get; set; } + + public string InitialWorkingDirectory { get; set; } + + public string ShellIntegrationScript { get; set; } +} +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Host/NullPSHostRawUI.cs b/src/PowerShellEditorServices/Services/PowerShell/Host/NullPSHostRawUI.cs new file mode 100644 index 0000000..4bfb94f --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Host/NullPSHostRawUI.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Management.Automation.Host; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Host +{ + internal class NullPSHostRawUI : PSHostRawUserInterface + { + private readonly BufferCell[,] _buffer; + + public NullPSHostRawUI() => _buffer = new BufferCell[0, 0]; + + public override Coordinates WindowPosition { get; set; } + + public override Size MaxWindowSize => new() { Width = _buffer.GetLength(0), Height = _buffer.GetLength(1) }; + + public override Size MaxPhysicalWindowSize => MaxWindowSize; + + public override bool KeyAvailable => false; + + public override ConsoleColor ForegroundColor { get; set; } + + public override int CursorSize { get; set; } + + public override Coordinates CursorPosition { get; set; } + + public override Size BufferSize { get; set; } + + public override ConsoleColor BackgroundColor { get; set; } + + public override Size WindowSize { get; set; } + + public override string WindowTitle { get; set; } + + public override void FlushInputBuffer() { } + + public override BufferCell[,] GetBufferContents(Rectangle rectangle) => _buffer; + + public override KeyInfo ReadKey(ReadKeyOptions options) => default; + + public override void ScrollBufferContents(Rectangle source, Coordinates destination, Rectangle clip, BufferCell fill) { } + + public override void SetBufferContents(Coordinates origin, BufferCell[,] contents) { } + + public override void SetBufferContents(Rectangle rectangle, BufferCell fill) { } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Host/NullPSHostUI.cs b/src/PowerShellEditorServices/Services/PowerShell/Host/NullPSHostUI.cs new file mode 100644 index 0000000..2d3af9e --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Host/NullPSHostUI.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Management.Automation; +using System.Management.Automation.Host; +using System.Security; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Host +{ + internal class NullPSHostUI : PSHostUserInterface + { + public NullPSHostUI() => RawUI = new NullPSHostRawUI(); + + public override bool SupportsVirtualTerminal => false; + + public override PSHostRawUserInterface RawUI { get; } + + public override Dictionary Prompt(string caption, string message, Collection descriptions) => new(); + + public override int PromptForChoice(string caption, string message, Collection choices, int defaultChoice) => 0; + + public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName, PSCredentialTypes allowedCredentialTypes, PSCredentialUIOptions options) => new(userName: string.Empty, password: new SecureString()); + + public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName) + => PromptForCredential(caption, message, userName, targetName, PSCredentialTypes.Default, PSCredentialUIOptions.Default); + + public override string ReadLine() => string.Empty; + + public override SecureString ReadLineAsSecureString() => new(); + + public override void Write(ConsoleColor foregroundColor, ConsoleColor backgroundColor, string value) { } + + public override void Write(string value) { } + + public override void WriteDebugLine(string message) { } + + public override void WriteErrorLine(string value) { } + + public override void WriteInformation(InformationRecord record) { } + + public override void WriteLine() { } + + public override void WriteLine(ConsoleColor foregroundColor, ConsoleColor backgroundColor, string value) { } + + public override void WriteLine(string value) { } + + public override void WriteProgress(long sourceId, ProgressRecord record) { } + + public override void WriteVerboseLine(string message) { } + + public override void WriteWarningLine(string message) { } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs b/src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs new file mode 100644 index 0000000..517c482 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs @@ -0,0 +1,1528 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Management.Automation.Host; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Hosting; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Console; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Context; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Debugging; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Host +{ + using System.Management.Automation; + using System.Management.Automation.Runspaces; + // NOTE: These last three are for a workaround for temporary Extension Terminals. + using Microsoft.PowerShell.EditorServices.Handlers; + using Microsoft.PowerShell.EditorServices.Server; + using OmniSharp.Extensions.DebugAdapter.Protocol.Server; + + internal class PsesInternalHost : PSHost, IHostSupportsInteractiveSession, IRunspaceContext, IInternalPowerShellExecutionService + { + internal const string DefaultPrompt = "> "; + + private static readonly PSCommand s_promptCommand = new PSCommand().AddCommand("prompt"); + + private static readonly PropertyInfo s_scriptDebuggerTriggerObjectProperty; + + /// + /// To workaround a horrid bug where the `TranscribeOnly` field of the PSHostUserInterface + /// can accidentally remain true, we have to use a bunch of reflection so that can reset it to false. (This was fixed in PowerShell + /// 7.) Note that it must be the internal UI instance, not our own UI instance, otherwise + /// this would be easier. Because of the amount of reflection involved, we contain it to + /// only PowerShell 5.1 at compile-time, and we have to set this up in this class, not because that's templated, making statics practically + /// useless. method calls when necessary. + /// See: https://github.com/PowerShell/PowerShell/pull/3436 + /// + [ThreadStatic] // Because we can re-use it, but only once per instance of PSES. + private static PSHostUserInterface s_internalPSHostUserInterface; + + private static readonly Func s_getTranscribeOnlyDelegate; + + private static readonly Action s_setTranscribeOnlyDelegate; + + private static readonly PropertyInfo s_executionContextProperty; + + private static readonly PropertyInfo s_internalHostProperty; + + private readonly ILoggerFactory _loggerFactory; + + private readonly ILogger _logger; + + private readonly ILanguageServerFacade _languageServer; + + /// + /// TODO: Improve this coupling. It's assigned by + /// so that the PowerShell process started when + /// is true can also receive the required 'sendKeyPress' notification to return from a + /// canceled . + /// + internal IDebugAdapterServerFacade DebugServer; + + private readonly HostStartupInfo _hostInfo; + + private readonly BlockingConcurrentDeque _taskQueue; + + private readonly Stack _psFrameStack; + + private readonly Stack _runspaceStack; + + private readonly CancellationContext _cancellationContext; + + internal readonly ReadLineProvider _readLineProvider; + + private readonly Thread _pipelineThread; + + private readonly IdempotentLatch _isRunningLatch = new(); + + private readonly TaskCompletionSource _started = new(); + + private readonly TaskCompletionSource _stopped = new(); + + private EngineIntrinsics _mainRunspaceEngineIntrinsics; + + private bool _shouldExit; + + private int _shuttingDown; + + private string _localComputerName; + + private bool _shellIntegrationEnabled; + + private ConsoleKeyInfo? _lastKey; + + private bool _skipNextPrompt; + + private CancellationToken _readKeyCancellationToken; + + private bool _resettingRunspace; + + static PsesInternalHost() + { + Type scriptDebuggerType = typeof(PSObject).Assembly + .GetType("System.Management.Automation.ScriptDebugger"); + + if (scriptDebuggerType is null) + { + return; + } + + s_scriptDebuggerTriggerObjectProperty = scriptDebuggerType.GetProperty( + "TriggerObject", + BindingFlags.Instance | BindingFlags.NonPublic); + + if (VersionUtils.IsNetCore) + { + // The following reflection methods are only needed for the .NET Framework. + return; + } + + PropertyInfo transcribeOnlyProperty = typeof(PSHostUserInterface) + .GetProperty("TranscribeOnly", BindingFlags.NonPublic | BindingFlags.Instance); + + MethodInfo transcribeOnlyGetMethod = transcribeOnlyProperty.GetGetMethod(nonPublic: true); + + s_getTranscribeOnlyDelegate = (Func)Delegate.CreateDelegate( + typeof(Func), transcribeOnlyGetMethod); + + MethodInfo transcribeOnlySetMethod = transcribeOnlyProperty.GetSetMethod(nonPublic: true); + + s_setTranscribeOnlyDelegate = (Action)Delegate.CreateDelegate( + typeof(Action), transcribeOnlySetMethod); + + s_executionContextProperty = typeof(Runspace) + .GetProperty("ExecutionContext", BindingFlags.NonPublic | BindingFlags.Instance); + + s_internalHostProperty = s_executionContextProperty.PropertyType + .GetProperty("InternalHost", BindingFlags.NonPublic | BindingFlags.Instance); + } + + public PsesInternalHost( + ILoggerFactory loggerFactory, + ILanguageServerFacade languageServer, + HostStartupInfo hostInfo) + { + _loggerFactory = loggerFactory; + _logger = loggerFactory.CreateLogger(); + _languageServer = languageServer; + _hostInfo = hostInfo; + _readLineProvider = new ReadLineProvider(loggerFactory); + _taskQueue = new BlockingConcurrentDeque(); + _psFrameStack = new Stack(); + _runspaceStack = new Stack(); + _cancellationContext = new CancellationContext(); + + // Default stack size on .NET Framework is 524288 (512KB) (as reported by GetProcessDefaultStackSize) + // this leaves very little room in the stack. Windows PowerShell internally sets the value based on + // PipelineMaxStackSizeMB as seen here: https://github.com/PowerShell/PowerShell/issues/1187, + // which has default of 10 and multiplies that by 1_000_000, so the default stack size is + // 10_000_000 (~10MB) when starting in normal console host. + // + // For PS7 the value is ignored by .NET because settings the stack size is not supported, but we can + // still provide 0, which means fallback to the default in both .NET and .NET Framework. + int maxStackSize = VersionUtils.IsPS5 ? 10_000_000 : 0; + _pipelineThread = new Thread(Run, maxStackSize) + { + Name = "PSES Pipeline Execution Thread", + }; + + if (VersionUtils.IsWindows) + { + _pipelineThread.SetApartmentState(ApartmentState.STA); + } + + PublicHost = new EditorServicesConsolePSHost(this); + Name = hostInfo.Name; + Version = hostInfo.Version; + + DebugContext = new PowerShellDebugContext(loggerFactory, this); + UI = hostInfo.UseNullPSHostUI + ? new NullPSHostUI() + : new EditorServicesConsolePSHostUserInterface(loggerFactory, hostInfo.PSHost.UI); + } + + public override CultureInfo CurrentCulture => _hostInfo.PSHost.CurrentCulture; + + public override CultureInfo CurrentUICulture => _hostInfo.PSHost.CurrentUICulture; + + public override Guid InstanceId { get; } = Guid.NewGuid(); + + public override string Name { get; } + + public override PSObject PrivateData => _hostInfo.PSHost.PrivateData; + + public override PSHostUserInterface UI { get; } + + public override Version Version { get; } + + public bool IsRunspacePushed { get; private set; } + + public Runspace Runspace => _runspaceStack.Peek().Runspace; + + public RunspaceInfo CurrentRunspace => CurrentFrame.RunspaceInfo; + + public PowerShell CurrentPowerShell => CurrentFrame.PowerShell; + + public EditorServicesConsolePSHost PublicHost { get; } + + public PowerShellDebugContext DebugContext { get; } + + public bool IsRunning => _isRunningLatch.IsSignaled; + + public Task Shutdown => _stopped.Task; + + IRunspaceInfo IRunspaceContext.CurrentRunspace => CurrentRunspace; + + internal PowerShellContextFrame CurrentFrame => _psFrameStack.Peek(); + + public event Action RunspaceChanged; + + private bool ShouldExitExecutionLoop => _shouldExit || _shuttingDown != 0; + + public override void EnterNestedPrompt() => PushPowerShellAndRunLoop( + CreateNestedPowerShell(CurrentRunspace), + PowerShellFrameType.Nested | PowerShellFrameType.Repl); + + public override void ExitNestedPrompt() => SetExit(); + + public override void NotifyBeginApplication() => _hostInfo.PSHost.NotifyBeginApplication(); + + public override void NotifyEndApplication() => _hostInfo.PSHost.NotifyEndApplication(); + + public void PopRunspace() + { + if (!Runspace.RunspaceIsRemote) + { + return; + } + + IsRunspacePushed = false; + CurrentFrame.SessionExiting = true; + PopPowerShell(); + SetExit(); + } + + public void PushRunspace(Runspace runspace) + { + IsRunspacePushed = true; + PushPowerShellAndMaybeRunLoop( + CreatePowerShellForRunspace(runspace), + PowerShellFrameType.Remote | PowerShellFrameType.Repl, + skipRunLoop: true); + } + + // TODO: Handle exit code if needed + public override void SetShouldExit(int exitCode) + { + if (CurrentFrame.IsRemote) + { + // PopRunspace also calls SetExit. + PopRunspace(); + return; + } + + SetExit(); + } + + /// + /// Try to start the PowerShell loop in the host. + /// If the host is already started, this is idempotent. + /// Returns when the host is in a valid initialized state. + /// + /// Options to configure host startup. + /// A token to cancel startup. + /// A task that resolves when the host has finished startup, with the value true if the caller started the host, and false otherwise. + public async Task TryStartAsync(HostStartOptions startOptions, CancellationToken cancellationToken) + { + _logger.LogDebug("Starting host..."); + if (!_isRunningLatch.TryEnter()) + { + _logger.LogDebug("Host start requested after already started."); + await _started.Task.ConfigureAwait(false); + return false; + } + + _pipelineThread.Start(); + + if (startOptions.InitialWorkingDirectory is not null) + { + _logger.LogDebug($"Setting InitialWorkingDirectory to {startOptions.InitialWorkingDirectory}..."); + await SetInitialWorkingDirectoryAsync(startOptions.InitialWorkingDirectory, cancellationToken).ConfigureAwait(false); + _logger.LogDebug("InitialWorkingDirectory set!"); + } + + if (startOptions.LoadProfiles) + { + _logger.LogDebug("Loading profiles..."); + await LoadHostProfilesAsync(cancellationToken).ConfigureAwait(false); + _logger.LogDebug("Profiles loaded!"); + } + + if (!string.IsNullOrEmpty(startOptions.ShellIntegrationScript)) + { + _logger.LogDebug("Enabling Terminal Shell Integration..."); + _shellIntegrationEnabled = true; + string sourceMethod = startOptions.ShellIntegrationScript.EndsWith(".ps1") ? "." : "Import-Module"; + // TODO: Make the __psEditorServices prefix shared (it's used elsewhere too). + string setupShellIntegration = $$""" + # Setup Terminal Shell Integration. + + # Define a fake PSConsoleHostReadLine so the integration script's wrapper + # can execute it to get the user's input. + $global:__psEditorServices_userInput = ""; + function global:PSConsoleHostReadLine { $global:__psEditorServices_userInput } + + # Execute the provided shell integration script. + try { {{sourceMethod}} '{{startOptions.ShellIntegrationScript}}' } catch {} + """; + await EnableShellIntegrationAsync(setupShellIntegration, cancellationToken).ConfigureAwait(false); + _logger.LogDebug("Shell integration enabled!"); + } + else + { + _logger.LogDebug("Terminal Shell Integration not enabled!"); + } + + await _started.Task.ConfigureAwait(false); + return true; + } + + public Task StopAsync() + { + TriggerShutdown(); + return Shutdown; + } + + public void TriggerShutdown() + { + _logger.LogDebug("Shutting down host..."); + if (Interlocked.Exchange(ref _shuttingDown, 1) == 0) + { + _cancellationContext.CancelCurrentTaskStack(); + } + } + + public void SetExit() + { + // Can't exit from the top level of PSES + // since if you do, you lose all LSP services + PowerShellContextFrame frame = CurrentFrame; + if (!frame.IsRepl || _psFrameStack.Count <= 1) + { + return; + } + + _shouldExit = true; + } + + internal void ForceSetExit() => _shouldExit = true; + + private void SetBusy(bool busy) => _languageServer?.SendNotification("powerShell/executionBusyStatus", busy); + + private bool CancelForegroundAndPrepend(ISynchronousTask task, bool isIdle = false) + { + // NOTE: This causes foreground tasks to act like they have `ExecutionPriority.Next`. + // + // When a task must displace the current foreground command, + // we must: + // - block the consumer thread from mutating the queue + // - cancel any running task on the consumer thread + // - place our task on the front of the queue + // - skip the next prompt so the task runs instead + // - unblock the consumer thread + if (!task.ExecutionOptions.RequiresForeground) + { + return false; + } + + _skipNextPrompt = true; + + if (task is ISynchronousPowerShellTask t) + { + t.MaybeAddToHistory(); + } + + using (_taskQueue.BlockConsumers()) + { + _taskQueue.Prepend(task); + if (isIdle) + { + CancelIdleParentTask(); + } + else + { + CancelCurrentTask(); + } + } + + return true; + } + + // This handles executing the task while also notifying the client that the pipeline is + // currently busy with a PowerShell task. The extension indicates this with a spinner. + private void ExecuteTaskSynchronously(ISynchronousTask task, CancellationToken cancellationToken) + { + // TODO: Simplify this logic. + bool busy = false; + if (task is ISynchronousPowerShellTask t + && (t.PowerShellExecutionOptions.AddToHistory + || t.PowerShellExecutionOptions.FromRepl)) + { + busy = true; + SetBusy(true); + } + try + { + task.ExecuteSynchronously(cancellationToken); + } + finally + { + if (busy) + { + SetBusy(false); + } + } + } + + public Task InvokeTaskOnPipelineThreadAsync(SynchronousTask task) + { + if (CancelForegroundAndPrepend(task)) + { + return task.Task; + } + + switch (task.ExecutionOptions.Priority) + { + case ExecutionPriority.Next: + _taskQueue.Prepend(task); + break; + + case ExecutionPriority.Normal: + _taskQueue.Append(task); + break; + } + + return task.Task; + } + + public void CancelCurrentTask() => _cancellationContext.CancelCurrentTask(); + + public void CancelIdleParentTask() => _cancellationContext.CancelIdleParentTask(); + + public void UnwindCallStack() => _cancellationContext.CancelCurrentTaskStack(); + + public Task ExecuteDelegateAsync( + string representation, + ExecutionOptions executionOptions, + Func func, + CancellationToken cancellationToken) + { + return InvokeTaskOnPipelineThreadAsync( + new SynchronousPSDelegateTask(_logger, this, representation, executionOptions, func, cancellationToken)); + } + + public Task ExecuteDelegateAsync( + string representation, + ExecutionOptions executionOptions, + Action action, + CancellationToken cancellationToken) + { + return InvokeTaskOnPipelineThreadAsync( + new SynchronousPSDelegateTask(_logger, this, representation, executionOptions, action, cancellationToken)); + } + + public Task ExecuteDelegateAsync( + string representation, + ExecutionOptions executionOptions, + Func func, + CancellationToken cancellationToken) + { + return InvokeTaskOnPipelineThreadAsync( + new SynchronousDelegateTask(_logger, representation, executionOptions, func, cancellationToken)); + } + + public Task ExecuteDelegateAsync( + string representation, + ExecutionOptions executionOptions, + Action action, + CancellationToken cancellationToken) + { + return InvokeTaskOnPipelineThreadAsync( + new SynchronousDelegateTask(_logger, representation, executionOptions, action, cancellationToken)); + } + + // TODO: One day fix these so the cancellation token is last. + public Task> ExecutePSCommandAsync( + PSCommand psCommand, + CancellationToken cancellationToken, + PowerShellExecutionOptions executionOptions = null) + { + return InvokeTaskOnPipelineThreadAsync( + new SynchronousPowerShellTask(_logger, this, psCommand, executionOptions, cancellationToken)); + } + + public Task ExecutePSCommandAsync( + PSCommand psCommand, + CancellationToken cancellationToken, + PowerShellExecutionOptions executionOptions = null) => ExecutePSCommandAsync(psCommand, cancellationToken, executionOptions); + + public TResult InvokeDelegate(string representation, ExecutionOptions executionOptions, Func func, CancellationToken cancellationToken) + { + SynchronousDelegateTask task = new(_logger, representation, executionOptions, func, cancellationToken); + return task.ExecuteAndGetResult(cancellationToken); + } + + public void InvokeDelegate(string representation, ExecutionOptions executionOptions, Action action, CancellationToken cancellationToken) + { + SynchronousDelegateTask task = new(_logger, representation, executionOptions, action, cancellationToken); + task.ExecuteAndGetResult(cancellationToken); + } + + public IReadOnlyList InvokePSCommand(PSCommand psCommand, PowerShellExecutionOptions executionOptions, CancellationToken cancellationToken) + { + SynchronousPowerShellTask task = new(_logger, this, psCommand, executionOptions, cancellationToken); + return task.ExecuteAndGetResult(cancellationToken); + } + + public void InvokePSCommand(PSCommand psCommand, PowerShellExecutionOptions executionOptions, CancellationToken cancellationToken) => InvokePSCommand(psCommand, executionOptions, cancellationToken); + + public TResult InvokePSDelegate(string representation, ExecutionOptions executionOptions, Func func, CancellationToken cancellationToken) + { + SynchronousPSDelegateTask task = new(_logger, this, representation, executionOptions, func, cancellationToken); + return task.ExecuteAndGetResult(cancellationToken); + } + + public void InvokePSDelegate(string representation, ExecutionOptions executionOptions, Action action, CancellationToken cancellationToken) + { + SynchronousPSDelegateTask task = new(_logger, this, representation, executionOptions, action, cancellationToken); + task.ExecuteAndGetResult(cancellationToken); + } + + internal void AddToHistory(string historyEntry) => _readLineProvider.ReadLine.AddToHistory(historyEntry); + + // This works around a bug in PowerShell 5.1 (that was later fixed) where a running + // transcription could cause output to disappear since the `TranscribeOnly` property was + // accidentally not reset to false. + internal void DisableTranscribeOnly() + { + if (VersionUtils.IsNetCore) + { + return; + } + + // To fix the TranscribeOnly bug, we have to get the internal UI, which involves a lot + // of reflection since we can't always just use PowerShell to execute `$Host.UI`. + s_internalPSHostUserInterface ??= + (s_internalHostProperty.GetValue( + s_executionContextProperty.GetValue(CurrentPowerShell.Runspace)) + as PSHost)?.UI; + + if (s_internalPSHostUserInterface is null) + { + return; + } + + if (s_getTranscribeOnlyDelegate(s_internalPSHostUserInterface)) + { + s_setTranscribeOnlyDelegate(s_internalPSHostUserInterface, false); + } + } + + internal Task LoadHostProfilesAsync(CancellationToken cancellationToken) + { + // NOTE: This is a special task run on startup! + return ExecuteDelegateAsync( + "LoadProfiles", + executionOptions: null, + (pwsh, _) => pwsh.LoadProfiles(_hostInfo.ProfilePaths), + cancellationToken); + } + + private Task EnableShellIntegrationAsync(string shellIntegrationScript, CancellationToken cancellationToken) + { + return ExecutePSCommandAsync( + new PSCommand().AddScript(shellIntegrationScript), + cancellationToken, + new PowerShellExecutionOptions { AddToHistory = false, ThrowOnError = false }); + } + + public Task SetInitialWorkingDirectoryAsync(string path, CancellationToken cancellationToken) + { + return Directory.Exists(path) + ? ExecutePSCommandAsync( + new PSCommand().AddCommand("Set-Location").AddParameter("LiteralPath", path), + cancellationToken) + : Task.CompletedTask; + } + + private void Run() + { + try + { + (PowerShell pwsh, RunspaceInfo localRunspaceInfo, EngineIntrinsics engineIntrinsics) = CreateInitialPowerShellSession(); + _mainRunspaceEngineIntrinsics = engineIntrinsics; + _localComputerName = localRunspaceInfo.SessionDetails.ComputerName; + _runspaceStack.Push(new RunspaceFrame(pwsh.Runspace, localRunspaceInfo)); + PushPowerShellAndRunLoop(pwsh, PowerShellFrameType.Normal | PowerShellFrameType.Repl, localRunspaceInfo); + } + catch (Exception e) + { + _started.TrySetException(e); + _stopped.TrySetException(e); + } + } + + private (PowerShell, RunspaceInfo, EngineIntrinsics) CreateInitialPowerShellSession() + { + (PowerShell pwsh, EngineIntrinsics engineIntrinsics) = CreateInitialPowerShell(_hostInfo, _readLineProvider); + RunspaceInfo localRunspaceInfo = RunspaceInfo.CreateFromLocalPowerShell(_logger, pwsh); + return (pwsh, localRunspaceInfo, engineIntrinsics); + } + + internal PowerShellContextFrame PushPowerShellForExecution() + { + PowerShellContextFrame frame = CurrentFrame; + PowerShellFrameType currentFrameType = frame.FrameType; + currentFrameType &= ~PowerShellFrameType.Repl; + PowerShellContextFrame newFrame = new( + frame.PowerShell.CloneForNewFrame(), + frame.RunspaceInfo, + currentFrameType); + + PushPowerShell(newFrame); + return newFrame; + } + + private void PushPowerShellAndRunLoop(PowerShell pwsh, PowerShellFrameType frameType, RunspaceInfo newRunspaceInfo = null) + => PushPowerShellAndMaybeRunLoop(pwsh, frameType, newRunspaceInfo, skipRunLoop: false); + + private void PushPowerShellAndMaybeRunLoop( + PowerShell pwsh, + PowerShellFrameType frameType, + RunspaceInfo newRunspaceInfo = null, + bool skipRunLoop = false) + { + // TODO: Improve runspace origin detection here + if (newRunspaceInfo is null) + { + newRunspaceInfo = GetRunspaceInfoForPowerShell(pwsh, out bool isNewRunspace, out RunspaceFrame oldRunspaceFrame); + + if (isNewRunspace) + { + Runspace newRunspace = pwsh.Runspace; + _runspaceStack.Push(new RunspaceFrame(newRunspace, newRunspaceInfo)); + RunspaceChanged.Invoke(this, new RunspaceChangedEventArgs(RunspaceChangeAction.Enter, oldRunspaceFrame.RunspaceInfo, newRunspaceInfo)); + } + } + + PushPowerShellAndMaybeRunLoop(new PowerShellContextFrame(pwsh, newRunspaceInfo, frameType), skipRunLoop); + } + + private RunspaceInfo GetRunspaceInfoForPowerShell(PowerShell pwsh, out bool isNewRunspace, out RunspaceFrame oldRunspaceFrame) + { + oldRunspaceFrame = null; + + if (_runspaceStack.Count > 0) + { + // This is more than just an optimization. + // When debugging, we cannot execute PowerShell directly to get this information; + // trying to do so will block on the command that called us, deadlocking execution. + // Instead, since we are reusing the runspace, we reuse that runspace's info as well. + oldRunspaceFrame = _runspaceStack.Peek(); + if (oldRunspaceFrame.Runspace == pwsh.Runspace) + { + isNewRunspace = false; + return oldRunspaceFrame.RunspaceInfo; + } + } + + isNewRunspace = true; + return RunspaceInfo.CreateFromPowerShell(_logger, pwsh, _localComputerName); + } + + private void PushPowerShellAndMaybeRunLoop(PowerShellContextFrame frame, bool skipRunLoop = false) + { + PushPowerShell(frame); + if (skipRunLoop) + { + return; + } + + try + { + if (_psFrameStack.Count == 1) + { + RunTopLevelExecutionLoop(); + } + else if (frame.IsDebug) + { + RunDebugExecutionLoop(); + } + else + { + RunExecutionLoop(); + } + } + finally + { + if (CurrentFrame != frame) + { + frame.IsAwaitingPop = true; + } + else + { + PopPowerShell(); + } + } + } + + private void PushPowerShell(PowerShellContextFrame frame) + { + if (_psFrameStack.Count > 0) + { + if (frame.PowerShell.Runspace == CurrentFrame.PowerShell.Runspace) + { + _psFrameStack.Push(frame); + return; + } + + RemoveRunspaceEventHandlers(CurrentFrame.PowerShell.Runspace); + } + + AddRunspaceEventHandlers(frame.PowerShell.Runspace); + + _psFrameStack.Push(frame); + } + + internal void PopPowerShellForExecution(PowerShellContextFrame expectedFrame) + { + if (CurrentFrame != expectedFrame) + { + expectedFrame.IsAwaitingPop = true; + return; + } + + PopPowerShellImpl(); + } + + private void PopPowerShell(RunspaceChangeAction runspaceChangeAction = RunspaceChangeAction.Exit) + { + _shouldExit = false; + PopPowerShellImpl(_ => + { + // If we're changing runspace, make sure we move the handlers over. If we just + // popped the last frame, then we're exiting and should pop the runspace too. + if (_psFrameStack.Count == 0 || Runspace != CurrentPowerShell.Runspace) + { + RunspaceFrame previousRunspaceFrame = _runspaceStack.Pop(); + RemoveRunspaceEventHandlers(previousRunspaceFrame.Runspace); + + // If there is still a runspace on the stack, then we need to re-register the + // handlers. Otherwise we're exiting and so don't need to run 'RunspaceChanged'. + if (_runspaceStack.Count > 0) + { + RunspaceFrame newRunspaceFrame = _runspaceStack.Peek(); + AddRunspaceEventHandlers(newRunspaceFrame.Runspace); + RunspaceChanged?.Invoke( + this, + new RunspaceChangedEventArgs( + runspaceChangeAction, + previousRunspaceFrame.RunspaceInfo, + newRunspaceFrame.RunspaceInfo)); + } + } + }); + } + + private void PopPowerShellImpl(Action action = null) + { + do + { + PowerShellContextFrame frame = _psFrameStack.Pop(); + try + { + action?.Invoke(frame); + } + finally + { + frame.Dispose(); + } + } + while (_psFrameStack.Count > 0 && CurrentFrame.IsAwaitingPop); + } + + private void RunTopLevelExecutionLoop() + { + try + { + // Make sure we execute any startup tasks first. These should be, in order: + // 1. Delegate to register psEditor variable + // 2. LoadProfiles delegate + // 3. Delegate to import PSEditModule + while (_taskQueue.TryTake(out ISynchronousTask task)) + { + task.ExecuteSynchronously(CancellationToken.None); + } + + // Signal that we are ready for outside services to use + _started.TrySetResult(true); + + // While loop is purely so we can recover gracefully from a + // terminate exception. + while (true) + { + try + { + RunExecutionLoop(); + break; + } + catch (TerminateException) + { + // Do nothing, since we are at the top level of the loop + // the call stack has been unwound successfully. + } + } + } + catch (Exception e) + { + _logger.LogError(e, "PSES pipeline thread loop experienced an unexpected top-level exception"); + _stopped.TrySetException(e); + return; + } + + _logger.LogDebug("PSES pipeline thread loop shutting down"); + _stopped.SetResult(true); + } + + private void RunDebugExecutionLoop() + { + try + { + DebugContext.EnterDebugLoop(); + RunExecutionLoop(isForDebug: true); + } + finally + { + DebugContext.ExitDebugLoop(); + } + } + + private void RunExecutionLoop(bool isForDebug = false) + { + Runspace initialRunspace = Runspace; + while (!ShouldExitExecutionLoop) + { + if (isForDebug && !initialRunspace.RunspaceStateInfo.IsUsable()) + { + return; + } + + using CancellationScope cancellationScope = _cancellationContext.EnterScope(false); + + try + { + DoOneRepl(cancellationScope.CancellationToken); + } + catch (OperationCanceledException) + { + if (isForDebug) + { + while (Runspace is { RunspaceIsRemote: true } remoteRunspace + && !remoteRunspace.RunspaceStateInfo.IsUsable()) + { + PopPowerShell(RunspaceChangeAction.Exit); + } + + if (ShouldExitExecutionLoop) + { + return; + } + } + } + + while (!ShouldExitExecutionLoop + && !cancellationScope.CancellationToken.IsCancellationRequested + && _taskQueue.TryTake(out ISynchronousTask task)) + { + try + { + ExecuteTaskSynchronously(task, cancellationScope.CancellationToken); + } + // Our flaky extension command test seems to be such because sometimes another + // task gets queued, and since it runs in the foreground it cancels that task. + // Interactively, this happens in the first loop (with DoOneRepl) which catches + // the cancellation exception, but when under test that is a no-op, so it + // happens in this second loop. Hence we need to catch it here too. + catch (OperationCanceledException e) + { + _logger.LogDebug(e, "Task {Task} was canceled!", task); + } + } + + if (_shouldExit + && CurrentFrame is { IsRemote: true, IsRepl: true, IsNested: false }) + { + _shouldExit = false; + PopPowerShell(); + } + } + } + + private void DoOneRepl(CancellationToken cancellationToken) + { + if (!_hostInfo.ConsoleReplEnabled) + { + // Throttle the REPL loop with a sleep because we're not interactively reading input from the user. + Thread.Sleep(100); + return; + } + + // TODO: We must remove this awful logic, it causes so much pain. The StopDebugContext() + // requires that we're not in a prompt that we're skipping, otherwise the debugger is + // "active" but we haven't yet hit a breakpoint. + // + // When a task must run in the foreground, we cancel out of the idle loop and return to + // the top level. At that point, we would normally run a REPL, but we need to + // immediately execute the task. So we set _skipNextPrompt to do that. + if (_skipNextPrompt) + { + _skipNextPrompt = false; + return; + } + + // We use the REPL as a poll to check if the debug context is active but PowerShell + // indicates we're no longer debugging. This happens when PowerShell was used to start + // the debugger (instead of using a Code launch configuration) via Wait-Debugger or + // simply hitting a PSBreakpoint. We need to synchronize the state and stop the debug + // context (and likely the debug server). + if (!DebugContext.IsDebuggingRemoteRunspace + && DebugContext.IsActive + && !CurrentRunspace.Runspace.Debugger.InBreakpoint) + { + StopDebugContext(); + } + + try + { + string prompt = GetPrompt(cancellationToken); + UI.Write(prompt); + string userInput = InvokeReadLine(cancellationToken); + + // If the user input was empty it's because: + // - the user provided no input + // - the ReadLine task was canceled + // - CtrlC was sent to ReadLine (which does not propagate a cancellation) + // + // In any event there's nothing to run in PowerShell, so we just loop back to the + // prompt again. However, PSReadLine will not print a newline for CtrlC, so we print + // one, but we do not want to print one if the ReadLine task was canceled. + if (string.IsNullOrEmpty(userInput)) + { + if (cancellationToken.IsCancellationRequested || LastKeyWasCtrlC()) + { + UI.WriteLine(); + } + // Propagate cancellation if that's what happened, since ReadLine won't. + // TODO: We may not need to do this at all. + cancellationToken.ThrowIfCancellationRequested(); + return; // Task wasn't canceled but there was no input. + } + + InvokeInput(userInput, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + // Propagate exceptions thrown from the debugger when quitting. + catch (TerminateException) + { + throw; + } + // Do nothing, a break or continue statement was used outside of a loop. + catch (FlowControlException) { } + catch (Exception e) + { + UI.WriteErrorLine($"An error occurred while running the REPL loop:{Environment.NewLine}{e}"); + _logger.LogError(e, "An error occurred while running the REPL loop"); + } + finally + { + // At the end of each REPL we need to complete all progress records so that the + // progress indicator is cleared. + if (UI is EditorServicesConsolePSHostUserInterface ui) + { + ui.ResetProgress(); + } + } + } + + internal string GetPrompt(CancellationToken cancellationToken) + { + Runspace.ThrowCancelledIfUnusable(); + string prompt = DefaultPrompt; + try + { + IReadOnlyList results = InvokePSCommand( + s_promptCommand, + executionOptions: new PowerShellExecutionOptions { ThrowOnError = false }, + cancellationToken); + + if (results?.Count > 0) + { + prompt = results[0]; + } + } + catch (RuntimeException) { } // Use default prompt + + if (CurrentRunspace.RunspaceOrigin != RunspaceOrigin.Local) + { + // This is a PowerShell-internal method that we reuse to decorate the prompt string + // with the remote details when remoting, + // so the prompt changes to indicate when you're in a remote session + prompt = Runspace.GetRemotePrompt(prompt); + } + + return prompt; + } + + /// + /// This is used to write the invocation text of a command with the user's prompt so that, + /// for example, F8 (evaluate selection) appears as if the user typed it. Used when + /// 'WriteInputToHost' is true. + /// + /// The PSCommand we'll print after the prompt. + /// + public void WriteWithPrompt(PSCommand command, CancellationToken cancellationToken) + { + UI.Write(GetPrompt(cancellationToken)); + UI.WriteLine(command.GetInvocationText()); + } + + private string InvokeReadLine(CancellationToken cancellationToken) + { + try + { + // TODO: If we can pass the cancellation token to ReadKey directly in PSReadLine, we + // can remove this logic. + _readKeyCancellationToken = cancellationToken; + cancellationToken.ThrowIfCancellationRequested(); + return _readLineProvider.ReadLine.ReadLine(cancellationToken); + } + finally + { + _readKeyCancellationToken = CancellationToken.None; + } + } + + // TODO: Should we actually be directly invoking input versus queueing it as a task like everything else? + private void InvokeInput(string input, CancellationToken cancellationToken) + { + SetBusy(true); + + try + { + // For the terminal shell integration feature, we call PSConsoleHostReadLine specially as it's been wrapped. + // Normally it would not be available (since we wrap ReadLine ourselves), + // but in this case we've made the original just emit the user's input so that the wrapper works as intended. + if (_shellIntegrationEnabled) + { + // Save the user's input to our special global variable so PSConsoleHostReadLine can read it. + InvokePSCommand( + new PSCommand().AddScript("$global:__psEditorServices_userInput = $args[0]").AddArgument(input), + new PowerShellExecutionOptions { ThrowOnError = false, WriteOutputToHost = false }, + cancellationToken); + + // Invoke the PSConsoleHostReadLine wrapper. We don't write the output because it + // returns the command line (user input) which would then be duplicate noise. Fortunately + // it writes the shell integration sequences directly using [Console]::Write. + InvokePSCommand( + new PSCommand().AddScript("PSConsoleHostReadLine"), + new PowerShellExecutionOptions { ThrowOnError = false, WriteOutputToHost = false }, + cancellationToken); + + // Reset our global variable. + InvokePSCommand( + new PSCommand().AddScript("$global:__psEditorServices_userInput = \"\""), + new PowerShellExecutionOptions { ThrowOnError = false, WriteOutputToHost = false }, + cancellationToken); + } + + InvokePSCommand( + new PSCommand().AddScript(input), + new PowerShellExecutionOptions + { + AddToHistory = true, + ThrowOnError = false, + WriteOutputToHost = true, + FromRepl = true, + }, + cancellationToken); + } + finally + { + SetBusy(false); + } + } + + private void AddRunspaceEventHandlers(Runspace runspace) + { + runspace.Debugger.DebuggerStop += OnDebuggerStopped; + runspace.Debugger.BreakpointUpdated += OnBreakpointUpdated; + runspace.StateChanged += OnRunspaceStateChanged; + } + + private void RemoveRunspaceEventHandlers(Runspace runspace) + { + runspace.Debugger.DebuggerStop -= OnDebuggerStopped; + runspace.Debugger.BreakpointUpdated -= OnBreakpointUpdated; + runspace.StateChanged -= OnRunspaceStateChanged; + } + + private static PowerShell CreateNestedPowerShell(RunspaceInfo currentRunspace) + { + if (currentRunspace.RunspaceOrigin != RunspaceOrigin.Local) + { + return CreatePowerShellForRunspace(currentRunspace.Runspace); + } + + // PowerShell.CreateNestedPowerShell() sets IsNested but not IsChild + // This means it throws due to the parent pipeline not running... + // So we must use the RunspaceMode.CurrentRunspace option on PowerShell.Create() instead + PowerShell pwsh = PowerShell.Create(RunspaceMode.CurrentRunspace); + pwsh.Runspace.ThreadOptions = PSThreadOptions.UseCurrentThread; + return pwsh; + } + + private static PowerShell CreatePowerShellForRunspace(Runspace runspace) + { + PowerShell pwsh = PowerShell.Create(); + pwsh.Runspace = runspace; + return pwsh; + } + + private (PowerShell, EngineIntrinsics) CreateInitialPowerShell( + HostStartupInfo hostStartupInfo, + ReadLineProvider readLineProvider) + { + Runspace runspace = CreateInitialRunspace(hostStartupInfo.InitialSessionState); + PowerShell pwsh = CreatePowerShellForRunspace(runspace); + + EngineIntrinsics engineIntrinsics = (EngineIntrinsics)runspace.SessionStateProxy.GetVariable("ExecutionContext"); + + if (hostStartupInfo.ConsoleReplEnabled) + { + // If we've been configured to use it, or if we can't load PSReadLine, use the legacy readline + if (hostStartupInfo.UsesLegacyReadLine || !TryLoadPSReadLine(pwsh, engineIntrinsics, out IReadLine readLine)) + { + readLine = new LegacyReadLine(this, ReadKey, OnPowerShellIdle); + } + + readLineProvider.OverrideReadLine(readLine); + System.Console.CancelKeyPress += OnCancelKeyPress; + } + + if (VersionUtils.IsWindows) + { + pwsh.SetCorrectExecutionPolicy(_logger); + } + + string commandsModulePath = Path.Combine( + _hostInfo.BundledModulePath, + "PowerShellEditorServices", + "Commands", + "PowerShellEditorServices.Commands.psd1"); + + pwsh.ImportModule(commandsModulePath); + + if (hostStartupInfo.AdditionalModules?.Count > 0) + { + foreach (string module in hostStartupInfo.AdditionalModules) + { + pwsh.ImportModule(module); + } + } + + return (pwsh, engineIntrinsics); + } + + private Runspace CreateInitialRunspace(InitialSessionState initialSessionState) + { + Runspace runspace = RunspaceFactory.CreateRunspace(PublicHost, initialSessionState); + + runspace.SetApartmentStateToSta(); + runspace.ThreadOptions = PSThreadOptions.UseCurrentThread; + + runspace.Open(); + + Runspace.DefaultRunspace = runspace; + + return runspace; + } + + /// + /// This delegate is handed to PSReadLine and overrides similar logic within its `ReadKey` + /// method. Essentially we're replacing PowerShell's `OnIdle` handler since the PowerShell + /// engine isn't idle when we're sitting in PSReadLine's `ReadKey` loop. In our case we also + /// use this idle time to process queued tasks by executing those that can run in the + /// background, and canceling the foreground task if a queued tasks requires the foreground. + /// Finally, if and only if we have to, we run an artificial pipeline to force PowerShell's + /// own event processing. + /// + /// + /// This token is received from PSReadLine, and it is the ReadKey cancellation token! + /// + internal void OnPowerShellIdle(CancellationToken idleCancellationToken) + { + IReadOnlyList eventSubscribers = _mainRunspaceEngineIntrinsics.Events.Subscribers; + + // Go through pending event subscribers and: + // - if we have any subscribers, ensure we process any events + // - if we have any idle events, generate an idle event and process that + bool runPipelineForEventProcessing = false; + foreach (PSEventSubscriber subscriber in eventSubscribers) + { + runPipelineForEventProcessing = true; + + if (string.Equals(subscriber.SourceIdentifier, PSEngineEvent.OnIdle, StringComparison.OrdinalIgnoreCase)) + { + // We control the pipeline thread, so it's not possible for PowerShell to generate events while we're here. + // But we know we're sitting waiting for the prompt, so we generate the idle event ourselves + // and that will flush idle event subscribers in PowerShell so we can service them + _mainRunspaceEngineIntrinsics.Events.GenerateEvent(PSEngineEvent.OnIdle, sender: null, args: null, extraData: null); + break; + } + } + + if (!runPipelineForEventProcessing && _taskQueue.IsEmpty) + { + return; + } + + using (CancellationScope cancellationScope = _cancellationContext.EnterScope(isIdleScope: true, idleCancellationToken)) + { + while (!cancellationScope.CancellationToken.IsCancellationRequested + && _taskQueue.TryTake(out ISynchronousTask task)) + { + // Tasks which require the foreground cannot run under this idle handler, so the + // current foreground tasks gets canceled, the new task gets prepended, and this + // handler returns. + if (CancelForegroundAndPrepend(task, isIdle: true)) + { + return; + } + + // If we're executing a PowerShell task, we don't need to run an extra pipeline + // later for events. + if (task is ISynchronousPowerShellTask) + { + // We don't ever want to set this to true here, just skip if it had + // previously been set true. + runPipelineForEventProcessing = false; + } + ExecuteTaskSynchronously(task, cancellationScope.CancellationToken); + } + } + + // We didn't end up executing anything in the background, + // so we need to run a small artificial pipeline instead + // to force event processing. + if (runPipelineForEventProcessing) + { + InvokePSCommand( + new PSCommand().AddScript( + "[System.Diagnostics.DebuggerHidden()]param() 0", + useLocalScope: true), + executionOptions: null, + CancellationToken.None); + } + } + + private void OnCancelKeyPress(object sender, ConsoleCancelEventArgs args) + { + // We need to cancel the current task. + _cancellationContext.CancelCurrentTask(); + + // If the current task was running under the debugger, we need to synchronize the + // cancellation with our debug context (and likely the debug server). Note that if we're + // currently stopped in a breakpoint, that means the task is _not_ under the debugger. + if (!CurrentRunspace.Runspace.Debugger.InBreakpoint) + { + StopDebugContext(); + } + } + + private ConsoleKeyInfo ReadKey(bool intercept) + { + // NOTE: This requests that the client (the Code extension) send a non-printing key back + // to the terminal on stdin, emulating a user pressing a button. This allows + // PSReadLine's thread waiting on Console.ReadKey to return. Normally we'd just cancel + // this call, but the .NET API ReadKey is not cancellable, and is stuck until we send + // input. This leads to a myriad of problems, but we circumvent them by pretending to + // press a key, thus allowing ReadKey to return, and us to ignore it. + using CancellationTokenRegistration registration = _readKeyCancellationToken.Register( + () => + { + // For the regular Extension Terminal, we have an associated language server on + // which we can send a notification, and have the client subscribe an action to + // send a key press. + _languageServer?.SendNotification("powerShell/sendKeyPress"); + + // When temporary Extension Terminals are spawned, there will be no associated + // language server, but instead a debug adaptor server. In this case, the + // notification sent here will come across as a DebugSessionCustomEvent to which + // we can subscribe in the same way. + DebugServer?.SendNotification("powerShell/sendKeyPress"); + }); + + // PSReadLine doesn't tell us when CtrlC was sent. So instead we keep track of the last + // key here. This isn't functionally required, but helps us determine when the prompt + // needs a newline added + // + // TODO: We may want to allow users of PSES to override this method call. + _lastKey = System.Console.ReadKey(intercept); + return _lastKey.Value; + } + + internal ConsoleKeyInfo ReadKey(bool intercept, CancellationToken cancellationToken) + { + try + { + _readKeyCancellationToken = cancellationToken; + return ReadKey(intercept); + } + finally + { + _readKeyCancellationToken = CancellationToken.None; + } + } + + private bool LastKeyWasCtrlC() => _lastKey.HasValue && _lastKey.Value.IsCtrlC(); + + private void StopDebugContext() + { + // We are officially stopping the debugger. + DebugContext.IsActive = false; + + // If the debug server is active, we need to synchronize state and stop it. + if (DebugContext.IsDebugServerActive) + { + _languageServer?.SendNotification("powerShell/stopDebugger"); + } + } + + private readonly object _replFromAnotherThread = new(); + + internal void WaitForExternalDebuggerStops() + { + lock (_replFromAnotherThread) + { + } + } + + private void OnDebuggerStopped(object sender, DebuggerStopEventArgs debuggerStopEventArgs) + { + // If ErrorActionPreference is set to Break, any engine exception is going to trigger a + // pipeline stop. Technically this is the same behavior as a standalone PowerShell + // process, but we use pipeline stops with greater frequency due to features like run + // selection and terminating the debugger. Without this, if the "Stop" button is pressed + // then we hit this repeatedly. + // + // This info is publicly accessible via `PSDebugContext` but we'd need to access it + // via a script. At this point in the call I'd prefer this to be as light as possible so + // we can escape ASAP but we may want to consider switching to that at some point. + if (!Runspace.RunspaceIsRemote && s_scriptDebuggerTriggerObjectProperty is not null) + { + object triggerObject = null; + try + { + triggerObject = s_scriptDebuggerTriggerObjectProperty.GetValue(Runspace.Debugger); + } + catch + { + // Ignore all exceptions. There shouldn't be any, but as this is implementation + // detail that is subject to change it's best to be overly cautious. + } + + if (triggerObject is PipelineStoppedException pse) + { + throw pse; + } + } + + // The debugger has officially started. We use this to later check if we should stop it. + DebugContext.IsActive = true; + + // The local debugging architecture works mostly because we control the pipeline thread, + // but remote runspaces will trigger debugger stops on a separate thread. We lock here + // if we're on a different thread so in then event of a transport error, we can + // safely wind down REPL loops in a different thread. + bool isExternal = Environment.CurrentManagedThreadId != _pipelineThread.ManagedThreadId; + if (!isExternal) + { + OnDebuggerStoppedImpl(sender, debuggerStopEventArgs); + return; + } + + lock (_replFromAnotherThread) + { + OnDebuggerStoppedImpl(sender, debuggerStopEventArgs); + } + + void OnDebuggerStoppedImpl(object sender, DebuggerStopEventArgs debuggerStopEventArgs) + { + // If the debug server is NOT active, we need to synchronize state and start it. + if (!DebugContext.IsDebugServerActive) + { + _languageServer?.SendNotification("powerShell/startDebugger"); + } + + DebugContext.SetDebuggerStopped(debuggerStopEventArgs); + + try + { + CurrentPowerShell.WaitForRemoteOutputIfNeeded(); + PowerShellFrameType frameBase = CurrentFrame.FrameType & PowerShellFrameType.Remote; + PushPowerShellAndRunLoop( + CreateNestedPowerShell(CurrentRunspace), + frameBase | PowerShellFrameType.Debug | PowerShellFrameType.Nested | PowerShellFrameType.Repl); + CurrentPowerShell.ResumeRemoteOutputIfNeeded(); + } + finally + { + DebugContext.SetDebuggerResumed(); + } + } + } + + private void OnBreakpointUpdated(object sender, BreakpointUpdatedEventArgs breakpointUpdatedEventArgs) => DebugContext.HandleBreakpointUpdated(breakpointUpdatedEventArgs); + + private void OnRunspaceStateChanged(object sender, RunspaceStateEventArgs runspaceStateEventArgs) + { + if (!ShouldExitExecutionLoop && !_resettingRunspace && !runspaceStateEventArgs.RunspaceStateInfo.IsUsable()) + { + _resettingRunspace = true; + Task _ = PopOrReinitializeRunspaceAsync().HandleErrorsAsync(_logger); + } + } + + private Task PopOrReinitializeRunspaceAsync() + { + _cancellationContext.CancelCurrentTaskStack(); + RunspaceStateInfo oldRunspaceState = CurrentPowerShell.Runspace.RunspaceStateInfo; + + // Rather than try to lock the PowerShell executor while we alter its state, + // we simply run this on its thread, guaranteeing that no other action can occur + return ExecuteDelegateAsync( + nameof(PopOrReinitializeRunspaceAsync), + new ExecutionOptions { RequiresForeground = true }, + (_) => + { + while (_psFrameStack.Count > 0 + && !_psFrameStack.Peek().PowerShell.Runspace.RunspaceStateInfo.IsUsable()) + { + PopPowerShell(RunspaceChangeAction.Shutdown); + } + + _resettingRunspace = false; + + if (_psFrameStack.Count == 0) + { + // If our main runspace was corrupted, + // we must re-initialize our state. + // TODO: Use runspace.ResetRunspaceState() here instead + (PowerShell pwsh, RunspaceInfo runspaceInfo, EngineIntrinsics engineIntrinsics) = CreateInitialPowerShellSession(); + _mainRunspaceEngineIntrinsics = engineIntrinsics; + PushPowerShell(new PowerShellContextFrame(pwsh, runspaceInfo, PowerShellFrameType.Normal)); + + _logger.LogError($"Top level runspace entered state '{oldRunspaceState.State}' for reason '{oldRunspaceState.Reason}' and was reinitialized." + + " Please report this issue in the PowerShell/vscode-PowerShell GitHub repository with these logs."); + UI.WriteErrorLine("The main runspace encountered an error and has been reinitialized. See the PowerShell extension logs for more details."); + } + else + { + _logger.LogError($"Current runspace entered state '{oldRunspaceState.State}' for reason '{oldRunspaceState.Reason}' and was popped."); + UI.WriteErrorLine($"The current runspace entered state '{oldRunspaceState.State}' for reason '{oldRunspaceState.Reason}'." + + " If this occurred when using Ctrl+C in a Windows PowerShell remoting session, this is expected behavior." + + " The session is now returning to the previous runspace."); + } + }, + CancellationToken.None); + } + + internal bool TryLoadPSReadLine(PowerShell pwsh, EngineIntrinsics engineIntrinsics, out IReadLine psrlReadLine) + { + psrlReadLine = null; + try + { + PSReadLineProxy psrlProxy = PSReadLineProxy.LoadAndCreate(_loggerFactory, _hostInfo.BundledModulePath, pwsh); + psrlReadLine = new PsrlReadLine(psrlProxy, this, engineIntrinsics, ReadKey, OnPowerShellIdle); + return true; + } + catch (Exception e) + { + _logger.LogError(e, "Unable to load PSReadLine. Will fall back to legacy readline implementation."); + return false; + } + } + + private record RunspaceFrame( + Runspace Runspace, + RunspaceInfo RunspaceInfo); + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/IPowerShellExecutionService.cs b/src/PowerShellEditorServices/Services/PowerShell/IPowerShellExecutionService.cs new file mode 100644 index 0000000..31a7572 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/IPowerShellExecutionService.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace; +using System; +using System.Collections.Generic; +using System.Management.Automation; +using System.Threading; +using System.Threading.Tasks; +using SMA = System.Management.Automation; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell +{ + public interface IPowerShellExecutionService + { + Task ExecuteDelegateAsync( + string representation, + ExecutionOptions executionOptions, + Func func, + CancellationToken cancellationToken); + + Task ExecuteDelegateAsync( + string representation, + ExecutionOptions executionOptions, + Action action, + CancellationToken cancellationToken); + + Task ExecuteDelegateAsync( + string representation, + ExecutionOptions executionOptions, + Func func, + CancellationToken cancellationToken); + + Task ExecuteDelegateAsync( + string representation, + ExecutionOptions executionOptions, + Action action, + CancellationToken cancellationToken); + + Task> ExecutePSCommandAsync( + PSCommand psCommand, + CancellationToken cancellationToken, + PowerShellExecutionOptions executionOptions = null); + + Task ExecutePSCommandAsync( + PSCommand psCommand, + CancellationToken cancellationToken, + PowerShellExecutionOptions executionOptions = null); + + void CancelCurrentTask(); + } + + internal interface IInternalPowerShellExecutionService : IPowerShellExecutionService + { + event Action RunspaceChanged; + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Runspace/IRunspaceContext.cs b/src/PowerShellEditorServices/Services/PowerShell/Runspace/IRunspaceContext.cs new file mode 100644 index 0000000..c9232d7 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Runspace/IRunspaceContext.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace +{ + internal interface IRunspaceContext + { + IRunspaceInfo CurrentRunspace { get; } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Runspace/IRunspaceInfo.cs b/src/PowerShellEditorServices/Services/PowerShell/Runspace/IRunspaceInfo.cs new file mode 100644 index 0000000..401d0de --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Runspace/IRunspaceInfo.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Context; +using SMA = System.Management.Automation.Runspaces; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace +{ + internal interface IRunspaceInfo + { + RunspaceOrigin RunspaceOrigin { get; } + + bool IsOnRemoteMachine { get; } + + PowerShellVersionDetails PowerShellVersionDetails { get; } + + SessionDetails SessionDetails { get; } + + SMA.Runspace Runspace { get; } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Runspace/RunspaceChangedEventArgs.cs b/src/PowerShellEditorServices/Services/PowerShell/Runspace/RunspaceChangedEventArgs.cs new file mode 100644 index 0000000..19bbf69 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Runspace/RunspaceChangedEventArgs.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Utility; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace +{ + /// + /// Defines the set of actions that will cause the runspace to be changed. + /// + internal enum RunspaceChangeAction + { + /// + /// The runspace change was caused by entering a new session. + /// + Enter, + + /// + /// The runspace change was caused by exiting the current session. + /// + Exit, + + /// + /// The runspace change was caused by shutting down the service. + /// + Shutdown + } + + /// + /// Provides arguments for the PowerShellContext.RunspaceChanged event. + /// + internal class RunspaceChangedEventArgs + { + /// + /// Creates a new instance of the RunspaceChangedEventArgs class. + /// + /// The action which caused the runspace to change. + /// The previously active runspace. + /// The newly active runspace. + public RunspaceChangedEventArgs( + RunspaceChangeAction changeAction, + IRunspaceInfo previousRunspace, + IRunspaceInfo newRunspace) + { + Validate.IsNotNull(nameof(previousRunspace), previousRunspace); + + ChangeAction = changeAction; + PreviousRunspace = previousRunspace; + NewRunspace = newRunspace; + } + + /// + /// Gets the RunspaceChangeAction which caused this event. + /// + public RunspaceChangeAction ChangeAction { get; } + + /// + /// Gets a RunspaceDetails object describing the previous runspace. + /// + public IRunspaceInfo PreviousRunspace { get; } + + /// + /// Gets a RunspaceDetails object describing the new runspace. + /// + public IRunspaceInfo NewRunspace { get; } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Runspace/RunspaceInfo.cs b/src/PowerShellEditorServices/Services/PowerShell/Runspace/RunspaceInfo.cs new file mode 100644 index 0000000..c955bac --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Runspace/RunspaceInfo.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Context; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Debugging; +using Microsoft.Extensions.Logging; +using System.Threading.Tasks; +using System; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace +{ + using System.Management.Automation; + using System.Management.Automation.Runspaces; + + internal class RunspaceInfo : IRunspaceInfo + { + public static RunspaceInfo CreateFromLocalPowerShell( + ILogger logger, + PowerShell pwsh) + { + PowerShellVersionDetails psVersionDetails = PowerShellVersionDetails.GetVersionDetails(logger, pwsh); + SessionDetails sessionDetails = SessionDetails.GetFromPowerShell(pwsh); + + return new RunspaceInfo( + pwsh.Runspace, + RunspaceOrigin.Local, + psVersionDetails, + sessionDetails, + isRemote: false); + } + + public static RunspaceInfo CreateFromPowerShell( + ILogger logger, + PowerShell pwsh, + string localComputerName) + { + PowerShellVersionDetails psVersionDetails = PowerShellVersionDetails.GetVersionDetails(logger, pwsh); + SessionDetails sessionDetails = SessionDetails.GetFromPowerShell(pwsh); + + bool isOnLocalMachine = string.Equals(sessionDetails.ComputerName, localComputerName, StringComparison.OrdinalIgnoreCase) + || string.Equals(sessionDetails.ComputerName, "localhost", StringComparison.OrdinalIgnoreCase); + + RunspaceOrigin runspaceOrigin = RunspaceOrigin.Local; + if (pwsh.Runspace.RunspaceIsRemote) + { + runspaceOrigin = pwsh.Runspace.ConnectionInfo is NamedPipeConnectionInfo + ? RunspaceOrigin.EnteredProcess + : RunspaceOrigin.PSSession; + } + + return new RunspaceInfo( + pwsh.Runspace, + runspaceOrigin, + psVersionDetails, + sessionDetails, + isRemote: !isOnLocalMachine); + } + + private DscBreakpointCapability _dscBreakpointCapability; + + public RunspaceInfo( + Runspace runspace, + RunspaceOrigin origin, + PowerShellVersionDetails powerShellVersionDetails, + SessionDetails sessionDetails, + bool isRemote) + { + Runspace = runspace; + RunspaceOrigin = origin; + SessionDetails = sessionDetails; + PowerShellVersionDetails = powerShellVersionDetails; + IsOnRemoteMachine = isRemote; + } + + public RunspaceOrigin RunspaceOrigin { get; } + + public PowerShellVersionDetails PowerShellVersionDetails { get; } + + public SessionDetails SessionDetails { get; } + + public Runspace Runspace { get; } + + public bool IsOnRemoteMachine { get; } + + public async Task GetDscBreakpointCapabilityAsync( + ILogger logger, + PsesInternalHost psesHost) + { + return _dscBreakpointCapability ??= await DscBreakpointCapability.GetDscCapabilityAsync( + logger, + this, + psesHost) + .ConfigureAwait(false); + } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Runspace/RunspaceOrigin.cs b/src/PowerShellEditorServices/Services/PowerShell/Runspace/RunspaceOrigin.cs new file mode 100644 index 0000000..875001e --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Runspace/RunspaceOrigin.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace +{ + /// + /// Specifies the context in which the runspace was encountered. + /// + internal enum RunspaceOrigin + { + /// + /// The original runspace in a local session. + /// + Local, + + /// + /// A remote runspace entered through Enter-PSSession. + /// + PSSession, + + /// + /// A runspace in a process that was entered with Enter-PSHostProcess. + /// + EnteredProcess, + + /// + /// A runspace that is being debugged with Debug-Runspace. + /// + DebuggedRunspace + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Runspace/SessionDetails.cs b/src/PowerShellEditorServices/Services/PowerShell/Runspace/SessionDetails.cs new file mode 100644 index 0000000..53be32e --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Runspace/SessionDetails.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; +using System.Linq; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace +{ + using System.Management.Automation; + + /// + /// Provides details about the current PowerShell session. + /// + internal class SessionDetails + { + private const string Property_ComputerName = "computerName"; + private const string Property_ProcessId = "processId"; + private const string Property_InstanceId = "instanceId"; + + /// + /// Runs a PowerShell command to gather details about the current session. + /// + /// A data object containing details about the PowerShell session. + public static SessionDetails GetFromPowerShell(PowerShell pwsh) + { + Hashtable detailsObject = pwsh + .AddScript( + $"[System.Diagnostics.DebuggerHidden()]param() @{{ '{Property_ComputerName}' = if ([Environment]::MachineName) {{[Environment]::MachineName}} else {{'localhost'}}; '{Property_ProcessId}' = $PID; '{Property_InstanceId}' = $host.InstanceId }}", + useLocalScope: true) + .InvokeAndClear() + .FirstOrDefault(); + + return new SessionDetails( + (int)detailsObject[Property_ProcessId], + (string)detailsObject[Property_ComputerName], + (Guid?)detailsObject[Property_InstanceId]); + } + + /// + /// Creates an instance of SessionDetails using the information + /// contained in the PSObject which was obtained using the + /// PSCommand returned by GetDetailsCommand. + /// + public SessionDetails( + int processId, + string computerName, + Guid? instanceId) + { + ProcessId = processId; + ComputerName = computerName; + InstanceId = instanceId; + } + + /// + /// Gets the process ID of the current process. + /// + public int? ProcessId { get; } + + /// + /// Gets the name of the current computer. + /// + public string ComputerName { get; } + + /// + /// Gets the current PSHost instance ID. + /// + public Guid? InstanceId { get; } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Utility/CancellationContext.cs b/src/PowerShellEditorServices/Services/PowerShell/Utility/CancellationContext.cs new file mode 100644 index 0000000..24d121b --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Utility/CancellationContext.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Concurrent; +using System.Threading; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility +{ + /// + /// Encapsulates the scoping logic for cancellation tokens. + /// As PowerShell commands nest, this class maintains a stack of cancellation scopes + /// that allow each scope of logic to be cancelled at its own level. + /// Implicitly handles the merging and cleanup of cancellation token sources. + /// + /// + /// The class + /// and the struct + /// are intended to be used with a using block so you can do this: + /// + /// using (CancellationScope cancellationScope = _cancellationContext.EnterScope(_globalCancellationSource.CancellationToken, localCancellationToken)) + /// { + /// ExecuteCommandAsync(command, cancellationScope.CancellationToken); + /// } + /// + /// + internal class CancellationContext + { + private readonly ConcurrentStack _cancellationSourceStack; + + public CancellationContext() => _cancellationSourceStack = new ConcurrentStack(); + + public CancellationScope EnterScope(bool isIdleScope, CancellationToken cancellationToken) + { + CancellationTokenSource newScopeCancellationSource = _cancellationSourceStack.TryPeek(out CancellationScope parentScope) + ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, parentScope.CancellationToken) + : CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + return EnterScope(isIdleScope, newScopeCancellationSource); + } + + public CancellationScope EnterScope(bool isIdleScope) => EnterScope(isIdleScope, CancellationToken.None); + + public void CancelCurrentTask() + { + if (_cancellationSourceStack.TryPeek(out CancellationScope currentCancellationSource)) + { + currentCancellationSource.Cancel(); + } + } + + public void CancelCurrentTaskStack() + { + foreach (CancellationScope scope in _cancellationSourceStack) + { + scope.Cancel(); + } + } + + /// + /// Cancels the parent task of the idle task. + /// + public void CancelIdleParentTask() + { + foreach (CancellationScope scope in _cancellationSourceStack) + { + scope.Cancel(); + + // Note that this check is done *after* the cancellation because we want to cancel + // not just the idle task, but its parent as well + // because we want to cancel the ReadLine call that the idle handler is running in + // so we can run something else in the foreground + if (!scope.IsIdleScope) + { + break; + } + } + } + + private CancellationScope EnterScope(bool isIdleScope, CancellationTokenSource cancellationFrameSource) + { + CancellationScope scope = new(_cancellationSourceStack, cancellationFrameSource, isIdleScope); + _cancellationSourceStack.Push(scope); + return scope; + } + } + + internal class CancellationScope : IDisposable + { + private readonly ConcurrentStack _cancellationStack; + + private readonly CancellationTokenSource _cancellationSource; + + internal CancellationScope( + ConcurrentStack cancellationStack, + CancellationTokenSource frameCancellationSource, + bool isIdleScope) + { + _cancellationStack = cancellationStack; + _cancellationSource = frameCancellationSource; + IsIdleScope = isIdleScope; + } + + public CancellationToken CancellationToken => _cancellationSource.Token; + + public void Cancel() + { + try + { + _cancellationSource.Cancel(); + } + catch (ObjectDisposedException) + { + // We don't want this race condition to cause flaky tests. + // TODO: Find out the cause of the race! + } + } + + public bool IsIdleScope { get; } + + public void Dispose() + { + // TODO: This is whack. It used to call `Cancel` on the cancellation source, but we + // shouldn't do that! + _cancellationSource.Dispose(); + _cancellationStack.TryPop(out CancellationScope _); + } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Utility/CommandHelpers.cs b/src/PowerShellEditorServices/Services/PowerShell/Utility/CommandHelpers.cs new file mode 100644 index 0000000..6532c53 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Utility/CommandHelpers.cs @@ -0,0 +1,267 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace; +using Microsoft.PowerShell.EditorServices.Utility; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility +{ + /// + /// Provides utility methods for working with PowerShell commands. + /// TODO: Handle the `fn ` prefix better. + /// + internal static class CommandHelpers + { + public record struct AliasMap( + Dictionary> CmdletToAliases, + Dictionary AliasToCmdlets); + + private static readonly HashSet s_nounExclusionList = new() + { + // PowerShellGet v2 nouns + "CredsFromCredentialProvider", + "DscResource", + "InstalledModule", + "InstalledScript", + "PSRepository", + "RoleCapability", + "Script", + "ScriptFileInfo", + + // PackageManagement nouns + "Package", + "PackageProvider", + "PackageSource", + }; + + // This is used when a noun exists in multiple modules (for example, "Command" is used in Microsoft.PowerShell.Core and also PowerShellGet) + private static readonly HashSet s_cmdletExclusionList = new() + { + // Commands in PowerShellGet with conflicting nouns + "Find-Command", + "Find-Module", + "Install-Module", + "Publish-Module", + "Save-Module", + "Uninstall-Module", + "Update-Module", + "Update-ModuleManifest", + }; + + private static readonly ConcurrentDictionary s_commandInfoCache = new(); + private static readonly ConcurrentDictionary s_synopsisCache = new(); + internal static readonly ConcurrentDictionary> s_cmdletToAliasCache = new(System.StringComparer.OrdinalIgnoreCase); + internal static readonly ConcurrentDictionary s_aliasToCmdletCache = new(System.StringComparer.OrdinalIgnoreCase); + + /// + /// Gets the actual command behind a fully module qualified command invocation, e.g. + /// Microsoft.PowerShell.Management\Get-ChildItem will return Get-ChildItem + /// + /// + /// The potentially module qualified command name at the site of invocation. + /// + /// + /// A reference that will contain the module name if the invocation is module qualified. + /// + /// The actual command name. + public static string StripModuleQualification(string invocationName, out ReadOnlyMemory moduleName) + { + int slashIndex = invocationName.LastIndexOfAny(new[] { '\\', '/' }); + if (slashIndex is -1) + { + moduleName = default; + return invocationName; + } + + // If '\' is the last character then it's probably not a module qualified command. + if (slashIndex == invocationName.Length - 1) + { + moduleName = default; + return invocationName; + } + + // Storing moduleName as ROMemory saves a string allocation in the common case where it + // is not needed. + moduleName = invocationName.AsMemory().Slice(0, slashIndex); + return invocationName.Substring(slashIndex + 1); + } + + /// + /// Gets the CommandInfo instance for a command with a particular name. + /// + /// The name of the command. + /// The current runspace. + /// The execution service. + /// The token used to cancel this. + /// A CommandInfo object with details about the specified command. + public static async Task GetCommandInfoAsync( + string commandName, + IRunspaceInfo currentRunspace, + IInternalPowerShellExecutionService executionService, + CancellationToken cancellationToken = default) + { + // This mechanism only works in-process + if (currentRunspace.RunspaceOrigin != RunspaceOrigin.Local) + { + return null; + } + + Validate.IsNotNull(nameof(commandName), commandName); + Validate.IsNotNull(nameof(executionService), executionService); + + // Remove the bucket identifier from symbol references. + if (commandName.StartsWith("fn ")) + { + commandName = commandName.Substring(3); + } + + // If we have a CommandInfo cached, return that. + if (s_commandInfoCache.TryGetValue(commandName, out CommandInfo cmdInfo)) + { + return cmdInfo; + } + + // Make sure the command's noun or command's name isn't in the exclusion lists. + // This is currently necessary to make sure that Get-Command doesn't + // load PackageManagement or PowerShellGet v2 because they cause + // a major slowdown in IntelliSense. + string[] commandParts = commandName.Split('-'); + if ((commandParts.Length == 2 && s_nounExclusionList.Contains(commandParts[1])) + || s_cmdletExclusionList.Contains(commandName)) + { + return null; + } + + PSCommand command = new PSCommand() + .AddCommand(@"Microsoft.PowerShell.Core\Get-Command") + .AddArgument(commandName) + .AddParameter("ErrorAction", "Ignore"); + + IReadOnlyList results = await executionService + .ExecutePSCommandAsync(command, cancellationToken) + .ConfigureAwait(false); + + CommandInfo commandInfo = results.Count > 0 ? results[0] : null; + + // Only cache CmdletInfos since they're exposed in binaries they are likely to not change throughout the session. + if (commandInfo?.CommandType == CommandTypes.Cmdlet) + { + s_commandInfoCache.TryAdd(commandName, commandInfo); + } + + return commandInfo; + } + + /// + /// Gets the command's "Synopsis" documentation section. + /// + /// The CommandInfo instance for the command. + /// The execution service to use for getting command documentation. + /// The token used to cancel this. + /// The synopsis. + public static async Task GetCommandSynopsisAsync( + CommandInfo commandInfo, + IInternalPowerShellExecutionService executionService, + CancellationToken cancellationToken = default) + { + Validate.IsNotNull(nameof(commandInfo), commandInfo); + Validate.IsNotNull(nameof(executionService), executionService); + + // A small optimization to not run Get-Help on things like DSC resources. + if (commandInfo.CommandType is not CommandTypes.Cmdlet and + not CommandTypes.Function and + not CommandTypes.Filter) + { + return string.Empty; + } + + // If we have a synopsis cached, return that. + // NOTE: If the user runs Update-Help, it's possible that this synopsis will be out of date. + // Given the perf increase of doing this, and the simple workaround of restarting the extension, + // this seems worth it. + if (s_synopsisCache.TryGetValue(commandInfo.Name, out string synopsis)) + { + return synopsis; + } + + PSCommand command = new PSCommand() + .AddCommand(@"Microsoft.PowerShell.Core\Get-Help") + // We use .Name here instead of just passing in commandInfo because + // CommandInfo.ToString() duplicates the Prefix if one exists. + .AddParameter("Name", commandInfo.Name) + .AddParameter("Online", false) + .AddParameter("ErrorAction", "Ignore"); + + IReadOnlyList results = await executionService + .ExecutePSCommandAsync(command, cancellationToken) + .ConfigureAwait(false); + + // Extract the synopsis string from the object + PSObject helpObject = results.Count > 0 ? results[0] : null; + string synopsisString = (string)helpObject?.Properties["synopsis"].Value ?? string.Empty; + + // Only cache cmdlet infos because since they're exposed in binaries, the can never change throughout the session. + if (commandInfo.CommandType == CommandTypes.Cmdlet) + { + s_synopsisCache.TryAdd(commandInfo.Name, synopsisString); + } + + // Ignore the placeholder value for this field + if (string.Equals(synopsisString, "SHORT DESCRIPTION", System.StringComparison.CurrentCultureIgnoreCase)) + { + return string.Empty; + } + + return synopsisString; + } + + /// + /// Gets all aliases found in the runspace + /// + /// + /// + public static async Task GetAliasesAsync( + IInternalPowerShellExecutionService executionService, + CancellationToken cancellationToken = default) + { + Validate.IsNotNull(nameof(executionService), executionService); + + // Need to execute a PSCommand here as Runspace.SessionStateProxy cannot be used from + // our PSRL on idle handler. + IReadOnlyList aliases = await executionService.ExecutePSCommandAsync( + new PSCommand() + .AddCommand(@"Microsoft.PowerShell.Core\Get-Command") + .AddParameter("ListImported", true) + .AddParameter("CommandType", CommandTypes.Alias), + cancellationToken).ConfigureAwait(false); + + foreach (AliasInfo aliasInfo in aliases.Cast()) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + + // TODO: When we move to netstandard2.1, we can use another overload which generates + // static delegates and thus reduces allocations. + s_cmdletToAliasCache.AddOrUpdate( + "fn " + aliasInfo.Definition, + (_) => new List { "fn " + aliasInfo.Name }, + (_, v) => { v.Add("fn " + aliasInfo.Name); return v; }); + + s_aliasToCmdletCache.TryAdd("fn " + aliasInfo.Name, "fn " + aliasInfo.Definition); + } + + return new AliasMap( + new Dictionary>(s_cmdletToAliasCache), + new Dictionary(s_aliasToCmdletCache)); + } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Utility/ConsoleKeyInfoExtensions.cs b/src/PowerShellEditorServices/Services/PowerShell/Utility/ConsoleKeyInfoExtensions.cs new file mode 100644 index 0000000..2449877 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Utility/ConsoleKeyInfoExtensions.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility +{ + internal static class ConsoleKeyInfoExtensions + { + public static bool IsCtrlC(this ConsoleKeyInfo keyInfo) + { + if ((int)keyInfo.Key == 3) + { + return true; + } + + return keyInfo.Key == ConsoleKey.C + && (keyInfo.Modifiers & ConsoleModifiers.Control) != 0 + && (keyInfo.Modifiers & ConsoleModifiers.Shift) == 0 + && (keyInfo.Modifiers & ConsoleModifiers.Alt) == 0; + } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Utility/ErrorRecordExtensions.cs b/src/PowerShellEditorServices/Services/PowerShell/Utility/ErrorRecordExtensions.cs new file mode 100644 index 0000000..1cd5e40 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Utility/ErrorRecordExtensions.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Utility; +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Management.Automation; +using System.Reflection; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility +{ + internal static class ErrorRecordExtensions + { + private static readonly Action s_setWriteStreamProperty; + + [SuppressMessage("Performance", "CA1810:Initialize reference type static fields inline", Justification = "cctor needed for version specific initialization")] + static ErrorRecordExtensions() + { + if (VersionUtils.IsPS7OrGreater) + { + // Used to write ErrorRecords to the Error stream. Using Public and NonPublic because the plan is to make this property + // public in 7.0.1 + PropertyInfo writeStreamProperty = typeof(PSObject).GetProperty("WriteStream", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + Type writeStreamType = typeof(PSObject).Assembly.GetType("System.Management.Automation.WriteStreamType"); + object errorStreamType = Enum.Parse(writeStreamType, "Error"); + + ParameterExpression errorObjectParameter = Expression.Parameter(typeof(PSObject)); + + // Generates a call like: + // $errorPSObject.WriteStream = [System.Management.Automation.WriteStreamType]::Error + // So that error record PSObjects will be rendered in the console properly + // See https://github.com/PowerShell/PowerShell/blob/946341b2ebe6a61f081f4c9143668dc7be1f9119/src/Microsoft.PowerShell.ConsoleHost/host/msh/ConsoleHost.cs#L2088-L2091 + s_setWriteStreamProperty = Expression.Lambda>( + Expression.Call( + errorObjectParameter, + writeStreamProperty.GetSetMethod(nonPublic: true), + Expression.Constant(errorStreamType)), + errorObjectParameter) + .Compile(); + } + } + + public static PSObject AsPSObject(this ErrorRecord errorRecord) + { + PSObject errorObject = PSObject.AsPSObject(errorRecord); + + // Used to write ErrorRecords to the Error stream so they are rendered in the console correctly. + if (s_setWriteStreamProperty != null) + { + s_setWriteStreamProperty(errorObject); + } + else + { + PSNoteProperty note = new("writeErrorStream", true); + errorObject.Properties.Add(note); + } + + return errorObject; + } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Utility/PowerShellDebugDisplay.cs b/src/PowerShellEditorServices/Services/PowerShell/Utility/PowerShellDebugDisplay.cs new file mode 100644 index 0000000..4a1536f --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Utility/PowerShellDebugDisplay.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#if DEBUG +using System.Diagnostics; +using SMA = System.Management.Automation; + +[assembly: DebuggerDisplay("{Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility.PowerShellDebugDisplay.ToDebuggerString(this)}", Target = typeof(SMA.PowerShell))] +[assembly: DebuggerDisplay("{Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility.PSCommandDebugDisplay.ToDebuggerString(this)}", Target = typeof(SMA.PSCommand))] + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; + +internal static class PowerShellDebugDisplay +{ + public static string ToDebuggerString(SMA.PowerShell pwsh) + { + if (pwsh.Commands.Commands.Count == 0) + { + return "{}"; + } + + return $"{{{pwsh.Commands.Commands[0].CommandText}}}"; + } +} + +internal static class PSCommandDebugDisplay +{ + public static string ToDebuggerString(SMA.PSCommand command) + { + if (command.Commands.Count == 0) + { + return "{}"; + } + + return $"{{{command.Commands[0].CommandText}}}"; + } +} +#endif diff --git a/src/PowerShellEditorServices/Services/PowerShell/Utility/PowerShellExtensions.cs b/src/PowerShellEditorServices/Services/PowerShell/Utility/PowerShellExtensions.cs new file mode 100644 index 0000000..f7ef51a --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Utility/PowerShellExtensions.cs @@ -0,0 +1,267 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Reflection; +using System.Text; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Hosting; +using Microsoft.PowerShell.EditorServices.Utility; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility +{ + using System.Management.Automation; + + internal static class PowerShellExtensions + { + private static readonly Action s_waitForServicingComplete; + + private static readonly Action s_suspendIncomingData; + + private static readonly Action s_resumeIncomingData; + + static PowerShellExtensions() + { + s_waitForServicingComplete = (Action)Delegate.CreateDelegate( + typeof(Action), + typeof(PowerShell).GetMethod("WaitForServicingComplete", BindingFlags.Instance | BindingFlags.NonPublic)); + + s_suspendIncomingData = (Action)Delegate.CreateDelegate( + typeof(Action), + typeof(PowerShell).GetMethod("SuspendIncomingData", BindingFlags.Instance | BindingFlags.NonPublic)); + + s_resumeIncomingData = (Action)Delegate.CreateDelegate( + typeof(Action), + typeof(PowerShell).GetMethod("ResumeIncomingData", BindingFlags.Instance | BindingFlags.NonPublic)); + } + + public static PowerShell CloneForNewFrame(this PowerShell pwsh) + { + if (pwsh.IsNested) + { + return PowerShell.Create(RunspaceMode.CurrentRunspace); + } + + PowerShell newPwsh = PowerShell.Create(); + newPwsh.Runspace = pwsh.Runspace; + return newPwsh; + } + + public static void DisposeWhenCompleted(this PowerShell pwsh) + { + static void handler(object self, PSInvocationStateChangedEventArgs e) + { + if (e.InvocationStateInfo.State is + not PSInvocationState.Completed + and not PSInvocationState.Failed + and not PSInvocationState.Stopped) + { + return; + } + + PowerShell pwsh = (PowerShell)self; + pwsh.InvocationStateChanged -= handler; + pwsh.Dispose(); + } + + pwsh.InvocationStateChanged += handler; + } + + public static Collection InvokeAndClear(this PowerShell pwsh, PSInvocationSettings invocationSettings = null) + { + try + { + return pwsh.Invoke(input: null, invocationSettings); + } + finally + { + pwsh.Commands.Clear(); + } + } + + public static void InvokeAndClear(this PowerShell pwsh, PSInvocationSettings invocationSettings = null) + { + try + { + pwsh.Invoke(input: null, invocationSettings); + } + finally + { + pwsh.Commands.Clear(); + } + } + + public static Collection InvokeCommand(this PowerShell pwsh, PSCommand psCommand, PSInvocationSettings invocationSettings = null) + { + pwsh.Commands = psCommand; + return pwsh.InvokeAndClear(invocationSettings); + } + + public static void InvokeCommand(this PowerShell pwsh, PSCommand psCommand, PSInvocationSettings invocationSettings = null) + { + pwsh.Commands = psCommand; + pwsh.InvokeAndClear(invocationSettings); + } + + /// + /// When running a remote session, waits for remote processing and output to complete. + /// + public static void WaitForRemoteOutputIfNeeded(this PowerShell pwsh) + { + if (!pwsh.Runspace.RunspaceIsRemote) + { + return; + } + + // These methods are required when running commands remotely. + // Remote rendering from command output is done asynchronously. + // So to ensure we wait for output to be rendered, + // we need these methods to wait for rendering. + // PowerShell does this in its own implementation: https://github.com/PowerShell/PowerShell/blob/883ca98dd74ea13b3d8c0dd62d301963a40483d6/src/System.Management.Automation/engine/debugger/debugger.cs#L4628-L4652 + s_waitForServicingComplete(pwsh); + s_suspendIncomingData(pwsh); + } + + public static void ResumeRemoteOutputIfNeeded(this PowerShell pwsh) + { + if (!pwsh.Runspace.RunspaceIsRemote) + { + return; + } + + s_resumeIncomingData(pwsh); + } + + public static void SetCorrectExecutionPolicy(this PowerShell pwsh, ILogger logger) + { + // We want to get the list hierarchy of execution policies + // Calling the cmdlet is the simplest way to do that + IReadOnlyList policies = pwsh + .AddCommand(@"Microsoft.PowerShell.Security\Get-ExecutionPolicy") + .AddParameter("List") + .InvokeAndClear(); + + // The policies come out in the following order: + // - MachinePolicy + // - UserPolicy + // - Process + // - CurrentUser + // - LocalMachine + // We want to ignore policy settings, since we'll already have those anyway. + // Then we need to look at the CurrentUser setting, and then the LocalMachine setting. + // + // Get-ExecutionPolicy -List emits PSObjects with Scope and ExecutionPolicy note properties + // set to expected values, so we must sift through those. + + ExecutionPolicy policyToSet = ExecutionPolicy.Bypass; + ExecutionPolicy currentUserPolicy = (ExecutionPolicy)policies[policies.Count - 2].Members["ExecutionPolicy"].Value; + if (currentUserPolicy != ExecutionPolicy.Undefined) + { + policyToSet = currentUserPolicy; + } + else + { + ExecutionPolicy localMachinePolicy = (ExecutionPolicy)policies[policies.Count - 1].Members["ExecutionPolicy"].Value; + if (localMachinePolicy != ExecutionPolicy.Undefined) + { + policyToSet = localMachinePolicy; + } + } + + // If there's nothing to do, save ourselves a PowerShell invocation + if (policyToSet == ExecutionPolicy.Bypass) + { + logger.LogTrace("Execution policy already set to Bypass. Skipping execution policy set"); + return; + } + + // Finally set the inherited execution policy + logger.LogTrace("Setting execution policy to {Policy}", policyToSet); + try + { + pwsh.AddCommand(@"Microsoft.PowerShell.Security\Set-ExecutionPolicy") + .AddParameter("Scope", ExecutionPolicyScope.Process) + .AddParameter("ExecutionPolicy", policyToSet) + .AddParameter("Force") + .InvokeAndClear(); + } + catch (CmdletInvocationException e) + { + logger.LogError(e, "Error occurred calling 'Set-ExecutionPolicy -Scope Process -ExecutionPolicy {Policy} -Force'", policyToSet); + } + } + + public static void LoadProfiles(this PowerShell pwsh, ProfilePathInfo profilePaths) + { + // Per the documentation, "the `$PROFILE` variable stores the path to the 'Current User, + // Current Host' profile. The other profiles are saved in note properties of the + // `$PROFILE` variable. Its type is `String`. + // + // https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_profiles?view=powershell-7.1#the-profile-variable + PSObject profileVariable = PSObject.AsPSObject(profilePaths.CurrentUserCurrentHost); + + PSCommand psCommand = new PSCommand() + .AddProfileLoadIfExists(profileVariable, nameof(profilePaths.AllUsersAllHosts), profilePaths.AllUsersAllHosts) + .AddProfileLoadIfExists(profileVariable, nameof(profilePaths.AllUsersCurrentHost), profilePaths.AllUsersCurrentHost) + .AddProfileLoadIfExists(profileVariable, nameof(profilePaths.CurrentUserAllHosts), profilePaths.CurrentUserAllHosts) + .AddProfileLoadIfExists(profileVariable, nameof(profilePaths.CurrentUserCurrentHost), profilePaths.CurrentUserCurrentHost); + + // NOTE: This must be set before the profiles are loaded. + pwsh.Runspace.SessionStateProxy.SetVariable("PROFILE", profileVariable); + + // NOTE: Because it's possible there are no profiles defined, we might have an empty + // command. Since this is being executed directly, we can't rely on `ThrowOnError = + // false` to avoid an exception here. Instead, we must just not execute it. + if (psCommand.Commands.Count > 0) + { + pwsh.InvokeCommand(psCommand); + } + } + + public static void ImportModule(this PowerShell pwsh, string moduleNameOrPath) + { + pwsh.AddCommand(@"Microsoft.PowerShell.Core\Import-Module") + .AddParameter("Name", moduleNameOrPath) + .InvokeAndClear(); + } + + public static string GetErrorString(this PowerShell pwsh) + { + StringBuilder sb = new StringBuilder(capacity: 1024) + .AppendLine("Execution of the following command(s) completed with errors:") + .AppendLine() + .Append(pwsh.Commands.GetInvocationText()); + + sb.AddErrorString(pwsh.Streams.Error[0], errorIndex: 1); + for (int i = 1; i < pwsh.Streams.Error.Count; i++) + { + sb.AppendLine().AppendLine(); + sb.AddErrorString(pwsh.Streams.Error[i], errorIndex: i + 1); + } + + return sb.ToString(); + } + + private static StringBuilder AddErrorString(this StringBuilder sb, ErrorRecord error, int errorIndex) + { + sb.Append("Error #").Append(errorIndex).Append(':').AppendLine() + .Append(error).AppendLine() + .AppendLine("ScriptStackTrace:") + .AppendLine(error.ScriptStackTrace ?? "") + .AppendLine("Exception:") + .Append(" ").Append(error.Exception.ToString() ?? ""); + + Exception innerException = error.Exception?.InnerException; + while (innerException != null) + { + sb.AppendLine("InnerException:") + .Append(" ").Append(innerException); + innerException = innerException.InnerException; + } + + return sb; + } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Utility/RunspaceExtensions.cs b/src/PowerShellEditorServices/Services/PowerShell/Utility/RunspaceExtensions.cs new file mode 100644 index 0000000..adb4bf9 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Utility/RunspaceExtensions.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Linq.Expressions; +using System.Management.Automation; +using System.Reflection; +using System.Threading; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility +{ + using System.Management.Automation.Runspaces; + + internal static class RunspaceExtensions + { + private static readonly Action s_runspaceApartmentStateSetter; + + private static readonly Func s_getRemotePromptFunc; + + static RunspaceExtensions() + { + // PowerShell ApartmentState APIs aren't available in PSStandard, so we need to use reflection. + MethodInfo setterInfo = typeof(Runspace).GetProperty("ApartmentState").GetSetMethod(); + Delegate setter = Delegate.CreateDelegate(typeof(Action), firstArgument: null, method: setterInfo); + s_runspaceApartmentStateSetter = (Action)setter; + + MethodInfo getRemotePromptMethod = typeof(HostUtilities).GetMethod("GetRemotePrompt", BindingFlags.NonPublic | BindingFlags.Static); + ParameterExpression runspaceParam = Expression.Parameter(typeof(Runspace)); + ParameterExpression basePromptParam = Expression.Parameter(typeof(string)); + s_getRemotePromptFunc = Expression.Lambda>( + Expression.Call( + getRemotePromptMethod, + new Expression[] + { + Expression.Convert(runspaceParam, typeof(Runspace).Assembly.GetType("System.Management.Automation.RemoteRunspace")), + basePromptParam, + Expression.Constant(false), // configuredSession must be false + }), + new ParameterExpression[] { runspaceParam, basePromptParam }).Compile(); + } + + public static void SetApartmentStateToSta(this Runspace runspace) => s_runspaceApartmentStateSetter?.Invoke(runspace, ApartmentState.STA); + + /// + /// Augment a given prompt string with a remote decoration. + /// This is an internal method on Runspace in PowerShell that we reuse via reflection. + /// + /// The runspace the prompt is for. + /// The base prompt to decorate. + /// A prompt string decorated with remote connection details. + public static string GetRemotePrompt(this Runspace runspace, string basePrompt) => s_getRemotePromptFunc(runspace, basePrompt); + + public static void ThrowCancelledIfUnusable(this Runspace runspace) + => runspace.RunspaceStateInfo.ThrowCancelledIfUnusable(); + + public static void ThrowCancelledIfUnusable(this RunspaceStateInfo runspaceStateInfo) + { + if (!IsUsable(runspaceStateInfo)) + { + throw new OperationCanceledException(); + } + } + + public static bool IsUsable(this RunspaceStateInfo runspaceStateInfo) + { + return runspaceStateInfo.State switch + { + RunspaceState.Broken or RunspaceState.Closed or RunspaceState.Closing or RunspaceState.Disconnecting or RunspaceState.Disconnected => false, + _ => true, + }; + } + } +} diff --git a/src/PowerShellEditorServices/Services/Symbols/IDocumentSymbolProvider.cs b/src/PowerShellEditorServices/Services/Symbols/IDocumentSymbolProvider.cs new file mode 100644 index 0000000..6abcdb1 --- /dev/null +++ b/src/PowerShellEditorServices/Services/Symbols/IDocumentSymbolProvider.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Services.Symbols +{ + /// + /// Specifies the contract for a document symbols provider. + /// + internal interface IDocumentSymbolProvider + { + string ProviderId { get; } + + /// + /// Provides a list of symbols for the given document. + /// + /// + /// The document for which SymbolReferences should be provided. + /// + /// An IEnumerable collection of SymbolReferences. + IEnumerable ProvideDocumentSymbols(ScriptFile scriptFile); + } +} diff --git a/src/PowerShellEditorServices/Services/Symbols/ParameterSetSignatures.cs b/src/PowerShellEditorServices/Services/Symbols/ParameterSetSignatures.cs new file mode 100644 index 0000000..8f8af2c --- /dev/null +++ b/src/PowerShellEditorServices/Services/Symbols/ParameterSetSignatures.cs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Management.Automation; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Services.Symbols +{ + /// + /// A class for containing the commandName, the command's + /// possible signatures, and the script extent of the command + /// + internal class ParameterSetSignatures + { + #region Properties + + /// + /// Gets the name of the command + /// + public string CommandName { get; internal set; } + + /// + /// Gets the collection of signatures for the command + /// + public ParameterSetSignature[] Signatures { get; internal set; } + + /// + /// Gets the script extent of the command + /// + public ScriptRegion ScriptRegion { get; internal set; } + + #endregion + + /// + /// Constructs an instance of a ParameterSetSignatures object + /// + /// Collection of parameter set info + /// The SymbolReference of the command + public ParameterSetSignatures(IEnumerable commandInfoSet, SymbolReference foundSymbol) + { + List paramSetSignatures = new(); + foreach (CommandParameterSetInfo setInfo in commandInfoSet) + { + paramSetSignatures.Add(new ParameterSetSignature(setInfo)); + } + Signatures = paramSetSignatures.ToArray(); + CommandName = foundSymbol.NameRegion.Text; + ScriptRegion = foundSymbol.NameRegion; + } + } + + /// + /// A class for containing the signature text and the collection of parameters for a signature + /// + internal class ParameterSetSignature + { + private static readonly ConcurrentDictionary commonParameterNames = + new(); + + static ParameterSetSignature() + { + commonParameterNames.TryAdd("Verbose", true); + commonParameterNames.TryAdd("Debug", true); + commonParameterNames.TryAdd("ErrorAction", true); + commonParameterNames.TryAdd("WarningAction", true); + commonParameterNames.TryAdd("InformationAction", true); + commonParameterNames.TryAdd("ErrorVariable", true); + commonParameterNames.TryAdd("WarningVariable", true); + commonParameterNames.TryAdd("InformationVariable", true); + commonParameterNames.TryAdd("OutVariable", true); + commonParameterNames.TryAdd("OutBuffer", true); + commonParameterNames.TryAdd("PipelineVariable", true); + } + + #region Properties + /// + /// Gets the signature text + /// + public string SignatureText { get; internal set; } + + /// + /// Gets the collection of parameters for the signature + /// + public IEnumerable Parameters { get; internal set; } + #endregion + + /// + /// Constructs an instance of a ParameterSetSignature + /// + /// Collection of parameter info + public ParameterSetSignature(CommandParameterSetInfo commandParamInfoSet) + { + List parameterInfo = new(); + foreach (CommandParameterInfo commandParameterInfo in commandParamInfoSet.Parameters) + { + if (!commonParameterNames.ContainsKey(commandParameterInfo.Name)) + { + parameterInfo.Add(new ParameterInfo(commandParameterInfo)); + } + } + + SignatureText = commandParamInfoSet.ToString(); + Parameters = parameterInfo.ToArray(); + } + } + + /// + /// A class for containing the parameter info of a parameter + /// + internal class ParameterInfo + { + #region Properties + /// + /// Gets the name of the parameter + /// + public string Name { get; internal set; } + + /// + /// Gets the type of the parameter + /// + public string ParameterType { get; internal set; } + + /// + /// Gets the position of the parameter + /// + public int Position { get; internal set; } + + /// + /// Gets a boolean for whetheer or not the parameter is required + /// + public bool IsMandatory { get; internal set; } + + /// + /// Gets the help message of the parameter + /// + public string HelpMessage { get; internal set; } + #endregion + + /// + /// Constructs an instance of a ParameterInfo object + /// + /// Parameter info of the parameter + public ParameterInfo(CommandParameterInfo parameterInfo) + { + Name = "-" + parameterInfo.Name; + ParameterType = parameterInfo.ParameterType.FullName; + Position = parameterInfo.Position; + IsMandatory = parameterInfo.IsMandatory; + HelpMessage = parameterInfo.HelpMessage; + } + } +} diff --git a/src/PowerShellEditorServices/Services/Symbols/PesterDocumentSymbolProvider.cs b/src/PowerShellEditorServices/Services/Symbols/PesterDocumentSymbolProvider.cs new file mode 100644 index 0000000..ebc2e72 --- /dev/null +++ b/src/PowerShellEditorServices/Services/Symbols/PesterDocumentSymbolProvider.cs @@ -0,0 +1,271 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation.Language; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; + +namespace Microsoft.PowerShell.EditorServices.Services.Symbols +{ + /// + /// Provides an IDocumentSymbolProvider implementation for + /// enumerating test symbols in Pester test (tests.ps1) files. + /// + internal class PesterDocumentSymbolProvider : IDocumentSymbolProvider + { + string IDocumentSymbolProvider.ProviderId => nameof(PesterDocumentSymbolProvider); + + IEnumerable IDocumentSymbolProvider.ProvideDocumentSymbols( + ScriptFile scriptFile) + { + if (!scriptFile.FilePath.EndsWith(".tests.ps1", StringComparison.OrdinalIgnoreCase) && + !scriptFile.FilePath.EndsWith(".Koans.ps1", StringComparison.OrdinalIgnoreCase)) + { + return Enumerable.Empty(); + } + + // Find plausible Pester commands + IEnumerable commandAsts = scriptFile.ScriptAst.FindAll(IsNamedCommandWithArguments, true); + + return commandAsts.OfType() + .Where(IsPesterCommand) + .Select(ast => ConvertPesterAstToSymbolReference(scriptFile, ast)); + } + + /// + /// Test if the given Ast is a regular CommandAst with arguments + /// + /// the PowerShell Ast to test + /// true if the Ast represents a PowerShell command with arguments, false otherwise + private static bool IsNamedCommandWithArguments(Ast ast) + { + return ast is CommandAst commandAst && + commandAst.InvocationOperator is not (TokenKind.Dot or TokenKind.Ampersand) && + commandAst.CommandElements.Count >= 2; + } + + /// + /// Test whether the given CommandAst represents a Pester command + /// + /// the CommandAst to test + /// true if the CommandAst represents a Pester command, false otherwise + private static bool IsPesterCommand(CommandAst commandAst) + { + if (commandAst is null) + { + return false; + } + + // Ensure the first word is a Pester keyword and in Pester-module if using module-qualified call + string commandName = CommandHelpers.StripModuleQualification(commandAst.GetCommandName(), out ReadOnlyMemory module); + if (!PesterSymbolReference.PesterKeywords.ContainsKey(commandName) || + (!module.IsEmpty && !module.Span.Equals("pester".AsSpan(), StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + // Ensure that the last argument of the command is a scriptblock + if (commandAst.CommandElements[commandAst.CommandElements.Count - 1] is not ScriptBlockExpressionAst) + { + return false; + } + + return true; + } + + private static readonly char[] DefinitionTrimChars = new char[] { ' ', '{' }; + + /// + /// Convert a CommandAst known to represent a Pester command and a reference to the scriptfile + /// it is in into symbol representing a Pester call for code lens + /// + /// the scriptfile the Pester call occurs in + /// the CommandAst representing the Pester call + /// a symbol representing the Pester call containing metadata for CodeLens to use + private static PesterSymbolReference ConvertPesterAstToSymbolReference(ScriptFile scriptFile, CommandAst pesterCommandAst) + { + string symbolName = scriptFile + .GetLine(pesterCommandAst.Extent.StartLineNumber) + .TrimStart() + .TrimEnd(DefinitionTrimChars); + + string commandName = CommandHelpers.StripModuleQualification(pesterCommandAst.GetCommandName(), out _); + PesterCommandType? commandType = PesterSymbolReference.GetCommandType(commandName); + if (commandType is null) + { + return null; + } + + string testName = null; + if (PesterSymbolReference.IsPesterTestCommand(commandType.Value)) + { + // Search for a name for the test + // If the test has more than one argument for names, we set it to null + bool alreadySawName = false; + for (int i = 1; i < pesterCommandAst.CommandElements.Count; i++) + { + CommandElementAst currentCommandElement = pesterCommandAst.CommandElements[i]; + + // Check for an explicit "-Name" parameter + if (currentCommandElement is CommandParameterAst) + { + // Found -Name parameter, move to next element which is the argument for -TestName + i++; + + if (!alreadySawName && TryGetTestNameArgument(pesterCommandAst.CommandElements[i], out testName)) + { + alreadySawName = true; + } + + continue; + } + + // Otherwise, if an argument is given with no parameter, we assume it's the name + // If we've already seen a name, we set the name to null + if (!alreadySawName && TryGetTestNameArgument(pesterCommandAst.CommandElements[i], out testName)) + { + alreadySawName = true; + } + } + } + + return new PesterSymbolReference( + scriptFile, + commandType.Value, + symbolName, + testName, + pesterCommandAst.Extent + ); + } + + private static bool TryGetTestNameArgument(CommandElementAst commandElementAst, out string testName) + { + testName = null; + + if (commandElementAst is StringConstantExpressionAst testNameStrAst) + { + testName = testNameStrAst.Value; + return true; + } + + return commandElementAst is ExpandableStringExpressionAst; + } + } + + /// + /// Defines command types for Pester blocks. + /// + internal enum PesterCommandType + { + /// + /// Identifies a Describe block. + /// + Describe, + + /// + /// Identifies a Context block. + /// + Context, + + /// + /// Identifies an It block. + /// + It, + + /// + /// Identifies an BeforeAll block. + /// + BeforeAll, + + /// + /// Identifies an BeforeEach block. + /// + BeforeEach, + + /// + /// Identifies an AfterAll block. + /// + AfterAll, + + /// + /// Identifies an AfterEach block. + /// + AfterEach, + + /// + /// Identifies an BeforeDiscovery block. + /// + BeforeDiscovery + } + + /// + /// Provides a specialization of SymbolReference containing + /// extra information about Pester test symbols. + /// + internal record PesterSymbolReference : SymbolReference + { + /// + /// Lookup for Pester keywords we support. Ideally we could extract these from Pester itself + /// + internal static readonly IReadOnlyDictionary PesterKeywords = + Enum.GetValues(typeof(PesterCommandType)) + .Cast() + .ToDictionary(pct => pct.ToString(), pct => pct, StringComparer.OrdinalIgnoreCase); + + /// + /// Gets the name of the test + /// TODO: We could get rid of this and use DisplayName now, but first attempt didn't work great. + /// + public string TestName { get; } + + /// + /// Gets the test's command type. + /// + public PesterCommandType Command { get; } + + internal PesterSymbolReference( + ScriptFile scriptFile, + PesterCommandType commandType, + string symbolName, + string testName, + IScriptExtent scriptExtent) + : base( + SymbolType.Function, + symbolName, + symbolName + " { }", + scriptExtent, + scriptExtent, + scriptFile, + isDeclaration: true) + { + Command = commandType; + TestName = testName; + } + + internal static PesterCommandType? GetCommandType(string commandName) + { + if (commandName is null || !PesterKeywords.TryGetValue(commandName, out PesterCommandType pesterCommandType)) + { + return null; + } + + return pesterCommandType; + } + + /// + /// Checks if the PesterCommandType is a block with executable tests (Describe/Context/It). + /// + /// the PesterCommandType representing the Pester command + /// True if command type is a block used to trigger test run. False if setup/teardown/support-block. + internal static bool IsPesterTestCommand(PesterCommandType pesterCommandType) + { + return pesterCommandType is + PesterCommandType.Describe or + PesterCommandType.Context or + PesterCommandType.It; + } + } +} diff --git a/src/PowerShellEditorServices/Services/Symbols/PsdDocumentSymbolProvider.cs b/src/PowerShellEditorServices/Services/Symbols/PsdDocumentSymbolProvider.cs new file mode 100644 index 0000000..a0a0325 --- /dev/null +++ b/src/PowerShellEditorServices/Services/Symbols/PsdDocumentSymbolProvider.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation.Language; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Services.Symbols +{ + /// + /// Provides an IDocumentSymbolProvider implementation for + /// enumerating symbols in .psd1 files. + /// + internal class PsdDocumentSymbolProvider : IDocumentSymbolProvider + { + string IDocumentSymbolProvider.ProviderId => nameof(PsdDocumentSymbolProvider); + + IEnumerable IDocumentSymbolProvider.ProvideDocumentSymbols( + ScriptFile scriptFile) + { + if ((scriptFile.FilePath?.EndsWith(".psd1", StringComparison.OrdinalIgnoreCase) == true) || + IsPowerShellDataFileAst(scriptFile.ScriptAst)) + { + FindHashtableSymbolsVisitor findHashtableSymbolsVisitor = new(scriptFile); + scriptFile.ScriptAst.Visit(findHashtableSymbolsVisitor); + return findHashtableSymbolsVisitor.SymbolReferences; + } + + return Enumerable.Empty(); + } + + /// + /// Checks if a given ast represents the root node of a *.psd1 file. + /// + /// The abstract syntax tree of the given script + /// true if the AST represents a *.psd1 file, otherwise false + public static bool IsPowerShellDataFileAst(Ast ast) + { + // sometimes we don't have reliable access to the filename + // so we employ heuristics to check if the contents are + // part of a psd1 file. + return IsPowerShellDataFileAstNode( + new { Item = ast, Children = new List() }, + new Type[] { + typeof(ScriptBlockAst), + typeof(NamedBlockAst), + typeof(PipelineAst), + typeof(CommandExpressionAst), + typeof(HashtableAst) }, + 0); + } + + private static bool IsPowerShellDataFileAstNode(dynamic node, Type[] levelAstMap, int level) + { + dynamic levelAstTypeMatch = node.Item.GetType().Equals(levelAstMap[level]); + if (!levelAstTypeMatch) + { + return false; + } + + if (level == levelAstMap.Length - 1) + { + return levelAstTypeMatch; + } + + IEnumerable astsFound = (node.Item as Ast)?.FindAll(a => a is not null, false); + if (astsFound != null) + { + foreach (Ast astFound in astsFound) + { + if (!astFound.Equals(node.Item) + && node.Item.Equals(astFound.Parent) + && IsPowerShellDataFileAstNode( + new { Item = astFound, Children = new List() }, + levelAstMap, + level + 1)) + { + return true; + } + } + } + + return false; + } + } +} diff --git a/src/PowerShellEditorServices/Services/Symbols/ReferenceTable.cs b/src/PowerShellEditorServices/Services/Symbols/ReferenceTable.cs new file mode 100644 index 0000000..6cb8e52 --- /dev/null +++ b/src/PowerShellEditorServices/Services/Symbols/ReferenceTable.cs @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Concurrent; +using System.Management.Automation.Language; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Services.Symbols; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.PowerShell.EditorServices.Services; + +/// +/// Represents the symbols that are referenced and their locations within a single document. +/// +internal sealed class ReferenceTable +{ + private readonly ScriptFile _parent; + + private readonly ConcurrentDictionary> _symbolReferences = new(StringComparer.OrdinalIgnoreCase); + + private bool _isInited; + + public ReferenceTable(ScriptFile parent) => _parent = parent; + + /// + /// Clears the reference table causing it to re-scan the source AST when queried. + /// + public void TagAsChanged() + { + _symbolReferences.Clear(); + _isInited = false; + } + + /// + /// Prefer checking if the dictionary has contents to determine if initialized. The field + /// `_isInited` is to guard against re-scanning files with no command references, but will + /// generally be less reliable of a check. + /// + private bool IsInitialized => !_symbolReferences.IsEmpty || _isInited; + + internal IEnumerable TryGetReferences(SymbolReference? symbol) + { + EnsureInitialized(); + return symbol is not null + && _symbolReferences.TryGetValue(symbol.Id, out ConcurrentBag? bag) + ? bag + : Enumerable.Empty(); + } + + // Gets symbol whose name contains the position + internal SymbolReference? TryGetSymbolAtPosition(int line, int column) => GetAllReferences() + .FirstOrDefault(i => i.NameRegion.ContainsPosition(line, column)); + + // Gets symbol whose whole extent contains the position + internal SymbolReference? TryGetSymbolContainingPosition(int line, int column) => GetAllReferences() + .FirstOrDefault(i => i.ScriptRegion.ContainsPosition(line, column)); + + internal IEnumerable GetAllReferences() + { + EnsureInitialized(); + foreach (ConcurrentBag bag in _symbolReferences.Values) + { + foreach (SymbolReference symbol in bag) + { + yield return symbol; + } + } + } + + internal void EnsureInitialized() + { + if (IsInitialized) + { + return; + } + + _parent.ScriptAst.Visit(new SymbolVisitor(_parent, AddReference)); + } + + private AstVisitAction AddReference(SymbolReference symbol) + { + // We have to exclude implicit things like `$this` that don't actually exist. + if (symbol.ScriptRegion.IsEmpty()) + { + return AstVisitAction.Continue; + } + + _symbolReferences.AddOrUpdate( + symbol.Id, + _ => new ConcurrentBag { symbol }, + (_, existing) => + { + // Keep only the first variable encountered as a declaration marked as such. This + // keeps the first assignment without also counting every reassignment as a + // declaration (cleaning up e.g. Code's outline view). + if (symbol.Type is SymbolType.Variable && symbol.IsDeclaration + && existing.Any(i => i.IsDeclaration)) + { + symbol = symbol with { IsDeclaration = false }; + } + + existing.Add(symbol); + return existing; + }); + + return AstVisitAction.Continue; + } +} diff --git a/src/PowerShellEditorServices/Services/Symbols/RegionDocumentSymbolProvider.cs b/src/PowerShellEditorServices/Services/Symbols/RegionDocumentSymbolProvider.cs new file mode 100644 index 0000000..e3b33b0 --- /dev/null +++ b/src/PowerShellEditorServices/Services/Symbols/RegionDocumentSymbolProvider.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Management.Automation.Language; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Services.Symbols +{ + /// + /// Provides an IDocumentSymbolProvider implementation for + /// enumerating regions as symbols in script (.psd1, .psm1) files. + /// + internal class RegionDocumentSymbolProvider : IDocumentSymbolProvider + { + string IDocumentSymbolProvider.ProviderId => nameof(RegionDocumentSymbolProvider); + + IEnumerable IDocumentSymbolProvider.ProvideDocumentSymbols(ScriptFile scriptFile) + { + Stack tokenCommentRegionStack = new(); + Token[] tokens = scriptFile.ScriptTokens; + + for (int i = 0; i < tokens.Length; i++) + { + Token token = tokens[i]; + + // Exclude everything but single-line comments + if (token.Kind != TokenKind.Comment || + token.Extent.StartLineNumber != token.Extent.EndLineNumber || + !TokenOperations.IsBlockComment(i, tokens)) + { + continue; + } + + // Processing for #region -> #endregion + if (TokenOperations.s_startRegionTextRegex.IsMatch(token.Text)) + { + tokenCommentRegionStack.Push(token); + continue; + } + + if (TokenOperations.s_endRegionTextRegex.IsMatch(token.Text)) + { + // Mismatched regions in the script can cause bad stacks. + if (tokenCommentRegionStack.Count > 0) + { + Token regionStart = tokenCommentRegionStack.Pop(); + Token regionEnd = token; + + BufferRange regionRange = new( + regionStart.Extent.StartLineNumber, + regionStart.Extent.StartColumnNumber, + regionEnd.Extent.EndLineNumber, + regionEnd.Extent.EndColumnNumber); + + yield return new SymbolReference( + SymbolType.Region, + regionStart.Extent.Text.Trim().TrimStart('#'), + regionStart.Extent.Text.Trim(), + regionStart.Extent, + new ScriptExtent() + { + Text = string.Join(System.Environment.NewLine, scriptFile.GetLinesInRange(regionRange)), + StartLineNumber = regionStart.Extent.StartLineNumber, + StartColumnNumber = regionStart.Extent.StartColumnNumber, + StartOffset = regionStart.Extent.StartOffset, + EndLineNumber = regionEnd.Extent.EndLineNumber, + EndColumnNumber = regionEnd.Extent.EndColumnNumber, + EndOffset = regionEnd.Extent.EndOffset, + File = regionStart.Extent.File + }, + scriptFile, + isDeclaration: true); + } + } + } + } + } +} diff --git a/src/PowerShellEditorServices/Services/Symbols/ScriptDocumentSymbolProvider.cs b/src/PowerShellEditorServices/Services/Symbols/ScriptDocumentSymbolProvider.cs new file mode 100644 index 0000000..c2f60f8 --- /dev/null +++ b/src/PowerShellEditorServices/Services/Symbols/ScriptDocumentSymbolProvider.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Services.Symbols +{ + /// + /// Provides an IDocumentSymbolProvider implementation for + /// enumerating symbols in script (.psd1, .psm1) files. + /// + internal class ScriptDocumentSymbolProvider : IDocumentSymbolProvider + { + string IDocumentSymbolProvider.ProviderId => nameof(ScriptDocumentSymbolProvider); + + IEnumerable IDocumentSymbolProvider.ProvideDocumentSymbols( + ScriptFile scriptFile) => scriptFile.References.GetAllReferences(); + } +} diff --git a/src/PowerShellEditorServices/Services/Symbols/ScriptExtent.cs b/src/PowerShellEditorServices/Services/Symbols/ScriptExtent.cs new file mode 100644 index 0000000..1cd87f4 --- /dev/null +++ b/src/PowerShellEditorServices/Services/Symbols/ScriptExtent.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Management.Automation.Language; + +namespace Microsoft.PowerShell.EditorServices.Services.Symbols +{ + /// + /// Provides a default IScriptExtent implementation + /// containing details about a section of script content + /// in a file. + /// + internal class ScriptExtent : IScriptExtent + { + #region Properties + + /// + /// Gets the file path of the script file in which this extent is contained. + /// + public string File + { + get; + set; + } + + /// + /// Gets or sets the starting column number of the extent. + /// + public int StartColumnNumber + { + get; + set; + } + + /// + /// Gets or sets the starting line number of the extent. + /// + public int StartLineNumber + { + get; + set; + } + + /// + /// Gets or sets the starting file offset of the extent. + /// + public int StartOffset + { + get; + set; + } + + /// + /// Gets or sets the starting script position of the extent. + /// + public IScriptPosition StartScriptPosition => throw new NotImplementedException(); + /// + /// Gets or sets the text that is contained within the extent. + /// + public string Text + { + get; + set; + } + + /// + /// Gets or sets the ending column number of the extent. + /// + public int EndColumnNumber + { + get; + set; + } + + /// + /// Gets or sets the ending line number of the extent. + /// + public int EndLineNumber + { + get; + set; + } + + /// + /// Gets or sets the ending file offset of the extent. + /// + public int EndOffset + { + get; + set; + } + + public override string ToString() => Text; + + /// + /// Gets the ending script position of the extent. + /// + public IScriptPosition EndScriptPosition => throw new NotImplementedException(); + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Services/Symbols/SymbolDetails.cs b/src/PowerShellEditorServices/Services/Symbols/SymbolDetails.cs new file mode 100644 index 0000000..dc0a57f --- /dev/null +++ b/src/PowerShellEditorServices/Services/Symbols/SymbolDetails.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Management.Automation; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Services.PowerShell; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; + +namespace Microsoft.PowerShell.EditorServices.Services.Symbols +{ + /// + /// Provides detailed information for a given symbol. + /// TODO: Get rid of this and just use return documentation. + /// + [DebuggerDisplay("SymbolReference = {SymbolReference.SymbolType}/{SymbolReference.SymbolName}, DisplayString = {DisplayString}")] + internal class SymbolDetails + { + #region Properties + + /// + /// Gets the original symbol reference which was used to gather details. + /// + public SymbolReference SymbolReference { get; private set; } + + /// + /// Gets the documentation string for this symbol. Returns an + /// empty string if the symbol has no documentation. + /// + public string Documentation { get; private set; } + + #endregion + + #region Constructors + + internal static async Task CreateAsync( + SymbolReference symbolReference, + IRunspaceInfo currentRunspace, + IInternalPowerShellExecutionService executionService, + CancellationToken cancellationToken) + { + SymbolDetails symbolDetails = new() + { + SymbolReference = symbolReference + }; + + if (symbolReference.Type is SymbolType.Function) + { + CommandInfo commandInfo = await CommandHelpers.GetCommandInfoAsync( + symbolReference.Id, + currentRunspace, + executionService, + cancellationToken).ConfigureAwait(false); + + if (commandInfo is not null) + { + symbolDetails.Documentation = + await CommandHelpers.GetCommandSynopsisAsync( + commandInfo, + executionService, + cancellationToken).ConfigureAwait(false); + + if (commandInfo.CommandType == CommandTypes.Application) + { + symbolDetails.SymbolReference = symbolReference with { Name = $"(application) ${symbolReference.Name}" }; + } + } + } + + return symbolDetails; + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Services/Symbols/SymbolReference.cs b/src/PowerShellEditorServices/Services/Symbols/SymbolReference.cs new file mode 100644 index 0000000..5e36bf6 --- /dev/null +++ b/src/PowerShellEditorServices/Services/Symbols/SymbolReference.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System.Diagnostics; +using System.Management.Automation.Language; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Services.Symbols +{ + /// + /// A class that holds the type, name, script extent, and source line of a symbol + /// + [DebuggerDisplay("Type = {Type}, Id = {Id}, Name = {Name}")] + internal record SymbolReference + { + public SymbolType Type { get; init; } + + public string Id { get; init; } + + public string Name { get; init; } + + public ScriptRegion NameRegion { get; init; } + + public ScriptRegion ScriptRegion { get; init; } + + public string SourceLine { get; internal set; } + + public string FilePath { get; internal set; } + + public bool IsDeclaration { get; init; } + + /// + /// Constructs and instance of a SymbolReference + /// + /// The higher level type of the symbol + /// The name of the symbol + /// The string used by outline, hover, etc. + /// The extent of the symbol's name + /// The script extent of the symbol + /// The script file that has the symbol + /// True if this reference is the definition of the symbol + public SymbolReference( + SymbolType type, + string id, + string name, + IScriptExtent nameExtent, + IScriptExtent scriptExtent, + ScriptFile file, + bool isDeclaration) + { + Type = type; + Id = id; + Name = name; + NameRegion = new(nameExtent); + ScriptRegion = new(scriptExtent); + FilePath = file.FilePath; + try + { + SourceLine = file.GetLine(ScriptRegion.StartLineNumber); + } + catch (System.ArgumentOutOfRangeException) + { + SourceLine = string.Empty; + } + IsDeclaration = isDeclaration; + } + + /// + /// This is only used for unit tests! + /// + internal SymbolReference(string id, SymbolType type) + { + Id = id; + Type = type; + Name = ""; + NameRegion = new("", "", 0, 0, 0, 0, 0, 0); + ScriptRegion = NameRegion; + SourceLine = ""; + FilePath = ""; + } + } +} diff --git a/src/PowerShellEditorServices/Services/Symbols/SymbolType.cs b/src/PowerShellEditorServices/Services/Symbols/SymbolType.cs new file mode 100644 index 0000000..02e34e6 --- /dev/null +++ b/src/PowerShellEditorServices/Services/Symbols/SymbolType.cs @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Services.Symbols +{ + /// + /// A way to define symbols on a higher level + /// + internal enum SymbolType + { + /// + /// The symbol type is unknown + /// + Unknown = 0, + + /// + /// The symbol is a variable + /// + Variable, + + /// + /// The symbol is a function + /// + Function, + + /// + /// The symbol is a parameter + /// + Parameter, + + /// + /// The symbol is a DSC configuration + /// + Configuration, + + /// + /// The symbol is a workflow + /// + Workflow, + + /// + /// The symbol is a hashtable key + /// + HashtableKey, + + /// + /// The symbol is a class + /// + Class, + + /// + /// The symbol is a enum + /// + Enum, + + /// + /// The symbol is a enum member/value + /// + EnumMember, + + /// + /// The symbol is a class property + /// + Property, + + /// + /// The symbol is a class method + /// + Method, + + /// + /// The symbol is a class constructor + /// + Constructor, + + /// + /// The symbol is a type reference + /// + Type, + + /// + /// The symbol is a region. Only used for navigation-features. + /// + Region + } + + internal static class SymbolTypeUtils + { + internal static SymbolKind GetSymbolKind(SymbolType symbolType) + { + return symbolType switch + { + SymbolType.Function or SymbolType.Configuration or SymbolType.Workflow => SymbolKind.Function, + SymbolType.Enum => SymbolKind.Enum, + SymbolType.Class => SymbolKind.Class, + SymbolType.Constructor => SymbolKind.Constructor, + SymbolType.Method => SymbolKind.Method, + SymbolType.Property => SymbolKind.Property, + SymbolType.EnumMember => SymbolKind.EnumMember, + SymbolType.Variable or SymbolType.Parameter => SymbolKind.Variable, + SymbolType.HashtableKey => SymbolKind.Key, + SymbolType.Type => SymbolKind.TypeParameter, + SymbolType.Region => SymbolKind.String, + SymbolType.Unknown or _ => SymbolKind.Object, + }; + } + } +} diff --git a/src/PowerShellEditorServices/Services/Symbols/SymbolsService.cs b/src/PowerShellEditorServices/Services/Symbols/SymbolsService.cs new file mode 100644 index 0000000..99f8bbb --- /dev/null +++ b/src/PowerShellEditorServices/Services/Symbols/SymbolsService.cs @@ -0,0 +1,505 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation; +using System.Management.Automation.Language; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.CodeLenses; +using Microsoft.PowerShell.EditorServices.Logging; +using Microsoft.PowerShell.EditorServices.Services.Configuration; +using Microsoft.PowerShell.EditorServices.Services.PowerShell; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; +using Microsoft.PowerShell.EditorServices.Services.Symbols; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Utility; + +namespace Microsoft.PowerShell.EditorServices.Services +{ + /// + /// Provides a high-level service for performing code completion and + /// navigation operations on PowerShell scripts. + /// + internal class SymbolsService + { + #region Private Fields + + private readonly ILogger _logger; + private readonly IRunspaceContext _runspaceContext; + private readonly IInternalPowerShellExecutionService _executionService; + private readonly WorkspaceService _workspaceService; + + private readonly ConcurrentDictionary _codeLensProviders; + private readonly ConcurrentDictionary _documentSymbolProviders; + private readonly ConfigurationService _configurationService; + private Task? _workspaceScanCompleted; + + #endregion Private Fields + #region Constructors + + /// + /// Constructs an instance of the SymbolsService class and uses + /// the given Runspace to execute language service operations. + /// + /// An ILoggerFactory implementation used for writing log messages. + /// + /// + /// + /// + public SymbolsService( + ILoggerFactory factory, + IRunspaceContext runspaceContext, + IInternalPowerShellExecutionService executionService, + WorkspaceService workspaceService, + ConfigurationService configurationService) + { + _logger = factory.CreateLogger(); + _runspaceContext = runspaceContext; + _executionService = executionService; + _workspaceService = workspaceService; + _configurationService = configurationService; + + _codeLensProviders = new ConcurrentDictionary(); + if (configurationService.CurrentSettings.EnableReferencesCodeLens) + { + ReferencesCodeLensProvider referencesProvider = new(_workspaceService, this); + _ = _codeLensProviders.TryAdd(referencesProvider.ProviderId, referencesProvider); + } + + PesterCodeLensProvider pesterProvider = new(configurationService); + _ = _codeLensProviders.TryAdd(pesterProvider.ProviderId, pesterProvider); + + _documentSymbolProviders = new ConcurrentDictionary(); + IDocumentSymbolProvider[] documentSymbolProviders = new IDocumentSymbolProvider[] + { + new ScriptDocumentSymbolProvider(), + new PsdDocumentSymbolProvider(), + new PesterDocumentSymbolProvider() + // NOTE: This specifically does not include RegionDocumentSymbolProvider. + }; + + foreach (IDocumentSymbolProvider documentSymbolProvider in documentSymbolProviders) + { + _ = _documentSymbolProviders.TryAdd(documentSymbolProvider.ProviderId, documentSymbolProvider); + } + } + + #endregion Constructors + + public bool TryRegisterCodeLensProvider(ICodeLensProvider codeLensProvider) => _codeLensProviders.TryAdd(codeLensProvider.ProviderId, codeLensProvider); + + public bool DeregisterCodeLensProvider(string providerId) => _codeLensProviders.TryRemove(providerId, out _); + + public IEnumerable GetCodeLensProviders() => _codeLensProviders.Values; + + public bool TryRegisterDocumentSymbolProvider(IDocumentSymbolProvider documentSymbolProvider) => _documentSymbolProviders.TryAdd(documentSymbolProvider.ProviderId, documentSymbolProvider); + + public bool DeregisterDocumentSymbolProvider(string providerId) => _documentSymbolProviders.TryRemove(providerId, out _); + + public IEnumerable GetDocumentSymbolProviders() => _documentSymbolProviders.Values; + + /// + /// Finds all the symbols in a file, through all document symbol providers. + /// + /// The ScriptFile in which the symbol can be located. + public IEnumerable FindSymbolsInFile(ScriptFile scriptFile) + { + Validate.IsNotNull(nameof(scriptFile), scriptFile); + + foreach (IDocumentSymbolProvider symbolProvider in GetDocumentSymbolProviders()) + { + foreach (SymbolReference symbol in symbolProvider.ProvideDocumentSymbols(scriptFile)) + { + yield return symbol; + } + } + } + + /// + /// Finds the symbol in the script given a file location. + /// + public static SymbolReference? FindSymbolAtLocation( + ScriptFile scriptFile, int line, int column) + { + Validate.IsNotNull(nameof(scriptFile), scriptFile); + return scriptFile.References.TryGetSymbolAtPosition(line, column); + } + + // Using a private method here to get a bit more readability and to avoid roslynator + // asserting we should use a giant nested ternary. + private static string[] GetIdentifiers(string symbolName, SymbolType symbolType, CommandHelpers.AliasMap aliases) + { + if (!aliases.CmdletToAliases.TryGetValue(symbolName, out List foundAliasList)) + { + return new[] { symbolName }; + } + + return foundAliasList.Prepend(symbolName) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + /// + /// Finds all the references of a symbol in the workspace, resolving aliases. + /// TODO: One day use IAsyncEnumerable. + /// + public async Task> ScanForReferencesOfSymbolAsync( + SymbolReference symbol, + CancellationToken cancellationToken = default) + { + if (symbol is null) + { + return Enumerable.Empty(); + } + + // We want to handle aliases for functions, but we only want to do the work of getting + // the aliases when we must. We can't cache the alias list on first run else we won't + // support newly defined aliases. + string[] allIdentifiers; + if (symbol.Type is SymbolType.Function) + { + CommandHelpers.AliasMap aliases = await CommandHelpers.GetAliasesAsync( + _executionService, + cancellationToken).ConfigureAwait(false); + + string targetName = symbol.Id; + if (aliases.AliasToCmdlets.TryGetValue(symbol.Id, out string aliasDefinition)) + { + targetName = aliasDefinition; + } + allIdentifiers = GetIdentifiers(targetName, symbol.Type, aliases); + } + else + { + allIdentifiers = new[] { symbol.Id }; + } + + await ScanWorkspacePSFiles(cancellationToken).ConfigureAwait(false); + + List symbols = new(); + + foreach (ScriptFile file in _workspaceService.GetOpenedFiles()) + { + foreach (string targetIdentifier in allIdentifiers) + { + await Task.Yield(); + if (cancellationToken.IsCancellationRequested) + { + break; + } + + symbols.AddRange(file.References.TryGetReferences(symbol with { Id = targetIdentifier })); + } + } + + return symbols; + } + + /// + /// Finds all the occurrences of a symbol in the script given a file location. + /// TODO: Doesn't support aliases, is it worth it? + /// + public static IEnumerable FindOccurrencesInFile( + ScriptFile scriptFile, int line, int column) => scriptFile + .References + .TryGetReferences(FindSymbolAtLocation(scriptFile, line, column)); + + /// + /// Finds the symbol at the location and returns it if it's a declaration. + /// + public static SymbolReference? FindSymbolDefinitionAtLocation( + ScriptFile scriptFile, int line, int column) + { + SymbolReference? symbol = FindSymbolAtLocation(scriptFile, line, column); + return symbol?.IsDeclaration == true ? symbol : null; + } + + /// + /// Finds the details of the symbol at the given script file location. + /// + public Task FindSymbolDetailsAtLocationAsync( + ScriptFile scriptFile, int line, int column, CancellationToken cancellationToken) + { + SymbolReference? symbol = FindSymbolAtLocation(scriptFile, line, column); + return symbol is null + ? Task.FromResult(null) + : SymbolDetails.CreateAsync( + symbol, + _runspaceContext.CurrentRunspace, + _executionService, + cancellationToken); + } + + /// + /// Finds the parameter set hints of a specific command (determined by a given file location) + /// + public async Task FindParameterSetsInFileAsync( + ScriptFile scriptFile, int line, int column) + { + // This needs to get by whole extent, not just the name, as it completes e.g. + // `Get-Process -` (after the dash). + SymbolReference? symbol = scriptFile.References.TryGetSymbolContainingPosition(line, column); + + // If we are not possibly looking at a Function, we don't + // need to continue because we won't be able to get the + // CommandInfo object. + if (symbol?.Type is not SymbolType.Function + and not SymbolType.Unknown) + { + return null; + } + + CommandInfo commandInfo = + await CommandHelpers.GetCommandInfoAsync( + symbol.Id, + _runspaceContext.CurrentRunspace, + _executionService).ConfigureAwait(false); + + if (commandInfo is null) + { + return null; + } + + try + { + // TODO: We should probably look at 'Parameters' instead of 'ParameterSets' + IEnumerable commandParamSets = commandInfo.ParameterSets; + return new ParameterSetSignatures(commandParamSets, symbol); + } + catch (RuntimeException e) + { + // A RuntimeException will be thrown when an invalid attribute is + // on a parameter binding block and then that command/script has + // its signatures resolved by typing it into a script. + _logger.LogException("RuntimeException encountered while accessing command parameter sets", e); + + return null; + } + catch (InvalidOperationException) + { + // For some commands there are no paramsets (like applications). Until + // the valid command types are better understood, catch this exception + // which gets raised when there are no ParameterSets for the command type. + return null; + } + } + + /// + /// Finds the possible definitions of the symbol in the file or workspace. + /// TODO: One day use IAsyncEnumerable. + /// TODO: Fix searching for definition of built-in commands. + /// TODO: Fix "definition" of dot-source (maybe?) + /// + public async Task> GetDefinitionOfSymbolAsync( + ScriptFile scriptFile, + SymbolReference symbol, + CancellationToken cancellationToken = default) + { + List declarations = new(); + declarations.AddRange(scriptFile.References.TryGetReferences(symbol).Where(i => i.IsDeclaration)); + if (declarations.Count > 0) + { + _logger.LogDebug($"Found possible declaration in same file ${declarations}"); + return declarations; + } + + IEnumerable references = + await ScanForReferencesOfSymbolAsync(symbol, cancellationToken).ConfigureAwait(false); + declarations.AddRange(references.Where(i => i.IsDeclaration)); + + _logger.LogDebug($"Found possible declaration in workspace ${declarations}"); + return declarations; + } + + internal async Task ScanWorkspacePSFiles(CancellationToken cancellationToken = default) + { + if (_configurationService.CurrentSettings.AnalyzeOpenDocumentsOnly) + { + return; + } + + Task? scanTask = _workspaceScanCompleted; + // It's not impossible for two scans to start at once but it should be exceedingly + // unlikely, and shouldn't break anything if it happens to. So we can save some + // lock time by accepting that possibility. + if (scanTask is null) + { + scanTask = Task.Run( + () => + { + foreach (string file in _workspaceService.EnumeratePSFiles()) + { + if (_workspaceService.TryGetFile(file, out ScriptFile scriptFile)) + { + scriptFile.References.EnsureInitialized(); + } + } + }, + CancellationToken.None); + + // Ignore the analyzer yelling that we're not awaiting this task, we'll get there. +#pragma warning disable CS4014 + Interlocked.CompareExchange(ref _workspaceScanCompleted, scanTask, null); +#pragma warning restore CS4014 + } + + // In the simple case where the task is already completed or the token we're given cannot + // be cancelled, do a simple await. + if (scanTask.IsCompleted || !cancellationToken.CanBeCanceled) + { + await scanTask.ConfigureAwait(false); + return; + } + + // If it's not yet done and we can be cancelled, create a new task to represent the + // cancellation. That way we can exit a request that relies on the scan without + // having to actually stop the work (and then request it again in a few seconds). + // + // TODO: There's a new API in net6 that lets you await a task with a cancellation token. + // we should #if that in if feasible. + TaskCompletionSource cancelled = new(); + _ = cancellationToken.Register(() => cancelled.TrySetCanceled()); + _ = await Task.WhenAny(scanTask, cancelled.Task).ConfigureAwait(false); + } + + /// + /// Finds a function definition that follows or contains the given line number. + /// + public static FunctionDefinitionAst? GetFunctionDefinitionForHelpComment( + ScriptFile scriptFile, + int line, + out string? helpLocation) + { + Validate.IsNotNull(nameof(scriptFile), scriptFile); + // check if the next line contains a function definition + FunctionDefinitionAst? funcDefnAst = GetFunctionDefinitionAtLine(scriptFile, line + 1); + if (funcDefnAst is not null) + { + helpLocation = "before"; + return funcDefnAst; + } + + // find all the script definitions that contain the line `lineNumber` + IEnumerable foundAsts = scriptFile.ScriptAst.FindAll( + ast => + { + if (ast is not FunctionDefinitionAst fdAst) + { + return false; + } + + return fdAst.Body.Extent.StartLineNumber < line && + fdAst.Body.Extent.EndLineNumber > line; + }, + true); + + if (foundAsts?.Any() != true) + { + helpLocation = null; + return null; + } + + // of all the function definitions found, return the innermost function + // definition that contains `lineNumber` + foreach (FunctionDefinitionAst foundAst in foundAsts.Cast()) + { + if (funcDefnAst is null) + { + funcDefnAst = foundAst; + continue; + } + + if (funcDefnAst.Extent.StartOffset >= foundAst.Extent.StartOffset + && funcDefnAst.Extent.EndOffset <= foundAst.Extent.EndOffset) + { + funcDefnAst = foundAst; + } + } + + // TODO: use tokens to check for non empty character instead of just checking for line offset + if (funcDefnAst?.Body.Extent.StartLineNumber == line - 1) + { + helpLocation = "begin"; + return funcDefnAst; + } + + if (funcDefnAst?.Body.Extent.EndLineNumber == line + 1) + { + helpLocation = "end"; + return funcDefnAst; + } + + // If we didn't find a function definition, then return null + helpLocation = null; + return null; + } + + /// + /// Gets the function defined on a given line. + /// TODO: Remove this. + /// + public static FunctionDefinitionAst? GetFunctionDefinitionAtLine( + ScriptFile scriptFile, + int lineNumber) + { + Ast functionDefinitionAst = scriptFile.ScriptAst.Find( + ast => ast is FunctionDefinitionAst && ast.Extent.StartLineNumber == lineNumber, + true); + + return functionDefinitionAst as FunctionDefinitionAst; + } + + internal void OnConfigurationUpdated(object _, LanguageServerSettings e) + { + if (e.AnalyzeOpenDocumentsOnly) + { + Task? scanInProgress = _workspaceScanCompleted; + if (scanInProgress is not null) + { + // Wait until after the scan completes to close unopened files. + _ = scanInProgress.ContinueWith(_ => CloseUnopenedFiles(), TaskScheduler.Default); + } + else + { + CloseUnopenedFiles(); + } + + _workspaceScanCompleted = null; + + void CloseUnopenedFiles() + { + foreach (ScriptFile scriptFile in _workspaceService.GetOpenedFiles()) + { + if (scriptFile.IsOpen) + { + continue; + } + + _workspaceService.CloseFile(scriptFile); + } + } + } + + if (e.EnableReferencesCodeLens) + { + if (_codeLensProviders.ContainsKey(ReferencesCodeLensProvider.Id)) + { + return; + } + + TryRegisterCodeLensProvider(new ReferencesCodeLensProvider(_workspaceService, this)); + return; + } + + DeregisterCodeLensProvider(ReferencesCodeLensProvider.Id); + } + } +} diff --git a/src/PowerShellEditorServices/Services/Symbols/Visitors/AstOperations.cs b/src/PowerShellEditorServices/Services/Symbols/Visitors/AstOperations.cs new file mode 100644 index 0000000..65d2d03 --- /dev/null +++ b/src/PowerShellEditorServices/Services/Symbols/Visitors/AstOperations.cs @@ -0,0 +1,184 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq.Expressions; +using System.Management.Automation; +using System.Management.Automation.Language; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services.PowerShell; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; + +namespace Microsoft.PowerShell.EditorServices.Services.Symbols +{ + /// + /// Provides common operations for the syntax tree of a parsed script. + /// + internal static class AstOperations + { + private static readonly Func s_clonePositionWithNewOffset; + + static AstOperations() + { + Type internalScriptPositionType = typeof(PSObject).GetTypeInfo().Assembly + .GetType("System.Management.Automation.Language.InternalScriptPosition"); + + MethodInfo cloneWithNewOffsetMethod = internalScriptPositionType.GetMethod("CloneWithNewOffset", BindingFlags.Instance | BindingFlags.NonPublic); + + ParameterExpression originalPosition = Expression.Parameter(typeof(IScriptPosition)); + ParameterExpression newOffset = Expression.Parameter(typeof(int)); + + ParameterExpression[] parameters = new ParameterExpression[] { originalPosition, newOffset }; + s_clonePositionWithNewOffset = Expression.Lambda>( + Expression.Call( + Expression.Convert(originalPosition, internalScriptPositionType), + cloneWithNewOffsetMethod, + newOffset), + parameters).Compile(); + } + + /// + /// Gets completions for the symbol found in the Ast at + /// the given file offset. + /// + /// + /// The Ast which will be traversed to find a completable symbol. + /// + /// + /// The array of tokens corresponding to the scriptAst parameter. + /// + /// + /// The 1-based file offset at which a symbol will be located. + /// + /// + /// The PowerShellContext to use for gathering completions. + /// + /// An ILogger implementation used for writing log messages. + /// + /// A CancellationToken to cancel completion requests. + /// + /// + /// A CommandCompletion instance that contains completions for the + /// symbol at the given offset. + /// + public static async Task GetCompletionsAsync( + Ast scriptAst, + Token[] currentTokens, + int fileOffset, + IInternalPowerShellExecutionService executionService, + ILogger logger, + CancellationToken cancellationToken) + { + IScriptPosition cursorPosition = s_clonePositionWithNewOffset(scriptAst.Extent.StartScriptPosition, fileOffset); + Stopwatch stopwatch = new(); + logger.LogTrace($"Getting completions at offset {fileOffset} (line: {cursorPosition.LineNumber}, column: {cursorPosition.ColumnNumber})"); + + CommandCompletion commandCompletion = await executionService.ExecuteDelegateAsync( + representation: "CompleteInput", + new ExecutionOptions { Priority = ExecutionPriority.Next }, + (pwsh, _) => + { + stopwatch.Start(); + + // If the current runspace is not out of process, then we call TabExpansion2 so + // that we have the ability to issue pipeline stop requests on cancellation. + if (executionService is PsesInternalHost psesInternalHost + && !psesInternalHost.Runspace.RunspaceIsRemote) + { + IReadOnlyList completionResults = new SynchronousPowerShellTask( + logger, + psesInternalHost, + new PSCommand() + .AddCommand("TabExpansion2") + .AddParameter("ast", scriptAst) + .AddParameter("tokens", currentTokens) + .AddParameter("positionOfCursor", cursorPosition), + executionOptions: null, + cancellationToken) + .ExecuteAndGetResult(cancellationToken); + + if (completionResults is { Count: > 0 }) + { + return completionResults[0]; + } + + return null; + } + + // If the current runspace is out of process, we can't call TabExpansion2 + // because the output will be serialized. + return CommandCompletion.CompleteInput( + scriptAst, + currentTokens, + cursorPosition, + options: null, + powershell: pwsh); + }, + cancellationToken).ConfigureAwait(false); + + stopwatch.Stop(); + + if (commandCompletion is null) + { + logger.LogError("Error Occurred in TabExpansion2"); + } + else + { + logger.LogTrace( + "IntelliSense completed in {elapsed}ms - WordToComplete: \"{word}\" MatchCount: {count}", + stopwatch.ElapsedMilliseconds, + commandCompletion.ReplacementLength > 0 + ? scriptAst.Extent.StartScriptPosition.GetFullScript()?.Substring( + commandCompletion.ReplacementIndex, + commandCompletion.ReplacementLength) + : null, + commandCompletion.CompletionMatches.Count); + } + return commandCompletion; + } + + internal static bool TryGetInferredValue(ExpandableStringExpressionAst expandableStringExpressionAst, out string value) + { + // Currently we only support inferring the value of `$PSScriptRoot`. We could potentially + // expand this to parts of `$MyInvocation` and some basic constant folding. + if (string.IsNullOrEmpty(expandableStringExpressionAst.Extent.File)) + { + value = null; + return false; + } + + string psScriptRoot = System.IO.Path.GetDirectoryName(expandableStringExpressionAst.Extent.File); + if (string.IsNullOrEmpty(psScriptRoot)) + { + value = null; + return false; + } + + string path = expandableStringExpressionAst.Value; + foreach (ExpressionAst nestedExpression in expandableStringExpressionAst.NestedExpressions) + { + // If the string contains the variable $PSScriptRoot, we replace it with the corresponding value. + if (!(nestedExpression is VariableExpressionAst variableAst + && variableAst.VariablePath.UserPath.Equals("PSScriptRoot", StringComparison.OrdinalIgnoreCase))) + { + value = null; + return false; + } + + // TODO: This should use offsets from the extent rather than a blind replace. In + // practice it won't hurt anything because $ is not valid in paths, but if we expand + // this functionality, this will be problematic. + path = path.Replace(variableAst.ToString(), psScriptRoot); + } + + value = path; + return true; + } + } +} diff --git a/src/PowerShellEditorServices/Services/Symbols/Visitors/HashTableVisitor.cs b/src/PowerShellEditorServices/Services/Symbols/Visitors/HashTableVisitor.cs new file mode 100644 index 0000000..3d9100c --- /dev/null +++ b/src/PowerShellEditorServices/Services/Symbols/Visitors/HashTableVisitor.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System.Collections.Generic; +using System.Management.Automation.Language; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Services.Symbols +{ + /// + /// Visitor to find all the keys in Hashtable AST + /// + internal class FindHashtableSymbolsVisitor : AstVisitor + { + private readonly ScriptFile _file; + + /// + /// List of symbols (keys) found in the hashtable + /// + public List SymbolReferences { get; } + + /// + /// Initializes a new instance of FindHashtableSymbolsVisitor class + /// + public FindHashtableSymbolsVisitor(ScriptFile file) + { + SymbolReferences = new List(); + _file = file; + } + + /// + /// Adds keys in the input hashtable to the symbol reference + /// + /// A HashtableAst in the script's AST + /// A visit action that continues the search for references + public override AstVisitAction VisitHashtable(HashtableAst hashtableAst) + { + if (hashtableAst.KeyValuePairs == null) + { + return AstVisitAction.Continue; + } + + foreach (System.Tuple kvp in hashtableAst.KeyValuePairs) + { + if (kvp.Item1 is StringConstantExpressionAst keyStrConstExprAst) + { + IScriptExtent nameExtent = new ScriptExtent() + { + Text = keyStrConstExprAst.Value, + StartLineNumber = kvp.Item1.Extent.StartLineNumber, + EndLineNumber = kvp.Item2.Extent.EndLineNumber, + StartColumnNumber = kvp.Item1.Extent.StartColumnNumber, + EndColumnNumber = kvp.Item2.Extent.EndColumnNumber, + File = hashtableAst.Extent.File + }; + + SymbolReferences.Add( + // TODO: Should we fill this out better? + new SymbolReference( + SymbolType.HashtableKey, + nameExtent.Text, + nameExtent.Text, + nameExtent, + nameExtent, + _file, + isDeclaration: false)); + } + } + + return AstVisitAction.Continue; + } + } +} diff --git a/src/PowerShellEditorServices/Services/Symbols/Visitors/SymbolVisitor.cs b/src/PowerShellEditorServices/Services/Symbols/Visitors/SymbolVisitor.cs new file mode 100644 index 0000000..58d7f58 --- /dev/null +++ b/src/PowerShellEditorServices/Services/Symbols/Visitors/SymbolVisitor.cs @@ -0,0 +1,281 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Management.Automation.Language; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Utility; + +namespace Microsoft.PowerShell.EditorServices.Services.Symbols; + +/// +/// The goal of this is to be our one and only visitor, which parses a file when necessary +/// performing Action, which takes a SymbolReference (that this visitor creates) and returns an +/// AstVisitAction. In this way, all our symbols are created with the same initialization logic. +/// TODO: Visit hashtable keys, constants, arrays, namespaces, interfaces, operators, etc. +/// +internal sealed class SymbolVisitor : AstVisitor2 +{ + private readonly ScriptFile _file; + + private readonly Func _action; + + public SymbolVisitor(ScriptFile file, Func action) + { + _file = file; + _action = action; + } + + // TODO: Make all the display strings better (and performant). + public override AstVisitAction VisitCommand(CommandAst commandAst) + { + string? commandName = VisitorUtils.GetCommandName(commandAst); + if (commandName is null) + { + return AstVisitAction.Continue; + } + + return _action(new SymbolReference( + SymbolType.Function, + "fn " + VisitorUtils.GetUnqualifiedFunctionName(CommandHelpers.StripModuleQualification(commandName, out _)), + commandName, + commandAst.CommandElements[0].Extent, + commandAst.Extent, + _file, + isDeclaration: false)); + } + + public override AstVisitAction VisitFunctionDefinition(FunctionDefinitionAst functionDefinitionAst) + { + SymbolType symbolType = functionDefinitionAst.IsWorkflow + ? SymbolType.Workflow + : SymbolType.Function; + + // Extent for constructors and method trigger both this and VisitFunctionMember(). Covered in the latter. + // This will not exclude nested functions as they have ScriptBlockAst as parent + if (functionDefinitionAst.Parent is FunctionMemberAst) + { + return AstVisitAction.Continue; + } + + IScriptExtent nameExtent = VisitorUtils.GetNameExtent(functionDefinitionAst); + return _action(new SymbolReference( + symbolType, + "fn " + VisitorUtils.GetUnqualifiedFunctionName(functionDefinitionAst.Name), + VisitorUtils.GetFunctionDisplayName(functionDefinitionAst), + nameExtent, + functionDefinitionAst.Extent, + _file, + isDeclaration: true)); + } + + public override AstVisitAction VisitParameter(ParameterAst parameterAst) + { + // TODO: Can we fix the display name's type by visiting this in VisitVariableExpression and + // getting the TypeConstraintAst somehow? + return _action(new SymbolReference( + SymbolType.Parameter, + "var " + parameterAst.Name.VariablePath.UserPath, + VisitorUtils.GetParamDisplayName(parameterAst), + parameterAst.Name.Extent, + parameterAst.Extent, + _file, + isDeclaration: true)); + } + + public override AstVisitAction VisitVariableExpression(VariableExpressionAst variableExpressionAst) + { + // Parameters are visited earlier, and we don't want to skip their children because we do + // want to visit their type constraints. + if (variableExpressionAst.Parent is ParameterAst) + { + return AstVisitAction.Continue; + } + + // Count $Function:MyFunction as function references. + if (variableExpressionAst.VariablePath.IsDriveQualified + && variableExpressionAst.VariablePath.DriveName.Equals("Function", StringComparison.OrdinalIgnoreCase)) + { + return _action(new SymbolReference( + SymbolType.Function, + "fn " + VisitorUtils.GetUnqualifiedVariableName(variableExpressionAst.VariablePath), + "$" + variableExpressionAst.VariablePath.UserPath, + variableExpressionAst.Extent, + variableExpressionAst.Extent, + _file, + false + )); + } + + // Traverse the parents to determine if this is a declaration. + static bool isDeclaration(Ast current) + { + Ast next = current.Parent; + while (true) + { + // Should come from an assignment statement. + if (next is AssignmentStatementAst assignment) + { + return assignment.Left == current; + } + // Or we might have type constraints or attributes to traverse first. + if (next is not ConvertExpressionAst or not AttributedExpressionAst) + { + return false; + } + current = next; + next = next.Parent; + } + } + + // TODO: Consider tracking unscoped variable references only when they're declared within + // the same function definition. + return _action(new SymbolReference( + SymbolType.Variable, + "var " + VisitorUtils.GetUnqualifiedVariableName(variableExpressionAst.VariablePath), + "$" + variableExpressionAst.VariablePath.UserPath, + variableExpressionAst.Extent, + variableExpressionAst.Extent, // TODO: Maybe parent? + _file, + isDeclaration(variableExpressionAst))); + } + + public override AstVisitAction VisitTypeDefinition(TypeDefinitionAst typeDefinitionAst) + { + SymbolType symbolType = typeDefinitionAst.IsEnum + ? SymbolType.Enum + : SymbolType.Class; + + IScriptExtent nameExtent = VisitorUtils.GetNameExtent(typeDefinitionAst); + return _action(new SymbolReference( + symbolType, + "type " + typeDefinitionAst.Name, + (symbolType is SymbolType.Enum ? "enum " : "class ") + typeDefinitionAst.Name + " { }", + nameExtent, + typeDefinitionAst.Extent, + _file, + isDeclaration: true)); + } + + public override AstVisitAction VisitTypeExpression(TypeExpressionAst typeExpressionAst) + { + return _action(new SymbolReference( + SymbolType.Type, + "type " + typeExpressionAst.TypeName.Name, + "(type) " + typeExpressionAst.TypeName.Name, + typeExpressionAst.Extent, + typeExpressionAst.Extent, + _file, + isDeclaration: false)); + } + + public override AstVisitAction VisitTypeConstraint(TypeConstraintAst typeConstraintAst) + { + return _action(new SymbolReference( + SymbolType.Type, + "type " + typeConstraintAst.TypeName.Name, + "(type) " + typeConstraintAst.TypeName.Name, + typeConstraintAst.Extent, + typeConstraintAst.Extent, + _file, + isDeclaration: false)); + } + + public override AstVisitAction VisitFunctionMember(FunctionMemberAst functionMemberAst) + { + SymbolType symbolType = functionMemberAst.IsConstructor + ? SymbolType.Constructor + : SymbolType.Method; + + IScriptExtent nameExtent = VisitorUtils.GetNameExtent(functionMemberAst); + + return _action(new SymbolReference( + symbolType, + "mtd " + functionMemberAst.Name, // We bucket all the overloads. + VisitorUtils.GetMemberOverloadName(functionMemberAst), + nameExtent, + functionMemberAst.Extent, + _file, + isDeclaration: true)); + } + + public override AstVisitAction VisitPropertyMember(PropertyMemberAst propertyMemberAst) + { + // Enum members and properties are the "same" according to PowerShell, so the symbol name's + // must be the same since we can't distinguish them in VisitMemberExpression. + SymbolType symbolType = + propertyMemberAst.Parent is TypeDefinitionAst typeAst && typeAst.IsEnum + ? SymbolType.EnumMember + : SymbolType.Property; + + return _action(new SymbolReference( + symbolType, + "prop " + propertyMemberAst.Name, + VisitorUtils.GetMemberOverloadName(propertyMemberAst), + VisitorUtils.GetNameExtent(propertyMemberAst), + propertyMemberAst.Extent, + _file, + isDeclaration: true)); + } + + public override AstVisitAction VisitMemberExpression(MemberExpressionAst memberExpressionAst) + { + string? memberName = memberExpressionAst.Member is StringConstantExpressionAst stringConstant ? stringConstant.Value : null; + if (string.IsNullOrEmpty(memberName)) + { + return AstVisitAction.Continue; + } + + // TODO: It's too bad we can't get the property's real symbol and reuse its display string. + return _action(new SymbolReference( + SymbolType.Property, + "prop " + memberName, + "(property) " + memberName, + memberExpressionAst.Member.Extent, + memberExpressionAst.Extent, + _file, + isDeclaration: false)); + } + + public override AstVisitAction VisitInvokeMemberExpression(InvokeMemberExpressionAst methodCallAst) + { + string? memberName = methodCallAst.Member is StringConstantExpressionAst stringConstant ? stringConstant.Value : null; + if (string.IsNullOrEmpty(memberName)) + { + return AstVisitAction.Continue; + } + + // TODO: It's too bad we can't get the member's real symbol and reuse its display string. + return _action(new SymbolReference( + SymbolType.Method, + "mtd " + memberName, + "(method) " + memberName, + methodCallAst.Member.Extent, + methodCallAst.Extent, + _file, + isDeclaration: false)); + } + + public override AstVisitAction VisitConfigurationDefinition(ConfigurationDefinitionAst configurationDefinitionAst) + { + string? name = configurationDefinitionAst.InstanceName is StringConstantExpressionAst stringConstant + ? stringConstant.Value : null; + if (string.IsNullOrEmpty(name)) + { + return AstVisitAction.Continue; + } + + IScriptExtent nameExtent = VisitorUtils.GetNameExtent(configurationDefinitionAst); + return _action(new SymbolReference( + SymbolType.Configuration, + "dsc " + name, + "configuration " + name + " { }", + nameExtent, + configurationDefinitionAst.Extent, + _file, + isDeclaration: true)); + } +} diff --git a/src/PowerShellEditorServices/Services/TextDocument/BufferPosition.cs b/src/PowerShellEditorServices/Services/TextDocument/BufferPosition.cs new file mode 100644 index 0000000..c5c7efc --- /dev/null +++ b/src/PowerShellEditorServices/Services/TextDocument/BufferPosition.cs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; + +namespace Microsoft.PowerShell.EditorServices.Services.TextDocument +{ + /// + /// Provides details about a position in a file buffer. All + /// positions are expressed in 1-based positions (i.e. the + /// first line and column in the file is position 1,1). + /// + [DebuggerDisplay("Position = {Line}:{Column}")] + internal class BufferPosition + { + #region Properties + + /// + /// Provides an instance that represents a position that has not been set. + /// + public static readonly BufferPosition None = new(-1, -1); + + /// + /// Gets the line number of the position in the buffer. + /// + public int Line { get; } + + /// + /// Gets the column number of the position in the buffer. + /// + public int Column { get; } + + #endregion + + #region Constructors + + /// + /// Creates a new instance of the BufferPosition class. + /// + /// The line number of the position. + /// The column number of the position. + public BufferPosition(int line, int column) + { + Line = line; + Column = column; + } + + #endregion + + #region Public Methods + + /// + /// Compares two instances of the BufferPosition class. + /// + /// The object to which this instance will be compared. + /// True if the positions are equal, false otherwise. + public override bool Equals(object obj) + { + if (obj is not BufferPosition) + { + return false; + } + + BufferPosition other = (BufferPosition)obj; + + return + Line == other.Line && + Column == other.Column; + } + + /// + /// Calculates a unique hash code that represents this instance. + /// + /// A hash code representing this instance. + public override int GetHashCode() => Line.GetHashCode() ^ Column.GetHashCode(); + + /// + /// Compares two positions to check if one is greater than the other. + /// + /// The first position to compare. + /// The second position to compare. + /// True if positionOne is greater than positionTwo. + public static bool operator >(BufferPosition positionOne, BufferPosition positionTwo) + { + return + (positionOne != null && positionTwo == null) || + (positionOne.Line > positionTwo.Line) || + (positionOne.Line == positionTwo.Line && + positionOne.Column > positionTwo.Column); + } + + /// + /// Compares two positions to check if one is less than the other. + /// + /// The first position to compare. + /// The second position to compare. + /// True if positionOne is less than positionTwo. + public static bool operator <(BufferPosition positionOne, BufferPosition positionTwo) => positionTwo > positionOne; + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Services/TextDocument/BufferRange.cs b/src/PowerShellEditorServices/Services/TextDocument/BufferRange.cs new file mode 100644 index 0000000..25eb7c1 --- /dev/null +++ b/src/PowerShellEditorServices/Services/TextDocument/BufferRange.cs @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Diagnostics; + +namespace Microsoft.PowerShell.EditorServices.Services.TextDocument +{ + /// + /// Provides details about a range between two positions in + /// a file buffer. + /// + [DebuggerDisplay("Start = {Start.Line}:{Start.Column}, End = {End.Line}:{End.Column}")] + internal sealed class BufferRange + { + #region Properties + + /// + /// Provides an instance that represents a range that has not been set. + /// + public static readonly BufferRange None = new(0, 0, 0, 0); + + /// + /// Gets the start position of the range in the buffer. + /// + public BufferPosition Start { get; } + + /// + /// Gets the end position of the range in the buffer. + /// + public BufferPosition End { get; } + + /// + /// Returns true if the current range is non-zero, i.e. + /// contains valid start and end positions. + /// + public bool HasRange => Equals(BufferRange.None); + + #endregion + + #region Constructors + + /// + /// Creates a new instance of the BufferRange class. + /// + /// The start position of the range. + /// The end position of the range. + public BufferRange(BufferPosition start, BufferPosition end) + { + if (start > end) + { + throw new ArgumentException( + string.Format( + "Start position ({0}, {1}) must come before or be equal to the end position ({2}, {3}).", + start.Line, start.Column, + end.Line, end.Column)); + } + + Start = start; + End = end; + } + + /// + /// Creates a new instance of the BufferRange class. + /// + /// The 1-based starting line number of the range. + /// The 1-based starting column number of the range. + /// The 1-based ending line number of the range. + /// The 1-based ending column number of the range. + public BufferRange( + int startLine, + int startColumn, + int endLine, + int endColumn) + { + Start = new BufferPosition(startLine, startColumn); + End = new BufferPosition(endLine, endColumn); + } + + #endregion + + #region Public Methods + + /// + /// Compares two instances of the BufferRange class. + /// + /// The object to which this instance will be compared. + /// True if the ranges are equal, false otherwise. + public override bool Equals(object obj) + { + if (obj is not BufferRange) + { + return false; + } + + BufferRange other = (BufferRange)obj; + + return + Start.Equals(other.Start) && + End.Equals(other.End); + } + + /// + /// Calculates a unique hash code that represents this instance. + /// + /// A hash code representing this instance. + public override int GetHashCode() => Start.GetHashCode() ^ End.GetHashCode(); + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Services/TextDocument/FileChange.cs b/src/PowerShellEditorServices/Services/TextDocument/FileChange.cs new file mode 100644 index 0000000..45f188b --- /dev/null +++ b/src/PowerShellEditorServices/Services/TextDocument/FileChange.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.PowerShell.EditorServices.Services.TextDocument +{ + /// + /// Contains details relating to a content change in an open file. + /// + public sealed class FileChange + { + /// + /// The string which is to be inserted in the file. + /// + public string InsertString { get; set; } + + /// + /// The 1-based line number where the change starts. + /// + public int Line { get; set; } + + /// + /// The 1-based column offset where the change starts. + /// + public int Offset { get; set; } + + /// + /// The 1-based line number where the change ends. + /// + public int EndLine { get; set; } + + /// + /// The 1-based column offset where the change ends. + /// + public int EndOffset { get; set; } + + /// + /// Indicates that the InsertString is an overwrite + /// of the content, and all stale content and metadata + /// should be discarded. + /// + public bool IsReload { get; set; } + } +} diff --git a/src/PowerShellEditorServices/Services/TextDocument/FilePosition.cs b/src/PowerShellEditorServices/Services/TextDocument/FilePosition.cs new file mode 100644 index 0000000..08b809e --- /dev/null +++ b/src/PowerShellEditorServices/Services/TextDocument/FilePosition.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.PowerShell.EditorServices.Services.TextDocument +{ + /// + /// Provides details and operations for a buffer position in a + /// specific file. + /// + internal sealed class FilePosition : BufferPosition + { + #region Private Fields + + private readonly ScriptFile scriptFile; + + #endregion + + #region Constructors + + /// + /// Creates a new FilePosition instance for the 1-based line and + /// column numbers in the specified file. + /// + /// The ScriptFile in which the position is located. + /// The 1-based line number in the file. + /// The 1-based column number in the file. + public FilePosition( + ScriptFile scriptFile, + int line, + int column) + : base(line, column) => this.scriptFile = scriptFile; + + /// + /// Creates a new FilePosition instance for the specified file by + /// copying the specified BufferPosition + /// + /// The ScriptFile in which the position is located. + /// The original BufferPosition from which the line and column will be copied. + public FilePosition( + ScriptFile scriptFile, + BufferPosition copiedPosition) + : this(scriptFile, copiedPosition.Line, copiedPosition.Column) => scriptFile.ValidatePosition(copiedPosition); + + #endregion + + #region Public Methods + + /// + /// Gets a FilePosition relative to this position by adding the + /// provided line and column offset relative to the contents of + /// the current file. + /// + /// The line offset to add to this position. + /// The column offset to add to this position. + /// A new FilePosition instance for the calculated position. + public FilePosition AddOffset(int lineOffset, int columnOffset) + { + return scriptFile.CalculatePosition( + this, + lineOffset, + columnOffset); + } + + /// + /// Gets a FilePosition for the line and column position + /// of the beginning of the current line after any initial + /// whitespace for indentation. + /// + /// A new FilePosition instance for the calculated position. + public FilePosition GetLineStart() + { + string scriptLine = scriptFile.FileLines[Line - 1]; + + int lineStartColumn = 1; + for (int i = 0; i < scriptLine.Length; i++) + { + if (!char.IsWhiteSpace(scriptLine[i])) + { + lineStartColumn = i + 1; + break; + } + } + + return new FilePosition(scriptFile, Line, lineStartColumn); + } + + /// + /// Gets a FilePosition for the line and column position + /// of the end of the current line. + /// + /// A new FilePosition instance for the calculated position. + public FilePosition GetLineEnd() + { + string scriptLine = scriptFile.FileLines[Line - 1]; + return new FilePosition(scriptFile, Line, scriptLine.Length + 1); + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Services/TextDocument/FoldingReference.cs b/src/PowerShellEditorServices/Services/TextDocument/FoldingReference.cs new file mode 100644 index 0000000..cd45ec1 --- /dev/null +++ b/src/PowerShellEditorServices/Services/TextDocument/FoldingReference.cs @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Services.TextDocument +{ + /// + /// A class that holds the information for a foldable region of text in a document + /// + internal class FoldingReference : IComparable, IEquatable + { + /// + /// The zero-based line number from where the folded range starts. + /// + public int StartLine { get; set; } + + /// + /// The zero-based character offset from where the folded range starts. If not defined, defaults to the length of the start line. + /// + public int StartCharacter { get; set; } + + /// + /// The zero-based line number where the folded range ends. + /// + public int EndLine { get; set; } + + /// + /// The zero-based character offset before the folded range ends. If not defined, defaults to the length of the end line. + /// + public int EndCharacter { get; set; } + + /// + /// Describes the kind of the folding range such as `comment' or 'region'. + /// + public FoldingRangeKind? Kind { get; set; } + + /// + /// A custom comparable method which can properly sort FoldingReference objects + /// + public int CompareTo(FoldingReference that) + { + // Initially look at the start line + if (StartLine < that.StartLine) { return -1; } + if (StartLine > that.StartLine) { return 1; } + + // They have the same start line so now consider the end line. + // The biggest line range is sorted first + if (EndLine > that.EndLine) { return -1; } + if (EndLine < that.EndLine) { return 1; } + + // They have the same lines, but what about character offsets + if (StartCharacter < that.StartCharacter) { return -1; } + if (StartCharacter > that.StartCharacter) { return 1; } + if (EndCharacter < that.EndCharacter) { return -1; } + if (EndCharacter > that.EndCharacter) { return 1; } + + // They're the same range, but what about kind + if (Kind == that.Kind) + { + return 0; + } + + // That has a kind but this doesn't. + if (Kind is null && that.Kind is not null) + { + return 1; + } + + // This has a kind but that doesn't. + return -1; + } + + public bool Equals(FoldingReference other) => CompareTo(other) == 0; + } + + /// + /// A class that holds a list of FoldingReferences and ensures that when adding a reference that the + /// folding rules are obeyed, e.g. Only one fold per start line + /// + internal class FoldingReferenceList + { + private readonly Dictionary references = new(); + + /// + /// Return all references in the list + /// + public IEnumerable References => references.Values; + + /// + /// Adds a FoldingReference to the list and enforces ordering rules e.g. Only one fold per start line + /// + public void SafeAdd(FoldingReference item) + { + if (item == null) { return; } + + // Only add the item if it hasn't been seen before or it's the largest range + if (references.TryGetValue(item.StartLine, out FoldingReference currentItem)) + { + if (currentItem.CompareTo(item) == 1) { references[item.StartLine] = item; } + } + else + { + references[item.StartLine] = item; + } + } + + /// + /// Helper method to easily convert the Dictionary Values into an array + /// + public FoldingReference[] ToArray() + { + FoldingReference[] result = new FoldingReference[references.Count]; + references.Values.CopyTo(result, 0); + return result; + } + } +} diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/CodeActionHandler.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/CodeActionHandler.cs new file mode 100644 index 0000000..5606c80 --- /dev/null +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/CodeActionHandler.cs @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Document; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Serialization; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + internal class PsesCodeActionHandler : CodeActionHandlerBase + { + private static readonly CommandOrCodeActionContainer s_emptyCommandOrCodeActionContainer = new(); + private readonly ILogger _logger; + private readonly AnalysisService _analysisService; + + public PsesCodeActionHandler(ILoggerFactory factory, AnalysisService analysisService) + { + _logger = factory.CreateLogger(); + _analysisService = analysisService; + } + + protected override CodeActionRegistrationOptions CreateRegistrationOptions(CodeActionCapability capability, ClientCapabilities clientCapabilities) => new() + { + // TODO: What do we do with the arguments? + DocumentSelector = LspUtils.PowerShellDocumentSelector, + CodeActionKinds = new CodeActionKind[] { CodeActionKind.QuickFix } + }; + + public override Task Handle(CodeAction request, CancellationToken cancellationToken) => Task.FromResult(request); + + public override async Task Handle(CodeActionParams request, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + _logger.LogDebug($"CodeAction request canceled at range: {request.Range}"); + return s_emptyCommandOrCodeActionContainer; + } + + IReadOnlyDictionary> corrections = await _analysisService.GetMostRecentCodeActionsForFileAsync( + request.TextDocument.Uri) + .ConfigureAwait(false); + + // GetMostRecentCodeActionsForFileAsync actually returns null if there's no corrections. + if (corrections is null) + { + return s_emptyCommandOrCodeActionContainer; + } + + List codeActions = new(); + + // If there are any code fixes, send these commands first so they appear at top of "Code Fix" menu in the client UI. + foreach (Diagnostic diagnostic in request.Context.Diagnostics) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + + if (string.IsNullOrEmpty(diagnostic.Code?.String)) + { + _logger.LogWarning( + $"textDocument/codeAction skipping diagnostic with empty Code field: {diagnostic.Source} {diagnostic.Message}"); + + continue; + } + + string diagnosticId = AnalysisService.GetUniqueIdFromDiagnostic(diagnostic); + if (corrections.TryGetValue(diagnosticId, out IEnumerable markerCorrections)) + { + foreach (MarkerCorrection markerCorrection in markerCorrections) + { + codeActions.Add(new CodeAction + { + Title = markerCorrection.Name, + Kind = CodeActionKind.QuickFix, + Edit = new WorkspaceEdit + { + DocumentChanges = new Container( + new WorkspaceEditDocumentChange( + new TextDocumentEdit + { + TextDocument = new OptionalVersionedTextDocumentIdentifier + { + Uri = request.TextDocument.Uri + }, + Edits = new TextEditContainer(markerCorrection.Edit.ToTextEdit()) + })) + } + }); + } + } + } + + // Add "show documentation" commands last so they appear at the bottom of the client UI. + // These commands do not require code fixes. Sometimes we get a batch of diagnostics + // to create commands for. No need to create multiple show doc commands for the same rule. + HashSet ruleNamesProcessed = new(); + foreach (Diagnostic diagnostic in request.Context.Diagnostics) + { + if (!diagnostic.Code.HasValue || + !diagnostic.Code.Value.IsString || + string.IsNullOrEmpty(diagnostic.Code?.String)) + { + continue; + } + + if (string.Equals(diagnostic.Source, "PSScriptAnalyzer", StringComparison.OrdinalIgnoreCase) && + !ruleNamesProcessed.Contains(diagnostic.Code?.String)) + { + _ = ruleNamesProcessed.Add(diagnostic.Code?.String); + string title = $"Show documentation for: {diagnostic.Code?.String}"; + codeActions.Add(new CodeAction + { + Title = title, + // This doesn't fix anything, but I'm adding it here so that it shows up in VS Code's + // Quick fix UI. The VS Code team is working on a way to support documentation CodeAction's better + // but this is good for now until that's ready. + Kind = CodeActionKind.QuickFix, + Command = new Command + { + Title = title, + Name = "PowerShell.ShowCodeActionDocumentation", + Arguments = JArray.FromObject(new object[] + { + diagnostic.Code?.String + }, + LspSerializer.Instance.JsonSerializer) + } + }); + } + } + + return codeActions.Count == 0 + ? s_emptyCommandOrCodeActionContainer + : codeActions; + } + } +} diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/CodeLensHandlers.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/CodeLensHandlers.cs new file mode 100644 index 0000000..407b0d3 --- /dev/null +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/CodeLensHandlers.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.CodeLenses; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Document; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + internal class PsesCodeLensHandlers : CodeLensHandlerBase + { + private static readonly CodeLensContainer s_emptyCodeLensContainer = new(); + private readonly ILogger _logger; + private readonly SymbolsService _symbolsService; + private readonly WorkspaceService _workspaceService; + + public PsesCodeLensHandlers(ILoggerFactory factory, SymbolsService symbolsService, WorkspaceService workspaceService) + { + _logger = factory.CreateLogger(); + _workspaceService = workspaceService; + _symbolsService = symbolsService; + } + + protected override CodeLensRegistrationOptions CreateRegistrationOptions(CodeLensCapability capability, ClientCapabilities clientCapabilities) => new() + { + DocumentSelector = LspUtils.PowerShellDocumentSelector, + ResolveProvider = true + }; + + public override Task Handle(CodeLensParams request, CancellationToken cancellationToken) + { + _logger.LogDebug($"Handling code lens request for {request.TextDocument.Uri}"); + + ScriptFile scriptFile = _workspaceService.GetFile(request.TextDocument.Uri); + IEnumerable codeLensResults = ProvideCodeLenses(scriptFile); + + return cancellationToken.IsCancellationRequested + ? Task.FromResult(s_emptyCodeLensContainer) + : Task.FromResult(new CodeLensContainer(codeLensResults)); + } + + public override Task Handle(CodeLens request, CancellationToken cancellationToken) + { + // TODO: Catch deserialization exception on bad object + CodeLensData codeLensData = request.Data.ToObject(); + + ICodeLensProvider originalProvider = _symbolsService + .GetCodeLensProviders() + .FirstOrDefault(provider => provider.ProviderId.Equals(codeLensData.ProviderId, StringComparison.Ordinal)); + + ScriptFile scriptFile = _workspaceService.GetFile(codeLensData.Uri); + return originalProvider.ResolveCodeLens(request, scriptFile, cancellationToken); + } + + /// + /// Get all the CodeLenses for a given script file. + /// + /// The PowerShell script file to get CodeLenses for. + /// All generated CodeLenses for the given script file. + private IEnumerable ProvideCodeLenses(ScriptFile scriptFile) + { + foreach (ICodeLensProvider provider in _symbolsService.GetCodeLensProviders()) + { + foreach (CodeLens codeLens in provider.ProvideCodeLenses(scriptFile)) + { + yield return codeLens; + } + } + } + } +} diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/CompletionHandler.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/CompletionHandler.cs new file mode 100644 index 0000000..c3d7a39 --- /dev/null +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/CompletionHandler.cs @@ -0,0 +1,527 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.PowerShell; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; +using Microsoft.PowerShell.EditorServices.Services.Symbols; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Document; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + internal record CompletionResults(bool IsIncomplete, IReadOnlyList Matches); + + internal class PsesCompletionHandler : CompletionHandlerBase + { + private readonly ILogger _logger; + private readonly IRunspaceContext _runspaceContext; + private readonly IInternalPowerShellExecutionService _executionService; + private readonly WorkspaceService _workspaceService; + private CompletionCapability _completionCapability; + + public PsesCompletionHandler( + ILoggerFactory factory, + IRunspaceContext runspaceContext, + IInternalPowerShellExecutionService executionService, + WorkspaceService workspaceService) + { + _logger = factory.CreateLogger(); + _runspaceContext = runspaceContext; + _executionService = executionService; + _workspaceService = workspaceService; + } + + protected override CompletionRegistrationOptions CreateRegistrationOptions(CompletionCapability capability, ClientCapabilities clientCapabilities) + { + _completionCapability = capability; + return new CompletionRegistrationOptions() + { + // TODO: What do we do with the arguments? + DocumentSelector = LspUtils.PowerShellDocumentSelector, + ResolveProvider = true, + TriggerCharacters = new[] { ".", "-", ":", "\\", "$", " " }, + }; + } + + public bool SupportsSnippets => _completionCapability?.CompletionItem?.SnippetSupport is true; + + public bool SupportsCommitCharacters => _completionCapability?.CompletionItem?.CommitCharactersSupport is true; + + public bool SupportsMarkdown => _completionCapability?.CompletionItem?.DocumentationFormat?.Contains(MarkupKind.Markdown) is true; + + public override async Task Handle(CompletionParams request, CancellationToken cancellationToken) + { + int cursorLine = request.Position.Line + 1; + int cursorColumn = request.Position.Character + 1; + + ScriptFile scriptFile = _workspaceService.GetFile(request.TextDocument.Uri); + try + { + (bool isIncomplete, IReadOnlyList completionResults) = await GetCompletionsInFileAsync( + scriptFile, + cursorLine, + cursorColumn, + cancellationToken).ConfigureAwait(false); + + // Treat completions triggered by space as incomplete so that `gci ` + // and then typing `-` doesn't just filter the list of parameter values + // (typically files) returned by the space completion + return new CompletionList(completionResults, isIncomplete || request?.Context?.TriggerCharacter is " "); + } + // Ignore canceled requests (logging will pollute the output). + catch (TaskCanceledException) + { + return new CompletionList(isIncomplete: true); + } + // We can't do anything about completions failing. + catch (Exception e) + { + _logger.LogWarning(e, "Exception occurred while running handling completion request"); + return new CompletionList(isIncomplete: true); + } + } + + // Handler for "completionItem/resolve". In VSCode this is fired when a completion item is highlighted in the completion list. + public override async Task Handle(CompletionItem request, CancellationToken cancellationToken) + { + if (SupportsMarkdown) + { + if (request.Kind is CompletionItemKind.Method) + { + string documentation = FormatUtils.GetMethodDocumentation( + _logger, + request.Data.ToString(), + out MarkupKind kind); + + return request with + { + Documentation = new MarkupContent() + { + Kind = kind, + Value = documentation, + }, + }; + } + + if (request.Kind is CompletionItemKind.Class or CompletionItemKind.TypeParameter or CompletionItemKind.Enum) + { + string documentation = FormatUtils.GetTypeDocumentation( + _logger, + request.Detail, + out MarkupKind kind); + + return request with + { + Detail = null, + Documentation = new MarkupContent() + { + Kind = kind, + Value = documentation, + }, + }; + } + + if (request.Kind is CompletionItemKind.EnumMember or CompletionItemKind.Property or CompletionItemKind.Field) + { + string documentation = FormatUtils.GetPropertyDocumentation( + _logger, + request.Data.ToString(), + out MarkupKind kind); + + return request with + { + Documentation = new MarkupContent() + { + Kind = kind, + Value = documentation, + }, + }; + } + } + + // We currently only support this request for anything that returns a CommandInfo: + // functions, cmdlets, aliases. No detail means the module hasn't been imported yet and + // IntelliSense shouldn't import the module to get this info. + if (request.Kind is not CompletionItemKind.Function || request.Detail is null) + { + return request; + } + + // Get the documentation for the function + CommandInfo commandInfo = await CommandHelpers.GetCommandInfoAsync( + request.Label, + _runspaceContext.CurrentRunspace, + _executionService, + cancellationToken).ConfigureAwait(false); + + if (commandInfo is not null) + { + return request with + { + Documentation = await CommandHelpers.GetCommandSynopsisAsync( + commandInfo, + _executionService, + cancellationToken).ConfigureAwait(false) + }; + } + + return request; + } + + /// + /// Gets completions for a statement contained in the given + /// script file at the specified line and column position. + /// + /// + /// The script file in which completions will be gathered. + /// + /// + /// The 1-based line number at which completions will be gathered. + /// + /// + /// The 1-based column number at which completions will be gathered. + /// + /// The token used to cancel this. + /// + /// A CommandCompletion instance completions for the identified statement. + /// + internal async Task GetCompletionsInFileAsync( + ScriptFile scriptFile, + int lineNumber, + int columnNumber, + CancellationToken cancellationToken) + { + Validate.IsNotNull(nameof(scriptFile), scriptFile); + + CommandCompletion result = await AstOperations.GetCompletionsAsync( + scriptFile.ScriptAst, + scriptFile.ScriptTokens, + scriptFile.GetOffsetAtPosition(lineNumber, columnNumber), + _executionService, + _logger, + cancellationToken).ConfigureAwait(false); + + if (result.CompletionMatches.Count == 0) + { + return new CompletionResults(IsIncomplete: true, Array.Empty()); + } + + BufferRange replacedRange = scriptFile.GetRangeBetweenOffsets( + result.ReplacementIndex, + result.ReplacementIndex + result.ReplacementLength); + + string textToBeReplaced = string.Empty; + if (result.ReplacementLength is not 0) + { + textToBeReplaced = scriptFile.Contents.Substring( + result.ReplacementIndex, + result.ReplacementLength); + } + + bool isIncomplete = false; + // Create OmniSharp CompletionItems from PowerShell CompletionResults. We use a for loop + // because the index is used for sorting. + CompletionItem[] completionItems = new CompletionItem[result.CompletionMatches.Count]; + for (int i = 0; i < result.CompletionMatches.Count; i++) + { + CompletionResult completionMatch = result.CompletionMatches[i]; + + // If a completion result is a variable scope like `$script:` we want to + // mark as incomplete so on typing `:` completion changes. + if (completionMatch.ResultType is CompletionResultType.Variable + && completionMatch.CompletionText.EndsWith(":")) + { + isIncomplete = true; + } + + completionItems[i] = CreateCompletionItem( + result.CompletionMatches[i], + replacedRange, + i + 1, + textToBeReplaced, + scriptFile); + + _logger.LogTrace("Created completion item: " + completionItems[i] + " with " + completionItems[i].TextEdit); + } + + return new CompletionResults(isIncomplete, completionItems); + } + + internal CompletionItem CreateCompletionItem( + CompletionResult result, + BufferRange completionRange, + int sortIndex, + string textToBeReplaced, + ScriptFile scriptFile) + { + Validate.IsNotNull(nameof(result), result); + + TextEdit textEdit = new() + { + NewText = result.CompletionText, + Range = new Range + { + Start = new Position + { + Line = completionRange.Start.Line - 1, + Character = completionRange.Start.Column - 1 + }, + End = new Position + { + Line = completionRange.End.Line - 1, + Character = completionRange.End.Column - 1 + } + } + }; + + // Some tooltips may have newlines or whitespace for unknown reasons. + string detail = result.ToolTip?.Trim(); + + CompletionItem item = new() + { + Label = result.ListItemText, + Detail = result.ListItemText.Equals(detail, StringComparison.CurrentCulture) + ? string.Empty : detail, // Don't repeat label. + // Retain PowerShell's sort order with the given index. + SortText = $"{sortIndex:D4}{result.ListItemText}", + FilterText = result.ResultType is CompletionResultType.Type + ? GetTypeFilterText(textToBeReplaced, result.CompletionText) + : result.CompletionText, + // Used instead of Label when TextEdit is unsupported + InsertText = result.CompletionText, + // Used instead of InsertText when possible + TextEdit = textEdit + }; + + return result.ResultType switch + { + CompletionResultType.Text => item with { Kind = CompletionItemKind.Text }, + CompletionResultType.History => item with { Kind = CompletionItemKind.Reference }, + CompletionResultType.Command => item with { Kind = CompletionItemKind.Function }, + CompletionResultType.ProviderItem or CompletionResultType.ProviderContainer + => CreateProviderItemCompletion(item, result, scriptFile, textToBeReplaced), + CompletionResultType.Property => item with + { + Kind = CompletionItemKind.Property, + Detail = SupportsMarkdown ? null : detail, + Data = SupportsMarkdown ? detail : null, + CommitCharacters = MaybeAddCommitCharacters("."), + }, + CompletionResultType.Method => item with + { + Kind = CompletionItemKind.Method, + Data = item.Detail, + Detail = SupportsMarkdown ? null : item.Detail, + }, + CompletionResultType.ParameterName => TryExtractType(detail, item.Label, out string type, out string documentation) + ? item with { Kind = CompletionItemKind.Variable, Detail = type, Documentation = documentation } + // The comparison operators (-eq, -not, -gt, etc) unfortunately come across as + // ParameterName types but they don't have a type associated to them, so we can + // deduce it is an operator. + : item with { Kind = CompletionItemKind.Operator }, + CompletionResultType.ParameterValue => item with { Kind = CompletionItemKind.Value }, + CompletionResultType.Variable => TryExtractType(detail, "$" + item.Label, out string type, out string documentation) + ? item with { Kind = CompletionItemKind.Variable, Detail = type, Documentation = documentation } + : item with { Kind = CompletionItemKind.Variable }, + CompletionResultType.Namespace => item with { Kind = CompletionItemKind.Module }, + CompletionResultType.Type => detail.StartsWith("Class ", StringComparison.CurrentCulture) + // Custom classes come through as types but the PowerShell completion tooltip + // will start with "Class ", so we can more accurately display its icon. + ? item with { Kind = CompletionItemKind.Class, Detail = detail.Substring("Class ".Length) } + : detail.StartsWith("Enum ", StringComparison.CurrentCulture) + ? item with { Kind = CompletionItemKind.Enum, Detail = detail.Substring("Enum ".Length) } + : item with { Kind = CompletionItemKind.TypeParameter }, + CompletionResultType.Keyword or CompletionResultType.DynamicKeyword => + item with { Kind = CompletionItemKind.Keyword }, + _ => throw new ArgumentOutOfRangeException(nameof(result)) + }; + } + + private CompletionItem CreateProviderItemCompletion( + CompletionItem item, + CompletionResult result, + ScriptFile scriptFile, + string textToBeReplaced) + { + // TODO: Work out a way to do this generally instead of special casing PSScriptRoot. + // + // This code relies on PowerShell/PowerShell#17376. Until that makes it into a release + // no matches will be returned anyway. + const string PSScriptRootVariable = "$PSScriptRoot"; + string completionText = result.CompletionText; + if (textToBeReplaced.IndexOf(PSScriptRootVariable, StringComparison.OrdinalIgnoreCase) is int variableIndex and not -1 + && System.IO.Path.GetDirectoryName(scriptFile.FilePath) is string scriptFolder and not "" + && completionText.IndexOf(scriptFolder, StringComparison.OrdinalIgnoreCase) is int pathIndex and not -1 + && !scriptFile.IsInMemory) + { + completionText = completionText + .Remove(pathIndex, scriptFolder.Length) + .Insert(variableIndex, textToBeReplaced.Substring(variableIndex, PSScriptRootVariable.Length)); + } + + InsertTextFormat insertFormat; + TextEdit edit; + CompletionItemKind itemKind; + if (result.ResultType is CompletionResultType.ProviderContainer + && SupportsSnippets + && TryBuildSnippet(completionText, out string snippet)) + { + edit = item.TextEdit.TextEdit with { NewText = snippet }; + insertFormat = InsertTextFormat.Snippet; + itemKind = CompletionItemKind.Folder; + } + else + { + edit = item.TextEdit.TextEdit with { NewText = completionText }; + insertFormat = default; + itemKind = CompletionItemKind.File; + } + + return item with + { + Kind = itemKind, + TextEdit = edit, + InsertText = completionText, + FilterText = completionText, + InsertTextFormat = insertFormat, + }; + } + + private Container MaybeAddCommitCharacters(params string[] characters) + => SupportsCommitCharacters ? new Container(characters) : null; + + private static string GetTypeFilterText(string textToBeReplaced, string completionText) + { + // FilterText for a type name with using statements gets a little complicated. Consider + // this script: + // + // using namespace System.Management.Automation + // [System.Management.Automation.Tracing.] + // + // Since we're emitting an edit that replaces `System.Management.Automation.Tracing.` with + // `Tracing.NullWriter` (for example), we can't use CompletionText as the filter. If we + // do, we won't find any matches because it's trying to filter `Tracing.NullWriter` with + // `System.Management.Automation.Tracing.` which is too different. So we prepend each + // namespace that exists in our original text but does not in our completion text. + if (!textToBeReplaced.Contains('.')) + { + return completionText; + } + + string[] oldTypeParts = textToBeReplaced.Split('.'); + string[] newTypeParts = completionText.Split('.'); + + StringBuilder newFilterText = new(completionText); + + int newPartsIndex = newTypeParts.Length - 2; + for (int i = oldTypeParts.Length - 2; i >= 0; i--) + { + if (newPartsIndex is >= 0 + && newTypeParts[newPartsIndex].Equals(oldTypeParts[i], StringComparison.OrdinalIgnoreCase)) + { + newPartsIndex--; + continue; + } + + newFilterText.Insert(0, '.').Insert(0, oldTypeParts[i]); + } + + return newFilterText.ToString(); + } + + private static readonly Regex s_typeRegex = new(@"^(\[.+\])", RegexOptions.Compiled); + + /// + /// Look for type encoded in the tooltip for parameters and variables. Display PowerShell + /// type names in [] to be consistent with PowerShell syntax and how the debugger displays + /// type names. + /// + /// The tooltip text to parse + /// The extracted type string, if found + /// The remaining text after the type, if any + /// The label to check for in the documentation prefix + /// Whether or not the type was found. + internal static bool TryExtractType(string toolTipText, string label, out string type, out string documentation) + { + MatchCollection matches = s_typeRegex.Matches(toolTipText); + type = string.Empty; + documentation = null; //We use null instead of String.Empty to indicate no documentation was found. + + if ((matches.Count > 0) && (matches[0].Groups.Count > 1)) + { + type = matches[0].Groups[1].Value; + + // Extract the description as everything after the type + if (matches[0].Length < toolTipText.Length) + { + documentation = toolTipText.Substring(matches[0].Length).Trim(); + + if (documentation is not null) + { + // If the substring is the same as the label, documentation should remain blank + if (documentation.Equals(label, StringComparison.OrdinalIgnoreCase)) + { + documentation = null; + } + // If the documentation starts with "label - ", remove this prefix + else if (documentation.StartsWith(label + " - ", StringComparison.OrdinalIgnoreCase)) + { + documentation = documentation.Substring((label + " - ").Length).Trim(); + } + } + if (string.IsNullOrWhiteSpace(documentation)) + { + documentation = null; + } + } + + return true; + } + return false; + } + + /// + /// Insert a final "tab stop" as identified by $0 in the snippet provided for completion. + /// For folder paths, we take the path returned by PowerShell e.g. 'C:\Program Files' and + /// insert the tab stop marker before the closing quote char e.g. 'C:\Program Files$0'. This + /// causes the editing cursor to be placed *before* the final quote after completion, which + /// makes subsequent path completions work. See this part of the LSP spec for details: + /// https://microsoft.github.io/language-server-protocol/specification#textDocument_completion + /// + /// + /// + /// + /// Whether or not the completion ended with a quote and so was a snippet. + /// + private static bool TryBuildSnippet(string completionText, out string snippet) + { + snippet = string.Empty; + if (!string.IsNullOrEmpty(completionText) + && completionText[completionText.Length - 1] is '"' or '\'') + { + // Since we want to use a "tab stop" we need to escape a few things. + StringBuilder sb = new StringBuilder(completionText) + .Replace(@"\", @"\\") + .Replace("}", @"\}") + .Replace("$", @"\$"); + snippet = sb.Insert(sb.Length - 1, "$0").ToString(); + return true; + } + return false; + } + } +} diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/DefinitionHandler.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/DefinitionHandler.cs new file mode 100644 index 0000000..2519891 --- /dev/null +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/DefinitionHandler.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.Symbols; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Document; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + internal class PsesDefinitionHandler : DefinitionHandlerBase + { + private static readonly LocationOrLocationLinks s_emptyLocationOrLocationLinks = new(); + private readonly SymbolsService _symbolsService; + private readonly WorkspaceService _workspaceService; + + public PsesDefinitionHandler( + SymbolsService symbolsService, + WorkspaceService workspaceService) + { + _symbolsService = symbolsService; + _workspaceService = workspaceService; + } + + protected override DefinitionRegistrationOptions CreateRegistrationOptions(DefinitionCapability capability, ClientCapabilities clientCapabilities) => new() + { + DocumentSelector = LspUtils.PowerShellDocumentSelector + }; + + public override async Task Handle(DefinitionParams request, CancellationToken cancellationToken) + { + ScriptFile scriptFile = _workspaceService.GetFile(request.TextDocument.Uri); + + SymbolReference foundSymbol = + SymbolsService.FindSymbolAtLocation( + scriptFile, + request.Position.Line + 1, + request.Position.Character + 1); + + if (foundSymbol is null) + { + return s_emptyLocationOrLocationLinks; + } + + // Short-circuit if we're already on the definition. + if (foundSymbol.IsDeclaration) + { + return new LocationOrLocationLink[] { + new LocationOrLocationLink( + new Location + { + Uri = DocumentUri.From(foundSymbol.FilePath), + Range = foundSymbol.NameRegion.ToRange() + })}; + } + + List definitionLocations = new(); + foreach (SymbolReference foundDefinition in await _symbolsService.GetDefinitionOfSymbolAsync( + scriptFile, foundSymbol, cancellationToken).ConfigureAwait(false)) + { + definitionLocations.Add( + new LocationOrLocationLink( + new Location + { + Uri = DocumentUri.From(foundDefinition.FilePath), + Range = foundDefinition.NameRegion.ToRange() + })); + } + + return definitionLocations.Count == 0 + ? s_emptyLocationOrLocationLinks + : definitionLocations; + } + } +} diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/DidChangeWatchedFilesHandler.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/DidChangeWatchedFilesHandler.cs new file mode 100644 index 0000000..aa4c0a9 --- /dev/null +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/DidChangeWatchedFilesHandler.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Microsoft.Extensions.FileSystemGlobbing; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.Configuration; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Workspace; + +// Using an alias since this is conflicting with System.IO.FileSystemWatcher and ends up really finicky. +using OmniSharpFileSystemWatcher = OmniSharp.Extensions.LanguageServer.Protocol.Models.FileSystemWatcher; + +namespace Microsoft.PowerShell.EditorServices.Handlers; + +/// +/// Receives file change notifications from the client for any file in the workspace, including those +/// that are not considered opened by the client. This handler is used to allow us to scan the +/// workspace only once when the language server starts. +/// +internal class DidChangeWatchedFilesHandler : IDidChangeWatchedFilesHandler +{ + private readonly WorkspaceService _workspaceService; + + private readonly ConfigurationService _configurationService; + + public DidChangeWatchedFilesHandler( + WorkspaceService workspaceService, + ConfigurationService configurationService) + { + _workspaceService = workspaceService; + _configurationService = configurationService; + } + + public DidChangeWatchedFilesRegistrationOptions GetRegistrationOptions( + DidChangeWatchedFilesCapability capability, + ClientCapabilities clientCapabilities) +#pragma warning disable CS8601 // Possible null reference assignment (it's from the library). + => new() + { + Watchers = new[] + { + new OmniSharpFileSystemWatcher() + { + GlobPattern = "**/*.{ps1,psm1}", + Kind = WatchKind.Create | WatchKind.Delete | WatchKind.Change, + }, + }, + }; +#pragma warning restore CS8601 // Possible null reference assignment. + + public Task Handle(DidChangeWatchedFilesParams request, CancellationToken cancellationToken) + { + LanguageServerSettings currentSettings = _configurationService.CurrentSettings; + if (currentSettings.AnalyzeOpenDocumentsOnly) + { + return Task.FromResult(Unit.Value); + } + + // Honor `search.exclude` settings in the watcher. + Matcher matcher = new(); + matcher.AddExcludePatterns(_workspaceService.ExcludeFilesGlob); + foreach (FileEvent change in request.Changes) + { + if (matcher.Match(change.Uri.GetFileSystemPath()).HasMatches) + { + continue; + } + + if (!_workspaceService.TryGetFile(change.Uri, out ScriptFile scriptFile)) + { + continue; + } + + if (change.Type is FileChangeType.Created) + { + // We've already triggered adding the file to `OpenedFiles` via `TryGetFile`. + continue; + } + + if (change.Type is FileChangeType.Deleted) + { + _workspaceService.CloseFile(scriptFile); + continue; + } + + if (change.Type is FileChangeType.Changed) + { + // If the file is opened by the editor (rather than by us in the background), let + // DidChangeTextDocument handle changes. + if (scriptFile.IsOpen) + { + continue; + } + + string fileContents; + try + { + fileContents = WorkspaceService.ReadFileContents(change.Uri); + } + catch + { + continue; + } + + scriptFile.SetFileContents(fileContents); + scriptFile.References.TagAsChanged(); + } + } + + return Task.FromResult(Unit.Value); + } +} diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/DocumentHighlightHandler.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/DocumentHighlightHandler.cs new file mode 100644 index 0000000..0e470d4 --- /dev/null +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/DocumentHighlightHandler.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.Symbols; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Document; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + internal class PsesDocumentHighlightHandler : DocumentHighlightHandlerBase + { + private static readonly DocumentHighlightContainer s_emptyHighlightContainer = new(); + private readonly ILogger _logger; + private readonly WorkspaceService _workspaceService; + + public PsesDocumentHighlightHandler( + ILoggerFactory loggerFactory, + WorkspaceService workspaceService) + { + _logger = loggerFactory.CreateLogger(); + _workspaceService = workspaceService; + } + + protected override DocumentHighlightRegistrationOptions CreateRegistrationOptions(DocumentHighlightCapability capability, ClientCapabilities clientCapabilities) => new() + { + DocumentSelector = LspUtils.PowerShellDocumentSelector + }; + + public override Task Handle( + DocumentHighlightParams request, + CancellationToken cancellationToken) + { + ScriptFile scriptFile = _workspaceService.GetFile(request.TextDocument.Uri); + + IEnumerable occurrences = SymbolsService.FindOccurrencesInFile( + scriptFile, + request.Position.Line + 1, + request.Position.Character + 1); + + List highlights = new(); + foreach (SymbolReference occurrence in occurrences) + { + highlights.Add(new DocumentHighlight + { + Kind = DocumentHighlightKind.Write, // TODO: Which symbol types are writable? + Range = occurrence.NameRegion.ToRange() // Just the symbol name + }); + } + + _logger.LogDebug("Highlights: " + highlights); + + return cancellationToken.IsCancellationRequested || highlights.Count == 0 + ? Task.FromResult(s_emptyHighlightContainer) + : Task.FromResult(new DocumentHighlightContainer(highlights)); + } + } +} diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/DocumentSymbolHandler.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/DocumentSymbolHandler.cs new file mode 100644 index 0000000..868d3af --- /dev/null +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/DocumentSymbolHandler.cs @@ -0,0 +1,213 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.Symbols; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Document; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + internal class PsesDocumentSymbolHandler : DocumentSymbolHandlerBase + { + private static readonly SymbolInformationOrDocumentSymbolContainer s_emptySymbolInformationOrDocumentSymbolContainer = new(); + private readonly ILogger _logger; + private readonly WorkspaceService _workspaceService; + private readonly IDocumentSymbolProvider[] _providers; + + public PsesDocumentSymbolHandler(ILoggerFactory factory, WorkspaceService workspaceService) + { + _logger = factory.CreateLogger(); + _workspaceService = workspaceService; + _providers = new IDocumentSymbolProvider[] + { + new ScriptDocumentSymbolProvider(), + new PsdDocumentSymbolProvider(), + new PesterDocumentSymbolProvider(), + new RegionDocumentSymbolProvider() + }; + } + + protected override DocumentSymbolRegistrationOptions CreateRegistrationOptions(DocumentSymbolCapability capability, ClientCapabilities clientCapabilities) => new() + { + DocumentSelector = LspUtils.PowerShellDocumentSelector + }; + + // Modifies a flat list of symbols into a hierarchical list. + private static Task SortHierarchicalSymbols(List symbols, CancellationToken cancellationToken) + { + // Sort by the start of the symbol definition (they're probably sorted but we need to be + // certain otherwise this algorithm won't work). We only need to sort the list once, and + // since the implementation is recursive, it's easiest to use the stack to track that + // this is the first call. + symbols.Sort((x1, x2) => x1.Range.Start.CompareTo(x2.Range.Start)); + return SortHierarchicalSymbolsImpl(symbols, cancellationToken); + } + + private static async Task SortHierarchicalSymbolsImpl(List symbols, CancellationToken cancellationToken) + { + for (int i = 0; i < symbols.Count; i++) + { + // This async method is pretty dense with synchronous code + // so it's helpful to add some yields. + await Task.Yield(); + if (cancellationToken.IsCancellationRequested) + { + return; + } + + HierarchicalSymbol symbol = symbols[i]; + + // Base case where we haven't found any parents yet (the first symbol must be a + // parent by definition). + if (i == 0) + { + continue; + } + // If the symbol starts after end of last symbol parsed then it's a new parent. + else if (symbol.Range.Start > symbols[i - 1].Range.End) + { + continue; + } + // Otherwise it's a child, we just need to figure out whose child it is and move it there (which also means removing it from the current list). + else + { + for (int j = 0; j <= i; j++) + { + // While we should only check up to j < i, we iterate up to j <= i so that + // we can check this assertion that we didn't exhaust the parents. + Debug.Assert(j != i, "We didn't find the child's parent!"); + + HierarchicalSymbol parent = symbols[j]; + // If the symbol starts after the parent starts and ends before the parent + // ends then its a child. + if (symbol.Range.Start > parent.Range.Start && symbol.Range.End < parent.Range.End) + { + // Add it to the parent's list. + parent.Children.Add(symbol); + // Remove it from this "parents" list (because it's a child) and adjust + // our loop counter because it's been removed. + symbols.RemoveAt(i); + i--; + break; + } + } + } + } + + // Now recursively sort the children into nested buckets of children too. + foreach (HierarchicalSymbol parent in symbols) + { + // Since this modifies in place we just recurse, no re-assignment or clearing from + // parent.Children necessary. + await SortHierarchicalSymbols(parent.Children, cancellationToken).ConfigureAwait(false); + } + } + + // This struct and the mapping function below exist to allow us to skip a *bunch* of + // unnecessary allocations when sorting the symbols since DocumentSymbol (which this is + // pretty much a mirror of) is an immutable record...but we need to constantly modify the + // list of children when sorting. + private struct HierarchicalSymbol + { + public SymbolKind Kind; + public Range Range; + public Range SelectionRange; + public string Name; + public List Children; + } + + // Recursively turn our HierarchicalSymbol struct into OmniSharp's DocumentSymbol record. + private static List GetDocumentSymbolsFromHierarchicalSymbols(IEnumerable hierarchicalSymbols) + { + List documentSymbols = new(); + foreach (HierarchicalSymbol symbol in hierarchicalSymbols) + { + documentSymbols.Add(new DocumentSymbol + { + Kind = symbol.Kind, + Range = symbol.Range, + SelectionRange = symbol.SelectionRange, + Name = symbol.Name, + Children = GetDocumentSymbolsFromHierarchicalSymbols(symbol.Children) + }); + } + return documentSymbols; + } + + // AKA the outline feature! + public override async Task Handle(DocumentSymbolParams request, CancellationToken cancellationToken) + { + _logger.LogDebug($"Handling document symbols for {request.TextDocument.Uri}"); + + ScriptFile scriptFile = _workspaceService.GetFile(request.TextDocument.Uri); + + List hierarchicalSymbols = new(); + + foreach (SymbolReference symbolReference in ProvideDocumentSymbols(scriptFile)) + { + // This async method is pretty dense with synchronous code + // so it's helpful to add some yields. + await Task.Yield(); + if (cancellationToken.IsCancellationRequested) + { + break; + } + + // Outline view should only include declarations. + // + // TODO: We should also include function invocations that are part of DSLs (like + // Invoke-Build etc.). + if (!symbolReference.IsDeclaration || symbolReference.Type is SymbolType.Parameter) + { + continue; + } + + hierarchicalSymbols.Add(new HierarchicalSymbol + { + Kind = SymbolTypeUtils.GetSymbolKind(symbolReference.Type), + Range = symbolReference.ScriptRegion.ToRange(), + SelectionRange = symbolReference.NameRegion.ToRange(), + Name = symbolReference.Name, + Children = new List() + }); + } + + // Short-circuit if we have no symbols. + if (hierarchicalSymbols.Count == 0) + { + return s_emptySymbolInformationOrDocumentSymbolContainer; + } + + // Otherwise slowly sort them into a hierarchy (this modifies the list). + await SortHierarchicalSymbols(hierarchicalSymbols, cancellationToken).ConfigureAwait(false); + + // And finally convert them to the silly SymbolInformationOrDocumentSymbol wrapper. + List container = new(); + foreach (DocumentSymbol symbol in GetDocumentSymbolsFromHierarchicalSymbols(hierarchicalSymbols)) + { + container.Add(new SymbolInformationOrDocumentSymbol(symbol)); + } + return container; + } + + private IEnumerable ProvideDocumentSymbols(ScriptFile scriptFile) + { + foreach (IDocumentSymbolProvider provider in _providers) + { + foreach (SymbolReference symbol in provider.ProvideDocumentSymbols(scriptFile)) + { + yield return symbol; + } + } + } + } +} diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/FoldingRangeHandler.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/FoldingRangeHandler.cs new file mode 100644 index 0000000..8559344 --- /dev/null +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/FoldingRangeHandler.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Document; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + internal class PsesFoldingRangeHandler : FoldingRangeHandlerBase + { + private static readonly Container s_emptyFoldingRangeContainer = new(); + private readonly ILogger _logger; + private readonly ConfigurationService _configurationService; + private readonly WorkspaceService _workspaceService; + + public PsesFoldingRangeHandler(ILoggerFactory factory, ConfigurationService configurationService, WorkspaceService workspaceService) + { + _logger = factory.CreateLogger(); + _configurationService = configurationService; + _workspaceService = workspaceService; + } + + protected override FoldingRangeRegistrationOptions CreateRegistrationOptions(FoldingRangeCapability capability, ClientCapabilities clientCapabilities) => new() + { + DocumentSelector = LspUtils.PowerShellDocumentSelector + }; + + public override Task> Handle(FoldingRangeRequestParam request, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + _logger.LogDebug("FoldingRange request canceled for file: {Uri}", request.TextDocument.Uri); + return Task.FromResult(s_emptyFoldingRangeContainer); + } + + // TODO: Should be using dynamic registrations + if (!_configurationService.CurrentSettings.CodeFolding.Enable) + { + return Task.FromResult(s_emptyFoldingRangeContainer); + } + + // Avoid crash when using untitled: scheme or any other scheme where the document doesn't + // have a backing file. https://github.com/PowerShell/vscode-powershell/issues/1676 + // Perhaps a better option would be to parse the contents of the document as a string + // as opposed to reading a file but the scenario of "no backing file" probably doesn't + // warrant the extra effort. + if (!_workspaceService.TryGetFile(request.TextDocument.Uri, out ScriptFile scriptFile)) + { + return Task.FromResult(s_emptyFoldingRangeContainer); + } + + // If we're showing the last line, decrement the Endline of all regions by one. + int endLineOffset = _configurationService.CurrentSettings.CodeFolding.ShowLastLine ? -1 : 0; + List folds = new(); + foreach (FoldingReference fold in TokenOperations.FoldableReferences(scriptFile.ScriptTokens).References) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + + folds.Add(new FoldingRange + { + EndCharacter = fold.EndCharacter, + EndLine = fold.EndLine + endLineOffset, + Kind = fold.Kind, + StartCharacter = fold.StartCharacter, + StartLine = fold.StartLine + }); + } + + return folds.Count == 0 + ? Task.FromResult(s_emptyFoldingRangeContainer) + : Task.FromResult(new Container(folds)); + } + } +} diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/FormattingHandlers.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/FormattingHandlers.cs new file mode 100644 index 0000000..64ccb31 --- /dev/null +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/FormattingHandlers.cs @@ -0,0 +1,194 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Document; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + // TODO: Add IDocumentOnTypeFormatHandler to support on-type formatting. + internal class PsesDocumentFormattingHandler : DocumentFormattingHandlerBase + { + private static readonly TextEditContainer s_emptyTextEditContainer = new(); + private readonly ILogger _logger; + private readonly AnalysisService _analysisService; + private readonly ConfigurationService _configurationService; + private readonly WorkspaceService _workspaceService; + + public PsesDocumentFormattingHandler( + ILoggerFactory factory, + AnalysisService analysisService, + ConfigurationService configurationService, + WorkspaceService workspaceService) + { + _logger = factory.CreateLogger(); + _analysisService = analysisService; + _configurationService = configurationService; + _workspaceService = workspaceService; + } + + protected override DocumentFormattingRegistrationOptions CreateRegistrationOptions(DocumentFormattingCapability capability, ClientCapabilities clientCapabilities) => new() + { + DocumentSelector = LspUtils.PowerShellDocumentSelector + }; + + public override async Task Handle(DocumentFormattingParams request, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + _logger.LogDebug($"Formatting request canceled for: {request.TextDocument.Uri}"); + return s_emptyTextEditContainer; + } + + Services.TextDocument.ScriptFile scriptFile = _workspaceService.GetFile(request.TextDocument.Uri); + System.Collections.Hashtable pssaSettings = _configurationService.CurrentSettings.CodeFormatting.GetPSSASettingsHashtable( + request.Options.TabSize, + request.Options.InsertSpaces, + _logger); + + // TODO: Raise an error event in case format returns null. + string formattedScript; + Range editRange; + System.Management.Automation.Language.IScriptExtent extent = scriptFile.ScriptAst.Extent; + + // TODO: Create an extension for converting range to script extent. + editRange = new Range + { + Start = new Position + { + Line = extent.StartLineNumber - 1, + Character = extent.StartColumnNumber - 1 + }, + End = new Position + { + Line = extent.EndLineNumber - 1, + Character = extent.EndColumnNumber - 1 + } + }; + + formattedScript = await _analysisService.FormatAsync( + scriptFile.Contents, + pssaSettings, + null).ConfigureAwait(false); + + if (formattedScript is null) + { + _logger.LogDebug($"Formatting returned null. Not formatting: {scriptFile.DocumentUri}"); + return s_emptyTextEditContainer; + } + + // Just in case the user really requested a cancellation. + if (cancellationToken.IsCancellationRequested) + { + _logger.LogDebug($"Formatting request canceled for: {scriptFile.DocumentUri}"); + return s_emptyTextEditContainer; + } + + return new TextEditContainer(new TextEdit + { + NewText = formattedScript, + Range = editRange + }); + } + } + + internal class PsesDocumentRangeFormattingHandler : DocumentRangeFormattingHandlerBase + { + private static readonly TextEditContainer s_emptyTextEditContainer = new(); + private readonly ILogger _logger; + private readonly AnalysisService _analysisService; + private readonly ConfigurationService _configurationService; + private readonly WorkspaceService _workspaceService; + + public PsesDocumentRangeFormattingHandler( + ILoggerFactory factory, + AnalysisService analysisService, + ConfigurationService configurationService, + WorkspaceService workspaceService) + { + _logger = factory.CreateLogger(); + _analysisService = analysisService; + _configurationService = configurationService; + _workspaceService = workspaceService; + } + + protected override DocumentRangeFormattingRegistrationOptions CreateRegistrationOptions(DocumentRangeFormattingCapability capability, ClientCapabilities clientCapabilities) => new() + { + DocumentSelector = LspUtils.PowerShellDocumentSelector + }; + + public override async Task Handle(DocumentRangeFormattingParams request, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + _logger.LogDebug($"Formatting request canceled for: {request.TextDocument.Uri}"); + return s_emptyTextEditContainer; + } + + Services.TextDocument.ScriptFile scriptFile = _workspaceService.GetFile(request.TextDocument.Uri); + System.Collections.Hashtable pssaSettings = _configurationService.CurrentSettings.CodeFormatting.GetPSSASettingsHashtable( + request.Options.TabSize, + request.Options.InsertSpaces, + _logger); + + // TODO: Raise an error event in case format returns null. + string formattedScript; + Range editRange; + System.Management.Automation.Language.IScriptExtent extent = scriptFile.ScriptAst.Extent; + + // TODO: Create an extension for converting range to script extent. + editRange = new Range + { + Start = new Position + { + Line = extent.StartLineNumber - 1, + Character = extent.StartColumnNumber - 1 + }, + End = new Position + { + Line = extent.EndLineNumber - 1, + Character = extent.EndColumnNumber - 1 + } + }; + + Range range = request.Range; + int[] rangeList = range == null ? null : new int[] + { + range.Start.Line + 1, + range.Start.Character + 1, + range.End.Line + 1, + range.End.Character + 1 + }; + + formattedScript = await _analysisService.FormatAsync( + scriptFile.Contents, + pssaSettings, + rangeList).ConfigureAwait(false); + + if (formattedScript is null) + { + _logger.LogDebug($"Formatting returned null. Not formatting: {scriptFile.DocumentUri}"); + return s_emptyTextEditContainer; + } + + // Just in case the user really requested a cancellation. + if (cancellationToken.IsCancellationRequested) + { + _logger.LogDebug($"Formatting request canceled for: {scriptFile.DocumentUri}"); + return s_emptyTextEditContainer; + } + + return new TextEditContainer(new TextEdit + { + NewText = formattedScript, + Range = editRange + }); + } + } +} diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/HoverHandler.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/HoverHandler.cs new file mode 100644 index 0000000..9da0549 --- /dev/null +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/HoverHandler.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.Symbols; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Document; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + internal class PsesHoverHandler : HoverHandlerBase + { + private readonly ILogger _logger; + private readonly SymbolsService _symbolsService; + private readonly WorkspaceService _workspaceService; + + public PsesHoverHandler( + ILoggerFactory factory, + SymbolsService symbolsService, + WorkspaceService workspaceService) + { + _logger = factory.CreateLogger(); + _symbolsService = symbolsService; + _workspaceService = workspaceService; + } + + protected override HoverRegistrationOptions CreateRegistrationOptions(HoverCapability capability, ClientCapabilities clientCapabilities) => new() + { + DocumentSelector = LspUtils.PowerShellDocumentSelector + }; + + public override async Task Handle(HoverParams request, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + _logger.LogDebug("Hover request canceled for file: {Uri}", request.TextDocument.Uri); + return null; + } + + ScriptFile scriptFile = _workspaceService.GetFile(request.TextDocument.Uri); + + SymbolDetails symbolDetails = + await _symbolsService.FindSymbolDetailsAtLocationAsync( + scriptFile, + request.Position.Line + 1, + request.Position.Character + 1, + cancellationToken).ConfigureAwait(false); + + if (symbolDetails is null) + { + return null; + } + + List symbolInfo = new() + { + new MarkedString("PowerShell", symbolDetails.SymbolReference.Name) + }; + + if (!string.IsNullOrEmpty(symbolDetails.Documentation)) + { + symbolInfo.Add(new MarkedString("markdown", symbolDetails.Documentation)); + } + + return new Hover + { + Contents = new MarkedStringsOrMarkupContent(symbolInfo), + Range = symbolDetails.SymbolReference.NameRegion.ToRange() + }; + } + } +} diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/PsesSemanticTokensHandler.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/PsesSemanticTokensHandler.cs new file mode 100644 index 0000000..494843b --- /dev/null +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/PsesSemanticTokensHandler.cs @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Management.Automation.Language; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Document; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + internal class PsesSemanticTokensHandler : SemanticTokensHandlerBase + { + protected override SemanticTokensRegistrationOptions CreateRegistrationOptions(SemanticTokensCapability capability, ClientCapabilities clientCapabilities) => new() + { + DocumentSelector = LspUtils.PowerShellDocumentSelector, + Legend = new SemanticTokensLegend(), + Full = new SemanticTokensCapabilityRequestFull + { + Delta = true + }, + Range = true + }; + + private readonly WorkspaceService _workspaceService; + + public PsesSemanticTokensHandler(WorkspaceService workspaceService) => _workspaceService = workspaceService; + + protected override Task Tokenize(SemanticTokensBuilder builder, ITextDocumentIdentifierParams identifier, + CancellationToken cancellationToken) + { + ScriptFile file = _workspaceService.GetFile(identifier.TextDocument.Uri); + foreach (Token token in file.ScriptTokens) + { + PushToken(token, builder); + } + return Task.CompletedTask; + } + + private static void PushToken(Token token, SemanticTokensBuilder builder) + { + foreach (SemanticToken sToken in ConvertToSemanticTokens(token)) + { + builder.Push( + sToken.Line, + sToken.Column, + length: sToken.Text.Length, + sToken.Type, + tokenModifiers: sToken.TokenModifiers); + } + } + + internal static IEnumerable ConvertToSemanticTokens(Token token) + { + if (token is StringExpandableToken stringExpandableToken) + { + // Try parsing tokens within the string + if (stringExpandableToken.NestedTokens != null) + { + foreach (Token t in stringExpandableToken.NestedTokens) + { + foreach (SemanticToken subToken in ConvertToSemanticTokens(t)) + { + yield return subToken; + } + } + yield break; + } + } + + SemanticTokenType mappedType = MapSemanticTokenType(token); + if (mappedType == default) + { + yield break; + } + + //Note that both column and line numbers are 0-based + yield return new SemanticToken( + token.Text, + mappedType, + line: token.Extent.StartLineNumber - 1, + column: token.Extent.StartColumnNumber - 1, + tokenModifiers: Array.Empty()); + } + + private static SemanticTokenType MapSemanticTokenType(Token token) + { + // First check token flags + if ((token.TokenFlags & TokenFlags.Keyword) != 0) + { + return SemanticTokenType.Keyword; + } + + if ((token.TokenFlags & TokenFlags.CommandName) != 0) + { + return SemanticTokenType.Function; + } + + if (token.Kind != TokenKind.Generic && (token.TokenFlags & + (TokenFlags.BinaryOperator | TokenFlags.UnaryOperator | TokenFlags.AssignmentOperator)) != 0) + { + return SemanticTokenType.Operator; + } + + if ((token.TokenFlags & TokenFlags.AttributeName) != 0) + { + return SemanticTokenType.Decorator; + } + + if ((token.TokenFlags & TokenFlags.TypeName) != 0) + { + return SemanticTokenType.Type; + } + + // This represents keys in hashtables and also properties like `Foo` in `$myVar.Foo` + if ((token.TokenFlags & TokenFlags.MemberName) != 0) + { + return SemanticTokenType.Property; + } + + // Only check token kind after checking flags + switch (token.Kind) + { + case TokenKind.Comment: + return SemanticTokenType.Comment; + + case TokenKind.Parameter: + case TokenKind.Generic when token is StringLiteralToken slt && slt.Text.StartsWith("--"): + return SemanticTokenType.Parameter; + + case TokenKind.Variable: + case TokenKind.SplattedVariable: + return SemanticTokenType.Variable; + + case TokenKind.StringExpandable: + case TokenKind.StringLiteral: + case TokenKind.HereStringExpandable: + case TokenKind.HereStringLiteral: + return SemanticTokenType.String; + + case TokenKind.Number: + return SemanticTokenType.Number; + + case TokenKind.Label: + return SemanticTokenType.Label; + } + + return null; + } + + protected override Task GetSemanticTokensDocument( + ITextDocumentIdentifierParams @params, + CancellationToken cancellationToken) => Task.FromResult(new SemanticTokensDocument(RegistrationOptions.Legend)); + } +} diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/ReferencesHandler.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/ReferencesHandler.cs new file mode 100644 index 0000000..c7297e1 --- /dev/null +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/ReferencesHandler.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.Symbols; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Document; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + internal class PsesReferencesHandler : ReferencesHandlerBase + { + private static readonly LocationContainer s_emptyLocationContainer = new(); + private readonly SymbolsService _symbolsService; + private readonly WorkspaceService _workspaceService; + + public PsesReferencesHandler(SymbolsService symbolsService, WorkspaceService workspaceService) + { + _symbolsService = symbolsService; + _workspaceService = workspaceService; + } + + protected override ReferenceRegistrationOptions CreateRegistrationOptions(ReferenceCapability capability, ClientCapabilities clientCapabilities) => new() + { + DocumentSelector = LspUtils.PowerShellDocumentSelector + }; + + public override async Task Handle(ReferenceParams request, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return s_emptyLocationContainer; + } + + ScriptFile scriptFile = _workspaceService.GetFile(request.TextDocument.Uri); + + SymbolReference foundSymbol = + SymbolsService.FindSymbolAtLocation( + scriptFile, + request.Position.Line + 1, + request.Position.Character + 1); + + List locations = new(); + foreach (SymbolReference foundReference in await _symbolsService.ScanForReferencesOfSymbolAsync( + foundSymbol, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + + // Respect the request's setting to include declarations. + if (!request.Context.IncludeDeclaration && foundReference.IsDeclaration) + { + continue; + } + + locations.Add(new Location + { + Uri = DocumentUri.From(foundReference.FilePath), + Range = foundReference.NameRegion.ToRange() + }); + } + + return locations.Count == 0 + ? s_emptyLocationContainer + : locations; + } + } +} diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/SignatureHelpHandler.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/SignatureHelpHandler.cs new file mode 100644 index 0000000..240cef1 --- /dev/null +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/SignatureHelpHandler.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.Symbols; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Document; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + internal class PsesSignatureHelpHandler : SignatureHelpHandlerBase + { + private readonly ILogger _logger; + private readonly SymbolsService _symbolsService; + private readonly WorkspaceService _workspaceService; + + public PsesSignatureHelpHandler( + ILoggerFactory factory, + SymbolsService symbolsService, + WorkspaceService workspaceService) + { + _logger = factory.CreateLogger(); + _symbolsService = symbolsService; + _workspaceService = workspaceService; + } + + protected override SignatureHelpRegistrationOptions CreateRegistrationOptions(SignatureHelpCapability capability, ClientCapabilities clientCapabilities) => new() + { + DocumentSelector = LspUtils.PowerShellDocumentSelector, + // TODO: We should evaluate what triggers (and re-triggers) signature help (like dash?) + TriggerCharacters = new Container(" ") + }; + + public override async Task Handle(SignatureHelpParams request, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + _logger.LogDebug("SignatureHelp request canceled for file: {Uri}", request.TextDocument.Uri); + return new SignatureHelp(); + } + + ScriptFile scriptFile = _workspaceService.GetFile(request.TextDocument.Uri); + + ParameterSetSignatures parameterSets = + await _symbolsService.FindParameterSetsInFileAsync( + scriptFile, + request.Position.Line + 1, + request.Position.Character + 1).ConfigureAwait(false); + + if (parameterSets is null) + { + return new SignatureHelp(); + } + + SignatureInformation[] signatures = new SignatureInformation[parameterSets.Signatures.Length]; + for (int i = 0; i < signatures.Length; i++) + { + List parameters = new(); + foreach (ParameterInfo param in parameterSets.Signatures[i].Parameters) + { + parameters.Add(CreateParameterInfo(param)); + } + + signatures[i] = new SignatureInformation + { + Label = parameterSets.CommandName + " " + parameterSets.Signatures[i].SignatureText, + Documentation = null, + Parameters = parameters, + }; + } + + return new SignatureHelp + { + Signatures = signatures, + ActiveParameter = null, + ActiveSignature = 0 + }; + } + + private static ParameterInformation CreateParameterInfo(ParameterInfo parameterInfo) + { + return new ParameterInformation + { + Label = parameterInfo.Name, + Documentation = parameterInfo.HelpMessage + }; + } + } +} diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/TextDocumentHandler.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/TextDocumentHandler.cs new file mode 100644 index 0000000..a02e7b8 --- /dev/null +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/TextDocumentHandler.cs @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using MediatR; +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Server.Capabilities; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.LanguageServer.Protocol.Document; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + internal class PsesTextDocumentHandler : TextDocumentSyncHandlerBase + { + private static readonly Uri s_fakeUri = new("Untitled:fake"); + + private readonly ILogger _logger; + private readonly AnalysisService _analysisService; + private readonly WorkspaceService _workspaceService; + private readonly RemoteFileManagerService _remoteFileManagerService; + + private bool _isFileWatcherSupported; + + public static TextDocumentSyncKind Change => TextDocumentSyncKind.Incremental; + + public PsesTextDocumentHandler( + ILoggerFactory factory, + AnalysisService analysisService, + WorkspaceService workspaceService, + RemoteFileManagerService remoteFileManagerService) + { + _logger = factory.CreateLogger(); + _analysisService = analysisService; + _workspaceService = workspaceService; + _remoteFileManagerService = remoteFileManagerService; + } + + public override Task Handle(DidChangeTextDocumentParams notification, CancellationToken token) + { + ScriptFile changedFile = _workspaceService.GetFile(notification.TextDocument.Uri); + + // A text change notification can batch multiple change requests + foreach (TextDocumentContentChangeEvent textChange in notification.ContentChanges) + { + changedFile.ApplyChange( + GetFileChangeDetails( + textChange.Range, + textChange.Text)); + } + + // Kick off script diagnostics without blocking the response + // TODO: Get all recently edited files in the workspace + _analysisService.StartScriptDiagnostics(new ScriptFile[] { changedFile }); + return Unit.Task; + } + + protected override TextDocumentSyncRegistrationOptions CreateRegistrationOptions(TextSynchronizationCapability capability, ClientCapabilities clientCapabilities) + { + _isFileWatcherSupported = clientCapabilities.Workspace.DidChangeWatchedFiles.IsSupported; + return new TextDocumentSyncRegistrationOptions() + { + DocumentSelector = LspUtils.PowerShellDocumentSelector, + Change = Change, + Save = new SaveOptions { IncludeText = true } + }; + } + + public override Task Handle(DidOpenTextDocumentParams notification, CancellationToken token) + { + // We're receiving notifications for special "git" scheme files from VS Code, and we + // need to ignore those! Otherwise they're added to our workspace service's opened files + // and cause duplicate references. + if (notification.TextDocument.Uri.Scheme == "git") + { + return Unit.Task; + } + + // We use a fake Uri because we only want to test the LanguageId here and not if the + // file ends in ps*1. + TextDocumentAttributes attributes = new(s_fakeUri, notification.TextDocument.LanguageId); + if (!LspUtils.PowerShellDocumentSelector.IsMatch(attributes)) + { + return Unit.Task; + } + + ScriptFile openedFile = + _workspaceService.GetFileBuffer( + notification.TextDocument.Uri, + notification.TextDocument.Text); + + openedFile.IsOpen = true; + _analysisService.StartScriptDiagnostics(new ScriptFile[] { openedFile }); + + _logger.LogTrace("Finished opening document."); + return Unit.Task; + } + + public override Task Handle(DidCloseTextDocumentParams notification, CancellationToken token) + { + // Find and close the file in the current session + ScriptFile fileToClose = _workspaceService.GetFile(notification.TextDocument.Uri); + + if (fileToClose != null) + { + fileToClose.IsOpen = false; + + // If the file watcher is supported, only close in-memory files when this + // notification is triggered. This lets us keep workspace files open so we can scan + // for references. When a file is deleted, the file watcher will close the file. + if (!_isFileWatcherSupported || fileToClose.IsInMemory) + { + _workspaceService.CloseFile(fileToClose); + } + + _analysisService.ClearMarkers(fileToClose); + } + + _logger.LogTrace("Finished closing document."); + return Unit.Task; + } + + public override async Task Handle(DidSaveTextDocumentParams notification, CancellationToken token) + { + ScriptFile savedFile = _workspaceService.GetFile(notification.TextDocument.Uri); + + if (savedFile != null) + { + if (_remoteFileManagerService.IsUnderRemoteTempPath(savedFile.FilePath)) + { + await _remoteFileManagerService.SaveRemoteFileAsync(savedFile.FilePath).ConfigureAwait(false); + } + } + return Unit.Value; + } + + public override TextDocumentAttributes GetTextDocumentAttributes(DocumentUri uri) => new(uri, "powershell"); + + private static FileChange GetFileChangeDetails(Range changeRange, string insertString) + { + // The protocol's positions are zero-based so add 1 to all offsets + + if (changeRange == null) + { + return new FileChange { InsertString = insertString, IsReload = true }; + } + + return new FileChange + { + InsertString = insertString, + Line = changeRange.Start.Line + 1, + Offset = changeRange.Start.Character + 1, + EndLine = changeRange.End.Line + 1, + EndOffset = changeRange.End.Character + 1, + IsReload = false + }; + } + } +} diff --git a/src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs b/src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs new file mode 100644 index 0000000..fb6772d --- /dev/null +++ b/src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs @@ -0,0 +1,582 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Management.Automation; +using System.Management.Automation.Language; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.LanguageServer.Protocol; + +namespace Microsoft.PowerShell.EditorServices.Services.TextDocument +{ + /// + /// Contains the details and contents of an open script file. + /// + internal sealed class ScriptFile + { + #region Private Fields + + private static readonly string[] s_newlines = new[] + { + "\r\n", + "\n" + }; + + private readonly Version powerShellVersion; + + #endregion + + #region Properties + + /// + /// Gets the path at which this file resides. + /// + public string FilePath { get; } + + /// + /// Gets the file path in LSP DocumentUri form. The ClientPath property must not be null. + /// + public DocumentUri DocumentUri { get; set; } + + /// + /// Gets or sets a boolean that determines whether + /// semantic analysis should be enabled for this file. + /// For internal use only. + /// TODO: Actually use and respect this property to avoid built-in files from being analyzed. + /// + internal bool IsAnalysisEnabled { get; set; } + + /// + /// Gets a boolean that determines whether this file is + /// in-memory or not (either unsaved or non-file content). + /// + public bool IsInMemory { get; } + + /// + /// Gets a string containing the full contents of the file. + /// + public string Contents => string.Join(Environment.NewLine, FileLines); + + /// + /// Gets a BufferRange that represents the entire content + /// range of the file. + /// + public BufferRange FileRange { get; private set; } + + /// + /// Gets the list of syntax markers found by parsing this + /// file's contents. + /// + public List DiagnosticMarkers + { + get; + private set; + } + + /// + /// Gets the list of strings for each line of the file. + /// + internal List FileLines + { + get; + private set; + } + + /// + /// Gets the ScriptBlockAst representing the parsed script contents. + /// + public ScriptBlockAst ScriptAst + { + get; + private set; + } + + /// + /// Gets the array of Tokens representing the parsed script contents. + /// + public Token[] ScriptTokens + { + get; + private set; + } + + internal ReferenceTable References { get; } + + internal bool IsOpen { get; set; } + + #endregion + + #region Constructors + + /// + /// Creates a new ScriptFile instance by reading file contents from + /// the given TextReader. + /// + /// The System.Uri of the file. + /// The TextReader to use for reading the file's contents. + /// The version of PowerShell for which the script is being parsed. + internal ScriptFile( + DocumentUri docUri, + TextReader textReader, + Version powerShellVersion) + { + // For non-files, use their URI representation instead + // so that other operations know it's untitled/in-memory + // and don't think that it's a relative path + // on the file system. + IsInMemory = !docUri.ToUri().IsFile; + FilePath = IsInMemory + ? docUri.ToString() + : docUri.GetFileSystemPath(); + DocumentUri = docUri; + IsAnalysisEnabled = true; + this.powerShellVersion = powerShellVersion; + + // SetFileContents() calls ParseFileContents() which initializes the rest of the properties. + SetFileContents(textReader.ReadToEnd()); + References = new ReferenceTable(this); + } + + /// + /// Creates a new ScriptFile instance with the specified file contents. + /// + /// The System.Uri of the file. + /// The initial contents of the script file. + /// The version of PowerShell for which the script is being parsed. + internal static ScriptFile Create( + DocumentUri fileUri, + string initialBuffer, + Version powerShellVersion) + + { + using TextReader textReader = new StringReader(initialBuffer); + return new ScriptFile(fileUri, textReader, powerShellVersion); + } + + #endregion + + #region Public Methods + + /// + /// Get the lines in a string. + /// + /// Input string to be split up into lines. + /// The lines in the string. + internal static List GetLines(string text) + { + if (text == null) + { + throw new ArgumentNullException(nameof(text)); + } + + return new List(text.Split(s_newlines, StringSplitOptions.None)); + } + + /// + /// Determines whether the supplied path indicates the file is an "untitled:Untitled-X" + /// which has not been saved to file. + /// + /// The path to check. + /// True if the path is an untitled file, false otherwise. + internal static bool IsUntitledPath(string path) + { + Validate.IsNotNull(nameof(path), path); + // This may not have been given a URI, so return false instead of throwing. + return Uri.IsWellFormedUriString(path, UriKind.RelativeOrAbsolute) && + !string.Equals(DocumentUri.From(path).Scheme, Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Gets a line from the file's contents. + /// + /// The 1-based line number in the file. + /// The complete line at the given line number. + public string GetLine(int lineNumber) + { + Validate.IsWithinRange( + nameof(lineNumber), lineNumber, + 1, FileLines.Count + 1); + + return FileLines[lineNumber - 1]; + } + + /// + /// Gets a range of lines from the file's contents. + /// + /// The buffer range from which lines will be extracted. + /// An array of strings from the specified range of the file. + public string[] GetLinesInRange(BufferRange bufferRange) + { + ValidatePosition(bufferRange.Start); + ValidatePosition(bufferRange.End); + + List linesInRange = new(); + + int startLine = bufferRange.Start.Line, + endLine = bufferRange.End.Line; + + for (int line = startLine; line <= endLine; line++) + { + string currentLine = FileLines[line - 1]; + int startColumn = + line == startLine + ? bufferRange.Start.Column + : 1; + int endColumn = + line == endLine + ? bufferRange.End.Column + : currentLine.Length + 1; + + currentLine = + currentLine.Substring( + startColumn - 1, + endColumn - startColumn); + + linesInRange.Add(currentLine); + } + + return linesInRange.ToArray(); + } + + /// + /// Throws ArgumentOutOfRangeException if the given position is outside + /// of the file's buffer extents. + /// + /// The position in the buffer to be validated. + public void ValidatePosition(BufferPosition bufferPosition) + { + ValidatePosition( + bufferPosition.Line, + bufferPosition.Column); + } + + /// + /// Throws ArgumentOutOfRangeException if the given position is outside + /// of the file's buffer extents. + /// + /// The 1-based line to be validated. + /// The 1-based column to be validated. + public void ValidatePosition(int line, int column) + { + int maxLine = FileLines.Count; + if (line < 1 || line > maxLine) + { + throw new ArgumentOutOfRangeException($"Position {line}:{column} is outside of the line range of 1 to {maxLine}."); + } + + // The maximum column is either **one past** the length of the string + // or 1 if the string is empty. + string lineString = FileLines[line - 1]; + int maxColumn = lineString.Length > 0 ? lineString.Length + 1 : 1; + + if (column < 1 || column > maxColumn) + { + throw new ArgumentOutOfRangeException($"Position {line}:{column} is outside of the column range of 1 to {maxColumn}."); + } + } + + /// + /// Applies the provided FileChange to the file's contents + /// + /// The FileChange to apply to the file's contents. + public void ApplyChange(FileChange fileChange) + { + // Break up the change lines + string[] changeLines = fileChange.InsertString.Split('\n'); + + if (fileChange.IsReload) + { + FileLines.Clear(); + FileLines.AddRange(changeLines); + } + else + { + // VSCode sometimes likes to give the change start line as (FileLines.Count + 1). + // This used to crash EditorServices, but we now treat it as an append. + // See https://github.com/PowerShell/vscode-powershell/issues/1283 + if (fileChange.Line == FileLines.Count + 1) + { + foreach (string addedLine in changeLines) + { + string finalLine = addedLine.TrimEnd('\r'); + FileLines.Add(finalLine); + } + } + // Similarly, when lines are deleted from the end of the file, + // VSCode likes to give the end line as (FileLines.Count + 1). + else if (fileChange.EndLine == FileLines.Count + 1 && string.Empty.Equals(fileChange.InsertString)) + { + int lineIndex = fileChange.Line - 1; + FileLines.RemoveRange(lineIndex, FileLines.Count - lineIndex); + } + // Otherwise, the change needs to go between existing content + else + { + ValidatePosition(fileChange.Line, fileChange.Offset); + ValidatePosition(fileChange.EndLine, fileChange.EndOffset); + + // Get the first fragment of the first line + string firstLineFragment = + FileLines[fileChange.Line - 1] + .Substring(0, fileChange.Offset - 1); + + // Get the last fragment of the last line + string endLine = FileLines[fileChange.EndLine - 1]; + string lastLineFragment = + endLine.Substring( + fileChange.EndOffset - 1, + FileLines[fileChange.EndLine - 1].Length - fileChange.EndOffset + 1); + + // Remove the old lines + for (int i = 0; i <= fileChange.EndLine - fileChange.Line; i++) + { + FileLines.RemoveAt(fileChange.Line - 1); + } + + // Build and insert the new lines + int currentLineNumber = fileChange.Line; + for (int changeIndex = 0; changeIndex < changeLines.Length; changeIndex++) + { + // Since we split the lines above using \n, make sure to + // trim the ending \r's off as well. + string finalLine = changeLines[changeIndex].TrimEnd('\r'); + + // Should we add first or last line fragments? + if (changeIndex == 0) + { + // Append the first line fragment + finalLine = firstLineFragment + finalLine; + } + if (changeIndex == changeLines.Length - 1) + { + // Append the last line fragment + finalLine += lastLineFragment; + } + + FileLines.Insert(currentLineNumber - 1, finalLine); + currentLineNumber++; + } + } + } + + // Parse the script again to be up-to-date + ParseFileContents(); + References.TagAsChanged(); + } + + /// + /// Calculates the zero-based character offset of a given + /// line and column position in the file. + /// + /// The 1-based line number from which the offset is calculated. + /// The 1-based column number from which the offset is calculated. + /// The zero-based offset for the given file position. + public int GetOffsetAtPosition(int lineNumber, int columnNumber) + { + Validate.IsWithinRange(nameof(lineNumber), lineNumber, 1, FileLines.Count + 1); + Validate.IsGreaterThan(nameof(columnNumber), columnNumber, 0); + + int offset = 0; + + for (int i = 0; i < lineNumber; i++) + { + if (i == lineNumber - 1) + { + // Subtract 1 to account for 1-based column numbering + offset += columnNumber - 1; + } + else + { + // Add an offset to account for the current platform's newline characters + offset += FileLines[i].Length + Environment.NewLine.Length; + } + } + + return offset; + } + + /// + /// Calculates a FilePosition relative to a starting BufferPosition + /// using the given 1-based line and column offset. + /// + /// The original BufferPosition from which an new position should be calculated. + /// The 1-based line offset added to the original position in this file. + /// The 1-based column offset added to the original position in this file. + /// A new FilePosition instance with the resulting line and column number. + public FilePosition CalculatePosition( + BufferPosition originalPosition, + int lineOffset, + int columnOffset) + { + int newLine = originalPosition.Line + lineOffset, + newColumn = originalPosition.Column + columnOffset; + + ValidatePosition(newLine, newColumn); + + string scriptLine = FileLines[newLine - 1]; + newColumn = Math.Min(scriptLine.Length + 1, newColumn); + + return new FilePosition(this, newLine, newColumn); + } + + /// + /// Calculates the 1-based line and column number position based + /// on the given buffer offset. + /// + /// The buffer offset to convert. + /// A new BufferPosition containing the position of the offset. + public BufferPosition GetPositionAtOffset(int bufferOffset) + { + BufferRange bufferRange = + GetRangeBetweenOffsets( + bufferOffset, bufferOffset); + + return bufferRange.Start; + } + + /// + /// Calculates the 1-based line and column number range based on + /// the given start and end buffer offsets. + /// + /// The start offset of the range. + /// The end offset of the range. + /// A new BufferRange containing the positions in the offset range. + public BufferRange GetRangeBetweenOffsets(int startOffset, int endOffset) + { + bool foundStart = false; + int currentOffset = 0; + int searchedOffset = startOffset; + + BufferPosition startPosition = new(0, 0); + BufferPosition endPosition = startPosition; + + int line = 0; + while (line < FileLines.Count) + { + if (searchedOffset <= currentOffset + FileLines[line].Length) + { + int column = searchedOffset - currentOffset; + + // Have we already found the start position? + if (foundStart) + { + // Assign the end position and end the search + endPosition = new BufferPosition(line + 1, column + 1); + break; + } + else + { + startPosition = new BufferPosition(line + 1, column + 1); + + // Do we only need to find the start position? + if (startOffset == endOffset) + { + endPosition = startPosition; + break; + } + else + { + // Since the end offset can be on the same line, + // skip the line increment and continue searching + // for the end position + foundStart = true; + searchedOffset = endOffset; + continue; + } + } + } + + // Increase the current offset and include newline length + currentOffset += FileLines[line].Length + Environment.NewLine.Length; + line++; + } + + return new BufferRange(startPosition, endPosition); + } + + #endregion + + #region Private Methods + + internal void SetFileContents(string fileContents) + { + // Split the file contents into lines and trim + // any carriage returns from the strings. + FileLines = GetLines(fileContents); + + // Parse the contents to get syntax tree and errors + ParseFileContents(); + } + + /// + /// Parses the current file contents to get the AST, tokens, + /// and parse errors. + /// + private void ParseFileContents() + { + ParseError[] parseErrors = null; + + // First, get the updated file range + int lineCount = FileLines.Count; + FileRange = lineCount > 0 + ? new BufferRange( + new BufferPosition(1, 1), + new BufferPosition( + lineCount + 1, + FileLines[lineCount - 1].Length + 1)) + : BufferRange.None; + + try + { + Token[] scriptTokens; + + // This overload appeared with Windows 10 Update 1 + if (powerShellVersion.Major >= 6 || + (powerShellVersion.Major == 5 && powerShellVersion.Build >= 10586)) + { + // Include the file path so that module relative + // paths are evaluated correctly + ScriptAst = + Parser.ParseInput( + Contents, + FilePath, + out scriptTokens, + out parseErrors); + } + else + { + ScriptAst = + Parser.ParseInput( + Contents, + out scriptTokens, + out parseErrors); + } + + ScriptTokens = scriptTokens; + } + catch (RuntimeException ex) + { + ParseError parseError = + new( + null, + ex.ErrorRecord.FullyQualifiedErrorId, + ex.Message); + + parseErrors = new[] { parseError }; + ScriptTokens = Array.Empty(); + ScriptAst = null; + } + + // Translate parse errors into syntax markers + DiagnosticMarkers = + parseErrors + .Select(ScriptFileMarker.FromParseError) + .ToList(); + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Services/TextDocument/ScriptFileMarker.cs b/src/PowerShellEditorServices/Services/TextDocument/ScriptFileMarker.cs new file mode 100644 index 0000000..e3273e3 --- /dev/null +++ b/src/PowerShellEditorServices/Services/TextDocument/ScriptFileMarker.cs @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Management.Automation; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation.Language; +using Microsoft.PowerShell.EditorServices.Utility; + +namespace Microsoft.PowerShell.EditorServices.Services.TextDocument +{ + /// + /// Contains details for a code correction which can be applied from a ScriptFileMarker. + /// + public sealed class MarkerCorrection + { + /// + /// Gets or sets the display name of the code correction. + /// + public string Name { get; set; } + + /// + /// Gets or sets the ScriptRegion that define the edit to be made by the correction. + /// + public ScriptRegion Edit { get; set; } + } + + /// + /// Defines the message level of a script file marker. + /// + public enum ScriptFileMarkerLevel + { + ///  +        /// Information: This warning is trivial, but may be useful. They are recommended by PowerShell best practice. +        ///  + Information = 0, +        ///  +        /// WARNING: This warning may cause a problem or does not follow PowerShell's recommended guidelines. +        ///  + Warning = 1, +        ///  +        /// ERROR: This warning is likely to cause a problem or does not follow PowerShell's required guidelines. +        ///  + Error = 2, +        ///  +        /// ERROR: This diagnostic is caused by an actual parsing error, and is generated only by the engine. +        ///  + ParseError = 3 + }; + + /// + /// Contains details about a marker that should be displayed + /// for the a script file. The marker information could come + /// from syntax parsing or semantic analysis of the script. + /// + public class ScriptFileMarker + { + #region Properties + + /// + /// Gets or sets the marker's message string. + /// + public string Message { get; set; } + + /// + /// Gets or sets the ruleName associated with this marker. + /// + public string RuleName { get; set; } + + /// + /// Gets or sets the marker's message level. + /// + public ScriptFileMarkerLevel Level { get; set; } + + /// + /// Gets or sets the ScriptRegion where the marker should appear. + /// + public ScriptRegion ScriptRegion { get; set; } + + /// + /// Gets or sets a optional code corrections that can be applied based on its marker. + /// + public IEnumerable Corrections { get; set; } + + /// + /// Gets or sets the name of the marker's source like "PowerShell" + /// or "PSScriptAnalyzer". + /// + public string Source { get; set; } + + #endregion + + #region Public Methods + + internal static ScriptFileMarker FromParseError( + ParseError parseError) + { + Validate.IsNotNull(nameof(parseError), parseError); + + return new ScriptFileMarker + { + Message = parseError.Message, + Level = ScriptFileMarkerLevel.Error, + ScriptRegion = new(parseError.Extent), + Source = "PowerShell" + }; + } + + internal static ScriptFileMarker FromDiagnosticRecord(PSObject psObject) + { + Validate.IsNotNull(nameof(psObject), psObject); + + // make sure psobject is of type DiagnosticRecord + if (!psObject.TypeNames.Contains( + "Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord", + StringComparer.OrdinalIgnoreCase)) + { + throw new ArgumentException("Input PSObject must of DiagnosticRecord type."); + } + + // casting psobject to dynamic allows us to access + // the diagnostic record's properties directly i.e. . + // without having to go through PSObject's Members property. + dynamic diagnosticRecord = psObject; + List markerCorrections = new(); + if (diagnosticRecord.SuggestedCorrections != null) + { + foreach (dynamic suggestedCorrection in diagnosticRecord.SuggestedCorrections) + { + markerCorrections.Add(new MarkerCorrection + { + Name = suggestedCorrection.Description ?? diagnosticRecord.Message, + Edit = new ScriptRegion( + diagnosticRecord.ScriptPath, + suggestedCorrection.Text, + startLineNumber: suggestedCorrection.StartLineNumber, + startColumnNumber: suggestedCorrection.StartColumnNumber, + startOffset: -1, + endLineNumber: suggestedCorrection.EndLineNumber, + endColumnNumber: suggestedCorrection.EndColumnNumber, + endOffset: -1), + }); + } + } + + string severity = diagnosticRecord.Severity.ToString(); + if (!Enum.TryParse(severity, out ScriptFileMarkerLevel level)) + { + throw new ArgumentException( + $"The provided DiagnosticSeverity value '{severity}' is unknown.", + "diagnosticSeverity"); + } + + return new ScriptFileMarker + { + Message = diagnosticRecord.Message as string ?? string.Empty, + RuleName = diagnosticRecord.RuleName as string ?? string.Empty, + Level = level, + ScriptRegion = new(diagnosticRecord.Extent as IScriptExtent), + Corrections = markerCorrections, + Source = "PSScriptAnalyzer" + }; + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Services/TextDocument/ScriptRegion.cs b/src/PowerShellEditorServices/Services/TextDocument/ScriptRegion.cs new file mode 100644 index 0000000..e818b6d --- /dev/null +++ b/src/PowerShellEditorServices/Services/TextDocument/ScriptRegion.cs @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Management.Automation.Language; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Services.TextDocument +{ + /// + /// Contains details about a specific region of text in script file. + /// + public sealed class ScriptRegion : IScriptExtent + { + internal TextEdit ToTextEdit() => new() { NewText = Text, Range = ToRange() }; + + internal Range ToRange() + { + return new Range + { + Start = new Position + { + Line = StartLineNumber - 1, + Character = StartColumnNumber - 1 + }, + End = new Position + { + Line = EndLineNumber - 1, + Character = EndColumnNumber - 1 + } + }; + } + + // Same as PowerShell's EmptyScriptExtent + internal bool IsEmpty() + { + return StartLineNumber == 0 && StartColumnNumber == 0 + && EndLineNumber == 0 && EndColumnNumber == 0 + && string.IsNullOrEmpty(File) + && string.IsNullOrEmpty(Text); + } + + // Do not use PowerShell's ContainsLineAndColumn, it's nonsense. + internal bool ContainsPosition(int line, int column) + { + return StartLineNumber <= line && line <= EndLineNumber + && StartColumnNumber <= column && column <= EndColumnNumber; + } + + public override string ToString() => $"Start {StartLineNumber}:{StartColumnNumber}, End {EndLineNumber}:{EndColumnNumber}"; + + #region Constructors + + public ScriptRegion( + string file, + string text, + int startLineNumber, + int startColumnNumber, + int startOffset, + int endLineNumber, + int endColumnNumber, + int endOffset) + { + File = file; + Text = text; + StartLineNumber = startLineNumber; + StartColumnNumber = startColumnNumber; + StartOffset = startOffset; + EndLineNumber = endLineNumber; + EndColumnNumber = endColumnNumber; + EndOffset = endOffset; + } + + public ScriptRegion(IScriptExtent scriptExtent) + { + File = scriptExtent.File; + + // IScriptExtent throws an ArgumentOutOfRange exception if Text is null + try + { + Text = scriptExtent.Text; + } + catch (ArgumentOutOfRangeException) + { + Text = string.Empty; + } + + StartLineNumber = scriptExtent.StartLineNumber; + StartColumnNumber = scriptExtent.StartColumnNumber; + StartOffset = scriptExtent.StartOffset; + EndLineNumber = scriptExtent.EndLineNumber; + EndColumnNumber = scriptExtent.EndColumnNumber; + EndOffset = scriptExtent.EndOffset; + } + + /// + /// NOTE: While unused, we kept this as it was previously exposed on a public class. + /// + public static ScriptRegion Create(IScriptExtent scriptExtent) => new(scriptExtent); + + #endregion + + #region Properties + + /// + /// Gets the file path of the script file in which this region is contained. + /// + public string File { get; } + + /// + /// Gets or sets the text that is contained within the region. + /// + public string Text { get; } + + /// + /// Gets or sets the starting line number of the region. + /// + public int StartLineNumber { get; } + + /// + /// Gets or sets the starting column number of the region. + /// + public int StartColumnNumber { get; } + + /// + /// Gets or sets the starting file offset of the region. + /// + public int StartOffset { get; } + + /// + /// Gets or sets the ending line number of the region. + /// + public int EndLineNumber { get; } + + /// + /// Gets or sets the ending column number of the region. + /// + public int EndColumnNumber { get; } + + /// + /// Gets or sets the ending file offset of the region. + /// + public int EndOffset { get; } + + /// + /// Gets the starting IScriptPosition in the script. + /// (Currently unimplemented.) + /// + IScriptPosition IScriptExtent.StartScriptPosition => throw new NotImplementedException(); + + /// + /// Gets the ending IScriptPosition in the script. + /// (Currently unimplemented.) + /// + IScriptPosition IScriptExtent.EndScriptPosition => throw new NotImplementedException(); + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Services/TextDocument/SemanticToken.cs b/src/PowerShellEditorServices/Services/TextDocument/SemanticToken.cs new file mode 100644 index 0000000..a3fe3f3 --- /dev/null +++ b/src/PowerShellEditorServices/Services/TextDocument/SemanticToken.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Services.TextDocument +{ + internal class SemanticToken + { + public SemanticToken(string text, SemanticTokenType type, int line, int column, IEnumerable tokenModifiers) + { + Line = line; + Text = text; + Column = column; + Type = type; + TokenModifiers = tokenModifiers; + } + + public string Text { get; set; } + + public int Line { get; set; } + + public int Column { get; set; } + + public SemanticTokenType Type { get; set; } + + public IEnumerable TokenModifiers { get; set; } + } +} diff --git a/src/PowerShellEditorServices/Services/TextDocument/TokenOperations.cs b/src/PowerShellEditorServices/Services/TextDocument/TokenOperations.cs new file mode 100644 index 0000000..9ed65db --- /dev/null +++ b/src/PowerShellEditorServices/Services/TextDocument/TokenOperations.cs @@ -0,0 +1,209 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Management.Automation.Language; +using System.Text.RegularExpressions; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Services.TextDocument +{ + /// + /// Provides common operations for the tokens of a parsed script. + /// + internal static class TokenOperations + { + // These regular expressions are used to match lines which mark the start and end of region comment in a PowerShell + // script. They are based on the defaults in the VS Code Language Configuration at; + // https://github.com/Microsoft/vscode/blob/64186b0a26/extensions/powershell/language-configuration.json#L26-L31 + // https://github.com/Microsoft/vscode/issues/49070 + internal static readonly Regex s_startRegionTextRegex = new( + @"^\s*#[rR]egion\b", RegexOptions.Compiled); + internal static readonly Regex s_endRegionTextRegex = new( + @"^\s*#[eE]nd[rR]egion\b", RegexOptions.Compiled); + + /// + /// Extracts all of the unique foldable regions in a script given the list tokens + /// + internal static FoldingReferenceList FoldableReferences(Token[] tokens) + { + FoldingReferenceList refList = new(); + + Stack tokenCurlyStack = new(); + Stack tokenParenStack = new(); + foreach (Token token in tokens) + { + switch (token.Kind) + { + // Find matching braces { -> } + // Find matching hashes @{ -> } + case TokenKind.LCurly: + case TokenKind.AtCurly: + tokenCurlyStack.Push(token); + break; + + case TokenKind.RCurly: + if (tokenCurlyStack.Count > 0) + { + refList.SafeAdd(CreateFoldingReference(tokenCurlyStack.Pop(), token, default)); + } + break; + + // Find matching parentheses ( -> ) + // Find matching array literals @( -> ) + // Find matching subexpressions $( -> ) + case TokenKind.LParen: + case TokenKind.AtParen: + case TokenKind.DollarParen: + tokenParenStack.Push(token); + break; + + case TokenKind.RParen: + if (tokenParenStack.Count > 0) + { + refList.SafeAdd(CreateFoldingReference(tokenParenStack.Pop(), token, default)); + } + break; + + // Find contiguous here strings @' -> '@ + // Find unopinionated variable names ${ \n \n } + // Find contiguous expandable here strings @" -> "@ + case TokenKind.HereStringLiteral: + case TokenKind.Variable: + case TokenKind.HereStringExpandable: + if (token.Extent.StartLineNumber != token.Extent.EndLineNumber) + { + refList.SafeAdd(CreateFoldingReference(token, token, default)); + } + break; + } + } + + // Find matching comment regions #region -> #endregion + // Given a list of tokens, find the tokens that are comments and + // the comment text is either `#region` or `#endregion`, and then use a stack to determine + // the ranges they span + // + // Find blocks of line comments # comment1\n# comment2\n... + // Finding blocks of comment tokens is more complicated as the newline characters are not + // classed as comments. To workaround this we search for valid block comments (See IsBlockComment) + // and then determine contiguous line numbers from there + // + // Find comments regions <# -> #> + // Match the token start and end of kind TokenKind.Comment + Stack tokenCommentRegionStack = new(); + Token blockStartToken = null; + int blockNextLine = -1; + + for (int index = 0; index < tokens.Length; index++) + { + Token token = tokens[index]; + if (token.Kind != TokenKind.Comment) { continue; } + + // Processing for comment regions <# -> #> + if (token.Extent.StartLineNumber != token.Extent.EndLineNumber) + { + refList.SafeAdd(CreateFoldingReference(token, token, FoldingRangeKind.Comment)); + continue; + } + + if (!IsBlockComment(index, tokens)) { continue; } + + // Regex's are very expensive. Use them sparingly! + // Processing for #region -> #endregion + if (s_startRegionTextRegex.IsMatch(token.Text)) + { + tokenCommentRegionStack.Push(token); + continue; + } + if (s_endRegionTextRegex.IsMatch(token.Text)) + { + // Mismatched regions in the script can cause bad stacks. + if (tokenCommentRegionStack.Count > 0) + { + refList.SafeAdd(CreateFoldingReference(tokenCommentRegionStack.Pop(), token, FoldingRangeKind.Region)); + } + continue; + } + + // If it's neither a start or end region then it could be block line comment + // Processing for blocks of line comments # comment1\n# comment2\n... + int thisLine = token.Extent.StartLineNumber - 1; + if ((blockStartToken != null) && (thisLine != blockNextLine)) + { + refList.SafeAdd(CreateFoldingReference(blockStartToken, blockNextLine - 1, FoldingRangeKind.Comment)); + blockStartToken = token; + } + blockStartToken ??= token; + blockNextLine = thisLine + 1; + } + + // If we exit the token array and we're still processing comment lines, then the + // comment block simply ends at the end of document + if (blockStartToken != null) + { + refList.SafeAdd(CreateFoldingReference(blockStartToken, blockNextLine - 1, FoldingRangeKind.Comment)); + } + + return refList; + } + + /// + /// Creates an instance of a FoldingReference object from a start and end langauge Token + /// Returns null if the line range is invalid + /// + private static FoldingReference CreateFoldingReference( + Token startToken, + Token endToken, + FoldingRangeKind? matchKind) + { + if (endToken.Extent.EndLineNumber == startToken.Extent.StartLineNumber) { return null; } + // Extents are base 1, but LSP is base 0, so minus 1 off all lines and character positions + return new FoldingReference + { + StartLine = startToken.Extent.StartLineNumber - 1, + StartCharacter = startToken.Extent.StartColumnNumber - 1, + EndLine = endToken.Extent.EndLineNumber - 1, + EndCharacter = endToken.Extent.EndColumnNumber - 1, + Kind = matchKind + }; + } + + /// + /// Creates an instance of a FoldingReference object from a start token and an end line + /// Returns null if the line range is invalid + /// + private static FoldingReference CreateFoldingReference( + Token startToken, + int endLine, + FoldingRangeKind? matchKind) + { + if (endLine == (startToken.Extent.StartLineNumber - 1)) { return null; } + // Extents are base 1, but LSP is base 0, so minus 1 off all lines and character positions + return new FoldingReference + { + StartLine = startToken.Extent.StartLineNumber - 1, + StartCharacter = startToken.Extent.StartColumnNumber - 1, + EndLine = endLine, + EndCharacter = 0, + Kind = matchKind + }; + } + + /// + /// Returns true if a Token is a block comment; + /// - Must be a TokenKind.comment + /// - Must be preceded by TokenKind.NewLine + /// - Token text must start with a '#'.false This is because comment regions + /// start with '<#' but have the same TokenKind + /// + internal static bool IsBlockComment(int index, Token[] tokens) + { + Token thisToken = tokens[index]; + if (thisToken.Kind != TokenKind.Comment) { return false; } + if (index == 0) { return true; } + if (tokens[index - 1].Kind != TokenKind.NewLine) { return false; } + return thisToken.Text.StartsWith("#"); + } + } +} diff --git a/src/PowerShellEditorServices/Services/Workspace/ConfigurationService.cs b/src/PowerShellEditorServices/Services/Workspace/ConfigurationService.cs new file mode 100644 index 0000000..afc8b84 --- /dev/null +++ b/src/PowerShellEditorServices/Services/Workspace/ConfigurationService.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.Configuration; + +namespace Microsoft.PowerShell.EditorServices.Services +{ + internal class ConfigurationService + { + // This probably needs some sort of lock... or maybe LanguageServerSettings needs it. + public LanguageServerSettings CurrentSettings { get; } = new LanguageServerSettings(); + } +} diff --git a/src/PowerShellEditorServices/Services/Workspace/Handlers/ConfigurationHandler.cs b/src/PowerShellEditorServices/Services/Workspace/Handlers/ConfigurationHandler.cs new file mode 100644 index 0000000..2cfb57c --- /dev/null +++ b/src/PowerShellEditorServices/Services/Workspace/Handlers/ConfigurationHandler.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.Configuration; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Workspace; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + internal class PsesConfigurationHandler : DidChangeConfigurationHandlerBase + { + private readonly ILogger _logger; + private readonly WorkspaceService _workspaceService; + private readonly ConfigurationService _configurationService; + public PsesConfigurationHandler( + ILoggerFactory factory, + WorkspaceService workspaceService, + AnalysisService analysisService, + ConfigurationService configurationService, + SymbolsService symbolsService) + { + _logger = factory.CreateLogger(); + _workspaceService = workspaceService; + _configurationService = configurationService; + + ConfigurationUpdated += analysisService.OnConfigurationUpdated; + ConfigurationUpdated += symbolsService.OnConfigurationUpdated; + } + + public override async Task Handle(DidChangeConfigurationParams request, CancellationToken cancellationToken) + { + LanguageServerSettingsWrapper incomingSettings = request.Settings.ToObject(); + _logger.LogTrace("Handling DidChangeConfiguration"); + if (incomingSettings is null || incomingSettings.Powershell is null) + { + _logger.LogTrace("Incoming settings were null"); + return await Unit.Task.ConfigureAwait(false); + } + + bool oldScriptAnalysisEnabled = _configurationService.CurrentSettings.ScriptAnalysis.Enable; + string oldScriptAnalysisSettingsPath = _configurationService.CurrentSettings.ScriptAnalysis?.SettingsPath; + + _configurationService.CurrentSettings.Update( + incomingSettings.Powershell, + _workspaceService.InitialWorkingDirectory, + _logger); + + // Run any events subscribed to configuration updates + _logger.LogTrace("Running configuration update event handlers"); + ConfigurationUpdated?.Invoke(this, _configurationService.CurrentSettings); + + // Convert the editor file glob patterns into an array for the Workspace + // Both the files.exclude and search.exclude hash tables look like (glob-text, is-enabled): + // + // "files.exclude" : { + // "Makefile": true, + // "*.html": true, + // "**/*.js": { "when": "$(basename).ts" }, + // "build/*": true + // } + // + // TODO: We only support boolean values. The clause predicates are ignored, but perhaps + // they shouldn't be. At least it doesn't crash! + List excludeFilePatterns = new(); + if (incomingSettings.Files?.Exclude is not null) + { + foreach (KeyValuePair patternEntry in incomingSettings.Files.Exclude) + { + if (patternEntry.Value is bool v && v) + { + excludeFilePatterns.Add(patternEntry.Key); + } + } + } + if (incomingSettings.Search?.Exclude is not null) + { + foreach (KeyValuePair patternEntry in incomingSettings.Search.Exclude) + { + if (patternEntry.Value is bool v && v && !excludeFilePatterns.Contains(patternEntry.Key)) + { + excludeFilePatterns.Add(patternEntry.Key); + } + } + } + _workspaceService.ExcludeFilesGlob = excludeFilePatterns; + + // Convert the editor file search options to Workspace properties + if (incomingSettings.Search?.FollowSymlinks is not null) + { + _workspaceService.FollowSymlinks = incomingSettings.Search.FollowSymlinks; + } + + return await Unit.Task.ConfigureAwait(false); + } + + public event EventHandler ConfigurationUpdated; + } +} diff --git a/src/PowerShellEditorServices/Services/Workspace/Handlers/WorkspaceSymbolsHandler.cs b/src/PowerShellEditorServices/Services/Workspace/Handlers/WorkspaceSymbolsHandler.cs new file mode 100644 index 0000000..040a4fe --- /dev/null +++ b/src/PowerShellEditorServices/Services/Workspace/Handlers/WorkspaceSymbolsHandler.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.Symbols; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Workspace; + +namespace Microsoft.PowerShell.EditorServices.Handlers +{ + internal class PsesWorkspaceSymbolsHandler : WorkspaceSymbolsHandlerBase + { + private static readonly Container s_emptySymbolInformationContainer = new(); + private readonly ILogger _logger; + private readonly SymbolsService _symbolsService; + private readonly WorkspaceService _workspaceService; + + public PsesWorkspaceSymbolsHandler(ILoggerFactory loggerFactory, SymbolsService symbols, WorkspaceService workspace) + { + _logger = loggerFactory.CreateLogger(); + _symbolsService = symbols; + _workspaceService = workspace; + } + + protected override WorkspaceSymbolRegistrationOptions CreateRegistrationOptions(WorkspaceSymbolCapability capability, ClientCapabilities clientCapabilities) => new() { }; + + public override async Task> Handle(WorkspaceSymbolParams request, CancellationToken cancellationToken) + { + _logger.LogDebug($"Handling workspace symbols request for query {request.Query}"); + + await _symbolsService.ScanWorkspacePSFiles(cancellationToken).ConfigureAwait(false); + List symbols = new(); + + foreach (ScriptFile scriptFile in _workspaceService.GetOpenedFiles()) + { + _logger.LogDebug($"Handling workspace symbols request for: {request.Query}"); + // TODO: Need to compute a relative path that is based on common path for all workspace files + string containerName = Path.GetFileNameWithoutExtension(scriptFile.FilePath); + + foreach (SymbolReference symbol in _symbolsService.FindSymbolsInFile(scriptFile)) + { + // This async method is pretty dense with synchronous code + // so it's helpful to add some yields. + await Task.Yield(); + if (cancellationToken.IsCancellationRequested) + { + break; + } + + if (!symbol.IsDeclaration) + { + continue; + } + + if (symbol.Type is SymbolType.Parameter) + { + continue; + } + + if (!IsQueryMatch(request.Query, symbol.Name)) + { + continue; + } + + // Exclude Pester setup/teardown symbols as they're unnamed + if (symbol is PesterSymbolReference pesterSymbol && + !PesterSymbolReference.IsPesterTestCommand(pesterSymbol.Command)) + { + continue; + } + + Location location = new() + { + Uri = DocumentUri.From(symbol.FilePath), + Range = symbol.NameRegion.ToRange() + }; + + symbols.Add(new WorkspaceSymbol + { + ContainerName = containerName, + Kind = SymbolTypeUtils.GetSymbolKind(symbol.Type), + Location = location, + Name = symbol.Name + }); + } + } + + return symbols.Count == 0 + ? s_emptySymbolInformationContainer + : symbols; + } + + private static bool IsQueryMatch(string query, string symbolName) => symbolName.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0; + } +} diff --git a/src/PowerShellEditorServices/Services/Workspace/LanguageServerSettings.cs b/src/PowerShellEditorServices/Services/Workspace/LanguageServerSettings.cs new file mode 100644 index 0000000..e417d7b --- /dev/null +++ b/src/PowerShellEditorServices/Services/Workspace/LanguageServerSettings.cs @@ -0,0 +1,474 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Security; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Logging; +using Newtonsoft.Json; + +namespace Microsoft.PowerShell.EditorServices.Services.Configuration +{ + internal class LanguageServerSettings + { + private readonly object updateLock = new(); + public bool EnableProfileLoading { get; set; } + public ScriptAnalysisSettings ScriptAnalysis { get; set; } + public CodeFormattingSettings CodeFormatting { get; set; } + public CodeFoldingSettings CodeFolding { get; set; } + public PesterSettings Pester { get; set; } + public string Cwd { get; set; } + public bool EnableReferencesCodeLens { get; set; } = true; + public bool AnalyzeOpenDocumentsOnly { get; set; } + + public LanguageServerSettings() + { + ScriptAnalysis = new ScriptAnalysisSettings(); + CodeFormatting = new CodeFormattingSettings(); + CodeFolding = new CodeFoldingSettings(); + Pester = new PesterSettings(); + } + + public void Update( + LanguageServerSettings settings, + string workspaceRootPath, + ILogger logger) + { + if (settings is not null) + { + lock (updateLock) + { + EnableProfileLoading = settings.EnableProfileLoading; + ScriptAnalysis.Update(settings.ScriptAnalysis, workspaceRootPath, logger); + CodeFormatting = new CodeFormattingSettings(settings.CodeFormatting); + CodeFolding.Update(settings.CodeFolding, logger); + Pester.Update(settings.Pester, logger); + Cwd = settings.Cwd; + EnableReferencesCodeLens = settings.EnableReferencesCodeLens; + AnalyzeOpenDocumentsOnly = settings.AnalyzeOpenDocumentsOnly; + } + } + } + } + + internal class ScriptAnalysisSettings + { + private readonly object updateLock = new(); + public bool Enable { get; set; } + public string SettingsPath { get; set; } + public ScriptAnalysisSettings() => Enable = true; + + public void Update( + ScriptAnalysisSettings settings, + string workspaceRootPath, + ILogger logger) + { + if (settings is not null) + { + lock (updateLock) + { + Enable = settings.Enable; + string settingsPath = settings.SettingsPath; + + try + { + if (string.IsNullOrWhiteSpace(settingsPath)) + { + settingsPath = null; + } + else if (!Path.IsPathRooted(settingsPath)) + { + if (string.IsNullOrEmpty(workspaceRootPath)) + { + // The workspace root path could be an empty string + // when the user has opened a PowerShell script file + // without opening an entire folder (workspace) first. + // In this case we should just log an error and let + // the specified settings path go through even though + // it will fail to load. + logger.LogError("Could not resolve Script Analyzer settings path due to null or empty workspaceRootPath."); + } + else + { + settingsPath = Path.GetFullPath(Path.Combine(workspaceRootPath, settingsPath)); + } + } + + SettingsPath = settingsPath; + logger.LogTrace($"Using Script Analyzer settings path - '{settingsPath ?? ""}'."); + } + catch (Exception ex) when (ex is NotSupportedException or PathTooLongException or SecurityException) + { + // Invalid chars in path like ${env:HOME} can cause Path.GetFullPath() to throw, catch such errors here + logger.LogException($"Invalid Script Analyzer settings path - '{settingsPath}'.", ex); + SettingsPath = null; + } + } + } + } + } + + /// + /// Code formatting presets. + /// See https://en.wikipedia.org/wiki/Indent_style for details on indent and brace styles. + /// + internal enum CodeFormattingPreset + { + /// + /// Use the formatting settings as-is. + /// + Custom, + + /// + /// Configure the formatting settings to resemble the Allman indent/brace style. + /// + Allman, + + /// + /// Configure the formatting settings to resemble the one true brace style variant of K&R indent/brace style. + /// + OTBS, + + /// + /// Configure the formatting settings to resemble the Stroustrup brace style variant of K&R indent/brace style. + /// + Stroustrup + } + + /// + /// Multi-line pipeline style settings. + /// + internal enum PipelineIndentationStyle + { + /// + /// After the indentation level only once after the first pipeline and keep this level for the following pipelines. + /// + IncreaseIndentationForFirstPipeline, + + /// + /// After every pipeline, keep increasing the indentation. + /// + IncreaseIndentationAfterEveryPipeline, + + /// + /// Do not increase indentation level at all after pipeline. + /// + NoIndentation, + + /// + /// Do not change pipeline indentation level at all. + /// + None, + } + + internal class CodeFormattingSettings + { + /// + /// Default constructor. + /// > + public CodeFormattingSettings() { } + + /// + /// Copy constructor. + /// + /// An instance of type CodeFormattingSettings. + public CodeFormattingSettings(CodeFormattingSettings codeFormattingSettings) + { + if (codeFormattingSettings is null) + { + throw new ArgumentNullException(nameof(codeFormattingSettings)); + } + + foreach (PropertyInfo prop in GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + prop.SetValue(this, prop.GetValue(codeFormattingSettings)); + } + } + + public bool AddWhitespaceAroundPipe { get; set; } + public bool AutoCorrectAliases { get; set; } + public bool AvoidSemicolonsAsLineTerminators { get; set; } + public bool UseConstantStrings { get; set; } + public CodeFormattingPreset Preset { get; set; } + public bool OpenBraceOnSameLine { get; set; } + public bool NewLineAfterOpenBrace { get; set; } + public bool NewLineAfterCloseBrace { get; set; } + public PipelineIndentationStyle PipelineIndentationStyle { get; set; } + public bool TrimWhitespaceAroundPipe { get; set; } + public bool WhitespaceBeforeOpenBrace { get; set; } + public bool WhitespaceBeforeOpenParen { get; set; } + public bool WhitespaceAroundOperator { get; set; } + public bool WhitespaceAfterSeparator { get; set; } + public bool WhitespaceBetweenParameters { get; set; } + public bool WhitespaceInsideBrace { get; set; } + public bool IgnoreOneLineBlock { get; set; } + public bool AlignPropertyValuePairs { get; set; } + public bool UseCorrectCasing { get; set; } + + /// + /// Get the settings hashtable that will be consumed by PSScriptAnalyzer. + /// + /// The tab size in the number spaces. + /// If true, insert spaces otherwise insert tabs for indentation. + /// The logger instance. + public Hashtable GetPSSASettingsHashtable( + int tabSize, + bool insertSpaces, + ILogger logger) + { + Hashtable settings = GetCustomPSSASettingsHashtable(tabSize, insertSpaces); + Hashtable ruleSettings = settings["Rules"] as Hashtable; + Hashtable closeBraceSettings = ruleSettings["PSPlaceCloseBrace"] as Hashtable; + Hashtable openBraceSettings = ruleSettings["PSPlaceOpenBrace"] as Hashtable; + + switch (Preset) + { + case CodeFormattingPreset.Allman: + openBraceSettings["OnSameLine"] = false; + openBraceSettings["NewLineAfter"] = true; + closeBraceSettings["NewLineAfter"] = true; + break; + + case CodeFormattingPreset.OTBS: + openBraceSettings["OnSameLine"] = true; + openBraceSettings["NewLineAfter"] = true; + closeBraceSettings["NewLineAfter"] = false; + break; + + case CodeFormattingPreset.Stroustrup: + openBraceSettings["OnSameLine"] = true; + openBraceSettings["NewLineAfter"] = true; + closeBraceSettings["NewLineAfter"] = true; + break; + } + + logger.LogDebug("Created formatting hashtable: {Settings}", JsonConvert.SerializeObject(settings)); + return settings; + } + + private Hashtable GetCustomPSSASettingsHashtable(int tabSize, bool insertSpaces) + { + Hashtable ruleConfigurations = new() + { + { + "PSPlaceOpenBrace", + new Hashtable { + { "Enable", true }, + { "OnSameLine", OpenBraceOnSameLine }, + { "NewLineAfter", NewLineAfterOpenBrace }, + { "IgnoreOneLineBlock", IgnoreOneLineBlock } + } + }, + { + "PSPlaceCloseBrace", + new Hashtable { + { "Enable", true }, + { "NewLineAfter", NewLineAfterCloseBrace }, + { "IgnoreOneLineBlock", IgnoreOneLineBlock } + } + }, + { + "PSUseConsistentIndentation", + new Hashtable { + { "Enable", true }, + { "IndentationSize", tabSize }, + { "PipelineIndentation", PipelineIndentationStyle }, + { "Kind", insertSpaces ? "space" : "tab" } + } + }, + { + "PSUseConsistentWhitespace", + new Hashtable { + { "Enable", true }, + { "CheckOpenBrace", WhitespaceBeforeOpenBrace }, + { "CheckOpenParen", WhitespaceBeforeOpenParen }, + { "CheckOperator", WhitespaceAroundOperator }, + { "CheckSeparator", WhitespaceAfterSeparator }, + { "CheckInnerBrace", WhitespaceInsideBrace }, + { "CheckParameter", WhitespaceBetweenParameters }, + { "CheckPipe", AddWhitespaceAroundPipe }, + { "CheckPipeForRedundantWhitespace", TrimWhitespaceAroundPipe }, + } + }, + { + "PSAlignAssignmentStatement", + new Hashtable { + { "Enable", true }, + { "CheckHashtable", AlignPropertyValuePairs } + } + }, + { + "PSUseCorrectCasing", + new Hashtable { + { "Enable", UseCorrectCasing } + } + }, + { + "PSAvoidUsingDoubleQuotesForConstantString", + new Hashtable { + { "Enable", UseConstantStrings } + } + }, + { + "PSAvoidSemicolonsAsLineTerminators", + new Hashtable { + { "Enable", AvoidSemicolonsAsLineTerminators } + } + }, + }; + + if (AutoCorrectAliases) + { + // Empty hashtable required to activate the rule, + // since PSAvoidUsingCmdletAliases inherits from IScriptRule and not ConfigurableRule + ruleConfigurations.Add("PSAvoidUsingCmdletAliases", new Hashtable()); + } + + return new Hashtable() + { + { "IncludeRules", new string[] { + "PSPlaceCloseBrace", + "PSPlaceOpenBrace", + "PSUseConsistentWhitespace", + "PSUseConsistentIndentation", + "PSAlignAssignmentStatement", + "PSAvoidUsingDoubleQuotesForConstantString", + }}, + { "Rules", ruleConfigurations + } + }; + } + } + + /// + /// Code folding settings + /// + internal class CodeFoldingSettings + { + /// + /// Whether the folding is enabled. Default is true as per VSCode + /// + public bool Enable { get; set; } = true; + + /// + /// Whether to show or hide the last line of a folding region. Default is true as per VSCode + /// + public bool ShowLastLine { get; set; } = true; + + /// + /// Update these settings from another settings object + /// + public void Update( + CodeFoldingSettings settings, + ILogger logger) + { + if (settings is not null) + { + if (Enable != settings.Enable) + { + Enable = settings.Enable; + logger.LogTrace(string.Format("Using Code Folding Enabled - {0}", Enable)); + } + if (ShowLastLine != settings.ShowLastLine) + { + ShowLastLine = settings.ShowLastLine; + logger.LogTrace(string.Format("Using Code Folding ShowLastLine - {0}", ShowLastLine)); + } + } + } + } + + /// + /// Pester settings + /// + public class PesterSettings + { + /// + /// If specified, the lenses "run tests" and "debug tests" will appear above all Pester tests + /// + public bool CodeLens { get; set; } = true; + + /// + /// Whether integration features specific to Pester v5 are enabled + /// + public bool UseLegacyCodeLens { get; set; } + + /// + /// Update these settings from another settings object + /// + public void Update( + PesterSettings settings, + ILogger logger) + { + if (settings is null) + { + return; + } + + if (CodeLens != settings.CodeLens) + { + CodeLens = settings.CodeLens; + logger.LogTrace(string.Format("Using Pester Code Lens - {0}", CodeLens)); + } + + if (UseLegacyCodeLens != settings.UseLegacyCodeLens) + { + UseLegacyCodeLens = settings.UseLegacyCodeLens; + logger.LogTrace(string.Format("Using Pester Legacy Code Lens - {0}", UseLegacyCodeLens)); + } + } + } + + /// + /// Additional settings from the Language Client that affect Language Server operations but + /// do not exist under the 'powershell' section + /// + internal class EditorFileSettings + { + /// + /// Exclude files globs consists of hashtable with the key as the glob and a boolean value + /// OR object with a predicate clause to indicate if the glob is in effect. + /// + public Dictionary Exclude { get; set; } + } + + /// + /// Additional settings from the Language Client that affect Language Server operations but + /// do not exist under the 'powershell' section + /// + internal class EditorSearchSettings + { + /// + /// Exclude files globs consists of hashtable with the key as the glob and a boolean value + /// OR object with a predicate clause to indicate if the glob is in effect. + /// + public Dictionary Exclude { get; set; } + + /// + /// Whether to follow symlinks when searching + /// + public bool FollowSymlinks { get; set; } = true; + } + + internal class LanguageServerSettingsWrapper + { + // NOTE: This property is capitalized as 'Powershell' because the + // mode name sent from the client is written as 'powershell' and + // JSON.net is using camelCasing. + public LanguageServerSettings Powershell { get; set; } + + // NOTE: This property is capitalized as 'Files' because the + // mode name sent from the client is written as 'files' and + // JSON.net is using camelCasing. + public EditorFileSettings Files { get; set; } + + // NOTE: This property is capitalized as 'Search' because the + // mode name sent from the client is written as 'search' and + // JSON.net is using camelCasing. + public EditorSearchSettings Search { get; set; } + } +} diff --git a/src/PowerShellEditorServices/Services/Workspace/RemoteFileManagerService.cs b/src/PowerShellEditorServices/Services/Workspace/RemoteFileManagerService.cs new file mode 100644 index 0000000..9613380 --- /dev/null +++ b/src/PowerShellEditorServices/Services/Workspace/RemoteFileManagerService.cs @@ -0,0 +1,809 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Extensions; +using Microsoft.PowerShell.EditorServices.Logging; +using Microsoft.PowerShell.EditorServices.Services.Extension; +using Microsoft.PowerShell.EditorServices.Services.PowerShell; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; +using Microsoft.PowerShell.EditorServices.Utility; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Management.Automation; +using System.Management.Automation.Runspaces; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using SMA = System.Management.Automation; + +namespace Microsoft.PowerShell.EditorServices.Services +{ + /// + /// Manages files that are accessed from a remote PowerShell session. + /// Also manages the registration and handling of the 'psedit' function. + /// + internal class RemoteFileManagerService + { + #region Fields + + private readonly ILogger logger; + private readonly string remoteFilesPath; + private readonly string processTempPath; + private readonly IRunspaceContext _runspaceContext; + private readonly IInternalPowerShellExecutionService _executionService; + private readonly IEditorOperations editorOperations; + + private readonly Dictionary filesPerComputer = + new(); + + private const string RemoteSessionOpenFile = "PSESRemoteSessionOpenFile"; + + private const string PSEditModule = @"<# + .SYNOPSIS + Opens the specified files in your editor window + .DESCRIPTION + Opens the specified files in your editor window + .EXAMPLE + PS > Open-EditorFile './foo.ps1' + Opens foo.ps1 in your editor + .EXAMPLE + PS > gci ./myDir | Open-EditorFile + Opens everything in 'myDir' in your editor + .INPUTS + Path + an array of files you want to open in your editor + #> + function Open-EditorFile { + param ( + [Parameter(Mandatory=$true, ValueFromPipeline=$true)] + [String[]] + $Path + ) + + begin { + $Paths = @() + } + + process { + $Paths += $Path + } + + end { + if ($Paths.Count -gt 1) { + $preview = $false + } else { + $preview = $true + } + + foreach ($fileName in $Paths) + { + Microsoft.PowerShell.Management\Get-ChildItem $fileName | Where-Object { ! $_.PSIsContainer } | Foreach-Object { + $filePathName = $_.FullName + + # Get file contents + $params = @{ Path=$filePathName; Raw=$true } + if ($PSVersionTable.PSEdition -eq 'Core') + { + $params['AsByteStream']=$true + } + else + { + $params['Encoding']='Byte' + } + + $contentBytes = Microsoft.PowerShell.Management\Get-Content @params + + # Notify client for file open. + Microsoft.PowerShell.Utility\New-Event -SourceIdentifier PSESRemoteSessionOpenFile -EventArguments @($filePathName, $contentBytes, $preview) > $null + } + } + } + } + + <# + .SYNOPSIS + Creates new files and opens them in your editor window + .DESCRIPTION + Creates new files and opens them in your editor window + .EXAMPLE + PS > New-EditorFile './foo.ps1' + Creates and opens a new foo.ps1 in your editor + .EXAMPLE + PS > Get-Process | New-EditorFile proc.txt + Creates and opens a new foo.ps1 in your editor with the contents of the call to Get-Process + .EXAMPLE + PS > Get-Process | New-EditorFile proc.txt -Force + Creates and opens a new foo.ps1 in your editor with the contents of the call to Get-Process. Overwrites the file if it already exists + .INPUTS + Path + an array of files you want to open in your editor + Value + The content you want in the new files + Force + Overwrites a file if it exists + #> + function New-EditorFile { + [CmdletBinding()] + param ( + [Parameter()] + [String[]] + [ValidateNotNullOrEmpty()] + $Path, + + [Parameter(ValueFromPipeline=$true)] + $Value, + + [Parameter()] + [switch] + $Force + ) + + begin { + $valueList = @() + } + + process { + $valueList += $Value + } + + end { + if ($Path) { + foreach ($fileName in $Path) + { + if (-not (Microsoft.PowerShell.Management\Test-Path $fileName) -or $Force) { + $valueList > $fileName + + # Get file contents + $params = @{ Path=$fileName; Raw=$true } + if ($PSVersionTable.PSEdition -eq 'Core') + { + $params['AsByteStream']=$true + } + else + { + $params['Encoding']='Byte' + } + + $contentBytes = Microsoft.PowerShell.Management\Get-Content @params + + if ($Path.Count -gt 1) { + $preview = $false + } else { + $preview = $true + } + + # Notify client for file open. + Microsoft.PowerShell.Utility\New-Event -SourceIdentifier PSESRemoteSessionOpenFile -EventArguments @($fileName, $contentBytes, $preview) > $null + } else { + $PSCmdlet.WriteError( ( + Microsoft.PowerShell.Utility\New-Object -TypeName System.Management.Automation.ErrorRecord -ArgumentList @( + [System.Exception]'File already exists.' + $Null + [System.Management.Automation.ErrorCategory]::ResourceExists + $fileName ) ) ) + } + } + } else { + $bytes = [System.Text.Encoding]::UTF8.GetBytes(($valueList | Microsoft.PowerShell.Utility\Out-String)) + Microsoft.PowerShell.Utility\New-Event -SourceIdentifier PSESRemoteSessionOpenFile -EventArguments @($null, $bytes) > $null + } + } + } + + Microsoft.PowerShell.Utility\Set-Alias psedit Open-EditorFile -Scope Global + Microsoft.PowerShell.Core\Export-ModuleMember -Function Open-EditorFile, New-EditorFile + "; + + // This script is templated so that the '-Forward' parameter can be added + // to the script when in non-local sessions + private const string CreatePSEditFunctionScript = @" + param ( + [string] $PSEditModule + ) + + Microsoft.PowerShell.Utility\Register-EngineEvent -SourceIdentifier PSESRemoteSessionOpenFile -Forward -SupportEvent + Microsoft.PowerShell.Core\New-Module -ScriptBlock ([Scriptblock]::Create($PSEditModule)) -Name PSEdit | + Microsoft.PowerShell.Core\Import-Module -Global + "; + + private const string RemovePSEditFunctionScript = @" + Microsoft.PowerShell.Core\Get-Module PSEdit | Microsoft.PowerShell.Core\Remove-Module + + Microsoft.PowerShell.Utility\Unregister-Event -SourceIdentifier PSESRemoteSessionOpenFile -Force -ErrorAction Ignore + "; + + private const string SetRemoteContentsScript = @" + param( + [string] $RemoteFilePath, + [byte[]] $Content + ) + + # Set file contents + $params = @{ Path=$RemoteFilePath; Value=$Content; Force=$true } + if ($PSVersionTable.PSEdition -eq 'Core') + { + $params['AsByteStream']=$true + } + else + { + $params['Encoding']='Byte' + } + + Microsoft.PowerShell.Management\Set-Content @params 2>&1 + "; + + #endregion + + #region Constructors + + /// + /// Creates a new instance of the RemoteFileManagerService class. + /// + /// An ILoggerFactory implementation used for writing log messages. + /// The runspace we're using. + /// + /// The PowerShellContext to use for file loading operations. + /// + /// + /// The IEditorOperations instance to use for opening/closing files in the editor. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD110:Observe result of async calls", Justification = "Intentionally fire and forget.")] + public RemoteFileManagerService( + ILoggerFactory factory, + IRunspaceContext runspaceContext, + IInternalPowerShellExecutionService executionService, + EditorOperationsService editorOperations) + { + logger = factory.CreateLogger(); + _runspaceContext = runspaceContext; + _executionService = executionService; + _executionService.RunspaceChanged += HandleRunspaceChanged; + + this.editorOperations = editorOperations; + + processTempPath = + Path.Combine( + Path.GetTempPath(), + "PSES-" + Process.GetCurrentProcess().Id); + + remoteFilesPath = Path.Combine(processTempPath, "RemoteFiles"); + + // Delete existing temporary file cache path if it already exists + TryDeleteTemporaryPath(); + + // Register the psedit function in the current runspace + RegisterPSEditFunctionAsync().HandleErrorsAsync(logger); + } + + #endregion + + #region Public Methods + + /// + /// Opens a remote file, fetching its contents if necessary. + /// + /// + /// The remote file path to be opened. + /// + /// + /// The runspace from which where the remote file will be fetched. + /// + /// + /// The local file path where the remote file's contents have been stored. + /// + public async Task FetchRemoteFileAsync( + string remoteFilePath, + IRunspaceInfo runspaceInfo) + { + string localFilePath = null; + + if (!string.IsNullOrEmpty(remoteFilePath)) + { + try + { + RemotePathMappings pathMappings = GetPathMappings(runspaceInfo); + localFilePath = GetMappedPath(remoteFilePath, runspaceInfo); + + if (!pathMappings.IsRemotePathOpened(remoteFilePath)) + { + // Does the local file already exist? + if (!File.Exists(localFilePath)) + { + // Load the file contents from the remote machine and create the buffer + PSCommand command = new PSCommand() + .AddCommand(@"Microsoft.PowerShell.Management\Get-Content") + .AddParameter("Path", remoteFilePath) + .AddParameter("Raw"); + + if (string.Equals(runspaceInfo.PowerShellVersionDetails.Edition, "Core")) + { + command.AddParameter("AsByteStream"); + } + else + { + command.AddParameter("Encoding", "Byte"); + } + + byte[] fileContent = + (await _executionService.ExecutePSCommandAsync(command, CancellationToken.None).ConfigureAwait(false)) + .FirstOrDefault(); + + if (fileContent != null) + { + StoreRemoteFile(localFilePath, fileContent, pathMappings); + } + else + { + logger.LogWarning( + $"Could not load contents of remote file '{remoteFilePath}'"); + } + } + } + } + catch (IOException e) + { + logger.LogError( + $"Caught {e.GetType().Name} while attempting to get remote file at path '{remoteFilePath}'\r\n\r\n{e}"); + } + } + + return localFilePath; + } + + /// + /// Saves the contents of the file under the temporary local + /// file cache to its corresponding remote file. + /// + /// + /// The local file whose contents will be saved. It is assumed + /// that the editor has saved the contents of the local cache + /// file to disk before this method is called. + /// + /// A Task to be awaited for completion. + public async Task SaveRemoteFileAsync(string localFilePath) + { + string remoteFilePath = + GetMappedPath( + localFilePath, + _runspaceContext.CurrentRunspace); + + logger.LogTrace( + $"Saving remote file {remoteFilePath} (local path: {localFilePath})"); + + byte[] localFileContents; + try + { + localFileContents = File.ReadAllBytes(localFilePath); + } + catch (IOException e) + { + logger.LogException( + "Failed to read contents of local copy of remote file", + e); + + return; + } + + PSCommand saveCommand = new(); + saveCommand + .AddScript(SetRemoteContentsScript) + .AddParameter("RemoteFilePath", remoteFilePath) + .AddParameter("Content", localFileContents); + + try + { + await _executionService.ExecutePSCommandAsync( + saveCommand, + CancellationToken.None).ConfigureAwait(false); + } + catch (Exception e) + { + logger.LogError(e, "Remote file save failed"); + } + } + + /// + /// Creates a temporary file with the given name and contents + /// corresponding to the specified runspace. + /// + /// + /// The name of the file to be created under the session path. + /// + /// + /// The contents of the file to be created. + /// + /// + /// The runspace for which the temporary file relates. + /// + /// The full temporary path of the file if successful, null otherwise. + public string CreateTemporaryFile(string fileName, string fileContents, IRunspaceInfo runspaceInfo) + { + string temporaryFilePath = Path.Combine(processTempPath, fileName); + + try + { + File.WriteAllText(temporaryFilePath, fileContents); + + RemotePathMappings pathMappings = GetPathMappings(runspaceInfo); + pathMappings.AddOpenedLocalPath(temporaryFilePath); + } + catch (IOException e) + { + logger.LogError( + $"Caught {e.GetType().Name} while attempting to write temporary file at path '{temporaryFilePath}'\r\n\r\n{e}"); + + temporaryFilePath = null; + } + + return temporaryFilePath; + } + + /// + /// For a remote or local cache path, get the corresponding local or + /// remote file path. + /// + /// + /// The remote or local file path. + /// + /// + /// The runspace from which the remote file was fetched. + /// + /// The mapped file path. + public string GetMappedPath( + string filePath, + IRunspaceInfo runspaceDetails) + { + RemotePathMappings remotePathMappings = GetPathMappings(runspaceDetails); + return remotePathMappings.GetMappedPath(filePath); + } + + /// + /// Returns true if the given file path is under the remote files + /// path in the temporary folder. + /// + /// The local file path to check. + /// + /// True if the file path is under the temporary remote files path. + /// + public bool IsUnderRemoteTempPath(string filePath) + { + return filePath.StartsWith( + remoteFilesPath, + System.StringComparison.CurrentCultureIgnoreCase); + } + + #endregion + + #region Private Methods + + private string StoreRemoteFile( + string remoteFilePath, + byte[] fileContent, + IRunspaceInfo runspaceInfo) + { + RemotePathMappings pathMappings = GetPathMappings(runspaceInfo); + string localFilePath = pathMappings.GetMappedPath(remoteFilePath); + + RemoteFileManagerService.StoreRemoteFile( + localFilePath, + fileContent, + pathMappings); + + return localFilePath; + } + + private static void StoreRemoteFile( + string localFilePath, + byte[] fileContent, + RemotePathMappings pathMappings) + { + File.WriteAllBytes(localFilePath, fileContent); + pathMappings.AddOpenedLocalPath(localFilePath); + } + + private RemotePathMappings GetPathMappings(IRunspaceInfo runspaceInfo) + { + string computerName = runspaceInfo.SessionDetails.ComputerName; + + if (!filesPerComputer.TryGetValue(computerName, out RemotePathMappings remotePathMappings)) + { + remotePathMappings = new RemotePathMappings(runspaceInfo, this); + filesPerComputer.Add(computerName, remotePathMappings); + } + + return remotePathMappings; + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Supporting synchronous API.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD110:Observe result of async calls", Justification = "Intentionally fire and forget.")] + private void HandleRunspaceChanged(object sender, RunspaceChangedEventArgs e) + { + if (e.ChangeAction == RunspaceChangeAction.Enter) + { + RegisterPSEditFunction(e.NewRunspace.Runspace); + return; + } + + // Close any remote files that were opened + if (ShouldTearDownRemoteFiles(e) + && filesPerComputer.TryGetValue(e.PreviousRunspace.SessionDetails.ComputerName, out RemotePathMappings remotePathMappings)) + { + List fileCloseTasks = new(); + foreach (string remotePath in remotePathMappings.OpenedPaths) + { + fileCloseTasks.Add(editorOperations?.CloseFileAsync(remotePath)); + } + + try + { + Task.WaitAll(fileCloseTasks.ToArray()); + } + catch (Exception ex) + { + logger.LogError(ex, "Unable to close all files in closed runspace"); + } + } + + if (e.PreviousRunspace != null) + { + RemovePSEditFunction(e.PreviousRunspace); + } + } + + private static bool ShouldTearDownRemoteFiles(RunspaceChangedEventArgs runspaceChangedEvent) + { + if (!runspaceChangedEvent.PreviousRunspace.IsOnRemoteMachine) + { + return false; + } + + if (runspaceChangedEvent.ChangeAction == RunspaceChangeAction.Shutdown) + { + return true; + } + + // Check to see if the runspace we're changing to is on a different machine to the one we left + return !string.Equals( + runspaceChangedEvent.NewRunspace.SessionDetails.ComputerName, + runspaceChangedEvent.PreviousRunspace.SessionDetails.ComputerName, + StringComparison.CurrentCultureIgnoreCase); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "It has to be async.")] + private async void HandlePSEventReceivedAsync(object sender, PSEventArgs args) + { + if (!string.Equals(RemoteSessionOpenFile, args.SourceIdentifier, StringComparison.CurrentCultureIgnoreCase)) + { + return; + } + + try + { + if (args.SourceArgs.Length >= 1) + { + string localFilePath = string.Empty; + string remoteFilePath = args.SourceArgs[0] as string; + + // Is this a local process runspace? Treat as a local file + if (!_runspaceContext.CurrentRunspace.IsOnRemoteMachine) + { + localFilePath = remoteFilePath; + } + else + { + byte[] fileContent = null; + + if (args.SourceArgs.Length >= 2) + { + // Try to cast as a PSObject to get the BaseObject, if not, then try to case as a byte[] + fileContent = args.SourceArgs[1] is PSObject sourceObj ? sourceObj.BaseObject as byte[] : args.SourceArgs[1] as byte[]; + } + + // If fileContent is still null after trying to + // unpack the contents, just return an empty byte + // array. + fileContent ??= Array.Empty(); + + if (remoteFilePath != null) + { + localFilePath = + StoreRemoteFile( + remoteFilePath, + fileContent, + _runspaceContext.CurrentRunspace); + } + else + { + await (editorOperations?.NewFileAsync()).ConfigureAwait(false); + EditorContext context = await (editorOperations?.GetEditorContextAsync()).ConfigureAwait(false); + context?.CurrentFile.InsertText(Encoding.UTF8.GetString(fileContent, 0, fileContent.Length)); + } + } + + bool preview = true; + if (args.SourceArgs.Length >= 3) + { + bool? previewCheck = args.SourceArgs[2] as bool?; + preview = previewCheck ?? true; + } + + // Open the file in the editor + await (editorOperations?.OpenFileAsync(localFilePath, preview)).ConfigureAwait(false); + } + } + catch (NullReferenceException e) + { + logger.LogException("Could not store null remote file content", e); + } + catch (Exception e) + { + logger.LogException("Unable to handle remote file update", e); + } + } + + // NOTE: This is a special task run on startup! + private Task RegisterPSEditFunctionAsync() + => _executionService.ExecuteDelegateAsync( + "Register PSEdit function", + executionOptions: null, + (pwsh, _) => RegisterPSEditFunction(pwsh.Runspace), + CancellationToken.None); + + private void RegisterPSEditFunction(Runspace runspace) + { + if (!runspace.RunspaceIsRemote) + { + return; + } + + runspace.Events.ReceivedEvents.PSEventReceived += HandlePSEventReceivedAsync; + + PSCommand createCommand = new PSCommand() + .AddScript(CreatePSEditFunctionScript) + .AddParameter("PSEditModule", PSEditModule); + + SMA.PowerShell pwsh = SMA.PowerShell.Create(); + pwsh.Runspace = runspace; + try + { + pwsh.InvokeCommand(createCommand, new PSInvocationSettings { AddToHistory = false, ErrorActionPreference = ActionPreference.Stop }); + } + catch (Exception e) + { + logger.LogException("Could not create PSEdit function.", e); + } + finally + { + pwsh.Dispose(); + } + } + + private void RemovePSEditFunction(IRunspaceInfo runspaceInfo) + { + if (runspaceInfo.RunspaceOrigin != RunspaceOrigin.PSSession) + { + return; + } + try + { + if (runspaceInfo.Runspace.Events != null) + { + runspaceInfo.Runspace.Events.ReceivedEvents.PSEventReceived -= HandlePSEventReceivedAsync; + } + + if (runspaceInfo.Runspace.RunspaceStateInfo.State == RunspaceState.Opened) + { + using SMA.PowerShell powerShell = SMA.PowerShell.Create(); + powerShell.Runspace = runspaceInfo.Runspace; + powerShell.Commands.AddScript(RemovePSEditFunctionScript); + powerShell.Invoke(); + } + } + catch (Exception e) when (e is RemoteException or PSInvalidOperationException) + { + logger.LogException("Could not remove psedit function.", e); + } + } + + private void TryDeleteTemporaryPath() + { + try + { + if (Directory.Exists(processTempPath)) + { + Directory.Delete(processTempPath, true); + } + + Directory.CreateDirectory(processTempPath); + } + catch (IOException e) + { + logger.LogException( + $"Could not delete temporary folder for current process: {processTempPath}", e); + } + } + + #endregion + + #region Nested Classes + + private class RemotePathMappings + { + private readonly IRunspaceInfo runspaceInfo; + private readonly RemoteFileManagerService remoteFileManager; + private readonly HashSet openedPaths = new(); + private readonly Dictionary pathMappings = new(); + + public IEnumerable OpenedPaths => openedPaths; + + public RemotePathMappings( + IRunspaceInfo runspaceInfo, + RemoteFileManagerService remoteFileManager) + { + this.runspaceInfo = runspaceInfo; + this.remoteFileManager = remoteFileManager; + } + + public void AddPathMapping(string remotePath, string localPath) + { + // Add mappings in both directions + pathMappings[localPath.ToLower()] = remotePath; + pathMappings[remotePath.ToLower()] = localPath; + } + + public void AddOpenedLocalPath(string openedLocalPath) => openedPaths.Add(openedLocalPath); + + public bool IsRemotePathOpened(string remotePath) => openedPaths.Contains(remotePath); + + public string GetMappedPath(string filePath) + { + if (!pathMappings.TryGetValue(filePath.ToLower(), out string mappedPath)) + { + // If the path isn't mapped yet, generate it + if (!filePath.StartsWith(remoteFileManager.remoteFilesPath)) + { + mappedPath = + MapRemotePathToLocal( + filePath, + runspaceInfo.SessionDetails.ComputerName); + + AddPathMapping(filePath, mappedPath); + } + } + + return mappedPath; + } + + private string MapRemotePathToLocal(string remotePath, string connectionString) + { + // The path generated by this code will look something like + // %TEMP%\PSES-[PID]\RemoteFiles\1205823508\computer-name\MyFile.ps1 + // The "path hash" is just the hashed representation of the remote + // file's full path (sans directory) to try and ensure some amount of + // uniqueness across different files on the remote machine. We put + // the "connection string" after the path slug so that it can be used + // as the differentiator string in editors like VS Code when more than + // one tab has the same filename. + + DirectoryInfo sessionDir = Directory.CreateDirectory(remoteFileManager.remoteFilesPath); + DirectoryInfo pathHashDir = + sessionDir.CreateSubdirectory( + Path.GetDirectoryName(remotePath).GetHashCode().ToString()); + + DirectoryInfo remoteFileDir = pathHashDir.CreateSubdirectory(connectionString); + + return + Path.Combine( + remoteFileDir.FullName, + Path.GetFileName(remotePath)); + } + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Services/Workspace/WorkspaceFileSystemWrapper.cs b/src/PowerShellEditorServices/Services/Workspace/WorkspaceFileSystemWrapper.cs new file mode 100644 index 0000000..a8861ce --- /dev/null +++ b/src/PowerShellEditorServices/Services/Workspace/WorkspaceFileSystemWrapper.cs @@ -0,0 +1,363 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Security; +using Microsoft.Extensions.FileSystemGlobbing.Abstractions; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Logging; + +namespace Microsoft.PowerShell.EditorServices.Services.Workspace +{ + /// + /// A FileSystem wrapper class which only returns files and directories that the consumer is interested in, + /// with a maximum recursion depth and silently ignores most file system errors. Typically this is used by the + /// Microsoft.Extensions.FileSystemGlobbing library. + /// + internal class WorkspaceFileSystemWrapperFactory + { + private readonly string[] _allowedExtensions; + private readonly bool _ignoreReparsePoints; + + /// + /// Gets the maximum depth of the directories that will be searched + /// + internal int MaxRecursionDepth { get; } + + /// + /// Gets the logging facility + /// + internal ILogger Logger { get; } + + /// + /// Gets the directory where the factory is rooted. Only files and directories at this level, or deeper, will be visible + /// by the wrapper + /// + public DirectoryInfoBase RootDirectory { get; } + + /// + /// Creates a new FileWrapper Factory + /// + /// The path to the root directory for the factory. + /// The maximum directory depth. + /// An array of file extensions that will be visible from the factory. For example [".ps1", ".psm1"] + /// Whether objects which are Reparse Points should be ignored. https://docs.microsoft.com/en-us/windows/desktop/fileio/reparse-points + /// An ILogger implementation used for writing log messages. + public WorkspaceFileSystemWrapperFactory(string rootPath, int recursionDepthLimit, string[] allowedExtensions, bool ignoreReparsePoints, ILogger logger) + { + MaxRecursionDepth = recursionDepthLimit; + RootDirectory = new WorkspaceFileSystemDirectoryWrapper(this, new DirectoryInfo(rootPath), 0); + _allowedExtensions = allowedExtensions; + _ignoreReparsePoints = ignoreReparsePoints; + Logger = logger; + } + + /// + /// Creates a wrapped object from . + /// + internal DirectoryInfoBase CreateDirectoryInfoWrapper(DirectoryInfo dirInfo, int depth) => + new WorkspaceFileSystemDirectoryWrapper(this, dirInfo, depth >= 0 ? depth : 0); + + /// + /// Creates a wrapped object from . + /// + internal FileInfoBase CreateFileInfoWrapper(FileInfo fileInfo, int depth) => + new WorkspaceFileSystemFileInfoWrapper(this, fileInfo, depth >= 0 ? depth : 0); + + /// + /// Enumerates all objects in the specified directory and ignores most errors + /// + internal IEnumerable SafeEnumerateFileSystemInfos(DirectoryInfo dirInfo) + { + // Find the subdirectories + string[] subDirs; + try + { + subDirs = Directory.GetDirectories(dirInfo.FullName, "*", SearchOption.TopDirectoryOnly); + } + catch (DirectoryNotFoundException e) + { + Logger.LogHandledException( + $"Could not enumerate directories in the path '{dirInfo.FullName}' due to it being an invalid path", + e); + + yield break; + } + catch (PathTooLongException e) + { + Logger.LogHandledException( + $"Could not enumerate directories in the path '{dirInfo.FullName}' due to the path being too long", + e); + + yield break; + } + catch (Exception e) when (e is SecurityException or UnauthorizedAccessException) + { + Logger.LogHandledException( + $"Could not enumerate directories in the path '{dirInfo.FullName}' due to the path not being accessible", + e); + + yield break; + } + catch (Exception e) + { + Logger.LogHandledException( + $"Could not enumerate directories in the path '{dirInfo.FullName}' due to an exception", + e); + + yield break; + } + foreach (string dirPath in subDirs) + { + DirectoryInfo subDirInfo = new(dirPath); + if (_ignoreReparsePoints && (subDirInfo.Attributes & FileAttributes.ReparsePoint) != 0) { continue; } + yield return subDirInfo; + } + + // Find the files + string[] filePaths; + try + { + filePaths = Directory.GetFiles(dirInfo.FullName, "*", SearchOption.TopDirectoryOnly); + } + catch (DirectoryNotFoundException e) + { + Logger.LogHandledException( + $"Could not enumerate files in the path '{dirInfo.FullName}' due to it being an invalid path", + e); + + yield break; + } + catch (PathTooLongException e) + { + Logger.LogHandledException( + $"Could not enumerate files in the path '{dirInfo.FullName}' due to the path being too long", + e); + + yield break; + } + catch (Exception e) when (e is SecurityException or UnauthorizedAccessException) + { + Logger.LogHandledException( + $"Could not enumerate files in the path '{dirInfo.FullName}' due to the path not being accessible", + e); + + yield break; + } + catch (Exception e) + { + Logger.LogHandledException( + $"Could not enumerate files in the path '{dirInfo.FullName}' due to an exception", + e); + + yield break; + } + foreach (string filePath in filePaths) + { + FileInfo fileInfo = new(filePath); + if (_allowedExtensions == null || _allowedExtensions.Length == 0) { yield return fileInfo; continue; } + if (_ignoreReparsePoints && (fileInfo.Attributes & FileAttributes.ReparsePoint) != 0) { continue; } + foreach (string extension in _allowedExtensions) + { + if (fileInfo.Extension == extension) { yield return fileInfo; break; } + } + } + } + } + + /// + /// Wraps an instance of and provides implementation of + /// . + /// Based on https://github.com/aspnet/Extensions/blob/c087cadf1dfdbd2b8785ef764e5ef58a1a7e5ed0/src/FileSystemGlobbing/src/Abstractions/DirectoryInfoWrapper.cs + /// + internal class WorkspaceFileSystemDirectoryWrapper : DirectoryInfoBase + { + private readonly DirectoryInfo _concreteDirectoryInfo; + private readonly bool _isParentPath; + private readonly WorkspaceFileSystemWrapperFactory _fsWrapperFactory; + private readonly int _depth; + + /// + /// Initializes an instance of . + /// + public WorkspaceFileSystemDirectoryWrapper(WorkspaceFileSystemWrapperFactory factory, DirectoryInfo directoryInfo, int depth) + { + _concreteDirectoryInfo = directoryInfo; + _isParentPath = depth == 0; + _fsWrapperFactory = factory; + _depth = depth; + } + + /// + public override IEnumerable EnumerateFileSystemInfos() + { + if (!_concreteDirectoryInfo.Exists || _depth >= _fsWrapperFactory.MaxRecursionDepth) { yield break; } + foreach (FileSystemInfo fileSystemInfo in _fsWrapperFactory.SafeEnumerateFileSystemInfos(_concreteDirectoryInfo)) + { + switch (fileSystemInfo) + { + case DirectoryInfo dirInfo: + yield return _fsWrapperFactory.CreateDirectoryInfoWrapper(dirInfo, _depth + 1); + break; + case FileInfo fileInfo: + yield return _fsWrapperFactory.CreateFileInfoWrapper(fileInfo, _depth); + break; + default: + // We should NEVER get here, but if we do just continue on + break; + } + } + } + + /// + /// Returns an instance of that represents a subdirectory. + /// + /// + /// If equals '..', this returns the parent directory. + /// + /// The directory name. + /// The directory + public override DirectoryInfoBase GetDirectory(string name) + { + bool isParentPath = string.Equals(name, "..", StringComparison.Ordinal); + + if (isParentPath) { return ParentDirectory; } + + DirectoryInfo[] dirs = _concreteDirectoryInfo.GetDirectories(name); + + if (dirs.Length == 1) { return _fsWrapperFactory.CreateDirectoryInfoWrapper(dirs[0], _depth + 1); } + if (dirs.Length == 0) { return null; } + // This shouldn't happen. The parameter name isn't supposed to contain wild card. + throw new InvalidOperationException( + string.Format( + System.Globalization.CultureInfo.CurrentCulture, + "More than one sub directories are found under {0} with name {1}.", + _concreteDirectoryInfo.FullName, name)); + } + + /// + public override FileInfoBase GetFile(string name) => _fsWrapperFactory.CreateFileInfoWrapper(new FileInfo(Path.Combine(_concreteDirectoryInfo.FullName, name)), _depth); + + /// + public override string Name => _isParentPath ? ".." : _concreteDirectoryInfo.Name; + + /// + /// Returns the full path to the directory. + /// + public override string FullName => _concreteDirectoryInfo.FullName; + + /// + /// Safely calculates the parent of this directory, swallowing most errors. + /// + private DirectoryInfoBase SafeParentDirectory() + { + try + { + return _fsWrapperFactory.CreateDirectoryInfoWrapper(_concreteDirectoryInfo.Parent, _depth - 1); + } + catch (DirectoryNotFoundException e) + { + _fsWrapperFactory.Logger.LogHandledException( + $"Could not get parent of '{_concreteDirectoryInfo.FullName}' due to it being an invalid path", + e); + } + catch (PathTooLongException e) + { + _fsWrapperFactory.Logger.LogHandledException( + $"Could not get parent of '{_concreteDirectoryInfo.FullName}' due to the path being too long", + e); + } + catch (Exception e) when (e is SecurityException or UnauthorizedAccessException) + { + _fsWrapperFactory.Logger.LogHandledException( + $"Could not get parent of '{_concreteDirectoryInfo.FullName}' due to the path not being accessible", + e); + } + catch (Exception e) + { + _fsWrapperFactory.Logger.LogHandledException( + $"Could not get parent of '{_concreteDirectoryInfo.FullName}' due to an exception", + e); + } + return null; + } + + /// + /// Returns the parent directory. (Overrides ). + /// + public override DirectoryInfoBase ParentDirectory => SafeParentDirectory(); + } + + /// + /// Wraps an instance of to provide implementation of . + /// + internal class WorkspaceFileSystemFileInfoWrapper : FileInfoBase + { + private readonly FileInfo _concreteFileInfo; + private readonly WorkspaceFileSystemWrapperFactory _fsWrapperFactory; + private readonly int _depth; + + /// + /// Initializes instance of to wrap the specified object . + /// + public WorkspaceFileSystemFileInfoWrapper(WorkspaceFileSystemWrapperFactory factory, FileInfo fileInfo, int depth) + { + _fsWrapperFactory = factory; + _concreteFileInfo = fileInfo; + _depth = depth; + } + + /// + /// The file name. (Overrides ). + /// + public override string Name => _concreteFileInfo.Name; + + /// + /// The full path of the file. (Overrides ). + /// + public override string FullName => _concreteFileInfo.FullName; + + /// + /// Safely calculates the parent of this file, swallowing most errors. + /// + private DirectoryInfoBase SafeParentDirectory() + { + try + { + return _fsWrapperFactory.CreateDirectoryInfoWrapper(_concreteFileInfo.Directory, _depth); + } + catch (DirectoryNotFoundException e) + { + _fsWrapperFactory.Logger.LogHandledException( + $"Could not get parent of '{_concreteFileInfo.FullName}' due to it being an invalid path", + e); + } + catch (PathTooLongException e) + { + _fsWrapperFactory.Logger.LogHandledException( + $"Could not get parent of '{_concreteFileInfo.FullName}' due to the path being too long", + e); + } + catch (Exception e) when (e is SecurityException or UnauthorizedAccessException) + { + _fsWrapperFactory.Logger.LogHandledException( + $"Could not get parent of '{_concreteFileInfo.FullName}' due to the path not being accessible", + e); + } + catch (Exception e) + { + _fsWrapperFactory.Logger.LogHandledException( + $"Could not get parent of '{_concreteFileInfo.FullName}' due to an exception", + e); + } + return null; + } + + /// + /// The directory containing the file. (Overrides ). + /// + public override DirectoryInfoBase ParentDirectory => SafeParentDirectory(); + } +} diff --git a/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs b/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs new file mode 100644 index 0000000..efd7f82 --- /dev/null +++ b/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs @@ -0,0 +1,451 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security; +using System.Text; +using Microsoft.Extensions.FileSystemGlobbing; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Services.Workspace; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Services +{ + /// + /// Manages a "workspace" of script files that are open for a particular + /// editing session. Also helps to navigate references between ScriptFiles. + /// + internal class WorkspaceService + { + #region Private Fields + + // List of all file extensions considered PowerShell files in the .Net Core Framework. + private static readonly string[] s_psFileExtensionsCoreFramework = + { + ".ps1", + ".psm1", + ".psd1" + }; + + // .Net Core doesn't appear to use the same three letter pattern matching rule although the docs + // suggest it should be find the '.ps1xml' files because we search for the pattern '*.ps1'. + // ref https://docs.microsoft.com/en-us/dotnet/api/system.io.directory.getfiles?view=netcore-2.1#System_IO_Directory_GetFiles_System_String_System_String_System_IO_EnumerationOptions_ + private static readonly string[] s_psFileExtensionsFullFramework = + { + ".ps1", + ".psm1", + ".psd1", + ".ps1xml" + }; + + // An array of globs which includes everything. + private static readonly string[] s_psIncludeAllGlob = new[] + { + "**/*" + }; + + private readonly ILogger logger; + private readonly Version powerShellVersion; + private readonly ConcurrentDictionary workspaceFiles = new(); + + #endregion + + #region Properties + + /// + /// Gets or sets the initial working directory. + /// + /// This is settable by the same key in the initialization options, and likely corresponds + /// to the root of the workspace if only one workspace folder is being used. However, in + /// multi-root workspaces this may be any workspace folder's root (or none if overridden). + /// + /// + public string InitialWorkingDirectory { get; set; } + + /// + /// Gets or sets the folders of the workspace. + /// + public List WorkspaceFolders { get; set; } + + /// + /// Gets or sets the default list of file globs to exclude during workspace searches. + /// + public List ExcludeFilesGlob { get; set; } + + /// + /// Gets or sets whether the workspace should follow symlinks in search operations. + /// + public bool FollowSymlinks { get; set; } + + #endregion + + #region Constructors + + /// + /// Creates a new instance of the Workspace class. + /// + public WorkspaceService(ILoggerFactory factory) + { + powerShellVersion = VersionUtils.PSVersion; + logger = factory.CreateLogger(); + WorkspaceFolders = new List(); + ExcludeFilesGlob = new List(); + FollowSymlinks = true; + } + + #endregion + + #region Public Methods + + public IEnumerable WorkspacePaths => WorkspaceFolders.Select(i => i.Uri.GetFileSystemPath()); + + /// + /// Gets an open file in the workspace. If the file isn't open but exists on the filesystem, load and return it. + /// IMPORTANT: Not all documents have a backing file e.g. untitled: scheme documents. Consider using + /// instead. + /// + /// The file path at which the script resides. + /// + /// is not found. + /// + /// + /// contains a null or empty string. + /// + public ScriptFile GetFile(string filePath) => GetFile(new Uri(filePath)); + + public ScriptFile GetFile(Uri fileUri) => GetFile(DocumentUri.From(fileUri)); + + /// + /// Gets an open file in the workspace. If the file isn't open but exists on the filesystem, load and return it. + /// IMPORTANT: Not all documents have a backing file e.g. untitled: scheme documents. Consider using + /// instead. + /// + /// The document URI at which the script resides. + /// + /// + public ScriptFile GetFile(DocumentUri documentUri) + { + Validate.IsNotNull(nameof(documentUri), documentUri); + + string keyName = GetFileKey(documentUri); + + // Make sure the file isn't already loaded into the workspace + if (!workspaceFiles.TryGetValue(keyName, out ScriptFile scriptFile)) + { + // This method allows FileNotFoundException to bubble up + // if the file isn't found. + using (StreamReader streamReader = OpenStreamReader(documentUri)) + { + scriptFile = + new ScriptFile( + documentUri, + streamReader, + powerShellVersion); + + workspaceFiles[keyName] = scriptFile; + } + + logger.LogDebug("Opened file on disk: " + documentUri.ToString()); + } + + return scriptFile; + } + + /// + /// Tries to get an open file in the workspace. Returns true if it succeeds, false otherwise. + /// + /// The file path at which the script resides. + /// The out parameter that will contain the ScriptFile object. + public bool TryGetFile(string filePath, out ScriptFile scriptFile) + { + // This might not have been given a file path, in which case the Uri constructor barfs. + try + { + return TryGetFile(new Uri(filePath), out scriptFile); + } + catch (UriFormatException) + { + scriptFile = null; + return false; + } + } + + /// + /// Tries to get an open file in the workspace. Returns true if it succeeds, false otherwise. + /// + /// The file uri at which the script resides. + /// The out parameter that will contain the ScriptFile object. + public bool TryGetFile(Uri fileUri, out ScriptFile scriptFile) => + TryGetFile(DocumentUri.From(fileUri), out scriptFile); + + /// + /// Tries to get an open file in the workspace. Returns true if it succeeds, false otherwise. + /// + /// The file uri at which the script resides. + /// The out parameter that will contain the ScriptFile object. + public bool TryGetFile(DocumentUri documentUri, out ScriptFile scriptFile) + { + switch (documentUri.Scheme) + { + // List supported schemes here + case "file": + case "inmemory": + case "untitled": + case "vscode-notebook-cell": + break; + + default: + scriptFile = null; + return false; + } + + try + { + scriptFile = GetFile(documentUri); + return true; + } + catch (Exception e) when ( + e is NotSupportedException or + FileNotFoundException or + DirectoryNotFoundException or + PathTooLongException or + IOException or + SecurityException or + UnauthorizedAccessException) + { + logger.LogWarning($"Failed to get file for fileUri: '{documentUri}'", e); + scriptFile = null; + return false; + } + } + + /// + /// Gets a new ScriptFile instance which is identified by the given file path. + /// + /// The file path for which a buffer will be retrieved. + /// A ScriptFile instance if there is a buffer for the path, null otherwise. + public ScriptFile GetFileBuffer(string filePath) => GetFileBuffer(filePath, initialBuffer: null); + + /// + /// Gets a new ScriptFile instance which is identified by the given file + /// path and initially contains the given buffer contents. + /// + /// The file path for which a buffer will be retrieved. + /// The initial buffer contents if there is not an existing ScriptFile for this path. + /// A ScriptFile instance for the specified path. + public ScriptFile GetFileBuffer(string filePath, string initialBuffer) => GetFileBuffer(new Uri(filePath), initialBuffer); + + /// + /// Gets a new ScriptFile instance which is identified by the given file path. + /// + /// The file Uri for which a buffer will be retrieved. + /// A ScriptFile instance if there is a buffer for the path, null otherwise. + public ScriptFile GetFileBuffer(Uri fileUri) => GetFileBuffer(fileUri, initialBuffer: null); + + /// + /// Gets a new ScriptFile instance which is identified by the given file + /// path and initially contains the given buffer contents. + /// + /// The file Uri for which a buffer will be retrieved. + /// The initial buffer contents if there is not an existing ScriptFile for this path. + /// A ScriptFile instance for the specified path. + public ScriptFile GetFileBuffer(Uri fileUri, string initialBuffer) => GetFileBuffer(DocumentUri.From(fileUri), initialBuffer); + + /// + /// Gets a new ScriptFile instance which is identified by the given file + /// path and initially contains the given buffer contents. + /// + /// The file Uri for which a buffer will be retrieved. + /// The initial buffer contents if there is not an existing ScriptFile for this path. + /// A ScriptFile instance for the specified path. + public ScriptFile GetFileBuffer(DocumentUri documentUri, string initialBuffer) + { + Validate.IsNotNull(nameof(documentUri), documentUri); + + string keyName = GetFileKey(documentUri); + + // Make sure the file isn't already loaded into the workspace + if (!workspaceFiles.TryGetValue(keyName, out ScriptFile scriptFile) && initialBuffer != null) + { + scriptFile = + ScriptFile.Create( + documentUri, + initialBuffer, + powerShellVersion); + + workspaceFiles[keyName] = scriptFile; + + logger.LogDebug("Opened file as in-memory buffer: " + documentUri.ToString()); + } + + return scriptFile; + } + + /// + /// Gets an IEnumerable of all opened ScriptFiles in the workspace. + /// + public IEnumerable GetOpenedFiles() => workspaceFiles.Values; + + /// + /// Closes a currently open script file with the given file path. + /// + /// The file path at which the script resides. + public void CloseFile(ScriptFile scriptFile) + { + Validate.IsNotNull(nameof(scriptFile), scriptFile); + + string keyName = GetFileKey(scriptFile.DocumentUri); + workspaceFiles.TryRemove(keyName, out ScriptFile _); + } + + /// + /// Gets the workspace-relative path of the given file path. + /// + /// A relative file path + public string GetRelativePath(ScriptFile scriptFile) + { + Uri fileUri = scriptFile.DocumentUri.ToUri(); + if (!scriptFile.IsInMemory) + { + // Support calculating out-of-workspace relative paths in the common case of a + // single workspace folder. Otherwise try to get the matching folder. + foreach (WorkspaceFolder workspaceFolder in WorkspaceFolders) + { + Uri workspaceUri = workspaceFolder.Uri.ToUri(); + if (WorkspaceFolders.Count == 1 || workspaceUri.IsBaseOf(fileUri)) + { + return workspaceUri.MakeRelativeUri(fileUri).ToString(); + } + } + } + + // Default to the absolute file path if possible, otherwise just return the URI. This + // removes the scheme and initial slash when possible. + if (fileUri.IsAbsoluteUri) + { + return fileUri.AbsolutePath; + } + return fileUri.ToString(); + } + + /// + /// Finds a file in the first workspace folder where it exists, if possible. + /// Used as a backwards-compatible way to find files in the workspace. + /// + /// + /// Best possible path. + public string FindFileInWorkspace(string filePath) + { + // If the file path is already an absolute path, just return it. + if (Path.IsPathRooted(filePath)) + { + return filePath; + } + + // If the file path is relative, try to find it in the workspace folders. + foreach (WorkspaceFolder workspaceFolder in WorkspaceFolders) + { + string folderPath = workspaceFolder.Uri.GetFileSystemPath(); + string combinedPath = Path.Combine(folderPath, filePath); + if (File.Exists(combinedPath)) + { + return combinedPath; + } + } + + // If the file path is not found in the workspace folders, return the original path. + return filePath; + } + + /// + /// Enumerate all the PowerShell (ps1, psm1, psd1) files in the workspace in a recursive manner, using default values. + /// + /// An enumerator over the PowerShell files found in the workspace. + public IEnumerable EnumeratePSFiles() + { + return EnumeratePSFiles( + ExcludeFilesGlob.ToArray(), + s_psIncludeAllGlob, + maxDepth: 64, + ignoreReparsePoints: !FollowSymlinks + ); + } + + /// + /// Enumerate all the PowerShell (ps1, psm1, psd1) files in the workspace folders in a + /// recursive manner. Falls back to initial working directory if there are no workspace folders. + /// + /// An enumerator over the PowerShell files found in the workspace. + public IEnumerable EnumeratePSFiles( + string[] excludeGlobs, + string[] includeGlobs, + int maxDepth, + bool ignoreReparsePoints) + { + Matcher matcher = new(); + foreach (string pattern in includeGlobs) { matcher.AddInclude(pattern); } + foreach (string pattern in excludeGlobs) { matcher.AddExclude(pattern); } + + foreach (string rootPath in WorkspacePaths) + { + if (!Directory.Exists(rootPath)) + { + continue; + } + + WorkspaceFileSystemWrapperFactory fsFactory = new( + rootPath, + maxDepth, + VersionUtils.IsNetCore ? s_psFileExtensionsCoreFramework : s_psFileExtensionsFullFramework, + ignoreReparsePoints, + logger); + + PatternMatchingResult fileMatchResult = matcher.Execute(fsFactory.RootDirectory); + foreach (FilePatternMatch item in fileMatchResult.Files) + { + // item.Path always contains forward slashes in paths when it should be backslashes on Windows. + // Since we're returning strings here, it's important to use the correct directory separator. + string path = VersionUtils.IsWindows ? item.Path.Replace('/', Path.DirectorySeparatorChar) : item.Path; + yield return Path.Combine(rootPath, path); + } + } + } + + #endregion + + #region Private Methods + + internal static StreamReader OpenStreamReader(DocumentUri uri) + { + FileStream fileStream = new(uri.GetFileSystemPath(), FileMode.Open, FileAccess.Read); + // Default to UTF8 no BOM if a BOM is not present. Note that `Encoding.UTF8` is *with* + // BOM, so we call the ctor here to get the BOM-less version. + // + // TODO: Honor workspace encoding settings for the fallback. + return new StreamReader(fileStream, new UTF8Encoding(), detectEncodingFromByteOrderMarks: true); + } + + internal static string ReadFileContents(DocumentUri uri) + { + using StreamReader reader = OpenStreamReader(uri); + return reader.ReadToEnd(); + } + + /// + /// Returns a normalized string for a given documentUri to be used as key name. + /// Case-sensitive uri on Linux and lowercase for other platforms. + /// + /// A DocumentUri object to get a normalized key name from + private static string GetFileKey(DocumentUri documentUri) + => VersionUtils.IsLinux ? documentUri.ToString() : documentUri.ToString().ToLower(); + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Utility/AsyncUtils.cs b/src/PowerShellEditorServices/Utility/AsyncUtils.cs new file mode 100644 index 0000000..c94023a --- /dev/null +++ b/src/PowerShellEditorServices/Utility/AsyncUtils.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.PowerShell.EditorServices.Utility +{ + /// + /// Provides utility methods for common asynchronous operations. + /// + internal static class AsyncUtils + { + /// + /// Creates a with an handle initial and + /// max count of one. + /// + /// A simple single handle . + internal static SemaphoreSlim CreateSimpleLockingSemaphore() => new(initialCount: 1, maxCount: 1); + + internal static Task HandleErrorsAsync( + this Task task, + ILogger logger, + [CallerMemberName] string callerName = null, + [CallerFilePath] string callerSourceFile = null, + [CallerLineNumber] int callerLineNumber = -1) + { + return task.IsCompleted && !(task.IsFaulted || task.IsCanceled) + ? task + : LogTaskErrors(task, logger, callerName, callerSourceFile, callerLineNumber); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "It's a wrapper.")] + private static async Task LogTaskErrors(Task task, ILogger logger, string callerName, string callerSourceFile, int callerLineNumber) + { + try + { + await task.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + logger.LogDebug($"Task canceled in '{callerName}' in file '{callerSourceFile}' line {callerLineNumber}"); + throw; + } + catch (Exception e) + { + logger.LogError(e, $"Exception thrown running task in '{callerName}' in file '{callerSourceFile}' line {callerLineNumber}"); + throw; + } + } + } +} diff --git a/src/PowerShellEditorServices/Utility/Extensions.cs b/src/PowerShellEditorServices/Utility/Extensions.cs new file mode 100644 index 0000000..88a9fa4 --- /dev/null +++ b/src/PowerShellEditorServices/Utility/Extensions.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Text; + +namespace Microsoft.PowerShell.EditorServices.Utility +{ + internal static class ObjectExtensions + { + /// + /// Extension to evaluate an object's ToString() method in an exception safe way. This will + /// extension method will not throw. + /// + /// The object on which to call ToString() + /// The ToString() return value or a suitable error message is that throws. + public static string SafeToString(this object obj) + { + string str; + + try + { + str = obj.ToString(); + } + catch (Exception ex) + { + str = $""; + } + + return str; + } + + /// + /// Same as but never CRLF. Use this when building + /// formatting for clients that may not render CRLF correctly. + /// + /// + public static StringBuilder AppendLineLF(this StringBuilder self) => self.Append('\n'); + + /// + /// Same as but never CRLF. Use this when building + /// formatting for clients that may not render CRLF correctly. + /// + /// + /// + public static StringBuilder AppendLineLF(this StringBuilder self, string value) + => self.Append(value).Append('\n'); + } +} diff --git a/src/PowerShellEditorServices/Utility/FormatUtils.cs b/src/PowerShellEditorServices/Utility/FormatUtils.cs new file mode 100644 index 0000000..b3bc791 --- /dev/null +++ b/src/PowerShellEditorServices/Utility/FormatUtils.cs @@ -0,0 +1,441 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Logging; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Utility +{ + internal static class FormatUtils + { + private const char GenericOpen = '['; + + private const char GenericClose = ']'; + + private const string Static = "static "; + + private static HashSet? usingNamespaces; + + /// + /// Space, new line, carriage return and tab. + /// + private static readonly ReadOnlyMemory s_whiteSpace = new[] { '\n', '\r', '\t', ' ' }; + + /// + /// A period, comma, and both open and square brackets. + /// + private static readonly ReadOnlyMemory s_commaSquareBracketOrDot = new[] { '.', ',', '[', ']' }; + + internal static string? GetTypeDocumentation(ILogger logger, string? toolTip, out MarkupKind kind) + { + if (toolTip is null) + { + kind = default; + return null; + } + + try + { + kind = MarkupKind.Markdown; + StringBuilder text = new(); + HashSet? usingNamespaces = null; + + text.Append('['); + ProcessType(toolTip.AsSpan(), text, ref usingNamespaces); + text.AppendLineLF("]").Append("```"); + return PrependUsingStatements(text, usingNamespaces) + .Insert(0, "```powershell\n") + .ToString(); + } + catch (Exception e) + { + logger.LogHandledException($"Failed to type property tool tip \"{toolTip}\".", e); + kind = MarkupKind.PlainText; + return toolTip.Replace("\r\n", "\n\n"); + } + } + + internal static string? GetPropertyDocumentation(ILogger logger, string? toolTip, out MarkupKind kind) + { + if (toolTip is null) + { + kind = default; + return null; + } + + try + { + return GetPropertyDocumentation( + StripAssemblyQualifications(toolTip).AsSpan(), + out kind); + } + catch (Exception e) + { + logger.LogHandledException($"Failed to parse property tool tip \"{toolTip}\".", e); + kind = MarkupKind.PlainText; + return toolTip.Replace("\r\n", "\n\n"); + } + } + + internal static string? GetMethodDocumentation(ILogger logger, string? toolTip, out MarkupKind kind) + { + if (toolTip is null) + { + kind = default; + return null; + } + + try + { + return GetMethodDocumentation( + StripAssemblyQualifications(toolTip).AsSpan(), + out kind); + } + catch (Exception e) + { + logger.LogHandledException($"Failed to parse method tool tip \"{toolTip}\".", e); + kind = MarkupKind.PlainText; + return toolTip.Replace("\r\n", "\n\n"); + } + } + + private static string GetPropertyDocumentation(ReadOnlySpan toolTip, out MarkupKind kind) + { + kind = MarkupKind.Markdown; + ReadOnlySpan originalToolTip = toolTip; + HashSet? usingNamespaces = null; + StringBuilder text = new(); + + if (toolTip.IndexOf(Static.AsSpan(), StringComparison.Ordinal) is 0) + { + text.Append(Static); + toolTip = toolTip.Slice(Static.Length); + } + + int endOfTypeIndex = toolTip.IndexOf(' '); + + // Abort trying to process if we come across something we don't understand. + if (endOfTypeIndex is -1) + { + kind = MarkupKind.PlainText; + // Replace CRLF with LF as some clients like vim render the CR as a printable + // character. Also double up on new lines as VSCode ignores single new lines. + return originalToolTip.ToString().Replace("\r\n", "\n\n"); + } + + text.Append('['); + ProcessType(toolTip.Slice(0, endOfTypeIndex), text, ref usingNamespaces); + text.Append("] "); + + toolTip = toolTip.Slice(endOfTypeIndex + 1); + + string nameAndAccessors = toolTip.ToString(); + + // Turn `{get;set;}` into `{ get; set; }` because it looks pretty. Also with namespaces + // separated we don't need to worry as much about space. This only needs to be done + // sometimes as for some reason instance properties already have spaces. + if (toolTip.IndexOf("{ ".AsSpan()) is -1) + { + nameAndAccessors = nameAndAccessors + .Replace("get;", " get;") + .Replace("set;", " set;") + .Replace("}", " }"); + } + + // Add a $ so it looks like a PowerShell class property. Though we don't have the accessor + // syntax used here, it still parses fine in the markdown. + text.Append('$') + .AppendLineLF(nameAndAccessors) + .Append("```"); + + return PrependUsingStatements(text, usingNamespaces) + .Insert(0, "```powershell\n") + .ToString(); + } + + private static string GetMethodDocumentation(ReadOnlySpan toolTip, out MarkupKind kind) + { + kind = MarkupKind.Markdown; + StringBuilder text = new(); + while (true) + { + usingNamespaces = null; + toolTip = toolTip.TrimStart(s_whiteSpace.Span); + toolTip = ProcessMethod(toolTip, text, ref usingNamespaces); + if (toolTip.IsEmpty) + { + return PrependUsingStatements(text.AppendLineLF().AppendLineLF("```"), usingNamespaces) + .Insert(0, "```powershell\n") + .ToString(); + } + + text.AppendLineLF().AppendLineLF(); + } + } + + private static StringBuilder PrependUsingStatements(StringBuilder text, HashSet? usingNamespaces) + { + if (usingNamespaces is null or { Count: 0 } || (usingNamespaces.Count is 1 && usingNamespaces.First() is "System")) + { + return text; + } + + string[] namespaces = usingNamespaces.ToArray(); + Array.Sort(namespaces); + text.Insert(0, "\n"); + for (int i = namespaces.Length - 1; i >= 0; i--) + { + if (namespaces[i] is "System") + { + continue; + } + + text.Insert(0, "using namespace " + namespaces[i] + "\n"); + } + + return text; + } + + private static string StripAssemblyQualifications(string value) + { + // Sometimes tooltip will have fully assembly qualified names, typically when a pointer + // is involved. This strips out the assembly qualification. + return Regex.Replace( + value, + ", [a-zA-Z.]+, Version=[0-9.]+, Culture=[a-zA-Z]*, PublicKeyToken=[0-9a-fnul]* ", + " "); + } + + private static ReadOnlySpan ProcessMethod( + ReadOnlySpan toolTip, + StringBuilder text, + ref HashSet? usingNamespaces) + { + if (toolTip.IsEmpty) + { + return default; + } + + if (toolTip.IndexOf(Static.AsSpan(), StringComparison.Ordinal) is 0) + { + text.Append(Static); + toolTip = toolTip.Slice(Static.Length); + } + + int endReturnTypeIndex = toolTip.IndexOf(' '); + if (endReturnTypeIndex is -1) + { + text.Append(toolTip.ToString()); + return default; + } + + text.Append('['); + ProcessType(toolTip.Slice(0, endReturnTypeIndex), text, ref usingNamespaces); + toolTip = toolTip.Slice(endReturnTypeIndex + 1); + text.Append("] "); + int endMethodNameIndex = toolTip.IndexOf('('); + if (endMethodNameIndex is -1) + { + text.Append(toolTip.ToString()); + return default; + } + + text.Append(toolTip.Slice(0, endMethodNameIndex + 1).ToString()); + toolTip = toolTip.Slice(endMethodNameIndex + 1); + if (!toolTip.IsEmpty && toolTip[0] is ')') + { + text.Append(')'); + return toolTip.Slice(1); + } + + const string indent = " "; + text.AppendLineLF().Append(indent); + while (true) + { + // ref/out/in parameters come through the tooltip with the literal text `[ref] ` + // prepended to the type. Unsure why it's the only instance where the square + // brackets are included, but without special handling it breaks the parser. + const string RefText = "[ref] "; + if (toolTip.IndexOf(RefText.AsSpan()) is 0) + { + text.Append(RefText); + toolTip = toolTip.Slice(RefText.Length); + } + + // PowerShell doesn't have a params keyword, though the binder does honor params + // methods. For lack of a better option that parses well, we'll use the decoration + // that is added in C# when the keyword is used. + const string ParamsText = "Params "; + if (toolTip.IndexOf(ParamsText.AsSpan()) is 0) + { + text.Append("[ParamArray()] "); + toolTip = toolTip.Slice(ParamsText.Length); + } + + // Generics aren't displayed with spaces in the tooltip so this is a safe end of + // type marker. + int spaceIndex = toolTip.IndexOf(' '); + if (spaceIndex is -1) + { + text.Append(toolTip.ToString()); + return default; + } + + text.Append('['); + ProcessType(toolTip.Slice(0, spaceIndex), text, ref usingNamespaces); + text.Append("] "); + toolTip = toolTip.Slice(spaceIndex + 1); + + // TODO: Add extra handling if PowerShell/PowerShell#13799 gets merged. This code + // should mostly handle it fine but a default string value with `,` or `)` would + // break. That's not the worst if it happens, but extra parsing to handle that might + // be nice. + int paramNameEndIndex = toolTip.IndexOfAny(',', ')'); + if (paramNameEndIndex is -1) + { + text.Append(toolTip.ToString()); + return default; + } + + text.Append('$').Append(toolTip.Slice(0, paramNameEndIndex).ToString()); + toolTip = toolTip.Slice(paramNameEndIndex); + if (toolTip[0] is ')') + { + text.Append(')'); + return toolTip.Slice(1); + } + + // Skip comma *and* space. + toolTip = toolTip.Slice(2); + + text.AppendLineLF(",") + .Append(indent); + } + } + + private static void ProcessType(ReadOnlySpan type, StringBuilder text, ref HashSet? usingNamespaces) + { + if (type.IndexOf('[') is int bracketIndex and not -1) + { + ProcessType(type.Slice(0, bracketIndex), text, ref usingNamespaces); + type = type.Slice(bracketIndex); + + // This is an array rather than a generic type. + if (type.IndexOfAny(',', ']') is 1) + { + text.Append(type.ToString()); + return; + } + + text.Append(GenericOpen); + type = type.Slice(1); + while (true) + { + if (type.IndexOfAny(',', '[', ']') is int nextDelimIndex and not -1) + { + ProcessType(type.Slice(0, nextDelimIndex), text, ref usingNamespaces); + type = type.Slice(nextDelimIndex); + + if (type[0] is '[' && type.IndexOfAny(',', ']') is 1) + { + type = ProcessArray(type, text); + continue; + } + + char delimChar = type[0] switch + { + '[' => GenericOpen, + ']' => GenericClose, + char c => c, + }; + + text.Append(delimChar); + type = type.Slice(1); + continue; + } + + if (!type.IsEmpty) + { + text.Append(type.ToString()); + } + + return; + } + } + + ReadOnlySpan namespaceStart = default; + int lastDot = 0; + while (true) + { + if (type.IndexOfAny(s_commaSquareBracketOrDot.Span) is int nextDelimIndex and not -1) + { + // Strip namespaces. + if (type[nextDelimIndex] is '.') + { + if (namespaceStart.IsEmpty) + { + namespaceStart = type; + } + + lastDot += nextDelimIndex + 1; + type = type.Slice(nextDelimIndex + 1); + continue; + } + + if (!namespaceStart.IsEmpty) + { + usingNamespaces ??= new(StringComparer.OrdinalIgnoreCase); + usingNamespaces.Add(namespaceStart.Slice(0, lastDot - 1).ToString()); + } + + text.Append(type.Slice(0, nextDelimIndex).ToString()); + return; + } + + if (!namespaceStart.IsEmpty) + { + usingNamespaces ??= new(StringComparer.OrdinalIgnoreCase); + usingNamespaces.Add(namespaceStart.Slice(0, lastDot - 1).ToString()); + } + + text.Append(type.ToString()); + return; + } + } + + private static ReadOnlySpan ProcessArray(ReadOnlySpan type, StringBuilder text) + { + for (int i = 0; i < type.Length; i++) + { + char c = type[i]; + if (c is ']') + { + text.Append(']'); + // Check for types like int[][] + if (type.Length - 1 > i && type[i + 1] is '[') + { + text.Append('['); + i++; + continue; + } + + return type.Slice(i + 1); + } + + text.Append(c); + } + + Debug.Fail("Span passed to ProcessArray should have contained a ']' char."); + return default; + } + } +} diff --git a/src/PowerShellEditorServices/Utility/IdempotentLatch.cs b/src/PowerShellEditorServices/Utility/IdempotentLatch.cs new file mode 100644 index 0000000..433b62a --- /dev/null +++ b/src/PowerShellEditorServices/Utility/IdempotentLatch.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Threading; + +namespace Microsoft.PowerShell.EditorServices.Utility +{ + internal class IdempotentLatch + { + private int _signaled; + + public IdempotentLatch() => _signaled = 0; + + public bool IsSignaled => _signaled != 0; + + public bool TryEnter() => Interlocked.Exchange(ref _signaled, 1) == 0; + } +} diff --git a/src/PowerShellEditorServices/Utility/LspDebugUtils.cs b/src/PowerShellEditorServices/Utility/LspDebugUtils.cs new file mode 100644 index 0000000..3667774 --- /dev/null +++ b/src/PowerShellEditorServices/Utility/LspDebugUtils.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.DebugAdapter; +using OmniSharp.Extensions.DebugAdapter.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Utility +{ + internal static class LspDebugUtils + { + internal static Breakpoint CreateBreakpoint( + BreakpointDetails breakpointDetails) + { + Validate.IsNotNull(nameof(breakpointDetails), breakpointDetails); + + return new Breakpoint + { + Id = breakpointDetails.Id, + Verified = breakpointDetails.Verified, + Message = breakpointDetails.Message, + Source = new Source { Path = breakpointDetails.Source }, + Line = breakpointDetails.LineNumber, + Column = breakpointDetails.ColumnNumber + }; + } + + internal static Breakpoint CreateBreakpoint( + CommandBreakpointDetails breakpointDetails) + { + Validate.IsNotNull(nameof(breakpointDetails), breakpointDetails); + + return new Breakpoint + { + Verified = breakpointDetails.Verified, + Message = breakpointDetails.Message + }; + } + + public static Breakpoint CreateBreakpoint( + SourceBreakpoint sourceBreakpoint, + string source, + string message, + bool verified = false) + { + Validate.IsNotNull(nameof(sourceBreakpoint), sourceBreakpoint); + Validate.IsNotNull(nameof(source), source); + Validate.IsNotNull(nameof(message), message); + + return new Breakpoint + { + Verified = verified, + Message = message, + Source = new Source { Path = source }, + Line = sourceBreakpoint.Line, + Column = sourceBreakpoint.Column + }; + } + + public static Scope CreateScope(VariableScope scope) + { + return new Scope + { + Name = scope.Name, + VariablesReference = scope.Id, + // Temporary fix for #95 to get debug hover tips to work well at least for the local scope. + Expensive = scope.Name is not VariableContainerDetails.LocalScopeName and + not VariableContainerDetails.AutoVariablesName + }; + } + + public static Variable CreateVariable(VariableDetailsBase variable) + { + return new Variable + { + Name = variable.Name, + Value = variable.ValueString ?? string.Empty, + Type = variable.Type, + EvaluateName = variable.Name, + VariablesReference = + variable.IsExpandable ? + variable.Id : 0 + }; + } + } +} diff --git a/src/PowerShellEditorServices/Utility/LspUtils.cs b/src/PowerShellEditorServices/Utility/LspUtils.cs new file mode 100644 index 0000000..d47f2b6 --- /dev/null +++ b/src/PowerShellEditorServices/Utility/LspUtils.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Utility +{ + internal static class LspUtils + { + public static TextDocumentSelector PowerShellDocumentSelector => new( + TextDocumentFilter.ForLanguage("powershell"), + TextDocumentFilter.ForLanguage("pwsh"), + + // The vim extension sets all PowerShell files as language "ps1" so this + // makes sure we track those. + TextDocumentFilter.ForLanguage("ps1"), + TextDocumentFilter.ForLanguage("psm1"), + TextDocumentFilter.ForLanguage("psd1"), + + // Also specify the file extensions to be thorough + // This won't handle untitled files which is why we have to do the ones above. + TextDocumentFilter.ForPattern("**/*.ps*1") + ); + } +} diff --git a/src/PowerShellEditorServices/Utility/PSCommandExtensions.cs b/src/PowerShellEditorServices/Utility/PSCommandExtensions.cs new file mode 100644 index 0000000..4fe2984 --- /dev/null +++ b/src/PowerShellEditorServices/Utility/PSCommandExtensions.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Management.Automation; +using System.Management.Automation.Runspaces; +using System.Text; + +namespace Microsoft.PowerShell.EditorServices.Utility +{ + internal static class PSCommandHelpers + { + public static PSCommand AddOutputCommand(this PSCommand psCommand) + { + return psCommand.MergePipelineResults() + .AddCommand("Out-Default", useLocalScope: true); + } + + public static PSCommand AddDebugOutputCommand(this PSCommand psCommand) + { + return psCommand.MergePipelineResults() + .AddCommand("Out-String", useLocalScope: true) + .AddParameter("Stream"); + } + + public static PSCommand MergePipelineResults(this PSCommand psCommand) + { + if (psCommand.Commands.Count > 0) + { + // We need to do merge errors and output before rendering with an Out- cmdlet + Command lastCommand = psCommand.Commands[psCommand.Commands.Count - 1]; + lastCommand.MergeMyResults(PipelineResultTypes.Error, PipelineResultTypes.Output); + } + return psCommand; + } + + public static PSCommand AddProfileLoadIfExists(this PSCommand psCommand, PSObject profileVariable, string profileName, string profilePath) + { + // This path should be added regardless of the existence of the file. + profileVariable.Members.Add(new PSNoteProperty(profileName, profilePath)); + + if (File.Exists(profilePath)) + { + psCommand.AddCommand(profilePath, useLocalScope: false).AddOutputCommand().AddStatement(); + } + + return psCommand; + } + + /// + /// Get a representation of the PSCommand, for logging purposes. + /// + /// + public static string GetInvocationText(this PSCommand command) + { + Command currentCommand = command.Commands[0]; + StringBuilder sb = new StringBuilder().AddCommandText(command.Commands[0]); + + for (int i = 1; i < command.Commands.Count; i++) + { + sb.Append(currentCommand.IsEndOfStatement ? "; " : " | "); + currentCommand = command.Commands[i]; + sb.AddCommandText(currentCommand); + } + + return sb.ToString(); + } + + private static StringBuilder AddCommandText(this StringBuilder sb, Command command) + { + sb.Append(command.CommandText); + if (command.Parameters != null) + { + foreach (CommandParameter parameter in command.Parameters) + { + if (parameter.Name != null) + { + sb.Append(" -").Append(parameter.Name); + } + + if (parameter.Value != null) + { + // This isn't going to get PowerShell's string form of the value, + // but it's good enough, and not as complex or expensive + sb.Append(' ').Append(parameter.Value); + } + } + } + + return sb; + } + + public static string EscapeScriptFilePath(string f) => string.Concat("'", f.Replace("'", "''"), "'"); + + // Operator defaults to dot-source but could also be call (ampersand). + // It can't be called that because it's a reserved keyword in C#. + public static PSCommand BuildDotSourceCommandWithArguments(string command, IEnumerable arguments, string executeMode = ".") + { + string args = string.Join(" ", arguments ?? Array.Empty()); + string script = string.Concat(executeMode, " ", command, string.IsNullOrEmpty(args) ? "" : " ", args); + // HACK: We use AddScript instead of AddArgument/AddParameter to reuse Powershell parameter binding logic. + return new PSCommand().AddScript(script); + } + } +} diff --git a/src/PowerShellEditorServices/Utility/PathUtils.cs b/src/PowerShellEditorServices/Utility/PathUtils.cs new file mode 100644 index 0000000..112e8b4 --- /dev/null +++ b/src/PowerShellEditorServices/Utility/PathUtils.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Management.Automation; +using System.Runtime.InteropServices; + +namespace Microsoft.PowerShell.EditorServices.Utility +{ + internal static class PathUtils + { + /// + /// The value to be used when comparing paths. Will be + /// for case sensitive file systems and + /// in case insensitive file systems. + /// + internal static readonly StringComparison PathComparison = RuntimeInformation.IsOSPlatform(OSPlatform.Linux) + ? StringComparison.Ordinal + : StringComparison.OrdinalIgnoreCase; + + /// + /// Determines whether two specified strings represent the same path. + /// + /// The first path to compare, or . + /// The second path to compare, or . + /// + /// if the value of represents the same + /// path as the value of ; otherwise, . + /// + internal static bool IsPathEqual(string left, string right) + { + if (string.IsNullOrEmpty(left)) + { + return string.IsNullOrEmpty(right); + } + + if (string.IsNullOrEmpty(right)) + { + return false; + } + + left = Path.GetFullPath(left).TrimEnd(Path.DirectorySeparatorChar); + right = Path.GetFullPath(right).TrimEnd(Path.DirectorySeparatorChar); + return left.Equals(right, PathComparison); + } + + /// + /// Return the given path with all PowerShell globbing characters escaped, + /// plus optionally the whitespace. + /// + /// The path to process. + /// Specify True to escape spaces in the path, otherwise False. + /// The path with *, ?, [, and ] escaped, including spaces if required + internal static string WildcardEscapePath(string path, bool escapeSpaces = false) + { + string wildcardEscapedPath = WildcardPattern.Escape(path); + + if (escapeSpaces) + { + wildcardEscapedPath = wildcardEscapedPath.Replace(" ", "` "); + } + return wildcardEscapedPath; + } + } +} diff --git a/src/PowerShellEditorServices/Utility/Validate.cs b/src/PowerShellEditorServices/Utility/Validate.cs new file mode 100644 index 0000000..f67ffe1 --- /dev/null +++ b/src/PowerShellEditorServices/Utility/Validate.cs @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; + +namespace Microsoft.PowerShell.EditorServices.Utility +{ + /// + /// Provides common validation methods to simplify method + /// parameter checks. + /// + public static class Validate + { + /// + /// Throws ArgumentNullException if value is null. + /// + /// The name of the parameter being validated. + /// The value of the parameter being validated. + public static void IsNotNull(string parameterName, object valueToCheck) + { + if (valueToCheck == null) + { + throw new ArgumentNullException(parameterName); + } + } + + /// + /// Throws ArgumentOutOfRangeException if the value is outside + /// of the given lower and upper limits. + /// + /// The name of the parameter being validated. + /// The value of the parameter being validated. + /// The lower limit which the value should not be less than. + /// The upper limit which the value should not be greater than. + public static void IsWithinRange( + string parameterName, + int valueToCheck, + int lowerLimit, + int upperLimit) + { + // TODO: Debug assert here if lowerLimit >= upperLimit + + if (valueToCheck < lowerLimit || valueToCheck > upperLimit) + { + throw new ArgumentOutOfRangeException( + parameterName, + valueToCheck, + string.Format( + "Value is not between {0} and {1}", + lowerLimit, + upperLimit)); + } + } + + /// + /// Throws ArgumentOutOfRangeException if the value is greater than or equal + /// to the given upper limit. + /// + /// The name of the parameter being validated. + /// The value of the parameter being validated. + /// The upper limit which the value should be less than. + public static void IsLessThan( + string parameterName, + int valueToCheck, + int upperLimit) + { + if (valueToCheck >= upperLimit) + { + throw new ArgumentOutOfRangeException( + parameterName, + valueToCheck, + string.Format( + "Value is greater than or equal to {0}", + upperLimit)); + } + } + + /// + /// Throws ArgumentOutOfRangeException if the value is less than or equal + /// to the given lower limit. + /// + /// The name of the parameter being validated. + /// The value of the parameter being validated. + /// The lower limit which the value should be greater than. + public static void IsGreaterThan( + string parameterName, + int valueToCheck, + int lowerLimit) + { + if (valueToCheck < lowerLimit) + { + throw new ArgumentOutOfRangeException( + parameterName, + valueToCheck, + string.Format( + "Value is less than or equal to {0}", + lowerLimit)); + } + } + + /// + /// Throws ArgumentException if the value is equal to the undesired value. + /// + /// The type of value to be validated. + /// The name of the parameter being validated. + /// The value of the parameter being validated. + /// The value that valueToCheck should not equal. + public static void IsNotEqual( + string parameterName, + TValue valueToCheck, + TValue undesiredValue) + { + if (EqualityComparer.Default.Equals(valueToCheck, undesiredValue)) + { + throw new ArgumentException( + string.Format( + "The given value '{0}' should not equal '{1}'", + valueToCheck, + undesiredValue), + parameterName); + } + } + + /// + /// Throws ArgumentException if the value is null, an empty string, + /// or a string containing only whitespace. + /// + /// The name of the parameter being validated. + /// The value of the parameter being validated. + public static void IsNotNullOrEmptyString(string parameterName, string valueToCheck) + { + if (string.IsNullOrWhiteSpace(valueToCheck)) + { + throw new ArgumentException( + "Parameter contains a null, empty, or whitespace string.", + parameterName); + } + } + } +} diff --git a/src/PowerShellEditorServices/Utility/VersionUtils.cs b/src/PowerShellEditorServices/Utility/VersionUtils.cs new file mode 100644 index 0000000..0b89192 --- /dev/null +++ b/src/PowerShellEditorServices/Utility/VersionUtils.cs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Reflection; +using System.Runtime.InteropServices; + +namespace Microsoft.PowerShell.EditorServices.Utility +{ + /// + /// General purpose common utilities to prevent reimplementation. + /// + internal static class VersionUtils + { + /// + /// True if we are running on .NET Core, false otherwise. + /// + public static bool IsNetCore { get; } = !RuntimeInformation.FrameworkDescription.StartsWith(".NET Framework", StringComparison.Ordinal); + + /// + /// Gets the Version of PowerShell being used. + /// + public static Version PSVersion { get; } = PowerShellReflectionUtils.PSVersion; + + /// + /// Gets the Edition of PowerShell being used. + /// + public static string PSEdition { get; } = PowerShellReflectionUtils.PSEdition; + + /// + /// Gets the GitCommitId of PowerShell being used. + /// + public static string GitCommitId { get; } = PowerShellReflectionUtils.GitCommitId; + + /// + /// Gets the string of the PSVersion including prerelease tags if it applies. + /// + public static string PSVersionString { get; } = PowerShellReflectionUtils.PSVersionString; + + /// + /// True if we are running in Windows PowerShell, false otherwise. + /// + public static bool IsPS5 { get; } = PSVersion.Major == 5; + + /// + /// True if we are running in PowerShell 7 or greater, false otherwise. + /// + public static bool IsPS7OrGreater { get; } = PSVersion.Major >= 7; + + /// + /// True if we are running in PowerShell 7.4, false otherwise. + /// + public static bool IsPS74 { get; } = PSVersion.Major == 7 && PSVersion.Minor == 4; + + /// + /// True if we are running on Windows, false otherwise. + /// + public static bool IsWindows { get; } = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + /// + /// True if we are running on macOS, false otherwise. + /// + public static bool IsMacOS { get; } = RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + + /// + /// True if we are running on Linux, false otherwise. + /// + public static bool IsLinux { get; } = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + + /// + /// The .NET Architecture as a string. + /// + public static string Architecture { get; } = RuntimeInformation.OSArchitecture.ToString(); + } + + internal static class PowerShellReflectionUtils + { + private static readonly Type s_psVersionInfoType = typeof(System.Management.Automation.Runspaces.Runspace).Assembly.GetType("System.Management.Automation.PSVersionInfo"); + + // This property is a Version type in PowerShell. It's existed since 5.1, but it was only made public in 6.2. + private static readonly PropertyInfo s_psVersionProperty = s_psVersionInfoType + .GetProperty("PSVersion", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static); + + // This property is a SemanticVersion in PowerShell that contains the prerelease tag as well. + // It was added in 6.2 so we can't depend on it for anything before. + private static readonly PropertyInfo s_psCurrentVersionProperty = s_psVersionInfoType + .GetProperty("PSCurrentVersion", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static); + + private static readonly PropertyInfo s_psEditionProperty = s_psVersionInfoType + .GetProperty("PSEdition", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static); + + // This property is a string, existing only in PowerShell Core. + private static readonly FieldInfo s_psGitCommitIdProperty = s_psVersionInfoType + .GetField("GitCommitId", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static); + + /// + /// Gets the Version of PowerShell being used. NOTE: this will get rid of the SemVer 2.0 suffix because apparently + /// that property is added as a note property and it is not there when we reflect. + /// + public static Version PSVersion { get; } = s_psVersionProperty.GetValue(null) as Version; + + /// + /// Get's the Edition of PowerShell being used. + /// + public static string PSEdition { get; } = s_psEditionProperty.GetValue(null) as string; + + /// + /// Gets the GitCommitId or at most x.y.z from the PSVersion, making Windows PowerShell conform to SemVer. + /// + public static string GitCommitId { get; } = s_psGitCommitIdProperty != null + ? s_psGitCommitIdProperty.GetValue(null).ToString() + : PSVersion.ToString(3); + + /// + /// Gets the stringified version of PowerShell including prerelease tags if it applies. + /// + public static string PSVersionString { get; } = s_psCurrentVersionProperty != null + ? s_psCurrentVersionProperty.GetValue(null).ToString() + : PSVersion.ToString(3); + } +} diff --git a/src/PowerShellEditorServices/Utility/VisitorUtils.cs b/src/PowerShellEditorServices/Utility/VisitorUtils.cs new file mode 100644 index 0000000..7ce37c1 --- /dev/null +++ b/src/PowerShellEditorServices/Utility/VisitorUtils.cs @@ -0,0 +1,385 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Management.Automation; +using System.Management.Automation.Language; +using System.Text; +using PSESSymbols = Microsoft.PowerShell.EditorServices.Services.Symbols; + +namespace Microsoft.PowerShell.EditorServices.Utility +{ + /// + /// General common utilities for AST visitors to prevent reimplementation. + /// + internal static class VisitorUtils + { + internal static string? GetCommandName(CommandAst commandAst) + { + string commandName = commandAst.GetCommandName(); + if (!string.IsNullOrEmpty(commandName)) + { + return commandName; + } + + if (commandAst.CommandElements[0] is not ExpandableStringExpressionAst expandableStringExpressionAst) + { + return null; + } + + return PSESSymbols.AstOperations.TryGetInferredValue(expandableStringExpressionAst, out string value) ? value : null; + } + + private static readonly string[] s_scopes = new string[] + { + "private:", + "script:", + "global:", + "local:" + }; + + // Strip the qualification, if there is any, so script:my-function is a reference of my-function etc. + internal static string GetUnqualifiedFunctionName(string name) + { + foreach (string scope in s_scopes) + { + if (name.StartsWith(scope, StringComparison.OrdinalIgnoreCase)) + { + return name.Substring(scope.Length); + } + } + + return name; + } + + // Strip the qualification, if there is any, so $var is a reference of $script:var etc. + internal static string GetUnqualifiedVariableName(VariablePath variablePath) + { + return variablePath.IsUnqualified + ? variablePath.UserPath + : variablePath.UserPath.Substring(variablePath.UserPath.IndexOf(':') + 1); + } + + /// + /// Calculates the start line and column of the actual symbol name in a AST. + /// + /// An Ast object in the script's AST + /// An int specifying start index of name in the AST's extent text + /// A tuple with start column and line of the symbol name + private static (int startColumn, int startLine) GetNameStartColumnAndLineFromAst(Ast ast, int nameStartIndex) + { + int startColumnNumber = ast.Extent.StartColumnNumber; + int startLineNumber = ast.Extent.StartLineNumber; + string astText = ast.Extent.Text; + // astOffset is the offset on the entire text of the AST. + for (int astOffset = 0; astOffset <= ast.Extent.Text.Length; astOffset++, startColumnNumber++) + { + if (astText[astOffset] == '\n') + { + // reset numbers since we are operating on a different line and increment the line number. + startColumnNumber = 0; + startLineNumber++; + } + else if (astText[astOffset] == '\r') + { + // Do nothing with carriage returns... we only look for line feeds since those + // are used on every platform. + } + else if (astOffset >= nameStartIndex && !char.IsWhiteSpace(astText[astOffset])) + { + // This is the start of the function name so we've found our start column and line number. + break; + } + } + + return (startColumnNumber, startLineNumber); + } + + /// + /// Calculates the start line and column of the actual function name in a function definition AST. + /// + /// A FunctionDefinitionAst object in the script's AST + /// A tuple with start column and line for the function name + internal static (int startColumn, int startLine) GetNameStartColumnAndLineFromAst(FunctionDefinitionAst functionDefinitionAst) + { + int startOffset = functionDefinitionAst.IsFilter ? "filter".Length : functionDefinitionAst.IsWorkflow ? "workflow".Length : "function".Length; + return GetNameStartColumnAndLineFromAst(functionDefinitionAst, startOffset); + } + + /// + /// Calculates the start line and column of the actual class/enum name in a type definition AST. + /// + /// A TypeDefinitionAst object in the script's AST + /// A tuple with start column and line for the type name + internal static (int startColumn, int startLine) GetNameStartColumnAndLineFromAst(TypeDefinitionAst typeDefinitionAst) + { + int startOffset = typeDefinitionAst.IsEnum ? "enum".Length : "class".Length; + return GetNameStartColumnAndLineFromAst(typeDefinitionAst, startOffset); + } + + /// + /// Calculates the start line and column of the actual method/constructor name in a function member AST. + /// + /// A FunctionMemberAst object in the script's AST + /// A tuple with start column and line for the method/constructor name + internal static (int startColumn, int startLine) GetNameStartColumnAndLineFromAst(FunctionMemberAst functionMemberAst) + { + // find name index to get offset even with attributes, static, hidden ++ + int nameStartIndex = functionMemberAst.Extent.Text.IndexOf( + functionMemberAst.Name + '(', StringComparison.OrdinalIgnoreCase); + return GetNameStartColumnAndLineFromAst(functionMemberAst, nameStartIndex); + } + + /// + /// Calculates the start line and column of the actual property name in a property member AST. + /// + /// A PropertyMemberAst object in the script's AST + /// A bool indicating this is a enum member + /// A tuple with start column and line for the property name + internal static (int startColumn, int startLine) GetNameStartColumnAndLineFromAst(PropertyMemberAst propertyMemberAst, bool isEnumMember) + { + // find name index to get offset even with attributes, static, hidden ++ + string searchString = isEnumMember + ? propertyMemberAst.Name : '$' + propertyMemberAst.Name; + int nameStartIndex = propertyMemberAst.Extent.Text.IndexOf( + searchString, StringComparison.OrdinalIgnoreCase); + return GetNameStartColumnAndLineFromAst(propertyMemberAst, nameStartIndex); + } + + /// + /// Calculates the start line and column of the actual configuration name in a configuration definition AST. + /// + /// A ConfigurationDefinitionAst object in the script's AST + /// A tuple with start column and line for the configuration name + internal static (int startColumn, int startLine) GetNameStartColumnAndLineFromAst(ConfigurationDefinitionAst configurationDefinitionAst) + { + const int startOffset = 13; // "configuration".Length + return GetNameStartColumnAndLineFromAst(configurationDefinitionAst, startOffset); + } + + /// + /// Gets a new ScriptExtent for a given Ast for the symbol name only (variable) + /// + /// A FunctionDefinitionAst in the script's AST + /// A ScriptExtent with for the symbol name only + internal static PSESSymbols.ScriptExtent GetNameExtent(FunctionDefinitionAst functionDefinitionAst) + { + (int startColumn, int startLine) = GetNameStartColumnAndLineFromAst(functionDefinitionAst); + + return new PSESSymbols.ScriptExtent() + { + Text = functionDefinitionAst.Name, + StartLineNumber = startLine, + EndLineNumber = startLine, + StartColumnNumber = startColumn, + EndColumnNumber = startColumn + functionDefinitionAst.Name.Length, + File = functionDefinitionAst.Extent.File + }; + } + + /// + /// Gets a new ScriptExtent for a given Ast for the symbol name only (variable) + /// + /// A TypeDefinitionAst in the script's AST + /// A ScriptExtent with for the symbol name only + internal static PSESSymbols.ScriptExtent GetNameExtent(TypeDefinitionAst typeDefinitionAst) + { + (int startColumn, int startLine) = GetNameStartColumnAndLineFromAst(typeDefinitionAst); + + return new PSESSymbols.ScriptExtent() + { + Text = typeDefinitionAst.Name, + StartLineNumber = startLine, + EndLineNumber = startLine, + StartColumnNumber = startColumn, + EndColumnNumber = startColumn + typeDefinitionAst.Name.Length, + File = typeDefinitionAst.Extent.File + }; + } + + /// + /// Gets a new ScriptExtent for a given Ast for the symbol name only (variable) + /// + /// A FunctionMemberAst in the script's AST + /// A ScriptExtent with for the symbol name only + internal static PSESSymbols.ScriptExtent GetNameExtent(FunctionMemberAst functionMemberAst) + { + (int startColumn, int startLine) = GetNameStartColumnAndLineFromAst(functionMemberAst); + + return new PSESSymbols.ScriptExtent() + { + Text = GetMemberOverloadName(functionMemberAst), + StartLineNumber = startLine, + EndLineNumber = startLine, + StartColumnNumber = startColumn, + EndColumnNumber = startColumn + functionMemberAst.Name.Length, + File = functionMemberAst.Extent.File + }; + } + + /// + /// Gets a new ScriptExtent for a given Ast for the property name only + /// + /// A PropertyMemberAst in the script's AST + /// A ScriptExtent with for the symbol name only + internal static PSESSymbols.ScriptExtent GetNameExtent(PropertyMemberAst propertyMemberAst) + { + bool isEnumMember = propertyMemberAst.Parent is TypeDefinitionAst typeDef && typeDef.IsEnum; + (int startColumn, int startLine) = GetNameStartColumnAndLineFromAst(propertyMemberAst, isEnumMember); + + // +1 when class property to as start includes $ + int endColumnNumber = isEnumMember ? + startColumn + propertyMemberAst.Name.Length : + startColumn + propertyMemberAst.Name.Length + 1; + + return new PSESSymbols.ScriptExtent() + { + Text = GetMemberOverloadName(propertyMemberAst), + StartLineNumber = startLine, + EndLineNumber = startLine, + StartColumnNumber = startColumn, + EndColumnNumber = endColumnNumber, + File = propertyMemberAst.Extent.File + }; + } + + /// + /// Gets a new ScriptExtent for a given Ast for the configuration instance name only + /// + /// A ConfigurationDefinitionAst in the script's AST + /// A ScriptExtent with for the symbol name only + internal static PSESSymbols.ScriptExtent GetNameExtent(ConfigurationDefinitionAst configurationDefinitionAst) + { + string configurationName = configurationDefinitionAst.InstanceName.Extent.Text; + (int startColumn, int startLine) = GetNameStartColumnAndLineFromAst(configurationDefinitionAst); + + return new PSESSymbols.ScriptExtent() + { + Text = configurationName, + StartLineNumber = startLine, + EndLineNumber = startLine, + StartColumnNumber = startColumn, + EndColumnNumber = startColumn + configurationName.Length, + File = configurationDefinitionAst.Extent.File + }; + } + + /// + /// Gets the function name with parameters and return type. + /// + internal static string GetFunctionDisplayName(FunctionDefinitionAst functionDefinitionAst) + { + StringBuilder sb = new(); + if (functionDefinitionAst.IsWorkflow) + { + sb.Append("workflow"); + } + else if (functionDefinitionAst.IsFilter) + { + sb.Append("filter"); + } + else + { + sb.Append("function"); + } + sb.Append(' ').Append(functionDefinitionAst.Name).Append(" ("); + // Add parameters + // TODO: Fix the parameters, this doesn't work for those specified in the body. + if (functionDefinitionAst.Parameters?.Count > 0) + { + List parameters = new(functionDefinitionAst.Parameters.Count); + foreach (ParameterAst param in functionDefinitionAst.Parameters) + { + parameters.Add(param.Extent.Text); + } + + sb.Append(string.Join(", ", parameters)); + } + sb.Append(')'); + + return sb.ToString(); + } + + /// + /// Gets the display name of a parameter with its default value. + /// + internal static string GetParamDisplayName(ParameterAst parameterAst) + { + StringBuilder sb = new(); + + sb.Append("(parameter) "); + if (parameterAst.StaticType is not null) + { + sb.Append('[').Append(parameterAst.StaticType).Append(']'); + } + sb.Append('$').Append(parameterAst.Name.VariablePath.UserPath); + string? constantValue = parameterAst.DefaultValue is ConstantExpressionAst constant + ? constant.Value.ToString() : null; + + if (!string.IsNullOrEmpty(constantValue)) + { + sb.Append(" = ").Append(constantValue); + } + + return sb.ToString(); + } + + /// + /// Gets the method or constructor name with parameters for current overload. + /// + /// A FunctionMemberAst object in the script's AST + /// Function member name with return type (optional) and parameters + internal static string GetMemberOverloadName(FunctionMemberAst functionMemberAst) + { + StringBuilder sb = new(); + + // Prepend return type and class. Used for symbol details (hover) + if (!functionMemberAst.IsConstructor) + { + sb.Append(functionMemberAst.ReturnType?.TypeName.Name ?? "void").Append(' '); + } + + sb.Append(functionMemberAst.Name); + + // Add parameters + sb.Append('('); + if (functionMemberAst.Parameters.Count > 0) + { + List parameters = new(functionMemberAst.Parameters.Count); + foreach (ParameterAst param in functionMemberAst.Parameters) + { + parameters.Add(param.Extent.Text); + } + + sb.Append(string.Join(", ", parameters)); + } + sb.Append(')'); + + return sb.ToString(); + } + + /// + /// Gets the property name with type and class/enum. + /// + /// A PropertyMemberAst object in the script's AST + /// Property name with type (optional) and class/enum + internal static string GetMemberOverloadName(PropertyMemberAst propertyMemberAst) + { + StringBuilder sb = new(); + + // Prepend return type and class. Used for symbol details (hover) + if (propertyMemberAst.Parent is TypeDefinitionAst typeAst && !typeAst.IsEnum) + { + sb.Append('[') + .Append(propertyMemberAst.PropertyType?.TypeName.Name ?? "object") + .Append("] $"); + } + + sb.Append(propertyMemberAst.Name); + return sb.ToString(); + } + } +} diff --git a/test/.editorconfig b/test/.editorconfig new file mode 100644 index 0000000..32a01ba --- /dev/null +++ b/test/.editorconfig @@ -0,0 +1,15 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# not the top-most EditorConfig file +root = false + +[*.{cs}] +# CA2007: Do not directly await a Task +dotnet_diagnostic.CA2007.severity = none + +# xUnit1004: Test methods should not be skipped +dotnet_diagnostic.xUnit1004.severity = suggestion +# xUnit1030: Do not call ConfigureAwait in test method +dotnet_diagnostic.xUnit1030.severity = error +# xUnit2013: Do not use equality check to check for collection size +dotnet_diagnostic.xUnit2013.severity = error diff --git a/test/.themisrc b/test/.themisrc new file mode 100644 index 0000000..d684e00 --- /dev/null +++ b/test/.themisrc @@ -0,0 +1,6 @@ +filetype plugin on + +let g:repo_root = fnamemodify(expand(''), ':h:h') + +call themis#option('runtimepath', g:repo_root . '/LanguageClient-neovim') +call themis#option('runtimepath', g:repo_root . '/vim-ps1') diff --git a/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs b/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs new file mode 100644 index 0000000..6e0f4ac --- /dev/null +++ b/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs @@ -0,0 +1,892 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using Microsoft.PowerShell.EditorServices.Handlers; +using Nerdbank.Streams; +using OmniSharp.Extensions.DebugAdapter.Client; +using OmniSharp.Extensions.DebugAdapter.Protocol.Client; +using OmniSharp.Extensions.DebugAdapter.Protocol.Events; +using OmniSharp.Extensions.DebugAdapter.Protocol.Models; +using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; +using OmniSharp.Extensions.JsonRpc.Server; +using Xunit; +using Xunit.Abstractions; +using DapStackFrame = OmniSharp.Extensions.DebugAdapter.Protocol.Models.StackFrame; + +namespace PowerShellEditorServices.Test.E2E +{ + [Trait("Category", "DAP")] + // ITestOutputHelper is injected by XUnit + // https://xunit.net/docs/capturing-output + public class DebugAdapterProtocolMessageTests(ITestOutputHelper output) : IAsyncLifetime + { + // After initialization, use this client to send messages for E2E tests and check results + private IDebugAdapterClient client; + + private static readonly bool s_isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + /// + /// Test scripts output here, where the output can be read to verify script progress against breakpointing + /// + private static readonly string testScriptLogPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + private readonly PsesStdioLanguageServerProcessHost psesHost = new(isDebugAdapter: true); + + private readonly TaskCompletionSource initializedLanguageClientTcs = new(); + /// + /// This task is useful for waiting until the client is initialized (but before Server Initialized is sent) + /// + private Task initializedLanguageClient => initializedLanguageClientTcs.Task; + + /// + /// Is used to read the script log file to verify script progress against breakpointing. + private StreamReader scriptLogReader; + + private TaskCompletionSource nextStoppedTcs = new(); + /// + /// This task is useful for waiting until a breakpoint is hit in a test. + /// + private Task nextStopped => nextStoppedTcs.Task; + + /// + /// This task is useful for waiting until a StartDebuggingAttachRequest is received. + /// + private readonly TaskCompletionSource startDebuggingAttachRequestTcs = new(); + + /// + /// This task is useful for waiting until the debug session has terminated. + /// + private readonly TaskCompletionSource terminatedTcs = new(); + + public async Task InitializeAsync() + { + // Cleanup testScriptLogPath if it exists due to an interrupted previous run + if (File.Exists(testScriptLogPath)) + { + File.Delete(testScriptLogPath); + } + + (StreamReader stdout, StreamWriter stdin) = await psesHost.Start(); + + // Splice the streams together and enable debug logging of all messages sent and received + DebugOutputStream psesStream = new( + FullDuplexStream.Splice(stdout.BaseStream, stdin.BaseStream) + ); + + /* + PSES follows the following DAP flow: + Receive a Initialize request + Run Initialize handler and send response back + Receive a Launch/Attach request + Run Launch/Attach handler and send response back + PSES sends the initialized event at the end of the Launch/Attach handler + + This is to spec, but the omnisharp client has a flaw where it does not complete the await until after + Server Initialized has been received, when it should in fact return once the Client Initialize (aka + capabilities) response is received. Per the DAP spec, we can send Launch/Attach before Server Initialized + and PSES relies on this behavior, but if we await the standard client initialization From method, it would + deadlock the test because it won't return until Server Initialized is received from PSES, which it won't + send until a launch is sent. + + HACK: To get around this, we abuse the OnInitialized handler to return the client "early" via the + `InitializedLanguageClient` once the Client Initialize response has been received. + see https://github.com/OmniSharp/csharp-language-server-protocol/issues/1408 + */ + Task dapClientInitializeTask = DebugAdapterClient.From(options => + { + options + .WithInput(psesStream) + .WithOutput(psesStream) + // The "early" return mentioned above + .OnInitialized((dapClient, _, _, _) => + { + initializedLanguageClientTcs.SetResult(dapClient); + return Task.CompletedTask; + }) + // This TCS is useful to wait for a breakpoint to be hit + .OnStopped((StoppedEvent e) => + { + TaskCompletionSource currentStoppedTcs = nextStoppedTcs; + nextStoppedTcs = new(); + + currentStoppedTcs.SetResult(e); + }) + .OnRequest("startDebugging", (StartDebuggingAttachRequestArguments request) => + { + startDebuggingAttachRequestTcs.SetResult(request); + return Task.CompletedTask; + }) + .OnTerminated((TerminatedEvent e) => + { + terminatedTcs.SetResult(e); + return Task.CompletedTask; + }) + ; + }); + + // This ensures any unhandled exceptions get addressed if it fails to start before our early return completes. + // Under normal operation the initializedLanguageClient will always return first. + await Task.WhenAny( + initializedLanguageClient, + dapClientInitializeTask + ); + + client = await initializedLanguageClient; + } + + public async Task DisposeAsync() + { + await client.RequestDisconnect(new DisconnectArguments + { + Restart = false, + TerminateDebuggee = true + }); + client?.Dispose(); + psesHost.Stop(); + + scriptLogReader?.Dispose(); //Also disposes the underlying filestream + if (File.Exists(testScriptLogPath)) + { + File.Delete(testScriptLogPath); + } + } + + private static string NewTestFile(string script, bool isPester = false) + { + string fileExt = isPester ? ".Tests.ps1" : ".ps1"; + string filePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + fileExt); + File.WriteAllText(filePath, script); + + return filePath; + } + + /// + /// Given an array of strings, generate a PowerShell script that writes each string to our test script log path + /// so it can be read back later to verify script progress against breakpointing. + /// + /// A list of statements that for which a script will be generated to write each statement to a testing log that can be read by . The strings are double quoted in Powershell, so variables such as $($PSScriptRoot) etc. can be used + /// A script string that should be written to disk and instructed by PSES to execute + /// + private string GenerateLoggingScript(params string[] logStatements) + { + if (logStatements.Length == 0) + { + throw new ArgumentNullException(nameof(logStatements), "Expected at least one argument."); + } + + // Clean up side effects from other test runs. + if (File.Exists(testScriptLogPath)) + { + File.Delete(testScriptLogPath); + } + + // Have script create file first with `>` (but don't rely on overwriting). + // NOTE: We uses double quotes so that we can use PowerShell variables. + StringBuilder builder = new StringBuilder() + .Append("Write-Output \"") + .Append(logStatements[0]) + .Append("\" > '") + .Append(testScriptLogPath) + .AppendLine("'"); + + for (int i = 1; i < logStatements.Length; i++) + { + // Then append to that script with `>>`. + builder + .Append("Write-Output \"") + .Append(logStatements[i]) + .Append("\" >> '") + .Append(testScriptLogPath) + .AppendLine("'"); + } + + output.WriteLine("Script is:"); + output.WriteLine(builder.ToString()); + return builder.ToString(); + } + + /// + /// Reads the next output line from the test script log file. Useful in assertions to verify script progress against breakpointing. + /// + private async Task ReadScriptLogLineAsync() + { + while (scriptLogReader is null) + { + try + { + scriptLogReader = new StreamReader( + new FileStream( + testScriptLogPath, + FileMode.OpenOrCreate, + FileAccess.Read, // Because we use append, its OK to create the file ahead of the script + FileShare.ReadWrite + ) + ); + } + catch (IOException) //Sadly there does not appear to be a xplat way to wait for file availability, but luckily this does not appear to fire often. + { + await Task.Delay(500); + } + } + + // return valid lines only + string nextLine = string.Empty; + while (nextLine is null || nextLine.Length == 0) + { + nextLine = await scriptLogReader.ReadLineAsync(); //Might return null if at EOF because we created it above but the script hasn't written to it yet + } + return nextLine; + } + + [Fact] + public void CanInitializeWithCorrectServerSettings() + { + Assert.True(client.ServerSettings.SupportsConditionalBreakpoints); + Assert.True(client.ServerSettings.SupportsConfigurationDoneRequest); + Assert.True(client.ServerSettings.SupportsFunctionBreakpoints); + Assert.True(client.ServerSettings.SupportsHitConditionalBreakpoints); + Assert.True(client.ServerSettings.SupportsLogPoints); + Assert.True(client.ServerSettings.SupportsSetVariable); + Assert.True(client.ServerSettings.SupportsDelayedStackTraceLoading); + } + + [Fact] + public async Task UsesDotSourceOperatorAndQuotesAsync() + { + string filePath = NewTestFile(GenerateLoggingScript("$($MyInvocation.Line)")); + await client.LaunchScript(filePath); + ConfigurationDoneResponse configDoneResponse = await client.RequestConfigurationDone(new ConfigurationDoneArguments()); + Assert.NotNull(configDoneResponse); + + string actual = await ReadScriptLogLineAsync(); + Assert.StartsWith(". '", actual); + } + + [Fact] + public async Task UsesCallOperatorWithSettingAsync() + { + string filePath = NewTestFile(GenerateLoggingScript("$($MyInvocation.Line)")); + await client.LaunchScript(filePath, executeMode: "Call"); + ConfigurationDoneResponse configDoneResponse = await client.RequestConfigurationDone(new ConfigurationDoneArguments()); + Assert.NotNull(configDoneResponse); + + string actual = await ReadScriptLogLineAsync(); + Assert.StartsWith("& '", actual); + } + + [Fact] + public async Task CanLaunchScriptWithNoBreakpointsAsync() + { + string filePath = NewTestFile(GenerateLoggingScript("works")); + + await client.LaunchScript(filePath); + + ConfigurationDoneResponse configDoneResponse = await client.RequestConfigurationDone(new ConfigurationDoneArguments()); + Assert.NotNull(configDoneResponse); + + string actual = await ReadScriptLogLineAsync(); + Assert.Equal("works", actual); + } + + [SkippableFact] + public async Task CanSetBreakpointsAsync() + { + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode, + "Breakpoints can't be set in Constrained Language Mode."); + + string filePath = NewTestFile(GenerateLoggingScript( + "before breakpoint", + "at breakpoint", + "after breakpoint" + )); + + await client.LaunchScript(filePath); + + // {"command":"setBreakpoints","arguments":{"source":{"name":"dfsdfg.ps1","path":"/Users/tyleonha/Code/PowerShell/Misc/foo/dfsdfg.ps1"},"lines":[2],"breakpoints":[{"line":2}],"sourceModified":false},"type":"request","seq":3} + SetBreakpointsResponse setBreakpointsResponse = await client.SetBreakpoints(new SetBreakpointsArguments + { + Source = new Source { Name = Path.GetFileName(filePath), Path = filePath }, + Breakpoints = new SourceBreakpoint[] { new SourceBreakpoint { Line = 2 } }, + SourceModified = false, + }); + + Breakpoint breakpoint = setBreakpointsResponse.Breakpoints.First(); + Assert.True(breakpoint.Verified); + Assert.Equal(filePath, breakpoint.Source.Path, ignoreCase: s_isWindows); + Assert.Equal(2, breakpoint.Line); + + ConfigurationDoneResponse configDoneResponse = await client.RequestConfigurationDone(new ConfigurationDoneArguments()); + Assert.NotNull(configDoneResponse); + + // Wait until we hit the breakpoint + StoppedEvent stoppedEvent = await nextStopped; + Assert.Equal("breakpoint", stoppedEvent.Reason); + + // The code before the breakpoint should have already run + Assert.Equal("before breakpoint", await ReadScriptLogLineAsync()); + + // Assert that the stopped breakpoint is the one we set + StackTraceResponse stackTraceResponse = await client.RequestStackTrace(new StackTraceArguments { ThreadId = 1 }); + DapStackFrame stoppedTopFrame = stackTraceResponse.StackFrames.First(); + Assert.Equal(2, stoppedTopFrame.Line); + + _ = await client.RequestContinue(new ContinueArguments { ThreadId = 1 }); + + string atBreakpointActual = await ReadScriptLogLineAsync(); + Assert.Equal("at breakpoint", atBreakpointActual); + + string afterBreakpointActual = await ReadScriptLogLineAsync(); + Assert.Equal("after breakpoint", afterBreakpointActual); + } + + [SkippableFact] + public async Task FailsIfStacktraceRequestedWhenNotPaused() + { + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode, + "Breakpoints can't be set in Constrained Language Mode."); + + // We want a long running script that never hits the next breakpoint + string filePath = NewTestFile(GenerateLoggingScript( + "$(sleep 10)", + "Should fail before we get here" + )); + + await client.SetBreakpoints( + new SetBreakpointsArguments + { + Source = new Source { Name = Path.GetFileName(filePath), Path = filePath }, + Breakpoints = new SourceBreakpoint[] { new SourceBreakpoint { Line = 1 } }, + SourceModified = false, + } + ); + + // Signal to start the script + await client.RequestConfigurationDone(new ConfigurationDoneArguments()); + await client.LaunchScript(filePath); + + // Try to get the stacktrace, which should throw as we are not currently at a breakpoint. + await Assert.ThrowsAsync(() => client.RequestStackTrace( + new StackTraceArguments { } + )); + } + + [SkippableFact] + public async Task SendsInitialLabelBreakpointForPerformanceReasons() + { + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode, + "Breakpoints can't be set in Constrained Language Mode."); + string filePath = NewTestFile(GenerateLoggingScript( + "before breakpoint", + "label breakpoint" + )); + + // Request a launch. Note that per DAP spec, launch doesn't actually begin until ConfigDone finishes. + await client.LaunchScript(filePath); + + SetBreakpointsResponse setBreakpointsResponse = await client.SetBreakpoints(new SetBreakpointsArguments + { + Source = new Source { Name = Path.GetFileName(filePath), Path = filePath }, + Breakpoints = new SourceBreakpoint[] { new SourceBreakpoint { Line = 2 } }, + SourceModified = false, + }); + + Breakpoint breakpoint = setBreakpointsResponse.Breakpoints.First(); + Assert.True(breakpoint.Verified); + Assert.Equal(filePath, breakpoint.Source.Path, ignoreCase: s_isWindows); + Assert.Equal(2, breakpoint.Line); + + _ = client.RequestConfigurationDone(new ConfigurationDoneArguments()); + + // Wait for the breakpoint to be hit + StoppedEvent stoppedEvent = await nextStopped; + Assert.Equal("breakpoint", stoppedEvent.Reason); + + // The code before the breakpoint should have already run + Assert.Equal("before breakpoint", await ReadScriptLogLineAsync()); + + // Get the stacktrace for the breakpoint + StackTraceResponse stackTraceResponse = await client.RequestStackTrace( + new StackTraceArguments { ThreadId = 1 } + ); + DapStackFrame firstFrame = stackTraceResponse.StackFrames.First(); + + // Our synthetic label breakpoint should be present + Assert.Equal( + StackFramePresentationHint.Label, + firstFrame.PresentationHint + ); + } + + // This is a regression test for a bug where user code causes a new synchronization context + // to be created, breaking the extension. It's most evident when debugging PowerShell + // scripts that use System.Windows.Forms. It required fixing both Editor Services and + // OmniSharp. + // + // This test depends on PowerShell being able to load System.Windows.Forms, which only works + // reliably with Windows PowerShell. It works with PowerShell Core in the real-world; + // however, our host executable is xUnit, not PowerShell. So by restricting to Windows + // PowerShell, we avoid all issues with our test project (and the xUnit executable) not + // having System.Windows.Forms deployed, and can instead rely on the Windows Global Assembly + // Cache (GAC) to find it. + [SkippableFact] + public async Task CanStepPastSystemWindowsForms() + { + Skip.IfNot(PsesStdioLanguageServerProcessHost.IsWindowsPowerShell, + "Windows Forms requires Windows PowerShell."); + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode, + "Breakpoints can't be set in Constrained Language Mode."); + + string filePath = NewTestFile(string.Join(Environment.NewLine, new[] + { + "Add-Type -AssemblyName System.Windows.Forms", + "$global:form = New-Object System.Windows.Forms.Form", + "Write-Host $form" + })); + + await client.LaunchScript(filePath); + + SetFunctionBreakpointsResponse setBreakpointsResponse = await client.SetFunctionBreakpoints( + new SetFunctionBreakpointsArguments + { + Breakpoints = new FunctionBreakpoint[] + { new FunctionBreakpoint { Name = "Write-Host", } } + }); + + Breakpoint breakpoint = setBreakpointsResponse.Breakpoints.First(); + Assert.True(breakpoint.Verified); + + ConfigurationDoneResponse configDoneResponse = await client.RequestConfigurationDone(new ConfigurationDoneArguments()); + Assert.NotNull(configDoneResponse); + await Task.Delay(5000); + + VariablesResponse variablesResponse = await client.RequestVariables( + new VariablesArguments { VariablesReference = 1 }); + + Variable form = variablesResponse.Variables.FirstOrDefault(v => v.Name == "$form"); + Assert.NotNull(form); + Assert.Equal("System.Windows.Forms.Form, Text: ", form.Value); + } + + // This tests the edge-case where a raw script (or an untitled script) has the last line + // commented. Since in some cases (such as Windows PowerShell, or the script not having a + // backing ScriptFile) we just wrap the script with braces, we had a bug where the last + // brace would be after the comment. We had to ensure we wrapped with newlines instead. + [Fact] + public async Task CanLaunchScriptWithCommentedLastLineAsync() + { + string script = GenerateLoggingScript("$($MyInvocation.Line)", "$(1+1)") + "# a comment at the end"; + Assert.EndsWith(Environment.NewLine + "# a comment at the end", script); + + // NOTE: This is horribly complicated, but the "script" parameter here is assigned to + // PsesLaunchRequestArguments.Script, which is then assigned to + // DebugStateService.ScriptToLaunch in that handler, and finally used by the + // ConfigurationDoneHandler in LaunchScriptAsync. + await client.LaunchScript(script); + + _ = await client.RequestConfigurationDone(new ConfigurationDoneArguments()); + + // We can check that the script was invoked as expected, which is to dot-source a script + // block with the contents surrounded by newlines. While we can't check that the last + // line was a curly brace by itself, we did check that the contents ended with a + // comment, so if this output exists then the bug did not recur. + Assert.Equal(". {", await ReadScriptLogLineAsync()); + + // Verifies that the script did run and the body was evaluated + Assert.Equal("2", await ReadScriptLogLineAsync()); + } + + [SkippableFact] + public async Task CanRunPesterTestFile() + { + Skip.If(true, "Pester test is broken."); + /* TODO: Get this to work on Windows. + string pesterLog = Path.Combine(s_binDir, Path.GetRandomFileName() + ".log"); + + string testCommand = @" + Start-Transcript -Path '" + pesterLog + @"' + Install-Module -Name Pester -RequiredVersion 5.3.3 -Force -PassThru | Write-Host + Import-Module -Name Pester -RequiredVersion 5.3.3 -PassThru | Write-Host + Get-Content '" + pesterTest + @"' + Stop-Transcript"; + + using CancellationTokenSource cts = new(5000); + while (!File.Exists(pesterLog) && !cts.Token.IsCancellationRequested) + { + await Task.Delay(1000); + } + await Task.Delay(15000); + output.WriteLine(File.ReadAllText(pesterLog)); + */ + + string pesterTest = NewTestFile(@" + Describe 'A' { + Context 'B' { + It 'C' { + { throw 'error' } | Should -Throw + } + It 'D' { + " + GenerateLoggingScript("pester") + @" + } + } + }", isPester: true); + + await client.LaunchScript($"Invoke-Pester -Script '{pesterTest}'"); + await client.RequestConfigurationDone(new ConfigurationDoneArguments()); + Assert.Equal("pester", await ReadScriptLogLineAsync()); + } + +#nullable enable + [InlineData("", null, null, 0, 0, null)] + [InlineData("-ProcessId 1234 -RunspaceId 5678", null, null, 1234, 5678, null)] + [InlineData("-ProcessId 1234 -RunspaceId 5678 -ComputerName comp", "comp", null, 1234, 5678, null)] + [InlineData("-CustomPipeName testpipe -RunspaceName rs-name", null, "testpipe", 0, 0, "rs-name")] + [SkippableTheory] + public async Task CanLaunchScriptWithNewChildAttachSession( + string paramString, + string? expectedComputerName, + string? expectedPipeName, + int expectedProcessId, + int expectedRunspaceId, + string? expectedRunspaceName) + { + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode, + "PowerShellEditorServices.Command is not signed to run FLM in Constrained Language Mode."); + + string script = NewTestFile($"Start-DebugAttachSession {paramString}"); + + using CancellationTokenSource timeoutCts = new(30000); + using CancellationTokenRegistration _ = timeoutCts.Token.Register(() => + { + startDebuggingAttachRequestTcs.TrySetCanceled(); + }); + using CancellationTokenRegistration _2 = timeoutCts.Token.Register(() => + { + terminatedTcs.TrySetCanceled(); + }); + + await client.LaunchScript(script); + await client.RequestConfigurationDone(new ConfigurationDoneArguments()); + + StartDebuggingAttachRequestArguments attachRequest = await startDebuggingAttachRequestTcs.Task; + Assert.Equal("attach", attachRequest.Request); + Assert.Equal(expectedComputerName, attachRequest.Configuration.ComputerName); + Assert.Equal(expectedPipeName, attachRequest.Configuration.CustomPipeName); + Assert.Equal(expectedProcessId, attachRequest.Configuration.ProcessId); + Assert.Equal(expectedRunspaceId, attachRequest.Configuration.RunspaceId); + Assert.Equal(expectedRunspaceName, attachRequest.Configuration.RunspaceName); + + await terminatedTcs.Task; + } + + [SkippableFact] + public async Task CanLaunchScriptWithNewChildAttachSessionAsJob() + { + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode, + "PowerShellEditorServices.Command is not signed to run FLM in Constrained Language Mode."); + Skip.If(PsesStdioLanguageServerProcessHost.IsWindowsPowerShell, + "WinPS does not have ThreadJob, needed by -AsJob, present by default."); + + string script = NewTestFile("Start-DebugAttachSession -AsJob | Receive-Job -Wait -AutoRemoveJob"); + + using CancellationTokenSource timeoutCts = new(30000); + using CancellationTokenRegistration _1 = timeoutCts.Token.Register(() => + { + startDebuggingAttachRequestTcs.TrySetCanceled(); + }); + using CancellationTokenRegistration _2 = timeoutCts.Token.Register(() => + { + terminatedTcs.TrySetCanceled(); + }); + + await client.LaunchScript(script); + await client.RequestConfigurationDone(new ConfigurationDoneArguments()); + + StartDebuggingAttachRequestArguments attachRequest = await startDebuggingAttachRequestTcs.Task; + Assert.Equal("attach", attachRequest.Request); + Assert.Null(attachRequest.Configuration.ComputerName); + Assert.Null(attachRequest.Configuration.CustomPipeName); + Assert.Equal(0, attachRequest.Configuration.ProcessId); + Assert.Equal(0, attachRequest.Configuration.RunspaceId); + Assert.Null(attachRequest.Configuration.RunspaceName); + + await terminatedTcs.Task; + } + + [SkippableFact] + public async Task CanAttachScriptWithPathMappings() + { + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode, + "Breakpoints can't be set in Constrained Language Mode."); + + string[] logStatements = ["$PSCommandPath", "after breakpoint"]; + + await RunWithAttachableProcess(logStatements, async (filePath, processId, runspaceId) => + { + string localParent = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + string localScriptPath = Path.Combine(localParent, Path.GetFileName(filePath)); + Directory.CreateDirectory(localParent); + File.Copy(filePath, localScriptPath); + + Task nextStoppedTask = nextStopped; + + AttachResponse attachResponse = await client.Attach( + new PsesAttachRequestArguments + { + ProcessId = processId, + RunspaceId = runspaceId, + PathMappings = [ + new() + { + LocalRoot = localParent + Path.DirectorySeparatorChar, + RemoteRoot = Path.GetDirectoryName(filePath) + Path.DirectorySeparatorChar + } + ] + }) ?? throw new Exception("Attach response was null."); + Assert.NotNull(attachResponse); + + SetBreakpointsResponse setBreakpointsResponse = await client.SetBreakpoints(new SetBreakpointsArguments + { + Source = new Source { Name = Path.GetFileName(localScriptPath), Path = localScriptPath }, + Breakpoints = new SourceBreakpoint[] { new SourceBreakpoint { Line = 2 } }, + SourceModified = false, + }); + + Breakpoint breakpoint = setBreakpointsResponse.Breakpoints.First(); + Assert.True(breakpoint.Verified); + Assert.NotNull(breakpoint.Source); + Assert.Equal(localScriptPath, breakpoint.Source.Path, ignoreCase: s_isWindows); + Assert.Equal(2, breakpoint.Line); + + ConfigurationDoneResponse configDoneResponse = await client.RequestConfigurationDone(new ConfigurationDoneArguments()); + Assert.NotNull(configDoneResponse); + + // Wait-Debugger stop + StoppedEvent stoppedEvent = await nextStoppedTask; + Assert.Equal("step", stoppedEvent.Reason); + Assert.NotNull(stoppedEvent.ThreadId); + + nextStoppedTask = nextStopped; + + // It is important we wait for the stack trace before continue. + // The stopped event starts to get the stack trace info in the + // background and requesting the stack trace is the only way to + // ensure it is done and won't conflict with the continue request. + await client.RequestStackTrace(new StackTraceArguments { ThreadId = (int)stoppedEvent.ThreadId }); + await client.RequestContinue(new ContinueArguments { ThreadId = (int)stoppedEvent.ThreadId }); + + // Wait until we hit the breakpoint + stoppedEvent = await nextStoppedTask; + Assert.Equal("breakpoint", stoppedEvent.Reason); + Assert.NotNull(stoppedEvent.ThreadId); + + // The code before the breakpoint should have already run + // It will contain the actual script being run + string beforeBreakpointActual = await ReadScriptLogLineAsync(); + Assert.Equal(filePath, beforeBreakpointActual); + + // Assert that the stopped breakpoint is the one we set + StackTraceResponse stackTraceResponse = await client.RequestStackTrace(new StackTraceArguments { ThreadId = (int)stoppedEvent.ThreadId }); + DapStackFrame? stoppedTopFrame = stackTraceResponse.StackFrames?.First(); + + // The top frame should have a source path of our local script. + Assert.NotNull(stoppedTopFrame); + Assert.Equal(2, stoppedTopFrame.Line); + Assert.NotNull(stoppedTopFrame.Source); + Assert.Equal(localScriptPath, stoppedTopFrame.Source.Path, ignoreCase: s_isWindows); + + await client.RequestContinue(new ContinueArguments { ThreadId = 1 }); + + string afterBreakpointActual = await ReadScriptLogLineAsync(); + Assert.Equal("after breakpoint", afterBreakpointActual); + }); + } + + private async Task RunWithAttachableProcess(string[] logStatements, Func action) + { + /* + There is no public API in pwsh to wait for an attach event. We + use reflection to wait until the AvailabilityChanged event is + subscribed to by Debug-Runspace as a marker that it is ready to + continue. + + We also run the test script in another runspace as WinPS' + Debug-Runspace will break on the first statement after the + attach and we want that to be the Wait-Debugger call. + + We can use https://github.com/PowerShell/PowerShell/pull/25788 + once that is merged and we are running against that version but + WinPS will always need this. + */ + string scriptEntrypoint = @" + param([string]$TestScript) + + $debugRunspaceCmd = Get-Command Debug-Runspace -Module Microsoft.PowerShell.Utility + $runspaceBase = [PSObject].Assembly.GetType( + 'System.Management.Automation.Runspaces.RunspaceBase') + $availabilityChangedField = $runspaceBase.GetField( + 'AvailabilityChanged', + [System.Reflection.BindingFlags]'NonPublic, Instance') + if (-not $availabilityChangedField) { + throw 'Failed to get AvailabilityChanged event field' + } + + $ps = [PowerShell]::Create() + $runspace = $ps.Runspace + + # Wait-Debugger is needed in WinPS to sync breakpoints before + # running the script. + $null = $ps.AddCommand('Wait-Debugger').AddStatement() + $null = $ps.AddCommand($TestScript) + + # Let the runner know what Runspace to attach to and that it + # is ready to run. + 'RID: {0}' -f $runspace.Id + + $start = Get-Date + while ($true) { + $subscribed = $availabilityChangedField.GetValue($runspace) | + Where-Object Target -is $debugRunspaceCmd.ImplementingType + if ($subscribed) { + break + } + + if (((Get-Date) - $start).TotalSeconds -gt 10) { + throw 'Timeout waiting for Debug-Runspace to be subscribed.' + } + } + + $ps.Invoke() + foreach ($e in $ps.Streams.Error) { + Write-Error -ErrorRecord $e + } + + # Keep running until the runner has deleted the test script to + # ensure the process doesn't finish before the test does in + # normal circumstances. + while (Test-Path -LiteralPath $TestScript) { + Start-Sleep -Seconds 1 + } + "; + + string filePath = NewTestFile(GenerateLoggingScript(logStatements)); + string encArgs = CreatePwshEncodedArgs(filePath); + string encCommand = Convert.ToBase64String(Encoding.Unicode.GetBytes(scriptEntrypoint)); + + ProcessStartInfo psi = new ProcessStartInfo + { + FileName = PsesStdioLanguageServerProcessHost.PwshExe, + Arguments = $"-NoLogo -NoProfile -EncodedCommand {encCommand} -EncodedArguments {encArgs}", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + psi.EnvironmentVariables["TERM"] = "dumb"; // Avoids color/VT sequences in test output. + + TaskCompletionSource ridOutput = new(); + + // Task shouldn't take longer than 30 seconds to complete. + using CancellationTokenSource debugTaskCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + using CancellationTokenRegistration _ = debugTaskCts.Token.Register(ridOutput.SetCanceled); + using Process? psProc = Process.Start(psi); + try + { + Assert.NotNull(psProc); + psProc.OutputDataReceived += (sender, args) => + { + if (!string.IsNullOrEmpty(args.Data)) + { + if (args.Data.StartsWith("RID: ")) + { + int rid = int.Parse(args.Data.Substring(5)); + ridOutput.SetResult(rid); + } + + output.WriteLine("STDOUT: {0}", args.Data); + } + }; + psProc.ErrorDataReceived += (sender, args) => + { + if (!string.IsNullOrEmpty(args.Data)) + { + output.WriteLine("STDERR: {0}", args.Data); + } + }; + psProc.EnableRaisingEvents = true; + psProc.BeginOutputReadLine(); + psProc.BeginErrorReadLine(); + + Task procExited = psProc.WaitForExitAsync(debugTaskCts.Token); + Task waitRid = ridOutput.Task; + + // Wait for the process to fail or the script to start. + Task finishedTask = await Task.WhenAny(waitRid, procExited); + if (finishedTask == procExited) + { + await procExited; + Assert.Fail("The attached process exited before the PowerShell entrypoint could start."); + } + int rid = await waitRid; + + Task debugTask = action(filePath, psProc.Id, rid); + finishedTask = await Task.WhenAny(procExited, debugTask); + if (finishedTask == procExited) + { + await procExited; + Assert.Fail("Attached process exited before the script could start."); + } + + await debugTask; + + File.Delete(filePath); + psProc.Kill(); + await procExited; + } + catch + { + if (psProc is not null && !psProc.HasExited) + { + psProc.Kill(); + } + + throw; + } + } + + private static string CreatePwshEncodedArgs(params string[] args) + { + // Only way to pass args to -EncodedCommand is to use CLIXML with + // -EncodedArguments. Not pretty but the structure isn't too + // complex and saves us trying to embed/escape strings in a script. + string clixmlNamespace = "http://schemas.microsoft.com/powershell/2004/04"; + string clixml = new XDocument( + new XDeclaration("1.0", "utf-16", "yes"), + new XElement(XName.Get("Objs", clixmlNamespace), + new XAttribute("Version", "1.1.0.1"), + new XElement(XName.Get("Obj", clixmlNamespace), + new XAttribute("RefId", "0"), + new XElement(XName.Get("TN", clixmlNamespace), + new XAttribute("RefId", "0"), + new XElement(XName.Get("T", clixmlNamespace), "System.Collections.ArrayList"), + new XElement(XName.Get("T", clixmlNamespace), "System.Object") + ), + new XElement(XName.Get("LST", clixmlNamespace), + args.Select(s => new XElement(XName.Get("S", clixmlNamespace), s)) + ) + ))).ToString(SaveOptions.DisableFormatting); + + return Convert.ToBase64String(Encoding.Unicode.GetBytes(clixml)); + } + + private record StartDebuggingAttachRequestArguments(PsesAttachRequestArguments Configuration, string Request); + } +} diff --git a/test/PowerShellEditorServices.Test.E2E/Hosts/DebugOutputStream.cs b/test/PowerShellEditorServices.Test.E2E/Hosts/DebugOutputStream.cs new file mode 100644 index 0000000..70ffdbc --- /dev/null +++ b/test/PowerShellEditorServices.Test.E2E/Hosts/DebugOutputStream.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#nullable enable + +using System.Diagnostics; +using System.IO; +using System.Text; +using Nerdbank.Streams; + +namespace PowerShellEditorServices.Test.E2E; + +/// +/// A stream that logs all data read and written to the debug stream which is visible in the debug console when a +/// debugger is attached. +/// +internal class DebugOutputStream : MonitoringStream +{ + public DebugOutputStream(Stream? underlyingStream) + : base(underlyingStream ?? new MemoryStream()) + { + DidRead += (_, segment) => + { + if (segment.Array is null) { return; } + LogData("⬅️", segment.Array, segment.Offset, segment.Count); + }; + + DidWrite += (_, segment) => + { + if (segment.Array is null) { return; } + LogData("➡️", segment.Array, segment.Offset, segment.Count); + }; + } + + private static void LogData(string header, byte[] buffer, int offset, int count) + { + // If debugging, the raw traffic will be visible in the debug console + if (Debugger.IsAttached) + { + string data = Encoding.UTF8.GetString(buffer, offset, count); + Debug.WriteLine($"{header} {data}"); + } + } +} diff --git a/test/PowerShellEditorServices.Test.E2E/Hosts/IAsyncLanguageServerHost.cs b/test/PowerShellEditorServices.Test.E2E/Hosts/IAsyncLanguageServerHost.cs new file mode 100644 index 0000000..11ea57e --- /dev/null +++ b/test/PowerShellEditorServices.Test.E2E/Hosts/IAsyncLanguageServerHost.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#nullable enable + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace PowerShellEditorServices.Test.E2E; + +/// +/// Represents a debug adapter server host that can be started and stopped and provides streams for communication. +/// +public interface IAsyncLanguageServerHost : IAsyncDisposable +{ + // Start the host and return when the host is ready to communicate. It should return a tuple of a stream Reader and stream Writer for communication with the LSP. The underlying streams can be retrieved via baseStream propertyif needed. + Task<(StreamReader, StreamWriter)> Start(CancellationToken token = default); + // Stops the host and returns when the host has fully stopped. It should be idempotent, such that if called while the host is already stopping/stopped, it will have the same result + Task Stop(CancellationToken token = default); + + // Optional to implement if more is required than a simple stop + async ValueTask IAsyncDisposable.DisposeAsync() => await Stop(); +} diff --git a/test/PowerShellEditorServices.Test.E2E/Hosts/IDebugAdapterClientExtensions.cs b/test/PowerShellEditorServices.Test.E2E/Hosts/IDebugAdapterClientExtensions.cs new file mode 100644 index 0000000..e37be26 --- /dev/null +++ b/test/PowerShellEditorServices.Test.E2E/Hosts/IDebugAdapterClientExtensions.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Handlers; +using OmniSharp.Extensions.DebugAdapter.Protocol.Client; +using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; + +namespace PowerShellEditorServices.Test.E2E +{ + public static class IDebugAdapterClientExtensions + { + public static async Task LaunchScript(this IDebugAdapterClient debugAdapterClient, string script, string executeMode = "DotSource") + { + _ = await debugAdapterClient.Launch( + new PsesLaunchRequestArguments + { + NoDebug = false, + Script = script, + Cwd = "", + CreateTemporaryIntegratedConsole = false, + ExecuteMode = executeMode, + }) ?? throw new Exception("Launch response was null."); + } + } +} diff --git a/test/PowerShellEditorServices.Test.E2E/Hosts/PsesStdioLanguageServerProcessHost.cs b/test/PowerShellEditorServices.Test.E2E/Hosts/PsesStdioLanguageServerProcessHost.cs new file mode 100644 index 0000000..3c58e67 --- /dev/null +++ b/test/PowerShellEditorServices.Test.E2E/Hosts/PsesStdioLanguageServerProcessHost.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; + +namespace PowerShellEditorServices.Test.E2E; + +/// +/// A is responsible for launching or attaching to a language server, providing access to its input and output streams, and tracking its lifetime. +/// +internal class PsesStdioLanguageServerProcessHost(bool isDebugAdapter) +: StdioLanguageServerProcessHost(PwshExe, GeneratePsesArguments(isDebugAdapter)) +{ + protected static readonly string s_binDir = + Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + + private static readonly string s_bundledModulePath = new FileInfo(Path.Combine( + s_binDir, "..", "..", "..", "..", "..", "module")).FullName; + + private static readonly string s_sessionDetailsPath = Path.Combine( + s_binDir, $"pses_test_sessiondetails_{Path.GetRandomFileName()}"); + + private static readonly string s_logPath = Path.Combine( + s_binDir, $"pses_test_logs_{Path.GetRandomFileName()}"); + + private const string s_logLevel = "Diagnostic"; + private static readonly string[] s_featureFlags = { "PSReadLine" }; + private const string s_hostName = "TestHost"; + private const string s_hostProfileId = "TestHost"; + private const string s_hostVersion = "1.0.0"; + + // Adjust the environment variable if wanting to test with 5.1 or a specific pwsh path + public static string PwshExe { get; } = Environment.GetEnvironmentVariable("PWSH_EXE_NAME") ?? "pwsh"; + public static bool IsWindowsPowerShell { get; } = PwshExe.EndsWith("powershell"); + public static bool RunningInConstrainedLanguageMode { get; } = + Environment.GetEnvironmentVariable("__PSLockdownPolicy", EnvironmentVariableTarget.Machine) != null; + + private static string[] GeneratePsesArguments(bool isDebugAdapter) + { + List args = new() + { + "&", + SingleQuoteEscape(Path.Combine(s_bundledModulePath, "PowerShellEditorServices", "Start-EditorServices.ps1")), + "-LogPath", + SingleQuoteEscape(s_logPath), + "-LogLevel", + s_logLevel, + "-SessionDetailsPath", + SingleQuoteEscape(s_sessionDetailsPath), + "-FeatureFlags", + string.Join(',', s_featureFlags), + "-HostName", + s_hostName, + "-HostProfileId", + s_hostProfileId, + "-HostVersion", + s_hostVersion, + "-BundledModulesPath", + SingleQuoteEscape(s_bundledModulePath), + "-Stdio" + }; + + if (isDebugAdapter) + { + args.Add("-DebugServiceOnly"); + } + + string base64Str = Convert.ToBase64String( + System.Text.Encoding.Unicode.GetBytes(string.Join(' ', args))); + + return + [ + "-NoLogo", + "-NoProfile", + "-EncodedCommand", + base64Str + ]; + } + + private static string SingleQuoteEscape(string str) => $"'{str.Replace("'", "''")}'"; +} diff --git a/test/PowerShellEditorServices.Test.E2E/Hosts/StdioLanguageServerProcessHost.cs b/test/PowerShellEditorServices.Test.E2E/Hosts/StdioLanguageServerProcessHost.cs new file mode 100644 index 0000000..447ae9b --- /dev/null +++ b/test/PowerShellEditorServices.Test.E2E/Hosts/StdioLanguageServerProcessHost.cs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace PowerShellEditorServices.Test.E2E; + +/// +/// Hosts a language server process that communicates over stdio +/// +internal class StdioLanguageServerProcessHost(string fileName, IEnumerable argumentList) : IAsyncLanguageServerHost +{ + // The PSES process that will be started and managed + private readonly Process process = new() + { + EnableRaisingEvents = true, + StartInfo = new ProcessStartInfo(fileName, argumentList) + { + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + // Track the state of the startup + private TaskCompletionSource<(StreamReader, StreamWriter)>? startTcs; + private TaskCompletionSource? stopTcs; + + // Starts the process. Returns when the process has started and streams are available. + public async Task<(StreamReader, StreamWriter)> Start(CancellationToken token = default) + { + // Runs this once upon process exit to clean up the state. + EventHandler? exitHandler = null; + exitHandler = (sender, e) => + { + // Complete the stopTcs task when the process finally exits, allowing stop to complete + stopTcs?.TrySetResult(true); + stopTcs = null; + startTcs = null; + process.Exited -= exitHandler; + }; + process.Exited += exitHandler; + + if (stopTcs is not null) + { + throw new InvalidOperationException("The process is currently stopping and cannot be started."); + } + + // Await the existing task if we have already started, making this operation idempotent + if (startTcs is not null) + { + return await startTcs.Task; + } + + // Initiate a new startTcs to track the startup + startTcs = new(); + + token.ThrowIfCancellationRequested(); + + // Should throw if there are any startup problems such as invalid path, etc. + process.Start(); + + // According to the source the streams should be allocated synchronously after the process has started, however it's not super clear so we will put this here in case there is an explicit race condition. + if (process.StandardInput.BaseStream is null || process.StandardOutput.BaseStream is null) + { + throw new InvalidOperationException("The process has started but the StandardInput or StandardOutput streams are not available. This should never happen and is probably a race condition, please report it to PowerShellEditorServices."); + } + + startTcs.SetResult(( + process.StandardOutput, + process.StandardInput + )); + + // Return the result of the completion task + return await startTcs.Task; + } + + public async Task WaitForExit(CancellationToken token = default) + { + AssertStarting(); + await process.WaitForExitAsync(token); + } + + /// + /// Determines if the process is in the starting state and throws if not. + /// + private void AssertStarting() + { + if (startTcs is null) + { + throw new InvalidOperationException("The process is not starting/started, use Start() first."); + } + } + + public async Task Stop(CancellationToken token = default) + { + AssertStarting(); + if (stopTcs is not null) + { + return await stopTcs.Task; + } + stopTcs = new(); + token.ThrowIfCancellationRequested(); + process.Kill(); + await process.WaitForExitAsync(token); + return true; + } +} diff --git a/test/PowerShellEditorServices.Test.E2E/LSPTestsFixtures.cs b/test/PowerShellEditorServices.Test.E2E/LSPTestsFixtures.cs new file mode 100644 index 0000000..6eb8e45 --- /dev/null +++ b/test/PowerShellEditorServices.Test.E2E/LSPTestsFixtures.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Logging; +using Microsoft.PowerShell.EditorServices.Services.Configuration; +using Nerdbank.Streams; +using Newtonsoft.Json.Linq; +using OmniSharp.Extensions.LanguageServer.Client; +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Client; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Document; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Window; +using OmniSharp.Extensions.LanguageServer.Protocol.Workspace; +using Xunit; +using Xunit.Abstractions; + +namespace PowerShellEditorServices.Test.E2E +{ + public class LSPTestsFixture : IAsyncLifetime + { + protected static readonly string s_binDir = + Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + + private const bool IsDebugAdapterTests = false; + + public ILanguageClient PsesLanguageClient { get; private set; } + public List Messages = new(); + public List Diagnostics = new(); + internal List TelemetryEvents = new(); + public ITestOutputHelper Output { get; set; } + + internal PsesStdioLanguageServerProcessHost _psesHost = new(IsDebugAdapterTests); + + public async Task InitializeAsync() + { + (StreamReader stdout, StreamWriter stdin) = await _psesHost.Start(); + + // Splice the streams together and enable debug logging of all messages sent and received + DebugOutputStream psesStream = new( + FullDuplexStream.Splice(stdout.BaseStream, stdin.BaseStream) + ); + + DirectoryInfo testDir = + Directory.CreateDirectory(Path.Combine(s_binDir, Path.GetRandomFileName())); + + PsesLanguageClient = LanguageClient.PreInit(options => + { + options + .WithInput(psesStream) + .WithOutput(psesStream) + .WithWorkspaceFolder(DocumentUri.FromFileSystemPath(testDir.FullName), "testdir") + .WithInitializationOptions(new { EnableProfileLoading = false }) + .OnPublishDiagnostics(diagnosticParams => Diagnostics.AddRange(diagnosticParams.Diagnostics.Where(d => d != null))) + .OnLogMessage(logMessageParams => + { + Output?.WriteLine($"{logMessageParams.Type}: {logMessageParams.Message}"); + Messages.Add(logMessageParams); + }) + .OnTelemetryEvent(telemetryEventParams => TelemetryEvents.Add( + new PsesTelemetryEvent + { + EventName = (string)telemetryEventParams.ExtensionData["eventName"], + Data = telemetryEventParams.ExtensionData["data"] as JObject + })); + + // Enable all capabilities this this is for testing. + // This will be a built in feature of the Omnisharp client at some point. + IEnumerable capabilityTypes = typeof(ICapability).Assembly.GetExportedTypes() + .Where(z => typeof(ICapability).IsAssignableFrom(z) && z.IsClass && !z.IsAbstract); + foreach (Type capabilityType in capabilityTypes) + { + options.WithCapability(Activator.CreateInstance(capabilityType, Array.Empty()) as ICapability); + } + }); + + await PsesLanguageClient.Initialize(CancellationToken.None); + + // Make sure Script Analysis is enabled because we'll need it in the tests. + // This also makes sure the configuration is set to default values. + PsesLanguageClient.Workspace.DidChangeConfiguration( + new DidChangeConfigurationParams + { + Settings = JToken.FromObject(new LanguageServerSettingsWrapper + { + Files = new EditorFileSettings(), + Search = new EditorSearchSettings(), + Powershell = new LanguageServerSettings() + }) + }); + } + + public async Task DisposeAsync() + { + await PsesLanguageClient.Shutdown(); + await _psesHost.Stop(); + PsesLanguageClient?.Dispose(); + } + } +} diff --git a/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs b/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs new file mode 100644 index 0000000..81bc88f --- /dev/null +++ b/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs @@ -0,0 +1,1304 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Handlers; +using Microsoft.PowerShell.EditorServices.Services.Configuration; +using Microsoft.PowerShell.EditorServices.Services.PowerShell; +using Newtonsoft.Json.Linq; +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Client; +using OmniSharp.Extensions.LanguageServer.Protocol.Document; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Workspace; +using Xunit; +using Xunit.Abstractions; +using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; + +namespace PowerShellEditorServices.Test.E2E +{ + [Trait("Category", "LSP")] + public class LanguageServerProtocolMessageTests : IClassFixture, IDisposable + { + private static readonly string s_binDir = + Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + private const string testCommand = "Expand-Archive"; + private const string testDescription = "Extracts files from a specified archive (zipped) file."; + + private readonly ILanguageClient PsesLanguageClient; + private readonly List Messages; + private readonly List Diagnostics; + private readonly string PwshExe; + + public LanguageServerProtocolMessageTests(ITestOutputHelper output, LSPTestsFixture data) + { + data.Output = output; + PsesLanguageClient = data.PsesLanguageClient; + Messages = data.Messages; + Messages.Clear(); + Diagnostics = data.Diagnostics; + Diagnostics.Clear(); + PwshExe = PsesStdioLanguageServerProcessHost.PwshExe; + } + + public void Dispose() + { + Diagnostics.Clear(); + GC.SuppressFinalize(this); + } + + private string NewTestFile(string script, bool isPester = false, string languageId = "powershell") + { + string fileExt = isPester ? ".Tests.ps1" : ".ps1"; + string filePath = Path.Combine(s_binDir, Path.GetRandomFileName() + fileExt); + File.WriteAllText(filePath, script); + + PsesLanguageClient.SendNotification("textDocument/didOpen", new DidOpenTextDocumentParams + { + TextDocument = new TextDocumentItem + { + LanguageId = languageId, + Version = 0, + Text = script, + Uri = new Uri(filePath) + } + }); + + // Give PSES a chance to run what it needs to run. + Thread.Sleep(2000); + + return filePath; + } + + private async Task WaitForDiagnosticsAsync() + { + // Wait for PSSA to finish. + for (int i = 0; Diagnostics.Count == 0; i++) + { + if (i >= 120) + { + throw new InvalidDataException("No diagnostics showed up after 2 minutes."); + } + + await Task.Delay(1000); + } + } + + [Fact] + public async Task CanSendPowerShellGetVersionRequestAsync() + { + PowerShellVersion details + = await PsesLanguageClient + .SendRequest("powerShell/getVersion", new GetVersionParams()) + .Returning(CancellationToken.None); + + if (PwshExe == "powershell") + { + Assert.Equal("Desktop", details.Edition); + Assert.StartsWith("5", details.Version); + } + else + { + Assert.Equal("Core", details.Edition); + Assert.StartsWith("7", details.Version); + } + } + + [Fact] + public async Task CanSendWorkspaceSymbolRequestAsync() + { + NewTestFile(@" +function CanSendWorkspaceSymbolRequest { + Write-Host 'hello' +} +"); + + Container symbols = await PsesLanguageClient + .SendRequest( + "workspace/symbol", + new WorkspaceSymbolParams + { + Query = "CanSendWorkspaceSymbolRequest" + }) + .Returning>(CancellationToken.None); + + WorkspaceSymbol symbol = Assert.Single(symbols); + Assert.Equal("function CanSendWorkspaceSymbolRequest ()", symbol.Name); + } + + [SkippableFact] + public async Task CanReceiveDiagnosticsFromFileOpenAsync() + { + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode && PsesStdioLanguageServerProcessHost.IsWindowsPowerShell, + "Windows PowerShell doesn't trust PSScriptAnalyzer by default so it won't load."); + + NewTestFile("$a = 4"); + await WaitForDiagnosticsAsync(); + + Diagnostic diagnostic = Assert.Single(Diagnostics); + Assert.Equal("PSUseDeclaredVarsMoreThanAssignments", diagnostic.Code); + } + + [Fact] + public async Task WontReceiveDiagnosticsFromFileOpenThatIsNotPowerShellAsync() + { + NewTestFile("$a = 4", languageId: "plaintext"); + await Task.Delay(2000); + + Assert.Empty(Diagnostics); + } + + [SkippableFact] + public async Task CanReceiveDiagnosticsFromFileChangedAsync() + { + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode && PsesStdioLanguageServerProcessHost.IsWindowsPowerShell, + "Windows PowerShell doesn't trust PSScriptAnalyzer by default so it won't load."); + + string filePath = NewTestFile("$a = 4"); + await WaitForDiagnosticsAsync(); + Diagnostics.Clear(); + + PsesLanguageClient.SendNotification("textDocument/didChange", new DidChangeTextDocumentParams + { + // Include several content changes to test against duplicate Diagnostics showing up. + ContentChanges = new Container(new[] + { + new TextDocumentContentChangeEvent + { + Text = "$a = 5" + }, + new TextDocumentContentChangeEvent + { + Text = "$a = 6" + }, + new TextDocumentContentChangeEvent + { + Text = "$a = 7" + } + }), + TextDocument = new OptionalVersionedTextDocumentIdentifier + { + Version = 4, + Uri = new Uri(filePath) + } + }); + + await WaitForDiagnosticsAsync(); + if (Diagnostics.Count > 1) + { + StringBuilder errorBuilder = new StringBuilder().AppendLine("Multiple diagnostics found when there should be only 1:"); + foreach (Diagnostic diag in Diagnostics) + { + errorBuilder.AppendLine(diag.Message); + } + + Assert.True(Diagnostics.Count == 1, errorBuilder.ToString()); + } + + Diagnostic diagnostic = Assert.Single(Diagnostics); + Assert.Equal("PSUseDeclaredVarsMoreThanAssignments", diagnostic.Code); + } + + [SkippableFact] + public async Task CanReceiveDiagnosticsFromConfigurationChangeAsync() + { + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode && PsesStdioLanguageServerProcessHost.IsWindowsPowerShell, + "Windows PowerShell doesn't trust PSScriptAnalyzer by default so it won't load."); + + PsesLanguageClient.SendNotification("workspace/didChangeConfiguration", + new DidChangeConfigurationParams + { + Settings = JToken.FromObject(new LanguageServerSettingsWrapper + { + Files = new EditorFileSettings(), + Search = new EditorSearchSettings(), + Powershell = new LanguageServerSettings + { + ScriptAnalysis = new ScriptAnalysisSettings + { + Enable = false + } + } + }) + }); + + string filePath = NewTestFile("$a = 4"); + + // Wait a bit to make sure no diagnostics came through + await Task.Delay(2000); + Assert.Empty(Diagnostics); + + // Restore default configuration + PsesLanguageClient.SendNotification("workspace/didChangeConfiguration", + new DidChangeConfigurationParams + { + Settings = JToken.FromObject(new LanguageServerSettingsWrapper + { + Files = new EditorFileSettings(), + Search = new EditorSearchSettings(), + Powershell = new LanguageServerSettings() + }) + }); + + // That notification does not trigger re-analyzing open files. For that we have to send + // a textDocument/didChange notification. + PsesLanguageClient.SendNotification("textDocument/didChange", new DidChangeTextDocumentParams + { + ContentChanges = new Container(), + TextDocument = new OptionalVersionedTextDocumentIdentifier + { + Version = 4, + Uri = new Uri(filePath) + } + }); + + await WaitForDiagnosticsAsync(); + + Diagnostic diagnostic = Assert.Single(Diagnostics); + Assert.Equal("PSUseDeclaredVarsMoreThanAssignments", diagnostic.Code); + } + + [Fact] + public async Task CanSendFoldingRangeRequestAsync() + { + string scriptPath = NewTestFile(@"gci | % { +$_ + +@"" + $_ +""@ +}"); + + Container foldingRanges = + await PsesLanguageClient + .SendRequest( + "textDocument/foldingRange", + new FoldingRangeRequestParam + { + TextDocument = new TextDocumentIdentifier + { + Uri = new Uri(scriptPath) + } + }) + .Returning>(CancellationToken.None); + + Assert.Collection(foldingRanges.OrderBy(f => f.StartLine), + range1 => + { + Assert.Equal(0, range1.StartLine); + Assert.Equal(8, range1.StartCharacter); + Assert.Equal(5, range1.EndLine); + Assert.Equal(1, range1.EndCharacter); + }, + range2 => + { + Assert.Equal(3, range2.StartLine); + Assert.Equal(0, range2.StartCharacter); + Assert.Equal(4, range2.EndLine); + Assert.Equal(2, range2.EndCharacter); + }); + } + + [SkippableFact] + public async Task CanSendFormattingRequestAsync() + { + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode && PsesStdioLanguageServerProcessHost.IsWindowsPowerShell, + "Windows PowerShell doesn't trust PSScriptAnalyzer by default so it won't load."); + + string scriptPath = NewTestFile(@" +gci | % { +Get-Process +} + +"); + + TextEditContainer textEdits = await PsesLanguageClient + .SendRequest( + "textDocument/formatting", + new DocumentFormattingParams + { + TextDocument = new TextDocumentIdentifier + { + Uri = new Uri(scriptPath) + }, + Options = new FormattingOptions + { + TabSize = 4, + InsertSpaces = false + } + }) + .Returning(CancellationToken.None); + + TextEdit textEdit = Assert.Single(textEdits); + + // If we have a tab, formatting ran. + Assert.Contains("\t", textEdit.NewText); + } + + [SkippableFact] + public async Task CanSendRangeFormattingRequestAsync() + { + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode && PsesStdioLanguageServerProcessHost.IsWindowsPowerShell, + "Windows PowerShell doesn't trust PSScriptAnalyzer by default so it won't load."); + + string scriptPath = NewTestFile(@" +gci | % { +Get-Process +} + +"); + + TextEditContainer textEdits = await PsesLanguageClient + .SendRequest( + "textDocument/formatting", + new DocumentRangeFormattingParams + { + Range = new Range + { + Start = new Position + { + Line = 2, + Character = 0 + }, + End = new Position + { + Line = 3, + Character = 0 + } + }, + TextDocument = new TextDocumentIdentifier + { + Uri = new Uri(scriptPath) + }, + Options = new FormattingOptions + { + TabSize = 4, + InsertSpaces = false + } + }) + .Returning(CancellationToken.None); + + TextEdit textEdit = Assert.Single(textEdits); + + // If we have a tab, formatting ran. + Assert.Contains("\t", textEdit.NewText); + } + + [Fact] + public async Task CanSendDocumentSymbolRequestAsync() + { + string scriptPath = NewTestFile(@" +function CanSendDocumentSymbolRequest { + +} + +CanSendDocumentSymbolRequest +"); + + SymbolInformationOrDocumentSymbolContainer symbolInformationOrDocumentSymbols = + await PsesLanguageClient + .SendRequest( + "textDocument/documentSymbol", + new DocumentSymbolParams + { + TextDocument = new TextDocumentIdentifier + { + Uri = new Uri(scriptPath) + } + }) + .Returning(CancellationToken.None); + + Assert.Collection(symbolInformationOrDocumentSymbols, + symInfoOrDocSym => + { + Assert.True(symInfoOrDocSym.IsDocumentSymbol); + Assert.NotNull(symInfoOrDocSym.DocumentSymbol); + DocumentSymbol symbol = symInfoOrDocSym.DocumentSymbol; + + Assert.Equal("function CanSendDocumentSymbolRequest ()", symbol.Name); + Assert.Equal(SymbolKind.Function, symbol.Kind); + + Assert.Equal(1, symbol.Range.Start.Line); + Assert.Equal(0, symbol.Range.Start.Character); + Assert.Equal(3, symbol.Range.End.Line); + Assert.Equal(1, symbol.Range.End.Character); + + Assert.Equal(1, symbol.SelectionRange.Start.Line); + Assert.Equal(9, symbol.SelectionRange.Start.Character); + Assert.Equal(1, symbol.SelectionRange.End.Line); + Assert.Equal(37, symbol.SelectionRange.End.Character); + }); + } + + [Fact] + public async Task CanSendReferencesRequestAsync() + { + string scriptPath = NewTestFile(@" +function CanSendReferencesRequest { + +} + +CanSendReferencesRequest +"); + + LocationContainer locations = await PsesLanguageClient + .SendRequest( + "textDocument/references", + new ReferenceParams + { + TextDocument = new TextDocumentIdentifier + { + Uri = new Uri(scriptPath) + }, + Position = new Position + { + Line = 5, + Character = 0 + }, + Context = new ReferenceContext + { + IncludeDeclaration = false + } + }) + .Returning(CancellationToken.None); + + Assert.Collection(locations, + location => + { + Range range = location.Range; + Assert.Equal(5, range.Start.Line); + Assert.Equal(0, range.Start.Character); + Assert.Equal(5, range.End.Line); + Assert.Equal(24, range.End.Character); + }); + } + + [Fact] + public async Task CanSendDocumentHighlightRequestAsync() + { + string scriptPath = NewTestFile(@" +Write-Host 'Hello!' + +Write-Host 'Goodbye' +"); + + DocumentHighlightContainer documentHighlights = + await PsesLanguageClient + .SendRequest( + "textDocument/documentHighlight", + new DocumentHighlightParams + { + TextDocument = new TextDocumentIdentifier + { + Uri = new Uri(scriptPath) + }, + Position = new Position + { + Line = 3, + Character = 1 + } + }) + .Returning(CancellationToken.None); + + Assert.Collection(documentHighlights.OrderBy(i => i.Range.Start.Line), + documentHighlight1 => + { + Range range = documentHighlight1.Range; + Assert.Equal(1, range.Start.Line); + Assert.Equal(0, range.Start.Character); + Assert.Equal(1, range.End.Line); + Assert.Equal(10, range.End.Character); + }, + documentHighlight2 => + { + Range range = documentHighlight2.Range; + Assert.Equal(3, range.Start.Line); + Assert.Equal(0, range.Start.Character); + Assert.Equal(3, range.End.Line); + Assert.Equal(10, range.End.Character); + }); + } + + [Fact] + public async Task CanSendPowerShellGetPSHostProcessesRequestAsync() + { + Process process = new(); + process.StartInfo.FileName = PwshExe; + process.StartInfo.ArgumentList.Add("-NoProfile"); + process.StartInfo.ArgumentList.Add("-NoLogo"); + process.StartInfo.ArgumentList.Add("-NoExit"); + + process.StartInfo.CreateNoWindow = true; + process.StartInfo.UseShellExecute = false; + + process.StartInfo.RedirectStandardInput = true; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + + process.Start(); + + // Wait for the process to start. + Thread.Sleep(1000); + + PSHostProcessResponse[] pSHostProcessResponses = null; + + try + { + pSHostProcessResponses = + await PsesLanguageClient + .SendRequest( + "powerShell/getPSHostProcesses", + new GetPSHostProcessesParams()) + .Returning(CancellationToken.None); + } + finally + { + process.Kill(); + process.Dispose(); + } + + Assert.NotEmpty(pSHostProcessResponses); + } + + [Fact] + public async Task CanSendPowerShellGetRunspaceRequestAsync() + { + Process process = new(); + process.StartInfo.FileName = PwshExe; + process.StartInfo.ArgumentList.Add("-NoProfile"); + process.StartInfo.ArgumentList.Add("-NoLogo"); + process.StartInfo.ArgumentList.Add("-NoExit"); + + process.StartInfo.CreateNoWindow = true; + process.StartInfo.UseShellExecute = false; + + process.StartInfo.RedirectStandardInput = true; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + + process.Start(); + + // Wait for the process to start. + Thread.Sleep(1000); + + RunspaceResponse[] runspaceResponses = null; + try + { + runspaceResponses = + await PsesLanguageClient + .SendRequest( + "powerShell/getRunspace", + new GetRunspaceParams + { + ProcessId = process.Id + }) + .Returning(CancellationToken.None); + } + finally + { + process.Kill(); + process.Dispose(); + } + + Assert.NotEmpty(runspaceResponses); + } + + [Fact] + public async Task CanSendPesterLegacyCodeLensRequestAsync() + { + // Make sure LegacyCodeLens is enabled because we'll need it in this test. + PsesLanguageClient.Workspace.DidChangeConfiguration( + new DidChangeConfigurationParams + { + Settings = JObject.Parse(@" +{ + ""powershell"": { + ""pester"": { + ""useLegacyCodeLens"": true, + ""codeLens"": true + } + } +} +") + }); + + string filePath = NewTestFile(@" +Describe 'DescribeName' { + Context 'ContextName' { + It 'ItName' { + 1 | Should - Be 1 + } + } +} +", isPester: true); + + CodeLensContainer codeLenses = await PsesLanguageClient + .SendRequest( + "textDocument/codeLens", + new CodeLensParams + { + TextDocument = new TextDocumentIdentifier + { + Uri = new Uri(filePath) + } + }) + .Returning(CancellationToken.None); + + Assert.Collection(codeLenses, + codeLens1 => + { + Range range = codeLens1.Range; + + Assert.Equal(1, range.Start.Line); + Assert.Equal(0, range.Start.Character); + Assert.Equal(7, range.End.Line); + Assert.Equal(1, range.End.Character); + + Assert.Equal("Run tests", codeLens1.Command.Title); + }, + codeLens2 => + { + Range range = codeLens2.Range; + + Assert.Equal(1, range.Start.Line); + Assert.Equal(0, range.Start.Character); + Assert.Equal(7, range.End.Line); + Assert.Equal(1, range.End.Character); + + Assert.Equal("Debug tests", codeLens2.Command.Title); + }); + } + + [Fact] + public async Task CanSendPesterCodeLensRequestAsync() + { + // Make sure Pester legacy CodeLens is disabled because we'll need it in this test. + PsesLanguageClient.Workspace.DidChangeConfiguration( + new DidChangeConfigurationParams + { + Settings = JObject.Parse(@" +{ + ""powershell"": { + ""pester"": { + ""useLegacyCodeLens"": false, + ""codeLens"": true + } + } +} +") + }); + + string filePath = NewTestFile(@" +Describe 'DescribeName' { + Context 'ContextName' { + It 'ItName' { + 1 | Should - Be 1 + } + } +} +", isPester: true); + + CodeLensContainer codeLenses = await PsesLanguageClient + .SendRequest( + "textDocument/codeLens", + new CodeLensParams + { + TextDocument = new TextDocumentIdentifier + { + Uri = new Uri(filePath) + } + }) + .Returning(CancellationToken.None); + + Assert.Collection(codeLenses, + codeLens => + { + Range range = codeLens.Range; + + Assert.Equal(1, range.Start.Line); + Assert.Equal(0, range.Start.Character); + Assert.Equal(7, range.End.Line); + Assert.Equal(1, range.End.Character); + + Assert.Equal("Run tests", codeLens.Command.Title); + }, + codeLens => + { + Range range = codeLens.Range; + + Assert.Equal(1, range.Start.Line); + Assert.Equal(0, range.Start.Character); + Assert.Equal(7, range.End.Line); + Assert.Equal(1, range.End.Character); + + Assert.Equal("Debug tests", codeLens.Command.Title); + }, + codeLens => + { + Range range = codeLens.Range; + + Assert.Equal(2, range.Start.Line); + Assert.Equal(4, range.Start.Character); + Assert.Equal(6, range.End.Line); + Assert.Equal(5, range.End.Character); + + Assert.Equal("Run tests", codeLens.Command.Title); + }, + codeLens => + { + Range range = codeLens.Range; + + Assert.Equal(2, range.Start.Line); + Assert.Equal(4, range.Start.Character); + Assert.Equal(6, range.End.Line); + Assert.Equal(5, range.End.Character); + + Assert.Equal("Debug tests", codeLens.Command.Title); + }, + codeLens => + { + Range range = codeLens.Range; + + Assert.Equal(3, range.Start.Line); + Assert.Equal(8, range.Start.Character); + Assert.Equal(5, range.End.Line); + Assert.Equal(9, range.End.Character); + + Assert.Equal("Run test", codeLens.Command.Title); + }, + codeLens => + { + Range range = codeLens.Range; + + Assert.Equal(3, range.Start.Line); + Assert.Equal(8, range.Start.Character); + Assert.Equal(5, range.End.Line); + Assert.Equal(9, range.End.Character); + + Assert.Equal("Debug test", codeLens.Command.Title); + }); + } + + [Fact] + public async Task NoMessageIfPesterCodeLensDisabled() + { + // Make sure Pester legacy CodeLens is disabled because we'll need it in this test. + PsesLanguageClient.Workspace.DidChangeConfiguration( + new DidChangeConfigurationParams + { + Settings = JObject.Parse(@" +{ + ""powershell"": { + ""pester"": { + ""codeLens"": false + } + } +} +") + }); + + string filePath = NewTestFile(@" +Describe 'DescribeName' { + Context 'ContextName' { + It 'ItName' { + 1 | Should - Be 1 + } + } +} +", isPester: true); + + CodeLensContainer codeLenses = await PsesLanguageClient + .SendRequest( + "textDocument/codeLens", + new CodeLensParams + { + TextDocument = new TextDocumentIdentifier + { + Uri = new Uri(filePath) + } + }) + .Returning(CancellationToken.None); + + Assert.Empty(codeLenses); + } + + [Fact] + public async Task CanSendFunctionReferencesCodeLensRequestAsync() + { + string filePath = NewTestFile(@" +function CanSendReferencesCodeLensRequest { + +} + +CanSendReferencesCodeLensRequest +"); + + CodeLensContainer codeLenses = await PsesLanguageClient + .SendRequest( + "textDocument/codeLens", + new CodeLensParams + { + TextDocument = new TextDocumentIdentifier + { + Uri = new Uri(filePath) + } + }) + .Returning(CancellationToken.None); + + CodeLens codeLens = Assert.Single(codeLenses); + + Range range = codeLens.Range; + Assert.Equal(1, range.Start.Line); + Assert.Equal(9, range.Start.Character); + Assert.Equal(1, range.End.Line); + Assert.Equal(41, range.End.Character); + + CodeLens codeLensResolveResult = await PsesLanguageClient + .SendRequest("codeLens/resolve", codeLens) + .Returning(CancellationToken.None); + + Assert.Equal("1 reference", codeLensResolveResult.Command.Title); + } + + [Fact] + public async Task CanSendClassReferencesCodeLensRequestAsync() + { + string filePath = NewTestFile(@" +param( + [MyBaseClass]$enumValue +) + +class MyBaseClass { + +} + +class ChildClass : MyBaseClass, System.IDisposable { + +} + +$o = [MyBaseClass]::new() +$o -is [MyBaseClass] +"); + + CodeLensContainer codeLenses = await PsesLanguageClient + .SendRequest( + "textDocument/codeLens", + new CodeLensParams + { + TextDocument = new TextDocumentIdentifier + { + Uri = new Uri(filePath) + } + }) + .Returning(CancellationToken.None); + + Assert.Collection(codeLenses.OrderBy(i => i.Range.Start.Line), + codeLens => + { + Range range = codeLens.Range; + Assert.Equal(5, range.Start.Line); + Assert.Equal(6, range.Start.Character); + Assert.Equal(5, range.End.Line); + Assert.Equal(17, range.End.Character); + }, + codeLens => + { + Range range = codeLens.Range; + Assert.Equal(9, range.Start.Line); + Assert.Equal(6, range.Start.Character); + Assert.Equal(9, range.End.Line); + Assert.Equal(16, range.End.Character); + } + ); + + CodeLens baseClassCodeLens = codeLenses.OrderBy(i => i.Range.Start.Line).First(); + CodeLens codeLensResolveResult = await PsesLanguageClient + .SendRequest("codeLens/resolve", baseClassCodeLens) + .Returning(CancellationToken.None); + + Assert.Equal("4 references", codeLensResolveResult.Command.Title); + } + + [Fact] + public async Task CanSendEnumReferencesCodeLensRequestAsync() + { + string filePath = NewTestFile(@" +param( + [MyEnum]$enumValue +) + +enum MyEnum { + First = 1 + Second + Third +} + +[MyEnum]::First +'First' -is [MyEnum] +"); + + CodeLensContainer codeLenses = await PsesLanguageClient + .SendRequest( + "textDocument/codeLens", + new CodeLensParams + { + TextDocument = new TextDocumentIdentifier + { + Uri = new Uri(filePath) + } + }) + .Returning(CancellationToken.None); + + CodeLens codeLens = Assert.Single(codeLenses); + + Range range = codeLens.Range; + Assert.Equal(5, range.Start.Line); + Assert.Equal(5, range.Start.Character); + Assert.Equal(5, range.End.Line); + Assert.Equal(11, range.End.Character); + + CodeLens codeLensResolveResult = await PsesLanguageClient + .SendRequest("codeLens/resolve", codeLens) + .Returning(CancellationToken.None); + + Assert.Equal("3 references", codeLensResolveResult.Command.Title); + } + + [SkippableFact] + public async Task CanSendCodeActionRequestAsync() + { + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode && PsesStdioLanguageServerProcessHost.IsWindowsPowerShell, + "Windows PowerShell doesn't trust PSScriptAnalyzer by default so it won't load."); + + string filePath = NewTestFile("gci"); + await WaitForDiagnosticsAsync(); + + CommandOrCodeActionContainer commandOrCodeActions = + await PsesLanguageClient + .SendRequest( + "textDocument/codeAction", + new CodeActionParams + { + TextDocument = new TextDocumentIdentifier( + new Uri(filePath, UriKind.Absolute)), + Range = new Range + { + Start = new Position + { + Line = 0, + Character = 0 + }, + End = new Position + { + Line = 0, + Character = 3 + } + }, + Context = new CodeActionContext + { + Diagnostics = new Container(Diagnostics) + } + }) + .Returning(CancellationToken.None); + + Assert.Collection(commandOrCodeActions, + command => + { + Assert.Equal( + "Replace gci with Get-ChildItem", + command.CodeAction.Title); + Assert.Equal( + CodeActionKind.QuickFix, + command.CodeAction.Kind); + Assert.Single(command.CodeAction.Edit.DocumentChanges); + }, + command => + { + Assert.Equal( + "PowerShell.ShowCodeActionDocumentation", + command.CodeAction.Command.Name); + }); + } + + [Fact] + public async Task CanSendCompletionAndCompletionResolveRequestAsync() + { + CompletionList completionItems = await PsesLanguageClient.TextDocument.RequestCompletion( + new CompletionParams + { + TextDocument = new TextDocumentIdentifier + { + Uri = DocumentUri.FromFileSystemPath(NewTestFile(testCommand)) + }, + Position = new Position(line: 0, character: 7) + }); + + CompletionItem completionItem = Assert.Single(completionItems, + completionItem1 => completionItem1.FilterText == testCommand); + + CompletionItem updatedCompletionItem = await PsesLanguageClient + .SendRequest("completionItem/resolve", completionItem) + .Returning(CancellationToken.None); + + Assert.Contains(testDescription, updatedCompletionItem.Documentation.String); + } + + [Fact] + public async Task CanSendCompletionResolveWithModulePrefixRequestAsync() + { + await PsesLanguageClient + .SendRequest( + "evaluate", + new EvaluateRequestArguments + { + Expression = "Import-Module Microsoft.PowerShell.Archive -Prefix Test" + }) + .ReturningVoid(CancellationToken.None); + + try + { + const string command = "Expand-TestArchive"; + + CompletionList completionItems = await PsesLanguageClient.TextDocument.RequestCompletion( + new CompletionParams + { + TextDocument = new TextDocumentIdentifier + { + Uri = DocumentUri.FromFileSystemPath(NewTestFile(command)) + }, + Position = new Position(line: 0, character: 12) + }); + + CompletionItem completionItem = Assert.Single(completionItems, + completionItem1 => completionItem1.Label == command); + + CompletionItem updatedCompletionItem = await PsesLanguageClient.ResolveCompletion(completionItem); + + Assert.Contains(testDescription, updatedCompletionItem.Documentation.String); + } + finally + { + // Reset the Archive module to the non-prefixed version + await PsesLanguageClient + .SendRequest( + "evaluate", + new EvaluateRequestArguments + { + Expression = "Remove-Module Microsoft.PowerShell.Archive;Import-Module Microsoft.PowerShell.Archive -Force" + }) + .ReturningVoid(CancellationToken.None); + } + } + + [Fact] + public async Task CanSendHoverRequestAsync() + { + string filePath = NewTestFile(testCommand); + + Hover hover = await PsesLanguageClient.TextDocument.RequestHover( + new HoverParams + { + TextDocument = new TextDocumentIdentifier + { + Uri = DocumentUri.FromFileSystemPath(filePath) + }, + Position = new Position(line: 0, character: 1) + }); + + Assert.True(hover.Contents.HasMarkedStrings); + Assert.Collection(hover.Contents.MarkedStrings, + str1 => Assert.Equal(testCommand, str1.Value), + str2 => + { + Assert.Equal("markdown", str2.Language); + Assert.Equal(testDescription, str2.Value); + }); + } + + [Fact] + public async Task CanSendSignatureHelpRequestAsync() + { + string filePath = NewTestFile($"{testCommand} -"); + + SignatureHelp signatureHelp = await PsesLanguageClient.RequestSignatureHelp + ( + new SignatureHelpParams + { + TextDocument = new TextDocumentIdentifier + { + Uri = new Uri(filePath) + }, + Position = new Position + { + Line = 0, + Character = 10 + } + } + ); + + Assert.Contains(testCommand, signatureHelp.Signatures.First().Label); + } + + [Fact] + public async Task CanSendDefinitionRequestAsync() + { + string scriptPath = NewTestFile(@" +function CanSendDefinitionRequest { + +} + +CanSendDefinitionRequest +"); + + LocationOrLocationLinks locationOrLocationLinks = + await PsesLanguageClient + .SendRequest( + "textDocument/definition", + new DefinitionParams + { + TextDocument = new TextDocumentIdentifier { Uri = new Uri(scriptPath) }, + Position = new Position { Line = 5, Character = 2 } + }) + .Returning(CancellationToken.None); + + LocationOrLocationLink locationOrLocationLink = + Assert.Single(locationOrLocationLinks); + + Assert.Equal(1, locationOrLocationLink.Location.Range.Start.Line); + Assert.Equal(9, locationOrLocationLink.Location.Range.Start.Character); + Assert.Equal(1, locationOrLocationLink.Location.Range.End.Line); + Assert.Equal(33, locationOrLocationLink.Location.Range.End.Character); + } + + [SkippableFact] + public async Task CanSendGetCommentHelpRequestAsync() + { + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode && PsesStdioLanguageServerProcessHost.IsWindowsPowerShell, + "Windows PowerShell doesn't trust PSScriptAnalyzer by default so it won't load."); + + string scriptPath = NewTestFile(@" +function CanSendGetCommentHelpRequest { + param( + $myParam, + $myOtherParam, + $yetAnotherParam + ) + + # Include other problematic code to make sure this still works + gci +} +"); + + CommentHelpRequestResult commentHelpRequestResult = + await PsesLanguageClient + .SendRequest( + "powerShell/getCommentHelp", + new CommentHelpRequestParams + { + DocumentUri = new Uri(scriptPath).ToString(), + BlockComment = false, + TriggerPosition = new Position + { + Line = 0, + Character = 0 + } + }) + .Returning(CancellationToken.None); + + Assert.NotEmpty(commentHelpRequestResult.Content); + Assert.Contains("myParam", commentHelpRequestResult.Content[7]); + } + + [Fact] + public async Task CanSendEvaluateRequestAsync() + { + EvaluateResponseBody evaluateResponseBody = + await PsesLanguageClient + .SendRequest( + "evaluate", + new EvaluateRequestArguments + { + Expression = "Get-ChildItem" + }) + .Returning(CancellationToken.None); + + // These always gets returned so this test really just makes sure we get _any_ response. + Assert.Equal("", evaluateResponseBody.Result); + Assert.Equal(0, evaluateResponseBody.VariablesReference); + } + + // getCommand gets all the commands in the system, and is not optimized and can take forever on CI systems + [SkippableFact(Timeout = 120000)] + public async Task CanSendGetCommandRequestAsync() + { + Skip.If(Environment.GetEnvironmentVariable("TF_BUILD") is not null, + "This test is too slow in CI."); + + List pSCommandMessages = + await PsesLanguageClient + .SendRequest("powerShell/getCommand", new GetCommandParams()) + .Returning>(CancellationToken.None); + + Assert.NotEmpty(pSCommandMessages); + // There should be at least 20 commands or so. + Assert.True(pSCommandMessages.Count > 20); + } + + [SkippableFact] + public async Task CanSendExpandAliasRequestAsync() + { + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode, + "The expand alias request doesn't work in Constrained Language Mode."); + + ExpandAliasResult expandAliasResult = + await PsesLanguageClient + .SendRequest( + "powerShell/expandAlias", + new ExpandAliasParams + { + Text = "gci" + }) + .Returning(CancellationToken.None); + + Assert.Equal("Get-ChildItem", expandAliasResult.Text); + } + + [Fact] + public async Task CanSendSemanticTokenRequestAsync() + { + const string scriptContent = "function"; + string scriptPath = NewTestFile(scriptContent); + + SemanticTokens result = + await PsesLanguageClient + .SendRequest( + "textDocument/semanticTokens/full", + new SemanticTokensParams + { + TextDocument = new TextDocumentIdentifier + { + Uri = new Uri(scriptPath) + } + }) + .Returning(CancellationToken.None); + + // More information about how this data is generated can be found at + // https://github.com/microsoft/vscode-extension-samples/blob/5ae1f7787122812dcc84e37427ca90af5ee09f14/semantic-tokens-sample/vscode.proposed.d.ts#L71 + int[] expectedArr = new int[5] + { + // line, index, token length, token type, token modifiers + 0, 0, scriptContent.Length, 1, 0 //function token: line 0, index 0, length of script, type 1 = keyword, no modifiers + }; + + Assert.Equal(expectedArr, result.Data.ToArray()); + } + } +} diff --git a/test/PowerShellEditorServices.Test.E2E/PowerShellEditorServices.Test.E2E.csproj b/test/PowerShellEditorServices.Test.E2E/PowerShellEditorServices.Test.E2E.csproj new file mode 100644 index 0000000..82c0899 --- /dev/null +++ b/test/PowerShellEditorServices.Test.E2E/PowerShellEditorServices.Test.E2E.csproj @@ -0,0 +1,43 @@ + + + + + net8.0 + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/PowerShellEditorServices.Test.E2E/xunit.runner.json b/test/PowerShellEditorServices.Test.E2E/xunit.runner.json new file mode 100644 index 0000000..f1c3b37 --- /dev/null +++ b/test/PowerShellEditorServices.Test.E2E/xunit.runner.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "appDomain": "denied", + "parallelizeTestCollections": true, + "parallelAlgorithm": "conservative", + "methodDisplay": "method", + "diagnosticMessages": true, + "longRunningTestSeconds": 10, + "showLiveOutput": true +} diff --git a/test/PowerShellEditorServices.Test.Shared/Completion/CompleteAttributeValue.cs b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteAttributeValue.cs new file mode 100644 index 0000000..629e50a --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteAttributeValue.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.Completion +{ + internal static class CompleteAttributeValue + { + public static readonly ScriptRegion SourceDetails = new( + file: TestUtilities.NormalizePath("Completion/CompletionExamples.psm1"), + text: string.Empty, + startLineNumber: 16, + startColumnNumber: 38, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly CompletionItem ExpectedCompletion1 = new() + { + Kind = CompletionItemKind.Property, + Detail = "System.Boolean ValueFromPipeline", + FilterText = "ValueFromPipeline", + InsertText = "ValueFromPipeline", + Label = "ValueFromPipeline", + TextEdit = new TextEdit + { + NewText = "ValueFromPipeline", + Range = new Range + { + Start = new Position { Line = 15, Character = 32 }, + End = new Position { Line = 15, Character = 37 } + } + } + }; + + public static readonly CompletionItem ExpectedCompletion2 = new() + { + Kind = CompletionItemKind.Property, + Detail = "System.Boolean ValueFromPipelineByPropertyName", + FilterText = "ValueFromPipelineByPropertyName", + InsertText = "ValueFromPipelineByPropertyName", + Label = "ValueFromPipelineByPropertyName", + TextEdit = new TextEdit + { + NewText = "ValueFromPipelineByPropertyName", + Range = new Range + { + Start = new Position { Line = 15, Character = 32 }, + End = new Position { Line = 15, Character = 37 } + } + } + }; + + public static readonly CompletionItem ExpectedCompletion3 = new() + { + Kind = CompletionItemKind.Property, + Detail = "System.Boolean ValueFromRemainingArguments", + FilterText = "ValueFromRemainingArguments", + InsertText = "ValueFromRemainingArguments", + Label = "ValueFromRemainingArguments", + TextEdit = new TextEdit + { + NewText = "ValueFromRemainingArguments", + Range = new Range + { + Start = new Position { Line = 15, Character = 32 }, + End = new Position { Line = 15, Character = 37 } + } + } + }; + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Completion/CompleteCommandFromModule.cs b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteCommandFromModule.cs new file mode 100644 index 0000000..dff6797 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteCommandFromModule.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.Completion +{ + internal static class CompleteCommandFromModule + { + public const string GetRandomDetail = + "Get-Random [[-Maximum] ] [-SetSeed ] [-Minimum ]"; + + public static readonly ScriptRegion SourceDetails = new( + file: TestUtilities.NormalizePath("Completion/CompletionExamples.psm1"), + text: string.Empty, + startLineNumber: 13, + startColumnNumber: 8, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly CompletionItem ExpectedCompletion = new() + { + Kind = CompletionItemKind.Function, + Detail = "", // OS-dependent, checked separately. + FilterText = "Get-Random", + InsertText = "Get-Random", + Label = "Get-Random", + SortText = "0001Get-Random", + TextEdit = new TextEdit + { + NewText = "Get-Random", + Range = new Range + { + Start = new Position { Line = 12, Character = 0 }, + End = new Position { Line = 12, Character = 8 } + } + } + }; + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Completion/CompleteCommandInFile.cs b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteCommandInFile.cs new file mode 100644 index 0000000..f84a44a --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteCommandInFile.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.Completion +{ + internal static class CompleteCommandInFile + { + public static readonly ScriptRegion SourceDetails = new( + file: TestUtilities.NormalizePath("Completion/CompletionExamples.psm1"), + text: string.Empty, + startLineNumber: 8, + startColumnNumber: 10, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly CompletionItem ExpectedCompletion = new() + { + Kind = CompletionItemKind.Function, + Detail = "", + FilterText = "Get-XYZSomething", + InsertText = "Get-XYZSomething", + Label = "Get-XYZSomething", + SortText = "0001Get-XYZSomething", + TextEdit = new TextEdit + { + NewText = "Get-XYZSomething", + Range = new Range + { + Start = new Position { Line = 7, Character = 0 }, + End = new Position { Line = 7, Character = 9 } + } + } + }; + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Completion/CompleteFilePath.cs b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteFilePath.cs new file mode 100644 index 0000000..a3a899f --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteFilePath.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.Completion +{ + internal static class CompleteFilePath + { + public static readonly ScriptRegion SourceDetails = new( + file: TestUtilities.NormalizePath("Completion/CompletionExamples.psm1"), + text: string.Empty, + startLineNumber: 19, + startColumnNumber: 15, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly TextEdit ExpectedEdit = new() + { + NewText = "", + Range = new Range + { + Start = new Position { Line = 18, Character = 14 }, + End = new Position { Line = 18, Character = 14 } + } + }; + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Completion/CompleteNamespace.cs b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteNamespace.cs new file mode 100644 index 0000000..8d24377 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteNamespace.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.Completion +{ + internal static class CompleteNamespace + { + public static readonly ScriptRegion SourceDetails = new( + file: TestUtilities.NormalizePath("Completion/CompletionExamples.psm1"), + text: string.Empty, + startLineNumber: 22, + startColumnNumber: 15, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly CompletionItem ExpectedCompletion = new() + { + Kind = CompletionItemKind.Module, + Detail = "Namespace System.Collections", + FilterText = "System.Collections", + InsertText = "System.Collections", + Label = "Collections", + SortText = "0001Collections", + TextEdit = new TextEdit + { + NewText = "System.Collections", + Range = new Range + { + Start = new Position { Line = 21, Character = 1 }, + End = new Position { Line = 21, Character = 15 } + } + } + }; + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Completion/CompleteTypeName.cs b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteTypeName.cs new file mode 100644 index 0000000..38bba7c --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteTypeName.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.Completion +{ + internal static class CompleteTypeName + { + public static readonly ScriptRegion SourceDetails = new( + file: TestUtilities.NormalizePath("Completion/CompletionExamples.psm1"), + text: string.Empty, + startLineNumber: 21, + startColumnNumber: 25, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly CompletionItem ExpectedCompletion = new() + { + Kind = CompletionItemKind.TypeParameter, + Detail = "System.Collections.ArrayList", + FilterText = "System.Collections.ArrayList", + InsertText = "System.Collections.ArrayList", + Label = "ArrayList", + SortText = "0001ArrayList", + TextEdit = new TextEdit + { + NewText = "System.Collections.ArrayList", + Range = new Range + { + Start = new Position { Line = 20, Character = 1 }, + End = new Position { Line = 20, Character = 29 } + } + } + }; + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Completion/CompleteVariableInFile.cs b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteVariableInFile.cs new file mode 100644 index 0000000..3dbfb80 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteVariableInFile.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.Completion +{ + internal static class CompleteVariableInFile + { + public static readonly ScriptRegion SourceDetails = new( + file: TestUtilities.NormalizePath("Completion/CompletionExamples.psm1"), + text: string.Empty, + startLineNumber: 10, + startColumnNumber: 9, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly CompletionItem ExpectedCompletion = new() + { + Kind = CompletionItemKind.Variable, + // PowerShell 7.4 now lights up a type for the detail, otherwise it's the same as the + // label and therefore hidden. + Detail = Utility.VersionUtils.IsPS74 ? "[string]" : "", + FilterText = "$testVar1", + InsertText = "$testVar1", + Label = "testVar1", + SortText = "0001testVar1", + TextEdit = new TextEdit + { + NewText = "$testVar1", + Range = new Range + { + Start = new Position { Line = 9, Character = 0 }, + End = new Position { Line = 9, Character = 8 } + } + } + }; + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Completion/CompletionExamples.psm1 b/test/PowerShellEditorServices.Test.Shared/Completion/CompletionExamples.psm1 new file mode 100644 index 0000000..840ec9f --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Completion/CompletionExamples.psm1 @@ -0,0 +1,22 @@ +function Get-XYZSomething +{ + $testVar2 = "Shouldn't find this variable" +} + +$testVar1 = "Should find this variable" + +Get-XYZSo + +$testVar + +Import-Module PowerShellGet +Get-Rand + +function Test-Completion { + param([Parameter(Mandatory, Value)]$test) +} + +Get-ChildItem / + +[System.Collections.ArrayList].GetType() +[System.Collect diff --git a/test/PowerShellEditorServices.Test.Shared/Debugging/Debug' W&ith $Params [Test].ps1 b/test/PowerShellEditorServices.Test.Shared/Debugging/Debug' W&ith $Params [Test].ps1 new file mode 100644 index 0000000..a84b313 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Debugging/Debug' W&ith $Params [Test].ps1 @@ -0,0 +1,3 @@ +param($Param1, $Param2, [switch]$Force) + +"args are $args" \ No newline at end of file diff --git a/test/PowerShellEditorServices.Test.Shared/Debugging/DebugTest.ps1 b/test/PowerShellEditorServices.Test.Shared/Debugging/DebugTest.ps1 new file mode 100644 index 0000000..fd51072 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Debugging/DebugTest.ps1 @@ -0,0 +1,12 @@ +$i = 1 + +while ($i -le 500000) +{ + $str = "Output $i" + Write-Host $str + $i = $i + 1 +} + +Write-Host "Done!" +Get-Date +Get-Host \ No newline at end of file diff --git a/test/PowerShellEditorServices.Test.Shared/Debugging/GetChildItemTest.ps1 b/test/PowerShellEditorServices.Test.Shared/Debugging/GetChildItemTest.ps1 new file mode 100644 index 0000000..bab9ef5 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Debugging/GetChildItemTest.ps1 @@ -0,0 +1,2 @@ +$file = Get-ChildItem -Path "." | Select-Object -First 1 +Write-Host "Debug over" diff --git a/test/PowerShellEditorServices.Test.Shared/Debugging/PSDebugContextTest.ps1 b/test/PowerShellEditorServices.Test.Shared/Debugging/PSDebugContextTest.ps1 new file mode 100644 index 0000000..103af99 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Debugging/PSDebugContextTest.ps1 @@ -0,0 +1,11 @@ +$promptSawDebug = $false + +function prompt { + if (Test-Path variable:/PSDebugContext -ErrorAction SilentlyContinue) { + $promptSawDebug = $true + } + + return "$promptSawDebug > " +} + +Write-Host "Debug over" diff --git a/test/PowerShellEditorServices.Test.Shared/Debugging/VariableTest.ps1 b/test/PowerShellEditorServices.Test.Shared/Debugging/VariableTest.ps1 new file mode 100644 index 0000000..2dc5137 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Debugging/VariableTest.ps1 @@ -0,0 +1,66 @@ +class MyClass { + [String] $Name; + [Int32] $Number; } +[bool]$scriptBool = $false +$scriptInt = 42 +function Test-Variables { + $strVar = "Hello" + [string]$strVar2 = "Hello2" + $arrVar = @(1, 2, $strVar, $objVar) + $assocArrVar = @{ firstChild = "Child"; secondChild = 42 } + $classVar = [MyClass]::new(); + $classVar.Name = "Test" + $classVar.Number = 42; + $enumVar = $ErrorActionPreference + $nullString = [NullString]::Value + $psObjVar = New-Object -TypeName PSObject -Property @{Name = 'John'; Age = 75 } + $psCustomObjVar = [PSCustomObject] @{Name = 'Paul'; Age = 73 } + $procVar = Get-Process -PID $PID + $trueVar = $true + $falseVar = $false + Write-Output "Done" +} + +Test-Variables +# NOTE: If a line is added to the function above, the line numbers in the +# associated unit tests MUST be adjusted accordingly. + +$SCRIPT:simpleArray = @( + 1 + 2 + 'red' + 'blue' +) + +# This is a dummy function that the test will use to stop and evaluate the debug environment +function __BreakDebuggerEnumerableShowsRawView{}; __BreakDebuggerEnumerableShowsRawView + +$SCRIPT:simpleDictionary = @{ + item1 = 1 + item2 = 2 + item3 = 'red' + item4 = 'blue' +} +function __BreakDebuggerDictionaryShowsRawView{}; __BreakDebuggerDictionaryShowsRawView + +$SCRIPT:sortedDictionary = [Collections.Generic.SortedDictionary[string, object]]::new() +$sortedDictionary[1] = 1 +$sortedDictionary[2] = 2 +$sortedDictionary['red'] = 'red' +$sortedDictionary['blue'] = 'red' + +# This is a dummy function that the test will use to stop and evaluate the debug environment +function __BreakDebuggerDerivedDictionaryPropertyInRawView{}; __BreakDebuggerDerivedDictionaryPropertyInRawView + +class CustomToString { + [String]$String = 'Hello' + [String]ToString() { + return $this.String.ToUpper() + } +} +$SCRIPT:CustomToStrings = 1..1000 | ForEach-Object { + [CustomToString]::new() +} + +# This is a dummy function that the test will use to stop and evaluate the debug environment +function __BreakDebuggerToStringShouldMarshallToPipeline{}; __BreakDebuggerToStringShouldMarshallToPipeline diff --git a/test/PowerShellEditorServices.Test.Shared/Definition/FindsFunctionDefinition.cs b/test/PowerShellEditorServices.Test.Shared/Definition/FindsFunctionDefinition.cs new file mode 100644 index 0000000..db34650 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Definition/FindsFunctionDefinition.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.Definition +{ + public static class FindsFunctionDefinitionData + { + public static readonly ScriptRegion SourceDetails = new( + file: TestUtilities.NormalizePath("References/SimpleFile.ps1"), + text: string.Empty, + startLineNumber: 3, + startColumnNumber: 12, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Definition/FindsFunctionDefinitionInDotSourceReference.cs b/test/PowerShellEditorServices.Test.Shared/Definition/FindsFunctionDefinitionInDotSourceReference.cs new file mode 100644 index 0000000..842dec9 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Definition/FindsFunctionDefinitionInDotSourceReference.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.Definition +{ + public static class FindsFunctionDefinitionInWorkspaceData + { + public static readonly ScriptRegion SourceDetails = new( + file: TestUtilities.NormalizePath("References/FileWithReferences.ps1"), + text: string.Empty, + startLineNumber: 3, + startColumnNumber: 6, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Definition/FindsFunctionDefinitionOfAlias.cs b/test/PowerShellEditorServices.Test.Shared/Definition/FindsFunctionDefinitionOfAlias.cs new file mode 100644 index 0000000..8704e1a --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Definition/FindsFunctionDefinitionOfAlias.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.Definition +{ + public static class FindsFunctionDefinitionOfAliasData + { + public static readonly ScriptRegion SourceDetails = new( + file: TestUtilities.NormalizePath("References/SimpleFile.ps1"), + text: string.Empty, + startLineNumber: 20, + startColumnNumber: 4, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Definition/FindsTypeSymbolsDefinition.cs b/test/PowerShellEditorServices.Test.Shared/Definition/FindsTypeSymbolsDefinition.cs new file mode 100644 index 0000000..a0b5ae6 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Definition/FindsTypeSymbolsDefinition.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.Definition +{ + public static class FindsTypeSymbolsDefinitionData + { + public static readonly ScriptRegion ClassSourceDetails = new( + file: TestUtilities.NormalizePath("References/TypeAndClassesFile.ps1"), + text: string.Empty, + startLineNumber: 8, + startColumnNumber: 14, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly ScriptRegion EnumSourceDetails = new( + file: TestUtilities.NormalizePath("References/TypeAndClassesFile.ps1"), + text: string.Empty, + startLineNumber: 39, + startColumnNumber: 10, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly ScriptRegion TypeExpressionSourceDetails = new( + file: TestUtilities.NormalizePath("References/TypeAndClassesFile.ps1"), + text: string.Empty, + startLineNumber: 45, + startColumnNumber: 5, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly ScriptRegion TypeConstraintSourceDetails = new( + file: TestUtilities.NormalizePath("References/TypeAndClassesFile.ps1"), + text: string.Empty, + startLineNumber: 25, + startColumnNumber: 24, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly ScriptRegion ConstructorSourceDetails = new( + file: TestUtilities.NormalizePath("References/TypeAndClassesFile.ps1"), + text: string.Empty, + startLineNumber: 9, + startColumnNumber: 14, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly ScriptRegion MethodSourceDetails = new( + file: TestUtilities.NormalizePath("References/TypeAndClassesFile.ps1"), + text: string.Empty, + startLineNumber: 19, + startColumnNumber: 25, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly ScriptRegion PropertySourceDetails = new( + file: TestUtilities.NormalizePath("References/TypeAndClassesFile.ps1"), + text: string.Empty, + startLineNumber: 15, + startColumnNumber: 32, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly ScriptRegion EnumMemberSourceDetails = new( + file: TestUtilities.NormalizePath("References/TypeAndClassesFile.ps1"), + text: string.Empty, + startLineNumber: 41, + startColumnNumber: 11, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Definition/FindsTypedVariableDefinition.cs b/test/PowerShellEditorServices.Test.Shared/Definition/FindsTypedVariableDefinition.cs new file mode 100644 index 0000000..98892df --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Definition/FindsTypedVariableDefinition.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.Definition +{ + public static class FindsTypedVariableDefinitionData + { + public static readonly ScriptRegion SourceDetails = new( + file: TestUtilities.NormalizePath("References/SimpleFile.ps1"), + text: string.Empty, + startLineNumber: 25, + startColumnNumber: 13, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Definition/FindsVariableDefinition.cs b/test/PowerShellEditorServices.Test.Shared/Definition/FindsVariableDefinition.cs new file mode 100644 index 0000000..e70fa98 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Definition/FindsVariableDefinition.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.Definition +{ + public static class FindsVariableDefinitionData + { + public static readonly ScriptRegion SourceDetails = new( + file: TestUtilities.NormalizePath("References/SimpleFile.ps1"), + text: string.Empty, + startLineNumber: 8, + startColumnNumber: 3, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Occurrences/FindOccurrencesOnParameter.cs b/test/PowerShellEditorServices.Test.Shared/Occurrences/FindOccurrencesOnParameter.cs new file mode 100644 index 0000000..5b708a0 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Occurrences/FindOccurrencesOnParameter.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.Occurrences +{ + public static class FindOccurrencesOnParameterData + { + public static readonly ScriptRegion SourceDetails = new( + file: TestUtilities.NormalizePath("References/SimpleFile.ps1"), + text: string.Empty, + startLineNumber: 1, + startColumnNumber: 31, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Occurrences/FindsOccurrencesOnFunction.cs b/test/PowerShellEditorServices.Test.Shared/Occurrences/FindsOccurrencesOnFunction.cs new file mode 100644 index 0000000..53f4767 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Occurrences/FindsOccurrencesOnFunction.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.Occurrences +{ + public static class FindsOccurrencesOnFunctionData + { + public static readonly ScriptRegion SourceDetails = new( + file: TestUtilities.NormalizePath("References/SimpleFile.ps1"), + text: string.Empty, + startLineNumber: 1, + startColumnNumber: 17, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Occurrences/FindsOccurrencesOnTypeSymbols.cs b/test/PowerShellEditorServices.Test.Shared/Occurrences/FindsOccurrencesOnTypeSymbols.cs new file mode 100644 index 0000000..675dbe4 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Occurrences/FindsOccurrencesOnTypeSymbols.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.Occurrences +{ + public static class FindsOccurrencesOnTypeSymbolsData + { + public static readonly ScriptRegion ClassSourceDetails = new( + file: TestUtilities.NormalizePath("References/TypeAndClassesFile.ps1"), + text: string.Empty, + startLineNumber: 8, + startColumnNumber: 16, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly ScriptRegion EnumSourceDetails = new( + file: TestUtilities.NormalizePath("References/TypeAndClassesFile.ps1"), + text: string.Empty, + startLineNumber: 39, + startColumnNumber: 7, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly ScriptRegion TypeExpressionSourceDetails = new( + file: TestUtilities.NormalizePath("References/TypeAndClassesFile.ps1"), + text: string.Empty, + startLineNumber: 34, + startColumnNumber: 16, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly ScriptRegion TypeConstraintSourceDetails = new( + file: TestUtilities.NormalizePath("References/TypeAndClassesFile.ps1"), + text: string.Empty, + startLineNumber: 8, + startColumnNumber: 24, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly ScriptRegion ConstructorSourceDetails = new( + file: TestUtilities.NormalizePath("References/TypeAndClassesFile.ps1"), + text: string.Empty, + startLineNumber: 13, + startColumnNumber: 14, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly ScriptRegion MethodSourceDetails = new( + file: TestUtilities.NormalizePath("References/TypeAndClassesFile.ps1"), + text: string.Empty, + startLineNumber: 28, + startColumnNumber: 22, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly ScriptRegion PropertySourceDetails = new( + file: TestUtilities.NormalizePath("References/TypeAndClassesFile.ps1"), + text: string.Empty, + startLineNumber: 15, + startColumnNumber: 18, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly ScriptRegion EnumMemberSourceDetails = new( + file: TestUtilities.NormalizePath("References/TypeAndClassesFile.ps1"), + text: string.Empty, + startLineNumber: 45, + startColumnNumber: 16, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Occurrences/FindsOccurrencesOnVariable.cs b/test/PowerShellEditorServices.Test.Shared/Occurrences/FindsOccurrencesOnVariable.cs new file mode 100644 index 0000000..c01db05 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Occurrences/FindsOccurrencesOnVariable.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.Occurrences +{ + public static class FindsOccurrencesOnVariableData + { + public static readonly ScriptRegion SourceDetails = new( + file: TestUtilities.NormalizePath("References/SimpleFile.ps1"), + text: string.Empty, + startLineNumber: 8, + startColumnNumber: 3, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/PSHelp/7/Microsoft.PowerShell.Archive_eb74e8da-9ae2-482a-a648-e96550fb8733_HelpInfo.xml b/test/PowerShellEditorServices.Test.Shared/PSHelp/7/Microsoft.PowerShell.Archive_eb74e8da-9ae2-482a-a648-e96550fb8733_HelpInfo.xml new file mode 100644 index 0000000..80a0b57 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/PSHelp/7/Microsoft.PowerShell.Archive_eb74e8da-9ae2-482a-a648-e96550fb8733_HelpInfo.xml @@ -0,0 +1,10 @@ + + + https://aka.ms/powershell75-help + + + en-US + 7.5.0.0 + + + \ No newline at end of file diff --git a/test/PowerShellEditorServices.Test.Shared/PSHelp/7/Microsoft.PowerShell.Archive_eb74e8da-9ae2-482a-a648-e96550fb8733_en-US_HelpContent.cab b/test/PowerShellEditorServices.Test.Shared/PSHelp/7/Microsoft.PowerShell.Archive_eb74e8da-9ae2-482a-a648-e96550fb8733_en-US_HelpContent.cab new file mode 100644 index 0000000000000000000000000000000000000000..9be01fd7f35819eb07c66a332a4377f9045a20e8 GIT binary patch literal 5388 zcmeYbc6MW6U|`@81(6I4IuMG1nURr!k%56hkB@;NiGhKEnSqg^S_>osQjij*zEgq0 zH#4~?zc@dwL@yw}Jhdn|BQ+;S&#@>uBeN`3HzPHtK(8Vb;Q5EOJYuNHQax#y)LMJ z;d6!?+Mb=hGhOn|O?K7!cBkMSyW0Q1=j&zb%fg?$3CmCptu(5Xll7aSkv#Q8QK8Yw z+^P@LPp0eF{onoA=gHNbqOFN)=Ufi@tFh{T;%vU|U2Jr6&Dy@S&4*HiZ=AhRxTdRV z`pLi_*WX(oD!+ABZbB!QWOiyumhugo!@8UO`S|~vRM$xyFn1`cG#1>xc|$D^tK3_z z#4pcguinq}+g5LK(Vu0GjFtDQ@2DRtnmK#Jo$7#3PJ1kZU6-~RvM;wf_hhZbJYKo= zds&W~2(d&PX&J?@Db=abtId7uD zJ4OGQm)^KXhHjX0O2%$^l<1idrD-d9x<6=TI4EuHSbHgG#*2z;jrl8Q<|-~dSMo#Vsgz97326t!66^g>>9G1VNoxu$+!%E(o8`fi z90uzIsf2RIN%2Emdw+VA z+qU0oND(-A^;E;;&U5$OY(p-ZZ{o_|aQSpGTTteUuR%Q$O~>7L>MZ%XfU8cud)J3O z(mxj;_-^;}Q|gg8)932m0x1XAPTnSHy`a8oxog#z3)|B9JstD=IP~W~FfIG4+w4Lo^sFU~94a?0ho;4Or zKTz1C^~k)UOteKZ_^@P7YUk_5bymp^57s_sTw2I|@u>LqcOM!Bv@1SM_^@6?K>uAu zprThx(yBSp`npHAc)Ysde9lotNBk+1uL*D4qyu&T-__1K@_f~{`u$mq_PvX4&NfSl zS+(>+xPVd71qI!-u;8KX^q$E_t%`8;*V?Wd-h`ck;U&K-?y^G zWdBy!B%k%6T42H2bw^w77U}-95nn&|#jLBY>z)-)5C3s~>KYcWAA8nhRU7SWbN%+^ z>8l{^3-Y^nF+2$VR`gZ$eD+74ZQ9CfPX{UJeU2&mAgL}KkYxBW&^+PAq}w^h!r$5V zZ#=|e{^GCK(Lm`fzprmtFeR>buIrkRv(xr$`O%@yDZMK9ey@85+r8uKcI|lZz;CW5 zi!slI)SJ1dLf=d|TVY(_{;0!{$#dHx)zab(cOT3@_-428(?T7s?y|Y+hSxpQk1qK7 z{<`=>hoY?|d)T)nE?hi&dtqVg`NY|BzZTEUsCj-U)H$fYYEI_T9DY0h;2)>IwEjEN z(Bh@-mEBi%sH8#t@nmL;j%gv~`_|^WusGlOcV$8`kJDUlGY zYu9TFmiZSxzKQiKh;x7Q>_D!y^{;pNN}^Im8jP?e>s7NaHup`x)adaXDR{p4yGW~>oRpo3O_ls+xg~BB*=0BluDZxo z7%ZA7`N6bOy!1ot^cSqwl2a->f*Q)1ykz=z23F?AW-eh>G1&L)Wyg#5y>nweRNlI^ zcR787cz+I7wga?hM%uX)3Dz|TT|%Z53xSr24oKa<_;p1wx;drR9*Gl6Bk z(+`?5-!8Jx;F8fARP$xxsO8kMPr`r;8@{o{kChwh4JWOV#;RnJ?4a zl-||@|MXHMG9zAcOFN_my!{ldSKPi`TSsq-4$SK%Ti^> zKX3ip<{e+yFTIK|(Vf`rH|I@}2RmE8OUR;@Q_{j~`EJgWy+1wb>eYxt{J%~l9LudQ z-n^OT&f6zy-`h(I1oj)eThOB5|tyElVk90{b|GwmDs#fVncHP!HD-MLIf73Gz?XgWM_sP)< z%6b2Km(K0LnZc=-X1lL3_ud(re=q&cyyd?&9vRJS&Yrt;s5_mB2!7yooDDPJG8^Vgljs>~6mzWg|75%KtG zNa68hZb@-7>9@iTe9z~*2$ZsiKe=NY_(S;4{-di-e|cZpT6MJWN}M5c=LfGZ4L%?1 z)1Ppy@hb56eeAs9zxLOU?5~{qB^P$^EBl@stM9Nz#vb2xpILY6*M6qNgh{Izuj-1j zUE$WZnpt}%`bcOwuR+Mv=0_jY<1?3JhwVQq)a@oFBAuDysLU-DdErEpo0!R_1ru3& zbu!R|jvhDMwBVd^>AaPnedeaWvI%}ZbJ?cfS1y%QJNo`-ML_&-!N&6au-+Gk*ohWxxLNU?QouO9oTg z7G8O@Fj0foIa9*xZ~$0HP1t1S0>01g#xsBL?=jo+;uxg47Mq=a+f3xIUBZ7yjW`%J@c{l!_tY0`$P|H zD^L%cEWL4>!J@yp5&z8A=)P^<%Hevp?9k7YMRV_PZ+|v9JiRbWUwQVa2V3Ut|7iXG z@6F2V9>3PP{=C=dTfVyF{ay9y{H|ATJMYbVu~%-<_U$L%)z&QP+7MvxP*{JG>t4!k zZnfKLD}_osg|=>89y+Pz`0C@UJuj~Cys+ZE|C;tW4;yz2A1J-@#Ee~qQD)v{I|~bi zP3xX+nNhAP)sd1mG0OXaQh(yZ)R#YxO$bfe!un{1 zCZ~n+QJvWjrdYbHxoRe8v^nPX^tY>{9l36%r%jJw+%UV+Kyc&E&?PUVKCfbWxWc;n zdabYe&m1F<>x%RG=Lo3GwH0JBEfz`L>Du^0tUqm=wtf0s7bP>vUXQREh1M*8(djM= zk6Nx+Zsk0AF00@P6}Qv!>=W4wtIocu-MDsP+Of?qHC{H`>)NK-UP`jq^n1f~j)`si zCoKB>S@rAG#-n0J?-^(P=dE@uF-*SY>LqGbt~966_uLLWn-dEkw=UjZvTTLEA$d6(^X@ctn;n?^Y_2o)1VW`rSzKZ{RFO$&u>En{DT#ydal;HgWIS*2MXb zjpuJ(&$z$U?$7<{@izka;=24ciwd(R@#-nhxUgms$E=^xl4qY5w(eVWZ3D;rZ~HlN zC5x6^I6ATY4A0Rt!QFz~7UeD*Ecx6#`xxIVYX11cyTX52VsQ7q!o^{azxM`A5qaIz z5OGkWL~zNK2;Xf}J0=x7sl?hmVQG=Ne@ZV@%s1nJ@)XB7(dkbLUkE%6XgqO+~!)VzYdtFEnD`})d8g^LR- zvsdz5kea|!==f{mhJX@H?Esq#7&E=TP8L}3EHb2zJIK5g=va>*30#& zK}EL@Uc0Q76Pohs)`4rw+sj2`Yc|h4bKYjxh3^}3|D2q<`p#o^QHSKWXIr(`oUeJ7 zylvO@g5GQCejdS(X0nCG?C717qsbh9dftlGOYdTjzdW?$3)?lX;?-;840u26dcn!H z;I?7DsK$Q#3F=c)LKr7an&nfJc~9@yVnK$&#*Y$9WA2FjZah7wR&`Z#eW`lj!QEWP z-~VKYOP*7rY7^Z!$0}i;U$(@QpgNJN@_a$5Iz+%&JN*PsA?xxVu@*ddVK@ ztRyXIbL-oyUyl?jCdXwvW~j6&Z1w9{)Oz*noI_KSlwNcgeCEnl&red#m|r~SQ(fz< zXU9H$(ac*?;npzU%2sewhoHdRUUsXjHv-3tL#G8FxtJGNmSTxbE0o)UeXFP6D)MsIoh=m` z9nGu7*Y@0a)pv$ypNv=z|C~xpbCF)P%L%f;@auUj+H2nx``*H`Xz9 zfBR*$(0KmDgC3u1HHF$swA`bT-|RTAqg-Gmvnz$!?~9xKlAn&o=}t#kw2mx`+biVp z*keKLncY#&g0r99?Ap0w32*F#(=W61baUS9 zEB23FwRfp#xY3Ib@7La(YjZun`azA~UG>WmGbc@$$v1_AXVK=TfwQ=n7%8G-d1Gdph2IdfiF00g<%|6}^3rZ@sV_x$F~ofczp^+YBF}cNZ{Ml2PAfat zTOM&g8$i!nf#GTBY@^fRC9M)TI)Ikxki>CepBWtu0V|0puf zt&cc1-(;d>T1%kA+bA9pFZs^P6MLFf3u@%J8tRwUsP)b|^xzmj->Z465+=R+qr({0 zBYJ3UVC$R7oAyj*G|gUeyzki5{d@NR{919Gd)aTfdlO;`FZyimP8W|cOP2a`cR|o! zmI+T^|4TczHa6$Zp``|A*R@7|bw7E-($+X*Yg={P9Q8vtSKg{Io4ZUhMzC;sUd2c0 zw*OnF^TyBa7ujF3fb}M49_!be3U%Bcwm1EbKKOU-gMVQkcK=!5 zS5I9v`T4z)-M^R3=AZP|VZxjGfPXv8RT-wkzZ<(d-qwdth9Qeopq%=ce|3LtC8QX7rAM|RnmKV@44kaK9qWY#j*g_ zW?LVxM&XQyQjXWMSyWVPqS&gp@aV@+Nb>SAWZN5ZX?Mk& zIodZY6Ax~7Kh)ID%Fn%zN36y!KX$?gZ|6g=r#l+#ne6{D`Dc$(R#Ayf$(N}YFDg4! zeXof;&&qf0)11SOxq6bbKU%$*og1-i^}P)e&(rD!-#C92T=;ONQ2k_MKl9jS_vSvZ zcD{L4zMdGdJ_?{eFG`M$Jnt literal 0 HcmV?d00001 diff --git a/test/PowerShellEditorServices.Test.Shared/PSHelp/7/Microsoft.PowerShell.Archive_eb74e8da-9ae2-482a-a648-e96550fb8733_en-US_HelpContent.zip b/test/PowerShellEditorServices.Test.Shared/PSHelp/7/Microsoft.PowerShell.Archive_eb74e8da-9ae2-482a-a648-e96550fb8733_en-US_HelpContent.zip new file mode 100644 index 0000000000000000000000000000000000000000..8d98199a7914080854cd9c3ca7c7c45670e0821c GIT binary patch literal 5529 zcmWIWW@Zs#U|`^2P}`Xj<$R$!>a7R^L$wwogDL|9gKuVXQGRiLT8UmjetBw9a7JoQ zj-F#tazm;J$kUCX z6J@TSJri<+WtK{k4$rQIswZnV{Z{Uu629a4m$qY0LQ^z4r&O(4ro+@5=U1O@pYiAU z|9^iU-R@RVOV@cGI8E-$Jl=WVIJ}#6dM>?mTrV*BpZVVW_yC>=eCocK9Rpz!#70t4c zFm`O3w$#%5mhtH+rvtoC2et7|(n;@4Jycpg^@+h&*)y3RTKyLK1|3nFb$pfgKKFCV zhRQSN*!R9Qd2n5>Gic($D;6J=wv?~%Q8N%)Y?EZ6AYN&rw}II+op14BkEs`*Iy3s7 zoUwCuM8b8KGc&C97Jd|Yrl0oCtWQ66^Yzrt-{15cWyzdlaPZSN-M72=S=moLU{Ief z({kp)i^m5fvp!7EST|px{kS{Jilk?&S6C?Au)Wu{SVd^n>XtYc&xtFxetsd(Gr#)Z z-PMeh>EHCd8YggyPP#RxRN~)9*K=yWSI@GIbnT7zKPrEsfB&x!AHTdk@A5CgZ+^zC zLp7E&T@z0PwXal*lxg~)_S|@f&cz;UuGiYn7XFgnfAN&Io0bbE z9Xc!WQ6o)|{SZqbqvf9Kquyap$`OFojYMk&q>=Q%Alh%Xn+m{!8PE=~TSRne*-)M%-?gb}h+K30w?WwHxGga$_Yq08y*DH=nH`O)TX}!1PpYcxTJ@d(b zaSeBvS#>`(eLW$0R@tiWX0@kPaj8`LxHWs`x_K}BQ+|eQHrW?)SyR&I@-n+ZV{yHVd;G>1kJ}eGHNTqTr z6#I?smRfC!xw(n*OUbKY&bO{JFV=Os?zC{P$1<;#+k-avWt?&RlUe`{1iN72nd ztFx-PVXB&5*Gey0*Ir#UFFiK&y!0{0c5lhOin<|_FMK$>Ch+8pWs_EA$_c1!&{$mg zz%940rFl)K_uA(3Ue5lXJ&&F}tSITBKBH46`OM}W0+&orNlF-;dakMdaMfNvr5_nd zKjY3aS!}Xi&XL^8UG}8AngO zU&6JnWTEEqgO!Z(kuIGjbz2vi<}4Mlb7Nhg`bNAc!soN*m(<#4eb*MJT$@$lbZiS_ zi>+|#go6&R-(@d3aEvMB+X;7t7^mb*S5uBUaNL^fAzmV`n!Ds_mqy^;B1xT<=hm$j z)X+T5s`1VL)}a|MwDmSFJS|zV`%918Mw#s;O3y3B614A_-(cAtzU0!uRk{quFP)z~ z5P0_4&D&0zed06z8CMyN6(`nt`W3~@W!LJTa5j=_p1D-i(*$X;n)^>94c7-(EbY0H zlk<4?+e1O?*G-;r=gx|$y*r+6G?m${*~Z#C%lgxV(rHI8@XeT)ovB+8-o*NG_STT8 zld45e@?FxkPX1Xbr@6d0)+I)R^UlGA@v81t$&cA*UDEV+XyE1Jp1AVH?FTG2&l1XV z)T)~=O#EHU|8{Qjsh=-(^vxLNUB9ALm0P7;HbvZN>baB0>Y2-B!upg%zim2u=wg|7 zboKO6+zC zHVm*movV~=cJM=8+>({PQ$01R@8lTfom}4!T zo%3~poX!S|sj(Lv8jiLebo2ua$|(JZNq8s}U~kt_YpRn6-Y>Bbm6JOD;~2v~>|byh5d9@&-{CLo}iO=(1 z)GIJ9SnK<|{?O$Hvq|Tt%VyrbJ&!My!D63F!7-Bw?E()C?YJiYstw4rWn{>GgpbuWmjkqThsfBY0oOt!fRm(4+8fbeRS=)zfE4vzbA=I zC!YJ){5$g8UoJrB)x4@!yZT3;)uBWYf9bUI^MCy5L?-Sm=2+BkQ+?;hiO+{wF7&z1 z`}^Ja4-yhP74lv#&)Maax^u136?NzL^V-35G|M9YwqGeLK*Wn}YRxea=iDvkBtw*&oeHs@BKQ{a^B|$I&q}$(*}^ROSi+9nVj=yet;02Y^=3uhu+NN z7g)!(%D(v2e9NuBHNO2c?VtVjaPRr`Z|xeUJ-PmB`3e2Q*j+ul@U6q~qJVqb z6^_fcSmc=QY2HoyJn@yUe;s@KX;Wxa+)?c~E$!_}lAH6imTivN{XcVt zuK%Hmv{?1Gn`GzgYLndbWcH=Gl_iJoPu|CRq|~I}ob5#R%*WRcz1DL6emPtR{Fb+6hG?0B(iRzacR#jsb0tUsTeo?d9xJ@NLb2RSzNk4@kIy|~9b z;>+EYPpY}6yqi0%I=_D7_uQ#fzn${qU(_16-n*x^`}@bvR95c0%p%{nM;x4;vpZqa z-L#c1*PS}g-PwFmiL*bv|9V!Z+i|AecmIx@6-lpnkjGjO{ceUA|87pfM;}Ct>jKV=NN-5gWs|Ec5rPOCqgrG&i62V6gP{3lg5an@{xiwVK$bj4QIW zGJBeR?Yz`)`c8PhS&w7Wxp|yTVZ|c8y8@GzEnPfw_R{#*w%sl_ReK}#{AOtU*48)o zczDF}K(OwTlk1u|bak0HH+JPN*~9+kpMR0q8P&}`yCYUTdU8)9{m#P8Q@Hyr{9?A85i8@W;SA&N z)x9-0>+j_ibs9%c+ifBN@?=APJGoAhn#7sJ^P6yyqfdR*OvK%bUjK}iK6|yWb%D;>Kta3b z%UibbS~8V9J>`CiXX;(AH$kEW?`9_ydpznm-SB-1=iYkZ4<}`EtS*}8Ua|UGyHQ0o z;jV~+-b#%e?Fm}fqt9V@4$(b+gHWw>dp#el~a~}qWgG~6`$>-l2-w% zu54>vs)I#F*qmldvR+#M-ckF}`kohJ%BQ4Mx9W=DoHRu=qr;2s)vO;Y!+u=f znEWm*!@lTJe7NVyyS&@>#kH+fow>_5;q~(MWumcBH=u zEc+GB5kBR7)pKXnyVg70%+viMf*)= zQ+N9Qdur9Gx@Y>s9})Tw=JrgE<37LD!B5;^e!48rsqGS9CoY>^v)eVCWzpH{n>?FV zo?6;^P+nrs$#Sj(7Bh{0uH85zdc|pmV=EljMVs+wd;IxZq;hFi#vUt)`{}_uQ&=~y zZhGT8<-XOClnd)(9Ae*ORIKIG(mN6|{ntjZNd5a3lNf!4w}w1C5!f4{Y_4!UBJ=Ii z^Ig&zf{9AoJJKQ~FK4tdp6>H8o20X@cCYHoUuW%p+F6%3UD#ycGVPjVP>#c{c}y{k z!b|cGow+_??yl}^$KN53L(XSEDagyXZ#T6oWS{ba`&PC>pYkeCoBq~I?P0hR`DNF` zUn`~=OwKr5U1;X|)$mmeU!|*E&gSSdRieA34eN!K67o#tK4rK%%jl&<#wzK(oi>T@ zl4*8EAD8Z~2@lsE-Wwid*&D8V-E`MGA1RX!(fcIMaX+YTS?iP0+q?YEA)T--mZ6(^ zm*kmlyt1)FEc1372P-@KFCjiZR(*zK*7^j|w?B27=AOT)vhC}>h9jCesc%Y_#eLT- zYRhkbJmpn`@#aGDNZAduj2Vp-xmT{$msh-IVU%J1JML6K=h;`gMY#((WqWV-%yXSt zGk-?IUEYqO-=$ZtrESVFUlZf?_S5|Y!;@LZTk~V5?MhW%r}Fak$GjVJuOx<--fCX= zyrkA>xz=T|l(fl{Sf)BXpXDBzD!`L*buo8)=8pEgyVoln2w217xYqkf*kbv=)8e*@ zMCq#K6dRZ~Ej#vgT2Y@w`Ug9&yYEU?9*i`Z8r@YcZNL)tqv(|0k<Zl=l%5WY_oivh5O#W z=Dt7A_069JIXC|QeXxCh+t&XNKfhGq{3q4QUVLx9(Skag>%m7QuU^{1eQlpy;;Nf3 z^-6X=1G}a`Yg8X^QPHb7D_%9RC7{ZBQGOSGaq3_my&YXRAn!^2u?JReG9aj6drgM!!au3sn?9>Gp zetGPYS@|i6bDv$m_=bP0zRa9+Nrct>@1q5;SI+VZ+HQBcA?z5}$156%&qJc_U1T{U z^?IZC<5TkY-mls5_+v%8_O@#C{HhHyN+w+7;ehA-aJ#N`Jz56Y`Gn0>x_H=A9}EcbEVTley8 zcWqt&dDWM51z(gOFZt|fB(HK?>DWgw?dhCa{qgDF+TmzPt4SHtIr4V(9MD$z z-#ab4e(}>^54qPT$t~)-c_@En$@xc>CoB0?mfLgRJAESd&}w_`d(7A8v%XI%+q?OJ z`S;w|dX+!xWI5N@+92}^bFd! zGPKRT5%WCRc$JHKt+~(l6@@y-`qR(X9ZQy%+bXv(@%)q>pH51;uS^R5UCI=b^jq?j zTH6b`HOb|dvgFnEmR}W`74}$Fe!;%U$qDnzZ(WI6;B#L^^cDVOz&M;`|s0##sF_dCJ_eQBc==}fT;1)0B=?{kS<0B OMuuPp1_lFB5Dx&;%C%Jh literal 0 HcmV?d00001 diff --git a/test/PowerShellEditorServices.Test.Shared/PSHelp/Microsoft.PowerShell.Archive_eb74e8da-9ae2-482a-a648-e96550fb8733_HelpInfo.xml b/test/PowerShellEditorServices.Test.Shared/PSHelp/Microsoft.PowerShell.Archive_eb74e8da-9ae2-482a-a648-e96550fb8733_HelpInfo.xml new file mode 100644 index 0000000..1b6473d --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/PSHelp/Microsoft.PowerShell.Archive_eb74e8da-9ae2-482a-a648-e96550fb8733_HelpInfo.xml @@ -0,0 +1,10 @@ + + + https://aka.ms/powershell51-help + + + en-US + 5.2.0.0 + + + \ No newline at end of file diff --git a/test/PowerShellEditorServices.Test.Shared/PSHelp/Microsoft.PowerShell.Archive_eb74e8da-9ae2-482a-a648-e96550fb8733_en-US_HelpContent.cab b/test/PowerShellEditorServices.Test.Shared/PSHelp/Microsoft.PowerShell.Archive_eb74e8da-9ae2-482a-a648-e96550fb8733_en-US_HelpContent.cab new file mode 100644 index 0000000000000000000000000000000000000000..15ab9f029b0fa7babd0ee46f2c294776b978856a GIT binary patch literal 5137 zcmeYbc6MW6U|nna_ZpLn^Ut@qtAtTu3j6^rTuTEjz#ms1uc?2jAtf&S^e{Y z`zP%s(lhEO?e=j}opeWilEi|GcQd}6XD_Jx{q*`~{p&lEwjYXE+5P$CP&k+`P5SNc%OYWzi!H(Gg^4cTfOLX&YaFU^ZkqFt>$cg*lIgJ<=oWen)D zoU?5G+u05p>E3dTO~1d#ws~FG^4l#Nx4!NE#MEzQwy7k6;qBySktccUf8F7D*OEHt z=+`?+i8cZAjB;$cw_~nZ(2W;4*CyugocT6! z`Qy43=l;I=KBImzXPI%>7vApAE}X|7$u9cpGRrKf%CIS8m%t6h>ux!noNsG2Z@OL- z(y08?r@zoYPE*}m)Z>>AKVlYO9=$QTz4TXx+2}`%nKmz2nA@yz{&B^pCC$;3=KM zt$ya6s%?p^7Wb@!oX@1W{nU2V$bMbcwQq8&ZBuMaywL@(!}%AIj!dwz3o@FnH{sOJ zsAr*aDce7#H$Jxe|19!IoXKl-?tqkYbFXg`W%v1Ckn2)a6~Ep6d*)+*@9*N@{>rb9 z+yC>I`?LBxYvp(9G{m1anyIqrMeeGQ)hu(&qf;-uSt}VX@?`ZXl^XZZACkYe3A8RV z*v!_SuN`na?$sThGskYLUdo-zux5UK$GUxWkFPt5El`|yXL;LGr21;r`|Q0!X?*vh z4<<<&?mlpD^Tin&{$*=l)=j@!p|e|4CO*IV`Gr2l($ml1aq`~Xe6OJ`W;d^6s(t8# zCWSDo;7-onPu)JX3hD2CHS5~aOWm#fA0L)$dN)e#(>Zl&uKzs;lQBy%>`b}+MTmqp#1p4jR&vDY36*K zwAfVQe`aLvl!G;~$7FB6=vrIx@PnYgkeSKteeS&l(k$iMU&d@%Zv8ddQEcO?No~7y zcKLZ7zjq_&23wt{jd7U5X}>LN-t|>m$1L44H#ya&&y~|IS2a|qc=85|=5#|Frq%z; zb_ z+PKuY(*5~oy<`bjJCYgEF)eiezORc~INOTbL!D0=%9ZGy4eO5Un0F}V?5h0B4yB3O zx7JM;sBuy+F0kDC*(%jsec$cRm*xMd1tltUvVE@=|D06I{#oGsMVq&LoHwTN^nQQ( zptyI-`x}R)%RaAo`lZgTKB0W`D(w41Q7 z@Z~%ym)VaTckl2t4nBL)Luy;J+>G4$rEg1b{jOQ{z3RKwwJF-%@7DYAKASLE^z{4s zH$~SKcRrjFP&8+kuV&dhruTZ=PrUoKdy9qaOO=B7?&%nmvBuey$eDa^ zI=9W^IIpMU`H*_+ z-R}deZ%!B))yjEw(PI}!uaO#_f6gQ zmuzF+pHt3Fnqp^JqTIjw{NiVmC7<|Iil#RQNeHjl)bil@YwZ(LJ@DpY{b z`*eBPgYP?HqARaYW8&ssW|X?#vCuO7&4nqVhMZ@t1N9^Hs_y&*>6tCtCZ``b_NwHi$qvM?#;+3oh*CvV{LLtXrF1#`u;WB z3%O0-l}+1P!oKt5@?!D;3_P`S-rWxpl97``;NYd2Ok7x!U)o>YB|DUp4Q4 z^krMXr@1fIZP`-AyR+S&ZNrq8Cz}g59I~!YI-ATcDZbrj7rTMwntCC}E3$WNK3#wR zKzm1hd+7AH@zYnn7S#`|E@n45cV{b&7OW4ZslfVr2p4|wS@+n$shwk*TEwh`%%-KB1R1;1y&B{gt5{wLF8FTE_~pgjBVY28s>}`d z&Yp44*C*0^@9Y`pX1wU>#31aZ8mlqYaIRNAFr6WusfP1|Dh8u={?X6-8LFKYTweug zLdE{hzilS)ulB${c14MA8cEh~Z>>E&Va>})>lm%JMC=un;PTm|Y8{n(dcqT%4R7n^ z-~U*a5xMDU_#q}|>%c`PE|)*CwVM}v;5us!GV$Fp#J0RuJ?0_j^lb&1+#sTRuTS(0 z-#`!Yy%Wi*Qx$b&?N!(&zy``(~wB6k9Sdw(9Jjx`Q_a3cM>+Z~^X~mOBcAiVYc}6;GN$0FPeJP2 zOcU-mA6|V}^?ZSL+Je=;gTY~GWix0uS9Z};D$mA12% zVV29Ebv*GdZ#fIrH_eC;Z}gZekj)wM#879C$^QuHO+h`&9CUP98J7pzd8_|8siPDY z^_pX5)Vi~$%UExUq$lMHe>$kw9606K`^7Qe)b+)Wt*^L$q<+>yMbFsV8A6L}Z~d08 zeP^(9VVFjIj-dX-9ILM151w+``+fay`u&wan8S_32UmZt@BDP_eE5-O=1(2|xlu3v znaj8?aZ50~^|R|sy``jz#g5{4N{>XXTwGp(8OmoxJLo@>imL}8LOu6tT=W0SGeE= zHaiApm0f2Qe=uENnGk6A!QbSCVZm%cMi+;*4ZFW6%2fS3`gV$kS6{e+!KDXnM>n&d z%n2&#FpO9-S%m3hnqG=!T~T7vC&3SXW;TnfXYaUaD!Sm6Drf4#hdhB|chhxS{bwGx z+Kb(Sw+ z6*+e~VM4p-e&2t)Z7ocq^pB?mpRu#cicf#Y_$4uD;sSfOu!CjBtiJKb_tm)FXUj}2 z`dmN1KF#T7^+9&u>6UXk40oCwvT>3$cK4s%A))-a?6=Wk-^YHZ{wr;9Rp@aLPk$Hu zm}9r=8#WW3TaOekT#y!I7qz=-aO&oFX#s!7gUf`3la@_$S}nOygY%l@`{gYsk{_#{ z*!fC@U2b=(jmoBq4*mz)ChVVA-AS+ImD|T*dn!Y+yYH9AaxNam+v-!KJ69T&mUxCP zS7$9<q_3V%2t)-qXkF4^)_mioW>v4jI-A&?$d`Hw|7thqj9HnP+ z)q(a89D_nn3C)?)?<5+;YhkbR_0Wpqm3=?89R8fu6287LUsU$eCoOIv5$>%k8!jZx zxt%@p{(-I^O9gWc7qtW?)qJwi^jtDQ*6DQjnhyE75!p%Y+p{)(pSt9XQRkw!39mCH zw|RYX_f2^HcyX<(`0X6^&z}>`zSw3-)h9jG>ie-;Xu-vQ&kl)ROaJ!NdDpJ+g5GQC zejLG%W-^7w?AYp)sHh)va^8xTOW*cAE=gQf!koQy-_>dQGo&hVJ&QRnyq&RKXv%%r z?q)`%RSYYQT-9b8fY0^J(YyJvn=C|EkFv4`bUNm*+68H>lb! zG_TjePt@SPiYU)1?v7szUbZbMnjy23J?EY^Q?Vdl(n0wrm(TG2n=oDcYaf#Yn{x0b zp6-ixFL^N!2>RR_j_G~7VS0=S7*E&b+lbWD?=88r3(w&V=f;L6m8EVGe60=Tnr@Gs? zKaXvGO>WmLjHfQ!6x^;3_1m~^TwqP^8_=|S} zWPTm$69X~${I4&tV;^4kj_EFBdQsD4kk(}{t!M*G^lAKl_w@vNd^h4}jn&QeIA>tR1(Rxdd2fTyVG0*b~Im!}^Qy z&bisEXVm;Ok?LXLo~lsjVfWPIWzVKJ>6t9;Mu{G34}N4^@qDF!rC80yXMxj~%ctFK z5@iave15NYJ>W>%0j7kc{Tm-oD!J0VuIaVK!Q>jbjk`G)U8%d8yLzV1?OEpHJ9qza z&{2Ex;H>Q%mG9HsVm#L$y|+EBeA8;VO=&vLSJi`eG&QNs;90_9@@wtGz*k&MjJn3V z3d}h97nV;|@Q)Mq6p)Bz+Q#*F!(l@!$LW4*TP$bvE?8M`*U3oYAjiEU!dtD6@$cws z|KYiCD}(bQuKBwz=GOfxYl`~FKGi1vsLSS`cXWFF*MF*+C0lN_Si?j8ZQ&jN$vTQm z3X5O)JbdEEre1FKSY=M{qQ0Ezxw&e~`1p@+ztK8>-QyR}T<+bdx>ol`b>{DW`CV@d z|F5^OnVGgW(PfGKtW66Kb@Z6LowM`0aL>HC>y)OO@_QKPUNexJx;^oLm)XYsFGC+M zDz}#N-IQE$_#|UPO9G1~vt!x+%Gn~rxIRsQlUFsynY^u?alSwo%4YQh7P6MxH%<5$NzHibsS?zVqV5EsyyMvPAV5{(JrM;WBMK)GM1FY#Vwm zKjdem%oYjlmL%Ps5|g*=dCX{--EzF|*i>!TnEhH;Z#JE;oM;> + + + 0.9.0-beta + netstandard2.0 + Microsoft.PowerShell.EditorServices.Test.Shared + + + + + + + <_Parameter1>Microsoft.PowerShell.EditorServices.Test + + + diff --git a/test/PowerShellEditorServices.Test.Shared/Profile/ProfileTest.ps1 b/test/PowerShellEditorServices.Test.Shared/Profile/ProfileTest.ps1 new file mode 100644 index 0000000..b7229c2 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Profile/ProfileTest.ps1 @@ -0,0 +1 @@ +Assert-ProfileLoaded \ No newline at end of file diff --git a/test/PowerShellEditorServices.Test.Shared/Profile/Test.PowerShellEditorServices_profile.ps1 b/test/PowerShellEditorServices.Test.Shared/Profile/Test.PowerShellEditorServices_profile.ps1 new file mode 100644 index 0000000..b08cacc --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Profile/Test.PowerShellEditorServices_profile.ps1 @@ -0,0 +1,9 @@ +if (-not $PROFILE) { + throw +} + +function Assert-ProfileLoaded { + return $true +} + +Register-EngineEvent -SourceIdentifier PowerShell.OnIdle -MaxTriggerCount 1 -Action { $global:handledInProfile = $true } diff --git a/test/PowerShellEditorServices.Test.Shared/References/FileWithReferences.ps1 b/test/PowerShellEditorServices.Test.Shared/References/FileWithReferences.ps1 new file mode 100644 index 0000000..7a4e4cf --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/References/FileWithReferences.ps1 @@ -0,0 +1,3 @@ +. ./SimpleFile.ps1 + +My-Function "test" diff --git a/test/PowerShellEditorServices.Test.Shared/References/FindsReferencesOnBuiltInCommandWithAlias.cs b/test/PowerShellEditorServices.Test.Shared/References/FindsReferencesOnBuiltInCommandWithAlias.cs new file mode 100644 index 0000000..2f39f29 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/References/FindsReferencesOnBuiltInCommandWithAlias.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.References +{ + public static class FindsReferencesOnBuiltInCommandWithAliasData + { + public static readonly ScriptRegion SourceDetails = new( + file: TestUtilities.NormalizePath("References/SimpleFile.ps1"), + text: string.Empty, + startLineNumber: 14, + startColumnNumber: 3, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/References/FindsReferencesOnFunction.cs b/test/PowerShellEditorServices.Test.Shared/References/FindsReferencesOnFunction.cs new file mode 100644 index 0000000..d7a8df3 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/References/FindsReferencesOnFunction.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.References +{ + public static class FindsReferencesOnFunctionData + { + public static readonly ScriptRegion SourceDetails = new( + file: TestUtilities.NormalizePath("References/SimpleFile.ps1"), + text: string.Empty, + startLineNumber: 3, + startColumnNumber: 8, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/References/FindsReferencesOnTypeSymbols.cs b/test/PowerShellEditorServices.Test.Shared/References/FindsReferencesOnTypeSymbols.cs new file mode 100644 index 0000000..ecd8926 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/References/FindsReferencesOnTypeSymbols.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.References +{ + public static class FindsReferencesOnTypeSymbolsData + { + public static readonly ScriptRegion ClassSourceDetails = new( + file: TestUtilities.NormalizePath("References/TypeAndClassesFile.ps1"), + text: string.Empty, + startLineNumber: 8, + startColumnNumber: 12, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly ScriptRegion EnumSourceDetails = new( + file: TestUtilities.NormalizePath("References/TypeAndClassesFile.ps1"), + text: string.Empty, + startLineNumber: 39, + startColumnNumber: 8, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly ScriptRegion ConstructorSourceDetails = new( + file: TestUtilities.NormalizePath("References/TypeAndClassesFile.ps1"), + text: string.Empty, + startLineNumber: 9, + startColumnNumber: 8, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly ScriptRegion MethodSourceDetails = new( + file: TestUtilities.NormalizePath("References/TypeAndClassesFile.ps1"), + text: string.Empty, + startLineNumber: 36, + startColumnNumber: 16, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly ScriptRegion PropertySourceDetails = new( + file: TestUtilities.NormalizePath("References/TypeAndClassesFile.ps1"), + text: string.Empty, + startLineNumber: 35, + startColumnNumber: 12, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly ScriptRegion EnumMemberSourceDetails = new( + file: TestUtilities.NormalizePath("References/TypeAndClassesFile.ps1"), + text: string.Empty, + startLineNumber: 45, + startColumnNumber: 16, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly ScriptRegion TypeExpressionSourceDetails = new( + file: TestUtilities.NormalizePath("References/TypeAndClassesFile.ps1"), + text: string.Empty, + startLineNumber: 34, + startColumnNumber: 12, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + + public static readonly ScriptRegion TypeConstraintSourceDetails = new( + file: TestUtilities.NormalizePath("References/TypeAndClassesFile.ps1"), + text: string.Empty, + startLineNumber: 25, + startColumnNumber: 22, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/References/FindsReferencesonVariable.cs b/test/PowerShellEditorServices.Test.Shared/References/FindsReferencesonVariable.cs new file mode 100644 index 0000000..90c5865 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/References/FindsReferencesonVariable.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.References +{ + public static class FindsReferencesOnVariableData + { + public static readonly ScriptRegion SourceDetails = new( + file: TestUtilities.NormalizePath("References/SimpleFile.ps1"), + text: string.Empty, + startLineNumber: 10, + startColumnNumber: 17, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/References/FunctionReference.ps1 b/test/PowerShellEditorServices.Test.Shared/References/FunctionReference.ps1 new file mode 100644 index 0000000..279f262 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/References/FunctionReference.ps1 @@ -0,0 +1,24 @@ +function BasicFunction {} +BasicFunction + +function FunctionWithExtraSpace +{ + +} FunctionWithExtraSpace + +function + + + FunctionNameOnDifferentLine + + + + + + + {} + + + FunctionNameOnDifferentLine + + function IndentedFunction { } IndentedFunction diff --git a/test/PowerShellEditorServices.Test.Shared/References/SimpleFile.ps1 b/test/PowerShellEditorServices.Test.Shared/References/SimpleFile.ps1 new file mode 100644 index 0000000..819f2a2 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/References/SimpleFile.ps1 @@ -0,0 +1,25 @@ +function My-Function ($myInput) +{ + My-Function $myInput +} + +$things = 4 + +$things = 3 + +My-Function $things + +Write-Output "Hello World"; + +Get-ChildItem +gci +dir +Write-Host +Get-ChildItem + +My-Alias + +Invoke-Command -ScriptBlock ${Function:My-Function} + +[string]$hello = "test" +Write-Host $hello diff --git a/test/PowerShellEditorServices.Test.Shared/References/TypeAndClassesFile.ps1 b/test/PowerShellEditorServices.Test.Shared/References/TypeAndClassesFile.ps1 new file mode 100644 index 0000000..ac2888f --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/References/TypeAndClassesFile.ps1 @@ -0,0 +1,46 @@ +Get-ChildItem ./file1.ps1 +$myScriptVar = 123 + +class BaseClass { + +} + +class SuperClass : BaseClass { + SuperClass([string]$name) { + + } + + SuperClass() { } + + [string]$SomePropWithDefault = 'this is a default value' + + [int]$SomeProp + + [string]MyClassMethod([string]$param1, $param2, [int]$param3) { + $this.SomePropWithDefault = 'something happend' + return 'finished' + } + + [string] + MyClassMethod([MyEnum]$param1) { + return 'hello world' + } + [string]MyClassMethod() { + return 'hello world' + } +} + +New-Object SuperClass +$o = [SuperClass]::new() +$o.SomeProp +$o.MyClassMethod() + + +enum MyEnum { + First + Second + Third +} + +[MyEnum]::First +'First' -is [MyEnum] diff --git a/test/PowerShellEditorServices.Test.Shared/SymbolDetails/FindsDetailsForBuiltInCommand.cs b/test/PowerShellEditorServices.Test.Shared/SymbolDetails/FindsDetailsForBuiltInCommand.cs new file mode 100644 index 0000000..e0818c1 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/SymbolDetails/FindsDetailsForBuiltInCommand.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.SymbolDetails +{ + public static class FindsDetailsForBuiltInCommandData + { + public static readonly ScriptRegion SourceDetails = new( + file: TestUtilities.NormalizePath("SymbolDetails/SymbolDetails.ps1"), + text: string.Empty, + startLineNumber: 1, + startColumnNumber: 10, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/SymbolDetails/SymbolDetails.ps1 b/test/PowerShellEditorServices.Test.Shared/SymbolDetails/SymbolDetails.ps1 new file mode 100644 index 0000000..3143ede --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/SymbolDetails/SymbolDetails.ps1 @@ -0,0 +1,39 @@ +Expand-Archive -Path $TEMP +# References Test uses this one +Get-Process -Name 'powershell*' + +<# +.Synopsis + Short description +.DESCRIPTION + Long description +.EXAMPLE + Example of how to use this cmdlet +.EXAMPLE + Another example of how to use this cmdlet +#> +function Get-Thing { + [Alias()] + [OutputType([int])] + Param + ( + # Param1 help description + [Parameter(Mandatory = $true, + ValueFromPipelineByPropertyName = $true, + Position = 0)] + $Name + ) + + Begin + { + } + Process + { + return 0; + } + End + { + } +} + +Get-Thing -Name "test" diff --git a/test/PowerShellEditorServices.Test.Shared/Symbols/DSCFile.ps1 b/test/PowerShellEditorServices.Test.Shared/Symbols/DSCFile.ps1 new file mode 100644 index 0000000..defec68 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Symbols/DSCFile.ps1 @@ -0,0 +1,4 @@ +# This file represents a script with a DSC configuration +configuration AConfiguration { + Node "TEST-PC" {} +} diff --git a/test/PowerShellEditorServices.Test.Shared/Symbols/FindSymbolsInDSCFile.cs b/test/PowerShellEditorServices.Test.Shared/Symbols/FindSymbolsInDSCFile.cs new file mode 100644 index 0000000..6e3d45f --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Symbols/FindSymbolsInDSCFile.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.Symbols +{ + public static class FindSymbolsInDSCFile + { + public static readonly ScriptRegion SourceDetails = + new( + file: TestUtilities.NormalizePath("Symbols/DSCFile.ps1"), + text: string.Empty, + startLineNumber: 0, + startColumnNumber: 0, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Symbols/FindSymbolsInMultiSymbolFile.cs b/test/PowerShellEditorServices.Test.Shared/Symbols/FindSymbolsInMultiSymbolFile.cs new file mode 100644 index 0000000..7743760 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Symbols/FindSymbolsInMultiSymbolFile.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.Symbols +{ + public static class FindSymbolsInMultiSymbolFile + { + public static readonly ScriptRegion SourceDetails = + new( + file: TestUtilities.NormalizePath("Symbols/MultipleSymbols.ps1"), + text: string.Empty, + startLineNumber: 0, + startColumnNumber: 0, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Symbols/FindSymbolsInNewLineSymbolFile.cs b/test/PowerShellEditorServices.Test.Shared/Symbols/FindSymbolsInNewLineSymbolFile.cs new file mode 100644 index 0000000..0be43f8 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Symbols/FindSymbolsInNewLineSymbolFile.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.Symbols +{ + public static class FindSymbolsInNewLineSymbolFile + { + public static readonly ScriptRegion SourceDetails = + new( + file: TestUtilities.NormalizePath("Symbols/NewLineSymbols.ps1"), + text: string.Empty, + startLineNumber: 0, + startColumnNumber: 0, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Symbols/FindSymbolsInNoSymbolsFile.cs b/test/PowerShellEditorServices.Test.Shared/Symbols/FindSymbolsInNoSymbolsFile.cs new file mode 100644 index 0000000..33fe958 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Symbols/FindSymbolsInNoSymbolsFile.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.Symbols +{ + public static class FindSymbolsInNoSymbolsFile + { + public static readonly ScriptRegion SourceDetails = + new( + file: TestUtilities.NormalizePath("Symbols/NoSymbols.ps1"), + text: string.Empty, + startLineNumber: 0, + startColumnNumber: 0, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Symbols/FindSymbolsInPSDFile.cs b/test/PowerShellEditorServices.Test.Shared/Symbols/FindSymbolsInPSDFile.cs new file mode 100644 index 0000000..3370980 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Symbols/FindSymbolsInPSDFile.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.Symbols +{ + public static class FindSymbolsInPSDFile + { + public static readonly ScriptRegion SourceDetails = + new( + file: TestUtilities.NormalizePath("Symbols/PowerShellDataFile.psd1"), + text: string.Empty, + startLineNumber: 0, + startColumnNumber: 0, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Symbols/FindSymbolsInPSKoansFile.cs b/test/PowerShellEditorServices.Test.Shared/Symbols/FindSymbolsInPSKoansFile.cs new file mode 100644 index 0000000..03d26d8 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Symbols/FindSymbolsInPSKoansFile.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.Symbols +{ + public static class FindSymbolsInPSKoansFile + { + public static readonly ScriptRegion SourceDetails = + new( + file: TestUtilities.NormalizePath("Symbols/PesterFile.Koans.ps1"), + text: string.Empty, + startLineNumber: 0, + startColumnNumber: 0, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Symbols/FindSymbolsInPesterFile.cs b/test/PowerShellEditorServices.Test.Shared/Symbols/FindSymbolsInPesterFile.cs new file mode 100644 index 0000000..844e047 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Symbols/FindSymbolsInPesterFile.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Services.TextDocument; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared.Symbols +{ + public static class FindSymbolsInPesterFile + { + public static readonly ScriptRegion SourceDetails = + new( + file: TestUtilities.NormalizePath("Symbols/PesterFile.tests.ps1"), + text: string.Empty, + startLineNumber: 0, + startColumnNumber: 0, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Symbols/MultipleSymbols.ps1 b/test/PowerShellEditorServices.Test.Shared/Symbols/MultipleSymbols.ps1 new file mode 100644 index 0000000..2831ea3 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Symbols/MultipleSymbols.ps1 @@ -0,0 +1,56 @@ +$Global:GlobalVar = 0 +$UnqualifiedScriptVar = 1 +$Script:ScriptVar2 = 2 + +"`$Script:ScriptVar2 is $Script:ScriptVar2" + +function script:AFunction {} + +filter AFilter {$_} + +function AnAdvancedFunction { + begin { + $LocalVar = 'LocalVar' + function ANestedFunction() { + $nestedVar = 42 + "`$nestedVar is $nestedVar" + } + } + process {} + end {} +} + +workflow AWorkflow {} + +class AClass { + [string]$AProperty + + AClass([string]$AParameter) { + + } + + [void]AMethod([string]$param1, [int]$param2, $param3) { + + } +} + +enum AEnum { + AValue = 0 +} + +AFunction +1..3 | AFilter +AnAdvancedFunction + +<# +#region don't find me inside comment block +abc +#endregion +#> + +#region find me outer +#region find me inner + +#endregion +#endregion +#region ignore this unclosed region diff --git a/test/PowerShellEditorServices.Test.Shared/Symbols/NewLineSymbols.ps1 b/test/PowerShellEditorServices.Test.Shared/Symbols/NewLineSymbols.ps1 new file mode 100644 index 0000000..5ca44f0 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Symbols/NewLineSymbols.ps1 @@ -0,0 +1,28 @@ +function +returnTrue { + $true +} + +class +NewLineClass { + NewLineClass() { + + } + + static + hidden + [string] + $SomePropWithDefault = 'some value' + + static + hidden + [string] + MyClassMethod([MyNewLineEnum]$param1) { + return 'hello world $param1' + } +} + +enum +MyNewLineEnum { + First +} diff --git a/test/PowerShellEditorServices.Test.Shared/Symbols/NoSymbols.ps1 b/test/PowerShellEditorServices.Test.Shared/Symbols/NoSymbols.ps1 new file mode 100644 index 0000000..3582fde --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Symbols/NoSymbols.ps1 @@ -0,0 +1 @@ +# This file represents a script with no symbols diff --git a/test/PowerShellEditorServices.Test.Shared/Symbols/PesterFile.Koans.ps1 b/test/PowerShellEditorServices.Test.Shared/Symbols/PesterFile.Koans.ps1 new file mode 100644 index 0000000..1c745d0 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Symbols/PesterFile.Koans.ps1 @@ -0,0 +1,23 @@ +Describe "Testing Pester symbols in a PSKoans-file" { + Context "Simple demo" { + BeforeAll { + + } + + BeforeEach { + + } + + It "Should return Pester symbols" { + + } + + AfterEach { + + } + } + + AfterAll { + + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Symbols/PesterFile.tests.ps1 b/test/PowerShellEditorServices.Test.Shared/Symbols/PesterFile.tests.ps1 new file mode 100644 index 0000000..54b2917 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Symbols/PesterFile.tests.ps1 @@ -0,0 +1,43 @@ +BeforeDiscovery { + +} + +BeforeAll { + +} + +Describe "Testing Pester symbols" { + Context "When a Pester file is given" { + BeforeAll { + + } + + BeforeEach { + + } + + It "Should return it symbols" { + + } + + It "Should return context symbols" { + + } + + It "Should return describe symbols" { + + } + + It "Should return setup and teardown symbols" { + + } + + AfterEach { + + } + } + + AfterAll { + + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Symbols/PowerShellDataFile.psd1 b/test/PowerShellEditorServices.Test.Shared/Symbols/PowerShellDataFile.psd1 new file mode 100644 index 0000000..f8f4e32 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Symbols/PowerShellDataFile.psd1 @@ -0,0 +1,5 @@ +@{ + property1 = "value1" + property2 = "value2" + property3 = "value3" +} diff --git a/test/PowerShellEditorServices.Test.Shared/TestUtilities/TestUtilities.cs b/test/PowerShellEditorServices.Test.Shared/TestUtilities/TestUtilities.cs new file mode 100644 index 0000000..3c65f27 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/TestUtilities/TestUtilities.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace Microsoft.PowerShell.EditorServices.Test.Shared +{ + /// + /// Convenience class to simplify cross-platform testing + /// + public static class TestUtilities + { + private static readonly char[] s_unixNewlines = new[] { '\n' }; + + /// + /// Takes a UNIX-style path and converts it to the path appropriate to the platform. + /// + /// A forward-slash separated path. + /// A path with directories separated by the appropriate separator. + public static string NormalizePath(string unixPath) + { + if (unixPath == null) + { + return unixPath; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return unixPath.Replace('/', Path.DirectorySeparatorChar); + } + + return unixPath; + } + + /// + /// Gets a normalized path from the directory of this assembly to the given path under the + /// shared test folder. + /// + /// A path or file under the shared test folder. + /// The normalized and resolved path to it. + public static string GetSharedPath(string path) + { + // TODO: When testing net462 with x64 host, another .. is needed! + return NormalizePath(Path.Combine( + Path.GetDirectoryName(typeof(TestUtilities).Assembly.Location), + "../../../../PowerShellEditorServices.Test.Shared", + path)); + } + + /// + /// Take a string with UNIX newlines and replaces them with platform-appropriate newlines. + /// + /// The string with UNIX-style newlines. + /// The platform-newline-normalized string. + public static string NormalizeNewlines(string unixString) + { + if (unixString == null) + { + return unixString; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return string.Join(Environment.NewLine, unixString.Split(s_unixNewlines)); + } + + return unixString; + } + + /// + /// Platform-normalize a string -- takes a UNIX-style string and gives it platform-appropriate newlines and path separators. + /// + /// The string to normalize for the platform, given with UNIX-specific separators. + /// The same string but separated by platform-appropriate directory and newline separators. + public static string PlatformNormalize(string unixString) => NormalizeNewlines(NormalizePath(unixString)); + + /// + /// Not for use in production -- convenience code for debugging tests. + /// + public static void AwaitDebuggerHere( + [CallerMemberName] string callerName = null, + [CallerFilePath] string callerPath = null, + [CallerLineNumber] int callerLine = -1) + { + if (Debugger.IsAttached) + { + return; + } + + System.Console.WriteLine(); + System.Console.WriteLine("===== AWAITING DEBUGGER ====="); + System.Console.WriteLine($" PID: {Process.GetCurrentProcess().Id}"); + System.Console.WriteLine($" Waiting at {callerPath} line {callerLine} ({callerName})"); + System.Console.WriteLine(" PRESS ANY KEY TO CONTINUE"); + System.Console.WriteLine("============================="); + System.Console.ReadKey(); + } + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/scriptassets/Bad&name4script.ps1 b/test/PowerShellEditorServices.Test.Shared/scriptassets/Bad&name4script.ps1 new file mode 100644 index 0000000..6cc75a1 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/scriptassets/Bad&name4script.ps1 @@ -0,0 +1,4 @@ +function Hello +{ + "Bye" +} diff --git a/test/PowerShellEditorServices.Test.Shared/scriptassets/NormalScript.ps1 b/test/PowerShellEditorServices.Test.Shared/scriptassets/NormalScript.ps1 new file mode 100644 index 0000000..e69de29 diff --git a/test/PowerShellEditorServices.Test.Shared/scriptassets/[Truly] b&d `N$(){}me_4_script.ps1 b/test/PowerShellEditorServices.Test.Shared/scriptassets/[Truly] b&d `N$(){}me_4_script.ps1 new file mode 100644 index 0000000..a7a7418 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/scriptassets/[Truly] b&d `N$(){}me_4_script.ps1 @@ -0,0 +1 @@ +Write-Output "Windows won't let me put * or ? in the name of this file..." diff --git a/test/PowerShellEditorServices.Test/AssemblyInfo.cs b/test/PowerShellEditorServices.Test/AssemblyInfo.cs new file mode 100644 index 0000000..7dfd8f1 --- /dev/null +++ b/test/PowerShellEditorServices.Test/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Xunit; + +// Disable test parallelization to avoid port reuse issues +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs new file mode 100644 index 0000000..3ba1600 --- /dev/null +++ b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs @@ -0,0 +1,1178 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Management.Automation; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.PowerShell.EditorServices.Handlers; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.DebugAdapter; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Console; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Test; +using Microsoft.PowerShell.EditorServices.Test.Shared; +using Microsoft.PowerShell.EditorServices.Utility; +using Xunit; + +namespace PowerShellEditorServices.Test.Debugging +{ + internal class TestReadLine : IReadLine + { + public List history = new(); + + public string ReadLine(CancellationToken cancellationToken) => ""; + + public void AddToHistory(string historyEntry) => history.Add(historyEntry); + } + + [Trait("Category", "DebugService")] + public class DebugServiceTests : IAsyncLifetime + { + private PsesInternalHost psesHost; + private BreakpointService breakpointService; + private DebugService debugService; + private readonly BlockingCollection debuggerStoppedQueue = new(); + private WorkspaceService workspace; + private ScriptFile debugScriptFile; + private ScriptFile oddPathScriptFile; + private ScriptFile variableScriptFile; + private readonly TestReadLine testReadLine = new(); + + public async Task InitializeAsync() + { + psesHost = await PsesHostFactory.Create(NullLoggerFactory.Instance); + // This is required for remote debugging, but we call it here to end up in the same + // state as the usual startup path. + psesHost.DebugContext.EnableDebugMode(); + psesHost._readLineProvider.ReadLine = testReadLine; + + breakpointService = new BreakpointService( + NullLoggerFactory.Instance, + psesHost, + psesHost, + new DebugStateService()); + + debugService = new DebugService( + psesHost, + psesHost.DebugContext, + remoteFileManager: null, + breakpointService, + psesHost, + NullLoggerFactory.Instance); + + debugService.DebuggerStopped += OnDebuggerStopped; + + // Load the test debug files. + workspace = new WorkspaceService(NullLoggerFactory.Instance); + debugScriptFile = GetDebugScript("DebugTest.ps1"); + oddPathScriptFile = GetDebugScript("Debug' W&ith $Params [Test].ps1"); + variableScriptFile = GetDebugScript("VariableTest.ps1"); + } + + public async Task DisposeAsync() + { + debugService.Abort(); + await Task.Run(psesHost.StopAsync); + debuggerStoppedQueue.Dispose(); + } + + /// + /// This event handler lets us test that the debugger stopped or paused + /// as expected. It will deadlock if called in the PSES Pipeline Thread. + /// Hence we use 'Task.Run(...)' when accessing the queue to ensure we + /// stay OFF the pipeline thread. + /// + /// + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD110:Observe result of async calls", Justification = "This intentionally fires and forgets on another thread.")] + private void OnDebuggerStopped(object sender, DebuggerStoppedEventArgs e) => Task.Run(() => debuggerStoppedQueue.Add(e)); + + private ScriptFile GetDebugScript(string fileName) => workspace.GetFile(TestUtilities.GetSharedPath(Path.Combine("Debugging", fileName))); + + private Task GetVariables(string scopeName) + { + VariableScope scope = Array.Find( + debugService.GetVariableScopes(0), + s => s.Name == scopeName); + return debugService.GetVariables(scope.Id, CancellationToken.None); + } + + private Task ExecuteScriptFileAsync(string scriptFilePath, params string[] args) + { + return psesHost.ExecutePSCommandAsync( + PSCommandHelpers.BuildDotSourceCommandWithArguments(PSCommandHelpers.EscapeScriptFilePath(scriptFilePath), args), + CancellationToken.None); + } + + private Task ExecuteDebugFileAsync() => ExecuteScriptFileAsync(debugScriptFile.FilePath); + + private Task ExecuteVariableScriptFileAsync() => ExecuteScriptFileAsync(variableScriptFile.FilePath); + + private async Task AssertDebuggerPaused() + { + using CancellationTokenSource cts = new(60000); + DebuggerStoppedEventArgs eventArgs = await Task.Run(() => debuggerStoppedQueue.Take(cts.Token)); + Assert.Empty(eventArgs.OriginalEvent.Breakpoints); + } + + private async Task AssertDebuggerStopped( + string scriptPath = "", + int lineNumber = -1, + CommandBreakpointDetails commandBreakpointDetails = default) + { + using CancellationTokenSource cts = new(30000); + DebuggerStoppedEventArgs eventArgs = await Task.Run(() => debuggerStoppedQueue.Take(cts.Token)); + + Assert.True(psesHost.DebugContext.IsStopped); + + if (!string.IsNullOrEmpty(scriptPath)) + { + // TODO: The drive letter becomes lower cased on Windows for some reason. + Assert.Equal(scriptPath, eventArgs.ScriptPath, ignoreCase: true); + } + else + { + Assert.Equal(string.Empty, scriptPath); + } + + if (lineNumber > -1) + { + Assert.Equal(lineNumber, eventArgs.LineNumber); + } + + if (commandBreakpointDetails is not null) + { + Assert.Equal(commandBreakpointDetails.Name, eventArgs.OriginalEvent.InvocationInfo.MyCommand.Name); + } + } + + private Task> GetConfirmedBreakpoints(ScriptFile scriptFile) + { + // TODO: Should we use the APIs in BreakpointService to get these? + return psesHost.ExecutePSCommandAsync( + new PSCommand().AddCommand("Get-PSBreakpoint").AddParameter("Script", scriptFile.FilePath), + CancellationToken.None); + } + + [Fact] + // This regression test asserts that `ExecuteScriptWithArgsAsync` works for both script + // files and, in this case, in-line scripts (commands). The bug was that the cwd was + // erroneously prepended when the script argument was a command. + public async Task DebuggerAcceptsInlineScript() + { + await debugService.SetCommandBreakpointsAsync( + new[] { CommandBreakpointDetails.Create("Get-Random") }); + + Task> executeTask = psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("Get-Random -SetSeed 42 -Maximum 100"), CancellationToken.None); + + await AssertDebuggerStopped("", 1); + debugService.Continue(); + Assert.Equal(17, (await executeTask)[0]); + + StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); + Assert.Equal(StackFrameDetails.NoFileScriptPath, stackFrames[0].ScriptPath); + + // NOTE: This assertion will fail if any error occurs. Notably this happens in testing + // when the assembly path changes and the commands definition file can't be found. + VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.GlobalScopeName); + VariableDetailsBase var = Array.Find(variables, v => v.Name == "$Error"); + Assert.NotNull(var); + Assert.True(var.IsExpandable); + Assert.Equal("[ArrayList: 0]", var.ValueString); + } + + // See https://www.thomasbogholm.net/2021/06/01/convenient-member-data-sources-with-xunit/ + public static IEnumerable DebuggerAcceptsScriptArgsTestData => new List() + { + new object[] { new object[] { "Foo -Param2 @('Bar','Baz') -Force Extra1" } }, + new object[] { new object[] { "Foo", "-Param2", "@('Bar','Baz')", "-Force", "Extra1" } } + }; + + [Theory] + [MemberData(nameof(DebuggerAcceptsScriptArgsTestData))] + public async Task DebuggerAcceptsScriptArgs(string[] args) + { + IReadOnlyList breakpoints = await debugService.SetLineBreakpointsAsync( + oddPathScriptFile.FilePath, + new[] { BreakpointDetails.Create(oddPathScriptFile.FilePath, 3) }); + + Assert.Single(breakpoints); + Assert.Collection(breakpoints, (breakpoint) => + { + // TODO: The drive letter becomes lower cased on Windows for some reason. + Assert.Equal(oddPathScriptFile.FilePath, breakpoint.Source, ignoreCase: true); + Assert.Equal(3, breakpoint.LineNumber); + Assert.True(breakpoint.Verified); + }); + + Task _ = ExecuteScriptFileAsync(oddPathScriptFile.FilePath, args); + + await AssertDebuggerStopped(oddPathScriptFile.FilePath, 3); + + VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); + + VariableDetailsBase var = Array.Find(variables, v => v.Name == "$Param1"); + Assert.NotNull(var); + Assert.Equal("\"Foo\"", var.ValueString); + Assert.False(var.IsExpandable); + + var = Array.Find(variables, v => v.Name == "$Param2"); + Assert.NotNull(var); + Assert.True(var.IsExpandable); + + VariableDetailsBase[] childVars = await debugService.GetVariables(var.Id, CancellationToken.None); + // 2 variables plus "Raw View" + Assert.Equal(3, childVars.Length); + Assert.Equal("\"Bar\"", childVars[0].ValueString); + Assert.Equal("\"Baz\"", childVars[1].ValueString); + + var = Array.Find(variables, v => v.Name == "$Force"); + Assert.NotNull(var); + Assert.Equal("True", var.ValueString); + Assert.True(var.IsExpandable); + + // NOTE: $args are no longer found in AutoVariables but CommandVariables instead. + StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); + variables = await debugService.GetVariables(stackFrames[0].CommandVariables.Id, CancellationToken.None); + var = Array.Find(variables, v => v.Name == "$args"); + Assert.NotNull(var); + Assert.True(var.IsExpandable); + + childVars = await debugService.GetVariables(var.Id, CancellationToken.None); + Assert.Equal(2, childVars.Length); + Assert.Equal("\"Extra1\"", childVars[0].ValueString); + } + + [Fact] + public async Task DebuggerSetsAndClearsFunctionBreakpoints() + { + IReadOnlyList breakpoints = await debugService.SetCommandBreakpointsAsync( + new[] { + CommandBreakpointDetails.Create("Write-Host"), + CommandBreakpointDetails.Create("Get-Date") + }); + + Assert.Equal(2, breakpoints.Count); + Assert.Equal("Write-Host", breakpoints[0].Name); + Assert.Equal("Get-Date", breakpoints[1].Name); + + breakpoints = await debugService.SetCommandBreakpointsAsync( + new[] { CommandBreakpointDetails.Create("Get-Host") }); + + Assert.Equal("Get-Host", Assert.Single(breakpoints).Name); + + breakpoints = await debugService.SetCommandBreakpointsAsync( + Array.Empty()); + + Assert.Empty(breakpoints); + } + + [Fact] + public async Task DebuggerStopsOnFunctionBreakpoints() + { + IReadOnlyList breakpoints = await debugService.SetCommandBreakpointsAsync( + new[] { CommandBreakpointDetails.Create("Write-Host") }); + + Task _ = ExecuteDebugFileAsync(); + await AssertDebuggerStopped(debugScriptFile.FilePath, 6); + + VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); + + // Verify the function breakpoint broke at Write-Host and $i is 1 + VariableDetailsBase i = Array.Find(variables, v => v.Name == "$i"); + Assert.NotNull(i); + Assert.False(i.IsExpandable); + Assert.Equal("1", i.ValueString); + + // The function breakpoint should fire the next time through the loop. + debugService.Continue(); + await AssertDebuggerStopped(debugScriptFile.FilePath, 6); + + variables = await GetVariables(VariableContainerDetails.LocalScopeName); + + // Verify the function breakpoint broke at Write-Host and $i is 1 + i = Array.Find(variables, v => v.Name == "$i"); + Assert.NotNull(i); + Assert.False(i.IsExpandable); + Assert.Equal("2", i.ValueString); + } + + [Fact] + public async Task DebuggerSetsAndClearsLineBreakpoints() + { + IReadOnlyList breakpoints = + await debugService.SetLineBreakpointsAsync( + debugScriptFile.FilePath, + new[] { + BreakpointDetails.Create(debugScriptFile.FilePath, 5), + BreakpointDetails.Create(debugScriptFile.FilePath, 10) + }); + + IReadOnlyList confirmedBreakpoints = await GetConfirmedBreakpoints(debugScriptFile); + + Assert.Equal(2, confirmedBreakpoints.Count); + Assert.Equal(5, breakpoints[0].LineNumber); + Assert.Equal(10, breakpoints[1].LineNumber); + + breakpoints = await debugService.SetLineBreakpointsAsync( + debugScriptFile.FilePath, + new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 2) }); + confirmedBreakpoints = await GetConfirmedBreakpoints(debugScriptFile); + + Assert.Single(confirmedBreakpoints); + Assert.Equal(2, breakpoints[0].LineNumber); + + await debugService.SetLineBreakpointsAsync( + debugScriptFile.FilePath, + Array.Empty()); + + IReadOnlyList remainingBreakpoints = await GetConfirmedBreakpoints(debugScriptFile); + Assert.Empty(remainingBreakpoints); + } + + [Fact] + public async Task DebuggerStopsOnLineBreakpoints() + { + await debugService.SetLineBreakpointsAsync( + debugScriptFile.FilePath, + new[] { + BreakpointDetails.Create(debugScriptFile.FilePath, 5), + BreakpointDetails.Create(debugScriptFile.FilePath, 7) + }); + + Task _ = ExecuteDebugFileAsync(); + await AssertDebuggerStopped(debugScriptFile.FilePath, 5); + debugService.Continue(); + await AssertDebuggerStopped(debugScriptFile.FilePath, 7); + } + + [Fact] + public async Task DebuggerStopsOnConditionalBreakpoints() + { + const int breakpointValue1 = 10; + const int breakpointValue2 = 20; + + await debugService.SetLineBreakpointsAsync( + debugScriptFile.FilePath, + new[] { + BreakpointDetails.Create(debugScriptFile.FilePath, 7, null, $"$i -eq {breakpointValue1} -or $i -eq {breakpointValue2}"), + }); + + Task _ = ExecuteDebugFileAsync(); + await AssertDebuggerStopped(debugScriptFile.FilePath, 7); + + VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); + + // Verify the breakpoint only broke at the condition ie. $i -eq breakpointValue1 + VariableDetailsBase i = Array.Find(variables, v => v.Name == "$i"); + Assert.NotNull(i); + Assert.False(i.IsExpandable); + Assert.Equal($"{breakpointValue1}", i.ValueString); + + // The conditional breakpoint should not fire again, until the value of + // i reaches breakpointValue2. + debugService.Continue(); + await AssertDebuggerStopped(debugScriptFile.FilePath, 7); + + variables = await GetVariables(VariableContainerDetails.LocalScopeName); + + // Verify the breakpoint only broke at the condition ie. $i -eq breakpointValue1 + i = Array.Find(variables, v => v.Name == "$i"); + Assert.NotNull(i); + Assert.False(i.IsExpandable); + Assert.Equal($"{breakpointValue2}", i.ValueString); + } + + [Fact] + public async Task DebuggerStopsOnHitConditionBreakpoint() + { + const int hitCount = 5; + + await debugService.SetLineBreakpointsAsync( + debugScriptFile.FilePath, + new[] { + BreakpointDetails.Create(debugScriptFile.FilePath, 6, null, null, $"{hitCount}"), + }); + + Task _ = ExecuteDebugFileAsync(); + await AssertDebuggerStopped(debugScriptFile.FilePath, 6); + + VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); + + // Verify the breakpoint only broke at the condition ie. $i -eq breakpointValue1 + VariableDetailsBase i = Array.Find(variables, v => v.Name == "$i"); + Assert.NotNull(i); + Assert.False(i.IsExpandable); + Assert.Equal($"{hitCount}", i.ValueString); + } + + [Fact] + public async Task DebuggerStopsOnConditionalAndHitConditionBreakpoint() + { + const int hitCount = 5; + + await debugService.SetLineBreakpointsAsync( + debugScriptFile.FilePath, + new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 6, null, "$i % 2 -eq 0", $"{hitCount}") }); + + Task _ = ExecuteDebugFileAsync(); + await AssertDebuggerStopped(debugScriptFile.FilePath, 6); + + VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); + + // Verify the breakpoint only broke at the condition ie. $i -eq breakpointValue1 + VariableDetailsBase i = Array.Find(variables, v => v.Name == "$i"); + Assert.NotNull(i); + Assert.False(i.IsExpandable); + // Condition is even numbers ($i starting at 1) should end up on 10 with a hit count of 5. + Assert.Equal("10", i.ValueString); + } + + [Fact] + public async Task DebuggerProvidesMessageForInvalidConditionalBreakpoint() + { + IReadOnlyList breakpoints = + await debugService.SetLineBreakpointsAsync( + debugScriptFile.FilePath, + new[] { + // TODO: Add this breakpoint back when it stops moving around?! The ordering + // of these two breakpoints seems to do with which framework executes the + // code. Best guess is that `IEnumerable` is not stably sorted so `ToArray` + // returns different orderings. However, that doesn't explain why this is + // the only affected test. + + // BreakpointDetails.Create(debugScriptFile.FilePath, 5), + BreakpointDetails.Create(debugScriptFile.FilePath, 10, column: null, condition: "$i -ez 100") + }); + + Assert.Single(breakpoints); + // Assert.Equal(5, breakpoints[0].LineNumber); + // Assert.True(breakpoints[0].Verified); + // Assert.Null(breakpoints[0].Message); + + Assert.Equal(10, breakpoints[0].LineNumber); + Assert.False(breakpoints[0].Verified); + Assert.NotNull(breakpoints[0].Message); + Assert.Contains("Unexpected token '-ez'", breakpoints[0].Message); + } + + [Fact] + public async Task DebuggerFindsParsableButInvalidSimpleBreakpointConditions() + { + IReadOnlyList breakpoints = + await debugService.SetLineBreakpointsAsync( + debugScriptFile.FilePath, + new[] { + BreakpointDetails.Create(debugScriptFile.FilePath, 5, column: null, condition: "$i == 100"), + BreakpointDetails.Create(debugScriptFile.FilePath, 7, column: null, condition: "$i > 100") + }); + + Assert.Equal(2, breakpoints.Count); + Assert.Equal(5, breakpoints[0].LineNumber); + Assert.False(breakpoints[0].Verified); + Assert.Contains("Use '-eq' instead of '=='", breakpoints[0].Message); + + Assert.Equal(7, breakpoints[1].LineNumber); + Assert.False(breakpoints[1].Verified); + Assert.NotNull(breakpoints[1].Message); + Assert.Contains("Use '-gt' instead of '>'", breakpoints[1].Message); + } + + [Fact] + public async Task DebuggerBreaksWhenRequested() + { + IReadOnlyList confirmedBreakpoints = await GetConfirmedBreakpoints(debugScriptFile); + Assert.Empty(confirmedBreakpoints); + Task _ = ExecuteDebugFileAsync(); + debugService.Break(); + await AssertDebuggerPaused(); + } + + [Fact] + public async Task DebuggerRunsCommandsWhileStopped() + { + Task _ = ExecuteDebugFileAsync(); + debugService.Break(); + await AssertDebuggerPaused(); + + // Try running a command from outside the pipeline thread + Task> executeTask = psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("Get-Random -SetSeed 42 -Maximum 100"), CancellationToken.None); + Assert.Equal(17, (await executeTask)[0]); + } + + // Regression test asserting that the PSDebugContext variable is available when running the + // "prompt" function. While we're unable to test the REPL loop, this still covers the + // behavior as I verified that it stepped through "ExecuteInDebugger" (which was the + // original problem). + [Fact] + public async Task DebugContextAvailableInPrompt() + { + await debugService.SetCommandBreakpointsAsync( + new[] { CommandBreakpointDetails.Create("Write-Host") }); + + ScriptFile testScript = GetDebugScript("PSDebugContextTest.ps1"); + Task _ = ExecuteScriptFileAsync(testScript.FilePath); + await AssertDebuggerStopped(testScript.FilePath, 11); + + VariableDetails prompt = await debugService.EvaluateExpressionAsync("prompt", false, CancellationToken.None); + Assert.Equal("True > ", prompt.ValueString); + } + + [Theory] + [InlineData("Command")] + [InlineData("Line")] + public async Task DebuggerBreaksInUntitledScript(string breakpointType) + { + const string contents = "Write-Output $($MyInvocation.Line)"; + const string scriptPath = "untitled:Untitled-1"; + Assert.True(ScriptFile.IsUntitledPath(scriptPath)); + ScriptFile scriptFile = workspace.GetFileBuffer(scriptPath, contents); + Assert.Equal(scriptPath, scriptFile.DocumentUri); + Assert.Equal(contents, scriptFile.Contents); + Assert.True(workspace.TryGetFile(scriptPath, out ScriptFile _)); + + if (breakpointType == "Command") + { + await debugService.SetCommandBreakpointsAsync( + new[] { CommandBreakpointDetails.Create("Write-Output") }); + } + else + { + await debugService.SetLineBreakpointsAsync( + scriptFile.FilePath, + new[] { BreakpointDetails.Create(scriptPath, 1) }); + } + + ConfigurationDoneHandler configurationDoneHandler = new( + NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null); + + Task _ = configurationDoneHandler.LaunchScriptAsync(scriptPath); + await AssertDebuggerStopped(scriptPath, 1); + + VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.CommandVariablesName); + VariableDetailsBase myInvocation = Array.Find(variables, v => v.Name == "$MyInvocation"); + Assert.NotNull(myInvocation); + Assert.True(myInvocation.IsExpandable); + + // Here we're asserting that our hacky workaround to support breakpoints in untitled + // scripts is working, namely that we're actually dot-sourcing our first argument, which + // should be a cached script block. See the `LaunchScriptAsync` for more info. + VariableDetailsBase[] myInvocationChildren = await debugService.GetVariables(myInvocation.Id, CancellationToken.None); + VariableDetailsBase myInvocationLine = Array.Find(myInvocationChildren, v => v.Name == "Line"); + Assert.Equal("\". $args[0]\"", myInvocationLine.ValueString); + } + + [Fact] + public async Task RecordsF5CommandInPowerShellHistory() + { + ConfigurationDoneHandler configurationDoneHandler = new( + NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null); + await configurationDoneHandler.LaunchScriptAsync(debugScriptFile.FilePath); + + IReadOnlyList historyResult = await psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("(Get-History).CommandLine"), + CancellationToken.None); + + // Check the PowerShell history + Assert.Equal(". '" + debugScriptFile.FilePath + "'", Assert.Single(historyResult)); + + // Check the stubbed PSReadLine history + Assert.Equal(". '" + debugScriptFile.FilePath + "'", Assert.Single(testReadLine.history)); + } + + [Fact] + public async Task RecordsF8CommandInHistory() + { + const string script = "Write-Output Hello"; + EvaluateHandler evaluateHandler = new(psesHost); + EvaluateResponseBody evaluateResponseBody = await evaluateHandler.Handle( + new EvaluateRequestArguments { Expression = script, Context = "repl" }, + CancellationToken.None); + // TODO: Right now this response is hard-coded, maybe it should change? + Assert.Equal("", evaluateResponseBody.Result); + + IReadOnlyList historyResult = await psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("(Get-History).CommandLine"), + CancellationToken.None); + + // Check the PowerShell history + Assert.Equal(script, Assert.Single(historyResult)); + + // Check the stubbed PSReadLine history + Assert.Equal(script, Assert.Single(testReadLine.history)); + } + + [Fact] + public async Task OddFilePathsLaunchCorrectly() + { + ConfigurationDoneHandler configurationDoneHandler = new( + NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null); + await configurationDoneHandler.LaunchScriptAsync(oddPathScriptFile.FilePath); + + IReadOnlyList historyResult = await psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("(Get-History).CommandLine"), + CancellationToken.None); + + // Check the PowerShell history + Assert.Equal(". " + PSCommandHelpers.EscapeScriptFilePath(oddPathScriptFile.FilePath), Assert.Single(historyResult)); + } + + [Fact] + public async Task DebuggerVariableStringDisplaysCorrectly() + { + await debugService.SetLineBreakpointsAsync( + variableScriptFile.FilePath, + new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 8) }); + + Task _ = ExecuteVariableScriptFileAsync(); + await AssertDebuggerStopped(variableScriptFile.FilePath); + + VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); + + VariableDetailsBase var = Array.Find(variables, v => v.Name == "$strVar"); + Assert.NotNull(var); + Assert.Equal("\"Hello\"", var.ValueString); + Assert.False(var.IsExpandable); + } + + [Fact] + public async Task DebuggerGetsVariables() + { + await debugService.SetLineBreakpointsAsync( + variableScriptFile.FilePath, + new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 21) }); + + Task _ = ExecuteVariableScriptFileAsync(); + await AssertDebuggerStopped(variableScriptFile.FilePath); + + VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); + + // TODO: Add checks for correct value strings as well + VariableDetailsBase strVar = Array.Find(variables, v => v.Name == "$strVar"); + Assert.NotNull(strVar); + Assert.False(strVar.IsExpandable); + + VariableDetailsBase objVar = Array.Find(variables, v => v.Name == "$assocArrVar"); + Assert.NotNull(objVar); + Assert.True(objVar.IsExpandable); + + VariableDetailsBase[] objChildren = await debugService.GetVariables(objVar.Id, CancellationToken.None); + // Two variables plus "Raw View" + Assert.Equal(3, objChildren.Length); + + VariableDetailsBase arrVar = Array.Find(variables, v => v.Name == "$arrVar"); + Assert.NotNull(arrVar); + Assert.True(arrVar.IsExpandable); + + VariableDetailsBase[] arrChildren = await debugService.GetVariables(arrVar.Id, CancellationToken.None); + Assert.Equal(5, arrChildren.Length); + + VariableDetailsBase classVar = Array.Find(variables, v => v.Name == "$classVar"); + Assert.NotNull(classVar); + Assert.True(classVar.IsExpandable); + + VariableDetailsBase[] classChildren = await debugService.GetVariables(classVar.Id, CancellationToken.None); + Assert.Equal(2, classChildren.Length); + + VariableDetailsBase trueVar = Array.Find(variables, v => v.Name == "$trueVar"); + Assert.NotNull(trueVar); + Assert.Equal("boolean", trueVar.Type); + Assert.Equal("$true", trueVar.ValueString); + + VariableDetailsBase falseVar = Array.Find(variables, v => v.Name == "$falseVar"); + Assert.NotNull(falseVar); + Assert.Equal("boolean", falseVar.Type); + Assert.Equal("$false", falseVar.ValueString); + } + + [Fact] + public async Task DebuggerSetsVariablesNoConversion() + { + await debugService.SetLineBreakpointsAsync( + variableScriptFile.FilePath, + new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 14) }); + + Task _ = ExecuteVariableScriptFileAsync(); + await AssertDebuggerStopped(variableScriptFile.FilePath); + + VariableScope[] scopes = debugService.GetVariableScopes(0); + VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); + + // Test set of a local string variable (not strongly typed) + const string newStrValue = "\"Goodbye\""; + VariableScope localScope = Array.Find(scopes, s => s.Name == VariableContainerDetails.LocalScopeName); + string setStrValue = await debugService.SetVariableAsync(localScope.Id, "$strVar", newStrValue); + Assert.Equal(newStrValue, setStrValue); + + // Test set of script scope int variable (not strongly typed) + VariableScope scriptScope = Array.Find(scopes, s => s.Name == VariableContainerDetails.ScriptScopeName); + const string newIntValue = "49"; + const string newIntExpr = "7 * 7"; + string setIntValue = await debugService.SetVariableAsync(scriptScope.Id, "$scriptInt", newIntExpr); + Assert.Equal(newIntValue, setIntValue); + + // Test set of global scope int variable (not strongly typed) + VariableScope globalScope = Array.Find(scopes, s => s.Name == VariableContainerDetails.GlobalScopeName); + const string newGlobalIntValue = "4242"; + string setGlobalIntValue = await debugService.SetVariableAsync(globalScope.Id, "$MaximumHistoryCount", newGlobalIntValue); + Assert.Equal(newGlobalIntValue, setGlobalIntValue); + + // The above just tests that the debug service returns the correct new value string. + // Let's step the debugger and make sure the values got set to the new values. + debugService.StepOver(); + await AssertDebuggerStopped(variableScriptFile.FilePath); + + // Test set of a local string variable (not strongly typed) + variables = await GetVariables(VariableContainerDetails.LocalScopeName); + VariableDetailsBase strVar = Array.Find(variables, v => v.Name == "$strVar"); + Assert.Equal(newStrValue, strVar.ValueString); + + // Test set of script scope int variable (not strongly typed) + variables = await GetVariables(VariableContainerDetails.ScriptScopeName); + VariableDetailsBase intVar = Array.Find(variables, v => v.Name == "$scriptInt"); + Assert.Equal(newIntValue, intVar.ValueString); + + // Test set of global scope int variable (not strongly typed) + variables = await GetVariables(VariableContainerDetails.GlobalScopeName); + VariableDetailsBase intGlobalVar = Array.Find(variables, v => v.Name == "$MaximumHistoryCount"); + Assert.Equal(newGlobalIntValue, intGlobalVar.ValueString); + } + + [Fact] + public async Task DebuggerSetsVariablesWithConversion() + { + await debugService.SetLineBreakpointsAsync( + variableScriptFile.FilePath, + new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 14) }); + + // Execute the script and wait for the breakpoint to be hit + Task _ = ExecuteVariableScriptFileAsync(); + await AssertDebuggerStopped(variableScriptFile.FilePath); + + VariableScope[] scopes = debugService.GetVariableScopes(0); + VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); + + // Test set of a local string variable (not strongly typed but force conversion) + const string newStrValue = "\"False\""; + const string newStrExpr = "$false"; + VariableScope localScope = Array.Find(scopes, s => s.Name == VariableContainerDetails.LocalScopeName); + string setStrValue = await debugService.SetVariableAsync(localScope.Id, "$strVar2", newStrExpr); + Assert.Equal(newStrValue, setStrValue); + + // Test set of script scope bool variable (strongly typed) + VariableScope scriptScope = Array.Find(scopes, s => s.Name == VariableContainerDetails.ScriptScopeName); + const string newBoolValue = "$true"; + const string newBoolExpr = "1"; + string setBoolValue = await debugService.SetVariableAsync(scriptScope.Id, "$scriptBool", newBoolExpr); + Assert.Equal(newBoolValue, setBoolValue); + + // Test set of global scope ActionPreference variable (strongly typed) + VariableScope globalScope = Array.Find(scopes, s => s.Name == VariableContainerDetails.GlobalScopeName); + const string newGlobalValue = "Continue"; + const string newGlobalExpr = "'Continue'"; + string setGlobalValue = await debugService.SetVariableAsync(globalScope.Id, "$VerbosePreference", newGlobalExpr); + Assert.Equal(newGlobalValue, setGlobalValue); + + // The above just tests that the debug service returns the correct new value string. + // Let's step the debugger and make sure the values got set to the new values. + debugService.StepOver(); + await AssertDebuggerStopped(variableScriptFile.FilePath); + + // Test set of a local string variable (not strongly typed but force conversion) + variables = await GetVariables(VariableContainerDetails.LocalScopeName); + VariableDetailsBase strVar = Array.Find(variables, v => v.Name == "$strVar2"); + Assert.Equal(newStrValue, strVar.ValueString); + + // Test set of script scope bool variable (strongly typed) + variables = await GetVariables(VariableContainerDetails.ScriptScopeName); + VariableDetailsBase boolVar = Array.Find(variables, v => v.Name == "$scriptBool"); + Assert.Equal(newBoolValue, boolVar.ValueString); + + // Test set of global scope ActionPreference variable (strongly typed) + variables = await GetVariables(VariableContainerDetails.GlobalScopeName); + VariableDetailsBase globalVar = Array.Find(variables, v => v.Name == "$VerbosePreference"); + Assert.Equal(newGlobalValue, globalVar.ValueString); + } + + [Fact] + public async Task DebuggerVariableEnumDisplaysCorrectly() + { + await debugService.SetLineBreakpointsAsync( + variableScriptFile.FilePath, + new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 15) }); + + // Execute the script and wait for the breakpoint to be hit + Task _ = ExecuteVariableScriptFileAsync(); + await AssertDebuggerStopped(variableScriptFile.FilePath); + + StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); + VariableDetailsBase[] variables = await debugService.GetVariables(stackFrames[0].AutoVariables.Id, CancellationToken.None); + + VariableDetailsBase var = Array.Find(variables, v => v.Name == "$enumVar"); + Assert.NotNull(var); + Assert.Equal("Continue", var.ValueString); + Assert.False(var.IsExpandable); + } + + [Fact] + public async Task DebuggerVariableHashtableDisplaysCorrectly() + { + await debugService.SetLineBreakpointsAsync( + variableScriptFile.FilePath, + new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 11) }); + + // Execute the script and wait for the breakpoint to be hit + Task _ = ExecuteVariableScriptFileAsync(); + await AssertDebuggerStopped(variableScriptFile.FilePath); + + StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); + VariableDetailsBase[] variables = await debugService.GetVariables(stackFrames[0].AutoVariables.Id, CancellationToken.None); + + VariableDetailsBase var = Array.Find(variables, v => v.Name == "$assocArrVar"); + Assert.NotNull(var); + Assert.Equal("[Hashtable: 2]", var.ValueString); + Assert.True(var.IsExpandable); + + VariableDetailsBase[] childVars = await debugService.GetVariables(var.Id, CancellationToken.None); + // 2 variables plus "Raw View" + Assert.Equal(3, childVars.Length); + + // Hashtables are unordered hence the Linq examination, examination by index is unreliable + VariableDetailsBase firstChild = Array.Find(childVars, v => v.Name == "[firstChild]"); + Assert.NotNull(firstChild); + Assert.Equal("\"Child\"", firstChild.ValueString); + + VariableDetailsBase secondChild = Array.Find(childVars, v => v.Name == "[secondChild]"); + Assert.NotNull(secondChild); + Assert.Equal("42", secondChild.ValueString); + } + + [Fact] + public async Task DebuggerVariableNullStringDisplaysCorrectly() + { + await debugService.SetLineBreakpointsAsync( + variableScriptFile.FilePath, + new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 16) }); + + // Execute the script and wait for the breakpoint to be hit + Task _ = ExecuteVariableScriptFileAsync(); + await AssertDebuggerStopped(variableScriptFile.FilePath); + + StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); + VariableDetailsBase[] variables = await debugService.GetVariables(stackFrames[0].AutoVariables.Id, CancellationToken.None); + + VariableDetailsBase nullStringVar = Array.Find(variables, v => v.Name == "$nullString"); + Assert.NotNull(nullStringVar); + Assert.Equal("[NullString]", nullStringVar.ValueString); + Assert.True(nullStringVar.IsExpandable); + } + + [Fact] + public async Task DebuggerVariablePSObjectDisplaysCorrectly() + { + await debugService.SetLineBreakpointsAsync( + variableScriptFile.FilePath, + new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 17) }); + + // Execute the script and wait for the breakpoint to be hit + Task _ = ExecuteVariableScriptFileAsync(); + await AssertDebuggerStopped(variableScriptFile.FilePath); + + StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); + VariableDetailsBase[] variables = await debugService.GetVariables(stackFrames[0].AutoVariables.Id, CancellationToken.None); + + VariableDetailsBase psObjVar = Array.Find(variables, v => v.Name == "$psObjVar"); + Assert.NotNull(psObjVar); + Assert.True("@{Age=75; Name=John}".Equals(psObjVar.ValueString) || "@{Name=John; Age=75}".Equals(psObjVar.ValueString)); + Assert.True(psObjVar.IsExpandable); + + VariableDetailsBase[] childVars = await debugService.GetVariables(psObjVar.Id, CancellationToken.None); + IDictionary dictionary = childVars.ToDictionary(v => v.Name, v => v.ValueString); + Assert.Equal(2, dictionary.Count); + Assert.Contains("Age", dictionary.Keys); + Assert.Contains("Name", dictionary.Keys); + Assert.Equal("75", dictionary["Age"]); + Assert.Equal("\"John\"", dictionary["Name"]); + } + + [Fact] + public async Task DebuggerEnumerableShowsRawView() + { + CommandBreakpointDetails breakpoint = CommandBreakpointDetails.Create("__BreakDebuggerEnumerableShowsRawView"); + await debugService.SetCommandBreakpointsAsync(new[] { breakpoint }); + + // Execute the script and wait for the breakpoint to be hit + Task _ = ExecuteVariableScriptFileAsync(); + await AssertDebuggerStopped(commandBreakpointDetails: breakpoint); + + VariableDetailsBase simpleArrayVar = Array.Find( + await GetVariables(VariableContainerDetails.ScriptScopeName), + v => v.Name == "$simpleArray"); + Assert.NotNull(simpleArrayVar); + VariableDetailsBase rawDetailsView = Array.Find( + simpleArrayVar.GetChildren(NullLogger.Instance), + v => v.Name == "Raw View"); + Assert.NotNull(rawDetailsView); + Assert.Empty(rawDetailsView.Type); + Assert.Empty(rawDetailsView.ValueString); + VariableDetailsBase[] rawViewChildren = rawDetailsView.GetChildren(NullLogger.Instance); + Assert.Collection(rawViewChildren, + (i) => + { + Assert.Equal("Length", i.Name); + Assert.Equal("4", i.ValueString); + }, + (i) => + { + Assert.Equal("LongLength", i.Name); + Assert.Equal("4", i.ValueString); + }, + (i) => + { + Assert.Equal("Rank", i.Name); + Assert.Equal("1", i.ValueString); + }, + (i) => + { + Assert.Equal("SyncRoot", i.Name); + Assert.True(i.IsExpandable); + }, + (i) => + { + Assert.Equal("IsReadOnly", i.Name); + Assert.Equal("$false", i.ValueString); + }, (i) => + { + Assert.Equal("IsFixedSize", i.Name); + Assert.Equal("$true", i.ValueString); + }, (i) => + { + Assert.Equal("IsSynchronized", i.Name); + Assert.Equal("$false", i.ValueString); + }); + } + + [Fact] + public async Task DebuggerDictionaryShowsRawView() + { + CommandBreakpointDetails breakpoint = CommandBreakpointDetails.Create("__BreakDebuggerDictionaryShowsRawView"); + await debugService.SetCommandBreakpointsAsync(new[] { breakpoint }); + + // Execute the script and wait for the breakpoint to be hit + Task _ = ExecuteVariableScriptFileAsync(); + await AssertDebuggerStopped(commandBreakpointDetails: breakpoint); + + VariableDetailsBase simpleDictionaryVar = Array.Find( + await GetVariables(VariableContainerDetails.ScriptScopeName), + v => v.Name == "$simpleDictionary"); + Assert.NotNull(simpleDictionaryVar); + VariableDetailsBase rawDetailsView = Array.Find( + simpleDictionaryVar.GetChildren(NullLogger.Instance), + v => v.Name == "Raw View"); + Assert.NotNull(rawDetailsView); + Assert.Empty(rawDetailsView.Type); + Assert.Empty(rawDetailsView.ValueString); + VariableDetailsBase[] rawDetailsChildren = rawDetailsView.GetChildren(NullLogger.Instance); + Assert.Collection(rawDetailsChildren, + (i) => + { + Assert.Equal("IsReadOnly", i.Name); + Assert.Equal("$false", i.ValueString); + }, + (i) => + { + Assert.Equal("IsFixedSize", i.Name); + Assert.Equal("$false", i.ValueString); + }, + (i) => + { + Assert.Equal("IsSynchronized", i.Name); + Assert.Equal("$false", i.ValueString); + }, + (i) => + { + Assert.Equal("Keys", i.Name); + Assert.Equal("[KeyCollection: 4]", i.ValueString); + }, + (i) => + { + Assert.Equal("Values", i.Name); + Assert.Equal("[ValueCollection: 4]", i.ValueString); + }, + (i) => + { + Assert.Equal("SyncRoot", i.Name); +#if CoreCLR + Assert.Equal("[Hashtable: 4]", i.ValueString); +#else + Assert.Equal("[Object]", i.ValueString); +#endif + }, + (i) => + { + Assert.Equal("Count", i.Name); + Assert.Equal("4", i.ValueString); + }); + } + + [Fact] + public async Task DebuggerDerivedDictionaryPropertyInRawView() + { + CommandBreakpointDetails breakpoint = CommandBreakpointDetails.Create("__BreakDebuggerDerivedDictionaryPropertyInRawView"); + await debugService.SetCommandBreakpointsAsync(new[] { breakpoint }); + + // Execute the script and wait for the breakpoint to be hit + Task _ = ExecuteVariableScriptFileAsync(); + await AssertDebuggerStopped(commandBreakpointDetails: breakpoint); + + VariableDetailsBase sortedDictionaryVar = Array.Find( + await GetVariables(VariableContainerDetails.ScriptScopeName), + v => v.Name == "$sortedDictionary"); + Assert.NotNull(sortedDictionaryVar); + VariableDetailsBase[] simpleDictionaryChildren = sortedDictionaryVar.GetChildren(NullLogger.Instance); + // 4 items + Raw View + Assert.Equal(5, simpleDictionaryChildren.Length); + VariableDetailsBase rawDetailsView = Array.Find( + simpleDictionaryChildren, + v => v.Name == "Raw View"); + Assert.NotNull(rawDetailsView); + Assert.Empty(rawDetailsView.Type); + Assert.Empty(rawDetailsView.ValueString); + VariableDetailsBase[] rawViewChildren = rawDetailsView.GetChildren(NullLogger.Instance); + Assert.Collection(rawViewChildren, + (i) => + { + Assert.Equal("Count", i.Name); + Assert.Equal("4", i.ValueString); + }, + (i) => + { + Assert.Equal("Comparer", i.Name); + Assert.Equal("[GenericComparer`1]", i.ValueString); + }, + (i) => + { + Assert.Equal("Keys", i.Name); + Assert.Equal("[KeyCollection: 4]", i.ValueString); + }, + (i) => + { + Assert.Equal("Values", i.Name); + Assert.Equal("[ValueCollection: 4]", i.ValueString); + } + ); + } + + [Fact] + public async Task DebuggerVariablePSCustomObjectDisplaysCorrectly() + { + await debugService.SetLineBreakpointsAsync( + variableScriptFile.FilePath, + new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 18) }); + + // Execute the script and wait for the breakpoint to be hit + Task _ = ExecuteVariableScriptFileAsync(); + await AssertDebuggerStopped(variableScriptFile.FilePath); + + StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); + VariableDetailsBase[] variables = await debugService.GetVariables(stackFrames[0].AutoVariables.Id, CancellationToken.None); + + VariableDetailsBase var = Array.Find(variables, v => v.Name == "$psCustomObjVar"); + Assert.NotNull(var); + Assert.Equal("@{Name=Paul; Age=73}", var.ValueString); + Assert.True(var.IsExpandable); + + VariableDetailsBase[] childVars = await debugService.GetVariables(var.Id, CancellationToken.None); + Assert.Equal(2, childVars.Length); + Assert.Equal("Name", childVars[0].Name); + Assert.Equal("\"Paul\"", childVars[0].ValueString); + Assert.Equal("Age", childVars[1].Name); + Assert.Equal("73", childVars[1].ValueString); + } + + // Verifies fix for issue #86, $proc = Get-Process foo displays just the ETS property set + // and not all process properties. + [Fact] + public async Task DebuggerVariableProcessObjectDisplaysCorrectly() + { + await debugService.SetLineBreakpointsAsync( + variableScriptFile.FilePath, + new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 19) }); + + // Execute the script and wait for the breakpoint to be hit + Task _ = ExecuteVariableScriptFileAsync(); + await AssertDebuggerStopped(variableScriptFile.FilePath); + + StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); + VariableDetailsBase[] variables = await debugService.GetVariables(stackFrames[0].AutoVariables.Id, CancellationToken.None); + + VariableDetailsBase var = Array.Find(variables, v => v.Name == "$procVar"); + Assert.NotNull(var); + Assert.StartsWith("System.Diagnostics.Process", var.ValueString); + Assert.True(var.IsExpandable); + + VariableDetailsBase[] childVars = await debugService.GetVariables(var.Id, CancellationToken.None); + Assert.Contains(childVars, i => i.Name is "Name"); + Assert.Contains(childVars, i => i.Name is "Handles"); +#if CoreCLR + Assert.Contains(childVars, i => i.Name is "CommandLine"); + Assert.Contains(childVars, i => i.Name is "ExitCode"); + Assert.Contains(childVars, i => i.Name is "HasExited" && i.ValueString is "$false"); +#endif + Assert.Contains(childVars, i => i.Name is "Id"); + } + + [Fact] + public async Task DebuggerVariableFileObjectDisplaysCorrectly() + { + await debugService.SetCommandBreakpointsAsync( + new[] { CommandBreakpointDetails.Create("Write-Host") }); + + ScriptFile testScript = GetDebugScript("GetChildItemTest.ps1"); + Task _ = ExecuteScriptFileAsync(testScript.FilePath); + await AssertDebuggerStopped(testScript.FilePath, 2); + + VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); + VariableDetailsBase var = Array.Find(variables, v => v.Name == "$file"); + VariableDetailsBase[] childVars = await debugService.GetVariables(var.Id, CancellationToken.None); + Assert.Contains(childVars, i => i.Name is "PSPath"); + Assert.Contains(childVars, i => i.Name is "PSProvider" && i.ValueString is @"Microsoft.PowerShell.Core\FileSystem"); + Assert.Contains(childVars, i => i.Name is "Exists" && i.ValueString is "$true"); + Assert.Contains(childVars, i => i.Name is "LastAccessTime"); + } + + // Verifies Issue #1686 + [Fact] + public async Task DebuggerToStringShouldMarshallToPipeline() + { + CommandBreakpointDetails breakpoint = CommandBreakpointDetails.Create("__BreakDebuggerToStringShouldMarshallToPipeline"); + await debugService.SetCommandBreakpointsAsync(new[] { breakpoint }); + + // Execute the script and wait for the breakpoint to be hit + Task _ = ExecuteVariableScriptFileAsync(); + await AssertDebuggerStopped(commandBreakpointDetails: breakpoint); + + VariableDetailsBase[] vars = await GetVariables(VariableContainerDetails.ScriptScopeName); + VariableDetailsBase customToStrings = Array.Find(vars, i => i.Name is "$CustomToStrings"); + Assert.True(customToStrings.IsExpandable); + Assert.Equal("[System.Object[]]", customToStrings.Type); + VariableDetailsBase[] childVars = await debugService.GetVariables(customToStrings.Id, CancellationToken.None); + // Check everything but the last variable (which is "Raw View") + Assert.Equal(1001, childVars.Length); // 1000 custom variables plus "Raw View" + Assert.All(childVars.Take(childVars.Length - 1), i => + { + Assert.Equal("HELLO", i.ValueString); + Assert.Equal("[CustomToString]", i.Type); + }); + } + } +} diff --git a/test/PowerShellEditorServices.Test/Extensions/ExtensionCommandTests.cs b/test/PowerShellEditorServices.Test/Extensions/ExtensionCommandTests.cs new file mode 100644 index 0000000..70e35d3 --- /dev/null +++ b/test/PowerShellEditorServices.Test/Extensions/ExtensionCommandTests.cs @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.PowerShell.EditorServices.Extensions; +using Microsoft.PowerShell.EditorServices.Extensions.Services; +using Microsoft.PowerShell.EditorServices.Services.Extension; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Test; +using Microsoft.PowerShell.EditorServices.Test.Shared; +using Xunit; + +namespace PowerShellEditorServices.Test.Extensions +{ + [Trait("Category", "Extensions")] + public class ExtensionCommandTests : IAsyncLifetime + { + private PsesInternalHost psesHost; + + private ExtensionCommandService extensionCommandService; + + public async Task InitializeAsync() + { + psesHost = await PsesHostFactory.Create(NullLoggerFactory.Instance); + ExtensionService extensionService = new( + languageServer: null, + serviceProvider: null, + editorOperations: null, + executionService: psesHost); + await extensionService.InitializeAsync(); + extensionCommandService = new(extensionService); + } + + public async Task DisposeAsync() => await psesHost.StopAsync(); + + [Fact] + public async Task CanRegisterAndInvokeCommandWithCmdletName() + { + string filePath = TestUtilities.NormalizePath(@"C:\Temp\Test.ps1"); + ScriptFile currentFile = ScriptFile.Create(new Uri(filePath), "This is a test file", new Version("7.0")); + EditorContext editorContext = new( + editorOperations: null, + currentFile, + new BufferPosition(line: 1, column: 1), + BufferRange.None); + + EditorCommand commandAdded = null; + extensionCommandService.CommandAdded += (_, command) => commandAdded = command; + + const string commandName = "test.function"; + const string commandDisplayName = "Function extension"; + + await psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript( + "function Invoke-Extension { $global:extensionValue = 5 }; " + + $"Register-EditorCommand -Name {commandName} -DisplayName \"{commandDisplayName}\" -Function Invoke-Extension"), + CancellationToken.None); + + Assert.NotNull(commandAdded); + Assert.Equal(commandName, commandAdded.Name); + Assert.Equal(commandDisplayName, commandAdded.DisplayName); + + // Invoke the command + await extensionCommandService.InvokeCommandAsync(commandName, editorContext); + + // Assert the expected value + PSCommand psCommand = new PSCommand().AddScript("$global:extensionValue"); + IEnumerable results = await psesHost.ExecutePSCommandAsync(psCommand, CancellationToken.None); + Assert.Equal(5, results.FirstOrDefault()); + } + + [Fact] + public async Task CanRegisterAndInvokeCommandWithScriptBlock() + { + string filePath = TestUtilities.NormalizePath(@"C:\Temp\Test.ps1"); + ScriptFile currentFile = ScriptFile.Create(new Uri(filePath), "This is a test file", new Version("7.0")); + EditorContext editorContext = new( + editorOperations: null, + currentFile, + new BufferPosition(line: 1, column: 1), + BufferRange.None); + + EditorCommand commandAdded = null; + extensionCommandService.CommandAdded += (_, command) => commandAdded = command; + + const string commandName = "test.scriptblock"; + const string commandDisplayName = "ScriptBlock extension"; + + await psesHost.ExecutePSCommandAsync( + new PSCommand() + .AddCommand("Register-EditorCommand") + .AddParameter("Name", commandName) + .AddParameter("DisplayName", commandDisplayName) + .AddParameter("ScriptBlock", ScriptBlock.Create("$global:extensionValue = 10")), + CancellationToken.None); + + Assert.NotNull(commandAdded); + Assert.Equal(commandName, commandAdded.Name); + Assert.Equal(commandDisplayName, commandAdded.DisplayName); + + // Invoke the command. + // TODO: What task was this cancelling? + await extensionCommandService.InvokeCommandAsync("test.scriptblock", editorContext); + + // Assert the expected value + PSCommand psCommand = new PSCommand().AddScript("$global:extensionValue"); + IEnumerable results = await psesHost.ExecutePSCommandAsync(psCommand, CancellationToken.None); + Assert.Equal(10, results.FirstOrDefault()); + } + + [Fact] + public async Task CanUpdateRegisteredCommand() + { + EditorCommand updatedCommand = null; + extensionCommandService.CommandUpdated += (_, command) => updatedCommand = command; + + const string commandName = "test.function"; + const string commandDisplayName = "Updated function extension"; + + // Register a command and then update it + await psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript( + "function Invoke-Extension { Write-Output \"Extension output!\" }; " + + $"Register-EditorCommand -Name {commandName} -DisplayName \"Old function extension\" -Function Invoke-Extension; " + + $"Register-EditorCommand -Name {commandName} -DisplayName \"{commandDisplayName}\" -Function Invoke-Extension"), + CancellationToken.None); + + // Wait for the add and update events + Assert.NotNull(updatedCommand); + Assert.Equal(commandName, updatedCommand.Name); + Assert.Equal(commandDisplayName, updatedCommand.DisplayName); + } + + [Fact] + public async Task CanUnregisterCommand() + { + string filePath = TestUtilities.NormalizePath(@"C:\Temp\Test.ps1"); + ScriptFile currentFile = ScriptFile.Create(new Uri(filePath), "This is a test file", new Version("7.0")); + EditorContext editorContext = new( + editorOperations: null, + currentFile, + new BufferPosition(line: 1, column: 1), + BufferRange.None); + + const string commandName = "test.scriptblock"; + const string commandDisplayName = "ScriptBlock extension"; + + EditorCommand removedCommand = null; + extensionCommandService.CommandRemoved += (_, command) => removedCommand = command; + + // Add the command and wait for the add event + await psesHost.ExecutePSCommandAsync( + new PSCommand() + .AddCommand("Register-EditorCommand") + .AddParameter("Name", commandName) + .AddParameter("DisplayName", commandDisplayName) + .AddParameter("ScriptBlock", ScriptBlock.Create("Write-Output \"Extension output!\"")), + CancellationToken.None); + + // Remove the command and wait for the remove event + await psesHost.ExecutePSCommandAsync( + new PSCommand().AddCommand("Unregister-EditorCommand").AddParameter("Name", commandName), + CancellationToken.None); + + Assert.NotNull(removedCommand); + Assert.Equal(commandName, removedCommand.Name); + Assert.Equal(commandDisplayName, removedCommand.DisplayName); + + // Ensure that the command has been unregistered + await Assert.ThrowsAsync( + () => extensionCommandService.InvokeCommandAsync("test.scriptblock", editorContext)); + } + + [Fact] + public async Task CannotRemovePSEditorVariable() + { + ActionPreferenceStopException exception = await Assert.ThrowsAsync( + () => psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("Remove-Variable psEditor -ErrorAction Stop"), + CancellationToken.None) + ); + + Assert.Equal( + "The running command stopped because the preference variable \"ErrorActionPreference\" or common parameter is set to Stop: Cannot remove variable psEditor because it is constant or read-only. If the variable is read-only, try the operation again specifying the Force option.", + exception.Message); + } + } +} diff --git a/test/PowerShellEditorServices.Test/Fixtures/Workspace/nested/donotfind.ps1 b/test/PowerShellEditorServices.Test/Fixtures/Workspace/nested/donotfind.ps1 new file mode 100644 index 0000000..29b0e6f --- /dev/null +++ b/test/PowerShellEditorServices.Test/Fixtures/Workspace/nested/donotfind.ps1 @@ -0,0 +1 @@ +# donotfind.ps1 diff --git a/test/PowerShellEditorServices.Test/Fixtures/Workspace/nested/donotfind.txt b/test/PowerShellEditorServices.Test/Fixtures/Workspace/nested/donotfind.txt new file mode 100644 index 0000000..c0070a9 --- /dev/null +++ b/test/PowerShellEditorServices.Test/Fixtures/Workspace/nested/donotfind.txt @@ -0,0 +1 @@ +donotfind.txt diff --git a/test/PowerShellEditorServices.Test/Fixtures/Workspace/nested/nestedmodule.psd1 b/test/PowerShellEditorServices.Test/Fixtures/Workspace/nested/nestedmodule.psd1 new file mode 100644 index 0000000..6657524 --- /dev/null +++ b/test/PowerShellEditorServices.Test/Fixtures/Workspace/nested/nestedmodule.psd1 @@ -0,0 +1 @@ +# nestedmodule.psd1 diff --git a/test/PowerShellEditorServices.Test/Fixtures/Workspace/nested/nestedmodule.psm1 b/test/PowerShellEditorServices.Test/Fixtures/Workspace/nested/nestedmodule.psm1 new file mode 100644 index 0000000..437ba73 --- /dev/null +++ b/test/PowerShellEditorServices.Test/Fixtures/Workspace/nested/nestedmodule.psm1 @@ -0,0 +1 @@ +# nestedmodule.psm1 diff --git a/test/PowerShellEditorServices.Test/Fixtures/Workspace/other/other.cdxml b/test/PowerShellEditorServices.Test/Fixtures/Workspace/other/other.cdxml new file mode 100644 index 0000000..08cfdb6 --- /dev/null +++ b/test/PowerShellEditorServices.Test/Fixtures/Workspace/other/other.cdxml @@ -0,0 +1 @@ + diff --git a/test/PowerShellEditorServices.Test/Fixtures/Workspace/other/other.ps1xml b/test/PowerShellEditorServices.Test/Fixtures/Workspace/other/other.ps1xml new file mode 100644 index 0000000..951d51d --- /dev/null +++ b/test/PowerShellEditorServices.Test/Fixtures/Workspace/other/other.ps1xml @@ -0,0 +1 @@ + diff --git a/test/PowerShellEditorServices.Test/Fixtures/Workspace/other/other.psrc b/test/PowerShellEditorServices.Test/Fixtures/Workspace/other/other.psrc new file mode 100644 index 0000000..a53a4dd --- /dev/null +++ b/test/PowerShellEditorServices.Test/Fixtures/Workspace/other/other.psrc @@ -0,0 +1 @@ +# other.psrc diff --git a/test/PowerShellEditorServices.Test/Fixtures/Workspace/other/other.pssc b/test/PowerShellEditorServices.Test/Fixtures/Workspace/other/other.pssc new file mode 100644 index 0000000..7d49b10 --- /dev/null +++ b/test/PowerShellEditorServices.Test/Fixtures/Workspace/other/other.pssc @@ -0,0 +1 @@ +# other.pssc diff --git a/test/PowerShellEditorServices.Test/Fixtures/Workspace/rootfile.ps1 b/test/PowerShellEditorServices.Test/Fixtures/Workspace/rootfile.ps1 new file mode 100644 index 0000000..f61acd8 --- /dev/null +++ b/test/PowerShellEditorServices.Test/Fixtures/Workspace/rootfile.ps1 @@ -0,0 +1 @@ +# rootfile.ps1 diff --git a/test/PowerShellEditorServices.Test/Language/CompletionHandlerTests.cs b/test/PowerShellEditorServices.Test/Language/CompletionHandlerTests.cs new file mode 100644 index 0000000..4ccdc05 --- /dev/null +++ b/test/PowerShellEditorServices.Test/Language/CompletionHandlerTests.cs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.PowerShell.EditorServices.Handlers; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Test; +using Microsoft.PowerShell.EditorServices.Test.Shared; +using Microsoft.PowerShell.EditorServices.Test.Shared.Completion; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using Xunit; + +namespace PowerShellEditorServices.Test.Language +{ + [Trait("Category", "Completions")] + public class CompletionHandlerTests : IAsyncLifetime + { + private PsesInternalHost psesHost; + private WorkspaceService workspace; + private PsesCompletionHandler completionHandler; + + public async Task InitializeAsync() + { + psesHost = await PsesHostFactory.Create(NullLoggerFactory.Instance); + workspace = new WorkspaceService(NullLoggerFactory.Instance); + completionHandler = new PsesCompletionHandler(NullLoggerFactory.Instance, psesHost, psesHost, workspace); + } + + public async Task DisposeAsync() => await Task.Run(psesHost.StopAsync); + + private ScriptFile GetScriptFile(ScriptRegion scriptRegion) => workspace.GetFile(TestUtilities.GetSharedPath(scriptRegion.File)); + + private Task GetCompletionResultsAsync(ScriptRegion scriptRegion) + { + return completionHandler.GetCompletionsInFileAsync( + GetScriptFile(scriptRegion), + scriptRegion.StartLineNumber, + scriptRegion.StartColumnNumber, + CancellationToken.None); + } + + [Fact] + public async Task CompletesCommandInFile() + { + (_, IEnumerable results) = await GetCompletionResultsAsync(CompleteCommandInFile.SourceDetails); + CompletionItem actual = Assert.Single(results); + Assert.Equal(CompleteCommandInFile.ExpectedCompletion, actual); + } + + [Fact] + public async Task CompletesCommandFromModule() + { + (_, IEnumerable results) = await GetCompletionResultsAsync(CompleteCommandFromModule.SourceDetails); + CompletionItem actual = Assert.Single(results); + // NOTE: The tooltip varies across PowerShell and OS versions, so we ignore it. + Assert.Equal(CompleteCommandFromModule.ExpectedCompletion, actual with { Detail = "" }); + Assert.StartsWith(CompleteCommandFromModule.GetRandomDetail, actual.Detail); + } + + [SkippableFact] + public async Task CompletesTypeName() + { + Skip.If(VersionUtils.PSEdition == "Desktop", "Windows PowerShell has trouble with this test right now."); + (_, IEnumerable results) = await GetCompletionResultsAsync(CompleteTypeName.SourceDetails); + CompletionItem actual = Assert.Single(results); + if (VersionUtils.IsNetCore) + { + Assert.Equal(CompleteTypeName.ExpectedCompletion, actual); + } + else + { + // Windows PowerShell shows ArrayList as a Class. + Assert.Equal(CompleteTypeName.ExpectedCompletion with + { + Kind = CompletionItemKind.Class, + Detail = "System.Collections.ArrayList" + }, actual); + } + } + + [SkippableFact] + public async Task CompletesNamespace() + { + Skip.If(VersionUtils.PSEdition == "Desktop", "Windows PowerShell has trouble with this test right now."); + (_, IEnumerable results) = await GetCompletionResultsAsync(CompleteNamespace.SourceDetails); + CompletionItem actual = Assert.Single(results); + Assert.Equal(CompleteNamespace.ExpectedCompletion, actual); + } + + [Fact] + public async Task CompletesVariableInFile() + { + (_, IEnumerable results) = await GetCompletionResultsAsync(CompleteVariableInFile.SourceDetails); + CompletionItem actual = Assert.Single(results); + Assert.Equal(CompleteVariableInFile.ExpectedCompletion, actual); + } + + [Fact] + public async Task CompletesAttributeValue() + { + (_, IEnumerable results) = await GetCompletionResultsAsync(CompleteAttributeValue.SourceDetails); + // NOTE: Since the completions come through un-ordered from PowerShell, their SortText + // (which has an index prepended from the original order) will mis-match our assumed + // order; hence we ignore it. + Assert.Collection(results.OrderBy(c => c.Label), + actual => Assert.Equal(actual with { Data = null, SortText = null }, CompleteAttributeValue.ExpectedCompletion1), + actual => Assert.Equal(actual with { Data = null, SortText = null }, CompleteAttributeValue.ExpectedCompletion2), + actual => Assert.Equal(actual with { Data = null, SortText = null }, CompleteAttributeValue.ExpectedCompletion3)); + } + + [Fact] + public async Task CompletesFilePath() + { + (_, IEnumerable results) = await GetCompletionResultsAsync(CompleteFilePath.SourceDetails); + Assert.NotEmpty(results); + CompletionItem actual = results.First(); + // Paths are system dependent so we ignore the text and just check the type and range. + Assert.Equal(actual.TextEdit.TextEdit with { NewText = "" }, CompleteFilePath.ExpectedEdit); + Assert.All(results, r => Assert.True(r.Kind is CompletionItemKind.File or CompletionItemKind.Folder)); + } + + // TODO: These should be an integration tests at a higher level if/when https://github.com/PowerShell/PowerShell/pull/25108 is merged. As of today, we can't actually test this in the PS engine currently. + [Fact] + public void CanExtractTypeAndDescriptionFromTooltip() + { + string expectedType = "[string]"; + string expectedDescription = "Test String"; + string paramName = "TestParam"; + string testHelp = $"{expectedType} {paramName} - {expectedDescription}"; + Assert.True(PsesCompletionHandler.TryExtractType(testHelp, paramName, out string type, out string description)); + Assert.Equal(expectedType, type); + Assert.Equal(expectedDescription, description); + } + + [Fact] + public void CanExtractTypeFromTooltip() + { + string expectedType = "[string]"; + string testHelp = $"{expectedType}"; + Assert.True(PsesCompletionHandler.TryExtractType(testHelp, string.Empty, out string type, out string description)); + Assert.Null(description); + Assert.Equal(expectedType, type); + } + } +} diff --git a/test/PowerShellEditorServices.Test/Language/SemanticTokenTest.cs b/test/PowerShellEditorServices.Test/Language/SemanticTokenTest.cs new file mode 100644 index 0000000..196fa9c --- /dev/null +++ b/test/PowerShellEditorServices.Test/Language/SemanticTokenTest.cs @@ -0,0 +1,200 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Management.Automation.Language; +using Microsoft.PowerShell.EditorServices.Handlers; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using Xunit; + +namespace PowerShellEditorServices.Test.Language +{ + public class SemanticTokenTest + { + [Fact] + public void TokenizesFunctionElements() + { + const string text = @" +function Get-Sum { + param( [parameter()] [int]$a, [int]$b ) + :loopLabel while (0) {break loopLabel} + return $a + $b +} +"; + ScriptFile scriptFile = ScriptFile.Create( + // Use any absolute path. Even if it doesn't exist. + DocumentUri.FromFileSystemPath(Path.Combine(Path.GetTempPath(), "TestFile.ps1")), + text, + Version.Parse("5.0")); + + foreach (Token t in scriptFile.ScriptTokens) + { + List mappedTokens = new(PsesSemanticTokensHandler.ConvertToSemanticTokens(t)); + switch (t.Text) + { + case "function": + case "param": + case "return": + case "while": + case "break": + Assert.Single(mappedTokens, sToken => SemanticTokenType.Keyword == sToken.Type); + break; + case "parameter": + Assert.Single(mappedTokens, sToken => SemanticTokenType.Decorator == sToken.Type); + break; + case "0": + Assert.Single(mappedTokens, sToken => SemanticTokenType.Number == sToken.Type); + break; + case ":loopLabel": + Assert.Single(mappedTokens, sToken => SemanticTokenType.Label == sToken.Type); + break; + case "loopLabel": + Assert.Single(mappedTokens, sToken => SemanticTokenType.Property == sToken.Type); + break; + case "$a": + case "$b": + Assert.Single(mappedTokens, sToken => SemanticTokenType.Variable == sToken.Type); + break; + case "[int]": + Assert.Single(mappedTokens, sToken => SemanticTokenType.Type == sToken.Type); + break; + case "+": + Assert.Single(mappedTokens, sToken => SemanticTokenType.Operator == sToken.Type); + break; + } + } + } + + [Fact] + public void TokenizesStringExpansion() + { + const string text = "Write-Host \"$(Test-Property Get-Whatever) $(Get-Whatever)\""; + ScriptFile scriptFile = ScriptFile.Create( + // Use any absolute path. Even if it doesn't exist. + DocumentUri.FromFileSystemPath(Path.Combine(Path.GetTempPath(), "TestFile.ps1")), + text, + Version.Parse("5.0")); + + Token commandToken = scriptFile.ScriptTokens[0]; + List mappedTokens = new(PsesSemanticTokensHandler.ConvertToSemanticTokens(commandToken)); + Assert.Single(mappedTokens, sToken => SemanticTokenType.Function == sToken.Type); + + Token stringExpandableToken = scriptFile.ScriptTokens[1]; + mappedTokens = new List(PsesSemanticTokensHandler.ConvertToSemanticTokens(stringExpandableToken)); + Assert.Collection(mappedTokens, + sToken => Assert.Equal(SemanticTokenType.Function, sToken.Type), + sToken => Assert.Equal(SemanticTokenType.Function, sToken.Type) + ); + } + + [Fact] + public void RecognizesTokensWithAsterisk() + { + const string text = @" +function Get-A*A { +} +Get-A*A +"; + ScriptFile scriptFile = ScriptFile.Create( + // Use any absolute path. Even if it doesn't exist. + DocumentUri.FromFileSystemPath(Path.Combine(Path.GetTempPath(), "TestFile.ps1")), + text, + Version.Parse("5.0")); + + foreach (Token t in scriptFile.ScriptTokens) + { + List mappedTokens = new(PsesSemanticTokensHandler.ConvertToSemanticTokens(t)); + switch (t.Text) + { + case "function": + Assert.Single(mappedTokens, sToken => SemanticTokenType.Keyword == sToken.Type); + break; + case "Get-A*A": + if (t.TokenFlags.HasFlag(TokenFlags.CommandName)) + { + Assert.Single(mappedTokens, sToken => SemanticTokenType.Function == sToken.Type); + } + + break; + } + } + } + + [Fact] + public void RecognizesArrayPropertyInExpandableString() + { + const string text = "\"$(@($Array).Count) OtherText\""; + ScriptFile scriptFile = ScriptFile.Create( + // Use any absolute path. Even if it doesn't exist. + DocumentUri.FromFileSystemPath(Path.Combine(Path.GetTempPath(), "TestFile.ps1")), + text, + Version.Parse("5.0")); + + foreach (Token t in scriptFile.ScriptTokens) + { + List mappedTokens = new(PsesSemanticTokensHandler.ConvertToSemanticTokens(t)); + switch (t.Text) + { + case "$Array": + Assert.Single(mappedTokens, sToken => SemanticTokenType.Variable == sToken.Type); + break; + case "Count": + Assert.Single(mappedTokens, sToken => SemanticTokenType.Property == sToken.Type); + break; + } + } + } + + [Fact] + public void RecognizesCurlyQuotedString() + { + const string text = "“^[-'a-z]*”"; + ScriptFile scriptFile = ScriptFile.Create( + // Use any absolute path. Even if it doesn't exist. + DocumentUri.FromFileSystemPath(Path.Combine(Path.GetTempPath(), "TestFile.ps1")), + text, + Version.Parse("5.0")); + + List mappedTokens = new(PsesSemanticTokensHandler.ConvertToSemanticTokens(scriptFile.ScriptTokens[0])); + Assert.Single(mappedTokens, sToken => SemanticTokenType.String == sToken.Type); + } + + [Fact] + public void RecognizeEnum() + { + const string text = @" +enum MyEnum{ + one + two + three +} +"; + ScriptFile scriptFile = ScriptFile.Create( + // Use any absolute path. Even if it doesn't exist. + DocumentUri.FromFileSystemPath(Path.Combine(Path.GetTempPath(), "TestFile.ps1")), + text, + Version.Parse("5.0")); + + foreach (Token t in scriptFile.ScriptTokens) + { + List mappedTokens = new(PsesSemanticTokensHandler.ConvertToSemanticTokens(t)); + switch (t.Text) + { + case "enum": + Assert.Single(mappedTokens, sToken => SemanticTokenType.Keyword == sToken.Type); + break; + case "MyEnum": + case "one": + case "two": + case "three": + Assert.Single(mappedTokens, sToken => SemanticTokenType.Property == sToken.Type); + break; + } + } + } + } +} diff --git a/test/PowerShellEditorServices.Test/Language/SymbolsServiceTests.cs b/test/PowerShellEditorServices.Test/Language/SymbolsServiceTests.cs new file mode 100644 index 0000000..593fddb --- /dev/null +++ b/test/PowerShellEditorServices.Test/Language/SymbolsServiceTests.cs @@ -0,0 +1,993 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; +using Microsoft.PowerShell.EditorServices.Services.Symbols; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Test; +using Microsoft.PowerShell.EditorServices.Test.Shared; +using Microsoft.PowerShell.EditorServices.Test.Shared.Definition; +using Microsoft.PowerShell.EditorServices.Test.Shared.Occurrences; +using Microsoft.PowerShell.EditorServices.Test.Shared.ParameterHint; +using Microsoft.PowerShell.EditorServices.Test.Shared.References; +using Microsoft.PowerShell.EditorServices.Test.Shared.SymbolDetails; +using Microsoft.PowerShell.EditorServices.Test.Shared.Symbols; +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using Xunit; + +namespace PowerShellEditorServices.Test.Language +{ + [Trait("Category", "Symbols")] + public class SymbolsServiceTests : IAsyncLifetime + { + private PsesInternalHost psesHost; + private WorkspaceService workspace; + private SymbolsService symbolsService; + private static readonly bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + public async Task InitializeAsync() + { + psesHost = await PsesHostFactory.Create(NullLoggerFactory.Instance); + workspace = new WorkspaceService(NullLoggerFactory.Instance); + workspace.WorkspaceFolders.Add(new WorkspaceFolder + { + Uri = DocumentUri.FromFileSystemPath(TestUtilities.GetSharedPath("References")) + }); + symbolsService = new SymbolsService( + NullLoggerFactory.Instance, + psesHost, + psesHost, + workspace, + new ConfigurationService()); + } + + public async Task DisposeAsync() + { + psesHost.StopAsync(); + CommandHelpers.s_cmdletToAliasCache.Clear(); + CommandHelpers.s_aliasToCmdletCache.Clear(); + } + + private static void AssertIsRegion( + ScriptRegion region, + int startLineNumber, + int startColumnNumber, + int endLineNumber, + int endColumnNumber) + { + Assert.Equal(startLineNumber, region.StartLineNumber); + Assert.Equal(startColumnNumber, region.StartColumnNumber); + Assert.Equal(endLineNumber, region.EndLineNumber); + Assert.Equal(endColumnNumber, region.EndColumnNumber); + } + + private ScriptFile GetScriptFile(ScriptRegion scriptRegion) => workspace.GetFile(TestUtilities.GetSharedPath(scriptRegion.File)); + + private Task GetParamSetSignatures(ScriptRegion scriptRegion) + { + return symbolsService.FindParameterSetsInFileAsync( + GetScriptFile(scriptRegion), + scriptRegion.StartLineNumber, + scriptRegion.StartColumnNumber); + } + + private async Task> GetDefinitions(ScriptRegion scriptRegion) + { + ScriptFile scriptFile = GetScriptFile(scriptRegion); + + // TODO: We should just use the name to find it. + SymbolReference symbol = SymbolsService.FindSymbolAtLocation( + scriptFile, + scriptRegion.StartLineNumber, + scriptRegion.StartColumnNumber); + + Assert.NotNull(symbol); + + IEnumerable symbols = + await symbolsService.GetDefinitionOfSymbolAsync(scriptFile, symbol); + + return symbols.OrderBy((i) => i.ScriptRegion.ToRange().Start); + } + + private async Task GetDefinition(ScriptRegion scriptRegion) + { + IEnumerable definitions = await GetDefinitions(scriptRegion); + return definitions.FirstOrDefault(); + } + + private async Task> GetReferences(ScriptRegion scriptRegion) + { + ScriptFile scriptFile = GetScriptFile(scriptRegion); + + SymbolReference symbol = SymbolsService.FindSymbolAtLocation( + scriptFile, + scriptRegion.StartLineNumber, + scriptRegion.StartColumnNumber); + + Assert.NotNull(symbol); + + IEnumerable symbols = + await symbolsService.ScanForReferencesOfSymbolAsync(symbol); + + return symbols.OrderBy((i) => i.ScriptRegion.ToRange().Start); + } + + private IEnumerable GetOccurrences(ScriptRegion scriptRegion) + { + return SymbolsService + .FindOccurrencesInFile( + GetScriptFile(scriptRegion), + scriptRegion.StartLineNumber, + scriptRegion.StartColumnNumber) + .OrderBy(symbol => symbol.ScriptRegion.ToRange().Start) + .ToArray(); + } + + private IEnumerable FindSymbolsInFile(ScriptRegion scriptRegion) + { + return symbolsService + .FindSymbolsInFile(GetScriptFile(scriptRegion)) + .OrderBy(symbol => symbol.ScriptRegion.ToRange().Start); + } + + [Fact] + public async Task FindsParameterHintsOnCommand() + { + // TODO: Fix signatures to use parameters, not sets. + ParameterSetSignatures signatures = await GetParamSetSignatures(FindsParameterSetsOnCommandData.SourceDetails); + Assert.NotNull(signatures); + Assert.Equal("Get-Process", signatures.CommandName); + Assert.Equal(6, signatures.Signatures.Length); + } + + [Fact] + public async Task FindsCommandForParamHintsWithSpaces() + { + ParameterSetSignatures signatures = await GetParamSetSignatures(FindsParameterSetsOnCommandWithSpacesData.SourceDetails); + Assert.NotNull(signatures); + Assert.Equal("Write-Host", signatures.CommandName); + Assert.Single(signatures.Signatures); + } + + [Fact] + public async Task FindsFunctionDefinition() + { + SymbolReference symbol = await GetDefinition(FindsFunctionDefinitionData.SourceDetails); + Assert.Equal("fn My-Function", symbol.Id); + Assert.Equal("function My-Function ($myInput)", symbol.Name); + Assert.Equal(SymbolType.Function, symbol.Type); + AssertIsRegion(symbol.NameRegion, 1, 10, 1, 21); + AssertIsRegion(symbol.ScriptRegion, 1, 1, 4, 2); + Assert.True(symbol.IsDeclaration); + } + + [Fact] + public async Task FindsFunctionDefinitionForAlias() + { + // TODO: Eventually we should get the aliases through the AST instead of relying on them + // being defined in the runspace. + await psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("Set-Alias -Name My-Alias -Value My-Function"), + CancellationToken.None); + + SymbolReference symbol = await GetDefinition(FindsFunctionDefinitionOfAliasData.SourceDetails); + Assert.Equal("function My-Function ($myInput)", symbol.Name); + Assert.Equal(SymbolType.Function, symbol.Type); + AssertIsRegion(symbol.NameRegion, 1, 10, 1, 21); + AssertIsRegion(symbol.ScriptRegion, 1, 1, 4, 2); + Assert.True(symbol.IsDeclaration); + } + + [Fact] + public async Task FindsReferencesOnFunction() + { + IEnumerable symbols = await GetReferences(FindsReferencesOnFunctionData.SourceDetails); + Assert.Collection(symbols, + (i) => + { + Assert.Equal("fn My-Function", i.Id); + Assert.Equal("function My-Function ($myInput)", i.Name); + Assert.Equal(SymbolType.Function, i.Type); + Assert.True(i.IsDeclaration); + }, + (i) => + { + Assert.Equal("fn My-Function", i.Id); + Assert.Equal("My-Function", i.Name); + Assert.Equal(SymbolType.Function, i.Type); + Assert.EndsWith(FindsFunctionDefinitionInWorkspaceData.SourceDetails.File, i.FilePath); + Assert.False(i.IsDeclaration); + }, + (i) => + { + Assert.Equal("fn My-Function", i.Id); + Assert.Equal("My-Function", i.Name); + Assert.Equal(SymbolType.Function, i.Type); + Assert.False(i.IsDeclaration); + }, + (i) => + { + Assert.Equal("fn My-Function", i.Id); + Assert.Equal("My-Function", i.Name); + Assert.Equal(SymbolType.Function, i.Type); + Assert.False(i.IsDeclaration); + }, + (i) => + { + Assert.Equal("fn My-Function", i.Id); + Assert.Equal("$Function:My-Function", i.Name); + Assert.Equal(SymbolType.Function, i.Type); + Assert.False(i.IsDeclaration); + }); + } + + [Fact] + public async Task FindsReferenceAcrossMultiRootWorkspace() + { + workspace.WorkspaceFolders = new[] { "Debugging", "ParameterHints", "SymbolDetails" } + .Select(i => new WorkspaceFolder + { + Uri = DocumentUri.FromFileSystemPath(TestUtilities.GetSharedPath(i)) + }).ToList(); + + SymbolReference symbol = new("fn Get-Process", SymbolType.Function); + IEnumerable symbols = await symbolsService.ScanForReferencesOfSymbolAsync(symbol); + Assert.Collection(symbols.OrderBy(i => i.FilePath), + i => Assert.EndsWith("VariableTest.ps1", i.FilePath), + i => Assert.EndsWith("ParamHints.ps1", i.FilePath), + i => Assert.EndsWith("SymbolDetails.ps1", i.FilePath)); + } + + [Fact] + public async Task FindsReferencesOnFunctionIncludingAliases() + { + // TODO: Same as in FindsFunctionDefinitionForAlias. + await psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("Set-Alias -Name My-Alias -Value My-Function"), + CancellationToken.None); + + IEnumerable symbols = await GetReferences(FindsReferencesOnFunctionData.SourceDetails); + + Assert.Collection(symbols, + (i) => AssertIsRegion(i.NameRegion, 1, 10, 1, 21), + (i) => AssertIsRegion(i.NameRegion, 3, 1, 3, 12), + (i) => AssertIsRegion(i.NameRegion, 3, 5, 3, 16), + (i) => AssertIsRegion(i.NameRegion, 10, 1, 10, 12), + // The alias. + (i) => + { + AssertIsRegion(i.NameRegion, 20, 1, 20, 9); + Assert.Equal("fn My-Alias", i.Id); + }, + (i) => AssertIsRegion(i.NameRegion, 22, 29, 22, 52)); + } + + [Fact] + public async Task FindsFunctionDefinitionInWorkspace() + { + IEnumerable symbols = await GetDefinitions(FindsFunctionDefinitionInWorkspaceData.SourceDetails); + SymbolReference symbol = Assert.Single(symbols); + Assert.Equal("fn My-Function", symbol.Id); + Assert.Equal("function My-Function ($myInput)", symbol.Name); + Assert.True(symbol.IsDeclaration); + Assert.EndsWith(FindsFunctionDefinitionData.SourceDetails.File, symbol.FilePath); + } + + [Fact] + public async Task FindsVariableDefinition() + { + IEnumerable definitions = await GetDefinitions(FindsVariableDefinitionData.SourceDetails); + SymbolReference symbol = Assert.Single(definitions); // Even though it's re-assigned + Assert.Equal("var things", symbol.Id); + Assert.Equal("$things", symbol.Name); + Assert.Equal(SymbolType.Variable, symbol.Type); + Assert.True(symbol.IsDeclaration); + AssertIsRegion(symbol.NameRegion, 6, 1, 6, 8); + } + + [Fact] + public async Task FindsTypedVariableDefinition() + { + IEnumerable definitions = await GetDefinitions(FindsTypedVariableDefinitionData.SourceDetails); + SymbolReference symbol = Assert.Single(definitions); + Assert.Equal("var hello", symbol.Id); + Assert.Equal("$hello", symbol.Name); + Assert.Equal(SymbolType.Variable, symbol.Type); + Assert.True(symbol.IsDeclaration); + AssertIsRegion(symbol.NameRegion, 24, 9, 24, 15); + } + + [Fact] + public async Task FindsReferencesOnVariable() + { + IEnumerable symbols = await GetReferences(FindsReferencesOnVariableData.SourceDetails); + Assert.Collection(symbols, + (i) => + { + Assert.Equal("var things", i.Id); + Assert.Equal("$things", i.Name); + Assert.Equal(SymbolType.Variable, i.Type); + Assert.True(i.IsDeclaration); + }, + (i) => + { + Assert.Equal("var things", i.Id); + Assert.Equal("$things", i.Name); + Assert.Equal(SymbolType.Variable, i.Type); + Assert.False(i.IsDeclaration); + }, + (i) => + { + Assert.Equal("var things", i.Id); + Assert.Equal("$things", i.Name); + Assert.Equal(SymbolType.Variable, i.Type); + Assert.False(i.IsDeclaration); + }); + + Assert.Equal(symbols, GetOccurrences(FindsOccurrencesOnVariableData.SourceDetails)); + } + + [Fact] + public void FindsOccurrencesOnFunction() + { + IEnumerable symbols = GetOccurrences(FindsOccurrencesOnFunctionData.SourceDetails); + Assert.Collection(symbols, + (i) => + { + Assert.Equal("fn My-Function", i.Id); + Assert.Equal(SymbolType.Function, i.Type); + Assert.True(i.IsDeclaration); + }, + (i) => + { + Assert.Equal("fn My-Function", i.Id); + Assert.Equal(SymbolType.Function, i.Type); + Assert.False(i.IsDeclaration); + }, + (i) => + { + Assert.Equal("fn My-Function", i.Id); + Assert.Equal(SymbolType.Function, i.Type); + Assert.False(i.IsDeclaration); + }, + (i) => + { + Assert.Equal("fn My-Function", i.Id); + Assert.Equal("$Function:My-Function", i.Name); + Assert.Equal(SymbolType.Function, i.Type); + Assert.False(i.IsDeclaration); + }); + } + + [Fact] + public void FindsOccurrencesOnParameter() + { + IEnumerable symbols = GetOccurrences(FindOccurrencesOnParameterData.SourceDetails); + Assert.Collection(symbols, + (i) => + { + Assert.Equal("var myInput", i.Id); + // TODO: Parameter names need work. + Assert.Equal("(parameter) [System.Object]$myInput", i.Name); + Assert.Equal(SymbolType.Parameter, i.Type); + AssertIsRegion(i.NameRegion, 1, 23, 1, 31); + Assert.True(i.IsDeclaration); + }, + (i) => + { + Assert.Equal("var myInput", i.Id); + Assert.Equal("$myInput", i.Name); + Assert.Equal(SymbolType.Variable, i.Type); + AssertIsRegion(i.NameRegion, 3, 17, 3, 25); + Assert.False(i.IsDeclaration); + }); + } + + [Fact] + public async Task FindsReferencesOnCommandWithAlias() + { + // NOTE: This doesn't use GetOccurrences as it's testing for aliases. + IEnumerable symbols = await GetReferences(FindsReferencesOnBuiltInCommandWithAliasData.SourceDetails); + Assert.Collection(symbols.Where( + (i) => i.FilePath + .EndsWith(FindsReferencesOnBuiltInCommandWithAliasData.SourceDetails.File)), + (i) => Assert.Equal("fn Get-ChildItem", i.Id), + (i) => Assert.Equal("fn gci", i.Id), + (i) => Assert.Equal("fn dir", i.Id), + (i) => Assert.Equal("fn Get-ChildItem", i.Id)); + } + + [Fact] + public async Task FindsClassDefinition() + { + SymbolReference symbol = await GetDefinition(FindsTypeSymbolsDefinitionData.ClassSourceDetails); + Assert.Equal("type SuperClass", symbol.Id); + Assert.Equal("class SuperClass { }", symbol.Name); + Assert.Equal(SymbolType.Class, symbol.Type); + Assert.True(symbol.IsDeclaration); + AssertIsRegion(symbol.NameRegion, 8, 7, 8, 17); + } + + [Fact] + public async Task FindsReferencesOnClass() + { + IEnumerable symbols = await GetReferences(FindsReferencesOnTypeSymbolsData.ClassSourceDetails); + Assert.Collection(symbols, + (i) => + { + Assert.Equal("type SuperClass", i.Id); + Assert.Equal("class SuperClass { }", i.Name); + Assert.Equal(SymbolType.Class, i.Type); + Assert.True(i.IsDeclaration); + }, + (i) => + { + Assert.Equal("type SuperClass", i.Id); + Assert.Equal("(type) SuperClass", i.Name); + Assert.Equal(SymbolType.Type, i.Type); + Assert.False(i.IsDeclaration); + }); + + Assert.Equal(symbols, GetOccurrences(FindsOccurrencesOnTypeSymbolsData.ClassSourceDetails)); + } + + [Fact] + public async Task FindsEnumDefinition() + { + SymbolReference symbol = await GetDefinition(FindsTypeSymbolsDefinitionData.EnumSourceDetails); + Assert.Equal("type MyEnum", symbol.Id); + Assert.Equal("enum MyEnum { }", symbol.Name); + Assert.Equal(SymbolType.Enum, symbol.Type); + Assert.True(symbol.IsDeclaration); + AssertIsRegion(symbol.NameRegion, 39, 6, 39, 12); + } + + [Fact] + public async Task FindsReferencesOnEnum() + { + IEnumerable symbols = await GetReferences(FindsReferencesOnTypeSymbolsData.EnumSourceDetails); + Assert.Collection(symbols, + (i) => + { + Assert.Equal("type MyEnum", i.Id); + Assert.Equal("(type) MyEnum", i.Name); + Assert.Equal(SymbolType.Type, i.Type); + Assert.False(i.IsDeclaration); + }, + (i) => + { + Assert.Equal("type MyEnum", i.Id); + Assert.Equal("enum MyEnum { }", i.Name); + Assert.Equal(SymbolType.Enum, i.Type); + Assert.True(i.IsDeclaration); + }, + (i) => + { + Assert.Equal("type MyEnum", i.Id); + Assert.Equal("(type) MyEnum", i.Name); + Assert.Equal(SymbolType.Type, i.Type); + Assert.False(i.IsDeclaration); + }, + (i) => + { + Assert.Equal("type MyEnum", i.Id); + Assert.Equal("(type) MyEnum", i.Name); + Assert.Equal(SymbolType.Type, i.Type); + Assert.False(i.IsDeclaration); + }); + + Assert.Equal(symbols, GetOccurrences(FindsOccurrencesOnTypeSymbolsData.EnumSourceDetails)); + } + + [Fact] + public async Task FindsTypeExpressionDefinition() + { + SymbolReference symbol = await GetDefinition(FindsTypeSymbolsDefinitionData.TypeExpressionSourceDetails); + AssertIsRegion(symbol.NameRegion, 39, 6, 39, 12); + Assert.Equal("type MyEnum", symbol.Id); + Assert.Equal("enum MyEnum { }", symbol.Name); + Assert.True(symbol.IsDeclaration); + } + + [Fact] + public async Task FindsReferencesOnTypeExpression() + { + IEnumerable symbols = await GetReferences(FindsReferencesOnTypeSymbolsData.TypeExpressionSourceDetails); + Assert.Collection(symbols, + (i) => + { + Assert.Equal("type SuperClass", i.Id); + Assert.Equal("class SuperClass { }", i.Name); + Assert.Equal(SymbolType.Class, i.Type); + Assert.True(i.IsDeclaration); + }, + (i) => + { + Assert.Equal("type SuperClass", i.Id); + Assert.Equal("(type) SuperClass", i.Name); + Assert.Equal(SymbolType.Type, i.Type); + Assert.False(i.IsDeclaration); + }); + + Assert.Equal(symbols, GetOccurrences(FindsOccurrencesOnTypeSymbolsData.TypeExpressionSourceDetails)); + } + + [Fact] + public async Task FindsTypeConstraintDefinition() + { + SymbolReference symbol = await GetDefinition(FindsTypeSymbolsDefinitionData.TypeConstraintSourceDetails); + AssertIsRegion(symbol.NameRegion, 39, 6, 39, 12); + Assert.Equal("type MyEnum", symbol.Id); + Assert.Equal("enum MyEnum { }", symbol.Name); + Assert.True(symbol.IsDeclaration); + } + + [Fact] + public async Task FindsReferencesOnTypeConstraint() + { + IEnumerable symbols = await GetReferences(FindsReferencesOnTypeSymbolsData.TypeConstraintSourceDetails); + Assert.Collection(symbols, + (i) => + { + Assert.Equal("type MyEnum", i.Id); + Assert.Equal("(type) MyEnum", i.Name); + Assert.Equal(SymbolType.Type, i.Type); + Assert.False(i.IsDeclaration); + }, + (i) => + { + Assert.Equal("type MyEnum", i.Id); + Assert.Equal("enum MyEnum { }", i.Name); + Assert.Equal(SymbolType.Enum, i.Type); + Assert.True(i.IsDeclaration); + }, + (i) => + { + Assert.Equal("type MyEnum", i.Id); + Assert.Equal("(type) MyEnum", i.Name); + Assert.Equal(SymbolType.Type, i.Type); + Assert.False(i.IsDeclaration); + }, + (i) => + { + Assert.Equal("type MyEnum", i.Id); + Assert.Equal("(type) MyEnum", i.Name); + Assert.Equal(SymbolType.Type, i.Type); + Assert.False(i.IsDeclaration); + }); + } + + [Fact] + public void FindsOccurrencesOnTypeConstraint() + { + IEnumerable symbols = GetOccurrences(FindsOccurrencesOnTypeSymbolsData.TypeConstraintSourceDetails); + Assert.Collection(symbols, + (i) => + { + Assert.Equal("type BaseClass", i.Id); + Assert.Equal("class BaseClass { }", i.Name); + Assert.Equal(SymbolType.Class, i.Type); + Assert.True(i.IsDeclaration); + }, + (i) => + { + Assert.Equal("type BaseClass", i.Id); + Assert.Equal("(type) BaseClass", i.Name); + Assert.Equal(SymbolType.Type, i.Type); + Assert.False(i.IsDeclaration); + }); + } + + [Fact] + public async Task FindsConstructorDefinition() + { + IEnumerable symbols = await GetDefinitions(FindsTypeSymbolsDefinitionData.ConstructorSourceDetails); + Assert.Collection(symbols, + (i) => + { + Assert.Equal("mtd SuperClass", i.Id); + Assert.Equal("SuperClass([string]$name)", i.Name); + Assert.Equal(SymbolType.Constructor, i.Type); + Assert.True(i.IsDeclaration); + }, + (i) => + { + Assert.Equal("mtd SuperClass", i.Id); + Assert.Equal("SuperClass()", i.Name); + Assert.Equal(SymbolType.Constructor, i.Type); + Assert.True(i.IsDeclaration); + }); + + Assert.Equal(symbols, await GetReferences(FindsReferencesOnTypeSymbolsData.ConstructorSourceDetails)); + Assert.Equal(symbols, GetOccurrences(FindsOccurrencesOnTypeSymbolsData.ConstructorSourceDetails)); + } + + [Fact] + public async Task FindsMethodDefinition() + { + IEnumerable symbols = await GetDefinitions(FindsTypeSymbolsDefinitionData.MethodSourceDetails); + Assert.Collection(symbols, + (i) => + { + Assert.Equal("mtd MyClassMethod", i.Id); + Assert.Equal("string MyClassMethod([string]$param1, $param2, [int]$param3)", i.Name); + Assert.Equal(SymbolType.Method, i.Type); + Assert.True(i.IsDeclaration); + }, + (i) => + { + Assert.Equal("mtd MyClassMethod", i.Id); + Assert.Equal("string MyClassMethod([MyEnum]$param1)", i.Name); + Assert.Equal(SymbolType.Method, i.Type); + Assert.True(i.IsDeclaration); + }, + (i) => + { + Assert.Equal("mtd MyClassMethod", i.Id); + Assert.Equal("string MyClassMethod()", i.Name); + Assert.Equal(SymbolType.Method, i.Type); + Assert.True(i.IsDeclaration); + }); + } + + [Fact] + public async Task FindsReferencesOnMethod() + { + IEnumerable symbols = await GetReferences(FindsReferencesOnTypeSymbolsData.MethodSourceDetails); + Assert.Collection(symbols, + (i) => Assert.Equal("string MyClassMethod([string]$param1, $param2, [int]$param3)", i.Name), + (i) => Assert.Equal("string MyClassMethod([MyEnum]$param1)", i.Name), + (i) => Assert.Equal("string MyClassMethod()", i.Name), + (i) => // The invocation! + { + Assert.Equal("mtd MyClassMethod", i.Id); + Assert.Equal("(method) MyClassMethod", i.Name); + Assert.Equal("$o.MyClassMethod()", i.SourceLine); + Assert.Equal(SymbolType.Method, i.Type); + Assert.False(i.IsDeclaration); + }); + + Assert.Equal(symbols, GetOccurrences(FindsOccurrencesOnTypeSymbolsData.MethodSourceDetails)); + } + + [Fact] + public async Task FindsPropertyDefinition() + { + SymbolReference symbol = await GetDefinition(FindsTypeSymbolsDefinitionData.PropertySourceDetails); + Assert.Equal("prop SomePropWithDefault", symbol.Id); + Assert.Equal("[string] $SomePropWithDefault", symbol.Name); + Assert.Equal(SymbolType.Property, symbol.Type); + Assert.True(symbol.IsDeclaration); + } + + [Fact] + public async Task FindsReferencesOnProperty() + { + IEnumerable symbols = await GetReferences(FindsReferencesOnTypeSymbolsData.PropertySourceDetails); + Assert.Collection(symbols, + (i) => + { + Assert.Equal("prop SomeProp", i.Id); + Assert.Equal("[int] $SomeProp", i.Name); + Assert.Equal(SymbolType.Property, i.Type); + Assert.True(i.IsDeclaration); + }, + (i) => + { + Assert.Equal("prop SomeProp", i.Id); + Assert.Equal("(property) SomeProp", i.Name); + Assert.Equal(SymbolType.Property, i.Type); + Assert.False(i.IsDeclaration); + }); + } + + [Fact] + public void FindsOccurrencesOnProperty() + { + IEnumerable symbols = GetOccurrences(FindsOccurrencesOnTypeSymbolsData.PropertySourceDetails); + Assert.Collection(symbols, + (i) => + { + Assert.Equal("prop SomePropWithDefault", i.Id); + Assert.Equal("[string] $SomePropWithDefault", i.Name); + Assert.Equal(SymbolType.Property, i.Type); + Assert.True(i.IsDeclaration); + }, + (i) => + { + Assert.Equal("prop SomePropWithDefault", i.Id); + Assert.Equal("(property) SomePropWithDefault", i.Name); + Assert.Equal(SymbolType.Property, i.Type); + Assert.False(i.IsDeclaration); + }); + } + + [Fact] + public async Task FindsEnumMemberDefinition() + { + SymbolReference symbol = await GetDefinition(FindsTypeSymbolsDefinitionData.EnumMemberSourceDetails); + Assert.Equal("prop Second", symbol.Id); + // Doesn't include [MyEnum]:: because that'd be redundant in the outline. + Assert.Equal("Second", symbol.Name); + Assert.Equal(SymbolType.EnumMember, symbol.Type); + Assert.True(symbol.IsDeclaration); + AssertIsRegion(symbol.NameRegion, 41, 5, 41, 11); + + symbol = await GetDefinition(FindsReferencesOnTypeSymbolsData.EnumMemberSourceDetails); + Assert.Equal("prop First", symbol.Id); + Assert.Equal("First", symbol.Name); + Assert.Equal(SymbolType.EnumMember, symbol.Type); + Assert.True(symbol.IsDeclaration); + AssertIsRegion(symbol.NameRegion, 40, 5, 40, 10); + } + + [Fact] + public async Task FindsReferencesOnEnumMember() + { + IEnumerable symbols = await GetReferences(FindsReferencesOnTypeSymbolsData.EnumMemberSourceDetails); + Assert.Collection(symbols, + (i) => + { + Assert.Equal("prop First", i.Id); + Assert.Equal("First", i.Name); + Assert.Equal(SymbolType.EnumMember, i.Type); + Assert.True(i.IsDeclaration); + }, + (i) => + { + Assert.Equal("prop First", i.Id); + // The reference is just a member invocation, and so indistinguishable from a property. + Assert.Equal("(property) First", i.Name); + Assert.Equal(SymbolType.Property, i.Type); + Assert.False(i.IsDeclaration); + }); + + Assert.Equal(symbols, GetOccurrences(FindsOccurrencesOnTypeSymbolsData.EnumMemberSourceDetails)); + } + + [Fact] + public async Task FindsDetailsForBuiltInCommand() + { + SymbolDetails symbolDetails = await symbolsService.FindSymbolDetailsAtLocationAsync( + GetScriptFile(FindsDetailsForBuiltInCommandData.SourceDetails), + FindsDetailsForBuiltInCommandData.SourceDetails.StartLineNumber, + FindsDetailsForBuiltInCommandData.SourceDetails.StartColumnNumber, + CancellationToken.None); + + Assert.Equal("Extracts files from a specified archive (zipped) file.", symbolDetails.Documentation); + } + + [Fact] + public void FindsSymbolsInFile() + { + IEnumerable symbols = FindSymbolsInFile(FindSymbolsInMultiSymbolFile.SourceDetails); + + Assert.Equal(7, symbols.Count(i => i.Type == SymbolType.Function)); + Assert.Equal(8, symbols.Count(i => i.Type == SymbolType.Variable)); + Assert.Equal(4, symbols.Count(i => i.Type == SymbolType.Parameter)); + Assert.Equal(12, symbols.Count(i => i.Id.StartsWith("var "))); + Assert.Equal(2, symbols.Count(i => i.Id.StartsWith("prop "))); + + SymbolReference symbol = symbols.First(i => i.Type == SymbolType.Function); + Assert.Equal("fn AFunction", symbol.Id); + Assert.Equal("function script:AFunction ()", symbol.Name); + Assert.True(symbol.IsDeclaration); + Assert.Equal(2, GetOccurrences(symbol.NameRegion).Count()); + + symbol = symbols.First(i => i.Id == "fn AFilter"); + Assert.Equal("filter AFilter ()", symbol.Name); + Assert.True(symbol.IsDeclaration); + + symbol = symbols.Last(i => i.Type == SymbolType.Variable); + Assert.Equal("var nestedVar", symbol.Id); + Assert.Equal("$nestedVar", symbol.Name); + Assert.False(symbol.IsDeclaration); + AssertIsRegion(symbol.NameRegion, 16, 29, 16, 39); + + symbol = Assert.Single(symbols, i => i.Type == SymbolType.Workflow); + Assert.Equal("fn AWorkflow", symbol.Id); + Assert.Equal("workflow AWorkflow ()", symbol.Name); + Assert.True(symbol.IsDeclaration); + + symbol = Assert.Single(symbols, i => i.Type == SymbolType.Class); + Assert.Equal("type AClass", symbol.Id); + Assert.Equal("class AClass { }", symbol.Name); + Assert.True(symbol.IsDeclaration); + + symbol = Assert.Single(symbols, i => i.Type == SymbolType.Property); + Assert.Equal("prop AProperty", symbol.Id); + Assert.Equal("[string] $AProperty", symbol.Name); + Assert.True(symbol.IsDeclaration); + + symbol = Assert.Single(symbols, i => i.Type == SymbolType.Constructor); + Assert.Equal("mtd AClass", symbol.Id); + Assert.Equal("AClass([string]$AParameter)", symbol.Name); + Assert.True(symbol.IsDeclaration); + + symbol = Assert.Single(symbols, i => i.Type == SymbolType.Method); + Assert.Equal("mtd AMethod", symbol.Id); + Assert.Equal("void AMethod([string]$param1, [int]$param2, $param3)", symbol.Name); + Assert.True(symbol.IsDeclaration); + + symbol = Assert.Single(symbols, i => i.Type == SymbolType.Enum); + Assert.Equal("type AEnum", symbol.Id); + Assert.Equal("enum AEnum { }", symbol.Name); + Assert.True(symbol.IsDeclaration); + + symbol = Assert.Single(symbols, i => i.Type == SymbolType.EnumMember); + Assert.Equal("prop AValue", symbol.Id); + Assert.Equal("AValue", symbol.Name); + Assert.True(symbol.IsDeclaration); + + // There should be no region symbols unless the provider has been registered. + Assert.DoesNotContain(symbols, i => i.Type == SymbolType.Region); + } + + [Fact] + public void FindsRegionsInFile() + { + symbolsService.TryRegisterDocumentSymbolProvider(new RegionDocumentSymbolProvider()); + IEnumerable symbols = FindSymbolsInFile(FindSymbolsInMultiSymbolFile.SourceDetails); + Assert.Collection(symbols.Where(i => i.Type == SymbolType.Region), + (i) => + { + Assert.Equal("region find me outer", i.Id); + Assert.Equal("#region find me outer", i.Name); + Assert.Equal(SymbolType.Region, i.Type); + Assert.True(i.IsDeclaration); + AssertIsRegion(i.NameRegion, 51, 1, 51, 22); + AssertIsRegion(i.ScriptRegion, 51, 1, 55, 11); + }, + (i) => + { + Assert.Equal("region find me inner", i.Id); + Assert.Equal("#region find me inner", i.Name); + Assert.Equal(SymbolType.Region, i.Type); + Assert.True(i.IsDeclaration); + AssertIsRegion(i.NameRegion, 52, 1, 52, 22); + AssertIsRegion(i.ScriptRegion, 52, 1, 54, 11); + }); + } + + [Fact] + public void FindsSymbolsWithNewLineInFile() + { + IEnumerable symbols = FindSymbolsInFile(FindSymbolsInNewLineSymbolFile.SourceDetails); + + SymbolReference symbol = Assert.Single(symbols, i => i.Type == SymbolType.Function); + Assert.Equal("fn returnTrue", symbol.Id); + AssertIsRegion(symbol.NameRegion, 2, 1, 2, 11); + AssertIsRegion(symbol.ScriptRegion, 1, 1, 4, 2); + + symbol = Assert.Single(symbols, i => i.Type == SymbolType.Class); + Assert.Equal("type NewLineClass", symbol.Id); + AssertIsRegion(symbol.NameRegion, 7, 1, 7, 13); + AssertIsRegion(symbol.ScriptRegion, 6, 1, 23, 2); + + symbol = Assert.Single(symbols, i => i.Type == SymbolType.Constructor); + Assert.Equal("mtd NewLineClass", symbol.Id); + AssertIsRegion(symbol.NameRegion, 8, 5, 8, 17); + AssertIsRegion(symbol.ScriptRegion, 8, 5, 10, 6); + + symbol = Assert.Single(symbols, i => i.Type == SymbolType.Property); + Assert.Equal("prop SomePropWithDefault", symbol.Id); + AssertIsRegion(symbol.NameRegion, 15, 5, 15, 25); + AssertIsRegion(symbol.ScriptRegion, 12, 5, 15, 40); + + symbol = Assert.Single(symbols, i => i.Type == SymbolType.Method); + Assert.Equal("mtd MyClassMethod", symbol.Id); + Assert.Equal("string MyClassMethod([MyNewLineEnum]$param1)", symbol.Name); + AssertIsRegion(symbol.NameRegion, 20, 5, 20, 18); + AssertIsRegion(symbol.ScriptRegion, 17, 5, 22, 6); + + symbol = Assert.Single(symbols, i => i.Type == SymbolType.Enum); + Assert.Equal("type MyNewLineEnum", symbol.Id); + AssertIsRegion(symbol.NameRegion, 26, 1, 26, 14); + AssertIsRegion(symbol.ScriptRegion, 25, 1, 28, 2); + + symbol = Assert.Single(symbols, i => i.Type == SymbolType.EnumMember); + Assert.Equal("prop First", symbol.Id); + AssertIsRegion(symbol.NameRegion, 27, 5, 27, 10); + AssertIsRegion(symbol.ScriptRegion, 27, 5, 27, 10); + } + + [SkippableFact] + public void FindsSymbolsInDSCFile() + { + Skip.If(!isWindows, "DSC only works properly on Windows."); + + IEnumerable symbols = FindSymbolsInFile(FindSymbolsInDSCFile.SourceDetails); + SymbolReference symbol = Assert.Single(symbols, i => i.Type == SymbolType.Configuration); + // The prefix "dsc" is added for sorting reasons. + Assert.Equal("dsc AConfiguration", symbol.Id); + Assert.Equal(2, symbol.ScriptRegion.StartLineNumber); + Assert.Equal(1, symbol.ScriptRegion.StartColumnNumber); + } + + [Fact] + public void FindsSymbolsInPesterFile() + { + IEnumerable symbols = FindSymbolsInFile(FindSymbolsInPesterFile.SourceDetails).OfType(); + Assert.Equal(12, symbols.Count(i => i.Type == SymbolType.Function)); + + SymbolReference symbol = Assert.Single(symbols, i => i.Command == PesterCommandType.Describe); + Assert.Equal("Describe \"Testing Pester symbols\"", symbol.Id); + Assert.Equal(9, symbol.ScriptRegion.StartLineNumber); + Assert.Equal(1, symbol.ScriptRegion.StartColumnNumber); + + symbol = Assert.Single(symbols, i => i.Command == PesterCommandType.Context); + Assert.Equal("Context \"When a Pester file is given\"", symbol.Id); + Assert.Equal(10, symbol.ScriptRegion.StartLineNumber); + Assert.Equal(5, symbol.ScriptRegion.StartColumnNumber); + + Assert.Equal(4, symbols.Count(i => i.Command == PesterCommandType.It)); + symbol = symbols.Last(i => i.Command == PesterCommandType.It); + Assert.Equal("It \"Should return setup and teardown symbols\"", symbol.Id); + Assert.Equal(31, symbol.ScriptRegion.StartLineNumber); + Assert.Equal(9, symbol.ScriptRegion.StartColumnNumber); + + symbol = Assert.Single(symbols, i => i.Command == PesterCommandType.BeforeDiscovery); + Assert.Equal("BeforeDiscovery", symbol.Id); + Assert.Equal(1, symbol.ScriptRegion.StartLineNumber); + Assert.Equal(1, symbol.ScriptRegion.StartColumnNumber); + + Assert.Equal(2, symbols.Count(i => i.Command == PesterCommandType.BeforeAll)); + symbol = symbols.Last(i => i.Command == PesterCommandType.BeforeAll); + Assert.Equal("BeforeAll", symbol.Id); + Assert.Equal(11, symbol.ScriptRegion.StartLineNumber); + Assert.Equal(9, symbol.ScriptRegion.StartColumnNumber); + + symbol = Assert.Single(symbols, i => i.Command == PesterCommandType.BeforeEach); + Assert.Equal("BeforeEach", symbol.Id); + Assert.Equal(15, symbol.ScriptRegion.StartLineNumber); + Assert.Equal(9, symbol.ScriptRegion.StartColumnNumber); + + symbol = Assert.Single(symbols, i => i.Command == PesterCommandType.AfterEach); + Assert.Equal("AfterEach", symbol.Id); + Assert.Equal(35, symbol.ScriptRegion.StartLineNumber); + Assert.Equal(9, symbol.ScriptRegion.StartColumnNumber); + + symbol = Assert.Single(symbols, i => i.Command == PesterCommandType.AfterAll); + Assert.Equal("AfterAll", symbol.Id); + Assert.Equal(40, symbol.ScriptRegion.StartLineNumber); + Assert.Equal(5, symbol.ScriptRegion.StartColumnNumber); + } + + [Fact] + public void FindsSymbolsInPSKoansFile() + { + IEnumerable symbols = FindSymbolsInFile(FindSymbolsInPSKoansFile.SourceDetails).OfType(); + + // Pester symbols are properly tested in FindsSymbolsInPesterFile so only counting to make sure they appear + Assert.Equal(7, symbols.Count(i => i.Type == SymbolType.Function)); + } + + [Fact] + public void FindsSymbolsInPSDFile() + { + IEnumerable symbols = FindSymbolsInFile(FindSymbolsInPSDFile.SourceDetails); + Assert.All(symbols, i => Assert.Equal(SymbolType.HashtableKey, i.Type)); + Assert.Collection(symbols, + i => Assert.Equal("property1", i.Id), + i => Assert.Equal("property2", i.Id), + i => Assert.Equal("property3", i.Id)); + } + + [Fact] + public void FindsSymbolsInNoSymbolsFile() + { + IEnumerable symbolsResult = FindSymbolsInFile(FindSymbolsInNoSymbolsFile.SourceDetails); + Assert.Empty(symbolsResult); + } + } +} diff --git a/test/PowerShellEditorServices.Test/Language/TokenOperationsTests.cs b/test/PowerShellEditorServices.Test/Language/TokenOperationsTests.cs new file mode 100644 index 0000000..19da0af --- /dev/null +++ b/test/PowerShellEditorServices.Test/Language/TokenOperationsTests.cs @@ -0,0 +1,351 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using Xunit; + +namespace PowerShellEditorServices.Test.Language +{ + public class TokenOperationsTests + { + /// + /// Helper method to create a stub script file and then call FoldableRegions + /// + private static FoldingReference[] GetRegions(string text) + { + ScriptFile scriptFile = ScriptFile.Create( + // Use any absolute path. Even if it doesn't exist. + DocumentUri.FromFileSystemPath(Path.Combine(Path.GetTempPath(), "TestFile.ps1")), + text, + Version.Parse("5.0")); + + FoldingReference[] result = TokenOperations.FoldableReferences(scriptFile.ScriptTokens).ToArray(); + // The foldable regions need to be deterministic for testing so sort the array. + Array.Sort(result); + return result; + } + + /// + /// Helper method to create FoldingReference objects with less typing + /// + private static FoldingReference CreateFoldingReference(int startLine, int startCharacter, int endLine, int endCharacter, FoldingRangeKind? matchKind) + { + return new FoldingReference + { + StartLine = startLine, + StartCharacter = startCharacter, + EndLine = endLine, + EndCharacter = endCharacter, + Kind = matchKind + }; + } + + // This PowerShell script will exercise all of the + // folding regions and regions which should not be + // detected. Due to file encoding this could be CLRF or LF line endings + private const string allInOneScript = +@"#Region This should fold +<# +Nested different comment types. This should fold +#> +#EndRegion + +# region This should not fold due to whitespace +$shouldFold = $false +# endRegion +function short-func-not-fold {}; +<# +.SYNOPSIS + This whole comment block should fold, not just the SYNOPSIS +.EXAMPLE + This whole comment block should fold, not just the EXAMPLE +#> +function New-VSCodeShouldFold { +<# +.SYNOPSIS + This whole comment block should fold, not just the SYNOPSIS +.EXAMPLE + This whole comment block should fold, not just the EXAMPLE +#> + $I = @' +herestrings should fold + +'@ + +# This won't confuse things +Get-Command -Param @I + +$I = @"" +double quoted herestrings should also fold + +""@ + + # this won't be folded + + # This block of comments should be foldable as a single block + # This block of comments should be foldable as a single block + # This block of comments should be foldable as a single block + + #region This fools the indentation folding. + Write-Host ""Hello"" + #region Nested regions should be foldable + Write-Host ""Hello"" + # comment1 + Write-Host ""Hello"" + #endregion + Write-Host ""Hello"" + # comment2 + Write-Host ""Hello"" + #endregion + + $c = { + Write-Host ""Script blocks should be foldable"" + } + + # Array fools indentation folding + $d = @( + 'should fold1', + 'should fold2' + ) +} + +# Make sure contiguous comment blocks can be folded properly + +# Comment Block 1 +# Comment Block 1 +# Comment Block 1 +#region Comment Block 3 +# Comment Block 2 +# Comment Block 2 +# Comment Block 2 +$something = $true +#endregion Comment Block 3 + +# What about anonymous variable assignment +${this +is +valid} = 5 + +#RegIon This should fold due to casing +$foo = 'bar' +#EnDReGion +"; + private readonly FoldingReference[] expectedAllInOneScriptFolds = { + CreateFoldingReference(0, 0, 4, 10, FoldingRangeKind.Region), + CreateFoldingReference(1, 0, 3, 2, FoldingRangeKind.Comment), + CreateFoldingReference(10, 0, 15, 2, FoldingRangeKind.Comment), + CreateFoldingReference(16, 30, 63, 1, null), + CreateFoldingReference(17, 0, 22, 2, FoldingRangeKind.Comment), + CreateFoldingReference(23, 7, 26, 2, null), + CreateFoldingReference(31, 5, 34, 2, null), + CreateFoldingReference(38, 2, 40, 0, FoldingRangeKind.Comment), + CreateFoldingReference(42, 2, 52, 14, FoldingRangeKind.Region), + CreateFoldingReference(44, 4, 48, 14, FoldingRangeKind.Region), + CreateFoldingReference(54, 7, 56, 3, null), + CreateFoldingReference(59, 7, 62, 3, null), + CreateFoldingReference(67, 0, 69, 0, FoldingRangeKind.Comment), + CreateFoldingReference(70, 0, 75, 26, FoldingRangeKind.Region), + CreateFoldingReference(71, 0, 73, 0, FoldingRangeKind.Comment), + CreateFoldingReference(78, 0, 80, 6, null), + }; + + /// + /// Assertion helper to compare two FoldingReference arrays. + /// + private static void AssertFoldingReferenceArrays( + FoldingReference[] expected, + FoldingReference[] actual) + { + for (int index = 0; index < expected.Length; index++) + { + Assert.Equal(expected[index], actual[index]); + } + Assert.Equal(expected.Length, actual.Length); + } + + [Trait("Category", "Folding")] + [Fact] + public void LanguageServiceFindsFoldablRegionsWithLF() + { + // Remove and CR characters + string testString = allInOneScript.Replace("\r", ""); + // Ensure that there are no CR characters in the string + Assert.False(testString.Contains("\r\n"), "CRLF should not be present in the test string"); + FoldingReference[] result = GetRegions(testString); + AssertFoldingReferenceArrays(expectedAllInOneScriptFolds, result); + } + + [Trait("Category", "Folding")] + [Fact] + public void LanguageServiceFindsFoldablRegionsWithCRLF() + { + // The Foldable regions should be the same regardless of line ending type + // Enforce CRLF line endings, if none exist + string testString = allInOneScript; + if (!testString.Contains("\r\n")) + { + testString = testString.Replace("\n", "\r\n"); + } + // Ensure that there are CRLF characters in the string + Assert.True(testString.IndexOf("\r\n") != -1, "CRLF should be present in the teststring"); + FoldingReference[] result = GetRegions(testString); + AssertFoldingReferenceArrays(expectedAllInOneScriptFolds, result); + } + + [Trait("Category", "Folding")] + [Fact] + public void LanguageServiceFindsFoldablRegionsWithMismatchedRegions() + { + const string testString = +@"#endregion should not fold - mismatched + +#region This should fold +$something = 'foldable' +#endregion + +#region should not fold - mismatched +"; + FoldingReference[] expectedFolds = { + CreateFoldingReference(2, 0, 4, 10, FoldingRangeKind.Region) + }; + + FoldingReference[] result = GetRegions(testString); + AssertFoldingReferenceArrays(expectedFolds, result); + } + + [Trait("Category", "Folding")] + [Fact] + public void LanguageServiceFindsFoldablRegionsWithDuplicateRegions() + { + const string testString = +@"# This script causes duplicate/overlapping ranges due to the `(` and `{` characters +$AnArray = @(Get-ChildItem -Path C:\ -Include *.ps1 -File).Where({ + $_.FullName -ne 'foo'}).ForEach({ + # Do Something +}) +"; + FoldingReference[] expectedFolds = { + CreateFoldingReference(1, 64, 2, 27, null), + CreateFoldingReference(2, 35, 4, 2, null) + }; + + FoldingReference[] result = GetRegions(testString); + AssertFoldingReferenceArrays(expectedFolds, result); + } + + // This tests that token matching { -> }, @{ -> } and + // ( -> ), @( -> ) and $( -> ) does not confuse the folder + [Trait("Category", "Folding")] + [Fact] + public void LanguageServiceFindsFoldablRegionsWithSameEndToken() + { + const string testString = +@"foreach ($1 in $2) { + + $x = @{ + 'abc' = 'def' + } +} + +$y = $( + $arr = @('1', '2'); Write-Host ($arr) +) +"; + FoldingReference[] expectedFolds = { + CreateFoldingReference(0, 19, 5, 1, null), + CreateFoldingReference(2, 9, 4, 5, null), + CreateFoldingReference(7, 5, 9, 1, null) + }; + + FoldingReference[] result = GetRegions(testString); + + AssertFoldingReferenceArrays(expectedFolds, result); + } + + // A simple PowerShell Classes test + [Trait("Category", "Folding")] + [Fact] + public void LanguageServiceFindsFoldablRegionsWithClasses() + { + const string testString = +@"class TestClass { + [string[]] $TestProperty = @( + 'first', + 'second', + 'third') + + [string] TestMethod() { + return $this.TestProperty[0] + } +} +"; + FoldingReference[] expectedFolds = { + CreateFoldingReference(0, 16, 9, 1, null), + CreateFoldingReference(1, 31, 4, 16, null), + CreateFoldingReference(6, 26, 8, 5, null) + }; + + FoldingReference[] result = GetRegions(testString); + + AssertFoldingReferenceArrays(expectedFolds, result); + } + + // This tests DSC style keywords and param blocks + [Trait("Category", "Folding")] + [Fact] + public void LanguageServiceFindsFoldablRegionsWithDSC() + { + const string testString = +@"Configuration Example +{ + param + ( + [Parameter()] + [System.String[]] + $NodeName = 'localhost', + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [System.Management.Automation.PSCredential] + $Credential + ) + + Import-DscResource -Module ActiveDirectoryCSDsc + + Node $AllNodes.NodeName + { + WindowsFeature ADCS-Cert-Authority + { + Ensure = 'Present' + Name = 'ADCS-Cert-Authority' + } + + AdcsCertificationAuthority CertificateAuthority + { + IsSingleInstance = 'Yes' + Ensure = 'Present' + Credential = $Credential + CAType = 'EnterpriseRootCA' + DependsOn = '[WindowsFeature]ADCS-Cert-Authority' + } + } +} +"; + FoldingReference[] expectedFolds = { + CreateFoldingReference(1, 0, 33, 1, null), + CreateFoldingReference(3, 4, 12, 5, null), + CreateFoldingReference(17, 4, 32, 5, null), + CreateFoldingReference(19, 8, 22, 9, null), + CreateFoldingReference(25, 8, 31, 9, null) + }; + + FoldingReference[] result = GetRegions(testString); + + AssertFoldingReferenceArrays(expectedFolds, result); + } + } +} diff --git a/test/PowerShellEditorServices.Test/PowerShellEditorServices.Test.csproj b/test/PowerShellEditorServices.Test/PowerShellEditorServices.Test.csproj new file mode 100644 index 0000000..6cd7ae8 --- /dev/null +++ b/test/PowerShellEditorServices.Test/PowerShellEditorServices.Test.csproj @@ -0,0 +1,50 @@ + + + + + net8.0;net462 + Microsoft.PowerShell.EditorServices.Test + x64 + + + + $(DefineConstants);CoreCLR + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + + + + + diff --git a/test/PowerShellEditorServices.Test/PsesHostFactory.cs b/test/PowerShellEditorServices.Test/PsesHostFactory.cs new file mode 100644 index 0000000..9f63236 --- /dev/null +++ b/test/PowerShellEditorServices.Test/PsesHostFactory.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Globalization; +using System.IO; +using System.Management.Automation.Host; +using System.Management.Automation.Runspaces; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Hosting; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; +using Microsoft.PowerShell.EditorServices.Test.Shared; +using Microsoft.PowerShell.EditorServices.Utility; + +namespace Microsoft.PowerShell.EditorServices.Test +{ + internal static class PsesHostFactory + { + // NOTE: These paths are arbitrarily chosen just to verify that the profile paths can be set + // to whatever they need to be for the given host. + + public static readonly ProfilePathInfo TestProfilePaths = new( + Path.GetFullPath(TestUtilities.NormalizePath("../../../../PowerShellEditorServices.Test.Shared/Profile/Test.PowerShellEditorServices_profile.ps1")), + Path.GetFullPath(TestUtilities.NormalizePath("../../../../PowerShellEditorServices.Test.Shared/Profile/ProfileTest.ps1")), + Path.GetFullPath(TestUtilities.NormalizePath("../../../../PowerShellEditorServices.Test.Shared/Test.PowerShellEditorServices_profile.ps1")), + Path.GetFullPath(TestUtilities.NormalizePath("../../../../PowerShellEditorServices.Test.Shared/ProfileTest.ps1"))); + + public static readonly string BundledModulePath = Path.GetFullPath(TestUtilities.NormalizePath("../../../../../module")); + + public static async Task Create(ILoggerFactory loggerFactory, bool loadProfiles = false) + { + // We intentionally use `CreateDefault2()` as it loads `Microsoft.PowerShell.Core` only, + // which is a more minimal and therefore safer state. + InitialSessionState initialSessionState = InitialSessionState.CreateDefault2(); + + // We set the process scope's execution policy (which is really the runspace's scope) to + // `Bypass` so we can import our bundled modules. This is equivalent in scope to the CLI + // argument `-ExecutionPolicy Bypass`, which (for instance) the extension passes. Thus + // we emulate this behavior for consistency such that unit tests can pass in a similar + // environment. + if (VersionUtils.IsWindows) + { + initialSessionState.ExecutionPolicy = ExecutionPolicy.Bypass; + } + + HostStartupInfo testHostDetails = new( + name: "PowerShell Editor Services Test Host", + profileId: "Test.PowerShellEditorServices", + version: new Version("1.0.0"), + psHost: new NullPSHost(), + profilePaths: TestProfilePaths, + featureFlags: Array.Empty(), + additionalModules: Array.Empty(), + initialSessionState: initialSessionState, + logPath: null, + logLevel: (int)LogLevel.None, + consoleReplEnabled: false, + usesLegacyReadLine: false, + useNullPSHostUI: true, + bundledModulePath: BundledModulePath); + + PsesInternalHost psesHost = new(loggerFactory, null, testHostDetails); + + if (await psesHost.TryStartAsync(new HostStartOptions { LoadProfiles = loadProfiles }, CancellationToken.None)) + { + return psesHost; + } + + throw new Exception("Host didn't start!"); + } + } + + internal class NullPSHost : PSHost + { + public override CultureInfo CurrentCulture => CultureInfo.CurrentCulture; + public override CultureInfo CurrentUICulture => CultureInfo.CurrentUICulture; + public override Guid InstanceId { get; } = Guid.NewGuid(); + public override string Name => nameof(NullPSHost); + public override PSHostUserInterface UI { get; } = new NullPSHostUI(); + public override Version Version { get; } = new Version(1, 0, 0); + public override void EnterNestedPrompt() { /* Do nothing */ } + public override void ExitNestedPrompt() { /* Do nothing */ } + public override void NotifyBeginApplication() { /* Do nothing */ } + public override void NotifyEndApplication() { /* Do nothing */ } + public override void SetShouldExit(int exitCode) { /* Do nothing */ } + } +} diff --git a/test/PowerShellEditorServices.Test/Services/Symbols/AstOperationsTests.cs b/test/PowerShellEditorServices.Test/Services/Symbols/AstOperationsTests.cs new file mode 100644 index 0000000..649ef32 --- /dev/null +++ b/test/PowerShellEditorServices.Test/Services/Symbols/AstOperationsTests.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.Symbols; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Test.Shared; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using Xunit; + +namespace PowerShellEditorServices.Test.Services.Symbols +{ + [Trait("Category", "AstOperations")] + public class AstOperationsTests + { + private readonly ScriptFile scriptFile; + + public AstOperationsTests() + { + WorkspaceService workspace = new(NullLoggerFactory.Instance); + scriptFile = workspace.GetFile(TestUtilities.GetSharedPath("References/FunctionReference.ps1")); + } + + [Theory] + [InlineData(1, 15, "fn BasicFunction")] + [InlineData(2, 3, "fn BasicFunction")] + [InlineData(4, 31, "fn FunctionWithExtraSpace")] + [InlineData(7, 18, "fn FunctionWithExtraSpace")] + [InlineData(12, 22, "fn FunctionNameOnDifferentLine")] + [InlineData(22, 13, "fn FunctionNameOnDifferentLine")] + [InlineData(24, 30, "fn IndentedFunction")] + [InlineData(24, 52, "fn IndentedFunction")] + public void CanFindSymbolAtPosition(int line, int column, string expectedName) + { + SymbolReference symbol = scriptFile.References.TryGetSymbolAtPosition(line, column); + Assert.NotNull(symbol); + Assert.Equal(expectedName, symbol.Id); + } + + [Theory] + [MemberData(nameof(FindReferencesOfSymbolAtPositionData))] + public void CanFindReferencesOfSymbolAtPosition(int line, int column, Range[] symbolRange) + { + SymbolReference symbol = scriptFile.References.TryGetSymbolAtPosition(line, column); + + IEnumerable references = scriptFile.References.TryGetReferences(symbol); + Assert.NotEmpty(references); + + int positionsIndex = 0; + foreach (SymbolReference reference in references.OrderBy((i) => i.ScriptRegion.ToRange().Start)) + { + Assert.Equal(symbolRange[positionsIndex].Start.Line, reference.NameRegion.StartLineNumber); + Assert.Equal(symbolRange[positionsIndex].Start.Character, reference.NameRegion.StartColumnNumber); + Assert.Equal(symbolRange[positionsIndex].End.Line, reference.NameRegion.EndLineNumber); + Assert.Equal(symbolRange[positionsIndex].End.Character, reference.NameRegion.EndColumnNumber); + + positionsIndex++; + } + } + + public static object[][] FindReferencesOfSymbolAtPositionData { get; } = new object[][] + { + new object[] { 1, 15, new[] { new Range(1, 10, 1, 23), new Range(2, 1, 2, 14) } }, + new object[] { 2, 3, new[] { new Range(1, 10, 1, 23), new Range(2, 1, 2, 14) } }, + new object[] { 4, 31, new[] { new Range(4, 19, 4, 41), new Range(7, 3, 7, 25) } }, + new object[] { 7, 18, new[] { new Range(4, 19, 4, 41), new Range(7, 3, 7, 25) } }, + new object[] { 22, 13, new[] { new Range(12, 8, 12, 35), new Range(22, 5, 22, 32) } }, + new object[] { 12, 22, new[] { new Range(12, 8, 12, 35), new Range(22, 5, 22, 32) } }, + new object[] { 24, 30, new[] { new Range(24, 22, 24, 38), new Range(24, 44, 24, 60) } }, + new object[] { 24, 52, new[] { new Range(24, 22, 24, 38), new Range(24, 44, 24, 60) } }, + }; + } +} diff --git a/test/PowerShellEditorServices.Test/Services/Symbols/PSScriptAnalyzerTests.cs b/test/PowerShellEditorServices.Test/Services/Symbols/PSScriptAnalyzerTests.cs new file mode 100644 index 0000000..1155db1 --- /dev/null +++ b/test/PowerShellEditorServices.Test/Services/Symbols/PSScriptAnalyzerTests.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.PowerShell.EditorServices.Hosting; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Test; +using Xunit; + +namespace PowerShellEditorServices.Test.Services.Symbols +{ + [Trait("Category", "PSScriptAnalyzer")] + public class PSScriptAnalyzerTests + { + private readonly WorkspaceService workspaceService = new(NullLoggerFactory.Instance); + private readonly AnalysisService analysisService; + private const string script = "function Do-Work {}"; + + public PSScriptAnalyzerTests() => analysisService = new( + NullLoggerFactory.Instance, + languageServer: null, + configurationService: null, + workspaceService: workspaceService, + new HostStartupInfo( + name: "", + profileId: "", + version: null, + psHost: null, + profilePaths: null, + featureFlags: null, + additionalModules: null, + initialSessionState: null, + logPath: null, + logLevel: 0, + consoleReplEnabled: false, + useNullPSHostUI: true, + usesLegacyReadLine: false, + bundledModulePath: PsesHostFactory.BundledModulePath)); + + [Fact] + public void IncludesDefaultRules() + { + Assert.Null(analysisService.AnalysisEngine._settingsParameter); + Assert.Equal(AnalysisService.s_defaultRules, analysisService.AnalysisEngine._rulesToInclude); + } + + [Fact] + public async Task CanLoadPSScriptAnalyzerAsync() + { + ScriptFileMarker[] violations = await analysisService + .AnalysisEngine + .AnalyzeScriptAsync(script); + + Assert.Collection(violations, + (actual) => + { + Assert.Empty(actual.Corrections); + Assert.Equal(ScriptFileMarkerLevel.Warning, actual.Level); + Assert.Equal("The cmdlet 'Do-Work' uses an unapproved verb.", actual.Message); + Assert.Equal("PSUseApprovedVerbs", actual.RuleName); + Assert.Equal("PSScriptAnalyzer", actual.Source); + }); + } + + [Fact] + public async Task DoesNotDuplicateScriptMarkersAsync() + { + ScriptFile scriptFile = workspaceService.GetFileBuffer("untitled:Untitled-1", script); + ScriptFile[] scriptFiles = { scriptFile }; + + await analysisService + .DelayThenInvokeDiagnosticsAsync(scriptFiles, CancellationToken.None); + Assert.Single(scriptFile.DiagnosticMarkers); + + // This is repeated to test that the markers are not duplicated. + await analysisService + .DelayThenInvokeDiagnosticsAsync(scriptFiles, CancellationToken.None); + Assert.Single(scriptFile.DiagnosticMarkers); + } + + [Fact] + public async Task DoesNotClearParseErrorsAsync() + { + // Causing a missing closing } parser error + ScriptFile scriptFile = workspaceService.GetFileBuffer("untitled:Untitled-2", script.TrimEnd('}')); + ScriptFile[] scriptFiles = { scriptFile }; + + await analysisService + .DelayThenInvokeDiagnosticsAsync(scriptFiles, CancellationToken.None); + + Assert.Collection(scriptFile.DiagnosticMarkers, + (actual) => + { + Assert.Equal("Missing closing '}' in statement block or type definition.", actual.Message); + Assert.Equal("PowerShell", actual.Source); + }, + (actual) => + { + Assert.Equal("PSUseApprovedVerbs", actual.RuleName); + Assert.Equal("PSScriptAnalyzer", actual.Source); + }); + } + } +} diff --git a/test/PowerShellEditorServices.Test/Session/PathEscapingTests.cs b/test/PowerShellEditorServices.Test/Session/PathEscapingTests.cs new file mode 100644 index 0000000..3d4f0d4 --- /dev/null +++ b/test/PowerShellEditorServices.Test/Session/PathEscapingTests.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Xunit; +using Microsoft.PowerShell.EditorServices.Utility; + +namespace PowerShellEditorServices.Test.Session +{ + public class PathEscapingTests + { + [Trait("Category", "PathEscaping")] + [Theory] + [InlineData("DebugTest.ps1", "DebugTest.ps1")] + [InlineData("../../DebugTest.ps1", "../../DebugTest.ps1")] + [InlineData(@"C:\Users\me\Documents\DebugTest.ps1", @"C:\Users\me\Documents\DebugTest.ps1")] + [InlineData("/home/me/Documents/weird&folder/script.ps1", "/home/me/Documents/weird&folder/script.ps1")] + [InlineData("./path/with some/spaces", "./path/with some/spaces")] + [InlineData(@"C:\path\with[some]brackets\file.ps1", @"C:\path\with`[some`]brackets\file.ps1")] + [InlineData(@"C:\look\an*\here.ps1", @"C:\look\an`*\here.ps1")] + [InlineData("/Users/me/Documents/?here.ps1", "/Users/me/Documents/`?here.ps1")] + [InlineData("/Brackets [and s]paces/path.ps1", "/Brackets `[and s`]paces/path.ps1")] + [InlineData("/CJK.chars/脚本/hello.ps1", "/CJK.chars/脚本/hello.ps1")] + [InlineData("/CJK.chars/脚本/[hello].ps1", "/CJK.chars/脚本/`[hello`].ps1")] + [InlineData(@"C:\Animals\утка\quack.ps1", @"C:\Animals\утка\quack.ps1")] + [InlineData(@"C:\&nimals\утка\qu*ck?.ps1", @"C:\&nimals\утка\qu`*ck`?.ps1")] + public void CorrectlyWildcardEscapesPathsNoSpaces(string unescapedPath, string escapedPath) + { + string extensionEscapedPath = PathUtils.WildcardEscapePath(unescapedPath); + Assert.Equal(escapedPath, extensionEscapedPath); + } + + [Trait("Category", "PathEscaping")] + [Theory] + [InlineData("DebugTest.ps1", "DebugTest.ps1")] + [InlineData("../../DebugTest.ps1", "../../DebugTest.ps1")] + [InlineData(@"C:\Users\me\Documents\DebugTest.ps1", @"C:\Users\me\Documents\DebugTest.ps1")] + [InlineData("/home/me/Documents/weird&folder/script.ps1", "/home/me/Documents/weird&folder/script.ps1")] + [InlineData("./path/with some/spaces", "./path/with` some/spaces")] + [InlineData(@"C:\path\with[some]brackets\file.ps1", @"C:\path\with`[some`]brackets\file.ps1")] + [InlineData(@"C:\look\an*\here.ps1", @"C:\look\an`*\here.ps1")] + [InlineData("/Users/me/Documents/?here.ps1", "/Users/me/Documents/`?here.ps1")] + [InlineData("/Brackets [and s]paces/path.ps1", "/Brackets` `[and` s`]paces/path.ps1")] + [InlineData("/CJK chars/脚本/hello.ps1", "/CJK` chars/脚本/hello.ps1")] + [InlineData("/CJK chars/脚本/[hello].ps1", "/CJK` chars/脚本/`[hello`].ps1")] + [InlineData(@"C:\Animal s\утка\quack.ps1", @"C:\Animal` s\утка\quack.ps1")] + [InlineData(@"C:\&nimals\утка\qu*ck?.ps1", @"C:\&nimals\утка\qu`*ck`?.ps1")] + public void CorrectlyWildcardEscapesPathsSpaces(string unescapedPath, string escapedPath) + { + string extensionEscapedPath = PathUtils.WildcardEscapePath(unescapedPath, escapeSpaces: true); + Assert.Equal(escapedPath, extensionEscapedPath); + } + } +} diff --git a/test/PowerShellEditorServices.Test/Session/PsesInternalHostTests.cs b/test/PowerShellEditorServices.Test/Session/PsesInternalHostTests.cs new file mode 100644 index 0000000..617b610 --- /dev/null +++ b/test/PowerShellEditorServices.Test/Session/PsesInternalHostTests.cs @@ -0,0 +1,294 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.PowerShell.EditorServices.Hosting; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Console; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; +using Microsoft.PowerShell.EditorServices.Test; +using Xunit; + +namespace PowerShellEditorServices.Test.Session +{ + using System.Management.Automation; + using System.Management.Automation.Runspaces; + + [Trait("Category", "PsesInternalHost")] + public class PsesInternalHostTests : IAsyncLifetime + { + private PsesInternalHost psesHost; + + public async Task InitializeAsync() => psesHost = await PsesHostFactory.Create(NullLoggerFactory.Instance); + + public async Task DisposeAsync() => await psesHost.StopAsync(); + + [Fact] + public async Task CanExecutePSCommand() + { + Assert.True(psesHost.IsRunning); + PSCommand command = new PSCommand().AddScript("$a = \"foo\"; $a"); + Task> task = psesHost.ExecutePSCommandAsync(command, CancellationToken.None); + IReadOnlyList result = await task; + Assert.Equal("foo", result[0]); + } + + [Fact] // https://github.com/PowerShell/vscode-powershell/issues/3677 + public async Task CanHandleThrow() + { + await psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("throw"), + CancellationToken.None, + new PowerShellExecutionOptions { ThrowOnError = false }); + } + + [Fact] + public async Task CanQueueParallelPSCommands() + { + // Concurrently initiate 4 requests in the session. + Task taskOne = psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("$x = 100"), + CancellationToken.None); + + Task taskTwo = psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("$x += 200"), + CancellationToken.None); + + Task taskThree = psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("$x = $x / 100"), + CancellationToken.None); + + Task> resultTask = psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("$x"), + CancellationToken.None); + + // Wait for all of the executes to complete. + await Task.WhenAll(taskOne, taskTwo, taskThree, resultTask); + + // Sanity checks + Assert.Equal(RunspaceState.Opened, psesHost.Runspace.RunspaceStateInfo.State); + + // 100 + 200 = 300, then divided by 100 is 3. We are ensuring that + // the commands were executed in the sequence they were called. + Assert.Equal(3, (await resultTask)[0]); + } + + [Fact] + public async Task CanCancelExecutionWithToken() + { + using CancellationTokenSource cancellationSource = new(millisecondsDelay: 1000); + await Assert.ThrowsAsync(() => + { + return psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("Start-Sleep 10"), + cancellationSource.Token); + }); + } + + [Fact] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Explicitly checking task cancellation status.")] + public async Task CanCancelExecutionWithMethod() + { + Task executeTask = psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("Start-Sleep 10"), + CancellationToken.None); + + // Cancel the task after 1 second in another thread. + Task.Run(() => { Thread.Sleep(1000); psesHost.CancelCurrentTask(); }); + await Assert.ThrowsAsync(() => executeTask); + Assert.True(executeTask.IsCanceled); + } + + [Fact] + public async Task CanHandleNoProfiles() + { + // Call LoadProfiles with profile paths that won't exist, and assert that it does not + // throw PSInvalidOperationException (which it previously did when it tried to invoke an + // empty command). + ProfilePathInfo emptyProfilePaths = new("", "", "", ""); + await psesHost.ExecuteDelegateAsync( + "LoadProfiles", + executionOptions: null, + (pwsh, _) => + { + pwsh.LoadProfiles(emptyProfilePaths); + Assert.Empty(pwsh.Commands.Commands); + }, + CancellationToken.None); + } + + // NOTE: Tests where we call functions that use PowerShell runspaces are slightly more + // complicated than one would expect because we explicitly need the methods to run on the + // pipeline thread, otherwise Windows complains about the the thread's apartment state not + // matching. Hence we use a delegate where it looks like we could just call the method. + + [Fact] + public async Task CanHandleBrokenPrompt() + { + _ = await Assert.ThrowsAsync(() => + { + return psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("function prompt { throw }; prompt"), + CancellationToken.None); + }); + + string prompt = await psesHost.ExecuteDelegateAsync( + nameof(psesHost.GetPrompt), + executionOptions: null, + (_, _) => psesHost.GetPrompt(CancellationToken.None), + CancellationToken.None); + + Assert.Equal(PsesInternalHost.DefaultPrompt, prompt); + } + + [Fact] + public async Task CanHandleUndefinedPrompt() + { + Assert.Empty(await psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("Remove-Item function:prompt; Get-Item function:prompt -ErrorAction Ignore"), + CancellationToken.None)); + + string prompt = await psesHost.ExecuteDelegateAsync( + nameof(psesHost.GetPrompt), + executionOptions: null, + (_, _) => psesHost.GetPrompt(CancellationToken.None), + CancellationToken.None); + + Assert.Equal(PsesInternalHost.DefaultPrompt, prompt); + } + + [Fact] + public async Task CanRunOnIdleTask() + { + IReadOnlyList task = await psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("$handled = $false; Register-EngineEvent -SourceIdentifier PowerShell.OnIdle -MaxTriggerCount 1 -Action { $global:handled = $true }"), + CancellationToken.None); + + IReadOnlyList handled = await psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("$handled"), + CancellationToken.None); + + Assert.Collection(handled, Assert.False); + + await psesHost.ExecuteDelegateAsync( + nameof(psesHost.OnPowerShellIdle), + executionOptions: null, + (_, _) => psesHost.OnPowerShellIdle(CancellationToken.None), + CancellationToken.None); + + // TODO: Why is this racy? + Thread.Sleep(2000); + + handled = await psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("$handled"), + CancellationToken.None); + + Assert.Collection(handled, Assert.True); + } + + [Fact] + public async Task CanLoadPSReadLine() + { + Assert.True(await psesHost.ExecuteDelegateAsync( + nameof(psesHost.TryLoadPSReadLine), + executionOptions: null, + (pwsh, _) => psesHost.TryLoadPSReadLine( + pwsh, + (EngineIntrinsics)pwsh.Runspace.SessionStateProxy.GetVariable("ExecutionContext"), + out IReadLine readLine), + CancellationToken.None)); + } + + // This test asserts that we do not mess up the console encoding, which leads to native + // commands receiving piped input failing. + [Fact] + public async Task ExecutesNativeCommandsCorrectly() + { + await psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("\"protocol=https`nhost=myhost.com`nusername=john`npassword=doe`n`n\" | git.exe credential approve; if ($LastExitCode) { throw }"), + CancellationToken.None); + } + + [Theory] + [InlineData("")] // Regression test for "unset" path. + [InlineData(@"C:\Some\Bad\Directory")] // Non-existent directory. + [InlineData("testhost.dll")] // Existent file. + public async Task CanHandleBadInitialWorkingDirectory(string path) + { + string cwd = Environment.CurrentDirectory; + await psesHost.SetInitialWorkingDirectoryAsync(path, CancellationToken.None); + + IReadOnlyList getLocation = await psesHost.ExecutePSCommandAsync( + new PSCommand().AddCommand("Get-Location"), + CancellationToken.None); + Assert.Collection(getLocation, (d) => Assert.Equal(cwd, d, ignoreCase: true)); + } + } + + [Trait("Category", "PsesInternalHost")] + public class PsesInternalHostWithProfileTests : IAsyncLifetime + { + private PsesInternalHost psesHost; + + public async Task InitializeAsync() => psesHost = await PsesHostFactory.Create(NullLoggerFactory.Instance, loadProfiles: true); + + public async Task DisposeAsync() => await psesHost.StopAsync(); + + [Fact] + public async Task CanResolveAndLoadProfilesForHostId() + { + // Ensure that the $PROFILE variable is a string with the value of CurrentUserCurrentHost. + IReadOnlyList profileVariable = await psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("$PROFILE"), + CancellationToken.None); + + Assert.Collection(profileVariable, + (p) => Assert.Equal(PsesHostFactory.TestProfilePaths.CurrentUserCurrentHost, p)); + + // Ensure that all the profile paths are set in the correct note properties. + IReadOnlyList profileProperties = await psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("$PROFILE | Get-Member -Type NoteProperty"), + CancellationToken.None); + + Assert.Collection(profileProperties, + (p) => Assert.Equal($"string AllUsersAllHosts={PsesHostFactory.TestProfilePaths.AllUsersAllHosts}", p, ignoreCase: true), + (p) => Assert.Equal($"string AllUsersCurrentHost={PsesHostFactory.TestProfilePaths.AllUsersCurrentHost}", p, ignoreCase: true), + (p) => Assert.Equal($"string CurrentUserAllHosts={PsesHostFactory.TestProfilePaths.CurrentUserAllHosts}", p, ignoreCase: true), + (p) => Assert.Equal($"string CurrentUserCurrentHost={PsesHostFactory.TestProfilePaths.CurrentUserCurrentHost}", p, ignoreCase: true)); + + // Ensure that the profile was loaded. The profile also checks that $PROFILE was defined. + IReadOnlyList profileLoaded = await psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("Assert-ProfileLoaded"), + CancellationToken.None); + + Assert.Collection(profileLoaded, Assert.True); + } + + // This test specifically relies on a handler registered in the test profile, and on the + // test host loading the profiles during startup, that way the pipeline timing is + // consistent. + [Fact] + public async Task CanRunOnIdleInProfileTask() + { + await psesHost.ExecuteDelegateAsync( + nameof(psesHost.OnPowerShellIdle), + executionOptions: null, + (_, _) => psesHost.OnPowerShellIdle(CancellationToken.None), + CancellationToken.None); + + // TODO: Why is this racy? + Thread.Sleep(2000); + + IReadOnlyList handled = await psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("$handledInProfile"), + CancellationToken.None); + + Assert.Collection(handled, Assert.True); + } + } +} diff --git a/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs b/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs new file mode 100644 index 0000000..11c27c4 --- /dev/null +++ b/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs @@ -0,0 +1,674 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Linq; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Test.Shared; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.LanguageServer.Protocol; +using Xunit; + +namespace PowerShellEditorServices.Test.Session +{ + public class ScriptFileChangeTests + { +#if CoreCLR + private static readonly Version PowerShellVersion = new(7, 2); +#else + private static readonly Version PowerShellVersion = new(5, 1); +#endif + + [Trait("Category", "ScriptFile")] + [Fact] + public void CanApplySingleLineInsert() + { + AssertFileChange( + "This is a test.", + "This is a working test.", + new FileChange + { + Line = 1, + EndLine = 1, + Offset = 10, + EndOffset = 10, + InsertString = " working" + }); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void CanApplySingleLineReplace() + { + AssertFileChange( + "This is a potentially broken test.", + "This is a working test.", + new FileChange + { + Line = 1, + EndLine = 1, + Offset = 11, + EndOffset = 29, + InsertString = "working" + }); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void CanApplySingleLineDelete() + { + AssertFileChange( + "This is a test of the emergency broadcasting system.", + "This is a test.", + new FileChange + { + Line = 1, + EndLine = 1, + Offset = 15, + EndOffset = 52, + InsertString = "" + }); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void CanApplyMultiLineInsert() + { + AssertFileChange( + TestUtilities.NormalizeNewlines("first\nsecond\nfifth"), + TestUtilities.NormalizeNewlines("first\nsecond\nthird\nfourth\nfifth"), + new FileChange + { + Line = 3, + EndLine = 3, + Offset = 1, + EndOffset = 1, + InsertString = TestUtilities.NormalizeNewlines("third\nfourth\n") + }); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void CanApplyMultiLineReplace() + { + AssertFileChange( + TestUtilities.NormalizeNewlines("first\nsecoXX\nXXfth"), + TestUtilities.NormalizeNewlines("first\nsecond\nthird\nfourth\nfifth"), + new FileChange + { + Line = 2, + EndLine = 3, + Offset = 5, + EndOffset = 3, + InsertString = TestUtilities.NormalizeNewlines("nd\nthird\nfourth\nfi") + }); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void CanApplyMultiLineReplaceWithRemovedLines() + { + AssertFileChange( + TestUtilities.NormalizeNewlines("first\nsecoXX\nREMOVE\nTHESE\nLINES\nXXfth"), + TestUtilities.NormalizeNewlines("first\nsecond\nthird\nfourth\nfifth"), + new FileChange + { + Line = 2, + EndLine = 6, + Offset = 5, + EndOffset = 3, + InsertString = TestUtilities.NormalizeNewlines("nd\nthird\nfourth\nfi") + }); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void CanApplyMultiLineDelete() + { + AssertFileChange( + TestUtilities.NormalizeNewlines("first\nsecond\nREMOVE\nTHESE\nLINES\nthird"), + TestUtilities.NormalizeNewlines("first\nsecond\nthird"), + new FileChange + { + Line = 3, + EndLine = 6, + Offset = 1, + EndOffset = 1, + InsertString = "" + }); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void CanApplyEditsToEndOfFile() + { + AssertFileChange( + TestUtilities.NormalizeNewlines("line1\nline2\nline3\n\n"), + TestUtilities.NormalizeNewlines("line1\nline2\nline3\n\n\n\n"), + new FileChange + { + Line = 5, + EndLine = 5, + Offset = 1, + EndOffset = 1, + InsertString = Environment.NewLine + Environment.NewLine + }); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void CanAppendToEndOfFile() + { + AssertFileChange( + TestUtilities.NormalizeNewlines("line1\nline2\nline3"), + TestUtilities.NormalizeNewlines("line1\nline2\nline3\nline4\nline5"), + new FileChange + { + Line = 4, + EndLine = 5, + Offset = 1, + EndOffset = 1, + InsertString = $"line4{Environment.NewLine}line5" + } + ); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void ThrowsExceptionWithEditOutsideOfRange() + { + Assert.Throws( + () => + { + AssertFileChange( + TestUtilities.NormalizeNewlines("first\nsecond\nREMOVE\nTHESE\nLINES\nthird"), + TestUtilities.NormalizeNewlines("first\nsecond\nthird"), + new FileChange + { + Line = 3, + EndLine = 8, + Offset = 1, + EndOffset = 1, + InsertString = "" + }); + }); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void CanDeleteFromEndOfFile() + { + AssertFileChange( + TestUtilities.NormalizeNewlines("line1\nline2\nline3\nline4"), + TestUtilities.NormalizeNewlines("line1\nline2"), + new FileChange + { + Line = 3, + EndLine = 5, + Offset = 1, + EndOffset = 1, + InsertString = "" + } + ); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void UpdatesParseErrorDiagnosticMarkers() + { + ScriptFile myScript = CreateScriptFile(TestUtilities.NormalizeNewlines("{\n{")); + + // Verify parse errors were detected on file open + Assert.Collection(myScript.DiagnosticMarkers.OrderBy(dm => dm.ScriptRegion.StartLineNumber), + (actual) => + { + Assert.Equal(1, actual.ScriptRegion.StartLineNumber); + Assert.Equal("Missing closing '}' in statement block or type definition.", actual.Message); + Assert.Equal("PowerShell", actual.Source); + }, + (actual) => + { + Assert.Equal(2, actual.ScriptRegion.StartLineNumber); + Assert.Equal("Missing closing '}' in statement block or type definition.", actual.Message); + Assert.Equal("PowerShell", actual.Source); + }); + + // Remove second { + myScript.ApplyChange( + new FileChange + { + Line = 2, + EndLine = 2, + Offset = 1, + EndOffset = 2, + InsertString = "" + }); + + // Verify parse errors were updated on file change + Assert.Collection(myScript.DiagnosticMarkers, + (actual) => + { + Assert.Equal(1, actual.ScriptRegion.StartLineNumber); + Assert.Equal("Missing closing '}' in statement block or type definition.", actual.Message); + Assert.Equal("PowerShell", actual.Source); + }); + } + + internal static ScriptFile CreateScriptFile(string initialString) + { + using StringReader stringReader = new(initialString); + // Create an in-memory file from the StringReader + ScriptFile fileToChange = + new( + // Use any absolute path. Even if it doesn't exist. + DocumentUri.FromFileSystemPath(Path.Combine(Path.GetTempPath(), "TestFile.ps1")), + stringReader, + PowerShellVersion); + + return fileToChange; + } + + private static void AssertFileChange( + string initialString, + string expectedString, + FileChange fileChange) + { + // Create an in-memory file from the StringReader + ScriptFile fileToChange = CreateScriptFile(initialString); + + // Apply the FileChange and assert the resulting contents + fileToChange.ApplyChange(fileChange); + Assert.Equal(expectedString, fileToChange.Contents); + } + } + + public class ScriptFileGetLinesTests + { + private static readonly string TestString_NoTrailingNewline = TestUtilities.NormalizeNewlines( + "Line One\nLine Two\nLine Three\nLine Four\nLine Five"); + + private static readonly string TestString_TrailingNewline = TestUtilities.NormalizeNewlines( + TestString_NoTrailingNewline + "\n"); + + private static readonly string[] s_newLines = new string[] { Environment.NewLine }; + + private static readonly string[] s_testStringLines_noTrailingNewline = TestString_NoTrailingNewline.Split(s_newLines, StringSplitOptions.None); + + private static readonly string[] s_testStringLines_trailingNewline = TestString_TrailingNewline.Split(s_newLines, StringSplitOptions.None); + + private readonly ScriptFile _scriptFile_trailingNewline; + + private readonly ScriptFile _scriptFile_noTrailingNewline; + + public ScriptFileGetLinesTests() + { + _scriptFile_noTrailingNewline = ScriptFileChangeTests.CreateScriptFile( + TestString_NoTrailingNewline); + + _scriptFile_trailingNewline = ScriptFileChangeTests.CreateScriptFile( + TestString_TrailingNewline); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void CanGetWholeLine() + { + string[] lines = + _scriptFile_noTrailingNewline.GetLinesInRange( + new BufferRange(5, 1, 5, 10)); + + Assert.Single(lines); + Assert.Equal("Line Five", lines[0]); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void CanGetMultipleWholeLines() + { + string[] lines = + _scriptFile_noTrailingNewline.GetLinesInRange( + new BufferRange(2, 1, 4, 10)); + + Assert.Equal(s_testStringLines_noTrailingNewline.Skip(1).Take(3), lines); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void CanGetSubstringInSingleLine() + { + string[] lines = + _scriptFile_noTrailingNewline.GetLinesInRange( + new BufferRange(4, 3, 4, 8)); + + Assert.Single(lines); + Assert.Equal("ne Fo", lines[0]); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void CanGetEmptySubstringRange() + { + string[] lines = + _scriptFile_noTrailingNewline.GetLinesInRange( + new BufferRange(4, 3, 4, 3)); + + Assert.Single(lines); + Assert.Equal("", lines[0]); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void CanGetSubstringInMultipleLines() + { + string[] expectedLines = new string[] + { + "Two", + "Line Three", + "Line Fou" + }; + + string[] lines = + _scriptFile_noTrailingNewline.GetLinesInRange( + new BufferRange(2, 6, 4, 9)); + + Assert.Equal(expectedLines, lines); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void CanGetRangeAtLineBoundaries() + { + string[] expectedLines = new string[] + { + "", + "Line Three", + "" + }; + + string[] lines = + _scriptFile_noTrailingNewline.GetLinesInRange( + new BufferRange(2, 9, 4, 1)); + + Assert.Equal(expectedLines, lines); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void CanSplitLinesNoTrailingNewline() => Assert.Equal(s_testStringLines_noTrailingNewline, _scriptFile_noTrailingNewline.FileLines); + + [Trait("Category", "ScriptFile")] + [Fact] + public void CanSplitLinesTrailingNewline() => Assert.Equal(s_testStringLines_trailingNewline, _scriptFile_trailingNewline.FileLines); + + [Trait("Category", "ScriptFile")] + [Fact] + public void CanGetSameLinesWithUnixLineBreaks() + { + ScriptFile unixFile = ScriptFileChangeTests.CreateScriptFile(TestString_NoTrailingNewline.Replace("\r\n", "\n")); + Assert.Equal(_scriptFile_noTrailingNewline.FileLines, unixFile.FileLines); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void CanGetLineForEmptyString() + { + ScriptFile emptyFile = ScriptFileChangeTests.CreateScriptFile(string.Empty); + Assert.Single(emptyFile.FileLines); + Assert.Equal(string.Empty, emptyFile.FileLines[0]); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void CanGetLineForSpace() + { + ScriptFile spaceFile = ScriptFileChangeTests.CreateScriptFile(" "); + Assert.Single(spaceFile.FileLines); + Assert.Equal(" ", spaceFile.FileLines[0]); + } + } + + public class ScriptFilePositionTests + { + private readonly ScriptFile scriptFile; + + public ScriptFilePositionTests() + { + scriptFile = + ScriptFileChangeTests.CreateScriptFile(@" +First line + Second line is longer + Third line +"); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void CanOffsetByLine() + { + AssertNewPosition( + 1, 1, + 2, 0, + 3, 1); + + AssertNewPosition( + 3, 1, + -2, 0, + 1, 1); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void CanOffsetByColumn() + { + AssertNewPosition( + 2, 1, + 0, 2, + 2, 3); + + AssertNewPosition( + 2, 5, + 0, -3, + 2, 2); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void ThrowsWhenPositionOutOfRange() + { + // Less than line range + Assert.Throws( + () => + { + scriptFile.CalculatePosition( + new BufferPosition(1, 1), + -10, 0); + }); + + // Greater than line range + Assert.Throws( + () => + { + scriptFile.CalculatePosition( + new BufferPosition(1, 1), + 10, 0); + }); + + // Less than column range + Assert.Throws( + () => + { + scriptFile.CalculatePosition( + new BufferPosition(1, 1), + 0, -10); + }); + + // Greater than column range + Assert.Throws( + () => + { + scriptFile.CalculatePosition( + new BufferPosition(1, 1), + 0, 10); + }); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void CanFindBeginningOfLine() + { + AssertNewPosition( + 4, 12, + pos => pos.GetLineStart(), + 4, 5); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void CanFindEndOfLine() + { + AssertNewPosition( + 4, 12, + pos => pos.GetLineEnd(), + 4, 15); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void CanComposePositionOperations() + { + AssertNewPosition( + 4, 12, + pos => pos.AddOffset(-1, 1).GetLineStart(), + 3, 3); + } + + private void AssertNewPosition( + int originalLine, int originalColumn, + int lineOffset, int columnOffset, + int expectedLine, int expectedColumn) + { + AssertNewPosition( + originalLine, originalColumn, + pos => pos.AddOffset(lineOffset, columnOffset), + expectedLine, expectedColumn); + } + + private void AssertNewPosition( + int originalLine, int originalColumn, + Func positionOperation, + int expectedLine, int expectedColumn) + { + FilePosition newPosition = + positionOperation( + new FilePosition( + scriptFile, + originalLine, + originalColumn)); + + Assert.Equal(expectedLine, newPosition.Line); + Assert.Equal(expectedColumn, newPosition.Column); + } + } + + public class ScriptFileConstructorTests + { + private static readonly Version PowerShellVersion = new("5.0"); + + [Trait("Category", "ScriptFile")] + [Fact] + public void PropertiesInitializedCorrectlyForFile() + { + // Use any absolute path. Even if it doesn't exist. + string path = Path.Combine(Path.GetTempPath(), "TestFile.ps1"); + ScriptFile scriptFile = ScriptFileChangeTests.CreateScriptFile(""); + + Assert.Equal(path, scriptFile.FilePath, ignoreCase: !VersionUtils.IsLinux); + Assert.True(scriptFile.IsAnalysisEnabled); + Assert.False(scriptFile.IsInMemory); + Assert.Empty(scriptFile.DiagnosticMarkers); + Assert.Single(scriptFile.ScriptTokens); + Assert.Single(scriptFile.FileLines); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void PropertiesInitializedCorrectlyForUntitled() + { + const string path = "untitled:untitled-1"; + + // 3 lines and 10 tokens in this script. + const string script = @"function foo() { + 'foo' +}"; + + using StringReader stringReader = new(script); + // Create an in-memory file from the StringReader + ScriptFile scriptFile = new(DocumentUri.From(path), stringReader, PowerShellVersion); + + Assert.Equal(path, scriptFile.FilePath); + Assert.Equal(path, scriptFile.DocumentUri); + Assert.True(scriptFile.IsAnalysisEnabled); + Assert.True(scriptFile.IsInMemory); + Assert.Empty(scriptFile.DiagnosticMarkers); + Assert.Equal(10, scriptFile.ScriptTokens.Length); + Assert.Equal(3, scriptFile.FileLines.Count); + } + + [Trait("Category", "ScriptFile")] + [Fact] + public void DocumentUriReturnsCorrectStringForAbsolutePath() + { + string path; + ScriptFile scriptFile; + StringReader emptyStringReader = new(""); + + if (Environment.OSVersion.Platform == PlatformID.Win32NT) + { + path = @"C:\Users\AmosBurton\projects\Rocinate\ProtoMolecule.ps1"; + scriptFile = new ScriptFile(DocumentUri.FromFileSystemPath(path), emptyStringReader, PowerShellVersion); + Assert.Equal("file:///c:/Users/AmosBurton/projects/Rocinate/ProtoMolecule.ps1", scriptFile.DocumentUri); + + path = @"c:\Users\BobbieDraper\projects\Rocinate\foo's_~#-[@] +,;=%.ps1"; + scriptFile = new ScriptFile(DocumentUri.FromFileSystemPath(path), emptyStringReader, PowerShellVersion); + Assert.Equal("file:///c:/Users/BobbieDraper/projects/Rocinate/foo%27s_~%23-%5B%40%5D%20%2B%2C%3B%3D%25.ps1", scriptFile.DocumentUri); + + // Test UNC path + path = @"\\ClarissaMao\projects\Rocinate\foo's_~#-[@] +,;=%.ps1"; + scriptFile = new ScriptFile(DocumentUri.FromFileSystemPath(path), emptyStringReader, PowerShellVersion); + // UNC authorities are lowercased. This is what VS Code does as well. + Assert.Equal("file://clarissamao/projects/Rocinate/foo%27s_~%23-%5B%40%5D%20%2B%2C%3B%3D%25.ps1", scriptFile.DocumentUri); + } + else + { + // Test the following only on Linux and macOS. + path = "/home/AlexKamal/projects/Rocinate/ProtoMolecule.ps1"; + scriptFile = new ScriptFile(DocumentUri.FromFileSystemPath(path), emptyStringReader, PowerShellVersion); + Assert.Equal("file:///home/AlexKamal/projects/Rocinate/ProtoMolecule.ps1", scriptFile.DocumentUri); + + path = "/home/BobbieDraper/projects/Rocinate/foo's_~#-[@] +,;=%.ps1"; + scriptFile = new ScriptFile(DocumentUri.FromFileSystemPath(path), emptyStringReader, PowerShellVersion); + Assert.Equal("file:///home/BobbieDraper/projects/Rocinate/foo%27s_~%23-%5B%40%5D%20%2B%2C%3B%3D%25.ps1", scriptFile.DocumentUri); + + path = "/home/NaomiNagata/projects/Rocinate/Proto:Mole:cule.ps1"; + scriptFile = new ScriptFile(DocumentUri.FromFileSystemPath(path), emptyStringReader, PowerShellVersion); + Assert.Equal("file:///home/NaomiNagata/projects/Rocinate/Proto%3AMole%3Acule.ps1", scriptFile.DocumentUri); + + path = @"/home/JamesHolden/projects/Rocinate/Proto:Mole\cule.ps1"; + scriptFile = new ScriptFile(DocumentUri.FromFileSystemPath(path), emptyStringReader, PowerShellVersion); + Assert.Equal("file:///home/JamesHolden/projects/Rocinate/Proto%3AMole%5Ccule.ps1", scriptFile.DocumentUri); + } + } + + [Trait("Category", "ScriptFile")] + [Theory] + [InlineData(@"C:\Users\me\Documents\test.ps1", false)] + [InlineData("/Users/me/Documents/test.ps1", false)] + [InlineData("vscode-notebook-cell:/Users/me/Documents/test.ps1#0001", true)] + [InlineData("https://microsoft.com", true)] + [InlineData("Untitled:Untitled-1", true)] + [InlineData(@"'a log statement' > 'c:\Users\me\Documents\test.txt' +", false)] + public void IsUntitledFileIsCorrect(string path, bool expected) => Assert.Equal(expected, ScriptFile.IsUntitledPath(path)); + } +} diff --git a/test/PowerShellEditorServices.Test/Session/WorkspaceTests.cs b/test/PowerShellEditorServices.Test/Session/WorkspaceTests.cs new file mode 100644 index 0000000..4abd80f --- /dev/null +++ b/test/PowerShellEditorServices.Test/Session/WorkspaceTests.cs @@ -0,0 +1,202 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Xunit; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol; + +namespace PowerShellEditorServices.Test.Session +{ + [Trait("Category", "Workspace")] + public class WorkspaceTests + { + private static readonly Lazy s_lazyDriveLetter = new(() => Path.GetFullPath("\\").Substring(0, 1)); + + public static string CurrentDriveLetter => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? s_lazyDriveLetter.Value + : string.Empty; + + internal static ScriptFile CreateScriptFile(string path) => ScriptFile.Create(path, "", VersionUtils.PSVersion); + + // Remember that LSP does weird stuff to the drive letter, so we have to convert it to a URI + // and back to ensure that drive letter gets lower cased and everything matches up. + private static string s_workspacePath => + DocumentUri.FromFileSystemPath(Path.GetFullPath("Fixtures/Workspace")).GetFileSystemPath(); + + [Fact] + public void CanResolveWorkspaceRelativePath() + { + string workspacePath = "c:/Test/Workspace/"; + ScriptFile testPathInside = CreateScriptFile("c:/Test/Workspace/SubFolder/FilePath.ps1"); + ScriptFile testPathOutside = CreateScriptFile("c:/Test/PeerPath/FilePath.ps1"); + ScriptFile testPathAnotherDrive = CreateScriptFile("z:/TryAndFindMe/FilePath.ps1"); + + WorkspaceService workspace = new(NullLoggerFactory.Instance); + + // Test with zero workspace folders + Assert.Equal( + testPathOutside.DocumentUri.ToUri().AbsolutePath, + workspace.GetRelativePath(testPathOutside)); + + string expectedInsidePath = "SubFolder/FilePath.ps1"; + string expectedOutsidePath = "../PeerPath/FilePath.ps1"; + + // Test with a single workspace folder + workspace.WorkspaceFolders.Add(new WorkspaceFolder + { + Uri = DocumentUri.FromFileSystemPath(workspacePath) + }); + + Assert.Equal(expectedInsidePath, workspace.GetRelativePath(testPathInside)); + Assert.Equal(expectedOutsidePath, workspace.GetRelativePath(testPathOutside)); + Assert.Equal( + testPathAnotherDrive.DocumentUri.ToUri().AbsolutePath, + workspace.GetRelativePath(testPathAnotherDrive)); + + // Test with two workspace folders + string anotherWorkspacePath = "c:/Test/AnotherWorkspace/"; + ScriptFile anotherTestPathInside = CreateScriptFile("c:/Test/AnotherWorkspace/DifferentFolder/FilePath.ps1"); + string anotherExpectedInsidePath = "DifferentFolder/FilePath.ps1"; + + workspace.WorkspaceFolders.Add(new WorkspaceFolder + { + Uri = DocumentUri.FromFileSystemPath(anotherWorkspacePath) + }); + + Assert.Equal(expectedInsidePath, workspace.GetRelativePath(testPathInside)); + Assert.Equal(anotherExpectedInsidePath, workspace.GetRelativePath(anotherTestPathInside)); + } + + internal static WorkspaceService FixturesWorkspace() + { + return new WorkspaceService(NullLoggerFactory.Instance) + { + WorkspaceFolders = + { + new WorkspaceFolder { Uri = DocumentUri.FromFileSystemPath(s_workspacePath) } + } + }; + } + + [Fact] + public void HasDefaultForWorkspacePaths() + { + WorkspaceService workspace = FixturesWorkspace(); + string workspacePath = Assert.Single(workspace.WorkspacePaths); + Assert.Equal(s_workspacePath, workspacePath); + // We shouldn't assume an initial working directory since none was given. + Assert.Null(workspace.InitialWorkingDirectory); + } + + // These are the default values for the EnumeratePSFiles() method + // in Microsoft.PowerShell.EditorServices.Workspace class + private static readonly string[] s_defaultExcludeGlobs = Array.Empty(); + private static readonly string[] s_defaultIncludeGlobs = new[] { "**/*" }; + private const int s_defaultMaxDepth = 64; + private const bool s_defaultIgnoreReparsePoints = false; + + internal static List ExecuteEnumeratePSFiles( + WorkspaceService workspace, + string[] excludeGlobs, + string[] includeGlobs, + int maxDepth, + bool ignoreReparsePoints) + { + List fileList = new(workspace.EnumeratePSFiles( + excludeGlobs: excludeGlobs, + includeGlobs: includeGlobs, + maxDepth: maxDepth, + ignoreReparsePoints: ignoreReparsePoints + )); + + // Assume order is not important from EnumeratePSFiles and sort the array so we can use + // deterministic asserts + fileList.Sort(); + return fileList; + } + + [Fact] + public void CanRecurseDirectoryTree() + { + WorkspaceService workspace = FixturesWorkspace(); + List actual = ExecuteEnumeratePSFiles( + workspace: workspace, + excludeGlobs: s_defaultExcludeGlobs, + includeGlobs: s_defaultIncludeGlobs, + maxDepth: s_defaultMaxDepth, + ignoreReparsePoints: s_defaultIgnoreReparsePoints + ); + + List expected = new() + { + Path.Combine(s_workspacePath, "nested", "donotfind.ps1"), + Path.Combine(s_workspacePath, "nested", "nestedmodule.psd1"), + Path.Combine(s_workspacePath, "nested", "nestedmodule.psm1"), + Path.Combine(s_workspacePath, "rootfile.ps1") + }; + + // .NET Core doesn't appear to use the same three letter pattern matching rule although the docs + // suggest it should be find the '.ps1xml' files because we search for the pattern '*.ps1' + // ref https://docs.microsoft.com/en-us/dotnet/api/system.io.directory.getfiles?view=netcore-2.1#System_IO_Directory_GetFiles_System_String_System_String_System_IO_EnumerationOptions_ + if (RuntimeInformation.FrameworkDescription.StartsWith(".NET Framework")) + { + expected.Insert(3, Path.Combine(s_workspacePath, "other", "other.ps1xml")); + } + + Assert.Equal(expected, actual); + } + + [Fact] + public void CanRecurseDirectoryTreeWithLimit() + { + WorkspaceService workspace = FixturesWorkspace(); + List actual = ExecuteEnumeratePSFiles( + workspace: workspace, + excludeGlobs: s_defaultExcludeGlobs, + includeGlobs: s_defaultIncludeGlobs, + maxDepth: 1, + ignoreReparsePoints: s_defaultIgnoreReparsePoints + ); + Assert.Equal(new[] { Path.Combine(s_workspacePath, "rootfile.ps1") }, actual); + } + + [Fact] + public void CanRecurseDirectoryTreeWithGlobs() + { + WorkspaceService workspace = FixturesWorkspace(); + List actual = ExecuteEnumeratePSFiles( + workspace: workspace, + excludeGlobs: new[] { "**/donotfind*" }, // Exclude any files starting with donotfind + includeGlobs: new[] { "**/*.ps1", "**/*.psd1" }, // Only include PS1 and PSD1 files + maxDepth: s_defaultMaxDepth, + ignoreReparsePoints: s_defaultIgnoreReparsePoints + ); + + Assert.Equal(new[] { + Path.Combine(s_workspacePath, "nested", "nestedmodule.psd1"), + Path.Combine(s_workspacePath, "rootfile.ps1") + }, actual); + } + + [Fact] + public void CanOpenAndCloseFile() + { + WorkspaceService workspace = FixturesWorkspace(); + string filePath = Path.GetFullPath(Path.Combine(s_workspacePath, "rootfile.ps1")); + + ScriptFile file = workspace.GetFile(filePath); + Assert.Equal(workspace.GetOpenedFiles(), new[] { file }); + + workspace.CloseFile(file); + Assert.Equal(workspace.GetOpenedFiles(), Array.Empty()); + } + } +} diff --git a/test/PowerShellEditorServices.Test/Utility/VersionUtilsTests.cs b/test/PowerShellEditorServices.Test/Utility/VersionUtilsTests.cs new file mode 100644 index 0000000..bc95053 --- /dev/null +++ b/test/PowerShellEditorServices.Test/Utility/VersionUtilsTests.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.EditorServices.Utility; +using Xunit; + +namespace Microsoft.PowerShell.EditorServices.Test.Utility +{ + public class VersionUtilsTests + { + [Trait("Category", "VersionUtils")] + [Fact] + public void IsNetCoreTest() => +#if CoreCLR + Assert.True(VersionUtils.IsNetCore); +#else + Assert.False(VersionUtils.IsNetCore); +#endif + + } +} diff --git a/test/PowerShellEditorServices.Test/xunit.runner.json b/test/PowerShellEditorServices.Test/xunit.runner.json new file mode 100644 index 0000000..f8b76c8 --- /dev/null +++ b/test/PowerShellEditorServices.Test/xunit.runner.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "appDomain": "denied", + "parallelizeTestCollections": false, + "methodDisplay": "method" +} diff --git a/test/emacs-simple-test.el b/test/emacs-simple-test.el new file mode 100644 index 0000000..c979b09 --- /dev/null +++ b/test/emacs-simple-test.el @@ -0,0 +1,63 @@ +;;; emacs-simple-test.el --- Integration testing script -*- lexical-binding: t; -*- + +;; Copyright (c) Microsoft Corporation. +;; Licensed under the MIT License. + +;; Author: Andy Jordan +;; Keywords: PowerShell, LSP + +;;; Code: + +;; Avoid using old packages. +(setq load-prefer-newer t) + +;; Improved TLS Security. +(with-eval-after-load 'gnutls + (custom-set-variables + '(gnutls-verify-error t) + '(gnutls-min-prime-bits 3072))) + +;; Package setup. +(require 'package) +(add-to-list 'package-archives + '("melpa" . "https://melpa.org/packages/") t) +(package-initialize) +(package-refresh-contents) + +(require 'ert) + +(require 'flymake) + +(unless (package-installed-p 'powershell) + (package-install 'powershell)) +(require 'powershell) + +(unless (package-installed-p 'eglot) + (package-install 'eglot)) +(require 'eglot) + +(ert-deftest powershell-editor-services () + "Eglot should connect to PowerShell Editor Services." + (let* ((repo (project-root (project-current))) + (start-script (expand-file-name "module/PowerShellEditorServices/Start-EditorServices.ps1" repo)) + (test-script (expand-file-name "test/PowerShellEditorServices.Test.Shared/Debugging/VariableTest.ps1" repo)) + (eglot-sync-connect t)) + (add-to-list + 'eglot-server-programs + `(powershell-mode + . ("pwsh" "-NoLogo" "-NoProfile" "-Command" ,start-script "-Stdio"))) + (with-current-buffer (find-file-noselect test-script) + (should (eq major-mode 'powershell-mode)) + (should (apply #'eglot--connect (eglot--guess-contact))) + (should (eglot-current-server)) + (let ((lsp (eglot-current-server))) + (should (string= (eglot--project-nickname lsp) "PowerShellEditorServices")) + (should (member (cons 'powershell-mode "powershell") (eglot--languages lsp)))) + (sleep-for 5) ; TODO: Wait for "textDocument/publishDiagnostics" instead + (flymake-start) + (goto-char (point-min)) + (flymake-goto-next-error) + (should (eq 'flymake-warning (face-at-point)))))) + +(provide 'emacs-test) +;;; emacs-test.el ends here diff --git a/test/emacs-test.el b/test/emacs-test.el new file mode 100644 index 0000000..378c792 --- /dev/null +++ b/test/emacs-test.el @@ -0,0 +1,71 @@ +;;; emacs-test.el --- Integration testing script -*- lexical-binding: t; -*- + +;; Copyright (c) Microsoft Corporation. +;; Licensed under the MIT License. + +;; Author: Andy Jordan +;; Keywords: PowerShell, LSP + +;;; Code: + +;; Avoid using old packages. +(setq load-prefer-newer t) + +;; Improved TLS Security. +(with-eval-after-load 'gnutls + (custom-set-variables + '(gnutls-verify-error t) + '(gnutls-min-prime-bits 3072))) + +;; Package setup. +(require 'package) +(add-to-list 'package-archives + '("melpa" . "https://melpa.org/packages/") t) +(package-initialize) +(package-refresh-contents) + +(require 'ert) + +(require 'flymake) + +(unless (package-installed-p 'powershell) + (package-install 'powershell)) +(require 'powershell) + +(unless (package-installed-p 'eglot) + (package-install 'eglot)) +(require 'eglot) + +(ert-deftest powershell-editor-services () + "Eglot should connect to PowerShell Editor Services." + (let* ((repo (project-root (project-current))) + (start-script (expand-file-name "module/PowerShellEditorServices/Start-EditorServices.ps1" repo)) + (module-path (expand-file-name "module" repo)) + (log-path (expand-file-name "test/emacs-test.log" repo)) + (session-path (expand-file-name "test/emacs-session.json" repo)) + (test-script (expand-file-name "test/PowerShellEditorServices.Test.Shared/Debugging/VariableTest.ps1" repo)) + (eglot-sync-connect t)) + (add-to-list + 'eglot-server-programs + `(powershell-mode + . ("pwsh" "-NoLogo" "-NoProfile" "-Command" ,start-script + "-HostName" "Emacs" "-HostProfileId" "Emacs" "-HostVersion" "1.0.0" + "-BundledModulesPath" ,module-path + "-LogPath" ,log-path "-LogLevel" "Diagnostic" + "-SessionDetailsPath" ,session-path + "-Stdio"))) + (with-current-buffer (find-file-noselect test-script) + (should (eq major-mode 'powershell-mode)) + (should (apply #'eglot--connect (eglot--guess-contact))) + (should (eglot-current-server)) + (let ((lsp (eglot-current-server))) + (should (string= (eglot--project-nickname lsp) "PowerShellEditorServices")) + (should (member (cons 'powershell-mode "powershell") (eglot--languages lsp)))) + (sleep-for 5) ; TODO: Wait for "textDocument/publishDiagnostics" instead + (flymake-start) + (goto-char (point-min)) + (flymake-goto-next-error) + (should (eq 'flymake-warning (face-at-point)))))) + +(provide 'emacs-test) +;;; emacs-test.el ends here diff --git a/test/vim-simple-test.vim b/test/vim-simple-test.vim new file mode 100644 index 0000000..fe0adda --- /dev/null +++ b/test/vim-simple-test.vim @@ -0,0 +1,40 @@ +let s:suite = themis#suite('pses') +let s:assert = themis#helper('assert') + +function s:suite.before() + let l:pses_path = g:repo_root . '/module' + let g:LanguageClient_serverCommands = { + \ 'ps1': [ 'pwsh', '-NoLogo', '-NoProfile', '-Command', + \ l:pses_path . '/PowerShellEditorServices/Start-EditorServices.ps1', '-Stdio' ] + \ } + let g:LanguageClient_serverStderr = 'DEBUG' + let g:LanguageClient_loggingFile = g:repo_root . '/LanguageClient.log' + let g:LanguageClient_serverStderr = g:repo_root . '/LanguageServer.log' +endfunction + +function s:suite.has_language_client() + call s:assert.includes(&runtimepath, g:repo_root . '/LanguageClient-neovim') + call s:assert.cmd_exists('LanguageClientStart') + call s:assert.not_empty(g:LanguageClient_serverCommands) + call s:assert.true(LanguageClient#HasCommand('ps1')) +endfunction + +function s:suite.analyzes_powershell_file() + view test/vim-test.ps1 " This must not use quotes! + + let l:bufnr = bufnr('vim-test.ps1$') + call s:assert.not_equal(l:bufnr, -1) + let l:bufinfo = getbufinfo(l:bufnr)[0] + + call s:assert.equal(l:bufinfo.name, g:repo_root . '/test/vim-test.ps1') + call s:assert.includes(getbufline(l:bufinfo.name, 1), 'function Do-Work {}') + " TODO: This shouldn't be necessary, vim-ps1 works locally but not in CI. + call setbufvar(l:bufinfo.bufnr, '&filetype', 'ps1') + call s:assert.equal(getbufvar(l:bufinfo.bufnr, '&filetype'), 'ps1') + + execute 'LanguageClientStart' + execute 'sleep' 5 + call s:assert.equal(getbufvar(l:bufinfo.name, 'LanguageClient_isServerRunning'), 1) + call s:assert.equal(getbufvar(l:bufinfo.name, 'LanguageClient_projectRoot'), g:repo_root) + call s:assert.equal(getbufvar(l:bufinfo.name, 'LanguageClient_statusLineDiagnosticsCounts'), {'E': 0, 'W': 1, 'H': 0, 'I': 0}) +endfunction diff --git a/test/vim-test.ps1 b/test/vim-test.ps1 new file mode 100644 index 0000000..8175124 --- /dev/null +++ b/test/vim-test.ps1 @@ -0,0 +1 @@ +function Do-Work {} diff --git a/test/vim-test.vim b/test/vim-test.vim new file mode 100644 index 0000000..3d94d17 --- /dev/null +++ b/test/vim-test.vim @@ -0,0 +1,44 @@ +let s:suite = themis#suite('pses') +let s:assert = themis#helper('assert') + +function s:suite.before() + let l:pses_path = g:repo_root . '/module' + let g:LanguageClient_serverCommands = { + \ 'ps1': [ 'pwsh', '-NoLogo', '-NoProfile', '-Command', + \ l:pses_path . '/PowerShellEditorServices/Start-EditorServices.ps1', + \ '-HostName', 'vim', '-HostProfileId', 'vim', '-HostVersion', '1.0.0', + \ '-BundledModulesPath', l:pses_path, '-Stdio', + \ '-LogPath', g:repo_root . '/pses.log', '-LogLevel', 'Diagnostic', + \ '-SessionDetailsPath', g:repo_root . '/pses_session.json' ] + \ } + let g:LanguageClient_serverStderr = 'DEBUG' + let g:LanguageClient_loggingFile = g:repo_root . '/LanguageClient.log' + let g:LanguageClient_serverStderr = g:repo_root . '/LanguageServer.log' +endfunction + +function s:suite.has_language_client() + call s:assert.includes(&runtimepath, g:repo_root . '/LanguageClient-neovim') + call s:assert.cmd_exists('LanguageClientStart') + call s:assert.not_empty(g:LanguageClient_serverCommands) + call s:assert.true(LanguageClient#HasCommand('ps1')) +endfunction + +function s:suite.analyzes_powershell_file() + view test/vim-test.ps1 " This must not use quotes! + + let l:bufnr = bufnr('vim-test.ps1$') + call s:assert.not_equal(l:bufnr, -1) + let l:bufinfo = getbufinfo(l:bufnr)[0] + + call s:assert.equal(l:bufinfo.name, g:repo_root . '/test/vim-test.ps1') + call s:assert.includes(getbufline(l:bufinfo.name, 1), 'function Do-Work {}') + " TODO: This shouldn't be necessary, vim-ps1 works locally but not in CI. + call setbufvar(l:bufinfo.bufnr, '&filetype', 'ps1') + call s:assert.equal(getbufvar(l:bufinfo.bufnr, '&filetype'), 'ps1') + + execute 'LanguageClientStart' + execute 'sleep' 5 + call s:assert.equal(getbufvar(l:bufinfo.name, 'LanguageClient_isServerRunning'), 1) + call s:assert.equal(getbufvar(l:bufinfo.name, 'LanguageClient_projectRoot'), g:repo_root) + call s:assert.equal(getbufvar(l:bufinfo.name, 'LanguageClient_statusLineDiagnosticsCounts'), {'E': 0, 'W': 1, 'H': 0, 'I': 0}) +endfunction diff --git a/tools/installPSResources.ps1 b/tools/installPSResources.ps1 new file mode 100644 index 0000000..6947988 --- /dev/null +++ b/tools/installPSResources.ps1 @@ -0,0 +1,24 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +param( + [ValidateSet("PSGallery", "CFS")] + [string]$PSRepository = "PSGallery" +) + +if ($PSRepository -eq "CFS" -and -not (Get-PSResourceRepository -Name CFS -ErrorAction SilentlyContinue)) { + Register-PSResourceRepository -Name CFS -Uri "https://pkgs.dev.azure.com/powershell/PowerShell/_packaging/PowerShellGalleryMirror/nuget/v3/index.json" +} + +# NOTE: Due to a bug in Install-PSResource with upstream feeds, we have to +# request an exact version. Otherwise, if a newer version is available in the +# upstream feed, it will fail to install any version at all. +Install-PSResource -Verbose -TrustRepository -RequiredResource @{ + InvokeBuild = @{ + version = "5.14.18" + repository = $PSRepository + } + platyPS = @{ + version = "0.14.2" + repository = $PSRepository + } +} diff --git a/tools/updateVersion.ps1 b/tools/updateVersion.ps1 new file mode 100644 index 0000000..85e75df --- /dev/null +++ b/tools/updateVersion.ps1 @@ -0,0 +1,47 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +param( + [Parameter(Mandatory)] + [semver]$Version, + + [Parameter(Mandatory)] + [string]$Changes +) + +git diff --staged --quiet --exit-code +if ($LASTEXITCODE -ne 0) { + throw "There are staged changes in the repository. Please commit or reset them before running this script." +} + +$v = "$($Version.Major).$($Version.Minor).$($Version.Patch)" + +$Path = "PowerShellEditorServices.Common.props" +$f = Get-Content -Path $Path +$f = $f -replace '^(?\s+)(.+)(?)$', "`${prefix}${v}`${suffix}" +$f = $f -replace '^(?\s+)(.*)(?)$', "`${prefix}$($Version.PreReleaseLabel)`${suffix}" +$f | Set-Content -Path $Path +git add $Path + +$Path = "module/PowerShellEditorServices/PowerShellEditorServices.psd1" +$f = Get-Content -Path $Path +$f = $f -replace "^(?ModuleVersion = ')(.+)(?')`$", "`${prefix}${v}`${suffix}" +$f | Set-Content -Path $Path +git add $Path + +$Path = "CHANGELOG.md" +$Changelog = Get-Content -Path $Path +@( + $Changelog[0..1] + "## v$Version" + "### $([datetime]::Now.ToString('dddd, MMMM dd, yyyy'))" + "" + "See more details at the GitHub Release for [v$Version](https://github.com/PowerShell/PowerShellEditorServices/releases/tag/v$Version)." + "" + $Changes + "" + $Changelog[2..$Changelog.Length] +) | Set-Content -Encoding utf8NoBOM -Path $Path +git add $Path + +git commit --edit --message "v$($Version): $Changes"