File: CommonOptions.cs
Web Access
Project: ..\..\..\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.Collections.Frozen;
using System.Collections.ObjectModel;
using System.CommandLine;
using System.CommandLine.Completions;
using System.CommandLine.Parsing;
using System.CommandLine.StaticCompletions;
using Microsoft.DotNet.Cli.Extensions;
using Microsoft.DotNet.Cli.Utils;
 
namespace Microsoft.DotNet.Cli;
 
internal static class CommonOptions
{
    public static Option<bool> YesOption =
        new DynamicOption<bool>("--yes", "-y")
        {
            Description = CliStrings.YesOptionDescription,
            Arity = ArgumentArity.Zero
        };
 
    public static Option<ReadOnlyDictionary<string, string>?> PropertiesOption =
        // these are all of the forms that the property switch can be understood by in MSBuild
        new ForwardedOption<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>?> RestorePropertiesOption =
        // these are all of the forms that the property switch can be understood by in MSBuild
        new ForwardedOption<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[]?> MSBuildTargetOption(string? defaultTargetName = null, (string key, string value)[]? additionalProperties = null) =>
        new ForwardedOption<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[]> RequiredMSBuildTargetOption(string defaultTargetName, (string key, string value)[]? additionalProperties = null) =>
        new ForwardedOption<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 readonly Option<string[]?> GetPropertyOption = MSBuildMultiOption("getProperty");
 
    public static readonly Option<string[]?> GetItemOption = MSBuildMultiOption("getItem");
 
    public static readonly Option<string[]?> GetTargetResultOption = MSBuildMultiOption("getTargetResult");
 
    public static readonly Option<string[]?> GetResultOutputFileOption = MSBuildMultiOption("getResultOutputFile");
 
    private static Option<string[]?> MSBuildMultiOption(string name)
        => new ForwardedOption<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> VerbosityOption(VerbosityOptions defaultVerbosity) =>
        new ForwardedOption<VerbosityOptions>("--verbosity", "-v")
        {
            Description = CliStrings.VerbosityOptionDescription,
            HelpName = CliStrings.LevelArgumentName,
            DefaultValueFactory = _ => defaultVerbosity
        }
        .ForwardAsSingle(o => $"--verbosity:{o}")
        .AggregateRepeatedTokens();
 
    public static Option<VerbosityOptions?> VerbosityOption() =>
        new ForwardedOption<VerbosityOptions?>("--verbosity", "-v", "--v", "-verbosity", "/v", "/verbosity")
        {
            Description = CliStrings.VerbosityOptionDescription,
            HelpName = CliStrings.LevelArgumentName
        }
        .ForwardAsSingle(o => $"--verbosity:{o}")
        .AggregateRepeatedTokens();
 
    public static Option<VerbosityOptions> HiddenVerbosityOption =
        new ForwardedOption<VerbosityOptions>("--verbosity", "-v", "--v", "-verbosity", "/v", "/verbosity")
        {
            Description = CliStrings.VerbosityOptionDescription,
            HelpName = CliStrings.LevelArgumentName,
            Hidden = true
        }
        .ForwardAsSingle(o => $"--verbosity:{o}")
        .AggregateRepeatedTokens();
 
    public static Option<string> FrameworkOption(string description) =>
        new DynamicForwardedOption<string>("--framework", "-f")
        {
            Description = description,
            HelpName = CliStrings.FrameworkArgumentName
        }
        .AddCompletions(CliCompletion.TargetFrameworksFromProjectFile)
        .ForwardAsSingle(o => $"--property:TargetFramework={o}");
 
    public static Option<string> ArtifactsPathOption =
        new ForwardedOption<string>(
            //  --artifacts-path is pretty verbose, should we use --artifacts instead (or possibly support both)?
            "--artifacts-path")
        {
            Description = CliStrings.ArtifactsPathOptionDescription,
            HelpName = CliStrings.ArtifactsPathArgumentName
        }.ForwardAsSingle(o => $"--property:ArtifactsPath={CommandDirectoryContext.GetFullPath(o)}");
 
