File: shells\PowershellShellProvider.cs
Web Access
Project: ..\..\..\src\System.CommandLine.StaticCompletions\System.CommandLine.StaticCompletions.csproj (System.CommandLine.StaticCompletions)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
namespace System.CommandLine.StaticCompletions.Shells;
 
using System.CodeDom.Compiler;
using System.CommandLine;
using System.CommandLine.Completions;
using System.CommandLine.StaticCompletions.Resources;
 
public class PowershellShellProvider : IShellProvider
{
    public static string PowerShell => "pwsh";
 
    public string ArgumentName => PowershellShellProvider.PowerShell;
 
    public string Extension => "ps1";
 
    public string HelpDescription => Strings.PowershellShellProvider_HelpDescription;
 
    // override the ToString method to return the argument name so that CLI help is cleaner for 'default' values
    public override string ToString() => ArgumentName;
 
    public string GenerateCompletions(Command command)
    {
        var binaryName = command.Name;
 
        using var textWriter = new StringWriter { NewLine = "\n" };
        using var writer = new IndentedTextWriter(textWriter);
        writer.WriteLine(
$$$"""
using namespace System.Management.Automation
using namespace System.Management.Automation.Language
 
Register-ArgumentCompleter -Native -CommandName '{{{binaryName}}}' -ScriptBlock {
    param($wordToComplete, $commandAst, $cursorPosition)
 
    $commandElements = $commandAst.CommandElements
    $command = @(
        '{{{binaryName}}}'
        for ($i = 1; $i -lt $commandElements.Count; $i++) {
            $element = $commandElements[$i]
            if ($element -isnot [StringConstantExpressionAst] -or
                $element.StringConstantType -ne [StringConstantType]::BareWord -or
                $element.Value.StartsWith('-') -or
                $element.Value -eq $wordToComplete) {
                break
            }
            $element.Value
        }) -join ';'
""");
        writer.WriteLine();
        writer.Indent++;
 
        writer.WriteLine("$completions = @()");
        writer.WriteLine("switch ($command) {");
        writer.Indent++;
        GenerateSubcommandCompletions([], writer, command);
 
        writer.Indent--;
        writer.WriteLine("}");
        writer.WriteLine("$completions | Where-Object -FilterScript { $_.CompletionText -like \"$wordToComplete*\" } | Sort-Object -Property ListItemText");
 
        writer.Indent--;
        writer.WriteLine("}");
        writer.Flush();
        return textWriter.ToString();
    }
 
    private static string CompletionResult(string name, string value, string type, string? helpText)
    {
        if (helpText is null)
        {
            return $"[CompletionResult]::new('{value}', '{name}', [CompletionResultType]::{type}, \"{value}\")";
        }
        else
        {
            return $"[CompletionResult]::new('{value}', '{name}', [CompletionResultType]::{type}, \"{helpText}\")";
        }
    }
 
    private static string ParameterNameResult(string name, string value, string? helpText) => CompletionResult(name, value, "ParameterName", helpText);
 
    private static string ParameterValueResult(string name, string value, string? helpText) => CompletionResult(name, value, "ParameterValue", helpText);
 
    private static string? SanitizeHelpDescription(Symbol s) => s.Description?.ReplaceLineEndings(" ").Replace("`", "``").Replace("'", "`'").Replace("\"", "`\"").Replace("$", "`$");
 
    /// <summary>
    /// Generations completion-list items for the names of the given option. Typically used by commands/subcommands for static lookup lists.
    /// </summary>
    /// <param name="o"></param>
    /// <returns></returns>
    private static IEnumerable<string> GenerateOptionNameCompletions(Option o)
    {
        if (o.Hidden)
        {
            yield break;
        }
 
        var verboseName = o.Name;
        var names = o.Names();
        var helpText = SanitizeHelpDescription(o);
 
        // generate a completion recognizer for each alias, but have it's 'commit' value
        // be the longest/most verbose for clarity
        foreach (var name in names)
        {
            // differentiate casing for single character flags because powershell doesn't do case-sensitivity
            var completionValue = name.IsUpperCaseSingleCharacterFlag() ? $"{verboseName} " : verboseName;
            yield return ParameterNameResult(name, completionValue, helpText);
        }
    }
 
