File: CommandLine\CommandLineOptions.cs
Web Access
Project: src\src\sdk\src\Dotnet.Watch\dotnet-watch\dotnet-watch.csproj (dotnet-watch)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Immutable;
using System.CommandLine;
using System.CommandLine.Parsing;
using System.Data;
using Microsoft.Build.Logging;
using Microsoft.DotNet.Cli;
using Microsoft.DotNet.Cli.CommandLine;
using Microsoft.DotNet.Cli.Commands;
using Microsoft.DotNet.Cli.Commands.Build;
using Microsoft.DotNet.Cli.Commands.MSBuild;
using Microsoft.DotNet.Cli.Commands.Run;
using Microsoft.DotNet.Cli.Commands.Test;
using Microsoft.Extensions.Logging;

namespace Microsoft.DotNet.Watch;

internal sealed class CommandLineOptions
{
    private static readonly ImmutableArray<string> s_binaryLogOptionNames = ["-bl", "/bl", "-binaryLogger", "--binaryLogger", "/binaryLogger"];

    public static readonly ParserConfiguration ParserConfiguration = new()
    {
        // To match dotnet command line parsing (see https://github.com/dotnet/sdk/blob/4712b35b94f2ad672e69ec35097cf86fc16c2e5e/src/Cli/dotnet/Parser.cs#L169):
        EnablePosixBundling = false,
    };

    public bool List { get; init; }
    public required GlobalOptions GlobalOptions { get; init; }

    public string? FilePath { get; init; }
    public string? ProjectPath { get; init; }
    public string? TargetFramework { get; init; }
    public string? Device { get; init; }
    public Optional<string?> LaunchProfileName { get; init; }

    /// <summary>
    /// Arguments passed to <see cref="Command"/>.
    /// </summary>
    public required IReadOnlyList<string> CommandArguments { get; init; }

    /// <summary>
    /// <see cref="CommandArguments"/> excluding binlog options. Workaround for https://github.com/dotnet/sdk/issues/49989.
    /// </summary>
    public required IReadOnlyList<string> CommandArgumentsWithoutBinLog { get; init; }

    /// <summary>
    /// Arguments passed to `dotnet build` and to design-time build evaluation.
    /// </summary>
    public required IReadOnlyList<string> BuildArguments { get; init; }

    public required Command Command { get; init; }

    public required bool IsExplicitCommand { get; init; }

    public static CommandLineOptions? Parse(IReadOnlyList<string> args, ILogger logger, TextWriter output, out int errorCode)
    {
        var definition = new DotnetWatchCommandDefinition();

        // We parse the command line outside of the action since the action
        // might not be invoked in presence of unmatched tokens.
        // We just need to know if the root command was invoked to handle --help.
        var rootCommandInvoked = false;
        definition.SetAction(parseResult => rootCommandInvoked = true);

        // parse without forwarded options first:
        var parseResult = definition.Parse(args, ParserConfiguration);
        if (ReportErrors(parseResult, logger))
        {
            errorCode = 1;
            return null;
        }

        // determine subcommand:
        var command = GetSubcommand(parseResult, out bool isExplicitCommand);

        // Options that the subcommand forwards to build command.
        // Exclude --framework option as it is passed to `dotnet build` and `dotnet run` explicitly by the watcher.
        var buildOptions = command.Options.Where(o => o.ForwardingFunction is not null && o.Name != CommonOptions.FrameworkOptionName);

        foreach (var buildOption in buildOptions)
        {
            definition.Options.Add(buildOption);
        }

        // reparse with forwarded options:
        parseResult = definition.Parse(args, ParserConfiguration);
        if (ReportErrors(parseResult, logger))
        {
            errorCode = 1;
            return null;
        }

        // invoke to execute default actions for displaying help
        errorCode = parseResult.Invoke(new()
        {
            Output = output,
            Error = output
        });

        if (!rootCommandInvoked)
        {
            // help displayed:
            return null;
        }

        var projectValue = parseResult.GetValue(definition.LongProjectOption);
        if (string.IsNullOrEmpty(projectValue))
        {
            var projectShortValue = parseResult.GetValue(definition.ShortProjectOption);
            if (!string.IsNullOrEmpty(projectShortValue))
            {
                logger.LogWarning(Resources.Warning_ProjectAbbreviationDeprecated);
                projectValue = projectShortValue;
            }
        }

        var commandArguments = GetCommandArguments(
            parseResult,
            command,
            isExplicitCommand,
            out var binLogToken,
            out var binLogPath,
            out var commandArgumentsWithoutBinLog);

        // We assume that forwarded options, if any, are intended for `dotnet build`.
        // Exclude --target option since we need to control the targets being built.
        var msbuildCommandDefinition = new MSBuildCommandDefinition();

        var buildArguments = buildOptions
            .Select(option => option.ForwardingFunction!(parseResult))
            .SelectMany(args => args)
            .Where(arg => !msbuildCommandDefinition.Parse(arg).HasOption(msbuildCommandDefinition.TargetOption))
            .ToList();

        if (binLogToken != null)
        {
            buildArguments.Add(binLogToken);
        }

        var logLevel = parseResult.GetValue(definition.VerboseOption)
            ? LogLevel.Debug
            : parseResult.GetValue(definition.QuietOption)
            ? LogLevel.Warning
            : LogLevel.Information;

        var launchProfile = parseResult.GetValue(definition.NoLaunchProfileOption)
            ? Optional<string?>.NoValue
            : parseResult.GetValue(definition.LaunchProfileOption);

        return new()
        {
            List = parseResult.GetValue(definition.ListOption),
            GlobalOptions = new()
            {
                LogLevel = logLevel,
                NoHotReload = parseResult.GetValue(definition.NoHotReloadOption),
                NonInteractive = parseResult.GetValue(definition.NonInteractiveOption),
                BinaryLogPath = ParseBinaryLogFilePath(binLogPath, logger),
            },

            CommandArguments = commandArguments,
            CommandArgumentsWithoutBinLog = commandArgumentsWithoutBinLog,
            Command = command,
            IsExplicitCommand = isExplicitCommand,

            ProjectPath = projectValue,
            FilePath = parseResult.GetValue(definition.FileOption),
            LaunchProfileName = launchProfile,
            BuildArguments = buildArguments,
            TargetFramework = parseResult.GetValue(definition.FrameworkOption),
            Device = parseResult.GetValue(definition.DeviceOption),
        };
    }

