feature: Add a way to invoke a command specifying optional values by name (#1123)
* Add NamedArgumentTypeAttribute * Add NamedArgumentTypeReader * Fix superflous empty line. * Fix logic for quoted arguments * Throw an exception with a tailored message. * Add a catch to wrap parsing/input errors * Trim potential excess whitespace * Fix an off-by-one * Support to read an IEnumerable property * Add a doc * Add assertion for the collection test
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Discord.Commands
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Instructs the command system to treat command paramters of this type
|
||||||
|
/// as a collection of named arguments matching to its properties.
|
||||||
|
/// </summary>
|
||||||
|
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
|
||||||
|
public sealed class NamedArgumentTypeAttribute : Attribute { }
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
|
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
|
||||||
namespace Discord.Commands
|
namespace Discord.Commands
|
||||||
@@ -27,8 +26,8 @@ namespace Discord.Commands
|
|||||||
/// => ReplyAsync(time);
|
/// => ReplyAsync(time);
|
||||||
/// </code>
|
/// </code>
|
||||||
/// </example>
|
/// </example>
|
||||||
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)]
|
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
|
||||||
public class OverrideTypeReaderAttribute : Attribute
|
public sealed class OverrideTypeReaderAttribute : Attribute
|
||||||
{
|
{
|
||||||
private static readonly TypeInfo TypeReaderTypeInfo = typeof(TypeReader).GetTypeInfo();
|
private static readonly TypeInfo TypeReaderTypeInfo = typeof(TypeReader).GetTypeInfo();
|
||||||
|
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ namespace Discord.Commands
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static TypeReader GetTypeReader(CommandService service, Type paramType, Type typeReaderType, IServiceProvider services)
|
internal static TypeReader GetTypeReader(CommandService service, Type paramType, Type typeReaderType, IServiceProvider services)
|
||||||
{
|
{
|
||||||
var readers = service.GetTypeReaders(paramType);
|
var readers = service.GetTypeReaders(paramType);
|
||||||
TypeReader reader = null;
|
TypeReader reader = null;
|
||||||
|
|||||||
@@ -56,11 +56,36 @@ namespace Discord.Commands.Builders
|
|||||||
|
|
||||||
private TypeReader GetReader(Type type)
|
private TypeReader GetReader(Type type)
|
||||||
{
|
{
|
||||||
var readers = Command.Module.Service.GetTypeReaders(type);
|
var commands = Command.Module.Service;
|
||||||
|
if (type.GetTypeInfo().GetCustomAttribute<NamedArgumentTypeAttribute>() != null)
|
||||||
|
{
|
||||||
|
IsRemainder = true;
|
||||||
|
var reader = commands.GetTypeReaders(type)?.FirstOrDefault().Value;
|
||||||
|
if (reader == null)
|
||||||
|
{
|
||||||
|
Type readerType;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
readerType = typeof(NamedArgumentTypeReader<>).MakeGenericType(new[] { type });
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Parameter type '{type.Name}' for command '{Command.Name}' must be a class with a public parameterless constructor to use as a NamedArgumentType.", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
reader = (TypeReader)Activator.CreateInstance(readerType, new[] { commands });
|
||||||
|
commands.AddTypeReader(type, reader);
|
||||||
|
}
|
||||||
|
|
||||||
|
return reader;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var readers = commands.GetTypeReaders(type);
|
||||||
if (readers != null)
|
if (readers != null)
|
||||||
return readers.FirstOrDefault().Value;
|
return readers.FirstOrDefault().Value;
|
||||||
else
|
else
|
||||||
return Command.Module.Service.GetDefaultTypeReader(type);
|
return commands.GetDefaultTypeReader(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ParameterBuilder WithSummary(string summary)
|
public ParameterBuilder WithSummary(string summary)
|
||||||
|
|||||||
191
src/Discord.Net.Commands/Readers/NamedArgumentTypeReader.cs
Normal file
191
src/Discord.Net.Commands/Readers/NamedArgumentTypeReader.cs
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Discord.Commands
|
||||||
|
{
|
||||||
|
internal sealed class NamedArgumentTypeReader<T> : TypeReader
|
||||||
|
where T : class, new()
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyDictionary<string, PropertyInfo> _tProps = typeof(T).GetTypeInfo().DeclaredProperties
|
||||||
|
.Where(p => p.SetMethod != null && p.SetMethod.IsPublic && !p.SetMethod.IsStatic)
|
||||||
|
.ToImmutableDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private readonly CommandService _commands;
|
||||||
|
|
||||||
|
public NamedArgumentTypeReader(CommandService commands)
|
||||||
|
{
|
||||||
|
_commands = commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<TypeReaderResult> ReadAsync(ICommandContext context, string input, IServiceProvider services)
|
||||||
|
{
|
||||||
|
var result = new T();
|
||||||
|
var state = ReadState.LookingForParameter;
|
||||||
|
int beginRead = 0, currentRead = 0;
|
||||||
|
|
||||||
|
while (state != ReadState.End)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var prop = Read(out var arg);
|
||||||
|
var propVal = await ReadArgumentAsync(prop, arg).ConfigureAwait(false);
|
||||||
|
if (propVal != null)
|
||||||
|
prop.SetMethod.Invoke(result, new[] { propVal });
|
||||||
|
else
|
||||||
|
return TypeReaderResult.FromError(CommandError.ParseFailed, $"Could not parse the argument for the parameter '{prop.Name}' as type '{prop.PropertyType}'.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
//TODO: use the Exception overload after a rebase on latest
|
||||||
|
return TypeReaderResult.FromError(CommandError.Exception, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return TypeReaderResult.FromSuccess(result);
|
||||||
|
|
||||||
|
PropertyInfo Read(out string arg)
|
||||||
|
{
|
||||||
|
string currentParam = null;
|
||||||
|
char match = '\0';
|
||||||
|
|
||||||
|
for (; currentRead < input.Length; currentRead++)
|
||||||
|
{
|
||||||
|
var currentChar = input[currentRead];
|
||||||
|
switch (state)
|
||||||
|
{
|
||||||
|
case ReadState.LookingForParameter:
|
||||||
|
if (Char.IsWhiteSpace(currentChar))
|
||||||
|
continue;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
beginRead = currentRead;
|
||||||
|
state = ReadState.InParameter;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ReadState.InParameter:
|
||||||
|
if (currentChar != ':')
|
||||||
|
continue;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
currentParam = input.Substring(beginRead, currentRead - beginRead);
|
||||||
|
state = ReadState.LookingForArgument;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ReadState.LookingForArgument:
|
||||||
|
if (Char.IsWhiteSpace(currentChar))
|
||||||
|
continue;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
beginRead = currentRead;
|
||||||
|
state = (QuotationAliasUtils.GetDefaultAliasMap.TryGetValue(currentChar, out match))
|
||||||
|
? ReadState.InQuotedArgument
|
||||||
|
: ReadState.InArgument;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ReadState.InArgument:
|
||||||
|
if (!Char.IsWhiteSpace(currentChar))
|
||||||
|
continue;
|
||||||
|
else
|
||||||
|
return GetPropAndValue(out arg);
|
||||||
|
case ReadState.InQuotedArgument:
|
||||||
|
if (currentChar != match)
|
||||||
|
continue;
|
||||||
|
else
|
||||||
|
return GetPropAndValue(out arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentParam == null)
|
||||||
|
throw new InvalidOperationException("No parameter name was read.");
|
||||||
|
|
||||||
|
return GetPropAndValue(out arg);
|
||||||
|
|
||||||
|
PropertyInfo GetPropAndValue(out string argv)
|
||||||
|
{
|
||||||
|
bool quoted = state == ReadState.InQuotedArgument;
|
||||||
|
state = (currentRead == (quoted ? input.Length - 1 : input.Length))
|
||||||
|
? ReadState.End
|
||||||
|
: ReadState.LookingForParameter;
|
||||||
|
|
||||||
|
if (quoted)
|
||||||
|
{
|
||||||
|
argv = input.Substring(beginRead + 1, currentRead - beginRead - 1).Trim();
|
||||||
|
currentRead++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
argv = input.Substring(beginRead, currentRead - beginRead);
|
||||||
|
|
||||||
|
return _tProps[currentParam];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<object> ReadArgumentAsync(PropertyInfo prop, string arg)
|
||||||
|
{
|
||||||
|
var elemType = prop.PropertyType;
|
||||||
|
bool isCollection = false;
|
||||||
|
if (elemType.GetTypeInfo().IsGenericType && elemType.GetGenericTypeDefinition() == typeof(IEnumerable<>))
|
||||||
|
{
|
||||||
|
elemType = prop.PropertyType.GenericTypeArguments[0];
|
||||||
|
isCollection = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var overridden = prop.GetCustomAttribute<OverrideTypeReaderAttribute>();
|
||||||
|
var reader = (overridden != null)
|
||||||
|
? ModuleClassBuilder.GetTypeReader(_commands, elemType, overridden.TypeReader, services)
|
||||||
|
: (_commands.GetDefaultTypeReader(elemType)
|
||||||
|
?? _commands.GetTypeReaders(elemType).FirstOrDefault().Value);
|
||||||
|
|
||||||
|
if (reader != null)
|
||||||
|
{
|
||||||
|
if (isCollection)
|
||||||
|
{
|
||||||
|
var method = _readMultipleMethod.MakeGenericMethod(elemType);
|
||||||
|
var task = (Task<IEnumerable>)method.Invoke(null, new object[] { reader, context, arg.Split(','), services });
|
||||||
|
return await task.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
return await ReadSingle(reader, context, arg, services).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<object> ReadSingle(TypeReader reader, ICommandContext context, string arg, IServiceProvider services)
|
||||||
|
{
|
||||||
|
var readResult = await reader.ReadAsync(context, arg, services).ConfigureAwait(false);
|
||||||
|
return (readResult.IsSuccess)
|
||||||
|
? readResult.BestMatch
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
private static async Task<IEnumerable> ReadMultiple<TObj>(TypeReader reader, ICommandContext context, IEnumerable<string> args, IServiceProvider services)
|
||||||
|
{
|
||||||
|
var objs = new List<TObj>();
|
||||||
|
foreach (var arg in args)
|
||||||
|
{
|
||||||
|
var read = await ReadSingle(reader, context, arg.Trim(), services).ConfigureAwait(false);
|
||||||
|
if (read != null)
|
||||||
|
objs.Add((TObj)read);
|
||||||
|
}
|
||||||
|
return objs.ToImmutableArray();
|
||||||
|
}
|
||||||
|
private static readonly MethodInfo _readMultipleMethod = typeof(NamedArgumentTypeReader<T>)
|
||||||
|
.GetTypeInfo()
|
||||||
|
.DeclaredMethods
|
||||||
|
.Single(m => m.IsPrivate && m.IsStatic && m.Name == nameof(ReadMultiple));
|
||||||
|
|
||||||
|
private enum ReadState
|
||||||
|
{
|
||||||
|
LookingForParameter,
|
||||||
|
InParameter,
|
||||||
|
LookingForArgument,
|
||||||
|
InArgument,
|
||||||
|
InQuotedArgument,
|
||||||
|
End
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<RootNamespace>Discord</RootNamespace>
|
<RootNamespace>Discord</RootNamespace>
|
||||||
<TargetFramework>netcoreapp1.1</TargetFramework>
|
<TargetFramework>netcoreapp1.1</TargetFramework>
|
||||||
|
<DebugType>portable</DebugType>
|
||||||
<PackageTargetFallback>$(PackageTargetFallback);portable-net45+win8+wp8+wpa81</PackageTargetFallback>
|
<PackageTargetFallback>$(PackageTargetFallback);portable-net45+win8+wp8+wpa81</PackageTargetFallback>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -23,8 +24,8 @@
|
|||||||
<PackageReference Include="Akavache" Version="5.0.0" />
|
<PackageReference Include="Akavache" Version="5.0.0" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.7.2" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.7.2" />
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
|
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
|
||||||
<PackageReference Include="xunit" Version="2.3.1" />
|
<PackageReference Include="xunit" Version="2.4.0" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.3.1" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.0" />
|
||||||
<PackageReference Include="xunit.runner.reporters" Version="2.3.1" />
|
<PackageReference Include="xunit.runner.reporters" Version="2.4.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
133
test/Discord.Net.Tests/Tests.TypeReaders.cs
Normal file
133
test/Discord.Net.Tests/Tests.TypeReaders.cs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Discord.Commands;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Discord
|
||||||
|
{
|
||||||
|
public sealed class TypeReaderTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task TestNamedArgumentReader()
|
||||||
|
{
|
||||||
|
var commands = new CommandService();
|
||||||
|
var module = await commands.AddModuleAsync<TestModule>(null);
|
||||||
|
|
||||||
|
Assert.NotNull(module);
|
||||||
|
Assert.NotEmpty(module.Commands);
|
||||||
|
|
||||||
|
var cmd = module.Commands[0];
|
||||||
|
Assert.NotNull(cmd);
|
||||||
|
Assert.NotEmpty(cmd.Parameters);
|
||||||
|
|
||||||
|
var param = cmd.Parameters[0];
|
||||||
|
Assert.NotNull(param);
|
||||||
|
Assert.True(param.IsRemainder);
|
||||||
|
|
||||||
|
var result = await param.ParseAsync(null, "bar: hello foo: 42");
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
|
||||||
|
var m = result.BestMatch as ArgumentType;
|
||||||
|
Assert.NotNull(m);
|
||||||
|
Assert.Equal(expected: 42, actual: m.Foo);
|
||||||
|
Assert.Equal(expected: "hello", actual: m.Bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TestQuotedArgumentValue()
|
||||||
|
{
|
||||||
|
var commands = new CommandService();
|
||||||
|
var module = await commands.AddModuleAsync<TestModule>(null);
|
||||||
|
|
||||||
|
Assert.NotNull(module);
|
||||||
|
Assert.NotEmpty(module.Commands);
|
||||||
|
|
||||||
|
var cmd = module.Commands[0];
|
||||||
|
Assert.NotNull(cmd);
|
||||||
|
Assert.NotEmpty(cmd.Parameters);
|
||||||
|
|
||||||
|
var param = cmd.Parameters[0];
|
||||||
|
Assert.NotNull(param);
|
||||||
|
Assert.True(param.IsRemainder);
|
||||||
|
|
||||||
|
var result = await param.ParseAsync(null, "foo: 42 bar: 《hello》");
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
|
||||||
|
var m = result.BestMatch as ArgumentType;
|
||||||
|
Assert.NotNull(m);
|
||||||
|
Assert.Equal(expected: 42, actual: m.Foo);
|
||||||
|
Assert.Equal(expected: "hello", actual: m.Bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TestNonPatternInput()
|
||||||
|
{
|
||||||
|
var commands = new CommandService();
|
||||||
|
var module = await commands.AddModuleAsync<TestModule>(null);
|
||||||
|
|
||||||
|
Assert.NotNull(module);
|
||||||
|
Assert.NotEmpty(module.Commands);
|
||||||
|
|
||||||
|
var cmd = module.Commands[0];
|
||||||
|
Assert.NotNull(cmd);
|
||||||
|
Assert.NotEmpty(cmd.Parameters);
|
||||||
|
|
||||||
|
var param = cmd.Parameters[0];
|
||||||
|
Assert.NotNull(param);
|
||||||
|
Assert.True(param.IsRemainder);
|
||||||
|
|
||||||
|
var result = await param.ParseAsync(null, "foobar");
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(expected: CommandError.Exception, actual: result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TestMultiple()
|
||||||
|
{
|
||||||
|
var commands = new CommandService();
|
||||||
|
var module = await commands.AddModuleAsync<TestModule>(null);
|
||||||
|
|
||||||
|
Assert.NotNull(module);
|
||||||
|
Assert.NotEmpty(module.Commands);
|
||||||
|
|
||||||
|
var cmd = module.Commands[0];
|
||||||
|
Assert.NotNull(cmd);
|
||||||
|
Assert.NotEmpty(cmd.Parameters);
|
||||||
|
|
||||||
|
var param = cmd.Parameters[0];
|
||||||
|
Assert.NotNull(param);
|
||||||
|
Assert.True(param.IsRemainder);
|
||||||
|
|
||||||
|
var result = await param.ParseAsync(null, "manyints: \"1, 2, 3, 4, 5, 6, 7\"");
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
|
||||||
|
var m = result.BestMatch as ArgumentType;
|
||||||
|
Assert.NotNull(m);
|
||||||
|
Assert.Equal(expected: new int[] { 1, 2, 3, 4, 5, 6, 7 }, actual: m.ManyInts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[NamedArgumentType]
|
||||||
|
public sealed class ArgumentType
|
||||||
|
{
|
||||||
|
public int Foo { get; set; }
|
||||||
|
|
||||||
|
[OverrideTypeReader(typeof(CustomTypeReader))]
|
||||||
|
public string Bar { get; set; }
|
||||||
|
|
||||||
|
public IEnumerable<int> ManyInts { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CustomTypeReader : TypeReader
|
||||||
|
{
|
||||||
|
public override Task<TypeReaderResult> ReadAsync(ICommandContext context, string input, IServiceProvider services)
|
||||||
|
=> Task.FromResult(TypeReaderResult.FromSuccess(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class TestModule : ModuleBase
|
||||||
|
{
|
||||||
|
[Command("test")]
|
||||||
|
public Task TestCommand(ArgumentType arg) => Task.Delay(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user