diff --git a/CliFx.Tests.Dummy/Program.cs b/CliFx.Tests.Dummy/Program.cs index eb6265e..7c44252 100644 --- a/CliFx.Tests.Dummy/Program.cs +++ b/CliFx.Tests.Dummy/Program.cs @@ -1,7 +1,6 @@ using System; using System.IO; using System.Reflection; -using System.Runtime.InteropServices; using System.Threading.Tasks; namespace CliFx.Tests.Dummy; @@ -13,7 +12,7 @@ public static class Program public static string FilePath { get; } = Path.ChangeExtension( Assembly.GetExecutingAssembly().Location, - RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "exe" : null + OperatingSystem.IsWindows() ? "exe" : null ); public static async Task Main() diff --git a/CliFx/Attributes/CommandParameterAttribute.cs b/CliFx/Attributes/CommandParameterAttribute.cs index c377105..db5cc61 100644 --- a/CliFx/Attributes/CommandParameterAttribute.cs +++ b/CliFx/Attributes/CommandParameterAttribute.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using CliFx.Extensibility; namespace CliFx.Attributes; @@ -15,8 +16,9 @@ public class CommandParameterAttribute(int order) : Attribute /// /// /// All parameters in a command must have unique order. - /// Parameter whose type is a non-scalar (e.g. array), must always be the last in order. - /// Only one non-scalar parameter is allowed in a command. + /// Parameter whose type is a sequence (e.g. Array, ; except ), + /// must always be the last parameter based on order. + /// Only one sequential parameter is allowed in a command. /// public int Order { get; } = order; diff --git a/CliFx/Extensibility/BindingConverter.cs b/CliFx/Extensibility/BindingConverter.cs index 49e5ec5..469934b 100644 --- a/CliFx/Extensibility/BindingConverter.cs +++ b/CliFx/Extensibility/BindingConverter.cs @@ -1,4 +1,6 @@ -namespace CliFx.Extensibility; +using System; + +namespace CliFx.Extensibility; /// /// Base type for custom converters. @@ -8,7 +10,8 @@ public abstract class BindingConverter : IBindingConverter /// /// Parses the value from a raw command-line argument. /// - public abstract T? Convert(string? rawValue); + public abstract T? Convert(string? rawValue, IFormatProvider? formatProvider); - object? IBindingConverter.Convert(string? rawValue) => Convert(rawValue); + object? IBindingConverter.Convert(string? rawValue, IFormatProvider? formatProvider) => + Convert(rawValue, formatProvider); } diff --git a/CliFx/Extensibility/BoolBindingConverter.cs b/CliFx/Extensibility/BoolBindingConverter.cs index 25347a5..b004638 100644 --- a/CliFx/Extensibility/BoolBindingConverter.cs +++ b/CliFx/Extensibility/BoolBindingConverter.cs @@ -1,4 +1,6 @@ -namespace CliFx.Extensibility; +using System; + +namespace CliFx.Extensibility; /// /// Converter for binding inputs to properties of type . @@ -6,5 +8,6 @@ public class BoolBindingConverter : BindingConverter { /// - public override bool Convert(string? rawValue) => string.IsNullOrWhiteSpace(rawValue) || bool.Parse(rawValue); -} \ No newline at end of file + public override bool Convert(string? rawValue, IFormatProvider? formatProvider) => + string.IsNullOrWhiteSpace(rawValue) || bool.Parse(rawValue); +} diff --git a/CliFx/Extensibility/ConvertibleBindingConverter.cs b/CliFx/Extensibility/ConvertibleBindingConverter.cs index c989ab0..ae51ffe 100644 --- a/CliFx/Extensibility/ConvertibleBindingConverter.cs +++ b/CliFx/Extensibility/ConvertibleBindingConverter.cs @@ -5,9 +5,10 @@ namespace CliFx.Extensibility; /// /// Converter for binding inputs to properties that implement . /// -public class ConvertibleBindingConverter(IFormatProvider formatProvider) : BindingConverter where T: IConvertible +public class ConvertibleBindingConverter : BindingConverter + where T : IConvertible { /// - public override T? Convert(string? rawValue) => + public override T? Convert(string? rawValue, IFormatProvider? formatProvider) => (T?)System.Convert.ChangeType(rawValue, typeof(T), formatProvider); -} \ No newline at end of file +} diff --git a/CliFx/Extensibility/DateTimeOffsetBindingConverter.cs b/CliFx/Extensibility/DateTimeOffsetBindingConverter.cs index 1a00845..35dd428 100644 --- a/CliFx/Extensibility/DateTimeOffsetBindingConverter.cs +++ b/CliFx/Extensibility/DateTimeOffsetBindingConverter.cs @@ -5,8 +5,9 @@ namespace CliFx.Extensibility; /// /// Converter for binding inputs to properties of type . /// -public class DateTimeOffsetBindingConverter(IFormatProvider formatProvider) : BindingConverter +public class DateTimeOffsetBindingConverter : BindingConverter { /// - public override DateTimeOffset Convert(string? rawValue) => DateTimeOffset.Parse(rawValue!, formatProvider); -} \ No newline at end of file + public override DateTimeOffset Convert(string? rawValue, IFormatProvider? formatProvider) => + DateTimeOffset.Parse(rawValue!, formatProvider); +} diff --git a/CliFx/Extensibility/DelegateBindingConverter.cs b/CliFx/Extensibility/DelegateBindingConverter.cs index a96ecf7..5292ea9 100644 --- a/CliFx/Extensibility/DelegateBindingConverter.cs +++ b/CliFx/Extensibility/DelegateBindingConverter.cs @@ -5,8 +5,16 @@ namespace CliFx.Extensibility; /// /// Converter for binding inputs to properties using a custom delegate. /// -public class DelegateBindingConverter(Func convert) : BindingConverter +public class DelegateBindingConverter(Func convert) + : BindingConverter { + /// + /// Initializes an instance of + /// + public DelegateBindingConverter(Func convert) + : this((rawValue, _) => convert(rawValue)) { } + /// - public override T? Convert(string? rawValue) => convert(rawValue); -} \ No newline at end of file + public override T Convert(string? rawValue, IFormatProvider? formatProvider) => + convert(rawValue, formatProvider); +} diff --git a/CliFx/Extensibility/EnumBindingConverter.cs b/CliFx/Extensibility/EnumBindingConverter.cs index 92ec1dd..f87b002 100644 --- a/CliFx/Extensibility/EnumBindingConverter.cs +++ b/CliFx/Extensibility/EnumBindingConverter.cs @@ -5,8 +5,10 @@ namespace CliFx.Extensibility; /// /// Converter for binding inputs to properties of type . /// -public class EnumBindingConverter : BindingConverter where T : struct, Enum +public class EnumBindingConverter : BindingConverter + where T : struct, Enum { /// - public override T Convert(string? rawValue) => (T)Enum.Parse(typeof(T), rawValue!, true); -} \ No newline at end of file + public override T Convert(string? rawValue, IFormatProvider? formatProvider) => + (T)Enum.Parse(typeof(T), rawValue!, true); +} diff --git a/CliFx/Extensibility/IBindingConverter.cs b/CliFx/Extensibility/IBindingConverter.cs index b50b9a4..a87c8a3 100644 --- a/CliFx/Extensibility/IBindingConverter.cs +++ b/CliFx/Extensibility/IBindingConverter.cs @@ -1,4 +1,6 @@ -namespace CliFx.Extensibility; +using System; + +namespace CliFx.Extensibility; /// /// Defines a custom conversion for binding command-line arguments to command inputs. @@ -11,5 +13,5 @@ public interface IBindingConverter /// /// Parses the value from a raw command-line argument. /// - object? Convert(string? rawValue); -} \ No newline at end of file + object? Convert(string? rawValue, IFormatProvider? formatProvider); +} diff --git a/CliFx/Extensibility/IBindingValidator.cs b/CliFx/Extensibility/IBindingValidator.cs index 7e13070..97cf561 100644 --- a/CliFx/Extensibility/IBindingValidator.cs +++ b/CliFx/Extensibility/IBindingValidator.cs @@ -13,4 +13,4 @@ public interface IBindingValidator /// Returns null if validation is successful, or an error in case of failure. /// BindingValidationError? Validate(object? value); -} \ No newline at end of file +} diff --git a/CliFx/Extensibility/NoopBindingConverter.cs b/CliFx/Extensibility/NoopBindingConverter.cs index 1b8c106..c977065 100644 --- a/CliFx/Extensibility/NoopBindingConverter.cs +++ b/CliFx/Extensibility/NoopBindingConverter.cs @@ -1,4 +1,6 @@ -namespace CliFx.Extensibility; +using System; + +namespace CliFx.Extensibility; /// /// Converter for binding inputs to properties without any conversion. @@ -6,5 +8,5 @@ public class NoopBindingConverter : IBindingConverter { /// - public object? Convert(string? rawValue) => rawValue; -} \ No newline at end of file + public object? Convert(string? rawValue, IFormatProvider? formatProvider) => rawValue; +} diff --git a/CliFx/Extensibility/NullableBindingConverter.cs b/CliFx/Extensibility/NullableBindingConverter.cs index 22b8f4b..be59b98 100644 --- a/CliFx/Extensibility/NullableBindingConverter.cs +++ b/CliFx/Extensibility/NullableBindingConverter.cs @@ -5,11 +5,12 @@ namespace CliFx.Extensibility; /// /// Converter for binding inputs to properties of type . /// -public class NullableBindingConverter(BindingConverter innerConverter) : BindingConverter where T : struct +public class NullableBindingConverter(BindingConverter innerConverter) : BindingConverter + where T : struct { /// - public override T? Convert(string? rawValue) => + public override T? Convert(string? rawValue, IFormatProvider? formatProvider) => !string.IsNullOrWhiteSpace(rawValue) - ? innerConverter.Convert(rawValue) + ? innerConverter.Convert(rawValue, formatProvider) : null; -} \ No newline at end of file +} diff --git a/CliFx/Extensibility/TimeSpanBindingConverter.cs b/CliFx/Extensibility/TimeSpanBindingConverter.cs index c785a05..37804d5 100644 --- a/CliFx/Extensibility/TimeSpanBindingConverter.cs +++ b/CliFx/Extensibility/TimeSpanBindingConverter.cs @@ -5,9 +5,9 @@ namespace CliFx.Extensibility; /// /// Converter for binding inputs to properties of type . /// -public class TimeSpanBindingConverter(IFormatProvider formatProvider) : BindingConverter +public class TimeSpanBindingConverter : BindingConverter { /// - public override TimeSpan Convert(string? rawValue) => + public override TimeSpan Convert(string? rawValue, IFormatProvider? formatProvider) => TimeSpan.Parse(rawValue!, formatProvider); -} \ No newline at end of file +} diff --git a/CliFx/FallbackDefaultCommand.cs b/CliFx/FallbackDefaultCommand.cs index 1109115..0ce3928 100644 --- a/CliFx/FallbackDefaultCommand.cs +++ b/CliFx/FallbackDefaultCommand.cs @@ -10,7 +10,10 @@ namespace CliFx; // Fallback command used when the application doesn't have one configured. // This command is only used as a stub for help text. [Command] -internal partial class FallbackDefaultCommand : IBindableCommand, ICommandWithHelpOption, ICommandWithVersionOption +internal partial class FallbackDefaultCommand + : IBindableCommand, + ICommandWithHelpOption, + ICommandWithVersionOption { [CommandHelpOption] public bool IsHelpRequested { get; init; } diff --git a/CliFx/IBindableCommand.cs b/CliFx/IBindableCommand.cs index 830f61a..c9c449a 100644 --- a/CliFx/IBindableCommand.cs +++ b/CliFx/IBindableCommand.cs @@ -17,4 +17,4 @@ public interface IBindableCommand : ICommand /// Binds the command input to the current instance. /// void Bind(CommandInput input); -} \ No newline at end of file +} diff --git a/CliFx/Schema/CommandSchema.cs b/CliFx/Schema/CommandSchema.cs index b9fc517..df551f8 100644 --- a/CliFx/Schema/CommandSchema.cs +++ b/CliFx/Schema/CommandSchema.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; namespace CliFx.Schema; @@ -11,8 +12,7 @@ public class CommandSchema( [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type, string? name, string? description, - IReadOnlyList parameters, - IReadOnlyList options + IReadOnlyList inputs ) { /// @@ -36,15 +36,21 @@ public class CommandSchema( /// public string? Description { get; } = description; + /// + /// Inputs (parameters and options) of the command. + /// + public IReadOnlyList Inputs { get; } = inputs; + /// /// Parameter inputs of the command. /// - public IReadOnlyList Parameters { get; } = parameters; + public IReadOnlyList Parameters { get; } = + inputs.OfType().ToArray(); /// /// Option inputs of the command. /// - public IReadOnlyList Options { get; } = options; + public IReadOnlyList Options { get; } = inputs.OfType().ToArray(); internal bool MatchesName(string? name) => !string.IsNullOrWhiteSpace(Name) @@ -57,16 +63,26 @@ public class CommandSchema( foreach (var parameterSchema in Parameters) { - var value = parameterSchema.Property.GetValue(instance); + var value = parameterSchema.Property.Get(instance); result[parameterSchema] = value; } foreach (var optionSchema in Options) { - var value = optionSchema.Property.GetValue(instance); + var value = optionSchema.Property.Get(instance); result[optionSchema] = value; } return result; } } + +// Generic version of the type is used to simplify initialization from the source-generated code +// and to enforce static references to all the types used in the binding. +// The non-generic version is used internally by the framework when operating in a dynamic context. +/// +public class CommandSchema< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TCommand +>(string? name, string? description, IReadOnlyList inputs) + : CommandSchema(typeof(TCommand), name, description, inputs) + where TCommand : ICommand; diff --git a/CliFx/Schema/InputSchema.cs b/CliFx/Schema/InputSchema.cs index 4f4e260..95e1afc 100644 --- a/CliFx/Schema/InputSchema.cs +++ b/CliFx/Schema/InputSchema.cs @@ -1,5 +1,11 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using CliFx.Exceptions; using CliFx.Extensibility; +using CliFx.Utils.Extensions; namespace CliFx.Schema; @@ -8,28 +14,99 @@ namespace CliFx.Schema; /// public abstract class InputSchema( PropertyBinding property, - bool isSequence, - IBindingConverter? converter, + IBindingConverter converter, IReadOnlyList validators ) { + internal bool IsSequence { get; } = + property.Type != typeof(string) + && property.Type.TryGetEnumerableUnderlyingType() is not null; + /// /// CLR property to which this input is bound. /// public PropertyBinding Property { get; } = property; - /// - /// Whether the input can accept more than one value. - /// - public bool IsSequence { get; } = isSequence; - /// /// Optional binding converter for this input. /// - public IBindingConverter? Converter { get; } = converter; + public IBindingConverter Converter { get; } = converter; /// /// Optional binding validator(s) for this input. /// public IReadOnlyList Validators { get; } = validators; + + internal 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); + } + + if (errors.Any()) + { + throw CliFxException.UserError( + $""" + {memberSchema.GetKind()} {memberSchema.GetFormattedIdentifier()} has been provided with an invalid value. + Error(s): + {errors.Select(e => "- " + e.Message).JoinToString(Environment.NewLine)} + """ + ); + } + } + + internal void Set(ICommand command, IReadOnlyList rawInputs) + { + var formatProvider = CultureInfo.InvariantCulture; + + // 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.Set(command, value); + } + // Single value expected, multiple values provided + else + { + throw CliFxException.UserError( + $""" + {memberSchema.GetKind()} {memberSchema.GetFormattedIdentifier()} expects a single argument, but provided with multiple: + {rawInputs.Select(v => '<' + v + '>').JoinToString(" ")} + """ + ); + } + } } + +// Generic version of the type is used to simplify initialization from the source-generated code +// and to enforce static references to all the types used in the binding. +// The non-generic version is used internally by the framework when operating in a dynamic context. +/// +public abstract class InputSchema< + TCommand, + [DynamicallyAccessedMembers( + DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods + )] + TProperty +>( + PropertyBinding property, + BindingConverter converter, + IReadOnlyList> validators +) : InputSchema(property, converter, validators) + where TCommand : ICommand; diff --git a/CliFx/Schema/OptionSchema.cs b/CliFx/Schema/OptionSchema.cs index 493760e..03509a6 100644 --- a/CliFx/Schema/OptionSchema.cs +++ b/CliFx/Schema/OptionSchema.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Text; using CliFx.Extensibility; @@ -10,15 +11,14 @@ namespace CliFx.Schema; /// public class OptionSchema( PropertyBinding property, - bool isSequence, string? name, char? shortName, string? environmentVariable, bool isRequired, string? description, - IBindingConverter? converter, + IBindingConverter converter, IReadOnlyList validators -) : InputSchema(property, isSequence, converter, validators) +) : InputSchema(property, converter, validators) { /// /// Option name. @@ -84,3 +84,35 @@ public class OptionSchema( return buffer.ToString(); } } + +// Generic version of the type is used to simplify initialization from the source-generated code +// and to enforce static references to all the types used in the binding. +// The non-generic version is used internally by the framework when operating in a dynamic context. +/// +public class OptionSchema< + TCommand, + [DynamicallyAccessedMembers( + DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods + )] + TProperty +>( + PropertyBinding property, + string? name, + char? shortName, + string? environmentVariable, + bool isRequired, + string? description, + BindingConverter converter, + IReadOnlyList> validators +) + : OptionSchema( + property, + name, + shortName, + environmentVariable, + isRequired, + description, + converter, + validators + ) + where TCommand : ICommand; diff --git a/CliFx/Schema/ParameterSchema.cs b/CliFx/Schema/ParameterSchema.cs index b52f40d..e7969fc 100644 --- a/CliFx/Schema/ParameterSchema.cs +++ b/CliFx/Schema/ParameterSchema.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using CliFx.Extensibility; namespace CliFx.Schema; @@ -8,14 +9,13 @@ namespace CliFx.Schema; /// public class ParameterSchema( PropertyBinding property, - bool isSequence, int order, string name, bool isRequired, string? description, - IBindingConverter? converter, + IBindingConverter converter, IReadOnlyList validators -) : InputSchema(property, isSequence, converter, validators) +) : InputSchema(property, converter, validators) { /// /// Order, in which the parameter is bound from the command-line arguments. @@ -39,3 +39,24 @@ public class ParameterSchema( internal string GetFormattedIdentifier() => IsSequence ? $"<{Name}>" : $"<{Name}...>"; } + +// Generic version of the type is used to simplify initialization from the source-generated code +// and to enforce static references to all the types used in the binding. +// The non-generic version is used internally by the framework when operating in a dynamic context. +/// +public class ParameterSchema< + TCommand, + [DynamicallyAccessedMembers( + DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods + )] + TProperty +>( + PropertyBinding property, + int order, + string name, + bool isRequired, + string? description, + BindingConverter converter, + IReadOnlyList> validators +) : ParameterSchema(property, order, name, isRequired, description, converter, validators) + where TCommand : ICommand; diff --git a/CliFx/Schema/PropertyBinding.cs b/CliFx/Schema/PropertyBinding.cs index fa3e651..26a8004 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 getValue, - Action setValue + Func get, + Action set ) { /// @@ -28,12 +28,12 @@ public class PropertyBinding( /// /// Gets the current value of the property on the specified instance. /// - public object? GetValue(object instance) => getValue(instance); + public object? Get(object instance) => get(instance); /// /// Sets the current value of the property on the specified instance. /// - public void SetValue(object instance, object? value) => setValue(instance, value); + public void Set(object instance, object? value) => set(instance, value); internal IReadOnlyList? TryGetValidValues() { @@ -54,3 +54,20 @@ public class PropertyBinding( return null; } } + +// Generic version of the type is used to simplify initialization from the source-generated code +// and to enforce static references to all the types used in the binding. +// The non-generic version is used internally by the framework when operating in a dynamic context. +/// +public class PropertyBinding< + TObject, + [DynamicallyAccessedMembers( + DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods + )] + TProperty +>(Func get, Action set) + : PropertyBinding( + typeof(TProperty), + o => get((TObject)o), + (o, v) => set((TObject)o, (TProperty?)v) + ); diff --git a/CliFx/Utils/Extensions/TypeExtensions.cs b/CliFx/Utils/Extensions/TypeExtensions.cs index 108cd88..0522702 100644 --- a/CliFx/Utils/Extensions/TypeExtensions.cs +++ b/CliFx/Utils/Extensions/TypeExtensions.cs @@ -31,7 +31,7 @@ internal static class TypeExtensions return type.GetGenericArguments().FirstOrDefault(); return type.GetInterfaces() - .Select(TryGetEnumerableUnderlyingType) + .Select(t => TryGetEnumerableUnderlyingType(t)) .Where(t => t is not null) // Every IEnumerable implements IEnumerable (which is essentially IEnumerable), // so we try to get a more specific underlying type. Still, if the type only implements