    /// <summary>
    /// Parses the value of msbuild option `-binaryLogger`.
    /// </summary>
    internal static string? ParseBinaryLogFilePath(string? value, ILogger logger)
    {
        try
        {
            return value != null ? BinaryLogger.ParseParameters(value).LogFilePath ?? "msbuild.binlog" : null;
        }
        catch (Build.Framework.LoggerException e)
        {
            // MSB4234: Invalid binary logger parameter(s): "{0}". Expected: ProjectImports={{None,Embed,ZipFile}} and/or [LogFile=]filePath.binlog (the log file name or path, must have the ".binlog" extension)
            logger.LogError(e.Message);
            return null;
        }
    }

    private static IReadOnlyList<string> GetCommandArguments(
        ParseResult parseResult,
        Command command,
        bool isExplicitCommand,
        out string? binLogToken,
        out string? binLogPath,
        out IReadOnlyList<string> argumentsWithoutBinLog)
    {
        var definition = (DotnetWatchCommandDefinition)parseResult.CommandResult.Command;

        var arguments = new List<string>();
        binLogToken = null;
        binLogPath = null;

        foreach (var child in parseResult.CommandResult.Children)
        {
            var optionResult = (OptionResult)child;

            // skip watch specific option:
            if (definition.IsWatchOption(optionResult.Option))
            {
                continue;
            }

            // forward forwardable option if the subcommand supports it:
            if (!command.Options.Any(option => option.Name == optionResult.Option.Name))
            {
                // pass project as an argument to commands that do not have --project option but accept project as an argument:
                if (command is BuildCommandDefinition or TestCommandDefinition.VSTest &&
                    (optionResult.Option == definition.ShortProjectOption || optionResult.Option == definition.LongProjectOption) &&
                    parseResult.GetValue((Option<string>)optionResult.Option) is { } projectPath)
                {
                    arguments.Add(projectPath);
                }

                continue;
            }

            // skip forwarding the interactive token (which may be computed by default) when users pass --non-interactive to watch itself
            if (optionResult.Option == definition.NonInteractiveOption && parseResult.GetValue(definition.NonInteractiveOption))
            {
                continue;
            }

            // skip Option<bool> zero-arity options with an implicit optionresult - these weren't actually specified by the user:
            if (optionResult.Option is Option<bool> boolOpt && boolOpt.Arity.Equals(ArgumentArity.Zero) && optionResult.Implicit)
            {
                continue;
            }

            var optionNameToForward = GetOptionNameToForward(optionResult);
            if (optionResult.Tokens.Count == 0 && !optionResult.Implicit)
            {
                arguments.Add(optionNameToForward);
            }
            else
            {
                foreach (var token in optionResult.Tokens)
                {
                    arguments.Add(optionNameToForward);
                    arguments.Add(token.Value);
                }
            }
        }

        // Assuming that all tokens after "--" are unmatched:
        var dashDashIndex = IndexOf(parseResult.Tokens, t => t.Value == "--");
        var unmatchedTokensBeforeDashDash = parseResult.UnmatchedTokens.Count - (dashDashIndex >= 0 ? parseResult.Tokens.Count - dashDashIndex - 1 : 0);

        var seenCommand = false;
        var dashDashInserted = false;


        var argumentsWithoutBinLogBuilder = new List<string>();
        argumentsWithoutBinLogBuilder.AddRange(arguments);

        for (int i = 0; i < parseResult.UnmatchedTokens.Count; i++)
        {
            var token = parseResult.UnmatchedTokens[i];
            var isBinLogToken = false;

            if (i < unmatchedTokensBeforeDashDash)
            {
                if (!seenCommand && isExplicitCommand && token == command.Name)
                {
                    seenCommand = true;
                    continue;
                }

                // Workaround: commands do not have forwarding option for -bl
                // https://github.com/dotnet/sdk/issues/49989
                foreach (var name in s_binaryLogOptionNames)
                {
                    if (token.StartsWith(name, StringComparison.OrdinalIgnoreCase))
                    {
                        if (token.Length == name.Length)
                        {
                            binLogToken = token;
                            binLogPath = "";
                        }
                        else if (token.Length > name.Length + 1 && token[name.Length] == ':')
                        {
                            binLogToken = token;
                            binLogPath = token[(name.Length + 1)..];
                        }

                        isBinLogToken = true;
                        break;
                    }
                }
            }

            if (!dashDashInserted && i >= unmatchedTokensBeforeDashDash)
            {
                arguments.Add("--");
                argumentsWithoutBinLogBuilder.Add("--");
                dashDashInserted = true;
            }

            arguments.Add(token);

            if (!isBinLogToken)
            {
                argumentsWithoutBinLogBuilder.Add(token);
            }
        }

        argumentsWithoutBinLog = argumentsWithoutBinLogBuilder;
        return arguments;
    }

    private static string GetOptionNameToForward(OptionResult optionResult)
        // Some options _may_ be computed or have defaults, so not all may have an IdentifierToken.
        // For those that do not, use the Option's Name instead.
        => optionResult.IdentifierToken?.Value ?? optionResult.Option.Name;

    private static Command GetSubcommand(ParseResult parseResult, out bool isExplicit)
    {
        // Assuming that all tokens after "--" are unmatched:
        var dashDashIndex = IndexOf(parseResult.Tokens, t => t.Value == "--");
        var unmatchedTokensBeforeDashDash = parseResult.UnmatchedTokens.Count - (dashDashIndex >= 0 ? parseResult.Tokens.Count - dashDashIndex - 1 : 0);

        var dotnetDefinition = new DotNetCommandDefinition();
        var knownCommandsByName = dotnetDefinition.Subcommands.ToDictionary(keySelector: c => c.Name, elementSelector: c => c);

        for (int i = 0; i < unmatchedTokensBeforeDashDash; i++)
        {
            // command token can't follow "--"
            if (knownCommandsByName.TryGetValue(parseResult.UnmatchedTokens[i], out var explicitCommand))
            {
                isExplicit = true;
                return explicitCommand;
            }
        }

        isExplicit = false;
        return dotnetDefinition.RunCommand;
    }

    private static bool ReportErrors(ParseResult parseResult, ILogger logger)
    {
        if (parseResult.Errors.Any())
        {
            foreach (var error in parseResult.Errors)
            {
                logger.LogError(error.Message);
            }

            return true;
        }

        return false;
    }

    private static int IndexOf<T>(IReadOnlyList<T> list, Func<T, bool> predicate)
    {
        for (var i = 0; i < list.Count; i++)
        {
            if (predicate(list[i]))
            {
                return i;
            }
        }

        return -1;
    }

    public ProjectOptions GetMainProjectOptions(ProjectRepresentation project, string workingDirectory)
        => new()
        {
            IsMainProject = true,
            Representation = project,
            WorkingDirectory = workingDirectory,
            TargetFramework = TargetFramework,
            Device = Device,
            Command = Command.Name,
            CommandArguments = CommandArguments,
            LaunchEnvironmentVariables = [],
            LaunchProfileName = LaunchProfileName,
        };
}