You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This issue is an addendum to #2, though focuses more broadly on the entire library.
Note Abstract (tl;dr for skim-readers)
The premise of this idea to increase the flexibility of commands in Remora.Commands by abstracting away the implementation detail currently present in CommandNode which includes a Type and MethodInfo and replacing it with a larger object that contains all relevant data about a command, such as its attributes, conditions, and parameters. In addition, a set of builders would be added to introduce a fluent API to construct these commands, which may or not be rooted to a CommandGroup (the 'normal' way of creating a command today).
Discord.NET, as insane of a library as it is, does occasionally spark good ideas.
Something that even DSharpPlus has achieved is the ability to dynamically define commands at runtime, without the need to be bound to a specific Type, which in Remora.Commands is used to pull an instance from the container and invoke its MethodInfo.
I want to preface this issue by saying this will be a MASSIVE undertaking, relatively speaking, as it requires uprooting a majority of the library's code, and even rewriting parts of the glue-code present in Remora.Discord.Commands
For obvious reasons, this is not something that can simply be done in a day, nor do I expect it to be, if ever. There are solid benefits to be had with this, though, which will be laid out below.
The What:
Remora.Commands is a great library, truly. Lightweight, performant, and quite enjoyable to use, and easy-to-use.
The issues arise from how the library functions, however. A simple parent-child tree system, which actually works quite well, but has short-comings. This system is very rigid, first and foremost. This is OK for a majority of use-cases; its primary function is served well by the current architecture, but when it comes to, say, generating delegate-bound commands, this just crumbles.
The Why:
There are probably an infinite number of examples that could be given for this, but as mentioned above, delegate-bound commands are probably the most tangible; be it for testing, or dynamic creation of commands, there's currently no way for Remora to handle this; the library expects a CommandNode with a given Type and MethodInfo that it can invoke (which is quite slow; I'll touch on that further down).
There are also runtime benefits to ditching the current tree system. As it stands, I wager we can bite the bullet for some extra, one-time allocations (Trees are non-ephemeral by nature). If instead, we treat CommandNode as a Func<IServiceCollection, object[], Task>, the implementation details become irrelevant to Remora.Commands; it simply invokes the delegate (which, under normal circumstances could be a compiled expression to replicate the current behavior of pulling from the container).
This in and of itself may not appear to be much of a motivator given the amount of work required to achieve this, but there's more to it. CommandNode (and by extension GroupNode) would be expanded to capture (or contain in the case of non-class-bound commands) all the metadata about a command, instead of performing reflection on every invocation.
This would allow for grabbing:
Conditions
Permissions
Parameters
Meta attributes (e.g. [ExcludeFromSlashCommands], [CommandType], and [SupressInteractionResponse])
Once, and referencing them via a list/property on some kind of metadata object (or CommandNode itself).
This in and of itself is a valuable improvement, in my opinion. As explained further down, this behavior also allows for further customizability by end-users by extending the library's functionality without the need for forking.
The How:
The biggest issue will definitely be rewriting glue-code, and handling searching/executing commands.
Actually rewriting GroupNode and ChildNode should prove rather trivial.
I imagine following in the footsteps of Discord.NET and DSharpPlus and having a builder system (much like there's already TreeBuilder, which holds an array of Type, which is then used to determine how to build the tree).
The pseudo-API would look something along the lines of this:
// CommandNode.cspublicclassCommandNode:IChildNode{/// <summary>/// The name of the command./// </summary>publicstringName{get;}/// <summary>/// The description of the command./// </summary>publicstringDescription{get;}/// <summary>/// The aliases of the command./// </summary>publicIReadOnlyList<string> Aliases {get;}/// <summary>/// The attributes of the command./// </summary>publicIReadOnlyList<Attribute> Attributes {get;}/// <summary>/// The parameters of the command, including metadata about the parameter itself./// </summary>// NOTE: CommandShape already exists here, but this is just a name for the sake of// example.publicIReadOnlyList<CommandParameter> Parameters {get;}/// <summary>/// The parent of the node./// </summary>// NOTE: This is nullable because of any runtime tom-foolery that may spawn this nodepublicIParentNode?Parent{get;}/// <summary>/// The delegate to invoke the command. This is responsible for any/// required instantiation, such as resolving the command from the IoC/// container, and invoking the command method itself./// </summary>publicFunc<IServiceCollection,object[],Task<IResult> InvocationDelegate {get;}}
// CommandParameter.cspublicclassCommandParameter{/// <summary>/// The name of this parameter, if any./// </summary>publicstring?Name{get;}/// <summary>/// The description of this parameter, if any./// </summary>publicstring?Description{get;}/// <summary>/// Whether this parameter is optional/// </summary>publicboolIsOptional{get;}/// <summary>/// Whether this parameter is greedy./// </summary>publicboolIsGreedy{get;}/// <summary>/// The default value of this parameter, if any./// </summary>publicvirtualobject?DefaultValue{get;}/// <summary>/// The type of the parameter./// </summary>// No need for an entire `ParameterInfo` when we only use two properties* from it.publicTypeParameterType{get;}/// <summary>/// The conditions applied to this parameter, if any./// </summary>publicIReadOnlyList<ConditionAttribute> Conditions {get;}/// <summary>/// The attributes applied to this parameter/// </summary>publicIReadOnlyList<Attribute> Attributes {get;}}
// CommandBuilder.cspublicclassCommandBuilder{privatestring_name;privatestring?_description;privatereadonlyGroupBuilder?_builder;privatereadonlyList<string>_aliases;privatereadonlyList<Attribute>_attributes;internalreadonlyList<ParameterBuilder>_parameters;privatereadonlyList<ConditionAttribute>_conditions;privateFunc<IServiceCollection,object[],Task>_invocation;/// <summary>/// Sets the name of the command./// </summary>public CommandBuilder WithName(stringname){}/// <summary>/// Sets the description of the command./// </summary>public CommandBuilder WithDescription(stringdescription){}/// <summary>/// Adds an alias to the command./// </summary>public CommandBuilder AddAlias(stringalias){}/// <summary>/// Adds multiple aliases to the command./// </summary>public CommandBuilder AddAliases(IEnumerable<string>aliases){}/// <summary>/// Adds an attribute to the command. Conditions must be added via <see cref="AddCondition{T}"/>./// </summary>public CommandBuilder AddAttribute<T>(Tattribute)whereT:Attribute{}/// <summary>/// Adds a condition to the command./// </summary>public CommandBuilder AddCondition<T>(Tcondition)whereT:ConditionAttribute{}/// <summary>/// Applies a function in which the command is invoked by./// </summary>public CommandBuilder WithInvocation(Func<IServiceProvider,object?[],Task>invocation){}/// <summary>/// Adds a parameter to the command./// </summary>publicParameterBuilder<T>AddParameter<T>(){}/// <summary>/// Builds the current <see cref="CommandBuilder"/> into a <see cref="CommandNode"/>./// </summary>public CommandNode Build(){}/// <summary>/// Finishes building the command, and returns the group builder if applicable./// This method should only be called if the instance was generated from <see cref="GroupBuilder.AddCommand"/>./// </summary>/// <exception cref="InvalidOperationException">Thrown if the command builder was not associated with a group.</exception>public GroupBuilder Finish(){}}
// CommandGroupBuilder.cspublicclassGroupBuilder{privatestring_name;privatestring?_description;privatereadonlyList<string>_groupAliases;privatereadonlyList<Attribute>_groupAttributes;privatereadonlyList<ConditionAttribute>_groupConditions;privatereadonlyList<OneOf<CommandBuilder,GroupBuilder>>_children;/// <summary>/// Sets the name of the group./// </summary>public GroupBuilder WithName(stringname){}/// <summary>/// Sets the description of the group./// </summary>public GroupBuilder WithDescription(stringdescription){}/// <summary>/// Adds an alias to the group./// </summary>public GroupBuilder AddAlias(stringalias){}/// <summary>/// Adds multiple aliases to the group./// </summary>public GroupBuilder AddAliases(IEnumerable<string>aliases){}/// <summary>/// Adds an attribute to the group. Conditions should be added via <see cref="AddCondition"/>./// </summary>public GroupBuilder AddAttriubte(Attributeattribute){}/// <summary>/// Adds a condition to the group./// </summary>public GroupBuilder AddCondition(ConditionAttributeattribute){}/// <summary>/// Adds a command to the group./// </summary>/// <returns>/// A <see cref="CommandBuilder"> to build the command with. /// Call <see cref="CommandBuilder.Finish"> to retrieve the group builder./// </returns>public CommandBuilder AddCommand(){}public GroupBuilder AddGroup(){varbuilder=new GroupBuilder(this);
_children.Add(OneOf<CommandBuilder,GroupBuilder>.FromT1(builder));// new() would also work.returnbuilder;}/// <summary>/// Builds the current <see cref="GroupBuilder"/> into a <see cref="GroupNode"/>./// </summary>public GroupNode Build(){}}
//ParameterBuilder.cspublic/* abstract? */classParameterBuilder{privateprotectedstring?_name;privateprotectedbool_isGreedy;privateprotectedbool_isOptional;// Implicitly set when `WithDefaultValue` is called.privateprotectedstring?_description;privateprotectedobject?_defaultValue;privateType_parameterType;privateprotectedreadonlyList<Attribute>_attributes;privateprotectedreadonlyList<ConditionAttribute>_conditions;privateprotectedreadonlyCommandBuilder_builder;/// <summary>/// Sets the name of the parameter./// </summary>public ParameterBuilder WithName(stringname){}/// <summary>/// Sets the description of the parameter./// </summary>public ParameterBuilder WithDescription(stringdescription){}/// <summary>/// Sets the default value of the parameter. This must match the parameter's type./// </summary>/// <remarks>/// Invoking this method will mark the parameter as optional regardless of the value/// passed./// </remarks>public ParameterBuilder WithDefaultValue(object?value){}/// <summary>/// Sets the parameter to be greedy./// </summary>public ParameterBuilder IsGreedy(){}// public ParameterBuilder.IsSwitch() (?)/// <summary>/// Adds an attribute to the parameter. Conditions must be added via <see cref="AddCondition{T}"/>./// </summary>public ParameterBuilder AddAttribute<T>(Tattribute)whereT:Attribute{}/// <summary>/// Adds a condition to the parameter./// </summary>public ParameterBuilder AddCondition<T>(Tcondition)whereT:ConditionAttribute{}public CommandBuilder Finish(){// Perhaps the builder should add it manually, and ParameterBuilder just holds a ref to return?// Akin to CommandTreeBuilder with it's service collection ref.
_builder._parameters.Add(this);return_builder;}/// <summary>/// Builds the current builder into a <see cref="CommandParameter"/>./// </summary>public CommandParameter Build(){}}publicclassParameterBuilder<T>:ParameterBuilder{publicParameterBuilder<T>(CommandBuilder builder):base(builder){// Of questionable viability, but something ot think of nonetheless
base.ParameterType =typeof(T);}/// <inheritdoc/>public ParameterBuilder WithName(string name){}/// <inheritdoc/>public ParameterBuilder WithDescription(stringdescription){}/// <summary>/// Sets a default value of type <typeparam name="T"/> for the parameter. /// This can be set to null (the default) for no default value. /// </summary>/// <inheritdoc/>publicParameterBuilder<T>WithDefaultValue<T>(T?value){}/// <inheritdoc/>publicParameterBuilder<T>IsGreedy(){}/// <inheritdoc/>public ParameterBuilder AddAttribute<TAttribute>(TAttributeattribute)whereTAttribute:Attribute{}/// <inheritdoc/>public ParameterBuilder AddCondition<TAttribute>(TAttributecondition)whereTAttribute:ConditionAttribute{}/// <inheritdoc/>public CommandParameter Build(){}}
This is a slightly cut down version of what I actually had in mind becuase it was 4:20 at the time of writing, but there'd also probably be a reference to IServiceCollection in there for good measure? .CreateCommand[Group] would be an additional extension method on IServiceCollection, with the .Finish/.Build calls actually registering themselves into the tree builder.
Alternatively, however (and what I had in mind?) is that registration code has to change very little (perhaps still writing those extension methods for those that want them, but mweh). Either way, Remora would, for default commands, generate the function necessary for invoking the command, and it's mostly similar to what Remora already does anyways:
I'll certainly go more in depth into this tomorrow (maybe). But for now I just wanted to get this idea off my mind and into a proper issue.
Also thanks to @uwx for the intial idea of compiled delegates. Unfortunately (ish), I can't imagine you would've thought that it would've spiraled into this enormous thing, but I think this is a better solution than "just" adding compiled delegates (we'll still use them, probably).
The text was updated successfully, but these errors were encountered:
This issue is an addendum to #2, though focuses more broadly on the entire library.
Discord.NET, as insane of a library as it is, does occasionally spark good ideas.
Something that even DSharpPlus has achieved is the ability to dynamically define commands at runtime, without the need to be bound to a specific
Type
, which in Remora.Commands is used to pull an instance from the container and invoke itsMethodInfo
.I want to preface this issue by saying this will be a MASSIVE undertaking, relatively speaking, as it requires uprooting a majority of the library's code, and even rewriting parts of the glue-code present in Remora.Discord.Commands
For obvious reasons, this is not something that can simply be done in a day, nor do I expect it to be, if ever. There are solid benefits to be had with this, though, which will be laid out below.
The What:
Remora.Commands is a great library, truly. Lightweight, performant, and quite enjoyable to use, and easy-to-use.
The issues arise from how the library functions, however. A simple parent-child tree system, which actually works quite well, but has short-comings. This system is very rigid, first and foremost. This is OK for a majority of use-cases; its primary function is served well by the current architecture, but when it comes to, say, generating delegate-bound commands, this just crumbles.
The Why:
There are probably an infinite number of examples that could be given for this, but as mentioned above, delegate-bound commands are probably the most tangible; be it for testing, or dynamic creation of commands, there's currently no way for Remora to handle this; the library expects a
CommandNode
with a givenType
andMethodInfo
that it can invoke (which is quite slow; I'll touch on that further down).There are also runtime benefits to ditching the current tree system. As it stands, I wager we can bite the bullet for some extra, one-time allocations (Trees are non-ephemeral by nature). If instead, we treat
CommandNode
as aFunc<IServiceCollection, object[], Task>
, the implementation details become irrelevant toRemora.Commands
; it simply invokes the delegate (which, under normal circumstances could be a compiled expression to replicate the current behavior of pulling from the container).This in and of itself may not appear to be much of a motivator given the amount of work required to achieve this, but there's more to it.
CommandNode
(and by extensionGroupNode
) would be expanded to capture (or contain in the case of non-class-bound commands) all the metadata about a command, instead of performing reflection on every invocation.This would allow for grabbing:
[ExcludeFromSlashCommands]
,[CommandType]
, and[SupressInteractionResponse]
)Once, and referencing them via a list/property on some kind of metadata object (or
CommandNode
itself).This in and of itself is a valuable improvement, in my opinion. As explained further down, this behavior also allows for further customizability by end-users by extending the library's functionality without the need for forking.
The How:
The biggest issue will definitely be rewriting glue-code, and handling searching/executing commands.
Actually rewriting
GroupNode
andChildNode
should prove rather trivial.I imagine following in the footsteps of Discord.NET and DSharpPlus and having a builder system (much like there's already
TreeBuilder
, which holds an array ofType
, which is then used to determine how to build the tree).The pseudo-API would look something along the lines of this:
This is a slightly cut down version of what I actually had in mind becuase it was 4:20 at the time of writing, but there'd also probably be a reference to
IServiceCollection
in there for good measure?.CreateCommand[Group]
would be an additional extension method onIServiceCollection
, with the.Finish
/.Build
calls actually registering themselves into the tree builder.Alternatively, however (and what I had in mind?) is that registration code has to change very little (perhaps still writing those extension methods for those that want them, but mweh). Either way, Remora would, for default commands, generate the function necessary for invoking the command, and it's mostly similar to what Remora already does anyways:
Remora.Commands/Remora.Commands/Trees/CommandTreeBuilder.cs
Lines 92 to 165 in aa452c2
I'll certainly go more in depth into this tomorrow (maybe). But for now I just wanted to get this idea off my mind and into a proper issue.
Also thanks to @uwx for the intial idea of compiled delegates. Unfortunately (ish), I can't imagine you would've thought that it would've spiraled into this enormous thing, but I think this is a better solution than "just" adding compiled delegates (we'll still use them, probably).
The text was updated successfully, but these errors were encountered: