File: CommandLine\CommandLineOptions.cs
Web Access
Project: ..\..\..\src\BuiltInTools\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 System.Diagnostics;
using Microsoft.Build.Logging;
using Microsoft.DotNet.Cli;
using Microsoft.DotNet.Cli.Commands.Run;
using Microsoft.DotNet.Cli.Extensions;
using Microsoft.Extensions.Logging;
 
namespace Microsoft.DotNet.Watch;
 
internal sealed class CommandLineOptions
{
    public const string DefaultCommand = "run";
 
    private static readonly ImmutableArray<string> s_binaryLogOptionNames = ["-bl", "/bl", "-binaryLogger", "--binaryLogger", "/binaryLogger"];
 
    public bool List { get; init; }
    public required GlobalOptions GlobalOptions { get; init; }
 
    public string? ProjectPath { get; init; }
    public string? TargetFramework { get; init; }
    public bool NoLaunchProfile { get; init; }
    public string? LaunchProfileName { get; init; }
 
    /// <summary>
    /// Arguments passed to <see cref="Command"/>.
    /// </summary>
    public required IReadOnlyList<string> CommandArguments { get; init; }
 
    /// <summary>
    /// Arguments passed to `dotnet build` and to design-time build evaluation.
    /// </summary>
    public required IReadOnlyList<string> BuildArguments { get; init; }
 
    public string? ExplicitCommand { get; init; }
 
    public string Command => ExplicitCommand ?? DefaultCommand;
 
    // this option is referenced from inner logic and so needs to be reference-able
    public static Option<bool> NonInteractiveOption = new Option<bool>("--non-interactive") { Description = Resources.Help_NonInteractive, Arity = ArgumentArity.Zero };
 
    public static CommandLineOptions? Parse(IReadOnlyList<string> args, ILogger logger, TextWriter output, out int errorCode)
    {
        // dotnet watch specific options:
        var quietOption = new Option<bool>("--quiet", "-q") { Description = Resources.Help_Quiet, Arity = ArgumentArity.Zero };
        var verboseOption = new Option<bool>("--verbose") { Description = Resources.Help_Verbose, Arity = ArgumentArity.Zero };
        var listOption = new Option<bool>("--list") { Description = Resources.Help_List, Arity = ArgumentArity.Zero };
        var noHotReloadOption = new Option<bool>("--no-hot-reload") { Description = Resources.Help_NoHotReload, Arity = ArgumentArity.Zero };
 
        verboseOption.Validators.Add(v =>
        {
            if (v.GetValue(quietOption) && v.GetValue(verboseOption))
            {
                v.AddError(Resources.Error_QuietAndVerboseSpecified);
            }
        });
 
        Option[] watchOptions =
        [
            quietOption,
            verboseOption,
            listOption,
            noHotReloadOption,
            NonInteractiveOption
        ];
 
        // Options we need to know about that are passed through to the subcommand:
        var shortProjectOption = new Option<string>("-p") { Hidden = true, Arity = ArgumentArity.ZeroOrOne, AllowMultipleArgumentsPerToken = false };
        var longProjectOption = new Option<string>("--project") { Hidden = true, Arity = ArgumentArity.ZeroOrOne, AllowMultipleArgumentsPerToken = false };
        var launchProfileOption = new Option<string>("--launch-profile", "-lp") { Hidden = true, Arity = ArgumentArity.ZeroOrOne, AllowMultipleArgumentsPerToken = false };
        var noLaunchProfileOption = new Option<bool>("--no-launch-profile") { Hidden = true, Arity = ArgumentArity.Zero };
 
        var rootCommand = new RootCommand(Resources.Help)
        {
            Directives = { new EnvironmentVariablesDirective() },
        };
 
        foreach (var watchOption in watchOptions)
        {
            rootCommand.Options.Add(watchOption);
        }
 
        rootCommand.Options.Add(longProjectOption);
        rootCommand.Options.Add(shortProjectOption);
        rootCommand.Options.Add(launchProfileOption);
        rootCommand.Options.Add(noLaunchProfileOption);
 
        // We process all tokens that do not match any of the above options
        // to find the subcommand (the first unmatched token preceding "--")
        // and all its options and arguments.
        rootCommand.TreatUnmatchedTokensAsErrors = false;
 
        // 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;
        rootCommand.SetAction(parseResult => rootCommandInvoked = true);
 
        ParserConfiguration parseConfig = new()
        {
            // To match dotnet command line parsing (see https://github.com/dotnet/sdk/blob/4712b35b94f2ad672e69ec35097cf86fc16c2e5e/src/Cli/dotnet/Parser.cs#L169):
            EnablePosixBundling = false,
        };
 
        // parse without forwarded options first:
        var parseResult = rootCommand.Parse(args, parseConfig);
        if (ReportErrors(parseResult, logger))
        {
            errorCode = 1;
            return null;
        }
 
        // determine subcommand:
        var explicitCommand = TryGetSubcommand(parseResult);
        var command = explicitCommand ?? RunCommandParser.GetCommand();
        var buildOptions = command.Options.Where(o => o is IForwardedOption);
 
        foreach (var buildOption in buildOptions)
        {
            rootCommand.Options.Add(buildOption);
        }
 
        // reparse with forwarded options:
        parseResult = rootCommand.Parse(args, parseConfig);
        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(longProjectOption);
        if (string.IsNullOrEmpty(projectValue))
        {
            var projectShortValue = parseResult.GetValue(shortProjectOption);
            if (!string.IsNullOrEmpty(projectShortValue))
            {
                logger.LogWarning(Resources.Warning_ProjectAbbreviationDeprecated);
                projectValue = projectShortValue;
            }
        }
 
        var commandArguments = GetCommandArguments(parseResult, watchOptions, explicitCommand, out var binLogToken, out var binLogPath);
 
        // We assume that forwarded options, if any, are intended for dotnet build.
        var buildArguments = buildOptions.Select(option => ((IForwardedOption)option).GetForwardingFunction()(parseResult)).SelectMany(args => args).ToList();
 
        if (binLogToken != null)
        {
            buildArguments.Add(binLogToken);
        }
 
        var targetFrameworkOption = (Option<string>?)buildOptions.SingleOrDefault(option => option.Name == "--framework");
 
        return new()
        {
            List = parseResult.GetValue(listOption),
            GlobalOptions = new()
            {
                Quiet = parseResult.GetValue(quietOption),
                NoHotReload = parseResult.GetValue(noHotReloadOption),
                NonInteractive = parseResult.GetValue(NonInteractiveOption),
                Verbose = parseResult.GetValue(verboseOption),
                BinaryLogPath = ParseBinaryLogFilePath(binLogPath),
            },
 
            CommandArguments = commandArguments,
            ExplicitCommand = explicitCommand?.Name,
 
            ProjectPath = projectValue,
            LaunchProfileName = parseResult.GetValue(launchProfileOption),
            NoLaunchProfile = parseResult.GetValue(noLaunchProfileOption),
            BuildArguments = buildArguments,
            TargetFramework = targetFrameworkOption != null ? parseResult.GetValue(targetFrameworkOption) : null,
        };
    }
 
