Frank Ray b793482ebb
Command line argument parsing improvements (#1048)
* Support negative numbers as command option values
* Support command line options before arguments
* POSIX-compliant handling of quotes (double and single, terminated and unterminated), whitespace, hyphens, and special characters (e.g. emojis)
2022-12-05 01:07:53 +01:00

399 lines
14 KiB
C#

namespace Spectre.Console.Cli;
internal class CommandTreeParser
{
private readonly CommandModel _configuration;
private readonly ParsingMode _parsingMode;
private readonly CommandOptionAttribute _help;
public CaseSensitivity CaseSensitivity { get; }
public enum State
{
Normal = 0,
Remaining = 1,
}
public CommandTreeParser(CommandModel configuration, ICommandAppSettings settings, ParsingMode? parsingMode = null)
{
if (settings is null)
{
throw new ArgumentNullException(nameof(settings));
}
_configuration = configuration;
_parsingMode = parsingMode ?? _configuration.ParsingMode;
_help = new CommandOptionAttribute("-h|--help");
CaseSensitivity = settings.CaseSensitivity;
}
public CommandTreeParserResult Parse(IEnumerable<string> args)
{
var context = new CommandTreeParserContext(args, _parsingMode);
var tokenizerResult = CommandTreeTokenizer.Tokenize(context.Arguments);
var tokens = tokenizerResult.Tokens;
var rawRemaining = tokenizerResult.Remaining;
var result = default(CommandTree);
if (tokens.Count > 0)
{
// Not a command?
var token = tokens.Current;
if (token == null)
{
// Should not happen, but the compiler isn't
// smart enough to realize this...
throw new CommandRuntimeException("Could not get current token.");
}
if (token.TokenKind != CommandTreeToken.Kind.String)
{
// Got a default command?
if (_configuration.DefaultCommand != null)
{
result = ParseCommandParameters(context, _configuration.DefaultCommand, null, tokens);
return new CommandTreeParserResult(
result, new RemainingArguments(context.GetRemainingArguments(), rawRemaining));
}
// Show help?
if (_help?.IsMatch(token.Value) == true)
{
return new CommandTreeParserResult(
null, new RemainingArguments(context.GetRemainingArguments(), rawRemaining));
}
// Unexpected option.
throw CommandParseException.UnexpectedOption(context.Arguments, token);
}
// Does the token value match a command?
var command = _configuration.FindCommand(token.Value, CaseSensitivity);
if (command == null)
{
if (_configuration.DefaultCommand != null)
{
result = ParseCommandParameters(context, _configuration.DefaultCommand, null, tokens);
return new CommandTreeParserResult(
result, new RemainingArguments(context.GetRemainingArguments(), rawRemaining));
}
}
// Parse the command.
result = ParseCommand(context, _configuration, null, tokens);
}
else
{
// Is there a default command?
if (_configuration.DefaultCommand != null)
{
result = ParseCommandParameters(context, _configuration.DefaultCommand, null, tokens);
}
}
return new CommandTreeParserResult(
result, new RemainingArguments(context.GetRemainingArguments(), rawRemaining));
}
private CommandTree ParseCommand(
CommandTreeParserContext context,
ICommandContainer current,
CommandTree? parent,
CommandTreeTokenStream stream)
{
// Find the command.
var commandToken = stream.Consume(CommandTreeToken.Kind.String);
if (commandToken == null)
{
throw new CommandRuntimeException("Could not consume token when parsing command.");
}
var command = current.FindCommand(commandToken.Value, CaseSensitivity);
if (command == null)
{
throw CommandParseException.UnknownCommand(_configuration, parent, context.Arguments, commandToken);
}
return ParseCommandParameters(context, command, parent, stream);
}
private CommandTree ParseCommandParameters(
CommandTreeParserContext context,
CommandInfo command,
CommandTree? parent,
CommandTreeTokenStream stream)
{
context.ResetArgumentPosition();
var node = new CommandTree(parent, command);
while (stream.Peek() != null)
{
var token = stream.Peek();
if (token == null)
{
// Should not happen, but the compiler isn't
// smart enough to realize this...
throw new CommandRuntimeException("Could not get the next token.");
}
switch (token.TokenKind)
{
case CommandTreeToken.Kind.LongOption:
// Long option
ParseOption(context, stream, token, node, true);
break;
case CommandTreeToken.Kind.ShortOption:
// Short option
ParseOption(context, stream, token, node, false);
break;
case CommandTreeToken.Kind.String:
// Command
ParseString(context, stream, node);
break;
case CommandTreeToken.Kind.Remaining:
// Remaining
stream.Consume(CommandTreeToken.Kind.Remaining);
context.State = State.Remaining;
break;
default:
throw new InvalidOperationException($"Encountered unknown token ({token.TokenKind}).");
}
}
// Add unmapped parameters.
foreach (var parameter in node.Command.Parameters)
{
if (node.Mapped.All(m => m.Parameter != parameter))
{
node.Unmapped.Add(parameter);
}
}
return node;
}
private void ParseString(
CommandTreeParserContext context,
CommandTreeTokenStream stream,
CommandTree node)
{
if (context.State == State.Remaining)
{
stream.Consume(CommandTreeToken.Kind.String);
return;
}
var token = stream.Expect(CommandTreeToken.Kind.String);
// Command?
var command = node.Command.FindCommand(token.Value, CaseSensitivity);
if (command != null)
{
if (context.State == State.Normal)
{
node.Next = ParseCommand(context, node.Command, node, stream);
}
return;
}
// Current command has no arguments?
if (!node.HasArguments())
{
throw CommandParseException.UnknownCommand(_configuration, node, context.Arguments, token);
}
// Argument?
var parameter = node.FindArgument(context.CurrentArgumentPosition);
if (parameter == null)
{
// No parameters left. Any commands after this?
if (node.Command.Children.Count > 0 || node.Command.IsDefaultCommand)
{
throw CommandParseException.UnknownCommand(_configuration, node, context.Arguments, token);
}
throw CommandParseException.CouldNotMatchArgument(context.Arguments, token);
}
// Yes, this was an argument.
if (parameter.ParameterKind == ParameterKind.Vector)
{
// Vector
var current = stream.Current;
while (current?.TokenKind == CommandTreeToken.Kind.String)
{
var value = stream.Consume(CommandTreeToken.Kind.String)?.Value;
node.Mapped.Add(new MappedCommandParameter(parameter, value));
current = stream.Current;
}
}
else
{
// Scalar
var value = stream.Consume(CommandTreeToken.Kind.String)?.Value;
node.Mapped.Add(new MappedCommandParameter(parameter, value));
context.IncreaseArgumentPosition();
}
}
private void ParseOption(
CommandTreeParserContext context,
CommandTreeTokenStream stream,
CommandTreeToken token,
CommandTree node,
bool isLongOption)
{
// Consume the option token.
stream.Consume(isLongOption ? CommandTreeToken.Kind.LongOption : CommandTreeToken.Kind.ShortOption);
if (context.State == State.Normal)
{
// Find the option.
var option = node.FindOption(token.Value, isLongOption, CaseSensitivity);
if (option != null)
{
node.Mapped.Add(new MappedCommandParameter(
option, ParseOptionValue(context, stream, token, node, option)));
return;
}
// Help?
if (_help?.IsMatch(token.Value) == true)
{
node.ShowHelp = true;
return;
}
}
if (context.State == State.Remaining)
{
ParseOptionValue(context, stream, token, node, null);
return;
}
if (context.ParsingMode == ParsingMode.Strict)
{
throw CommandParseException.UnknownOption(context.Arguments, token);
}
else
{
ParseOptionValue(context, stream, token, node, null);
}
}
private string? ParseOptionValue(
CommandTreeParserContext context,
CommandTreeTokenStream stream,
CommandTreeToken token,
CommandTree current,
CommandParameter? parameter)
{
var value = default(string);
// Parse the value of the token (if any).
var valueToken = stream.Peek();
if (valueToken?.TokenKind == CommandTreeToken.Kind.String)
{
var parseValue = true;
if (token.TokenKind == CommandTreeToken.Kind.ShortOption && token.IsGrouped)
{
parseValue = false;
}
if (context.State == State.Normal && parseValue)
{
// Is this a command?
if (current.Command.FindCommand(valueToken.Value, CaseSensitivity) == null)
{
if (parameter != null)
{
if (parameter.ParameterKind == ParameterKind.Flag)
{
if (!CliConstants.AcceptedBooleanValues.Contains(valueToken.Value, StringComparer.OrdinalIgnoreCase))
{
if (!valueToken.HadSeparator)
{
// Do nothing
// - assume valueToken is unrelated to the flag parameter (ie. we've parsed it unnecessarily)
// - rely on the "No value?" code below to set the flag to its default value
// - valueToken will be handled on the next pass of the parser
}
else
{
// Flags cannot be assigned a value.
throw CommandParseException.CannotAssignValueToFlag(context.Arguments, token);
}
}
else
{
value = stream.Consume(CommandTreeToken.Kind.String)?.Value;
}
}
else
{
value = stream.Consume(CommandTreeToken.Kind.String)?.Value;
}
}
else
{
// Unknown parameter value.
value = stream.Consume(CommandTreeToken.Kind.String)?.Value;
// In relaxed parsing mode?
if (context.ParsingMode == ParsingMode.Relaxed)
{
context.AddRemainingArgument(token.Value, value);
}
}
}
}
else
{
context.AddRemainingArgument(token.Value, parseValue ? valueToken.Value : null);
}
}
else
{
if (context.State == State.Remaining || context.ParsingMode == ParsingMode.Relaxed)
{
context.AddRemainingArgument(token.Value, null);
}
}
// No value?
if (context.State == State.Normal)
{
if (value == null && parameter != null)
{
if (parameter.ParameterKind == ParameterKind.Flag)
{
value = "true";
}
else
{
if (parameter is CommandOption option)
{
if (parameter.IsFlagValue())
{
return null;
}
throw CommandParseException.OptionHasNoValue(context.Arguments, token, option);
}
else
{
// This should not happen at all. If it does, it's because we've added a new
// option type which isn't a CommandOption for some reason.
throw new InvalidOperationException($"Found invalid parameter type '{parameter.GetType().FullName}'.");
}
}
}
}
return value;
}
}