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