File: Commands\Test\VSTest\VSTestArgumentConverter.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.
 
#nullable disable
 
using System.Collections.Immutable;
 
namespace Microsoft.DotNet.Cli.Commands.Test;
 
/// <summary>
/// Converts the given arguments to vstest parsable arguments.
/// </summary>
/// <remarks>
/// This converter is only used when running <c>dotnet test</c> with a dll/exe,
/// not when called for project, solution, directory, or with no path.
/// </remarks>
public class VSTestArgumentConverter
{
    private const string VerbosityString = "--logger:console;verbosity=";
 
    private static readonly ImmutableDictionary<string, string> s_argumentMapping
        = new Dictionary<string, string>()
        {
            ["-h"] = "--help",
            ["-s"] = "--settings",
            ["-t"] = "--listtests",
            // .NET 7 breaking change, before we had ["-a"] = "--testadapterpath",
            ["-a"] = "--platform",
            ["-l"] = "--logger",
            ["-f"] = "--framework",
            ["-d"] = "--diag",
            ["--filter"] = "--testcasefilter",
            ["--list-tests"] = "--listtests",
            ["--test-adapter-path"] = "--testadapterpath",
            // .NET 7 breaking change, before we had ["-r"] = "--resultsdirectory",
            ["--results-directory"] = "--resultsdirectory",
            ["--arch"] = "--platform"
        }.ToImmutableDictionary();
 
    private static readonly ImmutableDictionary<string, string> s_verbosityMapping
        = new Dictionary<string, string>()
        {
            ["q"] = "quiet",
            ["m"] = "minimal",
            ["n"] = "normal",
            ["d"] = "detailed",
            ["diag"] = "diagnostic"
        }.ToImmutableDictionary();
 
    private static readonly ImmutableHashSet<string> s_ignoredArguments
        = ImmutableHashSet.Create(
            StringComparer.OrdinalIgnoreCase,
            "-c",
            "--configuration",
            "-r",
            "--runtime",
            "-o",
            "--output",
            "--no-build",
            "--no-restore",
            "--interactive",
            "--testSessionCorrelationId",
            "--artifactsProcessingMode-collect"
        );
 
    /// <summary>
    /// The expanded (after applying argument mapping) list of arguments to consider
    /// as switches (i.e., for which we don't expect a non dashed arg). This also
    /// includes ignored arguments.
    /// </summary>
    private static readonly ImmutableHashSet<string> s_switchArguments
        = ImmutableHashSet.Create(
            StringComparer.OrdinalIgnoreCase,
            // Arguments
            "--listtests",
            "--nologo",
 
            // Ignored arguments
            "--no-build",
            "--no-restore",
            "--interactive",
            "--artifactsProcessingMode-collect"
        );
 
    /// <summary>
    /// Converts the given arguments to vstest parsable arguments
    /// </summary>
    /// <param name="args">original arguments</param>
    /// <param name="ignoredArgs">arguments ignored by the converter</param>
    /// <returns>list of args which can be passed to vstest</returns>
    public List<string> Convert(string[] args, out List<string> ignoredArgs)
    {
        var newArgList = new List<string>();
        ignoredArgs = [];
 
        string activeArgument = null;
        BlameArgs blame = new();
 
        foreach (string arg in args)
        {
            if (arg == "--")
            {
                throw new ArgumentException("Inline settings should not be passed to Convert.");
            }
 
            if (arg.StartsWith('-'))
            {
                if (!string.IsNullOrEmpty(activeArgument))
                {
                    if (s_ignoredArguments.Contains(activeArgument))
                    {
                        ignoredArgs.Add(activeArgument);
                    }
                    else if (BlameArgs.IsBlameArg(activeArgument))
                    {
                        // do nothing, we process remaining arguments ourselves
                    }
                    else
                    {
                        newArgList.Add(activeArgument);
                    }
                    activeArgument = null;
                }
 
                // Check if the arg contains the value separated by colon
                if (arg.Contains(':'))
                {
                    string[] argValues = arg.Split(':');
 
                    if (s_ignoredArguments.Contains(argValues[0]))
                    {
                        ignoredArgs.Add(arg);
                        continue;
                    }
 
                    if (IsVerbosityArg(argValues[0]))
                    {
                        UpdateVerbosity(argValues[1], newArgList);
                        continue;
                    }
 
                    if (BlameArgs.IsBlameArg(argValues[0]))
                    {
                        blame.UpdateBlame(argValues[0], argValues[1]);
                        continue;
                    }
 
                    // Check if the argument is shortname
                    if (s_argumentMapping.TryGetValue(argValues[0].ToLower(), out string longName))
                    {
                        argValues[0] = longName;
                    }
 
                    newArgList.Add(string.Join(':', argValues));
                }
                else
                {
                    if (BlameArgs.IsBlameSwitch(arg))
                    {
                        blame.UpdateBlame(arg, null);
                        activeArgument = arg;
                    }
                    else
                    {
                        activeArgument = arg.ToLower();
                        if (s_argumentMapping.TryGetValue(activeArgument, out string value))
                        {
                            activeArgument = value;
                        }
                    }
                }
            }
            else if (!string.IsNullOrEmpty(activeArgument))
            {
                if (IsVerbosityArg(activeArgument))
                {
                    UpdateVerbosity(arg, newArgList);
                }
                // It's possible to have activeArgument that is both ignored and considered as
                // a switch. Order of the two contains clauses is important because the current
                // implementation wants to check first if activeArgument is a switch first so
                // that we don't consume the arg, and the inner if will take care of deciding
                // if active argument should be ignored or handled.
                else if (s_switchArguments.Contains(activeArgument))
                {
                    if (s_ignoredArguments.Contains(activeArgument))
                    {
                        ignoredArgs.Add(activeArgument);
                    }
                    else
                    {
                        newArgList.Add(activeArgument);
                    }
 
                    // The only real case where we would end-up here is when the item under
                    // test (.dll or .exe) is passed as last argument so we could potentially
                    // check the extension and for invalid ones, either add it to the ignored
                    // args or have a specific failure.
                    newArgList.Add(arg);
                }
                // When reaching this point, we are sure that activeArgument is not a switch
                // so we can add it and the arg to the list of ignored arguments.
                else if (s_ignoredArguments.Contains(activeArgument))
                {
                    ignoredArgs.Add(activeArgument);
                    ignoredArgs.Add(arg);
                }
                // When entering this condition, we know that the activeArgument is either a
                // blame switch or a blame argument. Blame args have to be handled differently
                // because they are cumulative and so need to be combined. The inner logic will
                // decide how to handle it.
                else if (BlameArgs.IsBlameArg(activeArgument))
                {
                    // We know that activeArgument is a blame kind, we now want to see if arg
                    // is linked to the blame or not. If activeArgument is a not blame switch
                    // (e.g., --blame-crash-dump-type full) or if it is the legacy blame syntax
                    // (e.g., --blame CollectDump;DumpType=full) then we do want to update the
                    // blame combination based both on activeArgument and arg. Otherwise, we
                    // have a simple blame switch (e.g., --blame some.dll) so we can add it to
                    // the args list.
                    if (!BlameArgs.IsBlameSwitch(activeArgument)
                        || BlameArgs.IsLegacyBlame(activeArgument, arg))
                    {
                        blame.UpdateBlame(activeArgument, arg);
                    }
                    else
                    {
                        newArgList.Add(arg);
                    }
                }
                else
                {
                    newArgList.Add(string.Concat(activeArgument, ":", arg));
                }
 
                activeArgument = null;
            }
            else
            {
                if (BlameArgs.IsBlameArg(arg))
                {
                    blame.UpdateBlame(arg, null);
                }
                else
                {
                    newArgList.Add(arg);
                }
            }
        }
 
        if (!string.IsNullOrEmpty(activeArgument))
        {
            if (s_ignoredArguments.Contains(activeArgument))
            {
                ignoredArgs.Add(activeArgument);
            }
            else if (BlameArgs.IsBlameArg(activeArgument))
            {
                // do nothing, we process remaining arguments ourselves
            }
            else
            {
                newArgList.Add(activeArgument);
            }
        }
 
        if (blame.Blame)
        {
            blame.AddCombinedBlameArgs(newArgList);
        }
 
        return newArgList;
    }
 
    private static bool IsVerbosityArg(string arg)
        => string.Equals(arg, "-v", StringComparison.OrdinalIgnoreCase)
        || string.Equals(arg, "--verbosity", StringComparison.OrdinalIgnoreCase);
 
    private static void UpdateVerbosity(string verbosity, List<string> newArgList)
    {
        if (s_verbosityMapping.TryGetValue(verbosity.ToLower(), out string longValue))
        {
            newArgList.Add(VerbosityString + longValue);
            return;
        }
        newArgList.Add(VerbosityString + verbosity);
    }
}
 
class BlameArgs
{
    public bool Blame;
    private string _legacyBlame;
 
    private bool _collectCrashDump;
    private string _collectCrashDumpType;
    private bool _collectCrashDumpAlways;
 
    private bool _collectHangDump;
    private string _collectHangDumpType;
    private string _collectHangDumpTimeout;
 
    private const string BlameParam = "--blame";
    private const string BlameCrashParam = "--blame-crash";
    private const string BlameCrashDumpTypeParam = "--blame-crash-dump-type";
    private const string BlameCrashCollectAlwaysParam = "--blame-crash-collect-always";
    private const string BlameHangParam = "--blame-hang";
    private const string BlameHangDumpTypeParam = "--blame-hang-dump-type";
    private const string BlameHangTimeoutParam = "--blame-hang-timeout";
 
