diff --git a/src/PowerShellEditorServices/Services/PowerShell/Utility/CommandHelpers.cs b/src/PowerShellEditorServices/Services/PowerShell/Utility/CommandHelpers.cs index 6532c531a..36cdad63c 100644 --- a/src/PowerShellEditorServices/Services/PowerShell/Utility/CommandHelpers.cs +++ b/src/PowerShellEditorServices/Services/PowerShell/Utility/CommandHelpers.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. - using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -10,9 +9,143 @@ using System.Threading.Tasks; using Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace; using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility { + /// + /// Represents detailed help information for a PowerShell command. + /// + public record CommandHelp + { + /// + /// The name of the command. + /// + public string Name { get; init; } + /// + /// The command synopsis. + /// + public string Synopsis { get; init; } + + /// + /// The detailed description of the command. + /// + public string Description { get; init; } + + /// + /// The syntax examples for the command. + /// + public SyntaxHelp[] Syntax { get; init; } + + /// + /// The parameter descriptions for the command. + /// + public ParameterHelp[] Parameters { get; init; } + + /// + /// Usage examples for the command. + /// + public ExampleHelp[] Examples { get; init; } + + /// + /// Alerts or warnings related to the command. This shows as NOTES in the CLI help. + /// + public string[] Alerts { get; init; } + + /// + /// Online help link for the command. + /// + public string OnlineLink { get; init; } + + /// + /// Best-Effort Converts a Help object to a CommandHelp object. This is necessary because the original types are internal and inaccessible and our only access is a custom PSObject generated by Get-Help. + /// + /// The PSCustomObject to convert. + public static CommandHelp From(PSObject psObject) + { + // Dynamic simplifies processing as the help is a "serialized" PSCustomObject format rather than the original types + dynamic helpObj = psObject; + + // Extract description text from a PSCustomObject collection of strings + string description = string.Join(Environment.NewLine, + psObject + .GetPropertyValue(nameof(Description))? + .Select(x => x.GetPropertyValue("Text")) + ?? [] + ); + + PSObject links = helpObj.relatedLinks as PSObject; + PSObject[] navigationLinks = links.GetPropertyValue("navigationLink"); + string onlineLink = navigationLinks + .FirstOrDefault(navlink => + navlink.GetPropertyValue("linkText") == "Online Version:" + ) is PSObject onlineLinkMatch + ? onlineLinkMatch.GetPropertyValue("uri") + : string.Empty; + + PSObject paramsObj = helpObj.parameters as PSObject; + ParameterHelp[] parameters = [ + .. paramsObj + .GetPropertyValue("parameter") + .Select(ParameterHelp.From) + .OrderBy(p => p.Position) + .ThenBy(p => p.Required ? 0 : 1) // Required parameters first + .ThenBy(p => p.Name) + ]; + + return new() + { + Name = helpObj.Name, + Synopsis = helpObj.Synopsis, + Description = description, + OnlineLink = onlineLink, + Parameters = parameters + // Syntax = syntaxHelp ?? Array.Empty(), + // Parameters = parameterHelp ?? Array.Empty(), + // Examples = examplesHelp ?? Array.Empty(), + }; + } + + public MarkupContent ToMarkupContent(bool noTitle) + { + string Title = noTitle ? "" : $"# {Name} \n"; + string markdownSynopsis = string.IsNullOrWhiteSpace(Synopsis) ? string.Empty : $"**{Synopsis}** \n"; + string markdownOnlineLink = string.IsNullOrWhiteSpace(OnlineLink) ? string.Empty : $"[View Online Help]({OnlineLink}) \n"; + return new MarkupContent + { + Kind = MarkupKind.Markdown, + Value = $"{markdownOnlineLink}{Title}{markdownSynopsis}\n\n{Description}" + }; + } + } + + public record ParameterHelp( + string Name, + string Description, + bool PipelineInput, + int Position, + bool Required, + string Type, + bool VariableLength + ) + { + public static ParameterHelp From(PSObject psObject) + { + return new ParameterHelp( + psObject.GetPropertyValue("name"), + psObject.GetPropertyValue("description"), + psObject.GetPropertyValue("pipelineInput"), + psObject.GetPropertyValue("position"), + psObject.GetPropertyValue("required"), + psObject.GetPropertyValue("type").GetPropertyValue("name"), + psObject.GetPropertyValue("variableLength") + ); + } + } + + public record ExampleHelp(string Title, string Code, string Remarks); + public record SyntaxHelp(string Name, ParameterHelp[] Parameters); + /// /// Provides utility methods for working with PowerShell commands. /// TODO: Handle the `fn ` prefix better. @@ -56,7 +189,7 @@ public record struct AliasMap( }; private static readonly ConcurrentDictionary s_commandInfoCache = new(); - private static readonly ConcurrentDictionary s_synopsisCache = new(); + private static readonly ConcurrentDictionary s_helpInfoCache = new(); internal static readonly ConcurrentDictionary> s_cmdletToAliasCache = new(System.StringComparer.OrdinalIgnoreCase); internal static readonly ConcurrentDictionary s_aliasToCmdletCache = new(System.StringComparer.OrdinalIgnoreCase); @@ -170,6 +303,27 @@ public static async Task GetCommandSynopsisAsync( CommandInfo commandInfo, IInternalPowerShellExecutionService executionService, CancellationToken cancellationToken = default) + { + CommandHelp commandDetail = await GetCommandHelpAsync( + commandInfo, + executionService, + cancellationToken + ).ConfigureAwait(false); + + return commandDetail.Synopsis; + } + + /// + /// Gets the full command help information. + /// + /// The CommandInfo instance for the command. + /// The execution service to use for getting command documentation. + /// The token used to cancel this. + /// The command help information. + public static async Task GetCommandHelpAsync( + CommandInfo commandInfo, + IInternalPowerShellExecutionService executionService, + CancellationToken cancellationToken = default) { Validate.IsNotNull(nameof(commandInfo), commandInfo); Validate.IsNotNull(nameof(executionService), executionService); @@ -179,22 +333,18 @@ public static async Task GetCommandSynopsisAsync( not CommandTypes.Function and not CommandTypes.Filter) { - return string.Empty; + return new CommandHelp(); } - // 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)) + // If we have help info cached, return that. + if (s_helpInfoCache.TryGetValue(commandInfo.Name, out CommandHelp helpInfo)) { - return synopsis; + return helpInfo; } + // Get-Help always contains examples/etc. in the object so we don't need -full here. 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"); @@ -203,30 +353,21 @@ not CommandTypes.Function and .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); - } + // FirstOrDefault is intentional instead of Single in the case of multiple conflicting commands with the same name + CommandHelp commandHelpInfo = CommandHelp.From(results[0] ?? default); - // Ignore the placeholder value for this field - if (string.Equals(synopsisString, "SHORT DESCRIPTION", System.StringComparison.CurrentCultureIgnoreCase)) + // Only cache cmdlet info because since they're exposed in binaries, they can never change throughout the session + if (commandInfo.CommandType is CommandTypes.Cmdlet) { - return string.Empty; + s_helpInfoCache.TryAdd(commandInfo.Name, commandHelpInfo); } - return synopsisString; + return commandHelpInfo; } /// /// Gets all aliases found in the runspace /// - /// - /// public static async Task GetAliasesAsync( IInternalPowerShellExecutionService executionService, CancellationToken cancellationToken = default) diff --git a/src/PowerShellEditorServices/Services/PowerShell/Utility/PSCustomObjectExtensions.cs b/src/PowerShellEditorServices/Services/PowerShell/Utility/PSCustomObjectExtensions.cs new file mode 100644 index 000000000..0f70251cf --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Utility/PSCustomObjectExtensions.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#nullable enable + +using System.Management.Automation; + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; + +/// +/// Extension methods for working with PSObject/PSCustomObject properties. +/// +public static class PSCustomObjectExtensions +{ + /// + /// Gets a property value from a PSObject with the specified name and casts it to the specified type. + /// + public static T? GetPropertyValue(this PSObject psObject, string propertyName) + => psObject.TryGetPropertyValue(propertyName, out T? value) ? value : default; + + /// + /// Tries to get a property value from a PSObject with the specified name and casts it to the specified type. + /// + /// The type to cast the property value to. + /// The PSObject to get the property from. + /// The name of the property to get. + /// When this method returns, contains the value of the property if found and converted successfully, or the default value of the type if not. + /// true if the property was found and converted successfully; otherwise, false. + public static bool TryGetPropertyValue(this PSObject psObject, string propertyName, out T? value) + { + value = default; + + PSPropertyInfo property = psObject.Properties[propertyName]; + if (property == null || property.Value == null) + { + return false; + } + try + { + // Attempt a generic cast first + value = LanguagePrimitives.ConvertTo(property.Value); + + return true; + } + catch + { + return false; + } + } +} diff --git a/src/PowerShellEditorServices/Services/Symbols/SymbolDetails.cs b/src/PowerShellEditorServices/Services/Symbols/SymbolDetails.cs index dc0a57fcd..ff52ea459 100644 --- a/src/PowerShellEditorServices/Services/Symbols/SymbolDetails.cs +++ b/src/PowerShellEditorServices/Services/Symbols/SymbolDetails.cs @@ -8,6 +8,7 @@ using Microsoft.PowerShell.EditorServices.Services.PowerShell; using Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace; using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; namespace Microsoft.PowerShell.EditorServices.Services.Symbols { @@ -29,7 +30,7 @@ internal class SymbolDetails /// Gets the documentation string for this symbol. Returns an /// empty string if the symbol has no documentation. /// - public string Documentation { get; private set; } + public StringOrMarkupContent Documentation { get; private set; } #endregion @@ -57,10 +58,10 @@ internal static async Task CreateAsync( if (commandInfo is not null) { symbolDetails.Documentation = - await CommandHelpers.GetCommandSynopsisAsync( + (await CommandHelpers.GetCommandHelpAsync( commandInfo, executionService, - cancellationToken).ConfigureAwait(false); + cancellationToken).ConfigureAwait(false)).ToMarkupContent(noTitle: true); if (commandInfo.CommandType == CommandTypes.Application) { diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/CompletionHandler.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/CompletionHandler.cs index c3d7a39c5..d80330ab4 100644 --- a/src/PowerShellEditorServices/Services/TextDocument/Handlers/CompletionHandler.cs +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/CompletionHandler.cs @@ -161,25 +161,25 @@ public override async Task Handle(CompletionItem request, Cancel return request; } - // Get the documentation for the function + // Get the function definition CommandInfo commandInfo = await CommandHelpers.GetCommandInfoAsync( request.Label, _runspaceContext.CurrentRunspace, _executionService, cancellationToken).ConfigureAwait(false); - if (commandInfo is not null) - { - return request with + return commandInfo is not null + ? (request with { - Documentation = await CommandHelpers.GetCommandSynopsisAsync( + Documentation = (await CommandHelpers.GetCommandHelpAsync( commandInfo, _executionService, - cancellationToken).ConfigureAwait(false) - }; - } - - return request; + cancellationToken + ) + .ConfigureAwait(false)) + .ToMarkupContent(noTitle: true) + }) + : request; } /// diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/HoverHandler.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/HoverHandler.cs index 9da05490a..0c8dd69ac 100644 --- a/src/PowerShellEditorServices/Services/TextDocument/Handlers/HoverHandler.cs +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/HoverHandler.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -58,19 +57,17 @@ await _symbolsService.FindSymbolDetailsAtLocationAsync( return null; } - List symbolInfo = new() + if (symbolDetails.Documentation is null) { - new MarkedString("PowerShell", symbolDetails.SymbolReference.Name) - }; - - if (!string.IsNullOrEmpty(symbolDetails.Documentation)) - { - symbolInfo.Add(new MarkedString("markdown", symbolDetails.Documentation)); + _logger.LogDebug("No documentation found for symbol at {Uri} {Position}", request.TextDocument.Uri, request.Position); + return null; } return new Hover { - Contents = new MarkedStringsOrMarkupContent(symbolInfo), + Contents = symbolDetails.Documentation.HasMarkupContent + ? new MarkedStringsOrMarkupContent(symbolDetails.Documentation.MarkupContent) + : new MarkedStringsOrMarkupContent(symbolDetails.Documentation.String), Range = symbolDetails.SymbolReference.NameRegion.ToRange() }; } diff --git a/test/PowerShellEditorServices.Test/Language/CompletionHandlerTests.cs b/test/PowerShellEditorServices.Test/Language/CompletionHandlerTests.cs index fb8076476..65ef10bc4 100644 --- a/test/PowerShellEditorServices.Test/Language/CompletionHandlerTests.cs +++ b/test/PowerShellEditorServices.Test/Language/CompletionHandlerTests.cs @@ -148,5 +148,30 @@ public void CanExtractTypeFromTooltip() Assert.Null(description); Assert.Equal(expectedType, type); } + + [Fact] + public async Task CompletesAndResolvesCommand() + { + // Get initial completion results + (_, IEnumerable results) = await GetCompletionResultsAsync(CompleteCommandFromModule.SourceDetails); + CompletionItem initialCompletion = results.Single(); + + // Verify initial completion has expected properties + Assert.Equal(CompleteCommandFromModule.ExpectedCompletion.Label, initialCompletion.Label); + Assert.Equal(CompleteCommandFromModule.ExpectedCompletion.Kind, initialCompletion.Kind); + + // Resolve the completion item + CompletionItem resolvedCompletion = await completionHandler.Handle( + initialCompletion, + CancellationToken.None); + + // Verify the resolved completion has more details + Assert.NotNull(resolvedCompletion); + Assert.NotNull(resolvedCompletion.Detail); + Assert.NotNull(resolvedCompletion.Documentation); + Assert.Equal(initialCompletion.Label, resolvedCompletion.Label); + Assert.Equal(initialCompletion.Kind, resolvedCompletion.Kind); + Assert.StartsWith(CompleteCommandFromModule.GetRandomDetail, resolvedCompletion.Detail); + } } }