File: Common\CommonOptions.cs
Web Access
Project: src\src\sdk\src\Cli\Microsoft.DotNet.Cli.Definitions\Microsoft.DotNet.Cli.Definitions.csproj (Microsoft.DotNet.Cli.Definitions)
// 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.ObjectModel;
using System.CommandLine;
using System.CommandLine.Parsing;
using System.CommandLine.StaticCompletions;
using Microsoft.DotNet.Cli.CommandLine;
using Microsoft.DotNet.Cli.Utils;

namespace Microsoft.DotNet.Cli;

internal static class CommonOptions
{
    public static Option<bool> CreateYesOption(bool hidden = false) => new("--yes", "-y")
    {
        Description = CommandDefinitionStrings.YesOptionDescription,
        Arity = ArgumentArity.Zero,
        IsDynamic = true,
        Hidden = hidden
    };

    public static Option<ReadOnlyDictionary<string, string>?> CreatePropertyOption() =>
        // these are all of the forms that the property switch can be understood by in MSBuild
        new Option<ReadOnlyDictionary<string, string>?>("--property", "-property", "/property", "/p", "-p", "--p")
        {
            Hidden = true,
            Arity = ArgumentArity.ZeroOrMore,
            CustomParser = ParseMSBuildTokensIntoDictionary
        }.ForwardAsMSBuildProperty()
         .AllowSingleArgPerToken();

    /// <summary>
    /// Sets MSBuild Global Property values that are only used during Restore (implicit or explicit)
    /// </summary>
    /// <remarks>
    /// </remarks>
    public static Option<ReadOnlyDictionary<string, string>?> CreateRestorePropertyOption() =>
        // these are all of the forms that the property switch can be understood by in MSBuild
        new Option<ReadOnlyDictionary<string, string>?>("--restoreProperty", "-restoreProperty", "/restoreProperty", "-rp", "--rp", "/rp")
        {
            Hidden = true,
            Arity = ArgumentArity.ZeroOrMore,
            CustomParser = ParseMSBuildTokensIntoDictionary
        }
        .ForwardAsMSBuildProperty()
        .AllowSingleArgPerToken();

    private static ReadOnlyDictionary<string, string>? ParseMSBuildTokensIntoDictionary(ArgumentResult result)
    {
        if (result.Tokens.Count == 0)
        {
            return null;
        }
        var dictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        foreach (var token in result.Tokens)
        {
            foreach (var kvp in MSBuildPropertyParser.ParseProperties(token.Value))
            {
                // msbuild properties explicitly have the semantic of being 'overwrite' so we do not check for duplicates
                // and just overwrite the value if it already exists.
                dictionary[kvp.key] = kvp.value;
            }
        }
        return new(dictionary);
    }

    public static Option<string[]?> CreateMSBuildTargetOption(string? defaultTargetName = null, (string key, string value)[]? additionalProperties = null) =>
        new Option<string[]?>("--target", "/target", "-target", "-t", "--t", "/t")
        {
            Description = "Build these targets in this project. Use a semicolon or a comma to separate multiple targets, or specify each target separately.",
            HelpName = "TARGET",
            DefaultValueFactory = _ => defaultTargetName is not null ? [defaultTargetName] : null,
            CustomParser = r => SplitMSBuildValues(defaultTargetName, r),
            Hidden = true,
            Arity = ArgumentArity.ZeroOrMore
        }
        .ForwardAsMany(targets => ForwardTargetsAndAdditionalProperties(targets, additionalProperties))
        .AllowSingleArgPerToken();

    public static Option<string[]> CreateRequiredMSBuildTargetOption(string defaultTargetName, (string key, string value)[]? additionalProperties = null) =>
        new Option<string[]>("--target", "/target", "-target", "-t", "--t", "/t")
        {
            Description = "Build these targets in this project. Use a semicolon or a comma to separate multiple targets, or specify each target separately.",
            HelpName = "TARGET",
            DefaultValueFactory = _ => [defaultTargetName],
            CustomParser = r => SplitMSBuildValues(defaultTargetName, r),
            Hidden = true,
            Arity = ArgumentArity.ZeroOrMore
        }
        // we know there will be at least one target, so we return an enumerable with at least one item
        .ForwardAsMany(targets => ForwardTargetsAndAdditionalProperties(targets, additionalProperties))
        .AllowSingleArgPerToken();

