File: Completions\CompletionInvocation.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.CommandLine;
using System.CommandLine.Completions;
using System.CommandLine.Parsing;
using System.Globalization;
using Aspire.Cli.Resources;
using RootCommand = Aspire.Cli.Commands.RootCommand;
 
namespace Aspire.Cli.Completions;
 
internal static class CompletionInvocation
{
    internal static bool IsSuggestionRequest(string[] args)
        => args.Length > 0 && args[0].StartsWith("[suggest", StringComparison.Ordinal);
 
    internal static bool Matches(string[] args)
    {
        if (IsSuggestionRequest(args))
        {
            return true;
        }
 
        if (!args.Contains("completions", StringComparer.Ordinal))
        {
            return false;
        }
 
        // Recognize the command before startup, including global options placed before it.
        // Use the actual option metadata so values such as "--log-file completions" are
        // not mistaken for a request to generate a script.
        for (var i = 0; i < args.Length; i++)
        {
            var token = args[i];
            var separator = token.IndexOfAny(['=', ':']);
            var name = separator < 0 ? token : token[..separator];
            if (name is CommonOptionNames.Version or CommonOptionNames.VersionShort or
                CommonOptionNames.Help or CommonOptionNames.HelpShort or CommonOptionNames.HelpAlt or
                CommonOptionNames.HelpSlash or CommonOptionNames.HelpAltSlash)
            {
                continue;
            }
 
            if (!token.StartsWith('-'))
            {
                return token == "completions";
            }
 
            // This option is normally registered only by extension hosts, but it must
            // still select the completion path before any extension connection is made.
            var option = RootCommand.GlobalOptions.Append(RootCommand.StartDebugSessionOption)
                .FirstOrDefault(o => o.Name == name || o.Aliases.Contains(name));
            if (option is null)
            {
                return false;
            }
 
            if (separator < 0 && option.Arity.MaximumNumberOfValues > 0 &&
                (option is not Option<bool> || i + 1 < args.Length && bool.TryParse(args[i + 1], out _)))
            {
                i++;
            }
        }
 
        return false;
    }
 
    internal static int WriteSuggestions(System.CommandLine.RootCommand command, string[] args, TextWriter output, TextWriter error)
    {
        if (args is ["[suggest:tokens]", .. var tokens])
        {
            // Native shell adapters send argv directly, e.g. ["run", "--project",
            // "My App.csproj", ""]. Preserve spaces, literal quotes, and a final empty
            // token (the start of a new word) instead of rebuilding a raw command line.
            return WriteCompletions(GetTokenCompletions(command.Parse(tokens)), output);
        }
 
        // Shell hooks send: [suggest] "aspire run --apph", or [suggest:17] "aspire run --apph".
        // The line is data, never an action to invoke (even if it includes --help, --banner,
        // a debugger option, another directive, or a complete executable command).
        var position = args.Length == 2 ? args[1].Length : 0;
        if (args.Length != 2 ||
            (args[0] != "[suggest]" &&
                !(args[0].StartsWith("[suggest:", StringComparison.Ordinal) &&
                  args[0].EndsWith(']') &&
                  int.TryParse(args[0].AsSpan(9, args[0].Length - 10), NumberStyles.None, CultureInfo.InvariantCulture, out position))) ||
            position < 0 || position > args[1].Length)
        {
            error.WriteLine(RootCommandStrings.InvalidCompletionRequest);
            return CliExitCodes.InvalidCommand;
        }
 
        return WriteCompletions(command.Parse(args[1]).GetCompletions(position), output);
    }
 
    private static IEnumerable<CompletionItem> GetTokenCompletions(ParseResult parseResult)
    {
        // System.CommandLine 2.0's argv completion path stops suggesting option values once
        // their arity is filled, even when that final token is still being completed.
        // Resolve the owning option explicitly (including recursive options on ancestors).
        // https://github.com/dotnet/command-line-api/blob/v2.0.8/src/System.CommandLine/ParseResult.cs
        if (parseResult.Tokens.Count > 0 && parseResult.Tokens[^1] is { Type: TokenType.Argument } lastToken)
        {
            for (var commandResult = parseResult.CommandResult; commandResult is not null; commandResult = commandResult.Parent as System.CommandLine.Parsing.CommandResult)
            {
                foreach (var optionResult in commandResult.Children.OfType<OptionResult>())
                {
                    if (optionResult.Tokens.Contains(lastToken))
                    {
                        return optionResult.Option.GetCompletions(parseResult.GetCompletionContext());
                    }
                }
            }
        }
 
        return parseResult.GetCompletions();
    }
 
    private static int WriteCompletions(IEnumerable<CompletionItem> completions, TextWriter output)
    {
        foreach (var completion in completions)
        {
            // Git Bash also queries the Windows binary; CRLF would leave a literal '\r'
            // in each candidate when Bash reads the newline-delimited protocol.
            output.Write(completion.Label);
            output.Write('\n');
        }
 
        return CliExitCodes.Success;
    }
}