Move Spectre.Console.Cli to it's own package

This commit is contained in:
Patrik Svensson
2022-05-14 22:56:36 +02:00
committed by Patrik Svensson
parent b600832e00
commit 36ca22ffac
262 changed files with 736 additions and 48 deletions

View File

@ -0,0 +1,34 @@
namespace Spectre.Console.Cli;
internal sealed class CommandTree
{
public CommandInfo Command { get; }
public List<MappedCommandParameter> Mapped { get; }
public List<CommandParameter> Unmapped { get; }
public CommandTree? Parent { get; }
public CommandTree? Next { get; set; }
public bool ShowHelp { get; set; }
public CommandTree(CommandTree? parent, CommandInfo command)
{
Parent = parent;
Command = command;
Mapped = new List<MappedCommandParameter>();
Unmapped = new List<CommandParameter>();
}
public ICommand CreateCommand(ITypeResolver resolver)
{
if (Command.Delegate != null)
{
return new DelegateCommand(Command.Delegate);
}
if (resolver.Resolve(Command.CommandType) is ICommand command)
{
return command;
}
throw CommandParseException.CouldNotCreateCommand(Command.CommandType);
}
}

View File

@ -0,0 +1,66 @@
namespace Spectre.Console.Cli;
internal static class CommandTreeExtensions
{
public static CommandTree? GetRootCommand(this CommandTree node)
{
while (node.Parent != null)
{
node = node.Parent;
}
return node;
}
public static CommandTree GetLeafCommand(this CommandTree node)
{
while (node.Next != null)
{
node = node.Next;
}
return node;
}
public static bool HasArguments(this CommandTree tree)
{
return tree.Command.Parameters.OfType<CommandArgument>().Any();
}
public static CommandArgument? FindArgument(this CommandTree tree, int position)
{
return tree.Command.Parameters
.OfType<CommandArgument>()
.FirstOrDefault(c => c.Position == position);
}
public static CommandOption? FindOption(this CommandTree tree, string name, bool longOption, CaseSensitivity sensitivity)
{
return tree.Command.Parameters
.OfType<CommandOption>()
.FirstOrDefault(o => longOption
? o.LongNames.Contains(name, sensitivity.GetStringComparer(CommandPart.LongOption))
: o.ShortNames.Contains(name, StringComparer.Ordinal));
}
public static bool IsOptionMappedWithParent(this CommandTree tree, string name, bool longOption)
{
var node = tree.Parent;
while (node != null)
{
var option = node.Command?.Parameters.OfType<CommandOption>()
.FirstOrDefault(o => longOption
? o.LongNames.Contains(name, StringComparer.Ordinal)
: o.ShortNames.Contains(name, StringComparer.Ordinal));
if (option != null)
{
return node.Mapped.Any(p => p.Parameter == option);
}
node = node.Parent;
}
return false;
}
}

View File

@ -0,0 +1,383 @@
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))
{
// Flags cannot be assigned a value.
throw CommandParseException.CannotAssignValueToFlag(context.Arguments, token);
}
}
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;
}
}

View File

@ -0,0 +1,51 @@
namespace Spectre.Console.Cli;
internal class CommandTreeParserContext
{
private readonly List<string> _args;
private readonly Dictionary<string, List<string?>> _remaining;
public IReadOnlyList<string> Arguments => _args;
public int CurrentArgumentPosition { get; private set; }
public CommandTreeParser.State State { get; set; }
public ParsingMode ParsingMode { get; }
public CommandTreeParserContext(IEnumerable<string> args, ParsingMode parsingMode)
{
_args = new List<string>(args);
_remaining = new Dictionary<string, List<string?>>(StringComparer.Ordinal);
ParsingMode = parsingMode;
}
public void ResetArgumentPosition()
{
CurrentArgumentPosition = 0;
}
public void IncreaseArgumentPosition()
{
CurrentArgumentPosition++;
}
public void AddRemainingArgument(string key, string? value)
{
if (State == CommandTreeParser.State.Remaining || ParsingMode == ParsingMode.Relaxed)
{
if (!_remaining.ContainsKey(key))
{
_remaining.Add(key, new List<string?>());
}
_remaining[key].Add(value);
}
}
[SuppressMessage("Style", "IDE0004:Remove Unnecessary Cast", Justification = "Bug in analyzer?")]
public ILookup<string, string?> GetRemainingArguments()
{
return _remaining
.SelectMany(pair => pair.Value, (pair, value) => new { pair.Key, value })
.ToLookup(pair => pair.Key, pair => (string?)pair.value);
}
}

View File

@ -0,0 +1,14 @@
namespace Spectre.Console.Cli;
// Consider removing this in favor for value tuples at some point.
internal sealed class CommandTreeParserResult
{
public CommandTree? Tree { get; }
public IRemainingArguments Remaining { get; }
public CommandTreeParserResult(CommandTree? tree, IRemainingArguments remaining)
{
Tree = tree;
Remaining = remaining;
}
}

View File

@ -0,0 +1,26 @@
namespace Spectre.Console.Cli;
internal sealed class CommandTreeToken
{
public Kind TokenKind { get; }
public int Position { get; }
public string Value { get; }
public string Representation { get; }
public bool IsGrouped { get; set; }
public enum Kind
{
String,
LongOption,
ShortOption,
Remaining,
}
public CommandTreeToken(Kind kind, int position, string value, string representation)
{
TokenKind = kind;
Position = position;
Value = value;
Representation = representation;
}
}

View File

@ -0,0 +1,85 @@
namespace Spectre.Console.Cli;
internal sealed class CommandTreeTokenStream : IReadOnlyList<CommandTreeToken>
{
private readonly List<CommandTreeToken> _tokens;
private int _position;
public int Count => _tokens.Count;
public CommandTreeToken this[int index] => _tokens[index];
public CommandTreeToken? Current
{
get
{
if (_position >= Count)
{
return null;
}
return _tokens[_position];
}
}
public CommandTreeTokenStream(IEnumerable<CommandTreeToken> tokens)
{
_tokens = new List<CommandTreeToken>(tokens ?? Enumerable.Empty<CommandTreeToken>());
_position = 0;
}
public CommandTreeToken? Peek(int index = 0)
{
var position = _position + index;
if (position >= Count)
{
return null;
}
return _tokens[position];
}
public CommandTreeToken? Consume()
{
if (_position >= Count)
{
return null;
}
var token = _tokens[_position];
_position++;
return token;
}
public CommandTreeToken? Consume(CommandTreeToken.Kind type)
{
Expect(type);
return Consume();
}
public CommandTreeToken Expect(CommandTreeToken.Kind expected)
{
if (Current == null)
{
throw CommandParseException.ExpectedTokenButFoundNull(expected);
}
var found = Current.TokenKind;
if (expected != found)
{
throw CommandParseException.ExpectedTokenButFoundOther(expected, found);
}
return Current;
}
public IEnumerator<CommandTreeToken> GetEnumerator()
{
return _tokens.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}

View File

@ -0,0 +1,299 @@
namespace Spectre.Console.Cli;
internal static class CommandTreeTokenizer
{
public enum Mode
{
Normal = 0,
Remaining = 1,
}
// Consider removing this in favor for value tuples at some point.
public sealed class CommandTreeTokenizerResult
{
public CommandTreeTokenStream Tokens { get; }
public IReadOnlyList<string> Remaining { get; }
public CommandTreeTokenizerResult(CommandTreeTokenStream tokens, IReadOnlyList<string> remaining)
{
Tokens = tokens;
Remaining = remaining;
}
}
public static CommandTreeTokenizerResult Tokenize(IEnumerable<string> args)
{
var tokens = new List<CommandTreeToken>();
var position = 0;
var previousReader = default(TextBuffer);
var context = new CommandTreeTokenizerContext();
foreach (var arg in args)
{
var start = position;
var reader = new TextBuffer(previousReader, arg);
// Parse the token.
position = ParseToken(context, reader, position, start, tokens);
context.FlushRemaining();
previousReader = reader;
}
previousReader?.Dispose();
return new CommandTreeTokenizerResult(
new CommandTreeTokenStream(tokens),
context.Remaining);
}
private static int ParseToken(CommandTreeTokenizerContext context, TextBuffer reader, int position, int start, List<CommandTreeToken> tokens)
{
while (reader.Peek() != -1)
{
if (reader.ReachedEnd)
{
position += reader.Position - start;
break;
}
var character = reader.Peek();
// Eat whitespace
if (char.IsWhiteSpace(character))
{
reader.Consume();
continue;
}
if (character == '-')
{
// Option
tokens.AddRange(ScanOptions(context, reader));
}
else
{
// Command or argument
tokens.Add(ScanString(context, reader));
}
// Flush remaining tokens
context.FlushRemaining();
}
return position;
}
private static CommandTreeToken ScanString(
CommandTreeTokenizerContext context,
TextBuffer reader,
char[]? stop = null)
{
if (reader.TryPeek(out var character))
{
// Is this a quoted string?
if (character == '\"')
{
return ScanQuotedString(context, reader);
}
}
var position = reader.Position;
var builder = new StringBuilder();
while (!reader.ReachedEnd)
{
var current = reader.Peek();
if (stop?.Contains(current) ?? false)
{
break;
}
reader.Read(); // Consume
context.AddRemaining(current);
builder.Append(current);
}
var value = builder.ToString();
return new CommandTreeToken(CommandTreeToken.Kind.String, position, value.Trim(), value);
}
private static CommandTreeToken ScanQuotedString(CommandTreeTokenizerContext context, TextBuffer reader)
{
var position = reader.Position;
context.FlushRemaining();
reader.Consume('\"');
var builder = new StringBuilder();
var terminated = false;
while (!reader.ReachedEnd)
{
var character = reader.Peek();
if (character == '\"')
{
terminated = true;
reader.Read();
break;
}
builder.Append(reader.Read());
}
if (!terminated)
{
var unterminatedQuote = builder.ToString();
var token = new CommandTreeToken(CommandTreeToken.Kind.String, position, unterminatedQuote, $"\"{unterminatedQuote}");
throw CommandParseException.UnterminatedQuote(reader.Original, token);
}
var quotedString = builder.ToString();
// Add to the context
context.AddRemaining(quotedString);
return new CommandTreeToken(
CommandTreeToken.Kind.String,
position, quotedString,
quotedString);
}
private static IEnumerable<CommandTreeToken> ScanOptions(CommandTreeTokenizerContext context, TextBuffer reader)
{
var result = new List<CommandTreeToken>();
var position = reader.Position;
reader.Consume('-');
context.AddRemaining('-');
if (!reader.TryPeek(out var character))
{
var token = new CommandTreeToken(CommandTreeToken.Kind.ShortOption, position, "-", "-");
throw CommandParseException.OptionHasNoName(reader.Original, token);
}
switch (character)
{
case '-':
var option = ScanLongOption(context, reader, position);
if (option != null)
{
result.Add(option);
}
break;
default:
result.AddRange(ScanShortOptions(context, reader, position));
break;
}
if (reader.TryPeek(out character))
{
// Encountered a separator?
if (character == '=' || character == ':')
{
reader.Read(); // Consume
context.AddRemaining(character);
if (!reader.TryPeek(out _))
{
var token = new CommandTreeToken(CommandTreeToken.Kind.String, reader.Position, "=", "=");
throw CommandParseException.OptionValueWasExpected(reader.Original, token);
}
result.Add(ScanString(context, reader));
}
}
return result;
}
private static IEnumerable<CommandTreeToken> ScanShortOptions(CommandTreeTokenizerContext context, TextBuffer reader, int position)
{
var result = new List<CommandTreeToken>();
while (!reader.ReachedEnd)
{
var current = reader.Peek();
if (char.IsWhiteSpace(current))
{
break;
}
// Encountered a separator?
if (current == '=' || current == ':')
{
break;
}
if (char.IsLetter(current))
{
context.AddRemaining(current);
reader.Read(); // Consume
var value = current.ToString(CultureInfo.InvariantCulture);
result.Add(result.Count == 0
? new CommandTreeToken(CommandTreeToken.Kind.ShortOption, position, value, $"-{value}")
: new CommandTreeToken(CommandTreeToken.Kind.ShortOption, position + result.Count, value, value));
}
else
{
// Create a token representing the short option.
var tokenPosition = position + 1 + result.Count;
var represntation = current.ToString(CultureInfo.InvariantCulture);
var token = new CommandTreeToken(CommandTreeToken.Kind.ShortOption, tokenPosition, represntation, represntation);
throw CommandParseException.InvalidShortOptionName(reader.Original, token);
}
}
if (result.Count > 1)
{
foreach (var item in result)
{
item.IsGrouped = true;
}
}
return result;
}
private static CommandTreeToken ScanLongOption(CommandTreeTokenizerContext context, TextBuffer reader, int position)
{
reader.Consume('-');
context.AddRemaining('-');
if (reader.ReachedEnd)
{
// Rest of the arguments are remaining ones.
context.Mode = Mode.Remaining;
return new CommandTreeToken(CommandTreeToken.Kind.Remaining, position, "--", "--");
}
var name = ScanString(context, reader, new[] { '=', ':' });
// Perform validation of the name.
if (name.Value.Length == 0)
{
throw CommandParseException.LongOptionNameIsMissing(reader, position);
}
if (name.Value.Length == 1)
{
throw CommandParseException.LongOptionNameIsOneCharacter(reader, position, name.Value);
}
if (char.IsDigit(name.Value[0]))
{
throw CommandParseException.LongOptionNameStartWithDigit(reader, position, name.Value);
}
for (var index = 0; index < name.Value.Length; index++)
{
if (!char.IsLetterOrDigit(name.Value[index]) && name.Value[index] != '-' && name.Value[index] != '_')
{
throw CommandParseException.LongOptionNameContainSymbol(reader, position + 2 + index, name.Value[index]);
}
}
return new CommandTreeToken(CommandTreeToken.Kind.LongOption, position, name.Value, $"--{name.Value}");
}
}

View File

@ -0,0 +1,44 @@
namespace Spectre.Console.Cli;
internal sealed class CommandTreeTokenizerContext
{
private readonly StringBuilder _builder;
private readonly List<string> _remaining;
public CommandTreeTokenizer.Mode Mode { get; set; }
public IReadOnlyList<string> Remaining => _remaining;
public CommandTreeTokenizerContext()
{
_builder = new StringBuilder();
_remaining = new List<string>();
}
public void AddRemaining(char character)
{
if (Mode == CommandTreeTokenizer.Mode.Remaining)
{
_builder.Append(character);
}
}
public void AddRemaining(string text)
{
if (Mode == CommandTreeTokenizer.Mode.Remaining)
{
_builder.Append(text);
}
}
public void FlushRemaining()
{
if (Mode == CommandTreeTokenizer.Mode.Remaining)
{
if (_builder.Length > 0)
{
_remaining.Add(_builder.ToString());
_builder.Clear();
}
}
}
}

View File

@ -0,0 +1,14 @@
namespace Spectre.Console.Cli;
// Consider removing this in favor for value tuples at some point.
internal sealed class MappedCommandParameter
{
public CommandParameter Parameter { get; }
public string? Value { get; }
public MappedCommandParameter(CommandParameter parameter, string? value)
{
Parameter = parameter;
Value = value;
}
}