diff --git a/CliFx/CliApplication.cs b/CliFx/CliApplication.cs index 11b02a6..fdd82a6 100644 --- a/CliFx/CliApplication.cs +++ b/CliFx/CliApplication.cs @@ -97,7 +97,7 @@ public class CliApplication( var commandInstance = commandSchema == FallbackDefaultCommand.Schema ? new FallbackDefaultCommand() // bypass the activator - : typeActivator.CreateInstance(commandSchema.Type); + : typeActivator.CreateInstance(commandSchema.Type); // Assemble the help context var helpContext = new HelpContext( @@ -113,8 +113,8 @@ public class CliApplication( // propagate further. try { - // Bind the command input to the command instance - commandInstance.Bind(commandInput); + // Activate the command instance with the provided input + commandSchema.Activate(commandInput, commandInstance); // Handle the version option if (commandInstance is ICommandWithVersionOption { IsVersionRequested: true }) diff --git a/CliFx/FallbackDefaultCommand.cs b/CliFx/FallbackDefaultCommand.cs index 0ce3928..6e8fd7d 100644 --- a/CliFx/FallbackDefaultCommand.cs +++ b/CliFx/FallbackDefaultCommand.cs @@ -2,7 +2,6 @@ using System.Threading.Tasks; using CliFx.Attributes; using CliFx.Infrastructure; -using CliFx.Input; using CliFx.Schema; namespace CliFx; @@ -11,8 +10,7 @@ namespace CliFx; // This command is only used as a stub for help text. [Command] internal partial class FallbackDefaultCommand - : IBindableCommand, - ICommandWithHelpOption, + : ICommandWithHelpOption, ICommandWithVersionOption { [CommandHelpOption] @@ -21,11 +19,6 @@ internal partial class FallbackDefaultCommand [CommandVersionOption] public bool IsVersionRequested { get; init; } - public void Bind(CommandInput input) - { - throw new System.NotImplementedException(); - } - // Never actually executed [ExcludeFromCodeCoverage] public ValueTask ExecuteAsync(IConsole console) => default; @@ -33,6 +26,5 @@ internal partial class FallbackDefaultCommand internal partial class FallbackDefaultCommand { - public static CommandSchema Schema { get; } = - new(typeof(FallbackDefaultCommand), null, null, [], []); + public static CommandSchema Schema { get; } = new CommandSchema(null, null, []); } diff --git a/CliFx/IBindableCommand.cs b/CliFx/IBindableCommand.cs deleted file mode 100644 index c9c449a..0000000 --- a/CliFx/IBindableCommand.cs +++ /dev/null @@ -1,20 +0,0 @@ -using CliFx.Input; - -namespace CliFx; - -/// -/// Command whose inputs can be bound from command-line arguments. -/// -/// -/// This interface is required to facilitate binding of command inputs (parameters and options) -/// to their corresponding CLR properties. -/// You should not need to implement this interface directly, as it will be automatically -/// implemented by the framework. -/// -public interface IBindableCommand : ICommand -{ - /// - /// Binds the command input to the current instance. - /// - void Bind(CommandInput input); -} diff --git a/CliFx/Schema/CommandSchema.cs b/CliFx/Schema/CommandSchema.cs index df551f8..09bd3b4 100644 --- a/CliFx/Schema/CommandSchema.cs +++ b/CliFx/Schema/CommandSchema.cs @@ -2,6 +2,9 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using CliFx.Exceptions; +using CliFx.Input; +using CliFx.Utils.Extensions; namespace CliFx.Schema; @@ -63,18 +66,160 @@ public class CommandSchema( foreach (var parameterSchema in Parameters) { - var value = parameterSchema.Property.Get(instance); + var value = parameterSchema.Property.GetValue(instance); result[parameterSchema] = value; } foreach (var optionSchema in Options) { - var value = optionSchema.Property.Get(instance); + var value = optionSchema.Property.GetValue(instance); result[optionSchema] = value; } return result; } + + private void ActivateParameters(CommandInput input, ICommand instance) + { + // Ensure there are no unexpected parameters and that all parameters are provided + var remainingParameterInputs = input.Parameters.ToList(); + var remainingRequiredParameterSchemas = Parameters.Where(p => p.IsRequired).ToList(); + + var position = 0; + + foreach (var parameterSchema in Parameters.OrderBy(p => p.Order)) + { + // Break when there are no remaining inputs + if (position >= input.Parameters.Count) + break; + + // Non-sequence: take one input at the current position + if (!parameterSchema.IsSequence) + { + var parameterInput = input.Parameters[position]; + parameterSchema.Activate(instance, [parameterInput.Value]); + + position++; + remainingParameterInputs.Remove(parameterInput); + } + // Sequence: take all remaining inputs starting from the current position + else + { + var parameterInputs = input.Parameters.Skip(position).ToArray(); + + parameterSchema.Activate( + instance, + parameterInputs.Select(p => p.Value).ToArray() + ); + + position += parameterInputs.Length; + remainingParameterInputs.RemoveRange(parameterInputs); + } + + remainingRequiredParameterSchemas.Remove(parameterSchema); + } + + if (remainingParameterInputs.Any()) + { + throw CliFxException.UserError( + $""" + Unexpected parameter(s): + {remainingParameterInputs.Select(p => p.GetFormattedIdentifier()).JoinToString(" ")} + """ + ); + } + + if (remainingRequiredParameterSchemas.Any()) + { + throw CliFxException.UserError( + $""" + Missing required parameter(s): + {remainingRequiredParameterSchemas + .Select(p => p.GetFormattedIdentifier()) + .JoinToString(" ")} + """ + ); + } + } + + private void ActivateOptions(CommandInput input, ICommand instance) + { + // Ensure there are no unrecognized options and that all required options are set + var remainingOptionInputs = input.Options.ToList(); + var remainingRequiredOptionSchemas = Options.Where(o => o.IsRequired) + .ToList(); + + foreach (var optionSchema in Options) + { + var optionInputs = input + .Options.Where(o => optionSchema.MatchesIdentifier(o.Identifier)) + .ToArray(); + + var environmentVariableInput = input.EnvironmentVariables.FirstOrDefault(e => + optionSchema.MatchesEnvironmentVariable(e.Name) + ); + + // Direct input + if (optionInputs.Any()) + { + var rawValues = optionInputs.SelectMany(o => o.Values).ToArray(); + + optionSchema.Activate(instance, rawValues); + + // Required options need at least one value to be set + if (rawValues.Any()) + remainingRequiredOptionSchemas.Remove(optionSchema); + } + // Environment variable + else if (environmentVariableInput is not null) + { + var rawValues = !optionSchema.IsSequence + ? [environmentVariableInput.Value] + : environmentVariableInput.SplitValues(); + + optionSchema.Activate(instance, rawValues); + + // Required options need at least one value to be set + if (rawValues.Any()) + remainingRequiredOptionSchemas.Remove(optionSchema); + } + // No input, skip + else + { + continue; + } + + remainingOptionInputs.RemoveRange(optionInputs); + } + + if (remainingOptionInputs.Any()) + { + throw CliFxException.UserError( + $""" + Unrecognized option(s): + {remainingOptionInputs.Select(o => o.GetFormattedIdentifier()).JoinToString(", ")} + """ + ); + } + + if (remainingRequiredOptionSchemas.Any()) + { + throw CliFxException.UserError( + $""" + Missing required option(s): + {remainingRequiredOptionSchemas + .Select(o => o.GetFormattedIdentifier()) + .JoinToString(", ")} + """ + ); + } + } + + internal void Activate(CommandInput input, ICommand instance) + { + ActivateParameters(input, instance); + ActivateOptions(input, instance); + } } // Generic version of the type is used to simplify initialization from the source-generated code diff --git a/CliFx/Schema/InputSchema.cs b/CliFx/Schema/InputSchema.cs index 95e1afc..f692e94 100644 --- a/CliFx/Schema/InputSchema.cs +++ b/CliFx/Schema/InputSchema.cs @@ -28,32 +28,31 @@ public abstract class InputSchema( public PropertyBinding Property { get; } = property; /// - /// Optional binding converter for this input. + /// Binding converter used for this input. /// public IBindingConverter Converter { get; } = converter; /// - /// Optional binding validator(s) for this input. + /// Binding validator(s) used for this input. /// public IReadOnlyList Validators { get; } = validators; - internal void Validate(object? value) + internal abstract string GetKind(); + + internal abstract string GetFormattedIdentifier(); + + private void Validate(object? value) { - var errors = new List(); - - foreach (var validator in validators) - { - var error = validator.Validate(value); - - if (error is not null) - errors.Add(error); - } + var errors = Validators + .Select(validator => validator.Validate(value)) + .OfType() + .ToArray(); if (errors.Any()) { throw CliFxException.UserError( $""" - {memberSchema.GetKind()} {memberSchema.GetFormattedIdentifier()} has been provided with an invalid value. + {GetKind()} {GetFormattedIdentifier()} has been provided with an invalid value. Error(s): {errors.Select(e => "- " + e.Message).JoinToString(Environment.NewLine)} """ @@ -61,34 +60,48 @@ public abstract class InputSchema( } } - internal void Set(ICommand command, IReadOnlyList rawInputs) + internal void Activate(ICommand instance, IReadOnlyList rawInputs) { var formatProvider = CultureInfo.InvariantCulture; - // Multiple values expected, single or multiple values provided - if (IsSequence) + try { - var value = rawInputs.Select(v => Converter.Convert(v, formatProvider)).ToArray(); - Validate(value); + // Multiple values expected, single or multiple values provided + if (IsSequence) + { + var value = rawInputs.Select(v => Converter.Convert(v, formatProvider)).ToArray(); + Validate(value); - Property.Set(command, value); - } - // Single value expected, single value provided - else if (rawInputs.Count <= 1) - { - var value = Converter.Convert(rawInputs.SingleOrDefault(), formatProvider); - Validate(value); + Property.SetValue(instance, value); + } + // Single value expected, single value provided + else if (rawInputs.Count <= 1) + { + var value = Converter.Convert(rawInputs.SingleOrDefault(), formatProvider); + Validate(value); - Property.Set(command, value); + Property.SetValue(instance, value); + } + // Single value expected, multiple values provided + else + { + throw CliFxException.UserError( + $""" + {GetKind()} {GetFormattedIdentifier()} expects a single argument, but provided with multiple: + {rawInputs.Select(v => '<' + v + '>').JoinToString(" ")} + """ + ); + } } - // Single value expected, multiple values provided - else + catch (Exception ex) when (ex is not CliFxException) // don't wrap CliFxException { throw CliFxException.UserError( $""" - {memberSchema.GetKind()} {memberSchema.GetFormattedIdentifier()} expects a single argument, but provided with multiple: - {rawInputs.Select(v => '<' + v + '>').JoinToString(" ")} - """ + {GetKind()} {GetFormattedIdentifier()} cannot be set from the provided argument(s): + {rawInputs.Select(v => '<' + v + '>').JoinToString(" ")} + Error: {ex.Message} + """, + ex ); } } diff --git a/CliFx/Schema/OptionSchema.cs b/CliFx/Schema/OptionSchema.cs index 03509a6..84554b1 100644 --- a/CliFx/Schema/OptionSchema.cs +++ b/CliFx/Schema/OptionSchema.cs @@ -59,7 +59,9 @@ public class OptionSchema( !string.IsNullOrWhiteSpace(EnvironmentVariable) && string.Equals(EnvironmentVariable, environmentVariableName, StringComparison.Ordinal); - internal string GetFormattedIdentifier() + internal override string GetKind() => "Option"; + + internal override string GetFormattedIdentifier() { var buffer = new StringBuilder(); diff --git a/CliFx/Schema/ParameterSchema.cs b/CliFx/Schema/ParameterSchema.cs index e7969fc..4ed896f 100644 --- a/CliFx/Schema/ParameterSchema.cs +++ b/CliFx/Schema/ParameterSchema.cs @@ -37,7 +37,9 @@ public class ParameterSchema( /// public string? Description { get; } = description; - internal string GetFormattedIdentifier() => IsSequence ? $"<{Name}>" : $"<{Name}...>"; + internal override string GetKind() => "Parameter"; + + internal override string GetFormattedIdentifier() => IsSequence ? $"<{Name}>" : $"<{Name}...>"; } // Generic version of the type is used to simplify initialization from the source-generated code diff --git a/CliFx/Schema/PropertyBinding.cs b/CliFx/Schema/PropertyBinding.cs index 26a8004..9b1b8d5 100644 --- a/CliFx/Schema/PropertyBinding.cs +++ b/CliFx/Schema/PropertyBinding.cs @@ -13,8 +13,8 @@ public class PropertyBinding( DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods )] Type type, - Func get, - Action set + Func getValue, + Action setValue ) { /// @@ -28,12 +28,12 @@ public class PropertyBinding( /// /// Gets the current value of the property on the specified instance. /// - public object? Get(object instance) => get(instance); + public object? GetValue(object instance) => getValue(instance); /// /// Sets the current value of the property on the specified instance. /// - public void Set(object instance, object? value) => set(instance, value); + public void SetValue(object instance, object? value) => setValue(instance, value); internal IReadOnlyList? TryGetValidValues() { @@ -65,9 +65,9 @@ public class PropertyBinding< DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods )] TProperty ->(Func get, Action set) +>(Func getValue, Action setValue) : PropertyBinding( typeof(TProperty), - o => get((TObject)o), - (o, v) => set((TObject)o, (TProperty?)v) + o => getValue((TObject)o), + (o, v) => setValue((TObject)o, (TProperty?)v) ); diff --git a/CliFx/Utils/Extensions/CollectionExtensions.cs b/CliFx/Utils/Extensions/CollectionExtensions.cs index 1600c7b..cc1e165 100644 --- a/CliFx/Utils/Extensions/CollectionExtensions.cs +++ b/CliFx/Utils/Extensions/CollectionExtensions.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections; +using System.Collections; using System.Collections.Generic; using System.Linq; @@ -14,16 +13,6 @@ internal static class CollectionExtensions yield return (o, i++); } - public static IEnumerable WhereNotNull(this IEnumerable source) - where T : class - { - foreach (var i in source) - { - if (i is not null) - yield return i; - } - } - public static IEnumerable WhereNotNullOrWhiteSpace(this IEnumerable source) { foreach (var i in source) @@ -47,14 +36,4 @@ internal static class CollectionExtensions dictionary .Cast() .ToDictionary(entry => (TKey)entry.Key, entry => (TValue)entry.Value!, comparer); - - public static Array ToNonGenericArray(this IEnumerable source, Type elementType) - { - var sourceAsCollection = source as ICollection ?? source.ToArray(); - - var array = Array.CreateInstance(elementType, sourceAsCollection.Count); - sourceAsCollection.CopyTo(array, 0); - - return array; - } } diff --git a/CliFx/Utils/Extensions/TypeExtensions.cs b/CliFx/Utils/Extensions/TypeExtensions.cs index 0522702..1d469a0 100644 --- a/CliFx/Utils/Extensions/TypeExtensions.cs +++ b/CliFx/Utils/Extensions/TypeExtensions.cs @@ -3,20 +3,11 @@ using System.Collections; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Reflection; namespace CliFx.Utils.Extensions; internal static class TypeExtensions { - public static bool Implements( - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] this Type type, - Type interfaceType - ) => type.GetInterfaces().Contains(interfaceType); - - public static Type? TryGetNullableUnderlyingType(this Type type) => - Nullable.GetUnderlyingType(type); - public static Type? TryGetEnumerableUnderlyingType( [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] this Type type ) @@ -39,23 +30,11 @@ internal static class TypeExtensions .MaxBy(t => t != typeof(object)); } - public static MethodInfo? TryGetStaticParseMethod( - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] this Type type, - bool withFormatProvider = false - ) => - type.GetMethod( - "Parse", - BindingFlags.Public | BindingFlags.Static, - null, - withFormatProvider ? [typeof(string), typeof(IFormatProvider)] : [typeof(string)], - null - ); - public static bool IsToStringOverriden( [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] this Type type ) { var toStringMethod = type.GetMethod(nameof(ToString), Type.EmptyTypes); - return toStringMethod?.GetBaseDefinition()?.DeclaringType != toStringMethod?.DeclaringType; + return toStringMethod?.GetBaseDefinition().DeclaringType != toStringMethod?.DeclaringType; } }