* 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 testpull/1191/head
@@ -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)] | |||||
public class OverrideTypeReaderAttribute : Attribute | |||||
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = true)] | |||||
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) | ||||
@@ -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.runner.visualstudio" Version="2.3.1" /> | |||||
<PackageReference Include="xunit.runner.reporters" Version="2.3.1" /> | |||||
<PackageReference Include="xunit" Version="2.4.0" /> | |||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.0" /> | |||||
<PackageReference Include="xunit.runner.reporters" Version="2.4.0" /> | |||||
</ItemGroup> | </ItemGroup> | ||||
</Project> | </Project> |
@@ -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); | |||||
} | |||||
} |