This commit is contained in:
Tyrrrz
2024-08-11 01:44:40 +03:00
parent e20672328b
commit f7645afbdb
21 changed files with 254 additions and 64 deletions

View File

@@ -1,7 +1,6 @@
using System; using System;
using System.IO; using System.IO;
using System.Reflection; using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace CliFx.Tests.Dummy; namespace CliFx.Tests.Dummy;
@@ -13,7 +12,7 @@ public static class Program
public static string FilePath { get; } = public static string FilePath { get; } =
Path.ChangeExtension( Path.ChangeExtension(
Assembly.GetExecutingAssembly().Location, Assembly.GetExecutingAssembly().Location,
RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "exe" : null OperatingSystem.IsWindows() ? "exe" : null
); );
public static async Task Main() public static async Task Main()

View File

@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using CliFx.Extensibility; using CliFx.Extensibility;
namespace CliFx.Attributes; namespace CliFx.Attributes;
@@ -15,8 +16,9 @@ public class CommandParameterAttribute(int order) : Attribute
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// All parameters in a command must have unique order. /// 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. /// Parameter whose type is a sequence (e.g. Array, <see cref="List{T}" />; except <see cref="string" />),
/// Only one non-scalar parameter is allowed in a command. /// must always be the last parameter based on order.
/// Only one sequential parameter is allowed in a command.
/// </remarks> /// </remarks>
public int Order { get; } = order; public int Order { get; } = order;

View File

@@ -1,4 +1,6 @@
namespace CliFx.Extensibility; using System;
namespace CliFx.Extensibility;
/// <summary> /// <summary>
/// Base type for custom converters. /// Base type for custom converters.
@@ -8,7 +10,8 @@ public abstract class BindingConverter<T> : IBindingConverter
/// <summary> /// <summary>
/// Parses the value from a raw command-line argument. /// Parses the value from a raw command-line argument.
/// </summary> /// </summary>
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);
} }

View File

@@ -1,4 +1,6 @@
namespace CliFx.Extensibility; using System;
namespace CliFx.Extensibility;
/// <summary> /// <summary>
/// Converter for binding inputs to properties of type <see cref="bool" />. /// Converter for binding inputs to properties of type <see cref="bool" />.
@@ -6,5 +8,6 @@
public class BoolBindingConverter : BindingConverter<bool> public class BoolBindingConverter : BindingConverter<bool>
{ {
/// <inheritdoc /> /// <inheritdoc />
public override bool Convert(string? rawValue) => string.IsNullOrWhiteSpace(rawValue) || bool.Parse(rawValue); public override bool Convert(string? rawValue, IFormatProvider? formatProvider) =>
string.IsNullOrWhiteSpace(rawValue) || bool.Parse(rawValue);
} }

View File

@@ -5,9 +5,10 @@ namespace CliFx.Extensibility;
/// <summary> /// <summary>
/// Converter for binding inputs to properties that implement <see cref="IConvertible" />. /// Converter for binding inputs to properties that implement <see cref="IConvertible" />.
/// </summary> /// </summary>
public class ConvertibleBindingConverter<T>(IFormatProvider formatProvider) : BindingConverter<T> where T: IConvertible public class ConvertibleBindingConverter<T> : BindingConverter<T>
where T : IConvertible
{ {
/// <inheritdoc /> /// <inheritdoc />
public override T? Convert(string? rawValue) => public override T? Convert(string? rawValue, IFormatProvider? formatProvider) =>
(T?)System.Convert.ChangeType(rawValue, typeof(T), formatProvider); (T?)System.Convert.ChangeType(rawValue, typeof(T), formatProvider);
} }

View File

