* 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 testtags/2.0
| @@ -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.Reflection; | |||
| namespace Discord.Commands | |||
| @@ -27,8 +26,8 @@ namespace Discord.Commands | |||
| /// => ReplyAsync(time); | |||
| /// </code> | |||
| /// </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(); | |||
| @@ -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); | |||
| TypeReader reader = null; | |||
| @@ -56,11 +56,36 @@ namespace Discord.Commands.Builders | |||
| 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) | |||
| return readers.FirstOrDefault().Value; | |||
| else | |||
| return Command.Module.Service.GetDefaultTypeReader(type); | |||
| return commands.GetDefaultTypeReader(type); | |||
| } | |||
| 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> | |||
| <RootNamespace>Discord</RootNamespace> | |||
| <TargetFramework>netcoreapp1.1</TargetFramework> | |||
| <DebugType>portable</DebugType> | |||
| <PackageTargetFallback>$(PackageTargetFallback);portable-net45+win8+wp8+wpa81</PackageTargetFallback> | |||
| </PropertyGroup> | |||
| <ItemGroup> | |||
| @@ -23,8 +24,8 @@ | |||
| <PackageReference Include="Akavache" Version="5.0.0" /> | |||
| <PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.7.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> | |||
| </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); | |||
| } | |||
| } | |||