    /// <summary>
    /// Parses the value of msbuild option `-binaryLogger[:[LogFile=]output.binlog[;ProjectImports={None,Embed,ZipFile}]]`.
    /// Emulates https://github.com/dotnet/msbuild/blob/7f69ea906c29f2478cc05423484ad185de66e124/src/Build/Logging/BinaryLogger/BinaryLogger.cs#L481.
    /// See https://github.com/dotnet/msbuild/issues/12256
    /// </summary>
    internal static string? ParseBinaryLogFilePath(string? value)
        => value switch
        {
            null => null,
            _ => (from parameter in value.Split(';', StringSplitOptions.RemoveEmptyEntries)
                  where !string.Equals(parameter, "ProjectImports=None", StringComparison.OrdinalIgnoreCase) &&
                        !string.Equals(parameter, "ProjectImports=Embed", StringComparison.OrdinalIgnoreCase) &&
                        !string.Equals(parameter, "ProjectImports=ZipFile", StringComparison.OrdinalIgnoreCase) &&
                        !string.Equals(parameter, "OmitInitialInfo", StringComparison.OrdinalIgnoreCase)
                  let path = (parameter.StartsWith("LogFile=", StringComparison.OrdinalIgnoreCase) ? parameter["LogFile=".Length..] : parameter).Trim('"')
                  let pathWithExtension = path.EndsWith(".binlog", StringComparison.OrdinalIgnoreCase) ? path : $"{path}.binlog"
                  select pathWithExtension)
                 .LastOrDefault("msbuild.binlog")
        };
 
    private static IReadOnlyList<string> GetCommandArguments(
        ParseResult parseResult,
        IReadOnlyList<Option> watchOptions,
        Command? explicitCommand,
        out string? binLogToken,
        out string? binLogPath)
    {
        var arguments = new List<string>();
        binLogToken = null;
        binLogPath = null;
 
        foreach (var child in parseResult.CommandResult.Children)
        {
            var optionResult = (OptionResult)child;
 
            // skip watch options:
            if (!watchOptions.Contains(optionResult.Option))
            {
                if (optionResult.Option.Name.Equals("--interactive", StringComparison.Ordinal) && parseResult.GetValue(NonInteractiveOption))
                {
                    // skip forwarding the interactive token (which may be computed by default) when users pass --non-interactive to watch itself
                    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;
 
        for (int i = 0; i < parseResult.UnmatchedTokens.Count; i++)
        {
            var token = parseResult.UnmatchedTokens[i];
 
            if (i < unmatchedTokensBeforeDashDash)
            {
                if (!seenCommand && token == explicitCommand?.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)..];
                        }
                    }
                }
            }
 
            if (!dashDashInserted && i >= unmatchedTokensBeforeDashDash)
            {
                arguments.Add("--");
                dashDashInserted = true;
            }
 
            arguments.Add(token);
        }
 
        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? TryGetSubcommand(ParseResult parseResult)
    {
        // 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 knownCommandsByName = Parser.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))
            {
                return explicitCommand;
            }
        }
 
        return null;
    }
 
    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 GetProjectOptions(string projectPath, string workingDirectory)
        => new()
        {
            IsRootProject = true,
            ProjectPath = projectPath,
            WorkingDirectory = workingDirectory,
            Command = Command,
            CommandArguments = CommandArguments,
            LaunchEnvironmentVariables = [],
            LaunchProfileName = LaunchProfileName,
            NoLaunchProfile = NoLaunchProfile,
            BuildArguments = BuildArguments,
            TargetFramework = TargetFramework,
        };
 
    // Parses name=value pairs passed to --property. Skips invalid input.
    public static IEnumerable<(string key, string value)> ParseBuildProperties(IEnumerable<string> arguments)
        => from argument in arguments
           let colon = argument.IndexOf(':')
           where colon >= 0 && argument[0..colon] is "--property" or "-property" or "/property" or "/p" or "-p" or "--p"
           let eq = argument.IndexOf('=', colon)
           where eq >= 0
           let name = argument[(colon + 1)..eq].Trim()
           let value = argument[(eq + 1)..]
           where name is not []
           select (name, value);
 
    /// <summary>
    /// Returns true if the command executes the code of the target project.
    /// </summary>
    public static bool IsCodeExecutionCommand(string commandName)
        => commandName is "run" or "test";
}