    /// <summary>
    /// Generate completions for the statically-known arguments
    /// </summary>
    /// <param name="argument"></param>
    /// <returns></returns>
    private static IEnumerable<string> GenerateArgumentCompletions(Argument argument)
    {
        if (argument.Hidden)
        {
            yield break;
        }
 
        if (argument.IsDynamic())
        {
            // if the argument is a not-static-friendly argument, we need to call into the app for completions
            // TODO: not yet supported for powershell
            yield break;
        }
        var argCompletions = argument.GetCompletions(CompletionContext.Empty).ToArray();
        if (argCompletions is not null && argCompletions.Length > 0)
        {
            foreach (var completion in argCompletions)
            {
                yield return ParameterValueResult(completion.Label, completion.InsertText ?? completion.Label, completion.Documentation ?? completion.Detail ?? completion.Label);
            }
        }
    }
 
    /// <summary>
    /// Generate completions for the subcommands of a given command. Each subcommand generates a list of switches for the staticly-known flags and arguments.
    /// </summary>
    /// <param name="parentCommandNames"></param>
    /// <param name="writer"></param>
    /// <param name="command"></param>
    /// <remarks>Dynamically-generated completions are not yet supported</remarks>
    internal static void GenerateSubcommandCompletions(string[] parentCommandNames, IndentedTextWriter writer, Command command)
    {
        string[] commandNameList = parentCommandNames switch
        {
            null => [command.Name],
            [] => [command.Name],
            var names => [.. names, command.Name]
        };
 
        GenerateStaticCompletionsForCommand(commandNameList, command, writer);
        GenerateDynamicCompletionsForOptions(commandNameList, command.Options, writer);
        GenerateDynamicCompletionsForArguments(commandNameList, command.Arguments, writer);
 
        foreach (var subcommand in command.Subcommands)
        {
            if (subcommand.Hidden)
            {
                continue;
            }
            GenerateSubcommandCompletions(commandNameList, writer, subcommand);
        }
 
    }
 
    /// <summary>
    /// Generate completions for the statically-known options, arguments, and subcommands of a given command.
    /// </summary>
    private static void GenerateStaticCompletionsForCommand(string[] commandPath, Command command, IndentedTextWriter writer)
    {
        List<string> completions = new();
 
        foreach (var option in command.HierarchicalOptions())
        {
            completions.AddRange(GenerateOptionNameCompletions(option));
        }
 
        foreach (var argument in command.Arguments)
        {
            completions.AddRange(GenerateArgumentCompletions(argument));
        }
 
        foreach (var subcommand in command.Subcommands)
        {
            if (subcommand.Hidden)
            {
                continue;
            }
            var longName = subcommand.Name;
            var description = SanitizeHelpDescription(subcommand);
            foreach (var subcommandName in subcommand.Names())
            {
                completions.Add(ParameterValueResult(subcommandName, longName, description));
            }
        }
 
        writer.WriteLine($"'{string.Join(";", commandPath)}' {{");
        writer.Indent++;
        writer.WriteLine("$staticCompletions = @(");
        writer.Indent++;
        foreach (var completion in completions)
        {
            writer.WriteLine(completion);
        }
        writer.Indent--;
        writer.WriteLine(")");
 
        writer.WriteLine("$completions += $staticCompletions");
 
        if (command.Arguments.Any(argument => argument.IsDynamic()))
        {
            GenerateDynamicCompletionsCall(writer);
        }
 
        writer.WriteLine("break");
        writer.Indent--;
        writer.WriteLine("}");
    }
 
    /// <summary>
    /// Generate a call into `dotnet complete` for dynamic argument completions, then binds the returned values as CompletionResults.
    /// </summary>
    /// <remarks>TODO: this is currently bound to the .NET CLI's 'dotnet complete' command - this should be definable/injectable per-host instead.</remarks>
    private static void GenerateDynamicCompletionsCall(IndentedTextWriter writer)
    {
        writer.WriteLine("$text = $commandAst.ToString()");
        writer.WriteLine("$dotnetCompleteResults = @(dotnet complete --position $cursorPosition \"$text\") | Where-Object { $_ -NotMatch \"^-|^/\" }");
        writer.WriteLine("$dynamicCompletions = $dotnetCompleteResults | Foreach-Object { [CompletionResult]::new($_, $_, [CompletionResultType]::ParameterValue, $_) }");
        writer.WriteLine("$completions += $dynamicCompletions");
    }
 
 
    private static void GenerateDynamicCompletionsForArguments(string[] commandNameList, IList<Argument> arguments, IndentedTextWriter writer)
    {
 
    }
    private static void GenerateDynamicCompletionsForOptions(string[] commandNameList, IList<Option> options, IndentedTextWriter writer)
    {
 
    }
}