@@ -5,8 +5,9 @@ namespace CliFx.Extensibility;
/// <summary> /// <summary>
/// Converter for binding inputs to properties of type <see cref="DateTimeOffset" />. /// Converter for binding inputs to properties of type <see cref="DateTimeOffset" />.
/// </summary> /// </summary>
public class DateTimeOffsetBindingConverter(IFormatProvider formatProvider) : BindingConverter<DateTimeOffset> public class DateTimeOffsetBindingConverter : BindingConverter<DateTimeOffset>
{ {
/// <inheritdoc /> /// <inheritdoc />
public override DateTimeOffset Convert(string? rawValue) => DateTimeOffset.Parse(rawValue!, formatProvider); public override DateTimeOffset Convert(string? rawValue, IFormatProvider? formatProvider) =>
DateTimeOffset.Parse(rawValue!, formatProvider);
} }

View File

@@ -5,8 +5,16 @@ namespace CliFx.Extensibility;
/// <summary> /// <summary>
/// Converter for binding inputs to properties using a custom delegate. /// Converter for binding inputs to properties using a custom delegate.
/// </summary> /// </summary>
public class DelegateBindingConverter<T>(Func<string?, T> convert) : BindingConverter<T> public class DelegateBindingConverter<T>(Func<string?, IFormatProvider?, T> convert)
: BindingConverter<T>
{ {
/// <summary>
/// Initializes an instance of <see cref="DelegateBindingConverter{T}" />
/// </summary>
public DelegateBindingConverter(Func<string?, T> convert)
: this((rawValue, _) => convert(rawValue)) { }
/// <inheritdoc /> /// <inheritdoc />
public override T? Convert(string? rawValue) => convert(rawValue); public override T Convert(string? rawValue, IFormatProvider? formatProvider) =>
convert(rawValue, formatProvider);
} }

View File

@@ -5,8 +5,10 @@ namespace CliFx.Extensibility;
/// <summary> /// <summary>
/// Converter for binding inputs to properties of type <see cref="Enum" />. /// Converter for binding inputs to properties of type <see cref="Enum" />.
/// </summary> /// </summary>
public class EnumBindingConverter<T> : BindingConverter<T> where T : struct, Enum public class EnumBindingConverter<T> : BindingConverter<T>
where T : struct, Enum
{ {
/// <inheritdoc /> /// <inheritdoc />
public override T Convert(string? rawValue) => (T)Enum.Parse(typeof(T), rawValue!, true); public override T Convert(string? rawValue, IFormatProvider? formatProvider) =>
(T)Enum.Parse(typeof(T), rawValue!, true);
} }

View File

@@ -1,4 +1,6 @@
namespace CliFx.Extensibility; using System;
namespace CliFx.Extensibility;
/// <summary> /// <summary>
/// Defines a custom conversion for binding command-line arguments to command inputs. /// Defines a custom conversion for binding command-line arguments to command inputs.
@@ -11,5 +13,5 @@ public interface IBindingConverter
/// <summary> /// <summary>
/// Parses the value from a raw command-line argument. /// Parses the value from a raw command-line argument.
/// </summary> /// </summary>
object? Convert(string? rawValue); object? Convert(string? rawValue, IFormatProvider? formatProvider);
} }

View File

@@ -1,4 +1,6 @@
namespace CliFx.Extensibility; using System;
namespace CliFx.Extensibility;
/// <summary> /// <summary>
/// Converter for binding inputs to properties without any conversion. /// Converter for binding inputs to properties without any conversion.
@@ -6,5 +8,5 @@
public class NoopBindingConverter : IBindingConverter public class NoopBindingConverter : IBindingConverter
{ {
/// <inheritdoc /> /// <inheritdoc />
public object? Convert(string? rawValue) => rawValue; public object? Convert(string? rawValue, IFormatProvider? formatProvider) => rawValue;
} }

View File