    public static IEnumerable<string> ForwardTargetsAndAdditionalProperties(string[]? targets, (string key, string value)[]? additionalProperties)
    {
        var argsToReturn = new List<string>(targets is null ? 0 : 1 + (additionalProperties?.Length ?? 0));
        if (targets is not null)
        {
            argsToReturn.Add($"--target:{string.Join(";", targets)}");
        }
        if (additionalProperties is not null)
        {
            argsToReturn.AddRange(additionalProperties.Select(p => $"--property:{p.key}={p.value}"));
        }
        return argsToReturn;
    }

    public static Option<string[]?> CreateGetPropertyOption() => MSBuildMultiOption("getProperty");

    public static Option<string[]?> CreateGetItemOption() => MSBuildMultiOption("getItem");

    public static Option<string[]?> CreateGetTargetResultOption() => MSBuildMultiOption("getTargetResult");

    public static Option<string[]?> CreateGetResultOutputFileOption() => MSBuildMultiOption("getResultOutputFile");

    private static Option<string[]?> MSBuildMultiOption(string name)
        => new Option<string[]?>($"--{name}", $"-{name}", $"/{name}")
        {
            Hidden = true,
            Arity = ArgumentArity.OneOrMore,
            CustomParser = static r => SplitMSBuildValues(null, r),
        }
        .ForwardAsMany(xs => (xs ?? []).Select(x => $"--{name}:{x}"))
        .AllowSingleArgPerToken();

