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.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()

View File

@@ -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
/// </summary>
/// <remarks>
/// 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, <see cref="List{T}" />; except <see cref="string" />),
/// must always be the last parameter based on order.
/// Only one sequential parameter is allowed in a command.
/// </remarks>
public int Order { get; } = order;

View File

@@ -1,4 +1,6 @@
namespace CliFx.Extensibility;
using System;
namespace CliFx.Extensibility;
/// <summary>
/// Base type for custom converters.
@@ -8,7 +10,8 @@ public abstract class BindingConverter<T> : IBindingConverter
/// <summary>
/// Parses the value from a raw command-line argument.
/// </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>
/// Converter for binding inputs to properties of type <see cref="bool" />.
@@ -6,5 +8,6 @@
public class BoolBindingConverter : BindingConverter<bool>
{
/// <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>
/// Converter for binding inputs to properties that implement <see cref="IConvertible" />.
/// </summary>
public class ConvertibleBindingConverter<T>(IFormatProvider formatProvider) : BindingConverter<T> where T: IConvertible
public class ConvertibleBindingConverter<T> : BindingConverter<T>
where T : IConvertible
{
/// <inheritdoc />
public override T? Convert(string? rawValue) =>
public override T? Convert(string? rawValue, IFormatProvider? formatProvider) =>
(T?)System.Convert.ChangeType(rawValue, typeof(T), formatProvider);
}
}

View File

@@ -5,8 +5,9 @@ namespace CliFx.Extensibility;
/// <summary>
/// Converter for binding inputs to properties of type <see cref="DateTimeOffset" />.
/// </summary>
public class DateTimeOffsetBindingConverter(IFormatProvider formatProvider) : BindingConverter<DateTimeOffset>
public class DateTimeOffsetBindingConverter : BindingConverter<DateTimeOffset>
{
/// <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>
/// Converter for binding inputs to properties using a custom delegate.
/// </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 />
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>
/// Converter for binding inputs to properties of type <see cref="Enum" />.
/// </summary>
public class EnumBindingConverter<T> : BindingConverter<T> where T : struct, Enum
public class EnumBindingConverter<T> : BindingConverter<T>
where T : struct, Enum
{
/// <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>
/// Defines a custom conversion for binding command-line arguments to command inputs.
@@ -11,5 +13,5 @@ public interface IBindingConverter
/// <summary>
/// Parses the value from a raw command-line argument.
/// </summary>
object? Convert(string? rawValue);
}
object? Convert(string? rawValue, IFormatProvider? formatProvider);
}

View File

@@ -13,4 +13,4 @@ public interface IBindingValidator
/// Returns null if validation is successful, or an error in case of failure.
/// </summary>
BindingValidationError? Validate(object? value);
}
}

View File

@@ -1,4 +1,6 @@
namespace CliFx.Extensibility;
using System;
namespace CliFx.Extensibility;
/// <summary>
/// Converter for binding inputs to properties without any conversion.
@@ -6,5 +8,5 @@
public class NoopBindingConverter : IBindingConverter
{
/// <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>
/// Converter for binding inputs to properties of type <see cref="Nullable{T}" />.
/// </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 />
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;
}
}

View File

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

View File

@@ -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; }

View File

@@ -17,4 +17,4 @@ public interface IBindableCommand : ICommand
/// Binds the command input to the current instance.
/// </summary>
void Bind(CommandInput input);
}
}

View File

@@ -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<ParameterSchema> parameters,
IReadOnlyList<OptionSchema> options
IReadOnlyList<InputSchema> inputs
)
{
/// <summary>
@@ -36,15 +36,21 @@ public class CommandSchema(
/// </summary>
public string? Description { get; } = description;
/// <summary>
/// Inputs (parameters and options) of the command.
/// </summary>
public IReadOnlyList<InputSchema> Inputs { get; } = inputs;
/// <summary>
/// Parameter inputs of the command.
/// </summary>
public IReadOnlyList<ParameterSchema> Parameters { get; } = parameters;
public IReadOnlyList<ParameterSchema> Parameters { get; } =
inputs.OfType<ParameterSchema>().ToArray();
/// <summary>
/// Option inputs of the command.
/// </summary>
public IReadOnlyList<OptionSchema> Options { get; } = options;
public IReadOnlyList<OptionSchema> Options { get; } = inputs.OfType<OptionSchema>().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.
/// <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.Utils.Extensions;
namespace CliFx.Schema;
@@ -8,28 +14,99 @@ namespace CliFx.Schema;
/// </summary>
public abstract class InputSchema(
PropertyBinding property,
bool isSequence,
IBindingConverter? converter,
IBindingConverter converter,
IReadOnlyList<IBindingValidator> validators
)
{
internal bool IsSequence { get; } =
property.Type != typeof(string)
&& property.Type.TryGetEnumerableUnderlyingType() is not null;
/// <summary>
/// CLR property to which this input is bound.
/// </summary>
public PropertyBinding Property { get; } = property;
/// <summary>
/// Whether the input can accept more than one value.
/// </summary>
public bool IsSequence { get; } = isSequence;
/// <summary>
/// Optional binding converter for this input.
/// </summary>
public IBindingConverter? Converter { get; } = converter;
public IBindingConverter Converter { get; } = converter;
/// <summary>
/// Optional binding validator(s) for this input.
/// </summary>
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.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using CliFx.Extensibility;
@@ -10,15 +11,14 @@ namespace CliFx.Schema;
/// </summary>
public class OptionSchema(
PropertyBinding property,
bool isSequence,
string? name,
char? shortName,
string? environmentVariable,
bool isRequired,
string? description,
IBindingConverter? converter,
IBindingConverter converter,
IReadOnlyList<IBindingValidator> validators
) : InputSchema(property, isSequence, converter, validators)
) : InputSchema(property, converter, validators)
{
/// <summary>
/// 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.
/// <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.Diagnostics.CodeAnalysis;
using CliFx.Extensibility;
namespace CliFx.Schema;
@@ -8,14 +9,13 @@ namespace CliFx.Schema;
/// </summary>
public class ParameterSchema(
PropertyBinding property,
bool isSequence,
int order,
string name,
bool isRequired,
string? description,
IBindingConverter? converter,
IBindingConverter converter,
IReadOnlyList<IBindingValidator> validators
) : InputSchema(property, isSequence, converter, validators)
) : InputSchema(property, converter, validators)
{
/// <summary>
/// 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.
/// <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
)]
Type type,
Func<object, object?> getValue,
Action<object, object?> setValue
Func<object, object?> get,
Action<object, object?> set
)
{
/// <summary>
@@ -28,12 +28,12 @@ public class PropertyBinding(
/// <summary>
/// Gets the current value of the property on the specified instance.
/// </summary>
public object? GetValue(object instance) => getValue(instance);
public object? Get(object instance) => get(instance);
/// <summary>
/// Sets the current value of the property on the specified instance.
/// </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()
{
@@ -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.
/// <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.GetInterfaces()
.Select(TryGetEnumerableUnderlyingType)
.Select(t => TryGetEnumerableUnderlyingType(t))
.Where(t => t is not null)
// 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