    private const string LegacyBlameCollectDump = "CollectDump";
    private const string LegacyBlameCollectHangDump = "CollectHangDump";
 
    // parameters that expect arguments
    private static readonly ImmutableArray<string> s_blameArgList
        = ImmutableArray.Create(
            BlameCrashDumpTypeParam,
 
            BlameHangDumpTypeParam,
            BlameHangTimeoutParam
        );
 
    // parameters that don't expect any arguments
    private static readonly ImmutableArray<string> s_blameSwitchList
        = ImmutableArray.Create(
            BlameParam,
 
            BlameCrashParam,
            BlameCrashCollectAlwaysParam,
 
            BlameHangParam
        );
 
 
    internal static bool IsBlameArg(string parameter)
    {
        return s_blameArgList.Any(p => Eq(p, parameter)) || s_blameSwitchList.Any(p => Eq(p, parameter));
    }
 
    internal static bool IsLegacyBlame(string parameter, string value)
    {
        // when provided --blame <value>, we do not want to process it any further
        // most likely a legacy call, and the param is already in the format that vstest.console expects
        return Eq(BlameParam, parameter)
            && value != null
            && (value.StartsWith(LegacyBlameCollectDump, StringComparison.OrdinalIgnoreCase)
                || value.StartsWith(LegacyBlameCollectHangDump, StringComparison.OrdinalIgnoreCase));
    }
 
    internal static bool IsBlame(string parameter)
    {
        return Eq(BlameParam, parameter);
    }
 
    internal static bool IsBlameSwitch(string parameter)
    {
        return s_blameSwitchList.Any(p => Eq(p, parameter));
    }
 
    internal void UpdateBlame(string parameter, string argument)
    {
        if (IsLegacyBlame(parameter, argument))
        {
            Blame = true;
            _legacyBlame = argument;
        }
 
        if (Eq(parameter, BlameParam))
        {
            Blame = true;
        }
 
        // Any blame-crash param implies that we collect crash dump
        if (Eq(parameter, BlameCrashParam))
        {
            Blame = true;
            _collectCrashDump = true;
        }
 
        if (Eq(parameter, BlameCrashCollectAlwaysParam))
        {
            Blame = true;
            _collectCrashDump = true;
            _collectCrashDumpAlways = true;
        }
 
        if (Eq(parameter, BlameCrashDumpTypeParam))
        {
            Blame = true;
            _collectCrashDump = true;
            _collectCrashDumpType = argument;
        }
 
        // Any Blame-hang param implies that we collect hang dump
        if (Eq(parameter, BlameHangParam))
        {
            Blame = true;
            _collectHangDump = true;
        }
 
        if (Eq(parameter, BlameHangDumpTypeParam))
        {
            Blame = true;
            _collectHangDump = true;
            _collectHangDumpType = argument;
        }
 
        if (Eq(parameter, BlameHangTimeoutParam))
        {
            Blame = true;
            _collectHangDump = true;
            _collectHangDumpTimeout = argument;
        }
    }
 
    private static bool Eq(string left, string right)
    {
        return string.Equals(left, right, StringComparison.OrdinalIgnoreCase);
    }
 
    internal void AddCombinedBlameArgs(List<string> newArgList)
    {
        if (!Blame)
            return;
 
        if (!string.IsNullOrWhiteSpace(_legacyBlame))
        {
            // when legacy call is detected don't process
            // any more parameters
            newArgList.Add($"--blame:{_legacyBlame}");
            return;
        }
 
        string crashDumpArgs = null;
        string hangDumpArgs = null;
 
        if (_collectCrashDump)
        {
            crashDumpArgs = "CollectDump";
            if (_collectCrashDumpAlways)
            {
                crashDumpArgs += ";CollectAlways=true";
            }
 
            if (!string.IsNullOrWhiteSpace(_collectCrashDumpType))
            {
                crashDumpArgs += $";DumpType={_collectCrashDumpType}";
            }
        }
 
        if (_collectHangDump)
        {
            hangDumpArgs = "CollectHangDump";
            if (!string.IsNullOrWhiteSpace(_collectHangDumpType))
            {
                hangDumpArgs += $";DumpType={_collectHangDumpType}";
            }
 
            if (!string.IsNullOrWhiteSpace(_collectHangDumpTimeout))
            {
                hangDumpArgs += $";TestTimeout={_collectHangDumpTimeout}";
            }
        }
 
        if (_collectCrashDump || _collectHangDump)
        {
            newArgList.Add($@"--blame:{string.Join(";", crashDumpArgs, hangDumpArgs).Trim(';')}");
        }
        else
        {
            newArgList.Add("--blame");
        }
    }
}