    public static string[] SplitMSBuildValues(string? defaultValue, ArgumentResult argumentResult)
    {
        if (argumentResult.Tokens.Count == 0)
        {
            return defaultValue is not null ? [defaultValue] : [];
        }
        var userValues =
            argumentResult.Tokens.Select(t => t.Value)
            .SelectMany(t => t.Split([';', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
            .Where(t => !string.IsNullOrEmpty(t));
        var allValues = defaultValue is null ? userValues : [defaultValue, .. userValues];
        return allValues.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
    }

    public static Option<VerbosityOptions> CreateVerbosityOption(VerbosityOptions defaultVerbosity) =>
        new Option<VerbosityOptions>("--verbosity", "-v")
        {
            Description = CommandDefinitionStrings.VerbosityOptionDescription,
            HelpName = CommandDefinitionStrings.LevelArgumentName,
            DefaultValueFactory = _ => defaultVerbosity
        }
        .ForwardAsSingle(o => $"--verbosity:{o}")
        .AggregateRepeatedTokens();

    public static Option<VerbosityOptions?> CreateVerbosityOption() =>
        new Option<VerbosityOptions?>("--verbosity", "-v", "--v", "-verbosity", "/v", "/verbosity")
        {
            Description = CommandDefinitionStrings.VerbosityOptionDescription,
            HelpName = CommandDefinitionStrings.LevelArgumentName
        }
        .ForwardAsSingle(o => $"--verbosity:{o}")
        .AggregateRepeatedTokens();

    public static Option<VerbosityOptions> CreateHiddenVerbosityOption() =>
        new Option<VerbosityOptions>("--verbosity", "-v", "--v", "-verbosity", "/v", "/verbosity")
        {
            Description = CommandDefinitionStrings.VerbosityOptionDescription,
            HelpName = CommandDefinitionStrings.LevelArgumentName,
            Hidden = true
        }
        .ForwardAsSingle(o => $"--verbosity:{o}")
        .AggregateRepeatedTokens();

    public const string FrameworkOptionName = "--framework";

    public static Option<string> CreateFrameworkOption(string description) =>
        new Option<string>(FrameworkOptionName, "-f")
        {
            Description = description,
            HelpName = CommandDefinitionStrings.FrameworkArgumentName,
            IsDynamic = true
        }
        .ForwardAsSingle(o => $"--property:TargetFramework={o}");

    public static Option<string> CreateArtifactsPathOption() =>
        new Option<string>(
            //  --artifacts-path is pretty verbose, should we use --artifacts instead (or possibly support both)?
            "--artifacts-path")
        {
            Description = CommandDefinitionStrings.ArtifactsPathOptionDescription,
            HelpName = CommandDefinitionStrings.ArtifactsPathArgumentName
        }.ForwardAsSingle(o => $"--property:ArtifactsPath={CommandDirectoryContext.GetFullPath(o)}");

    public static Option<bool> CreateUseCurrentRuntimeOption(string description) =>
        new Option<bool>("--use-current-runtime", "--ucr")
        {
            Description = description,
            Arity = ArgumentArity.Zero
        }.ForwardAs("--property:UseCurrentRuntimeIdentifier=True");

    public const string ConfigurationOptionName = "--configuration";

    public static Option<string?> CreateConfigurationOption(string description) =>
        new Option<string?>(ConfigurationOptionName, "-c")
        {
            Description = description,
            HelpName = CommandDefinitionStrings.ConfigurationArgumentName,
            IsDynamic = true
        }.ForwardAsSingle(o => $"--property:Configuration={o}");

    public static Option<string> CreateVersionSuffixOption() =>
        new Option<string>("--version-suffix")
        {
            Description = CommandDefinitionStrings.CmdVersionSuffixDescription,
            HelpName = CommandDefinitionStrings.VersionSuffixArgumentName
        }.ForwardAsSingle(o => $"--property:VersionSuffix={o}");

    public static Lazy<string> NormalizedCurrentDirectory = new(() => PathUtilities.EnsureTrailingSlash(Directory.GetCurrentDirectory()));

    public static Argument<string> DefaultToCurrentDirectory(this Argument<string> arg)
    {
        // we set this lazily so that we don't pay the overhead of determining the
        // CWD multiple times, one for each Argument that uses this.
        arg.DefaultValueFactory = _ => NormalizedCurrentDirectory.Value;
        return arg;
    }

    public static Option<bool> CreateNoRestoreOption() => new Option<bool>("--no-restore")
    {
        Description = CommandDefinitionStrings.NoRestoreDescription,
        Arity = ArgumentArity.Zero
    }.ForwardAs("-restore:false");

    public static Option<bool> RestoreOption = new Option<bool>("--restore", "-restore")
    {
        Description = "Restore the project before building it. This is the default behavior.",
        Arity = ArgumentArity.Zero,
        Hidden = true
    }.ForwardAs("-restore");

    private static bool IsCIEnvironmentOrRedirected() =>
        new Telemetry.CIEnvironmentDetectorForTelemetry().IsCIEnvironment() || Console.IsOutputRedirected;

    public const string InteractiveOptionName = "--interactive";

    /// <summary>
    /// A 'template' for interactive usage across the whole dotnet CLI. Use this as a base and then specialize it for your use cases.
    /// Despite being a 'forwarded option' there is no default forwarding configured, so if you want forwarding you can add it on a per-command basis.
    /// </summary>
    /// <param name="acceptArgument">Whether the option accepts an boolean argument. If false, the option will be a flag.</param>
    /// <remarks>
    /// If not set by a user, this will default to true if the user is not in a CI environment as detected by <see cref="Telemetry.CIEnvironmentDetectorForTelemetry.IsCIEnvironment"/>.
    /// If this is set to function as a flag, then there is no simple user-provided way to circumvent the behavior.
    /// </remarks>
    public static Option<bool> CreateInteractiveOption(bool acceptArgument = false, bool hidden = false) =>
        new(InteractiveOptionName)
        {
            Description = CommandDefinitionStrings.CommandInteractiveOptionDescription,
            Arity = acceptArgument ? ArgumentArity.ZeroOrOne : ArgumentArity.Zero,
            // this default is called when no tokens/options are passed on the CLI args
            DefaultValueFactory = (ar) => !IsCIEnvironmentOrRedirected(),
            Hidden = hidden,
        };

    public static Option<bool> CreateInteractiveMsBuildForwardOption()
        => CreateInteractiveOption(acceptArgument: true)
           .ForwardAsSingle(b => $"--property:NuGetInteractive={(b ? "true" : "false")}");

    public static Option<bool> CreateDisableBuildServersOption() =>
        new Option<bool>("--disable-build-servers")
        {
            Description = CommandDefinitionStrings.DisableBuildServersOptionDescription,
            Arity = ArgumentArity.Zero
        }
        .ForwardIfEnabled(["--property:UseRazorBuildServer=false", "--property:UseSharedCompilation=false", "/nodeReuse:false"]);

    public static Option<bool> DebugOption = new("--debug")
    {
        Arity = ArgumentArity.Zero,
    };

    public static Option<bool> CreateSelfContainedOption() =>
        new Option<bool>("--self-contained", "--sc")
        {
            Description = CommandDefinitionStrings.SelfContainedOptionDescription,
            Arity = ArgumentArity.ZeroOrOne
        }
        .ForwardAsMany(o => [$"--property:SelfContained={(o ? "true" : "false")}", "--property:_CommandLineDefinedSelfContained=true"])
        .IfExplicitlyProvided();

    public static Option<bool> CreateNoSelfContainedOption() =>
        new Option<bool>("--no-self-contained")
        {
            Description = CommandDefinitionStrings.FrameworkDependentOptionDescription,
            Arity = ArgumentArity.Zero
        }
        .ForwardIfEnabled([$"--property:SelfContained=false", "--property:_CommandLineDefinedSelfContained=true"]);

    public static Option<IReadOnlyDictionary<string, string>> CreateEnvOption(string? description = null) => new("--environment", "-e")
    {
        Description = description ?? CommandDefinitionStrings.CmdEnvironmentVariableDescription,
        HelpName = CommandDefinitionStrings.CmdEnvironmentVariableExpression,
        CustomParser = ParseEnvironmentVariables,
        // Can't allow multiple arguments because the separator needs to be parsed as part of the environment variable value.
        AllowMultipleArgumentsPerToken = false
    };

    private static IReadOnlyDictionary<string, string> ParseEnvironmentVariables(ArgumentResult argumentResult)
    {
        var result = new Dictionary<string, string>(
            RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);

        List<Token>? invalid = null;

        foreach (var token in argumentResult.Tokens)
        {
            var separator = token.Value.IndexOf('=');
            var (name, value) = (separator >= 0)
                ? (token.Value[0..separator], token.Value[(separator + 1)..])
                : (token.Value, "");

            name = name.Trim();

            if (name != "")
            {
                result[name] = value;
            }
            else
            {
                invalid ??= [];
                invalid.Add(token);
            }
        }

        if (invalid != null)
        {
            argumentResult.AddError(string.Format(
                CommandDefinitionStrings.IncorrectlyFormattedEnvironmentVariables,
                string.Join(", ", invalid.Select(x => $"'{x.Value}'"))));
        }

        return result;
    }

    /// <summary>
    /// Creates an implementation of the <c>--no-logo</c> option.
    /// This option suppresses the display of the startup banner or logos for commands or products it is applied to.
    /// The behavior of this option is influenced by the <c>DOTNET_NOLOGO</c> environment variable if it is set:
    /// <list type="bullet">
    /// <item>If the <c>--no-logo</c> option is not specified on the command line, the environment variable will be checked to determine
    /// whether to suppress logos. Any truthy value - <c>true</c>, <c>1</c>, <c>yes</c>, <c>on</c> - will suppress logos, while any falsy value - <c>false</c>, <c>0</c>, <c>no</c>, <c>off</c> - will show logos.</item>
    /// <item>If the option is specified on the command line, it takes precedence over the environment variable.</item>
    /// </list>
    /// Finally, if neither the option nor the environment variable is set, the option will default to the provided <paramref name="defaultValue"/>.
    /// </summary>
    public static Option<bool> CreateNoLogoOption(bool defaultValue = true, string forwardAs = "--nologo", string? description = null)
    {
        return new Option<bool>("--no-logo", "--nologo", "-nologo", "/nologo")
        {
            Description = description ?? CommandDefinitionStrings.NoLogoOptionDescription,
            DefaultValueFactory = (ar) => EnvironmentVariableParser.ParseBool(Environment.GetEnvironmentVariable("DOTNET_NOLOGO"), defaultValue),
            CustomParser = (ar) => true,
            Arity = ArgumentArity.Zero
        }.ForwardIfEnabled(forwardAs);
    }

    public static void ValidateSelfContainedOptions(bool hasSelfContainedOption, bool hasNoSelfContainedOption)
    {
        if (hasSelfContainedOption && hasNoSelfContainedOption)
        {
            throw new GracefulException(CommandDefinitionStrings.SelfContainAndNoSelfContainedConflict);
        }
    }

    /// <summary>
    /// Creates common diagnostics option (-d|--diagnostics).
    /// </summary>
    public static Option<bool> CreateDiagnosticsOption(bool recursive) => new("--diagnostics", "-d")
    {
        Description = CommandDefinitionStrings.SDKDiagnosticsCommandDefinition,
        Recursive = recursive,
        Arity = ArgumentArity.Zero
    };
}