@@ -5,11 +5,12 @@ namespace CliFx.Extensibility;
/// <summary> /// <summary>
/// Converter for binding inputs to properties of type <see cref="Nullable{T}" />. /// Converter for binding inputs to properties of type <see cref="Nullable{T}" />.
/// </summary> /// </summary>
public class NullableBindingConverter<T>(BindingConverter<T> innerConverter) : BindingConverter<T?> where T : struct public class NullableBindingConverter<T>(BindingConverter<T> innerConverter) : BindingConverter<T?>
where T : struct
{ {
/// <inheritdoc /> /// <inheritdoc />
public override T? Convert(string? rawValue) => public override T? Convert(string? rawValue, IFormatProvider? formatProvider) =>
!string.IsNullOrWhiteSpace(rawValue) !string.IsNullOrWhiteSpace(rawValue)
? innerConverter.Convert(rawValue) ? innerConverter.Convert(rawValue, formatProvider)
: null; : null;
} }

View File

@@ -5,9 +5,9 @@ namespace CliFx.Extensibility;
/// <summary> /// <summary>
/// Converter for binding inputs to properties of type <see cref="TimeSpan" />. /// Converter for binding inputs to properties of type <see cref="TimeSpan" />.
/// </summary> /// </summary>
public class TimeSpanBindingConverter(IFormatProvider formatProvider) : BindingConverter<TimeSpan> public class TimeSpanBindingConverter : BindingConverter<TimeSpan>
{ {
/// <inheritdoc /> /// <inheritdoc />
public override TimeSpan Convert(string? rawValue) => public override TimeSpan Convert(string? rawValue, IFormatProvider? formatProvider) =>
TimeSpan.Parse(rawValue!, formatProvider); TimeSpan.Parse(rawValue!, formatProvider);
} }

View File

