Skip to content

Commit

Permalink
feature: Add a way to invoke a command specifying optional values by …
Browse files Browse the repository at this point in the history
…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
  • Loading branch information
Joe4evr authored and foxbot committed Nov 6, 2018
1 parent 8ef5f81 commit 419c0a5
Show file tree
Hide file tree
Showing 7 changed files with 369 additions and 9 deletions.
11 changes: 11 additions & 0 deletions src/Discord.Net.Commands/Attributes/NamedArgumentTypeAttribute.cs
@@ -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
Expand Down Expand Up @@ -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();

Expand Down
2 changes: 1 addition & 1 deletion src/Discord.Net.Commands/Builders/ModuleClassBuilder.cs
Expand Up @@ -280,7 +280,7 @@ private static void BuildParameter(ParameterBuilder builder, System.Reflection.P
}
}

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;
Expand Down
29 changes: 27 additions & 2 deletions src/Discord.Net.Commands/Builders/ParameterBuilder.cs
Expand Up @@ -56,11 +56,36 @@ internal void SetType(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)
return readers.FirstOrDefault().Value;
else
return Command.Module.Service.GetDefaultTypeReader(type);
return commands.GetDefaultTypeReader(type);
}

public ParameterBuilder WithSummary(string summary)
Expand Down
191 changes: 191 additions & 0 deletions src/Discord.Net.Commands/Readers/NamedArgumentTypeReader.cs
@@ -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
}
}
}
7 changes: 4 additions & 3 deletions test/Discord.Net.Tests/Discord.Net.Tests.csproj
Expand Up @@ -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>
Expand All @@ -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 comments on commit 419c0a5

Please sign in to comment.