// 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; } } }