@@ -10,7 +10,10 @@ namespace CliFx;
// Fallback command used when the application doesn't have one configured. // Fallback command used when the application doesn't have one configured.
// This command is only used as a stub for help text. // This command is only used as a stub for help text.
[Command] [Command]
internal partial class FallbackDefaultCommand : IBindableCommand, ICommandWithHelpOption, ICommandWithVersionOption internal partial class FallbackDefaultCommand
: IBindableCommand,
ICommandWithHelpOption,
ICommandWithVersionOption
{ {
[CommandHelpOption] [CommandHelpOption]
public bool IsHelpRequested { get; init; } public bool IsHelpRequested { get; init; }

View File

@@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Linq;
namespace CliFx.Schema; namespace CliFx.Schema;
@@ -11,8 +12,7 @@ public class CommandSchema(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type,
string? name, string? name,
string? description, string? description,
IReadOnlyList<ParameterSchema> parameters, IReadOnlyList<InputSchema> inputs
IReadOnlyList<OptionSchema> options
) )
{ {
/// <summary> /// <summary>
@@ -36,15 +36,21 @@ public class CommandSchema(
/// </summary> /// </summary>
public string? Description { get; } = description; public string? Description { get; } = description;
/// <summary>
/// Inputs (parameters and options) of the command.
/// </summary>
public IReadOnlyList<InputSchema> Inputs { get; } = inputs;
/// <summary> /// <summary>
/// Parameter inputs of the command. /// Parameter inputs of the command.
/// </summary> /// </summary>
public IReadOnlyList<ParameterSchema> Parameters { get; } = parameters; public IReadOnlyList<ParameterSchema> Parameters { get; } =
inputs.OfType<ParameterSchema>().ToArray();
/// <summary> /// <summary>
/// Option inputs of the command. /// Option inputs of the command.
/// </summary> /// </summary>
public IReadOnlyList<OptionSchema> Options { get; } = options; public IReadOnlyList<OptionSchema> Options { get; } = inputs.OfType<OptionSchema>().ToArray();
internal bool MatchesName(string? name) => internal bool MatchesName(string? name) =>
!string.IsNullOrWhiteSpace(Name) !string.IsNullOrWhiteSpace(Name)
@@ -57,16 +63,26 @@ public class CommandSchema(
foreach (var parameterSchema in Parameters) foreach (var parameterSchema in Parameters)
{ {
var value = parameterSchema.Property.GetValue(instance); var value = parameterSchema.Property.Get(instance);
result[parameterSchema] = value; result[parameterSchema] = value;
} }
foreach (var optionSchema in Options) foreach (var optionSchema in Options)
{ {
var value = optionSchema.Property.GetValue(instance); var value = optionSchema.Property.Get(instance);
result[optionSchema] = value; result[optionSchema] = value;
} }
return result; 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.
/// <inheritdoc cref="CommandSchema" />
public class CommandSchema<
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TCommand
>(string? name, string? description, IReadOnlyList<InputSchema> inputs)
: CommandSchema(typeof(TCommand), name, description, inputs)
where TCommand : ICommand;

View File

@@ -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.Extensibility;
using CliFx.Utils.Extensions;
namespace CliFx.Schema; namespace CliFx.Schema;
@@ -8,28 +14,99 @@ namespace CliFx.Schema;
/// </summary> /// </summary>
public abstract class InputSchema( public abstract class InputSchema(
PropertyBinding property, PropertyBinding property,
bool isSequence, IBindingConverter converter,
IBindingConverter? converter,
IReadOnlyList<IBindingValidator> validators IReadOnlyList<IBindingValidator> validators
) )
{ {
internal bool IsSequence { get; } =
property.Type != typeof(string)
&& property.Type.TryGetEnumerableUnderlyingType() is not null;
/// <summary> /// <summary>
/// CLR property to which this input is bound. /// CLR property to which this input is bound.
/// </summary> /// </summary>
public PropertyBinding Property { get; } = property; public PropertyBinding Property { get; } = property;
/// <summary>
/// Whether the input can accept more than one value.
/// </summary>
public bool IsSequence { get; } = isSequence;
/// <summary> /// <summary>
/// Optional binding converter for this input. /// Optional binding converter for this input.
/// </summary> /// </summary>
public IBindingConverter? Converter { get; } = converter; public IBindingConverter Converter { get; } = converter;
/// <summary> /// <summary>
/// Optional binding validator(s) for this input. /// Optional binding validator(s) for this input.
/// </summary> /// </summary>
public IReadOnlyList<IBindingValidator> Validators { get; } = validators; public IReadOnlyList<IBindingValidator> Validators { get; } = validators;
internal void Validate(object? value)
{
var errors = new List<BindingValidationError>();
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<string?> 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.
/// <inheritdoc cref="InputSchema" />
public abstract class InputSchema<
TCommand,
[DynamicallyAccessedMembers(
DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods
)]
TProperty
>(
PropertyBinding<TCommand, TProperty> property,
BindingConverter<TProperty> converter,
IReadOnlyList<BindingValidator<TProperty>> validators
) : InputSchema(property, converter, validators)
where TCommand : ICommand;

View File

@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text; using System.Text;
using CliFx.Extensibility; using CliFx.Extensibility;
@@ -10,15 +11,14 @@ namespace CliFx.Schema;
/// </summary> /// </summary>
public class OptionSchema( public class OptionSchema(
PropertyBinding property, PropertyBinding property,
bool isSequence,
string? name, string? name,
char? shortName, char? shortName,
string? environmentVariable, string? environmentVariable,
bool isRequired, bool isRequired,
string? description, string? description,
IBindingConverter? converter, IBindingConverter converter,
IReadOnlyList<IBindingValidator> validators IReadOnlyList<IBindingValidator> validators
) : InputSchema(property, isSequence, converter, validators) ) : InputSchema(property, converter, validators)
{ {
/// <summary> /// <summary>
/// Option name. /// Option name.
@@ -84,3 +84,35 @@ public class OptionSchema(
return buffer.ToString(); 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.
/// <inheritdoc cref="OptionSchema" />
public class OptionSchema<
TCommand,
[DynamicallyAccessedMembers(
DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods
)]
TProperty
>(
PropertyBinding<TCommand, TProperty> property,
string? name,
char? shortName,
string? environmentVariable,
bool isRequired,
string? description,
BindingConverter<TProperty> converter,
IReadOnlyList<BindingValidator<TProperty>> validators
)
: OptionSchema(
property,
name,
shortName,
environmentVariable,
isRequired,
description,
converter,
validators
)
where TCommand : ICommand;

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using CliFx.Extensibility; using CliFx.Extensibility;
namespace CliFx.Schema; namespace CliFx.Schema;
@@ -8,14 +9,13 @@ namespace CliFx.Schema;
/// </summary> /// </summary>
public class ParameterSchema( public class ParameterSchema(
PropertyBinding property, PropertyBinding property,
bool isSequence,
int order, int order,
string name, string name,
bool isRequired, bool isRequired,
string? description, string? description,
IBindingConverter? converter, IBindingConverter converter,
IReadOnlyList<IBindingValidator> validators IReadOnlyList<IBindingValidator> validators
) : InputSchema(property, isSequence, converter, validators) ) : InputSchema(property, converter, validators)
{ {
/// <summary> /// <summary>
/// Order, in which the parameter is bound from the command-line arguments. /// 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}...>"; 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.
/// <inheritdoc cref="ParameterSchema" />
public class ParameterSchema<
TCommand,
[DynamicallyAccessedMembers(
DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods
)]
TProperty
>(
PropertyBinding<TCommand, TProperty> property,
int order,
string name,
bool isRequired,
string? description,
BindingConverter<TProperty> converter,
IReadOnlyList<BindingValidator<TProperty>> validators
) : ParameterSchema(property, order, name, isRequired, description, converter, validators)
where TCommand : ICommand;

View File

@@ -13,8 +13,8 @@ public class PropertyBinding(
DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods
)] )]
Type type, Type type,
Func<object, object?> getValue, Func<object, object?> get,
Action<object, object?> setValue Action<object, object?> set
) )
{ {
/// <summary> /// <summary>
@@ -28,12 +28,12 @@ public class PropertyBinding(
/// <summary> /// <summary>
/// Gets the current value of the property on the specified instance. /// Gets the current value of the property on the specified instance.
/// </summary> /// </summary>
public object? GetValue(object instance) => getValue(instance); public object? Get(object instance) => get(instance);
/// <summary> /// <summary>
/// Sets the current value of the property on the specified instance. /// Sets the current value of the property on the specified instance.
/// </summary> /// </summary>
public void SetValue(object instance, object? value) => setValue(instance, value); public void Set(object instance, object? value) => set(instance, value);
internal IReadOnlyList<object?>? TryGetValidValues() internal IReadOnlyList<object?>? TryGetValidValues()
{ {
@@ -54,3 +54,20 @@ public class PropertyBinding(
return null; 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.
/// <inheritdoc cref="PropertyBinding" />
public class PropertyBinding<
TObject,
[DynamicallyAccessedMembers(
DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods
)]
TProperty
>(Func<TObject, TProperty?> get, Action<TObject, TProperty?> set)
: PropertyBinding(
typeof(TProperty),
o => get((TObject)o),
(o, v) => set((TObject)o, (TProperty?)v)
);

View File

@@ -31,7 +31,7 @@ internal static class TypeExtensions
return type.GetGenericArguments().FirstOrDefault(); return type.GetGenericArguments().FirstOrDefault();
return type.GetInterfaces() return type.GetInterfaces()
.Select(TryGetEnumerableUnderlyingType) .Select(t => TryGetEnumerableUnderlyingType(t))
.Where(t => t is not null) .Where(t => t is not null)
// Every IEnumerable<T> implements IEnumerable (which is essentially IEnumerable<object>), // Every IEnumerable<T> implements IEnumerable (which is essentially IEnumerable<object>),
// so we try to get a more specific underlying type. Still, if the type only implements // so we try to get a more specific underlying type. Still, if the type only implements