diff --git a/.idea/.idea.Remora.Commands/.idea/vcs.xml b/.idea/.idea.Remora.Commands/.idea/vcs.xml index 94a25f7..4c6280e 100644 --- a/.idea/.idea.Remora.Commands/.idea/vcs.xml +++ b/.idea/.idea.Remora.Commands/.idea/vcs.xml @@ -1,5 +1,11 @@ + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 660c8fb..185226b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,5 +5,6 @@ + \ No newline at end of file diff --git a/Remora.Commands/Builders/AbstractCommandBuilder.cs b/Remora.Commands/Builders/AbstractCommandBuilder.cs new file mode 100644 index 0000000..3357804 --- /dev/null +++ b/Remora.Commands/Builders/AbstractCommandBuilder.cs @@ -0,0 +1,175 @@ +// +// AbstractCommandBuilder.cs +// +// Author: +// Jarl Gullberg +// +// Copyright (c) Jarl Gullberg +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with this program. If not, see . +// + +using System; +using System.Collections.Generic; +using Remora.Commands.Conditions; +using Remora.Commands.DependencyInjection; + +namespace Remora.Commands.Builders; + +/// +/// An abstract builder class with shared functionality/data between commands and groups. +/// +/// The type of the builder. +public abstract class AbstractCommandBuilder where TSelf : AbstractCommandBuilder +{ + /// + /// Gets or sets the description of the group or command. + /// + public string? Description { get; protected set; } + + /// + /// Gets the associated list of aliases. + /// + protected List Aliases { get; } + + /// + /// Gets the associated list of attributes. + /// + protected List Attributes { get; } + + /// + /// Gets the associated list of conditions. + /// + protected List Conditions { get; } + + /// + /// Gets the associated parent node if any. + /// + protected GroupBuilder? Parent { get; } + + /// + /// Gets the associated for the command or group. + /// + protected TreeRegistrationBuilder? TreeBuilder { get; } + + /// + /// Gets or sets the name of the command or group. + /// + protected string Name { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The registration builder. + protected AbstractCommandBuilder(TreeRegistrationBuilder treeBuilder) + : this() + { + this.TreeBuilder = treeBuilder; + } + + /// + /// Initializes a new instance of the class. + /// + /// The parent of the builder. + protected AbstractCommandBuilder(GroupBuilder? parent = null) + { + this.Name = string.Empty; + this.Aliases = new(); + this.Attributes = new(); + this.Conditions = new(); + this.Parent = parent; + } + + /// + /// Initializes a new instance of the class. + /// + private AbstractCommandBuilder() + { + this.Aliases = new(); + this.Attributes = new(); + this.Conditions = new(); + this.Name = string.Empty; + } + + /// + /// Sets the name of the builder. + /// + /// The name of the builder. + /// The current builder to chain calls with. + public TSelf WithName(string name) + { + this.Name = name; + return (TSelf)this; + } + + /// + /// Sets the description of the builder. + /// + /// The description of the builder. + /// The current builder to chain calls with. + public TSelf WithDescription(string description) + { + this.Description = description; + return (TSelf)this; + } + + /// + /// Adds an alias to the builder. + /// + /// The alias to add. + /// The current builder to chain calls with. + public TSelf AddAlias(string alias) + { + this.Aliases.Add(alias); + return (TSelf)this; + } + + /// + /// Adds multiple aliases to the builder. + /// + /// The aliases to add. + /// The current builder to chain calls with. + public TSelf AddAliases(IEnumerable aliases) + { + this.Aliases.AddRange(aliases); + return (TSelf)this; + } + + /// + /// Adds an attribute to the builder. Conditions must be added via . + /// + /// The attribute to add. + /// The current builder to chain calls with. + public TSelf AddAttribute(Attribute attribute) + { + if (attribute is ConditionAttribute) + { + throw new InvalidOperationException("Conditions must be added via AddCondition."); + } + + this.Attributes.Add(attribute); + return (TSelf)this; + } + + /// + /// Adds a condition to the builder. + /// + /// The condition to add. + /// The current builder to chain calls with. + public TSelf AddCondition(ConditionAttribute condition) + { + this.Conditions.Add(condition); + return (TSelf)this; + } +} diff --git a/Remora.Commands/Builders/CommandBuilder.cs b/Remora.Commands/Builders/CommandBuilder.cs new file mode 100644 index 0000000..54c70a1 --- /dev/null +++ b/Remora.Commands/Builders/CommandBuilder.cs @@ -0,0 +1,243 @@ +// +// CommandBuilder.cs +// +// Author: +// Jarl Gullberg +// +// Copyright (c) Jarl Gullberg +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with this program. If not, see . +// + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using OneOf; +using Remora.Commands.Attributes; +using Remora.Commands.DependencyInjection; +using Remora.Commands.Extensions; +using Remora.Commands.Signatures; +using Remora.Commands.Trees; +using Remora.Commands.Trees.Nodes; + +namespace Remora.Commands.Builders; + +/// +/// A builder for commands, which exposes a fluent API. +/// +public class CommandBuilder : AbstractCommandBuilder +{ + private CommandInvocation? _invocation; + + /// + /// Gets the parameters of the command. + /// + internal List Parameters { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The parent of the command. + public CommandBuilder(GroupBuilder? parent = null) + : base(parent) + { + this.Parameters = new List(); + this.Parent?.Children.Add(this); + } + + /// + /// Initializes a new instance of the class. + /// + /// The registration builder. + public CommandBuilder(TreeRegistrationBuilder treeBuilder) + : base(treeBuilder) + { + this.Parameters = new List(); + } + + /// + /// Sets the delegate that represents the command. + /// This delegate may do additional work prior to the actual invocation of the command (such as resolving dependencies). + /// + /// The function to invoke the command, or the command itself. + /// This method MUST be called before . + /// The current builder to chain calls with. + public CommandBuilder WithInvocation(CommandInvocation invokeFunc) + { + _invocation = invokeFunc; + return this; + } + + /// + /// Adds a new parameter to the command. + /// + /// The optional type of the parameter. + /// The parameter builder to build the parameter with. + public CommandParameterBuilder AddParameter(Type type) + { + var parameterBuilder = new CommandParameterBuilder(this, type); + this.Parameters.Add(parameterBuilder); + return parameterBuilder; + } + + /// + /// Creates a from a method. + /// + /// The parent builder, if applicable. + /// The method to extract from. + /// The builder. + public static CommandBuilder FromMethod(GroupBuilder? parent, MethodInfo info) + { + var builder = new CommandBuilder(parent); + + var commandAttribute = info.GetCustomAttribute()!; + + builder.WithName(commandAttribute.Name); + builder.AddAliases(commandAttribute.Aliases); + + var descriptionAttribute = info.GetCustomAttribute(); + + if (descriptionAttribute is not null) + { + builder.WithDescription(descriptionAttribute.Description); + } + + var parameters = info.GetParameters(); + builder.WithInvocation(CommandTreeBuilder.CreateDelegate(info)); + + foreach (var parameter in parameters) + { + var parameterBuilder = builder.AddParameter(parameter.ParameterType); + parameterBuilder.WithName(parameter.Name!); + + var description = parameter.GetCustomAttribute(); + if (description is not null) + { + parameterBuilder.WithDescription(description.Description); + } + + if (parameter.HasDefaultValue) + { + parameterBuilder.WithDefaultValue(parameter.DefaultValue); + } + + parameter.GetAttributesAndConditions(out var attributes, out var conditions); + + foreach (var attribute in attributes) + { + parameterBuilder.AddAttribute(attribute); + } + + foreach (var condition in conditions) + { + parameterBuilder.AddCondition(condition); + } + + var switchOrOptionAttribute = parameter.GetCustomAttribute(); + + if (switchOrOptionAttribute is SwitchAttribute sa) + { + if (parameter.ParameterType != typeof(bool)) + { + throw new InvalidOperationException("Switches must be of type bool."); + } + + if (!parameter.HasDefaultValue) + { + throw new InvalidOperationException("Switches must have a default value."); + } + + parameterBuilder.IsSwitch((bool)parameter.DefaultValue!, GetAttributeValue(sa.ShortName, sa.LongName)); + } + else if (switchOrOptionAttribute is OptionAttribute oa) + { + parameterBuilder.IsOption(GetAttributeValue(oa.ShortName, oa.LongName)); + } + + var greedyAttribute = parameter.GetCustomAttribute(); + + if (greedyAttribute is not null) + { + parameterBuilder.IsGreedy(); + } + } + + // Alternatively check if the builder is null? Expected case is from Remora.Commands invoking this, + // in which case it's expected that a group builder is ALWAYS passed if the command is within a group. + if (!info.DeclaringType!.TryGetGroupName(out _)) + { + info.DeclaringType!.GetAttributesAndConditions(out var attributes, out var conditions); + + builder.Attributes.AddRange(attributes); + builder.Conditions.AddRange(conditions); + } + + info.GetAttributesAndConditions(out var methodAttributes, out var methodConditions); + + builder.Attributes.AddRange(methodAttributes); + builder.Conditions.AddRange(methodConditions); + + return builder; + + OneOf GetAttributeValue(char? shortName, string? longName) + { + return (shortName, longName) switch + { + (null, null) => throw new InvalidOperationException("Switches and options must have a name."), + (null, string ln) => ln, + (char sn, null) => sn, + (char sn, string ln) => (sn, ln), + }; + } + } + + /// + /// Builds the current into a . + /// + /// The parrent of the constructed command. + /// The built . + public CommandNode Build(IParentNode parent) + { + if (_invocation is not { } invoke) + { + throw new InvalidOperationException("Cannot create a command without an entrypoint."); + } + + var shape = CommandShape.FromBuilder(this); + + return new CommandNode + ( + parent, + this.Name, + invoke, + shape, + this.Aliases, + this.Attributes, + this.Conditions + ); + } + + /// + /// Finishes building the command, and returns the group builder if applicable. + /// This method should only be called if the instance was generated from . + /// + /// Thrown if the command builder was not associated with a group. + /// The parent builder. + public GroupBuilder Finish() + { + return this.Parent ?? throw new InvalidOperationException("The command builder was not attatched to a group."); + } +} diff --git a/Remora.Commands/Builders/CommandParameterBuilder.cs b/Remora.Commands/Builders/CommandParameterBuilder.cs new file mode 100644 index 0000000..7e1b8cf --- /dev/null +++ b/Remora.Commands/Builders/CommandParameterBuilder.cs @@ -0,0 +1,446 @@ +// +// CommandParameterBuilder.cs +// +// Author: +// Jarl Gullberg +// +// Copyright (c) Jarl Gullberg +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with this program. If not, see . +// + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using OneOf; +using Remora.Commands.Attributes; +using Remora.Commands.Conditions; +using Remora.Commands.Extensions; +using Remora.Commands.Signatures; + +namespace Remora.Commands.Builders; + +/// +/// A builder class for command parameters. +/// +public class CommandParameterBuilder +{ + private readonly CommandBuilder _builder; + private readonly List _attributes; + private readonly List _conditions; + + private string _name; + private bool _isGreedy; + private bool _isOptional; + private string? _description; + private object? _defaultValue; + private Type _parameterType; + + /// + /// Initializes a new instance of the class. + /// + /// The command this parameter belongs to. + /// The optional type of the parameter. + /// If is null, MUST be called before . + public CommandParameterBuilder(CommandBuilder builder, Type type) + { + _name = string.Empty; + _builder = builder; + _attributes = new(); + _conditions = new(); + builder.Parameters.Add(this); + + _parameterType = type; + } + + /// + /// Sets the name of the parameter. + /// + /// The name of the parameter. + /// The builder to chain calls with. + public CommandParameterBuilder WithName(string name) + { + _name = name; + return this; + } + + /// + /// Sets the description of the parameter. + /// + /// The description of the parameter. + /// The builder to chain calls with. + public CommandParameterBuilder WithDescription(string description) + { + _description = description; + return this; + } + + /// + /// Sets the default value of the parameter. This must match the parameter's type. + /// + /// The default value. + /// The parameter type. + /// + /// Invoking this method will mark the parameter as optional regardless of the value + /// passed. + /// + /// The builder to chain calls with. + public CommandParameterBuilder WithDefaultValue([MaybeNull] T value) + { + if (typeof(T).IsValueType && !typeof(T).IsNullableStruct() && value is null) + { + throw new InvalidOperationException("Non-nullable value types must have a non-null default value."); + } + + if (value is not null && _parameterType is { } parameterType && !parameterType.IsInstanceOfType(value)) + { + throw new ArgumentException + ( + "The default value must match the parameter's type.", + nameof(value) + ); + } + + _defaultValue = value; + _isOptional = true; + + return this; + } + + /// + /// Sets the type of the parameter. + /// + /// The type to set the parameter to. + /// The builder to chain calls with. + /// Thrown if the type set on the builder does not match the default value (if set). + public CommandParameterBuilder WithType(Type type) + { + if (_defaultValue is not null && _defaultValue.GetType() != type) + { + throw new InvalidOperationException("The type of the parameter must match the default value's type."); + } + + _parameterType = type; + return this; + } + + /// + /// Sets the parameter to be greedy. + /// + /// The builder to chain calls with. + public CommandParameterBuilder IsGreedy() + { + _isGreedy = true; + + return this; + } + + /// + /// Sets the parameter to be a named switch. + /// + /// The default value of the switch. + /// Either the short name (e.g. '-o') or the long name (e.g. '--switch') of the switch. + /// The builder to chain calls with. + /// + /// This method will set a default value for the parameter, which will + /// implicitly make it optional. + /// + /// Thrown if both tuple values in are null. + public CommandParameterBuilder IsSwitch(bool defaultValue, OneOf shortOrLongName = default) + { + _defaultValue = defaultValue; + _parameterType = typeof(bool); + + var canBeSwitch = !_attributes.Any(r => r is OptionAttribute); + + if (!canBeSwitch) + { + throw new InvalidOperationException("A parameter marked as an option cannot be a switch."); + } + + var attribute = shortOrLongName.Match + ( + shortName => new SwitchAttribute(shortName), + longName => new SwitchAttribute(longName), + shortAndLongName => new SwitchAttribute(shortAndLongName.Short, shortAndLongName.Long) + ); + + _attributes.Add(attribute); + + return this; + } + + /// + /// Marks the parameter as being a named option. + /// + /// Either the short name (e.g. '-o') or the long name (e.g. '--option') of the option. + /// The builder to chain calls with. + /// Thrown if both values for are null.. + public CommandParameterBuilder IsOption(OneOf shortOrLongName = default) + { + var canBeOption = !_attributes.Any(r => r is SwitchAttribute); + + if (!canBeOption) + { + throw new InvalidOperationException("A parameter marked as an option cannot be a switch."); + } + + var attribute = shortOrLongName.Match + ( + shortName => new OptionAttribute(shortName), + longName => new OptionAttribute(longName), + shortAndLongName => new OptionAttribute(shortAndLongName.Short, shortAndLongName.Long) + ); + + _attributes.Add(attribute); + + return this; + } + + /// + /// Adds an attribute to the parameter. Conditions must be added via . + /// + /// The attriubte to add. + /// The builder to chain calls with. + public CommandParameterBuilder AddAttribute(Attribute attribute) + { + if (attribute is ConditionAttribute) + { + throw new InvalidOperationException("Conditions must be added via AddCondition."); + } + + _attributes.Add(attribute); + return this; + } + + /// + /// Adds a condition to the parameter. + /// + /// The condition to add. + /// The builder to chain with. + public CommandParameterBuilder AddCondition(ConditionAttribute condition) + { + _conditions.Add(condition); + return this; + } + + /// + /// Finishes creating the parameter, and returns the original builder. + /// + /// The builder this parameter belongs to. + public CommandBuilder Finish() + { + return _builder; + } + + /// + /// Builds the current builder into a . + /// + /// The constructed . + public IParameterShape Build() + { + var rangeAttribute = _attributes.OfType().SingleOrDefault(); + var optionAttribute = _attributes.OfType().SingleOrDefault(); + + if (optionAttribute is SwitchAttribute) + { + if (_defaultValue is not bool || _parameterType != typeof(bool)) + { + throw new InvalidOperationException + ( + $"A switch parameter must be a {typeof(bool)}" + + $" (The parameter \"{_name}\" was marked as ({_parameterType})." + ); + } + } + + return optionAttribute is null + ? CreatePositionalParameterShape(rangeAttribute) + : CreateNamedParameterShape(optionAttribute, rangeAttribute); + } + + private IParameterShape CreateNamedParameterShape(OptionAttribute optionAttribute, RangeAttribute? rangeAttribute) + { + var isCollection = _parameterType.IsSupportedCollection(); + + IParameterShape newNamedParameter; + if (optionAttribute is SwitchAttribute) + { + newNamedParameter = CreateNamedSwitchParameterShape(optionAttribute); + } + else if (!isCollection) + { + newNamedParameter = _isGreedy + ? CreateNamedSingleValueParameterShape(optionAttribute) + : CreateGreedyNamedSingleValueParameterShape(optionAttribute); + } + else + { + newNamedParameter = CreateNamedCollectionParameterShape(optionAttribute, rangeAttribute); + } + + return newNamedParameter; + } + + private IParameterShape CreateNamedCollectionParameterShape + ( + OptionAttribute optionAttribute, + RangeAttribute? rangeAttribute + ) + { + var description = _description ?? Constants.DefaultDescription; + + IParameterShape newNamedParameter = new NamedCollectionParameterShape + ( + optionAttribute.ShortName, + optionAttribute.LongName, + rangeAttribute?.GetMin(), + rangeAttribute?.GetMax(), + _name, + _parameterType, + _isOptional, + _defaultValue, + _attributes, + _conditions, + description + ); + + return newNamedParameter; + } + + private IParameterShape CreateNamedSwitchParameterShape(OptionAttribute optionAttribute) + { + if (!_isOptional) + { + throw new InvalidOperationException("Switches must have a default value."); + } + + if (_parameterType != typeof(bool)) + { + throw new InvalidOperationException("Switches must be booleans."); + } + + var description = _description ?? Constants.DefaultDescription; + + IParameterShape newNamedParameter = new SwitchParameterShape + ( + optionAttribute.ShortName, + optionAttribute.LongName, + _name, + _parameterType, + _isOptional, + _defaultValue, + _attributes, + _conditions, + description + ); + + return newNamedParameter; + } + + private IParameterShape CreateNamedSingleValueParameterShape + ( + OptionAttribute optionAttribute + ) + { + var description = _description ?? Constants.DefaultDescription; + + IParameterShape newNamedParameter = new NamedParameterShape + ( + optionAttribute.ShortName, + optionAttribute.LongName, + _name, + _parameterType, + _isOptional, + _defaultValue, + _attributes, + _conditions, + description + ); + + return newNamedParameter; + } + + private IParameterShape CreateGreedyNamedSingleValueParameterShape(OptionAttribute optionAttribute) + { + var description = _description ?? Constants.DefaultDescription; + + IParameterShape newNamedParameter = new NamedGreedyParameterShape + ( + optionAttribute.ShortName, + optionAttribute.LongName, + _name, + _parameterType, + _isOptional, + _defaultValue, + _attributes, + _conditions, + description + ); + + return newNamedParameter; + } + + private IParameterShape CreatePositionalParameterShape(RangeAttribute? rangeAttribute) + { + var description = _description ?? Constants.DefaultDescription; + var isCollection = _parameterType.IsSupportedCollection(); + + IParameterShape newPositionalParameter; + if (!isCollection) + { + var greedyAttribute = _attributes.OfType().SingleOrDefault(); + + newPositionalParameter = greedyAttribute is null + ? new PositionalParameterShape + ( + _name, + _parameterType, + _isOptional, + _defaultValue, + _attributes, + _conditions, + description + ) + : new PositionalGreedyParameterShape + ( + _name, + _parameterType, + _isOptional, + _defaultValue, + _attributes, + _conditions, + description + ); + } + else + { + newPositionalParameter = new PositionalCollectionParameterShape + ( + rangeAttribute?.GetMin(), + rangeAttribute?.GetMax(), + _name, + _parameterType, + _isOptional, + _defaultValue, + _attributes, + _conditions, + description + ); + } + + return newPositionalParameter; + } +} diff --git a/Remora.Commands/Builders/GroupBuilder.cs b/Remora.Commands/Builders/GroupBuilder.cs new file mode 100644 index 0000000..8945a7a --- /dev/null +++ b/Remora.Commands/Builders/GroupBuilder.cs @@ -0,0 +1,226 @@ +// +// GroupBuilder.cs +// +// Author: +// Jarl Gullberg +// +// Copyright (c) Jarl Gullberg +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with this program. If not, see . +// + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Threading.Tasks; +using OneOf; +using Remora.Commands.Attributes; +using Remora.Commands.DependencyInjection; +using Remora.Commands.Extensions; +using Remora.Commands.Groups; +using Remora.Commands.Trees.Nodes; +using Remora.Results; + +namespace Remora.Commands.Builders; + +/// +/// A builder class for creating s. +/// +public class GroupBuilder : AbstractCommandBuilder +{ + private List _groupTypes; + + /// + /// Gets the children of the group. + /// + internal List> Children { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The parent this group belongs to, if any. + public GroupBuilder(GroupBuilder? parent = null) + : base(parent) + { + this.Name = string.Empty; + this.Children = new List>(); + _groupTypes = new List(); + } + + /// + /// Initializes a new instance of the class. + /// + /// The registration builder. + public GroupBuilder(TreeRegistrationBuilder treeBuilder) + : base(treeBuilder) + { + this.Name = string.Empty; + this.Children = new List>(); + _groupTypes = new List(); + } + + /// + /// Adds a command to the group. + /// + /// A to build the command with. + public CommandBuilder AddCommand() + { + return new CommandBuilder(this); + } + + /// + /// Adds another group to the group builder. + /// + /// The current builder to chain calls with. + public GroupBuilder AddGroup() + { + var builder = new GroupBuilder(this); + this.Children.Add(builder); + + return builder; + } + + /// + /// Returns the parent builder of the current group. + /// + /// The parent builder of the group, if applicable. + /// Thrown if the group does not belong to a parent. + /// This method should only be called if the builder was instantiated from a call to . + public GroupBuilder Complete() + { + if (this.Parent is null) + { + throw new InvalidOperationException("Cannot complete a group that has no parent."); + } + + return this.Parent; + } + + /// + /// Returns the registration builder of the current group. + /// + /// The that created this builder, if applicable. + /// Thrown if the group was not created via a registration builder. + /// This method should only be called if the builder was instnatiated from a call to . + public TreeRegistrationBuilder Finish() + { + if (this.TreeBuilder is null) + { + throw new InvalidOperationException("Cannot complete a group that has no parent."); + } + + return this.TreeBuilder; + } + + /// + /// Constructs a from a given module type. + /// + /// The type of the module to construct from. + /// The parent of the builder, if applicable. + /// The constructed builder. + /// Thrown when a command method is marked with + /// but the return type of the command is not supported. + public static GroupBuilder FromType(Type moduleType, GroupBuilder? parent = null) + { + var builder = new GroupBuilder(parent); + + builder._groupTypes = new List { moduleType }; + + var groupAttribute = moduleType.GetCustomAttribute()!; + + builder.WithName(groupAttribute.Name); + + builder.AddAliases(groupAttribute.Aliases); + + var description = moduleType.GetCustomAttribute(); + + if (description is not null) + { + builder.WithDescription(description.Description); + } + + moduleType.GetAttributesAndConditions(out var attributes, out var conditions); + + builder.Attributes.AddRange(attributes); + builder.Conditions.AddRange(conditions); + + foreach (var childMethod in moduleType.GetMethods()) + { + var commandAttribute = childMethod.GetCustomAttribute(); + + if (commandAttribute is null) + { + continue; + } + + if (!childMethod.ReturnType.IsSupportedCommandReturnType()) + { + throw new InvalidOperationException + ( + $"Methods marked as commands must return a {typeof(Task<>)} or {typeof(ValueTask<>)}, " + + $"containing a type that implements {typeof(IResult)}." + ); + } + + var commandBuilder = CommandBuilder.FromMethod(builder, childMethod); + builder.Children.Add(commandBuilder); + } + + foreach (var childType in moduleType.GetNestedTypes()) + { + if (!childType.TryGetGroupName(out _) || !childType.IsSubclassOf(typeof(CommandGroup))) + { + continue; + } + + var childGroup = FromType(childType, builder); + builder.Children.Add(childGroup); + } + + return builder; + } + + /// + /// Recursively builds the current into a . + /// + /// The parent of the group. + /// The built group node. + public GroupNode Build(IParentNode parent) + { + var children = new List(); + + var node = new GroupNode + ( + Type.EmptyTypes, + children, + parent, + this.Name, + this.Aliases, + this.Attributes, + this.Conditions, + this.Description + ); + + foreach (var child in this.Children) + { + children.Add(child.Match + ( + commandBuilder => commandBuilder.Build(node), + groupBuilder => groupBuilder.Build(node) + )); + } + + return node; + } +} diff --git a/Remora.Commands/DependencyInjection/TreeRegistrationBuilder.cs b/Remora.Commands/DependencyInjection/TreeRegistrationBuilder.cs index 622db42..ae00179 100644 --- a/Remora.Commands/DependencyInjection/TreeRegistrationBuilder.cs +++ b/Remora.Commands/DependencyInjection/TreeRegistrationBuilder.cs @@ -24,6 +24,7 @@ using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Remora.Commands.Builders; using Remora.Commands.Groups; using Remora.Commands.Trees; @@ -107,6 +108,40 @@ void AddGroupsScoped(Type groupType) return this; } + /// + /// Creates a new to add to the command tree. + /// + /// The created builder. + public GroupBuilder CreateCommandGroup() + { + var builder = new GroupBuilder(this); + + _serviceCollection.Configure + ( + _treeName, + b => b.RegisterNodeBuilder(builder) + ); + + return builder; + } + + /// + /// Creates a new to add to the command tree. + /// + /// The created builder. + public CommandBuilder CreateCommand() + { + var builder = new CommandBuilder(this); + + _serviceCollection.Configure + ( + _treeName, + b => b.RegisterNodeBuilder(builder) + ); + + return builder; + } + /// /// Finishes configuring the tree, returning the service collection. /// diff --git a/Remora.Commands/Extensions/CustomAttributeProviderExtensions.cs b/Remora.Commands/Extensions/CustomAttributeProviderExtensions.cs index 835f28c..69274cc 100644 --- a/Remora.Commands/Extensions/CustomAttributeProviderExtensions.cs +++ b/Remora.Commands/Extensions/CustomAttributeProviderExtensions.cs @@ -25,6 +25,7 @@ using System.Linq; using System.Reflection; using JetBrains.Annotations; +using Remora.Commands.Conditions; namespace Remora.Commands.Extensions; @@ -55,6 +56,18 @@ public static TAttribute? GetCustomAttribute return attributeProvider.GetCustomAttributes(typeof(TAttribute), inherit).FirstOrDefault() as TAttribute; } + /// + /// Gets the conditions and attributes of the given attribute provider. + /// + /// The attribute provider. + /// The attributes extracted from the provider, excluding conditions. + /// The conditions extracted from the provider. + public static void GetAttributesAndConditions(this ICustomAttributeProvider attributeProvider, out Attribute[] attributes, out ConditionAttribute[] conditions) + { + attributes = attributeProvider.GetCustomAttributes(true).Where(att => att is not ConditionAttribute).Cast().ToArray(); + conditions = attributeProvider.GetCustomAttributes(typeof(ConditionAttribute), true).Cast().ToArray(); + } + /// /// Gets a user-configured description of the given attribute provider. The description is taken from an /// instance of the . diff --git a/Remora.Commands/Remora.Commands.csproj b/Remora.Commands/Remora.Commands.csproj index 464273d..90b040b 100644 --- a/Remora.Commands/Remora.Commands.csproj +++ b/Remora.Commands/Remora.Commands.csproj @@ -19,6 +19,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/Remora.Commands/Services/CommandService.cs b/Remora.Commands/Services/CommandService.cs index e0a9615..7b98d87 100644 --- a/Remora.Commands/Services/CommandService.cs +++ b/Remora.Commands/Services/CommandService.cs @@ -23,14 +23,12 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Reflection; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; using Remora.Commands.Conditions; using Remora.Commands.Extensions; -using Remora.Commands.Groups; using Remora.Commands.Results; using Remora.Commands.Signatures; using Remora.Commands.Tokenization; @@ -335,43 +333,9 @@ public async Task> TryExecuteAsync { var (boundCommandNode, parameters) = preparedCommand; - var groupType = boundCommandNode.Node.GroupType; - var groupInstance = (CommandGroup)services.GetRequiredService(groupType); - - groupInstance.SetCancellationToken(ct); - - var method = boundCommandNode.Node.CommandMethod; - try { - IResult result; - if (method.ReturnType.GetGenericTypeDefinition() == typeof(ValueTask<>)) - { - var genericUnwrapMethod = GetType() - .GetMethod(nameof(UnwrapCommandValueTask), BindingFlags.NonPublic | BindingFlags.Static) - ?? throw new InvalidOperationException(); - - var unwrapMethod = genericUnwrapMethod - .MakeGenericMethod(method.ReturnType.GetGenericArguments().Single()); - - var invocationResult = method.Invoke(groupInstance, parameters); - var unwrapTask = (Task)(unwrapMethod.Invoke - ( - null, new[] { invocationResult } - ) ?? throw new InvalidOperationException()); - - result = await unwrapTask; - } - else - { - var invocationResult = (Task)(method.Invoke(groupInstance, parameters) - ?? throw new InvalidOperationException()); - await invocationResult; - - result = (IResult)(invocationResult.GetType().GetProperty(nameof(Task.Result)) - ?.GetValue(invocationResult) ?? throw new InvalidOperationException()); - } - + var result = await boundCommandNode.Node.Invoke(services, parameters, ct); return Result.FromSuccess(result); } catch (Exception ex) @@ -442,7 +406,6 @@ private async Task> TryPrepareCommandAsync ) { // Check group-level conditions, if any - var groupTypeWithConditions = boundCommandNode.Node.GroupType; var groupNode = boundCommandNode.Node.Parent as GroupNode; while (true) @@ -451,7 +414,6 @@ private async Task> TryPrepareCommandAsync ( services, groupNode, - groupTypeWithConditions, ct ); @@ -460,25 +422,22 @@ private async Task> TryPrepareCommandAsync return Result.FromError(groupConditionsResult); } - if (!typeof(CommandGroup).IsAssignableFrom(groupTypeWithConditions.DeclaringType)) + // If we're at the root, we're done + // Any root-level commands copy the conditions applied to the module type + // so we don't have to worry about that. + if (groupNode?.Parent is RootNode or null) { break; } - groupTypeWithConditions = groupTypeWithConditions.DeclaringType; - if (groupNode is not null && !groupNode.GroupTypes.Contains(groupTypeWithConditions)) - { - groupNode = groupNode.Parent as GroupNode; - } + groupNode = groupNode.Parent as GroupNode; } // Check method-level conditions, if any - var method = boundCommandNode.Node.CommandMethod; var methodConditionsResult = await CheckConditionsAsync ( services, boundCommandNode.Node, - method, ct ); @@ -502,7 +461,7 @@ private async Task> TryPrepareCommandAsync var materializedParameters = materializeResult.Entity; // Check parameter-level conditions, if any - var methodParameters = method.GetParameters(); + var methodParameters = boundCommandNode.Node.Shape.Parameters; foreach (var (parameter, value) in methodParameters.Zip(materializedParameters, (info, o) => (info, o))) { var parameterConditionResult = await CheckConditionsAsync @@ -529,18 +488,16 @@ private async Task> TryPrepareCommandAsync /// /// The available services. /// The node whose conditions are being checked. - /// The group. /// The cancellation token for this operation. /// A condition result which may or may not have succeeded. private async Task CheckConditionsAsync ( IServiceProvider services, IChildNode? node, - ICustomAttributeProvider attributeProvider, CancellationToken ct ) { - var conditionAttributes = attributeProvider.GetCustomAttributes(typeof(ConditionAttribute), false); + var conditionAttributes = node?.Conditions ?? Array.Empty(); if (!conditionAttributes.Any()) { return Result.FromSuccess(); @@ -575,7 +532,7 @@ CancellationToken ct var invocationResult = conditionMethod.Invoke ( condition, - new[] { conditionAttribute, ct } + new object[] { conditionAttribute, ct } ) ?? throw new InvalidOperationException(); @@ -614,12 +571,12 @@ private async Task CheckConditionsAsync ( IServiceProvider services, IChildNode node, - ParameterInfo parameter, + IParameterShape parameter, object? value, CancellationToken ct ) { - var conditionAttributes = parameter.GetCustomAttributes(typeof(ConditionAttribute), false); + var conditionAttributes = parameter.Conditions; if (!conditionAttributes.Any()) { return Result.FromSuccess(); @@ -700,25 +657,26 @@ CancellationToken ct { var materializedParameters = new List(); - var boundParameters = boundCommandNode.BoundParameters.ToDictionary(bp => bp.ParameterShape.Parameter); - var parameterShapes = boundCommandNode.Node.Shape.Parameters.ToDictionary(p => p.Parameter); + // parsed, un-typed parameters + var boundParameters = boundCommandNode.BoundParameters.ToDictionary(bp => bp.ParameterShape); - foreach (var parameter in boundCommandNode.Node.CommandMethod.GetParameters()) + // for each parameter defined on the command itself + foreach (var parameter in boundCommandNode.Node.Shape.Parameters) { + // Argument not present if (!boundParameters.TryGetValue(parameter, out var boundParameter)) { - var shape = parameterShapes[parameter]; - if (!shape.IsOmissible()) + if (!parameter.IsOmissible()) { - return new RequiredParameterValueMissingError(shape); + return new RequiredParameterValueMissingError(parameter); } - materializedParameters.Add(shape.DefaultValue); + materializedParameters.Add(parameter.DefaultValue); continue; } // Special case: nullability - if (boundParameter.ParameterShape.Parameter.AllowsNull()) + if (boundParameter.ParameterShape.IsNullable) { // Support null literals if (boundParameter.Tokens.Count == 1 && boundParameter.Tokens[0].Trim() is "null") diff --git a/Remora.Commands/Signatures/CommandShape.cs b/Remora.Commands/Signatures/CommandShape.cs index ad929aa..0f6bbaa 100644 --- a/Remora.Commands/Signatures/CommandShape.cs +++ b/Remora.Commands/Signatures/CommandShape.cs @@ -26,6 +26,7 @@ using System.Reflection; using JetBrains.Annotations; using Remora.Commands.Attributes; +using Remora.Commands.Builders; using Remora.Commands.Extensions; namespace Remora.Commands.Signatures; @@ -58,6 +59,16 @@ public CommandShape(IReadOnlyList parameters, string? descripti this.Description = description ?? Constants.DefaultDescription; } + /// + /// Creates a new from the given builder. + /// + /// The builder. + /// The command shape. + public static CommandShape FromBuilder(CommandBuilder builder) + { + return new CommandShape(builder.Parameters.Select(p => p.Build()).ToArray(), builder.Description ?? Constants.DefaultDescription); + } + /// /// Creates a new from the given method. /// diff --git a/Remora.Commands/Signatures/IParameterShape.cs b/Remora.Commands/Signatures/IParameterShape.cs index fa6e8e3..ab84fdd 100644 --- a/Remora.Commands/Signatures/IParameterShape.cs +++ b/Remora.Commands/Signatures/IParameterShape.cs @@ -20,9 +20,10 @@ // along with this program. If not, see . // +using System; using System.Collections.Generic; -using System.Reflection; using JetBrains.Annotations; +using Remora.Commands.Conditions; using Remora.Commands.Tokenization; using Remora.Commands.Trees; @@ -35,11 +36,6 @@ namespace Remora.Commands.Signatures; [PublicAPI] public interface IParameterShape { - /// - /// Gets the matching parameter. - /// - ParameterInfo Parameter { get; } - /// /// Gets the default value, if any. /// @@ -56,6 +52,26 @@ public interface IParameterShape /// string Description { get; } + /// + /// Gets a list of attributes that apply to this parameter. + /// + IReadOnlyList Attributes { get; } + + /// + /// Gets a list of conditions that apply to this parameter. + /// + IReadOnlyList Conditions { get; } + + /// + /// Gets the type the parameter represents, or otherwise should be parsed as. + /// + Type ParameterType { get; } + + /// + /// Gets a value indicating whether the parameter's type allows for null values. + /// + bool IsNullable { get; } + /// /// Determines whether the given token sequence matches the parameter shape. /// diff --git a/Remora.Commands/Signatures/ParameterShapes/NamedCollectionParameterShape.cs b/Remora.Commands/Signatures/ParameterShapes/NamedCollectionParameterShape.cs index 6ae19c2..ff1342c 100644 --- a/Remora.Commands/Signatures/ParameterShapes/NamedCollectionParameterShape.cs +++ b/Remora.Commands/Signatures/ParameterShapes/NamedCollectionParameterShape.cs @@ -25,6 +25,7 @@ using System.Linq; using System.Reflection; using JetBrains.Annotations; +using Remora.Commands.Conditions; using Remora.Commands.Extensions; using Remora.Commands.Tokenization; using Remora.Commands.Trees; @@ -51,12 +52,11 @@ public override object? DefaultValue { get { - if (this.Parameter.IsOptional) + if (this.IsOptional) { - return this.Parameter.DefaultValue; + return base.DefaultValue; } - - if (this.Min is null or 0) + else if (this.Min is null or 0) { return _emptyCollection; } @@ -94,7 +94,7 @@ public NamedCollectionParameterShape this.Min = min; this.Max = max; - var elementType = this.Parameter.ParameterType.GetCollectionElementType(); + var elementType = parameter.ParameterType.GetCollectionElementType(); var emptyArrayMethod = _emptyArrayMethod.MakeGenericMethod(elementType); _emptyCollection = emptyArrayMethod.Invoke(null, null)!; @@ -121,7 +121,46 @@ public NamedCollectionParameterShape this.Min = min; this.Max = max; - var elementType = this.Parameter.ParameterType.GetCollectionElementType(); + var elementType = parameter.ParameterType.GetCollectionElementType(); + + var emptyArrayMethod = _emptyArrayMethod.MakeGenericMethod(elementType); + _emptyCollection = emptyArrayMethod.Invoke(null, null)!; + } + + /// + /// Initializes a new instance of the class. + /// + /// The short name. + /// The long name. + /// The minimum number of items in the collection. + /// The maximum number of items in the collection. + /// The name of the parameter. + /// The type of the parameter. + /// Whether the parameter is optional. + /// The default value of the parameter, if any. + /// The attributes of the parameter. + /// The conditions of the parameter. + /// The description of the paremeter. + public NamedCollectionParameterShape + ( + char? shortName, + string? longName, + ulong? min, + ulong? max, + string parameterName, + Type parameterType, + bool isOptional, + object? defaultValue, + IReadOnlyList attributes, + IReadOnlyList conditions, + string description + ) + : base(shortName, longName, parameterName, parameterType, isOptional, defaultValue, attributes, conditions, description) + { + this.Min = min; + this.Max = max; + + var elementType = parameterType.GetCollectionElementType(); var emptyArrayMethod = _emptyArrayMethod.MakeGenericMethod(elementType); _emptyCollection = emptyArrayMethod.Invoke(null, null)!; @@ -148,7 +187,7 @@ public NamedCollectionParameterShape this.Min = min; this.Max = max; - var elementType = this.Parameter.ParameterType.GetCollectionElementType(); + var elementType = parameter.ParameterType.GetCollectionElementType(); var emptyArrayMethod = _emptyArrayMethod.MakeGenericMethod(elementType); _emptyCollection = emptyArrayMethod.Invoke(null, null)!; @@ -287,7 +326,7 @@ public override bool Matches /// public override bool IsOmissible(TreeSearchOptions? searchOptions = null) { - if (this.Parameter.IsOptional) + if (this.IsOptional) { return true; } diff --git a/Remora.Commands/Signatures/ParameterShapes/NamedGreedyParameterShape.cs b/Remora.Commands/Signatures/ParameterShapes/NamedGreedyParameterShape.cs index f4e62b8..00fcef6 100644 --- a/Remora.Commands/Signatures/ParameterShapes/NamedGreedyParameterShape.cs +++ b/Remora.Commands/Signatures/ParameterShapes/NamedGreedyParameterShape.cs @@ -24,6 +24,8 @@ using System.Collections.Generic; using System.Reflection; using JetBrains.Annotations; +using Remora.Commands.Conditions; +using Remora.Commands.Extensions; using Remora.Commands.Tokenization; using Remora.Commands.Trees; @@ -35,6 +37,9 @@ namespace Remora.Commands.Signatures; [PublicAPI] public class NamedGreedyParameterShape : IParameterShape { + private readonly bool _isOptional; + private readonly string? _parameterName; + /// /// Gets the short name of the parameter, if any. At least one of and /// must be set. @@ -47,11 +52,8 @@ public class NamedGreedyParameterShape : IParameterShape /// public string? LongName { get; } - /// - public ParameterInfo Parameter { get; } - /// - public virtual object? DefaultValue => this.Parameter.DefaultValue; + public virtual object? DefaultValue { get; } /// public string HintName @@ -68,13 +70,25 @@ public string HintName return this.ShortName.ToString() ?? throw new InvalidOperationException(); } - return this.Parameter.Name ?? throw new InvalidOperationException(); + return _parameterName ?? throw new InvalidOperationException(); } } /// public string Description { get; } + /// + public IReadOnlyList Attributes { get; } + + /// + public IReadOnlyList Conditions { get; } + + /// + public Type ParameterType { get; } + + /// + public bool IsNullable { get; } + /// /// Initializes a new instance of the class. /// @@ -89,8 +103,8 @@ public NamedGreedyParameterShape string longName, string? description = null ) + : this(parameter) { - this.Parameter = parameter; this.ShortName = shortName; this.LongName = longName; this.Description = description ?? Constants.DefaultDescription; @@ -108,8 +122,8 @@ public NamedGreedyParameterShape string longName, string? description = null ) + : this(parameter) { - this.Parameter = parameter; this.LongName = longName; this.Description = description ?? Constants.DefaultDescription; } @@ -126,12 +140,63 @@ public NamedGreedyParameterShape char shortName, string? description = null ) + : this(parameter) { - this.Parameter = parameter; this.ShortName = shortName; this.Description = description ?? Constants.DefaultDescription; } + private NamedGreedyParameterShape(ParameterInfo parameter) + { + parameter.GetAttributesAndConditions(out var attributes, out var conditions); + + this.DefaultValue = parameter.DefaultValue; + this.ParameterType = parameter.ParameterType; + this.Attributes = attributes; + this.Conditions = conditions; + this.IsNullable = parameter.AllowsNull(); + _parameterName = parameter.Name; + _isOptional = parameter.IsOptional; + this.Description = Constants.DefaultDescription; + } + + /// + /// Initializes a new instance of the class. + /// + /// The short name. + /// The long name. + /// The name of the parameter. + /// The type of the parameter. + /// Whether the parameter is optional. + /// The default value of the parameter, if any. + /// The attributes of the parameter. + /// The conditions of the parameter. + /// The description of the paremeter. + public NamedGreedyParameterShape + ( + char? shortName, + string? longName, + string parameterName, + Type parameterType, + bool isOptional, + object? defaultValue, + IReadOnlyList attributes, + IReadOnlyList conditions, + string description + ) + { + this.ShortName = shortName; + this.LongName = longName; + _parameterName = parameterName; + this.ParameterType = parameterType; + _isOptional = isOptional; + this.IsNullable = parameterType.IsNullable(); + this.DefaultValue = defaultValue; + this.Attributes = attributes; + this.Conditions = conditions; + this.Description = description; + } + /// public virtual bool Matches ( @@ -245,5 +310,5 @@ public virtual bool Matches } /// - public virtual bool IsOmissible(TreeSearchOptions? searchOptions = null) => this.Parameter.IsOptional; + public virtual bool IsOmissible(TreeSearchOptions? searchOptions = null) => _isOptional; } diff --git a/Remora.Commands/Signatures/ParameterShapes/NamedParameterShape.cs b/Remora.Commands/Signatures/ParameterShapes/NamedParameterShape.cs index 6af5849..5b47315 100644 --- a/Remora.Commands/Signatures/ParameterShapes/NamedParameterShape.cs +++ b/Remora.Commands/Signatures/ParameterShapes/NamedParameterShape.cs @@ -24,6 +24,8 @@ using System.Collections.Generic; using System.Reflection; using JetBrains.Annotations; +using Remora.Commands.Conditions; +using Remora.Commands.Extensions; using Remora.Commands.Tokenization; using Remora.Commands.Trees; @@ -35,6 +37,8 @@ namespace Remora.Commands.Signatures; [PublicAPI] public class NamedParameterShape : IParameterShape { + private readonly string? _parameterName; + /// /// Gets the short name of the parameter, if any. At least one of and /// must be set. @@ -47,11 +51,8 @@ public class NamedParameterShape : IParameterShape /// public string? LongName { get; } - /// - public ParameterInfo Parameter { get; } - /// - public virtual object? DefaultValue => this.Parameter.DefaultValue; + public virtual object? DefaultValue { get; } /// public string HintName @@ -68,13 +69,30 @@ public string HintName return this.ShortName.ToString() ?? throw new InvalidOperationException(); } - return this.Parameter.Name ?? throw new InvalidOperationException(); + return _parameterName ?? throw new InvalidOperationException(); } } /// public string Description { get; } + /// + public IReadOnlyList Attributes { get; } + + /// + public IReadOnlyList Conditions { get; } + + /// + public Type ParameterType { get; } + + /// + public bool IsNullable { get; } + + /// + /// Gets a value indicating whether this parameter is optional. + /// + protected bool IsOptional { get; } + /// /// Initializes a new instance of the class. /// @@ -89,8 +107,8 @@ public NamedParameterShape string longName, string? description = null ) + : this(parameter) { - this.Parameter = parameter; this.ShortName = shortName; this.LongName = longName; this.Description = description ?? Constants.DefaultDescription; @@ -108,8 +126,8 @@ public NamedParameterShape string longName, string? description = null ) + : this(parameter) { - this.Parameter = parameter; this.LongName = longName; this.Description = description ?? Constants.DefaultDescription; } @@ -126,12 +144,62 @@ public NamedParameterShape char shortName, string? description = null ) + : this(parameter) { - this.Parameter = parameter; this.ShortName = shortName; this.Description = description ?? Constants.DefaultDescription; } + private NamedParameterShape(ParameterInfo parameter) + { + parameter.GetAttributesAndConditions(out var attributes, out var conditions); + this.DefaultValue = parameter.DefaultValue; + this.ParameterType = parameter.ParameterType; + this.Attributes = attributes; + this.Conditions = conditions; + this.IsNullable = parameter.AllowsNull(); + _parameterName = parameter.Name; + this.IsOptional = parameter.IsOptional; + this.Description = Constants.DefaultDescription; + } + + /// + /// Initializes a new instance of the class. + /// + /// The short name. + /// The long name. + /// The name of the parameter. + /// The type of the parameter. + /// Whether the parameter is optional. + /// The default value of the parameter, if any. + /// The attributes of the parameter. + /// The conditions of the parameter. + /// The description of the paremeter. + public NamedParameterShape + ( + char? shortName, + string? longName, + string parameterName, + Type parameterType, + bool isOptional, + object? defaultValue, + IReadOnlyList attributes, + IReadOnlyList conditions, + string description + ) + { + this.ShortName = shortName; + this.LongName = longName; + _parameterName = parameterName; + this.ParameterType = parameterType; + this.IsOptional = isOptional; + this.IsNullable = parameterType.IsNullable(); + this.DefaultValue = defaultValue; + this.Attributes = attributes; + this.Conditions = conditions; + this.Description = description; + } + /// public virtual bool Matches ( @@ -238,5 +306,5 @@ public virtual bool Matches } /// - public virtual bool IsOmissible(TreeSearchOptions? searchOptions = null) => this.Parameter.IsOptional; + public virtual bool IsOmissible(TreeSearchOptions? searchOptions = null) => this.IsOptional; } diff --git a/Remora.Commands/Signatures/ParameterShapes/PositionalCollectionParameterShape.cs b/Remora.Commands/Signatures/ParameterShapes/PositionalCollectionParameterShape.cs index d58120a..5ea7112 100644 --- a/Remora.Commands/Signatures/ParameterShapes/PositionalCollectionParameterShape.cs +++ b/Remora.Commands/Signatures/ParameterShapes/PositionalCollectionParameterShape.cs @@ -25,6 +25,7 @@ using System.Linq; using System.Reflection; using JetBrains.Annotations; +using Remora.Commands.Conditions; using Remora.Commands.Extensions; using Remora.Commands.Tokenization; using Remora.Commands.Trees; @@ -52,12 +53,11 @@ public override object? DefaultValue { get { - if (this.Parameter.IsOptional) + if (this.IsOptional) { - return this.Parameter.DefaultValue; + return base.DefaultValue; } - - if (this.Min is null or 0) + else if (this.Min is null or 0) { return _emptyCollection; } @@ -91,7 +91,51 @@ public PositionalCollectionParameterShape this.Min = min; this.Max = max; - var elementType = this.Parameter.ParameterType.GetCollectionElementType(); + var elementType = parameter.ParameterType.GetCollectionElementType(); + + var emptyArrayMethod = _emptyArrayMethod.MakeGenericMethod(elementType); + _emptyCollection = emptyArrayMethod.Invoke(null, null)!; + } + + /// + /// Initializes a new instance of the class. + /// + /// The minimum number of elements. + /// The maximum number of elements. + /// The name of the parameter. + /// The type of the parameter. + /// Whether the parameter is optional. + /// The default value of the parameter, if any. + /// The attributes of the parameter. + /// The conditions of the parameter. + /// The description of the paremeter. + public PositionalCollectionParameterShape + ( + ulong? min, + ulong? max, + string parameterName, + Type parameterType, + bool isOptional, + object? defaultValue, + IReadOnlyList attributes, + IReadOnlyList conditions, + string? description = null + ) + : base + ( + parameterName, + parameterType, + isOptional, + defaultValue, + attributes, + conditions, + description ?? Constants.DefaultDescription + ) + { + this.Min = min; + this.Max = max; + + var elementType = parameterType.GetCollectionElementType(); var emptyArrayMethod = _emptyArrayMethod.MakeGenericMethod(elementType); _emptyCollection = emptyArrayMethod.Invoke(null, null)!; @@ -150,7 +194,7 @@ public override bool Matches // we'll use the actual parameter name as a hint to match against. var (name, value) = namedValue; - if (!name.Equals(this.Parameter.Name, searchOptions.KeyComparison)) + if (!name.Equals(this.ParameterName, searchOptions.KeyComparison)) { return false; } @@ -179,7 +223,7 @@ public override bool Matches /// public override bool IsOmissible(TreeSearchOptions? searchOptions = null) { - if (this.Parameter.IsOptional) + if (this.IsOptional) { return true; } diff --git a/Remora.Commands/Signatures/ParameterShapes/PositionalGreedyParameterShape.cs b/Remora.Commands/Signatures/ParameterShapes/PositionalGreedyParameterShape.cs index 2d3d9bd..94dafd5 100644 --- a/Remora.Commands/Signatures/ParameterShapes/PositionalGreedyParameterShape.cs +++ b/Remora.Commands/Signatures/ParameterShapes/PositionalGreedyParameterShape.cs @@ -24,6 +24,8 @@ using System.Collections.Generic; using System.Reflection; using JetBrains.Annotations; +using Remora.Commands.Conditions; +using Remora.Commands.Extensions; using Remora.Commands.Tokenization; using Remora.Commands.Trees; using static Remora.Commands.Tokenization.TokenType; @@ -36,18 +38,30 @@ namespace Remora.Commands.Signatures; [PublicAPI] public class PositionalGreedyParameterShape : IParameterShape { - /// - public ParameterInfo Parameter { get; } - /// - public virtual object? DefaultValue => this.Parameter.DefaultValue; + public virtual object? DefaultValue { get; } /// - public string HintName => this.Parameter.Name ?? throw new InvalidOperationException(); + public string HintName => _parameterName ?? throw new InvalidOperationException(); /// public string Description { get; } + /// + public IReadOnlyList Attributes { get; } + + /// + public IReadOnlyList Conditions { get; } + + /// + public Type ParameterType { get; } + + /// + public bool IsNullable { get; } + + private readonly bool _isOptional; + private readonly string? _parameterName; + /// /// Initializes a new instance of the class. /// @@ -55,10 +69,49 @@ public class PositionalGreedyParameterShape : IParameterShape /// The description of the parameter. public PositionalGreedyParameterShape(ParameterInfo parameter, string? description = null) { - this.Parameter = parameter; + parameter.GetAttributesAndConditions(out var attributes, out var conditions); + + _parameterName = parameter.Name; + _isOptional = parameter.IsOptional; + this.DefaultValue = parameter.DefaultValue; + this.ParameterType = parameter.ParameterType; + this.Attributes = attributes; + this.Conditions = conditions; + this.IsNullable = parameter.AllowsNull(); this.Description = description ?? Constants.DefaultDescription; } + /// + /// Initializes a new instance of the class. + /// + /// The name of the parameter. + /// The type of the parameter. + /// Whether the parameter is optional. + /// The default value of the parameter, if any. + /// The attributes of the parameter. + /// The conditions of the parameter. + /// The description of the paremeter. + public PositionalGreedyParameterShape + ( + string parameterName, + Type parameterType, + bool isOptional, + object? defaultValue, + IReadOnlyList attributes, + IReadOnlyList conditions, + string description + ) + { + _isOptional = isOptional; + _parameterName = parameterName; + this.ParameterType = parameterType; + this.DefaultValue = defaultValue; + this.IsNullable = parameterType.IsNullable(); + this.Attributes = attributes; + this.Conditions = conditions; + this.Description = description; + } + /// public virtual bool Matches ( @@ -105,7 +158,7 @@ public virtual bool Matches // we'll use the actual parameter name as a hint to match against. var (name, value) = namedValue; - if (!name.Equals(this.Parameter.Name, searchOptions.KeyComparison)) + if (!name.Equals(_parameterName, searchOptions.KeyComparison)) { return false; } @@ -120,5 +173,5 @@ public virtual bool Matches } /// - public virtual bool IsOmissible(TreeSearchOptions? searchOptions = null) => this.Parameter.IsOptional; + public virtual bool IsOmissible(TreeSearchOptions? searchOptions = null) => _isOptional; } diff --git a/Remora.Commands/Signatures/ParameterShapes/PositionalParameterShape.cs b/Remora.Commands/Signatures/ParameterShapes/PositionalParameterShape.cs index 2aef5ba..62894a1 100644 --- a/Remora.Commands/Signatures/ParameterShapes/PositionalParameterShape.cs +++ b/Remora.Commands/Signatures/ParameterShapes/PositionalParameterShape.cs @@ -24,6 +24,8 @@ using System.Collections.Generic; using System.Reflection; using JetBrains.Annotations; +using Remora.Commands.Conditions; +using Remora.Commands.Extensions; using Remora.Commands.Tokenization; using Remora.Commands.Trees; using static Remora.Commands.Tokenization.TokenType; @@ -36,18 +38,37 @@ namespace Remora.Commands.Signatures; [PublicAPI] public class PositionalParameterShape : IParameterShape { - /// - public ParameterInfo Parameter { get; } - /// - public virtual object? DefaultValue => this.Parameter.DefaultValue; + public virtual object? DefaultValue { get; } /// - public string HintName => this.Parameter.Name ?? throw new InvalidOperationException(); + public string HintName => this.ParameterName ?? throw new InvalidOperationException(); /// public string Description { get; } + /// + public IReadOnlyList Attributes { get; } + + /// + public IReadOnlyList Conditions { get; } + + /// + public Type ParameterType { get; } + + /// + public bool IsNullable { get; } + + /// + /// Gets a value indicating whether this parameter is optional. + /// + protected bool IsOptional { get; } + + /// + /// Gets the parameter's name. + /// + protected string? ParameterName { get; } + /// /// Initializes a new instance of the class. /// @@ -55,10 +76,47 @@ public class PositionalParameterShape : IParameterShape /// The description of the parameter. public PositionalParameterShape(ParameterInfo parameter, string? description = null) { - this.Parameter = parameter; + parameter.GetAttributesAndConditions(out var attributes, out var conditions); + this.ParameterName = parameter.Name; + this.ParameterType = parameter.ParameterType; + this.IsOptional = parameter.IsOptional; + this.IsNullable = parameter.IsNullable(); + this.DefaultValue = parameter.DefaultValue; + this.Attributes = attributes; + this.Conditions = conditions; this.Description = description ?? Constants.DefaultDescription; } + /// + /// Initializes a new instance of the class. + /// + /// The name of the parameter. + /// The type of the parameter. + /// Whether the parameter is optional. + /// The default value of the parameter, if any. + /// The attributes of the parameter. + /// The conditions of the parameter. + /// The description of the paremeter. + public PositionalParameterShape + ( + string parameterName, + Type parameterType, + bool isOptional, + object? defaultValue, + IReadOnlyList attributes, + IReadOnlyList conditions, + string description + ) + { + this.ParameterName = parameterName; + this.ParameterType = parameterType; + this.IsOptional = isOptional; + this.DefaultValue = defaultValue; + this.Attributes = attributes; + this.Conditions = conditions; + this.Description = description; + } + /// public virtual bool Matches ( @@ -98,7 +156,7 @@ public virtual bool Matches // we'll use the actual parameter name as a hint to match against. var (name, value) = namedValue; - if (!name.Equals(this.Parameter.Name, searchOptions.KeyComparison)) + if (!name.Equals(this.ParameterName, searchOptions.KeyComparison)) { return false; } @@ -113,5 +171,5 @@ public virtual bool Matches } /// - public virtual bool IsOmissible(TreeSearchOptions? searchOptions = null) => this.Parameter.IsOptional; + public virtual bool IsOmissible(TreeSearchOptions? searchOptions = null) => this.IsOptional; } diff --git a/Remora.Commands/Signatures/ParameterShapes/SwitchParameterShape.cs b/Remora.Commands/Signatures/ParameterShapes/SwitchParameterShape.cs index 220481f..01ffdcc 100644 --- a/Remora.Commands/Signatures/ParameterShapes/SwitchParameterShape.cs +++ b/Remora.Commands/Signatures/ParameterShapes/SwitchParameterShape.cs @@ -24,6 +24,8 @@ using System.Collections.Generic; using System.Reflection; using JetBrains.Annotations; +using Remora.Commands.Conditions; +using Remora.Commands.Extensions; using Remora.Commands.Tokenization; using Remora.Commands.Trees; @@ -35,6 +37,9 @@ namespace Remora.Commands.Signatures; [PublicAPI] public class SwitchParameterShape : IParameterShape { + private readonly bool _isOptional; + private readonly string? _parameterName; + /// /// Gets the short name of the parameter, if any. At least one of and /// must be set. @@ -47,11 +52,8 @@ public class SwitchParameterShape : IParameterShape /// public string? LongName { get; } - /// - public ParameterInfo Parameter { get; } - /// - public virtual object? DefaultValue => this.Parameter.DefaultValue; + public virtual object? DefaultValue { get; } /// public string HintName @@ -68,13 +70,25 @@ public string HintName return this.ShortName.ToString() ?? throw new InvalidOperationException(); } - return this.Parameter.Name ?? throw new InvalidOperationException(); + return _parameterName ?? throw new InvalidOperationException(); } } /// public string Description { get; } + /// + public IReadOnlyList Attributes { get; } + + /// + public IReadOnlyList Conditions { get; } + + /// + public Type ParameterType { get; } + + /// + public bool IsNullable { get; } + /// /// Initializes a new instance of the class. /// @@ -89,8 +103,8 @@ public SwitchParameterShape string longName, string? description = null ) + : this(parameter) { - this.Parameter = parameter; this.ShortName = shortName; this.LongName = longName; this.Description = description ?? Constants.DefaultDescription; @@ -103,8 +117,8 @@ public SwitchParameterShape /// The short name. /// The description of the parameter. public SwitchParameterShape(ParameterInfo parameter, char shortName, string? description = null) + : this(parameter) { - this.Parameter = parameter; this.ShortName = shortName; this.Description = description ?? Constants.DefaultDescription; } @@ -121,12 +135,62 @@ public SwitchParameterShape string longName, string? description = null ) + : this(parameter) { - this.Parameter = parameter; this.LongName = longName; this.Description = description ?? Constants.DefaultDescription; } + private SwitchParameterShape(ParameterInfo parameter) + { + _isOptional = parameter.IsOptional; + _parameterName = parameter.Name; + parameter.GetAttributesAndConditions(out var attributes, out var conditions); + this.DefaultValue = parameter.DefaultValue; + this.ParameterType = parameter.ParameterType; + this.Attributes = attributes; + this.Conditions = conditions; + this.IsNullable = parameter.AllowsNull(); + this.Description = Constants.DefaultDescription; + } + + /// + /// Initializes a new instance of the class. + /// + /// The short name. + /// The long name. + /// The name of the parameter. + /// The type of the parameter. + /// Whether the parameter is optional. + /// The default value of the parameter, if any. + /// The attributes of the parameter. + /// The conditions of the parameter. + /// The description of the paremeter. + public SwitchParameterShape + ( + char? shortName, + string? longName, + string parameterName, + Type parameterType, + bool isOptional, + object? defaultValue, + IReadOnlyList attributes, + IReadOnlyList conditions, + string description + ) + { + _isOptional = isOptional; + _parameterName = parameterName; + this.ShortName = shortName; + this.LongName = longName; + this.ParameterType = parameterType; + this.IsNullable = parameterType.IsNullable(); + this.DefaultValue = defaultValue; + this.Attributes = attributes; + this.Conditions = conditions; + this.Description = description; + } + /// public virtual bool Matches ( @@ -223,5 +287,5 @@ public virtual bool Matches } /// - public virtual bool IsOmissible(TreeSearchOptions? searchOptions = null) => this.Parameter.IsOptional; + public virtual bool IsOmissible(TreeSearchOptions? searchOptions = null) => _isOptional; } diff --git a/Remora.Commands/Trees/CommandTreeBuilder.cs b/Remora.Commands/Trees/CommandTreeBuilder.cs index 637367d..6a3e35a 100644 --- a/Remora.Commands/Trees/CommandTreeBuilder.cs +++ b/Remora.Commands/Trees/CommandTreeBuilder.cs @@ -23,12 +23,18 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; using System.Reflection; +using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; +using OneOf; using Remora.Commands.Attributes; +using Remora.Commands.Builders; +using Remora.Commands.Conditions; using Remora.Commands.Extensions; using Remora.Commands.Groups; +using Remora.Commands.Signatures; using Remora.Commands.Trees.Nodes; using Remora.Results; @@ -41,6 +47,10 @@ namespace Remora.Commands.Trees; public class CommandTreeBuilder { private readonly List _registeredModuleTypes = new(); + private readonly List> _registeredBuilders = new(); + + private static readonly MethodInfo GetServiceMethodInfo = typeof(IServiceProvider).GetMethod(nameof(IServiceProvider.GetService), BindingFlags.Instance | BindingFlags.Public)!; + private static readonly MethodInfo SetCancellationTokenMethodInfo = typeof(CommandGroup).GetMethod(nameof(CommandGroup.SetCancellationToken), BindingFlags.Instance | BindingFlags.NonPublic)!; /// /// Registers a module type with the builder. @@ -63,6 +73,15 @@ public void RegisterModule(Type commandModule) } } + /// + /// Registers a command with the builder. + /// + /// The builder to register. + public void RegisterNodeBuilder(OneOf builder) + { + _registeredBuilders.Add(builder); + } + /// /// Builds a command tree from the registered types. /// @@ -71,11 +90,122 @@ public CommandTree Build() { var rootChildren = new List(); var rootNode = new RootNode(rootChildren); - rootChildren.AddRange(ToChildNodes(_registeredModuleTypes, rootNode)); + + var builtCommands = _registeredBuilders.Select(rb => rb.Match(cb => cb.Build(rootNode), gb => (IChildNode)gb.Build(rootNode))); + var topLevelCommands = BindDynamicCommands(ToChildNodes(_registeredModuleTypes, rootNode).ToArray(), builtCommands.ToList(), rootNode); + + rootChildren.AddRange(topLevelCommands); return new CommandTree(rootNode); } + /// + /// Recursively binds dynamic commands (constructed from builders) to their compile-time type counterparts if they exist. + /// + /// The nodes to bind to. + /// The values to bind. + /// The root node to bind groups and commands to. + /// Any nodes that could not be bound, and thusly should be added directly to the root as-is. + private IEnumerable BindDynamicCommands(IReadOnlyList nodes, List values, IParentNode root) + { + if (!values.Any()) + { + foreach (var node in nodes) + { + yield return node; + } + yield break; + } + + for (int i = values.Count - 1; i >= 0; i--) + { + var current = values[i]; + + // Return top-level commands (non-group nodes) as-is. + if (current is IParentNode) + { + continue; + } + + values.RemoveAt(i); + yield return current; + } + + if (values.Count is 0) + { + yield break; + } + + var groups = nodes.OfType() + .Concat(values.Cast()) + .GroupBy(g => g.Key) + .Select(g => MergeRecursively(g.ToArray(), root)); + + foreach (var group in groups) + { + yield return group; + } + } + + private GroupNode MergeRecursively(IReadOnlyList children, IParentNode parent) + { + var childNodes = new List(); + var groupNodesFromChildren = children.OfType().ToArray(); + + var name = groupNodesFromChildren.Select(g => g.Key).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n)) ?? string.Empty; + var description = groupNodesFromChildren.Select(g => g.Description).FirstOrDefault(d => !string.IsNullOrWhiteSpace(d)) ?? Constants.DefaultDescription; + + var group = new GroupNode + ( + groupNodesFromChildren.SelectMany(t => t.GroupTypes).ToArray(), + childNodes, + parent, + name, + groupNodesFromChildren.SelectMany(g => g.Aliases).ToArray(), + groupNodesFromChildren.SelectMany(g => g.Attributes).ToArray(), + groupNodesFromChildren.SelectMany(g => g.Conditions).ToArray(), + description + ); + + var mutableChildren = children.SelectMany(n => n is GroupNode gn ? gn.Children : new[] { n }).ToList(); + + for (var i = children.Count - 1; i >= 0; i--) + { + var child = children[i]; + + if (child is not GroupNode cgn) + { + childNodes.Add(child); + mutableChildren.RemoveAt(i); + continue; + } + + // Parity with ToChildNodes; if the group's name is empty, or + // shouldn't be merged, just nest it under the parent. + if (string.IsNullOrWhiteSpace(cgn.Key) || name != child.Key) + { + childNodes.AddRange(cgn.Children); + mutableChildren.RemoveAt(i); + } + } + + var groups = mutableChildren.GroupBy(g => g.Key); + + foreach (var subgroup in groups) + { + if (subgroup.Count() is 1) + { + childNodes.Add(subgroup.Single()); + } + else + { + childNodes.Add(MergeRecursively(subgroup.ToArray(), group)); + } + } + + return group; + } + /// /// Parses the given list of module types into a set of child nodes. /// @@ -131,7 +261,19 @@ private IEnumerable ToChildNodes(IEnumerable moduleTypes, IPar description ??= Constants.DefaultDescription; - var groupNode = new GroupNode(group.ToArray(), groupChildren, parent, group.Key, groupAliases, description); + var attributes = group.SelectMany(t => t.GetCustomAttributes(true).Cast().Where(att => att is not ConditionAttribute)).ToArray(); + var conditions = group.SelectMany(t => t.GetCustomAttributes()).ToArray(); + + if (group.First().DeclaringType is { } parentType && !parentType.TryGetGroupName(out _)) + { + // If the group is being hoisted, take the attributes of the parent type(s). + ExtractExtraAttributes(parentType, out var extraAttributes, out var extraConditions); + + attributes = extraAttributes.Concat(attributes).ToArray(); + conditions = extraConditions.Concat(conditions).ToArray(); + } + + var groupNode = new GroupNode(group.ToArray(), groupChildren, parent, group.Key, groupAliases, attributes, conditions, description); foreach (var groupType in group) { @@ -164,6 +306,43 @@ private IEnumerable ToChildNodes(IEnumerable moduleTypes, IPar } } + /// + /// Extracts attributes and conditions from the given type and its parent types. + /// + /// The type to begin extracting attributes from. + /// The extracted attributes, in descending order. + /// The extracted conditions, in descending order. + private static void ExtractExtraAttributes(Type parentType, out IEnumerable attributes, out IEnumerable conditions) + { + var parentGroupType = parentType; + + var extraAttributes = new List(); + var extraConditions = new List(); + + attributes = extraAttributes; + conditions = extraConditions; + + do + { + if (parentGroupType.TryGetGroupName(out _)) + { + break; + } + + parentGroupType.GetAttributesAndConditions(out var parentAttributes, out var parentConditions); + + extraAttributes.AddRange(parentAttributes.Reverse()); + extraConditions.AddRange(parentConditions.Reverse()); + } + while ((parentGroupType = parentGroupType!.DeclaringType) is not null); + + // These are inserted in reverse order as we traverse up the + // inheritance tree, so re-reversing the list gives us all attributes + // in the correct order, *decescending* down the tree, effectively. + extraAttributes.Reverse(); + extraConditions.Reverse(); + } + /// /// Parses a set of command nodes from the given type. /// @@ -173,6 +352,8 @@ private IEnumerable ToChildNodes(IEnumerable moduleTypes, IPar private IEnumerable GetModuleCommands(Type moduleType, IParentNode parent) { var methods = moduleType.GetMethods(); + var isInUnnamedGroup = !moduleType.TryGetGroupName(out var gn) || gn == string.Empty; + foreach (var method in methods) { var commandAttribute = method.GetCustomAttribute(); @@ -190,14 +371,124 @@ private IEnumerable GetModuleCommands(Type moduleType, IParentNode ); } + method.GetAttributesAndConditions(out var attributes, out var conditions); + + if (isInUnnamedGroup) + { + // If the group is being hoisted, take the attributes of the parent type(s). + ExtractExtraAttributes(moduleType, out var extraAttributes, out var extraConditions); + + attributes = extraAttributes.Concat(attributes).ToArray(); + conditions = extraConditions.Concat(conditions).ToArray(); + } + yield return new CommandNode ( parent, commandAttribute.Name, - moduleType, - method, - commandAttribute.Aliases + CreateDelegate(method), + CommandShape.FromMethod(method), + commandAttribute.Aliases, + attributes, + conditions ); } } + + /// + /// Creates a command invocation delegate from the supplied parameters. + /// + /// The method that will be invoked. + /// The created command invocation. + internal static CommandInvocation CreateDelegate(MethodInfo method) + { + // Get the object from the container + var serviceProvider = Expression.Parameter(typeof(IServiceProvider), "serviceProvider"); + + var instance = Expression.Call(serviceProvider, GetServiceMethodInfo, Expression.Constant(method.DeclaringType)); + + var parameters = Expression.Parameter(typeof(object?[]), "parameters"); + + var methodParameters = method.GetParameters(); + var orderedArgumentTypes = methodParameters.Select((t, n) => (t, n)) + .OrderBy(tn => tn.t.GetCustomAttribute() is null) + .Select((t, nn) => (t.t, nn)) + .ToArray(); + + // Create the arguments + var arguments = new Expression[orderedArgumentTypes.Length]; + for (var i = 0; i < orderedArgumentTypes.Length; i++) + { + var argumentType = methodParameters[i].ParameterType; + var argumentIndex = orderedArgumentTypes.First(tn => tn.t == methodParameters[i]).nn; + var argument = Expression.ArrayIndex(parameters, Expression.Constant(argumentIndex)); + arguments[i] = Expression.Convert(argument, argumentType); + } + + var castedInstance = Expression.Convert(instance, method.DeclaringType!); + + var call = Expression.Call(castedInstance, method, arguments); + + // Convert the result to a ValueTask + call = CoerceToValueTask(call); + + var ct = Expression.Parameter(typeof(CancellationToken), "cancellationToken"); + + var block = Expression.Block + ( + Expression.Call(castedInstance, SetCancellationTokenMethodInfo, ct), + call + ); + + // Compile the expression + var lambda = Expression.Lambda(block, serviceProvider, parameters, ct); + + return lambda.Compile(); + } + + /// + /// Coerces the static result type of an expression to a . + /// + /// If the type is , returns the expression as-is + /// If the type is , returns an expression wrapping the Task in a + /// Otherwise, throws + /// + /// + /// The input expression. + /// The new expression. + /// If the type of is not wrappable. + public static MethodCallExpression CoerceToValueTask(Expression expression) + { + var expressionType = expression.Type; + + MethodCallExpression invokerExpr; + if (expressionType.IsConstructedGenericType && expressionType.GetGenericTypeDefinition() == typeof(ValueTask<>)) + { + invokerExpr = Expression.Call(ToResultValueTaskInfo.MakeGenericMethod(expressionType.GetGenericArguments()[0]), expression); + } + else if (expressionType.IsConstructedGenericType && expressionType.GetGenericTypeDefinition() == typeof(Task<>)) + { + invokerExpr = Expression.Call(ToResultTaskInfo.MakeGenericMethod(expressionType.GetGenericArguments()[0]), expression); + } + else + { + throw new InvalidOperationException($"{nameof(CoerceToValueTask)} expression must be {nameof(Task)} or {nameof(ValueTask)}"); + } + + return invokerExpr; + } + + private static async ValueTask ToResultValueTask(ValueTask task) where T : IResult + => await task; + + private static async ValueTask ToResultTask(Task task) where T : IResult + => await task; + + private static readonly MethodInfo ToResultValueTaskInfo + = typeof(CommandTreeBuilder).GetMethod(nameof(ToResultValueTask), BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Did not find {nameof(ToResultValueTask)}"); + + private static readonly MethodInfo ToResultTaskInfo + = typeof(CommandTreeBuilder).GetMethod(nameof(ToResultTask), BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Did not find {nameof(ToResultTask)}"); } diff --git a/Remora.Commands/Trees/Nodes/CommandNode.cs b/Remora.Commands/Trees/Nodes/CommandNode.cs index 4d8d133..d5a89e0 100644 --- a/Remora.Commands/Trees/Nodes/CommandNode.cs +++ b/Remora.Commands/Trees/Nodes/CommandNode.cs @@ -24,13 +24,25 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Reflection; +using System.Threading; +using System.Threading.Tasks; using JetBrains.Annotations; +using Remora.Commands.Conditions; using Remora.Commands.Signatures; using Remora.Commands.Tokenization; +using Remora.Results; namespace Remora.Commands.Trees.Nodes; +/// +/// Represents a delegate that executes a command. +/// +/// The service provider. +/// The parameters to be passed to the command. +/// The cancellation token. +/// The result of executing the command. +public delegate ValueTask CommandInvocation(IServiceProvider services, object?[] parameters, CancellationToken cancellationToken); + /// /// Represents a command in a command group. /// @@ -38,14 +50,9 @@ namespace Remora.Commands.Trees.Nodes; public class CommandNode : IChildNode { /// - /// Gets the module type that the command is in. - /// - public Type GroupType { get; } - - /// - /// Gets the method that the command invokes. + /// Gets the delegate that represents the command, or invokes it. /// - public MethodInfo CommandMethod { get; } + public CommandInvocation Invoke { get; } /// public IParentNode Parent { get; } @@ -55,6 +62,16 @@ public class CommandNode : IChildNode /// public CommandShape Shape { get; } + /// + /// Gets the attributes of the command. + /// + public IReadOnlyList Attributes { get; } + + /// + /// Gets the conditions of the command. + /// + public IReadOnlyList Conditions { get; } + /// /// /// This key value represents the name of the command, which terminates the command prefix. @@ -69,24 +86,29 @@ public class CommandNode : IChildNode /// /// The parent node. /// The key value of the command node. - /// The module type that the command is in. - /// The method that the command invokes. + /// The function to invoke the command. + /// The shape of the command. /// Additional key aliases, if any. + /// Applied attributes for the command, if any. + /// Applied conditions for the command, if any. public CommandNode ( IParentNode parent, string key, - Type groupType, - MethodInfo commandMethod, - IReadOnlyList? aliases = null + CommandInvocation invoke, + CommandShape shape, + IReadOnlyList? aliases = null, + IReadOnlyList? attributes = null, + IReadOnlyList? conditions = null ) { + this.Invoke = invoke; this.Parent = parent; this.Key = key; - this.GroupType = groupType; - this.CommandMethod = commandMethod; this.Aliases = aliases ?? Array.Empty(); - this.Shape = CommandShape.FromMethod(this.CommandMethod); + this.Shape = shape; + this.Attributes = attributes ?? Array.Empty(); + this.Conditions = conditions ?? Array.Empty(); } /// diff --git a/Remora.Commands/Trees/Nodes/GroupNode.cs b/Remora.Commands/Trees/Nodes/GroupNode.cs index 4921ddd..5f0494c 100644 --- a/Remora.Commands/Trees/Nodes/GroupNode.cs +++ b/Remora.Commands/Trees/Nodes/GroupNode.cs @@ -23,6 +23,7 @@ using System; using System.Collections.Generic; using JetBrains.Annotations; +using Remora.Commands.Conditions; namespace Remora.Commands.Trees.Nodes; @@ -43,6 +44,16 @@ public class GroupNode : IParentNode, IChildNode /// public IReadOnlyList Children { get; } + /// + /// Gets the attributes that apply to the group. + /// + public IReadOnlyList Attributes { get; } + + /// + /// Gets the conditions that apply to the group. + /// + public IReadOnlyList Conditions { get; } + /// public IParentNode Parent { get; } @@ -68,6 +79,8 @@ public class GroupNode : IParentNode, IChildNode /// The parent of the group node. /// The key value for the group node. /// Additional key aliases, if any. + /// Attributes that apply to the group, if any. + /// Conditions that apply to the group, if any. /// The description of the group. public GroupNode ( @@ -76,6 +89,8 @@ public GroupNode IParentNode parent, string key, IReadOnlyList? aliases = null, + IReadOnlyList? groupAttributes = null, + IReadOnlyList? groupConditions = null, string? description = null ) { @@ -84,6 +99,8 @@ public GroupNode this.Parent = parent; this.Key = key; this.Aliases = aliases ?? Array.Empty(); + this.Attributes = groupAttributes ?? Array.Empty(); + this.Conditions = groupConditions ?? Array.Empty(); this.Description = description ?? Constants.DefaultDescription; } } diff --git a/Remora.Commands/Trees/Nodes/IChildNode.cs b/Remora.Commands/Trees/Nodes/IChildNode.cs index 6c6a2c4..30f4dee 100644 --- a/Remora.Commands/Trees/Nodes/IChildNode.cs +++ b/Remora.Commands/Trees/Nodes/IChildNode.cs @@ -20,8 +20,10 @@ // along with this program. If not, see . // +using System; using System.Collections.Generic; using JetBrains.Annotations; +using Remora.Commands.Conditions; namespace Remora.Commands.Trees.Nodes; @@ -45,4 +47,14 @@ public interface IChildNode /// Gets a set of additional keys that the child node has, in addition to its primary key (). /// IReadOnlyList Aliases { get; } + + /// + /// Gets the attributes of the node. + /// + public IReadOnlyList Attributes { get; } + + /// + /// Gets the conditions of the node. + /// + public IReadOnlyList Conditions { get; } } diff --git a/Tests/Remora.Commands.Tests/Builders/CommandBuilderTests.cs b/Tests/Remora.Commands.Tests/Builders/CommandBuilderTests.cs new file mode 100644 index 0000000..dc0ef74 --- /dev/null +++ b/Tests/Remora.Commands.Tests/Builders/CommandBuilderTests.cs @@ -0,0 +1,156 @@ +// +// CommandBuilderTests.cs +// +// Author: +// Jarl Gullberg +// +// Copyright (c) Jarl Gullberg +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with this program. If not, see . +// + +using System; +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Remora.Commands.Builders; +using Remora.Commands.Extensions; +using Remora.Commands.Tests.Data.DummyModules; +using Remora.Commands.Trees; +using Remora.Commands.Trees.Nodes; +using Xunit; + +namespace Remora.Commands.Tests.Builders; + +/// +/// Tests the class. +/// +public class CommandBuilderTests +{ + /// + /// Asserts that the builder can successfully build a command. + /// + [Fact] + public void CanBuildCommand() + { + var rootNode = new RootNode(Array.Empty()); + var command = new CommandBuilder() + .WithName("test") + .WithDescription("A test command.") + .WithInvocation((_, _, _) => default) + .Build(rootNode); + + Assert.Equal("test", command.Key); + Assert.Equal("A test command.", command.Shape.Description); + Assert.Equal(rootNode, command.Parent); + } + + /// + /// Tests that the builder can successfully build a command with parameters. + /// + [Fact] + public void CanBuildParameterizedCommand() + { + var rootNode = new RootNode(Array.Empty()); + var commandBuilder = new CommandBuilder() + .WithName("test") + .WithInvocation((_, _, _) => default) + .WithDescription("A test command."); + + var parameterBuilder = new CommandParameterBuilder(commandBuilder, typeof(string)) + .WithName("test") + .WithDescription("A test parameter."); + + var command = commandBuilder.Build(rootNode); + + Assert.Equal("test", command.Key); + Assert.Equal("A test command.", command.Shape.Description); + Assert.Equal(rootNode, command.Parent); + Assert.Single(command.Shape.Parameters); + Assert.Equal("test", command.Shape.Parameters[0].HintName); + } + + /// + /// Asserts that the builder can successfully build a command with multiple parameters. + /// + [Fact] + public void CanBuildParameterizedCommandWithMultipleParameters() + { + var rootNode = new RootNode(Array.Empty()); + var commandBuilder = new CommandBuilder() + .WithName("test") + .WithInvocation((_, _, _) => default) + .WithDescription("A test command."); + + var parameterBuilder = new CommandParameterBuilder(commandBuilder, typeof(string)) + .WithName("test") + .WithDescription("A test parameter."); + + var parameterBuilder2 = new CommandParameterBuilder(commandBuilder, typeof(int)) + .WithName("test2") + .WithDescription("A second test parameter."); + + var command = commandBuilder.Build(rootNode); + + Assert.Equal("test", command.Key); + Assert.Equal("A test command.", command.Shape.Description); + Assert.Equal(rootNode, command.Parent); + Assert.Equal(2, command.Shape.Parameters.Count); + Assert.Equal("test", command.Shape.Parameters[0].HintName); + Assert.Equal("test2", command.Shape.Parameters[1].HintName); + } + + /// + /// Asserts that the builder can correctly create a command from a given . + /// + [Fact] + public void CanCreateCommandFromMethod() + { + var rootNode = new RootNode(Array.Empty()); + var builder = CommandBuilder.FromMethod(null, typeof(DescribedGroup).GetMethod(nameof(DescribedGroup.B), BindingFlags.Public | BindingFlags.Instance)!); + + var command = builder.Build(rootNode); + + Assert.Equal("b", command.Key); + Assert.Equal("Command description", command.Shape.Description); + } + + /// + /// Asserts that the invocation on a built command is functional and preserved. + /// + [Fact] + public void CanInvokeCommand() + { + var services = new ServiceCollection(); + services.AddCommands() + .AddCommandTree() + .CreateCommand() + .WithName("a") + .WithInvocation + ( + (_, p, _) => + { + p[0] = (object)true; + return default; + } + ); + + var provider = services.BuildServiceProvider(); + var command = ((CommandNode)provider.GetRequiredService().Root.Children[0]).Invoke; + + var parameters = new object[1]; + command.Invoke(provider, parameters, default); + + Assert.Equal(parameters[0], true); + } +} diff --git a/Tests/Remora.Commands.Tests/Remora.Commands.Tests.csproj b/Tests/Remora.Commands.Tests/Remora.Commands.Tests.csproj index 896ee2b..3c7a455 100644 --- a/Tests/Remora.Commands.Tests/Remora.Commands.Tests.csproj +++ b/Tests/Remora.Commands.Tests/Remora.Commands.Tests.csproj @@ -149,5 +149,8 @@ CommandServiceTests.Raw.cs + + CommandTreeBuilderTests.cs + diff --git a/Tests/Remora.Commands.Tests/Services/CommandServiceTests.Preparsed.Conditions.cs b/Tests/Remora.Commands.Tests/Services/CommandServiceTests.Preparsed.Conditions.cs index fbd4c85..5a831d1 100644 --- a/Tests/Remora.Commands.Tests/Services/CommandServiceTests.Preparsed.Conditions.cs +++ b/Tests/Remora.Commands.Tests/Services/CommandServiceTests.Preparsed.Conditions.cs @@ -100,7 +100,7 @@ public async Task UnsuccessfulCommandInUnnamedGroupWithGroupConditionProducesCor Assert.False(executionResult.IsSuccess); Assert.IsType(executionResult.Error); - Assert.Null(((ConditionNotSatisfiedError)executionResult.Error!).Node); + Assert.IsType(((ConditionNotSatisfiedError)executionResult.Error!).Node); } /// @@ -633,7 +633,7 @@ public async Task UnsuccessfulCommandInNestedUnnamedGroupInsideUnnamedGroupWithC Assert.False(executionResult.IsSuccess); Assert.IsType(executionResult.Error); - Assert.Null(((ConditionNotSatisfiedError)executionResult.Error!).Node); + Assert.IsType(((ConditionNotSatisfiedError)executionResult.Error!).Node); } /// @@ -665,7 +665,7 @@ public async Task UnsuccessfulCommandInNestedNamedGroupInsideUnnamedGroupWithCon Assert.False(executionResult.IsSuccess); Assert.IsType(executionResult.Error); - Assert.Null(((ConditionNotSatisfiedError)executionResult.Error!).Node); + Assert.IsType(((ConditionNotSatisfiedError)executionResult.Error!).Node); } } } diff --git a/Tests/Remora.Commands.Tests/Services/CommandServiceTests.PreparsedWithPath.Conditions.cs b/Tests/Remora.Commands.Tests/Services/CommandServiceTests.PreparsedWithPath.Conditions.cs index 3880c40..27b658d 100644 --- a/Tests/Remora.Commands.Tests/Services/CommandServiceTests.PreparsedWithPath.Conditions.cs +++ b/Tests/Remora.Commands.Tests/Services/CommandServiceTests.PreparsedWithPath.Conditions.cs @@ -100,7 +100,7 @@ public async Task UnsuccessfulCommandInUnnamedGroupWithGroupConditionProducesCor Assert.False(executionResult.IsSuccess); Assert.IsType(executionResult.Error); - Assert.Null(((ConditionNotSatisfiedError)executionResult.Error!).Node); + Assert.IsType(((ConditionNotSatisfiedError)executionResult.Error!).Node); } /// @@ -633,7 +633,7 @@ public async Task UnsuccessfulCommandInNestedUnnamedGroupInsideUnnamedGroupWithC Assert.False(executionResult.IsSuccess); Assert.IsType(executionResult.Error); - Assert.Null(((ConditionNotSatisfiedError)executionResult.Error!).Node); + Assert.IsType(((ConditionNotSatisfiedError)executionResult.Error!).Node); } /// @@ -665,7 +665,7 @@ public async Task UnsuccessfulCommandInNestedNamedGroupInsideUnnamedGroupWithCon Assert.False(executionResult.IsSuccess); Assert.IsType(executionResult.Error); - Assert.Null(((ConditionNotSatisfiedError)executionResult.Error!).Node); + Assert.IsType(((ConditionNotSatisfiedError)executionResult.Error!).Node); } } } diff --git a/Tests/Remora.Commands.Tests/Services/CommandServiceTests.Raw.Conditions.cs b/Tests/Remora.Commands.Tests/Services/CommandServiceTests.Raw.Conditions.cs index 573b270..8cca5f0 100644 --- a/Tests/Remora.Commands.Tests/Services/CommandServiceTests.Raw.Conditions.cs +++ b/Tests/Remora.Commands.Tests/Services/CommandServiceTests.Raw.Conditions.cs @@ -95,7 +95,7 @@ public async Task UnsuccessfulCommandInUnnamedGroupWithGroupConditionProducesCor Assert.False(executionResult.IsSuccess); Assert.IsType(executionResult.Error); - Assert.Null(((ConditionNotSatisfiedError)executionResult.Error!).Node); + Assert.IsType(((ConditionNotSatisfiedError)executionResult.Error!).Node); } /// @@ -581,7 +581,7 @@ public async Task Assert.False(executionResult.IsSuccess); Assert.IsType(executionResult.Error); - Assert.Null(((ConditionNotSatisfiedError)executionResult.Error!).Node); + Assert.IsType(((ConditionNotSatisfiedError)executionResult.Error!).Node); } /// @@ -612,7 +612,7 @@ public async Task Assert.False(executionResult.IsSuccess); Assert.IsType(executionResult.Error); - Assert.Null(((ConditionNotSatisfiedError)executionResult.Error!).Node); + Assert.IsType(((ConditionNotSatisfiedError)executionResult.Error!).Node); } } } diff --git a/Tests/Remora.Commands.Tests/Trees/CommandTreeBuilderTests.Builders.cs b/Tests/Remora.Commands.Tests/Trees/CommandTreeBuilderTests.Builders.cs new file mode 100644 index 0000000..730d55f --- /dev/null +++ b/Tests/Remora.Commands.Tests/Trees/CommandTreeBuilderTests.Builders.cs @@ -0,0 +1,82 @@ +// +// CommandTreeBuilderTests.Builders.cs +// +// Author: +// Jarl Gullberg +// +// Copyright (c) Jarl Gullberg +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with this program. If not, see . +// + +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using Remora.Commands.Extensions; +using Remora.Commands.Services; +using Remora.Commands.Tests.Data.DummyModules; +using Remora.Commands.Trees; +using Remora.Commands.Trees.Nodes; +using Xunit; + +namespace Remora.Commands.Tests.Trees; + +public static partial class CommandTreeBuilderTests +{ + /// + /// Tests builder-related functionality. + /// + public class Builders + { + /// + /// Asserts that a command group built from a builder is registered in the tree correctly. + /// + [Fact] + public void RegistersCommandGroupSuccessfully() + { + var services = new ServiceCollection().AddCommands(); + + services.AddCommandTree().CreateCommand().WithName("command").WithInvocation((_, _, _) => default); + + var provider = services.BuildServiceProvider(); + var tree = provider.GetRequiredService(); + + var command = tree.Root.Children.FirstOrDefault(); + Assert.Single(tree.Root.Children); + Assert.Equal("command", command!.Key); + } + + /// + /// Asserts that the builder merges a group into its constituent sibling correctly. + /// + [Fact] + public void MergesSimpleGroupCorrectly() + { + var services = new ServiceCollection().AddCommands(); + + services.AddCommandTree() + .WithCommandGroup() + .CreateCommandGroup().WithName("a").AddCommand().WithName("h").WithInvocation((_, _, _) => default); + + var provider = services.BuildServiceProvider(); + var tree = provider.GetRequiredService(); + + var group = tree.Root.Children.FirstOrDefault() as GroupNode; + + Assert.Single(tree.Root.Children); + Assert.Equal("a", group!.Key); + + Assert.Equal(4, group.Children.Count); + } + } +}