File: Extensions\ParseResultExtensions.cs
Web Access
Project: src\src\sdk\src\Cli\dotnet\dotnet.csproj (dotnet)
// 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.Parsing;
using System.Diagnostics;
using System.Text.RegularExpressions;
using Microsoft.DotNet.Cli.CommandLine;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.DotNet.Cli.Utils.Extensions;
using CommandResult = System.CommandLine.Parsing.CommandResult;

namespace Microsoft.DotNet.Cli.Extensions;

public static class ParseResultExtensions
{
    /// <summary>
    /// Finds the command of the parse result and invokes help for that command.
    /// If no command is specified, invokes help for the application.
    /// </summary>
    /// <remarks>
    /// This is accomplished by finding a set of tokens that should be valid and appending a help token
    /// to that list, then re-parsing the list of tokens. This is not ideal - either we should have a direct way
    /// of invoking help for a ParseResult, or we should eliminate this custom, ad-hoc help invocation by moving
    /// more situations that want to show help into Parsing Errors (which trigger help in the default System.CommandLine pipeline)
    /// or custom Invocation Middleware, so we can more easily create our version of a HelpResult type.
    /// </remarks>
    public static void ShowHelp(this ParseResult parseResult)
    {
        // Take from the start of the list until we hit an option/--/unparsed token.
        // Since commands can have arguments, we must take those as well in order to get accurate help.
        var filteredTokenValues = parseResult.Tokens.TakeWhile(token =>
            token.Type == TokenType.Argument
                || token.Type == TokenType.Command
                || token.Type == TokenType.Directive)
            .Select(t => t.Value);
        Parser.Parse([.. filteredTokenValues, "-h"]).Invoke();
    }

    public static void ShowHelpOrErrorIfAppropriate(this ParseResult parseResult)
    {
        if (parseResult.Errors.Any())
        {
            var unrecognizedTokenErrors = parseResult.Errors.Where(error =>
            {
                // Can't really cache this access in a static or something because it implicitly depends on the environment.
                var rawResourcePartsForThisLocale = DistinctFormatStringParts(CliStrings.UnrecognizedCommandOrArgument);
                return ErrorContainsAllParts(error.Message, rawResourcePartsForThisLocale);
            });

            if (parseResult.CommandResult.Command.TreatUnmatchedTokensAsErrors
                || parseResult.Errors.Except(unrecognizedTokenErrors).Any())
            {
                throw new CommandParsingException(
                    message: string.Join(Environment.NewLine, parseResult.Errors.Select(e => e.Message)),
                    parseResult: parseResult);
            }
        }

        /// <summary>
        /// Splits a .NET format string by the format placeholders (the {N} parts) to get an array of the literal parts, to be used in message-checking.
        /// </summary>
        static string[] DistinctFormatStringParts(string formatString) =>
            // Match the literal '{', followed by any of 0-9 one or more times, followed by the literal '}'.
            Regex.Split(formatString, @"{[0-9]+}");

        /// <summary>
        /// Given a string and a series of parts, ensures that all parts are present in the string in sequential order.
        /// </summary>
        static bool ErrorContainsAllParts(ReadOnlySpan<char> error, string[] parts)
        {
            foreach (var part in parts)
            {
                var foundIndex = error.IndexOf(part);
                if (foundIndex != -1)
                {
                    error = error.Slice(foundIndex + part.Length);
                    continue;
                }

                return false;
            }

            return true;
        }
    }

    public static string RootSubCommandResult(this ParseResult parseResult) => parseResult.RootCommandResult.Children?
        .Select(child => parseResult.GetSymbolResultValue(child))
        .FirstOrDefault(subcommand => !string.IsNullOrEmpty(subcommand)) ?? string.Empty;

    public static bool IsDotnetBuiltInCommand(this ParseResult parseResult) =>
        string.IsNullOrEmpty(parseResult.RootSubCommandResult())
        || Parser.GetBuiltInCommand(parseResult.RootSubCommandResult()) != null;

    public static bool IsTopLevelDotnetCommand(this ParseResult parseResult) =>
        parseResult.CommandResult.Command.Equals(Parser.RootCommand) && string.IsNullOrEmpty(parseResult.RootSubCommandResult());

    public static bool CanBeInvoked(this ParseResult parseResult) =>
        Parser.GetBuiltInCommand(parseResult.RootSubCommandResult()) != null
        || parseResult.Tokens.Any(token => token.Type == TokenType.Directive)
        || (parseResult.IsTopLevelDotnetCommand() && string.IsNullOrEmpty(parseResult.GetValue(Parser.RootCommand.DotnetSubCommand)));

    public static int HandleMissingCommand(this ParseResult parseResult)
    {
        Reporter.Error.WriteLine(CliStrings.RequiredCommandNotPassed.Red());
        parseResult.ShowHelp();
        return 1;
    }

    public static string[] GetArguments(this ParseResult parseResult) =>
        parseResult.Tokens.Select(t => t.Value).ToArray().GetSubArguments();

    public static string[] GetSubArguments(this string[] args)
    {
        var subargs = args.ToList();

        // Don't remove any arguments that are being passed to the app in dotnet run
        var dashDashIndex = subargs.IndexOf("--");

        var runArgs = dashDashIndex > -1 ? subargs.GetRange(dashDashIndex, subargs.Count() - dashDashIndex) : [];
        subargs = dashDashIndex > -1 ? subargs.GetRange(0, dashDashIndex) : subargs;

        // Remove top level command (ex build or publish).
        var subargsFiltered = subargs
            .SkipWhile(arg => Parser.RootCommand.DiagOption.Name.Equals(arg)
                || Parser.RootCommand.DiagOption.Aliases.Contains(arg)
                || arg.Equals("dotnet"))
            .Skip(1);

        return [.. subargsFiltered, .. runArgs];
    }

    private static string? GetSymbolResultValue(this ParseResult parseResult, SymbolResult symbolResult) => symbolResult switch
    {
        CommandResult commandResult => commandResult.Command.Name,
        ArgumentResult argResult => argResult.Tokens.FirstOrDefault()?.Value,
        _ => parseResult.GetResult(Parser.RootCommand.DotnetSubCommand)?.GetValueOrDefault<string>()
    };

    public static IEnumerable<string>? GetRunCommandShorthandProjectValues(this ParseResult parseResult) =>
        parseResult.GetRunPropertyOptions(true)?.Where(property => !property.Contains("="));

    public static IEnumerable<string> GetRunCommandPropertyValues(this ParseResult parseResult)
    {
        var shorthandProperties = parseResult.GetRunPropertyOptions(true)?.Where(property => property.Contains("="));
        var longhandProperties = parseResult.GetRunPropertyOptions(false);
        return (shorthandProperties, longhandProperties) switch
        {
            (null, null) => Enumerable.Empty<string>(),
            (null, var longhand) => longhand,
            (var shorthand, null) => shorthand,
            (var shorthand, var longhand) => shorthand.Concat(longhand)
        };
    }

    private static IEnumerable<string>? GetRunPropertyOptions(this ParseResult parseResult, bool shorthand)
    {
        var optionString = shorthand ? "-p" : "--property";
        var propertyOptions = parseResult.CommandResult.Children.Where(c => GetOptionTokenOrDefault(c)?.Value.Equals(optionString) ?? false);
        var propertyValues = propertyOptions.SelectMany(o => o.Tokens.Select(t => t.Value)).ToArray();
        return propertyValues;

        static Token? GetOptionTokenOrDefault(SymbolResult symbolResult)
        {
            if (symbolResult is not OptionResult optionResult)
            {
                return null;
            }

            return optionResult.IdentifierToken ?? new Token($"--{optionResult.Option.Name}", TokenType.Option, optionResult.Option);
        }
    }

    [Conditional("DEBUG")]
    public static void HandleDebugSwitch(this ParseResult parseResult)
    {
        if (parseResult.HasOption(CommonOptions.DebugOption))
        {
            DebugHelper.WaitForDebugger();
        }
    }

    public static string GetCommandName(this ParseResult parseResult)
    {
        // Walk the parent command tree to find the top-level command name and get the full command name for this ParseResult.
        List<string> parentNames = [parseResult.CommandResult.Command.Name];
        var current = parseResult.CommandResult.Parent;
        while (current is CommandResult parentCommandResult)
        {
            parentNames.Add(parentCommandResult.Command.Name);
            current = parentCommandResult.Parent;
        }
        parentNames.Reverse();

        // Options that perform terminating actions are considered part of the command name as they are essentially subcommands themselves.
        // Example: dotnet --version
        if (parseResult.Action is InvocableOptionAction { Terminating: true } optionAction)
        {
            parentNames.Add(optionAction.Option.Name);
        }

        return string.Join(' ', parentNames);
    }
}