    private static readonly string RuntimeArgName = CliStrings.RuntimeIdentifierArgumentName;
    public static IEnumerable<string> RuntimeArgFunc(string rid)
    {
        if (GetArchFromRid(rid) == "amd64")
        {
            rid = GetOsFromRid(rid) + "-x64";
        }
        return [$"--property:RuntimeIdentifier={rid}", "--property:_CommandLineDefinedRuntimeIdentifier=true"];
    }
 
    public const string RuntimeOptionName = "--runtime";
 
    public static Option<string> RuntimeOption(string description) =>
        new DynamicForwardedOption<string>(RuntimeOptionName, "-r")
        {
            HelpName = RuntimeArgName,
            Description = description
        }.ForwardAsMany(RuntimeArgFunc!)
        .AddCompletions(CliCompletion.RunTimesFromProjectFile);
 
    public static Option<string> LongFormRuntimeOption =
        new DynamicForwardedOption<string>(RuntimeOptionName)
        {
            HelpName = RuntimeArgName
        }.ForwardAsMany(RuntimeArgFunc!)
        .AddCompletions(CliCompletion.RunTimesFromProjectFile);
 
    public static Option<bool> CurrentRuntimeOption(string description) =>
        new ForwardedOption<bool>("--use-current-runtime", "--ucr")
        {
            Description = description,
            Arity = ArgumentArity.Zero
        }.ForwardAs("--property:UseCurrentRuntimeIdentifier=True");
 
    public static Option<string?> ConfigurationOption(string description) =>
        new DynamicForwardedOption<string?>("--configuration", "-c")
        {
            Description = description,
            HelpName = CliStrings.ConfigurationArgumentName
        }.ForwardAsSingle(o => $"--property:Configuration={o}")
        .AddCompletions(CliCompletion.ConfigurationsFromProjectFileOrDefaults);
 
    public static Option<string> VersionSuffixOption =
        new ForwardedOption<string>("--version-suffix")
        {
            Description = CliStrings.CmdVersionSuffixDescription,
            HelpName = CliStrings.VersionSuffixArgumentName
        }.ForwardAsSingle(o => $"--property:VersionSuffix={o}");
 
