// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using Microsoft.Testing.Platform.Helpers;
using Microsoft.Testing.Platform.Resources;
namespace Microsoft.Testing.Platform.CommandLine;
internal static class CommandLineParser
{
///
/// Options parser support:
/// * Only - and -- prefix for options https://learn.microsoft.com/dotnet/standard/commandline/syntax#options
/// * Multiple option arguments https://learn.microsoft.com/dotnet/standard/commandline/syntax#multiple-arguments
/// * Use '=' or ':' as the delimiter between an option name and its argument. See https://learn.microsoft.com/dotnet/standard/commandline/syntax#option-argument-delimiters
/// * escape with \
/// * surrounding with ""
/// * surrounding with ''
///
/// Options parser doesn't support
/// * Default argument/commands/verb
/// * Alias https://learn.microsoft.com/dotnet/standard/commandline/syntax#aliases
/// * Case sensitivity https://learn.microsoft.com/dotnet/standard/commandline/syntax#case-sensitivity
/// * -- token https://learn.microsoft.com/dotnet/standard/commandline/syntax#the----token
///
/// https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_02_03
/// https://learn.microsoft.com/cpp/c-language/parsing-c-command-line-arguments?view=msvc-170
/// Double-Quotes: we don't support for now
/// * The dollar-sign shall retain its special meaning introducing parameter expansion
/// * The backquote shall retain its special meaning introducing the other form of command substitution
/// * A POSIX convention lets you omit the delimiter when you are specifying a single-character option alias, i.e. myapp -vquiet.
///
public static CommandLineParseResult Parse(string[] args, IEnvironment environment)
=> Parse(args.ToList(), environment);
private static CommandLineParseResult Parse(List args, IEnvironment environment)
{
string[] originalArgs = [.. args];
List options = [];
List errors = [];
string? currentOption = null;
string? toolName = null;
List currentOptionArguments = [];
bool isFirstRealArgument = true;
for (int i = 0; i < args.Count; i++)
{
string? currentArg = args[i];
if (currentArg.StartsWith("@", StringComparison.Ordinal))
{
string responseFilePath = currentArg.Substring(1);
string redactedResponseFileArgument = CommandLineArgumentsRedactor.RedactArgument([.. args], i);
string diagnosticPath = redactedResponseFileArgument.StartsWith("@", StringComparison.Ordinal)
? redactedResponseFileArgument.Substring(1)
: redactedResponseFileArgument;
if (ResponseFileHelper.TryReadResponseFile(responseFilePath, diagnosticPath, errors, out string[]? newArguments))
{
args.RemoveAt(i);
args.InsertRange(i, newArguments);
i--;
continue;
}
}
// If it's the first argument and it doesn't start with - then it's the tool name
if (isFirstRealArgument && currentArg.Length > 0 && currentArg[0] != '-')
{
toolName = currentArg;
isFirstRealArgument = false;
continue;
}
isFirstRealArgument = false;
// we accept as start for options -- and - all the rest are arguments to the previous option
if ((currentArg.Length > 1 && currentArg[0].Equals('-') && !currentArg[1].Equals('-')) ||
(currentArg.Length > 2 && currentArg[0].Equals('-') && currentArg[1].Equals('-') && !currentArg[2].Equals('-')))
{
if (currentOption is not null)
{
options.Add(new(currentOption, [.. currentOptionArguments]));
currentOptionArguments.Clear();
}
ParseOptionAndSeparators(currentArg, out currentOption, out currentArg);
}
if (currentArg is not null)
{
if (currentOption is null)
{
errors.Add(
string.Format(
CultureInfo.InvariantCulture,
PlatformResources.CommandLineParserUnexpectedArgument,
CommandLineArgumentsRedactor.RedactArgument([.. args], i)));
}
else
{
if (TryUnescape(currentArg.Trim(), currentOption, environment, out string? unescapedArg, out string? error))
{
currentOptionArguments.Add(unescapedArg);
}
else
{
errors.Add(error);
}
}
}
}
if (currentOption is not null)
{
options.Add(new(currentOption, [.. currentOptionArguments]));
}
return new CommandLineParseResult(toolName, options, errors, originalArgs, args);
static void ParseOptionAndSeparators(string arg, out string? currentOption, out string? currentArg)
{
(currentOption, currentArg) = arg.IndexOfAny([':', '=']) switch
{
-1 => (arg, null),
var delimiterIndex => (arg[..delimiterIndex], arg[(delimiterIndex + 1)..]),
};
currentOption = currentOption.TrimStart('-');
}
static bool TryUnescape(string input, string? option, IEnvironment environment, [NotNullWhen(true)] out string? unescapedArg, [NotNullWhen(false)] out string? error)
{
unescapedArg = input;
error = null;
// Enclosing characters in single-quotes ( '' ) shall preserve the literal value of each character within the single-quotes.
// A single-quote cannot occur within single-quotes.
if (input.StartsWith("\'", StringComparison.Ordinal) && input.EndsWith("\'", StringComparison.Ordinal))
{
if (input.IndexOf('\'', 1, input.Length - 2) != -1)
{
const string redactedPlaceholder = "***REDACTED***";
string displayInput =
PlatformCommandLineProvider.DotNetTestHttpTokenOptionKey.Equals(option, StringComparison.OrdinalIgnoreCase)
|| PlatformCommandLineProvider.DotNetTestHttpEndpointOptionKey.Equals(option, StringComparison.OrdinalIgnoreCase)
? redactedPlaceholder
: input;
error = option is null
? string.Format(CultureInfo.InvariantCulture, PlatformResources.CommandLineParserUnexpectedSingleQuoteInArgument, displayInput)
: string.Format(CultureInfo.InvariantCulture, PlatformResources.CommandLineParserUnexpectedSingleQuoteInArgumentForOption, displayInput, option);
return false;
}
unescapedArg = input[1..^1];
return true;
}
// Enclosing characters in double-quotes("") shall preserve the literal value of all characters within the double-quotes,
// with the exception of the characters backquote, , and , as follows:
// * The backquote shall retain its special meaning introducing the other form of command substitution. [NOT SUPPORTED]
// * The shall retain its special meaning introducing parameter expansion. [NOT SUPPORTED]
// * The backslash shall retain its special meaning as an escape character only when followed by one of the following characters when considered special:
// $ ` " \
if (input.StartsWith("\"", StringComparison.Ordinal) && input.EndsWith("\"", StringComparison.Ordinal))
{
unescapedArg = input[1..^1].Replace(@"\\", "\\")
.Replace(@"\""", "\"")
.Replace(@"\$", "$")
.Replace(@"\`", "`")
.Replace($@"\{environment.NewLine}", environment.NewLine);
return true;
}
return true;
}
}
}