diff --git a/src/Discord.Net.Commands/Builders/CommandBuilder.cs b/src/Discord.Net.Commands/Builders/CommandBuilder.cs new file mode 100644 index 000000000..921e5df0a --- /dev/null +++ b/src/Discord.Net.Commands/Builders/CommandBuilder.cs @@ -0,0 +1,118 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using System.Collections.Generic; + +namespace Discord.Commands.Builders +{ + public class CommandBuilder + { + private List preconditions; + private List parameters; + private List aliases; + + internal CommandBuilder(ModuleBuilder module) + { + preconditions = new List(); + parameters = new List(); + aliases = new List(); + + Module = module; + } + + public string Name { get; set; } + public string Summary { get; set; } + public string Remarks { get; set; } + public RunMode RunMode { get; set; } + public int Priority { get; set; } + public Func Callback { get; set; } + public ModuleBuilder Module { get; } + + public List Preconditions => preconditions; + public List Parameters => parameters; + public List Aliases => aliases; + + public CommandBuilder SetName(string name) + { + Name = name; + return this; + } + + public CommandBuilder SetSummary(string summary) + { + Summary = summary; + return this; + } + + public CommandBuilder SetRemarks(string remarks) + { + Remarks = remarks; + return this; + } + + public CommandBuilder SetRunMode(RunMode runMode) + { + RunMode = runMode; + return this; + } + + public CommandBuilder SetPriority(int priority) + { + Priority = priority; + return this; + } + + public CommandBuilder SetCallback(Func callback) + { + Callback = callback; + return this; + } + + public CommandBuilder AddPrecondition(PreconditionAttribute precondition) + { + preconditions.Add(precondition); + return this; + } + + public CommandBuilder AddParameter(Action createFunc) + { + var param = new ParameterBuilder(); + createFunc(param); + parameters.Add(param); + return this; + } + + public CommandBuilder AddAliases(params string[] newAliases) + { + aliases.AddRange(newAliases); + return this; + } + + internal CommandInfo Build(ModuleInfo info, CommandService service) + { + if (aliases.Count == 0) + throw new InvalidOperationException("Commands require at least one alias to be registered"); + + if (Callback == null) + throw new InvalidOperationException("Commands require a callback to be built"); + + if (Name == null) + Name = aliases[0]; + + if (parameters.Count > 0) + { + var lastParam = parameters[parameters.Count - 1]; + + var firstMultipleParam = parameters.FirstOrDefault(x => x.Multiple); + if ((firstMultipleParam != null) && (firstMultipleParam != lastParam)) + throw new InvalidOperationException("Only the last parameter in a command may have the Multiple flag."); + + var firstRemainderParam = parameters.FirstOrDefault(x => x.Remainder); + if ((firstRemainderParam != null) && (firstRemainderParam != lastParam)) + throw new InvalidOperationException("Only the last parameter in a command may have the Remainder flag."); + } + + return new CommandInfo(this, info, service); + } + } +} \ No newline at end of file diff --git a/src/Discord.Net.Commands/Builders/ModuleBuilder.cs b/src/Discord.Net.Commands/Builders/ModuleBuilder.cs new file mode 100644 index 000000000..148eedfcf --- /dev/null +++ b/src/Discord.Net.Commands/Builders/ModuleBuilder.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord.Commands.Builders +{ + public class ModuleBuilder + { + private List commands; + private List submodules; + private List preconditions; + private List aliases; + + public ModuleBuilder() + { + commands = new List(); + submodules = new List(); + preconditions = new List(); + aliases = new List(); + } + + internal ModuleBuilder(ModuleBuilder parent) + : this() + { + ParentModule = parent; + } + + public string Name { get; set; } + public string Summary { get; set; } + public string Remarks { get; set; } + public ModuleBuilder ParentModule { get; } + + public List Commands => commands; + public List Modules => submodules; + public List Preconditions => preconditions; + public List Aliases => aliases; + + public ModuleBuilder SetName(string name) + { + Name = name; + return this; + } + + public ModuleBuilder SetSummary(string summary) + { + Summary = summary; + return this; + } + + public ModuleBuilder SetRemarks(string remarks) + { + Remarks = remarks; + return this; + } + + public ModuleBuilder AddAliases(params string[] newAliases) + { + aliases.AddRange(newAliases); + return this; + } + + public ModuleBuilder AddPrecondition(PreconditionAttribute precondition) + { + preconditions.Add(precondition); + return this; + } + + public ModuleBuilder AddCommand(Action createFunc) + { + var builder = new CommandBuilder(this); + createFunc(builder); + commands.Add(builder); + return this; + } + + public ModuleBuilder AddSubmodule(Action createFunc) + { + var builder = new ModuleBuilder(this); + createFunc(builder); + submodules.Add(builder); + return this; + } + + public ModuleInfo Build(CommandService service) + { + if (aliases.Count == 0) + throw new InvalidOperationException("Modules require at least one alias to be registered"); + + if (commands.Count == 0 && submodules.Count == 0) + throw new InvalidOperationException("Tried to build empty module"); + + if (Name == null) + Name = aliases[0]; + + return new ModuleInfo(this, service); + } + } +} diff --git a/src/Discord.Net.Commands/Builders/ParameterBuilder.cs b/src/Discord.Net.Commands/Builders/ParameterBuilder.cs new file mode 100644 index 000000000..a6c8a5c23 --- /dev/null +++ b/src/Discord.Net.Commands/Builders/ParameterBuilder.cs @@ -0,0 +1,100 @@ +using System; +using System.Threading.Tasks; +using System.Collections.Generic; + +namespace Discord.Commands.Builders +{ + public class ParameterBuilder + { + public ParameterBuilder() + { } + + public ParameterBuilder(string name) + { + Name = name; + } + + public string Name { get; set; } + public string Summary { get; set; } + public object DefaultValue { get; set; } + public Type ParameterType { get; set; } + + public TypeReader TypeReader { get; set; } + + public bool Optional { get; set; } + public bool Remainder { get; set; } + public bool Multiple { get; set; } + + public ParameterBuilder SetName(string name) + { + Name = name; + return this; + } + + public ParameterBuilder SetSummary(string summary) + { + Summary = summary; + return this; + } + + public ParameterBuilder SetDefault(T defaultValue) + { + Optional = true; + DefaultValue = defaultValue; + ParameterType = typeof(T); + + if (ParameterType.IsArray) + ParameterType = ParameterType.GetElementType(); + + return this; + } + + public ParameterBuilder SetType(Type parameterType) + { + ParameterType = parameterType; + return this; + } + + public ParameterBuilder SetTypeReader(TypeReader reader) + { + TypeReader = reader; + return this; + } + + public ParameterBuilder SetOptional(bool isOptional) + { + Optional = isOptional; + return this; + } + + public ParameterBuilder SetRemainder(bool isRemainder) + { + Remainder = isRemainder; + return this; + } + + public ParameterBuilder SetMultiple(bool isMultiple) + { + Multiple = isMultiple; + return this; + } + + internal ParameterInfo Build(CommandInfo info, CommandService service) + { + // TODO: should we throw when we don't have a name? + if (Name == null) + Name = "[unknown parameter]"; + + if (ParameterType == null) + throw new InvalidOperationException($"Could not build parameter {Name} from command {info.Name} - An invalid parameter type was given"); + + if (TypeReader == null) + TypeReader = service.GetTypeReader(ParameterType); + + if (TypeReader == null) + throw new InvalidOperationException($"Could not build parameter {Name} from command {info.Name} - A valid TypeReader could not be found"); + + return new ParameterInfo(this, info, service); + } + } +} \ No newline at end of file diff --git a/src/Discord.Net.Commands/CommandParser.cs b/src/Discord.Net.Commands/CommandParser.cs index 1808b705d..7bdbed955 100644 --- a/src/Discord.Net.Commands/CommandParser.cs +++ b/src/Discord.Net.Commands/CommandParser.cs @@ -15,7 +15,7 @@ namespace Discord.Commands public static async Task ParseArgs(CommandInfo command, CommandContext context, string input, int startPos) { - CommandParameter curParam = null; + ParameterInfo curParam = null; StringBuilder argBuilder = new StringBuilder(input.Length); int endPos = input.Length; var curPart = ParserPart.None; diff --git a/src/Discord.Net.Commands/CommandService.cs b/src/Discord.Net.Commands/CommandService.cs index ef0dba7e7..ee18157dc 100644 --- a/src/Discord.Net.Commands/CommandService.cs +++ b/src/Discord.Net.Commands/CommandService.cs @@ -7,24 +7,26 @@ using System.Reflection; using System.Threading; using System.Threading.Tasks; +using Discord.Commands.Builders; + namespace Discord.Commands { public class CommandService { - private static readonly TypeInfo _moduleTypeInfo = typeof(ModuleBase).GetTypeInfo(); - private readonly SemaphoreSlim _moduleLock; - private readonly ConcurrentDictionary _moduleDefs; + private readonly ConcurrentDictionary _typedModuleDefs; private readonly ConcurrentDictionary _typeReaders; + private readonly ConcurrentBag _moduleDefs; private readonly CommandMap _map; - public IEnumerable Modules => _moduleDefs.Select(x => x.Value); - public IEnumerable Commands => _moduleDefs.SelectMany(x => x.Value.Commands); + public IEnumerable Modules => _typedModuleDefs.Select(x => x.Value); + public IEnumerable Commands => _typedModuleDefs.SelectMany(x => x.Value.Commands); public CommandService() { _moduleLock = new SemaphoreSlim(1, 1); - _moduleDefs = new ConcurrentDictionary(); + _typedModuleDefs = new ConcurrentDictionary(); + _moduleDefs = new ConcurrentBag(); _map = new CommandMap(); _typeReaders = new ConcurrentDictionary { @@ -65,22 +67,40 @@ namespace Discord.Commands } //Modules + public async Task BuildModule(Action buildFunc) + { + await _moduleLock.WaitAsync().ConfigureAwait(false); + try + { + var builder = new ModuleBuilder(); + buildFunc(builder); + + var module = builder.Build(this); + return LoadModuleInternal(module); + } + finally + { + _moduleLock.Release(); + } + } public async Task AddModule() { await _moduleLock.WaitAsync().ConfigureAwait(false); try { var typeInfo = typeof(T).GetTypeInfo(); - if (!_moduleTypeInfo.IsAssignableFrom(typeInfo)) - throw new ArgumentException($"Modules must inherit ModuleBase."); - - if (typeInfo.IsAbstract) - throw new InvalidOperationException("Modules must not be abstract."); - if (_moduleDefs.ContainsKey(typeof(T))) + if (_typedModuleDefs.ContainsKey(typeof(T))) throw new ArgumentException($"This module has already been added."); - return AddModuleInternal(typeInfo); + var module = ModuleClassBuilder.Build(this, typeInfo).FirstOrDefault(); + + if (module.Value == default(ModuleInfo)) + throw new InvalidOperationException($"Could not build the module {typeof(T).FullName}, did you pass an invalid type?"); + + _typedModuleDefs[module.Key] = module.Value; + + return LoadModuleInternal(module.Value); } finally { @@ -89,39 +109,36 @@ namespace Discord.Commands } public async Task> AddModules(Assembly assembly) { - var moduleDefs = ImmutableArray.CreateBuilder(); await _moduleLock.WaitAsync().ConfigureAwait(false); try { - foreach (var type in assembly.ExportedTypes) + var types = ModuleClassBuilder.Search(assembly).ToArray(); + var moduleDefs = ModuleClassBuilder.Build(types, this); + + foreach (var info in moduleDefs) { - if (!_moduleDefs.ContainsKey(type)) - { - var typeInfo = type.GetTypeInfo(); - if (_moduleTypeInfo.IsAssignableFrom(typeInfo)) - { - var dontAutoLoad = typeInfo.GetCustomAttribute(); - if (dontAutoLoad == null && !typeInfo.IsAbstract) - moduleDefs.Add(AddModuleInternal(typeInfo)); - } - } + _typedModuleDefs[info.Key] = info.Value; + LoadModuleInternal(info.Value); } - return moduleDefs.ToImmutable(); + + return moduleDefs.Select(x => x.Value).ToImmutableArray(); } finally { _moduleLock.Release(); } } - private ModuleInfo AddModuleInternal(TypeInfo typeInfo) + private ModuleInfo LoadModuleInternal(ModuleInfo module) { - var moduleDef = new ModuleInfo(typeInfo, this); - _moduleDefs[typeInfo.AsType()] = moduleDef; + _moduleDefs.Add(module); - foreach (var cmd in moduleDef.Commands) - _map.AddCommand(cmd); + foreach (var command in module.Commands) + _map.AddCommand(command); - return moduleDef; + foreach (var submodule in module.Submodules) + LoadModuleInternal(submodule); + + return module; } public async Task RemoveModule(ModuleInfo module) @@ -129,7 +146,7 @@ namespace Discord.Commands await _moduleLock.WaitAsync().ConfigureAwait(false); try { - return RemoveModuleInternal(module.Source.BaseType); + return RemoveModuleInternal(module); } finally { @@ -141,24 +158,33 @@ namespace Discord.Commands await _moduleLock.WaitAsync().ConfigureAwait(false); try { - return RemoveModuleInternal(typeof(T)); + ModuleInfo module; + _typedModuleDefs.TryGetValue(typeof(T), out module); + if (module == default(ModuleInfo)) + return false; + + return RemoveModuleInternal(module); } finally { _moduleLock.Release(); } } - private bool RemoveModuleInternal(Type type) + private bool RemoveModuleInternal(ModuleInfo module) { - ModuleInfo unloadedModule; - if (_moduleDefs.TryRemove(type, out unloadedModule)) + var defsRemove = module; + if (!_moduleDefs.TryTake(out defsRemove)) + return false; + + foreach (var cmd in module.Commands) + _map.RemoveCommand(cmd); + + foreach (var submodule in module.Submodules) { - foreach (var cmd in unloadedModule.Commands) - _map.RemoveCommand(cmd); - return true; + RemoveModuleInternal(submodule); } - else - return false; + + return true; } //Type Readers diff --git a/src/Discord.Net.Commands/Extensions/IEnumerableExtensions.cs b/src/Discord.Net.Commands/Extensions/IEnumerableExtensions.cs new file mode 100644 index 000000000..b922dd903 --- /dev/null +++ b/src/Discord.Net.Commands/Extensions/IEnumerableExtensions.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; + +namespace Discord.Commands +{ + public static class IEnumerableExtensions + { + public static IEnumerable Permutate( + this IEnumerable set, + IEnumerable others, + Func func) + { + foreach (TFirst elem in set) + { + foreach (TSecond elem2 in others) + { + yield return func(elem, elem2); + } + } + } + } +} \ No newline at end of file diff --git a/src/Discord.Net.Commands/CommandInfo.cs b/src/Discord.Net.Commands/Info/CommandInfo.cs similarity index 52% rename from src/Discord.Net.Commands/CommandInfo.cs rename to src/Discord.Net.Commands/Info/CommandInfo.cs index 47aae1ae2..8ece1cc2c 100644 --- a/src/Discord.Net.Commands/CommandInfo.cs +++ b/src/Discord.Net.Commands/Info/CommandInfo.cs @@ -1,88 +1,52 @@ -using System; -using System.Collections.Concurrent; +using System; +using System.Linq; using System.Collections.Generic; using System.Collections.Immutable; -using System.Diagnostics; -using System.Linq; -using System.Reflection; +using System.Collections.Concurrent; using System.Threading.Tasks; +using System.Reflection; + +using Discord.Commands.Builders; namespace Discord.Commands { - [DebuggerDisplay(@"{DebuggerDisplay,nq}")] public class CommandInfo { - private static readonly MethodInfo _convertParamsMethod = typeof(CommandInfo).GetTypeInfo().GetDeclaredMethod(nameof(ConvertParamsList)); + private static readonly System.Reflection.MethodInfo _convertParamsMethod = typeof(CommandInfo).GetTypeInfo().GetDeclaredMethod(nameof(ConvertParamsList)); private static readonly ConcurrentDictionary, object>> _arrayConverters = new ConcurrentDictionary, object>>(); - + private readonly Func _action; - public MethodInfo Source { get; } public ModuleInfo Module { get; } public string Name { get; } public string Summary { get; } public string Remarks { get; } - public string Text { get; } public int Priority { get; } public bool HasVarArgs { get; } public RunMode RunMode { get; } + public IReadOnlyList Aliases { get; } - public IReadOnlyList Parameters { get; } + public IReadOnlyList Parameters { get; } public IReadOnlyList Preconditions { get; } - internal CommandInfo(MethodInfo source, ModuleInfo module, CommandAttribute attribute, string groupPrefix) + internal CommandInfo(CommandBuilder builder, ModuleInfo module, CommandService service) { - try - { - Source = source; - Module = module; - - Name = source.Name; - - if (attribute.Text == null) - Text = groupPrefix; - RunMode = attribute.RunMode; - - if (groupPrefix != "") - groupPrefix += " "; - - if (attribute.Text != null) - Text = groupPrefix + attribute.Text; - - var aliasesBuilder = ImmutableArray.CreateBuilder(); - - aliasesBuilder.Add(Text); - - var aliasesAttr = source.GetCustomAttribute(); - if (aliasesAttr != null) - aliasesBuilder.AddRange(aliasesAttr.Aliases.Select(x => groupPrefix + x)); - - Aliases = aliasesBuilder.ToImmutable(); - - var nameAttr = source.GetCustomAttribute(); - if (nameAttr != null) - Name = nameAttr.Text; + Module = module; + + Name = builder.Name; + Summary = builder.Summary; + Remarks = builder.Remarks; - var summary = source.GetCustomAttribute(); - if (summary != null) - Summary = summary.Text; + RunMode = builder.RunMode; + Priority = builder.Priority; - var remarksAttr = source.GetCustomAttribute(); - if (remarksAttr != null) - Remarks = remarksAttr.Text; + Aliases = module.Aliases.Permutate(builder.Aliases, (first, second) => first + " " + second).ToImmutableArray(); + Preconditions = builder.Preconditions.ToImmutableArray(); - var priorityAttr = source.GetCustomAttribute(); - Priority = priorityAttr?.Priority ?? 0; + Parameters = builder.Parameters.Select(x => x.Build(this, service)).ToImmutableArray(); + HasVarArgs = builder.Parameters.Count > 0 ? builder.Parameters[builder.Parameters.Count - 1].Multiple : false; - Parameters = BuildParameters(source); - HasVarArgs = Parameters.Count > 0 ? Parameters[Parameters.Count - 1].IsMultiple : false; - Preconditions = BuildPreconditions(source); - _action = BuildAction(source); - } - catch (Exception ex) - { - throw new Exception($"Failed to build command {source.DeclaringType.FullName}.{source.Name}", ex); - } + _action = builder.Callback; } public async Task CheckPreconditions(CommandContext context, IDependencyMap map = null) @@ -128,6 +92,7 @@ namespace Discord.Commands return await CommandParser.ParseArgs(this, context, input, 0).ConfigureAwait(false); } + public Task Execute(CommandContext context, ParseResult parseResult, IDependencyMap map) { if (!parseResult.IsSuccess) @@ -179,72 +144,6 @@ namespace Discord.Commands } } - private IReadOnlyList BuildPreconditions(MethodInfo methodInfo) - { - return methodInfo.GetCustomAttributes().ToImmutableArray(); - } - - private IReadOnlyList BuildParameters(MethodInfo methodInfo) - { - var parameters = methodInfo.GetParameters(); - - var paramBuilder = ImmutableArray.CreateBuilder(parameters.Length); - for (int i = 0; i < parameters.Length; i++) - { - var parameter = parameters[i]; - var type = parameter.ParameterType; - - //Detect 'params' - bool isMultiple = parameter.GetCustomAttribute() != null; - if (isMultiple) - type = type.GetElementType(); - - var reader = Module.Service.GetTypeReader(type); - var typeInfo = type.GetTypeInfo(); - - //Detect enums - if (reader == null && typeInfo.IsEnum) - { - reader = EnumTypeReader.GetReader(type); - Module.Service.AddTypeReader(type, reader); - } - - if (reader == null) - throw new InvalidOperationException($"{type.FullName} is not supported as a command parameter, are you missing a TypeReader?"); - - bool isRemainder = parameter.GetCustomAttribute() != null; - if (isRemainder && i != parameters.Length - 1) - throw new InvalidOperationException("Remainder parameters must be the last parameter in a command."); - - string name = parameter.Name; - string summary = parameter.GetCustomAttribute()?.Text; - bool isOptional = parameter.IsOptional; - object defaultValue = parameter.HasDefaultValue ? parameter.DefaultValue : null; - - paramBuilder.Add(new CommandParameter(parameters[i], name, summary, type, reader, isOptional, isRemainder, isMultiple, defaultValue)); - } - return paramBuilder.ToImmutable(); - } - private Func BuildAction(MethodInfo methodInfo) - { - if (methodInfo.ReturnType != typeof(Task)) - throw new InvalidOperationException("Commands must return a non-generic Task."); - - return (context, args, map) => - { - var instance = Module.CreateInstance(map); - instance.Context = context; - try - { - return methodInfo.Invoke(instance, args) as Task ?? Task.CompletedTask; - } - finally - { - (instance as IDisposable)?.Dispose(); - } - }; - } - private object[] GenerateArgs(IEnumerable argList, IEnumerable paramsList) { int argCount = Parameters.Count; @@ -264,7 +163,7 @@ namespace Discord.Commands if (HasVarArgs) { - var func = _arrayConverters.GetOrAdd(Parameters[Parameters.Count - 1].ElementType, t => + var func = _arrayConverters.GetOrAdd(Parameters[Parameters.Count - 1].ParameterType, t => { var method = _convertParamsMethod.MakeGenericMethod(t); return (Func, object>)method.CreateDelegate(typeof(Func, object>)); @@ -277,8 +176,5 @@ namespace Discord.Commands private static T[] ConvertParamsList(IEnumerable paramsList) => paramsList.Cast().ToArray(); - - public override string ToString() => Name; - private string DebuggerDisplay => $"{Module.Name}.{Name} ({Text})"; } -} +} \ No newline at end of file diff --git a/src/Discord.Net.Commands/Info/ModuleInfo.cs b/src/Discord.Net.Commands/Info/ModuleInfo.cs new file mode 100644 index 000000000..f874f5540 --- /dev/null +++ b/src/Discord.Net.Commands/Info/ModuleInfo.cs @@ -0,0 +1,91 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using System.Collections.Immutable; + +using Discord.Commands.Builders; + +namespace Discord.Commands +{ + public class ModuleInfo + { + public CommandService Service { get; } + public string Name { get; } + public string Summary { get; } + public string Remarks { get; } + + public IReadOnlyList Aliases { get; } + public IEnumerable Commands { get; } + public IReadOnlyList Preconditions { get; } + public IReadOnlyList Submodules { get; } + + internal ModuleInfo(ModuleBuilder builder, CommandService service) + { + Service = service; + + Name = builder.Name; + Summary = builder.Summary; + Remarks = builder.Remarks; + + Aliases = BuildAliases(builder).ToImmutableArray(); + Commands = builder.Commands.Select(x => x.Build(this, service)); + Preconditions = BuildPreconditions(builder).ToImmutableArray(); + + Submodules = BuildSubmodules(builder, service).ToImmutableArray(); + } + + private static IEnumerable BuildAliases(ModuleBuilder builder) + { + IEnumerable result = null; + + Stack builderStack = new Stack(); + builderStack.Push(builder); + + ModuleBuilder parent = builder.ParentModule; + while (parent != null) + { + builderStack.Push(parent); + parent = parent.ParentModule; + } + + while (builderStack.Count() > 0) + { + ModuleBuilder level = builderStack.Pop(); // get the topmost builder + if (result == null) + result = level.Aliases.ToList(); // create a shallow copy so we don't overwrite the builder unexpectedly + else if (result.Count() > level.Aliases.Count) + result = result.Permutate(level.Aliases, (first, second) => first + " " + second); + else + result = level.Aliases.Permutate(result, (second, first) => first + " " + second); + } + + return result; + } + + private static List BuildSubmodules(ModuleBuilder parent, CommandService service) + { + var result = new List(); + + foreach (var submodule in parent.Modules) + { + result.Add(submodule.Build(service)); + } + + return result; + } + + private static List BuildPreconditions(ModuleBuilder builder) + { + var result = new List(); + + ModuleBuilder parent = builder; + while (parent != null) + { + result.AddRange(parent.Preconditions); + parent = parent.ParentModule; + } + + return result; + } + } +} \ No newline at end of file diff --git a/src/Discord.Net.Commands/CommandParameter.cs b/src/Discord.Net.Commands/Info/ParameterInfo.cs similarity index 50% rename from src/Discord.Net.Commands/CommandParameter.cs rename to src/Discord.Net.Commands/Info/ParameterInfo.cs index 1edf42bf1..812fec572 100644 --- a/src/Discord.Net.Commands/CommandParameter.cs +++ b/src/Discord.Net.Commands/Info/ParameterInfo.cs @@ -1,37 +1,40 @@ -using System; -using System.Diagnostics; -using System.Reflection; +using System; +using System.Linq; using System.Threading.Tasks; +using Discord.Commands.Builders; + namespace Discord.Commands { - [DebuggerDisplay(@"{DebuggerDisplay,nq}")] - public class CommandParameter + public class ParameterInfo { private readonly TypeReader _reader; - public ParameterInfo Source { get; } + internal ParameterInfo(ParameterBuilder builder, CommandInfo command, CommandService service) + { + Command = command; + + Name = builder.Name; + Summary = builder.Summary; + IsOptional = builder.Optional; + IsRemainder = builder.Remainder; + IsMultiple = builder.Multiple; + + ParameterType = builder.ParameterType; + DefaultValue = builder.DefaultValue; + + _reader = builder.TypeReader; + } + + public CommandInfo Command { get; } public string Name { get; } public string Summary { get; } public bool IsOptional { get; } public bool IsRemainder { get; } public bool IsMultiple { get; } - public Type ElementType { get; } + public Type ParameterType { get; } public object DefaultValue { get; } - public CommandParameter(ParameterInfo source, string name, string summary, Type type, TypeReader reader, bool isOptional, bool isRemainder, bool isMultiple, object defaultValue) - { - Source = source; - Name = name; - Summary = summary; - ElementType = type; - _reader = reader; - IsOptional = isOptional; - IsRemainder = isRemainder; - IsMultiple = isMultiple; - DefaultValue = defaultValue; - } - public async Task Parse(CommandContext context, string input) { return await _reader.Read(context, input).ConfigureAwait(false); @@ -40,4 +43,4 @@ namespace Discord.Commands public override string ToString() => Name; private string DebuggerDisplay => $"{Name}{(IsOptional ? " (Optional)" : "")}{(IsRemainder ? " (Remainder)" : "")}"; } -} +} \ No newline at end of file diff --git a/src/Discord.Net.Commands/ModuleInfo.cs b/src/Discord.Net.Commands/ModuleInfo.cs deleted file mode 100644 index b7471edb5..000000000 --- a/src/Discord.Net.Commands/ModuleInfo.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Diagnostics; -using System.Reflection; - -namespace Discord.Commands -{ - [DebuggerDisplay(@"{DebuggerDisplay,nq}")] - public class ModuleInfo - { - internal readonly Func _builder; - - public TypeInfo Source { get; } - public CommandService Service { get; } - public string Name { get; } - public string Prefix { get; } - public string Summary { get; } - public string Remarks { get; } - public IEnumerable Commands { get; } - public IReadOnlyList Preconditions { get; } - - internal ModuleInfo(TypeInfo source, CommandService service) - { - Source = source; - Service = service; - Name = source.Name; - _builder = ReflectionUtils.CreateBuilder(source, Service); - - var groupAttr = source.GetCustomAttribute(); - if (groupAttr != null) - Prefix = groupAttr.Prefix; - else - Prefix = ""; - - var nameAttr = source.GetCustomAttribute(); - if (nameAttr != null) - Name = nameAttr.Text; - - var summaryAttr = source.GetCustomAttribute(); - if (summaryAttr != null) - Summary = summaryAttr.Text; - - var remarksAttr = source.GetCustomAttribute(); - if (remarksAttr != null) - Remarks = remarksAttr.Text; - - List commands = new List(); - SearchClass(source, commands, Prefix); - Commands = commands; - - Preconditions = Source.GetCustomAttributes().ToImmutableArray(); - } - private void SearchClass(TypeInfo parentType, List commands, string groupPrefix) - { - foreach (var method in parentType.DeclaredMethods) - { - var cmdAttr = method.GetCustomAttribute(); - if (cmdAttr != null) - commands.Add(new CommandInfo(method, this, cmdAttr, groupPrefix)); - } - foreach (var type in parentType.DeclaredNestedTypes) - { - var groupAttrib = type.GetCustomAttribute(); - if (groupAttrib != null) - { - string nextGroupPrefix; - - if (groupPrefix != "") - nextGroupPrefix = groupPrefix + " " + (groupAttrib.Prefix ?? type.Name.ToLowerInvariant()); - else - nextGroupPrefix = groupAttrib.Prefix ?? type.Name.ToLowerInvariant(); - - SearchClass(type, commands, nextGroupPrefix); - } - } - } - - internal ModuleBase CreateInstance(IDependencyMap map) - => _builder(map); - - public override string ToString() => Name; - private string DebuggerDisplay => Name; - } -} diff --git a/src/Discord.Net.Commands/Utilities/ModuleClassBuilder.cs b/src/Discord.Net.Commands/Utilities/ModuleClassBuilder.cs new file mode 100644 index 000000000..85cd9a664 --- /dev/null +++ b/src/Discord.Net.Commands/Utilities/ModuleClassBuilder.cs @@ -0,0 +1,226 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; + +using Discord.Commands.Builders; + +namespace Discord.Commands +{ + internal static class ModuleClassBuilder + { + private static readonly TypeInfo _moduleTypeInfo = typeof(ModuleBase).GetTypeInfo(); + + public static IEnumerable Search(Assembly assembly) + { + foreach (var type in assembly.ExportedTypes) + { + var typeInfo = type.GetTypeInfo(); + if (IsValidModuleDefinition(typeInfo) && + !typeInfo.IsDefined(typeof(DontAutoLoadAttribute))) + { + yield return typeInfo; + } + } + } + + public static Dictionary Build(CommandService service, params TypeInfo[] validTypes) => Build(validTypes, service); + public static Dictionary Build(IEnumerable validTypes, CommandService service) + { + if (!validTypes.Any()) + throw new InvalidOperationException("Could not find any valid modules from the given selection"); + + var topLevelGroups = validTypes.Where(x => x.DeclaringType == null); + var subGroups = validTypes.Intersect(topLevelGroups); + + var builtTypes = new List(); + + var result = new Dictionary(); + + foreach (var typeInfo in topLevelGroups) + { + // this shouldn't be the case; may be safe to remove? + if (result.ContainsKey(typeInfo.AsType())) + continue; + + var module = new ModuleBuilder(); + + BuildModule(module, typeInfo, service); + BuildSubTypes(module, typeInfo.DeclaredNestedTypes, builtTypes, service); + + result[typeInfo.AsType()] = module.Build(service); + } + + return result; + } + + private static void BuildSubTypes(ModuleBuilder builder, IEnumerable subTypes, List builtTypes, CommandService service) + { + foreach (var typeInfo in subTypes) + { + if (!IsValidModuleDefinition(typeInfo)) + continue; + + if (builtTypes.Contains(typeInfo)) + continue; + + builder.AddSubmodule((module) => { + BuildModule(module, typeInfo, service); + BuildSubTypes(module, typeInfo.DeclaredNestedTypes, builtTypes, service); + }); + + builtTypes.Add(typeInfo); + } + } + + private static void BuildModule(ModuleBuilder builder, TypeInfo typeInfo, CommandService service) + { + var attributes = typeInfo.GetCustomAttributes(); + + foreach (var attribute in attributes) + { + // TODO: C#7 type switch + if (attribute is NameAttribute) + builder.Name = (attribute as NameAttribute).Text; + else if (attribute is SummaryAttribute) + builder.Summary = (attribute as SummaryAttribute).Text; + else if (attribute is RemarksAttribute) + builder.Remarks = (attribute as RemarksAttribute).Text; + else if (attribute is AliasAttribute) + builder.AddAliases((attribute as AliasAttribute).Aliases); + else if (attribute is GroupAttribute) + { + var groupAttr = attribute as GroupAttribute; + builder.Name = builder.Name ?? groupAttr.Prefix; + builder.AddAliases(groupAttr.Prefix); + } + else if (attribute is PreconditionAttribute) + builder.AddPrecondition(attribute as PreconditionAttribute); + } + + var validCommands = typeInfo.DeclaredMethods.Where(x => IsValidCommandDefinition(x)); + + foreach (var method in validCommands) + { + builder.AddCommand((command) => { + BuildCommand(command, typeInfo, method, service); + }); + } + } + + private static void BuildCommand(CommandBuilder builder, TypeInfo typeInfo, MethodInfo method, CommandService service) + { + var attributes = method.GetCustomAttributes(); + + foreach (var attribute in attributes) + { + // TODO: C#7 type switch + if (attribute is CommandAttribute) + { + var cmdAttr = attribute as CommandAttribute; + builder.AddAliases(cmdAttr.Text); + builder.RunMode = cmdAttr.RunMode; + builder.Name = builder.Name ?? cmdAttr.Text; + } + else if (attribute is NameAttribute) + builder.Name = (attribute as NameAttribute).Text; + else if (attribute is PriorityAttribute) + builder.Priority = (attribute as PriorityAttribute).Priority; + else if (attribute is SummaryAttribute) + builder.Summary = (attribute as SummaryAttribute).Text; + else if (attribute is RemarksAttribute) + builder.Remarks = (attribute as RemarksAttribute).Text; + else if (attribute is AliasAttribute) + builder.AddAliases((attribute as AliasAttribute).Aliases); + else if (attribute is PreconditionAttribute) + builder.AddPrecondition(attribute as PreconditionAttribute); + } + + var parameters = method.GetParameters(); + int pos = 0, count = parameters.Length; + foreach (var paramInfo in parameters) + { + builder.AddParameter((parameter) => { + BuildParameter(parameter, paramInfo, pos++, count, service); + }); + } + + var createInstance = ReflectionUtils.CreateBuilder(typeInfo, service); + + builder.Callback = (ctx, args, map) => { + var instance = createInstance(map); + instance.Context = ctx; + try + { + return method.Invoke(instance, args) as Task ?? Task.CompletedTask; + } + finally{ + (instance as IDisposable)?.Dispose(); + } + }; + } + + private static void BuildParameter(ParameterBuilder builder, System.Reflection.ParameterInfo paramInfo, int position, int count, CommandService service) + { + var attributes = paramInfo.GetCustomAttributes(); + var paramType = paramInfo.ParameterType; + + builder.Name = paramInfo.Name; + + builder.Optional = paramInfo.IsOptional; + builder.DefaultValue = paramInfo.HasDefaultValue ? paramInfo.DefaultValue : null; + + foreach (var attribute in attributes) + { + // TODO: C#7 type switch + if (attribute is SummaryAttribute) + builder.Summary = (attribute as SummaryAttribute).Text; + else if (attribute is ParamArrayAttribute) + { + builder.Multiple = true; + paramType = paramType.GetElementType(); + } + else if (attribute is RemainderAttribute) + { + if (position != count-1) + throw new InvalidOperationException("Remainder parameters must be the last parameter in a command."); + + builder.Remainder = true; + } + } + + var reader = service.GetTypeReader(paramType); + if (reader == null) + { + var paramTypeInfo = paramType.GetTypeInfo(); + if (paramTypeInfo.IsEnum) + { + reader = EnumTypeReader.GetReader(paramType); + service.AddTypeReader(paramType, reader); + } + else + { + throw new InvalidOperationException($"{paramType.FullName} is not supported as a command parameter, are you missing a TypeReader?"); + } + } + + builder.ParameterType = paramType; + builder.TypeReader = reader; + } + + private static bool IsValidModuleDefinition(TypeInfo typeInfo) + { + return _moduleTypeInfo.IsAssignableFrom(typeInfo) && + !typeInfo.IsAbstract; + } + + private static bool IsValidCommandDefinition(MethodInfo methodInfo) + { + return methodInfo.IsDefined(typeof(CommandAttribute)) && + methodInfo.ReturnType == typeof(Task) && + !methodInfo.IsStatic && + !methodInfo.IsGenericMethod; + } + } +} \ No newline at end of file diff --git a/src/Discord.Net.Commands/ReflectionUtils.cs b/src/Discord.Net.Commands/Utilities/ReflectionUtils.cs similarity index 95% rename from src/Discord.Net.Commands/ReflectionUtils.cs rename to src/Discord.Net.Commands/Utilities/ReflectionUtils.cs index 052e5fe98..27ea601bf 100644 --- a/src/Discord.Net.Commands/ReflectionUtils.cs +++ b/src/Discord.Net.Commands/Utilities/ReflectionUtils.cs @@ -18,7 +18,7 @@ namespace Discord.Commands throw new InvalidOperationException($"Multiple constructors found for \"{typeInfo.FullName}\""); var constructor = constructors[0]; - ParameterInfo[] parameters = constructor.GetParameters(); + System.Reflection.ParameterInfo[] parameters = constructor.GetParameters(); return (map) => {