    public static Lazy<string> NormalizedCurrentDirectory = new(() => PathUtility.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> NoRestoreOption = new ForwardedOption<bool>("--no-restore")
    {
        Description = CliStrings.NoRestoreDescription,
        Arity = ArgumentArity.Zero
    }.ForwardAs("-restore:false");
 
 
    public static Option<bool> RestoreOption = new ForwardedOption<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 ForwardedOption<bool> InteractiveOption(bool acceptArgument = false) =>
        new(InteractiveOptionName)
        {
            Description = CliStrings.CommandInteractiveOptionDescription,
            Arity = acceptArgument ? ArgumentArity.ZeroOrOne : ArgumentArity.Zero,
            // this default is called when no tokens/options are passed on the CLI args
            DefaultValueFactory = (ar) => !IsCIEnvironmentOrRedirected()
        };
 
    public static Option<bool> InteractiveMsBuildForwardOption = InteractiveOption(acceptArgument: true).ForwardAsSingle(b => $"--property:NuGetInteractive={(b ? "true" : "false")}");
 
    public static Option<bool> DisableBuildServersOption =
        new ForwardedOption<bool>("--disable-build-servers")
        {
            Description = CliStrings.DisableBuildServersOptionDescription,
            Arity = ArgumentArity.Zero
        }
        .ForwardIfEnabled(["--property:UseRazorBuildServer=false", "--property:UseSharedCompilation=false", "/nodeReuse:false"]);
 
    public static Option<string> ArchitectureOption =
        new ForwardedOption<string>("--arch", "-a")
        {
            Description = CliStrings.ArchitectureOptionDescription,
            HelpName = CliStrings.ArchArgumentName
        }.SetForwardingFunction(ResolveArchOptionToRuntimeIdentifier);
 
    public static Option<string> LongFormArchitectureOption =
        new ForwardedOption<string>("--arch")
        {
            Description = CliStrings.ArchitectureOptionDescription,
            HelpName = CliStrings.ArchArgumentName
        }.SetForwardingFunction(ResolveArchOptionToRuntimeIdentifier);
 
    internal static string? ArchOptionValue(ParseResult parseResult) =>
        string.IsNullOrEmpty(parseResult.GetValue(ArchitectureOption)) ?
            parseResult.GetValue(LongFormArchitectureOption) :
            parseResult.GetValue(ArchitectureOption);
 
    public static Option<string> OperatingSystemOption =
        new ForwardedOption<string>("--os")
        {
            Description = CliStrings.OperatingSystemOptionDescription,
            HelpName = CliStrings.OSArgumentName
        }.SetForwardingFunction(ResolveOsOptionToRuntimeIdentifier);
 
    public static Option<bool> DebugOption = new("--debug")
    {
        Arity = ArgumentArity.Zero,
    };
 
    public static Option<bool> SelfContainedOption =
        new ForwardedOption<bool>("--self-contained", "--sc")
        {
            Description = CliStrings.SelfContainedOptionDescription
        }
        .ForwardIfEnabled([$"--property:SelfContained=true", "--property:_CommandLineDefinedSelfContained=true"]);
 
    public static Option<bool> NoSelfContainedOption =
        new ForwardedOption<bool>("--no-self-contained")
        {
            Description = CliStrings.FrameworkDependentOptionDescription,
            Arity = ArgumentArity.Zero
        }
        .ForwardIfEnabled([$"--property:SelfContained=false", "--property:_CommandLineDefinedSelfContained=true"]);
 
    public static Option<IReadOnlyDictionary<string, string>> CreateEnvOption(string description) => new("--environment", "-e")
    {
        Description = description,
        HelpName = CliStrings.CmdEnvironmentVariableExpression,
        CustomParser = ParseEnvironmentVariables,
        // Can't allow multiple arguments because the separator needs to be parsed as part of the environment variable value.
        AllowMultipleArgumentsPerToken = false
    };
 
    public static readonly Option<IReadOnlyDictionary<string, string>> EnvOption = CreateEnvOption(CliStrings.CmdEnvironmentVariableDescription);
    
    public static readonly Option<IReadOnlyDictionary<string, string>> TestEnvOption = CreateEnvOption(CliStrings.CmdTestEnvironmentVariableDescription);
 
    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(
                CliStrings.IncorrectlyFormattedEnvironmentVariables,
                string.Join(", ", invalid.Select(x => $"'{x.Value}'"))));
        }
 
        return result;
    }
 
    public static readonly Option<string> TestPlatformOption = new("--Platform");
 
    public static readonly Option<string> TestFrameworkOption = new("--Framework");
 
    public static readonly Option<string[]> TestLoggerOption = new("--logger");
 
    public static void ValidateSelfContainedOptions(bool hasSelfContainedOption, bool hasNoSelfContainedOption)
    {
        if (hasSelfContainedOption && hasNoSelfContainedOption)
        {
            throw new GracefulException(CliStrings.SelfContainAndNoSelfContainedConflict);
        }
    }
 
    internal static IEnumerable<string> ResolveArchOptionToRuntimeIdentifier(string? arg, ParseResult parseResult)
    {
        if (parseResult.GetResult(RuntimeOptionName) is not null)
        {
            throw new GracefulException(CliStrings.CannotSpecifyBothRuntimeAndArchOptions);
        }
 
        if (parseResult.BothArchAndOsOptionsSpecified())
        {
            // ResolveOsOptionToRuntimeIdentifier handles resolving the RID when both arch and os are specified
            return [];
        }
 
        return ResolveRidShorthandOptions(null, arg);
    }
 
    internal static IEnumerable<string> ResolveOsOptionToRuntimeIdentifier(string? arg, ParseResult parseResult)
    {
        if (parseResult.GetResult(RuntimeOptionName) is not null)
        {
            throw new GracefulException(CliStrings.CannotSpecifyBothRuntimeAndOsOptions);
        }
 
        var arch = parseResult.BothArchAndOsOptionsSpecified() ? ArchOptionValue(parseResult) : null;
        return ResolveRidShorthandOptions(arg, arch);
    }
 
    private static IEnumerable<string> ResolveRidShorthandOptions(string? os, string? arch) =>
        [$"--property:RuntimeIdentifier={ResolveRidShorthandOptionsToRuntimeIdentifier(os, arch)}"];
 
    internal static string ResolveRidShorthandOptionsToRuntimeIdentifier(string? os, string? arch)
    {
        var currentRid = GetCurrentRuntimeId();
        arch = arch == "amd64" ? "x64" : arch;
        os = string.IsNullOrEmpty(os) ? GetOsFromRid(currentRid) : os;
        arch = string.IsNullOrEmpty(arch) ? GetArchFromRid(currentRid) : arch;
        return $"{os}-{arch}";
    }
 
    public static string GetCurrentRuntimeId()
    {
        // Get the dotnet directory, while ignoring custom msbuild resolvers
        string? dotnetRootPath = NativeWrapper.EnvironmentProvider.GetDotnetExeDirectory(key =>
            key.Equals("DOTNET_MSBUILD_SDK_RESOLVER_CLI_DIR", StringComparison.InvariantCultureIgnoreCase)
                ? null
                : Environment.GetEnvironmentVariable(key));
        var ridFileName = "NETCoreSdkRuntimeIdentifierChain.txt";
        var sdkPath = dotnetRootPath is not null ? Path.Combine(dotnetRootPath, "sdk") : "sdk";
 
        // When running under test the Product.Version might be empty or point to version not installed in dotnetRootPath.
        string runtimeIdentifierChainPath = string.IsNullOrEmpty(Product.Version) || !Directory.Exists(Path.Combine(sdkPath, Product.Version)) ?
            Path.Combine(Directory.GetDirectories(sdkPath)[0], ridFileName) :
            Path.Combine(sdkPath, Product.Version, ridFileName);
        string[] currentRuntimeIdentifiers = File.Exists(runtimeIdentifierChainPath) ? [.. File.ReadAllLines(runtimeIdentifierChainPath).Where(l => !string.IsNullOrEmpty(l))] : [];
        if (currentRuntimeIdentifiers == null || !currentRuntimeIdentifiers.Any() || !currentRuntimeIdentifiers[0].Contains("-"))
        {
            throw new GracefulException(CliStrings.CannotResolveRuntimeIdentifier);
        }
        return currentRuntimeIdentifiers[0]; // First rid is the most specific (ex win-x64)
    }
 
    private static string GetOsFromRid(string rid) => rid.Substring(0, rid.LastIndexOf("-", StringComparison.InvariantCulture));
 
    private static string GetArchFromRid(string rid) => rid.Substring(rid.LastIndexOf("-", StringComparison.InvariantCulture) + 1, rid.Length - rid.LastIndexOf("-", StringComparison.InvariantCulture) - 1);
 
    internal static Option<T> AddCompletions<T>(this Option<T> option, Func<CompletionContext, IEnumerable<CompletionItem>> completionSource)
    {
        option.CompletionSources.Add(completionSource);
        return option;
    }
 
    internal static Argument<T> AddCompletions<T>(this Argument<T> argument, Func<CompletionContext, IEnumerable<CompletionItem>> completionSource)
    {
        argument.CompletionSources.Add(completionSource);
        return argument;
    }
 
    internal static DynamicOption<T> AddCompletions<T>(this DynamicOption<T> option, Func<CompletionContext, IEnumerable<CompletionItem>> completionSource)
    {
        option.CompletionSources.Add(completionSource);
        return option;
    }
 
    internal static DynamicForwardedOption<T> AddCompletions<T>(this DynamicForwardedOption<T> option, Func<CompletionContext, IEnumerable<CompletionItem>> completionSource)
    {
        option.CompletionSources.Add(completionSource);
        return option;
    }
}
 
public class DynamicOption<T>(string name, params string[] aliases) : Option<T>(name, aliases), IDynamicOption
{
}
 
public class DynamicArgument<T>(string name) : Argument<T>(name), IDynamicArgument
{
}