This commit is contained in:
Tyrrrz
2024-08-11 03:58:59 +03:00
parent f7645afbdb
commit a4376c955b
10 changed files with 211 additions and 119 deletions

View File

@@ -97,7 +97,7 @@ public class CliApplication(
var commandInstance = var commandInstance =
commandSchema == FallbackDefaultCommand.Schema commandSchema == FallbackDefaultCommand.Schema
? new FallbackDefaultCommand() // bypass the activator ? new FallbackDefaultCommand() // bypass the activator
: typeActivator.CreateInstance<IBindableCommand>(commandSchema.Type); : typeActivator.CreateInstance<ICommand>(commandSchema.Type);
// Assemble the help context // Assemble the help context
var helpContext = new HelpContext( var helpContext = new HelpContext(
@@ -113,8 +113,8 @@ public class CliApplication(
// propagate further. // propagate further.
try try
{ {
// Bind the command input to the command instance // Activate the command instance with the provided input
commandInstance.Bind(commandInput); commandSchema.Activate(commandInput, commandInstance);
// Handle the version option // Handle the version option
if (commandInstance is ICommandWithVersionOption { IsVersionRequested: true }) if (commandInstance is ICommandWithVersionOption { IsVersionRequested: true })

View File

@@ -2,7 +2,6 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using CliFx.Input;
using CliFx.Schema; using CliFx.Schema;
namespace CliFx; namespace CliFx;
@@ -11,8 +10,7 @@ namespace CliFx;
// 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 internal partial class FallbackDefaultCommand
: IBindableCommand, : ICommandWithHelpOption,
ICommandWithHelpOption,
ICommandWithVersionOption ICommandWithVersionOption
{ {
[CommandHelpOption] [CommandHelpOption]
@@ -21,11 +19,6 @@ internal partial class FallbackDefaultCommand
[CommandVersionOption] [CommandVersionOption]
public bool IsVersionRequested { get; init; } public bool IsVersionRequested { get; init; }
public void Bind(CommandInput input)
{
throw new System.NotImplementedException();
}
// Never actually executed // Never actually executed
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public ValueTask ExecuteAsync(IConsole console) => default; public ValueTask ExecuteAsync(IConsole console) => default;
@@ -33,6 +26,5 @@ internal partial class FallbackDefaultCommand
internal partial class FallbackDefaultCommand internal partial class FallbackDefaultCommand
{ {
public static CommandSchema Schema { get; } = public static CommandSchema Schema { get; } = new CommandSchema<FallbackDefaultCommand>(null, null, []);
new(typeof(FallbackDefaultCommand), null, null, [], []);
} }

View File

@@ -1,20 +0,0 @@
using CliFx.Input;
namespace CliFx;
/// <summary>
/// Command whose inputs can be bound from command-line arguments.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public interface IBindableCommand : ICommand
{
/// <summary>
/// Binds the command input to the current instance.
/// </summary>
void Bind(CommandInput input);
}

View File

@@ -2,6 +2,9 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Linq; using System.Linq;
using CliFx.Exceptions;
using CliFx.Input;
using CliFx.Utils.Extensions;
namespace CliFx.Schema; namespace CliFx.Schema;
@@ -63,18 +66,160 @@ public class CommandSchema(
foreach (var parameterSchema in Parameters) foreach (var parameterSchema in Parameters)
{ {
var value = parameterSchema.Property.Get(instance); var value = parameterSchema.Property.GetValue(instance);
result[parameterSchema] = value; result[parameterSchema] = value;
} }
foreach (var optionSchema in Options) foreach (var optionSchema in Options)
{ {
var value = optionSchema.Property.Get(instance); var value = optionSchema.Property.GetValue(instance);
result[optionSchema] = value; result[optionSchema] = value;
} }
return result; 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 // Generic version of the type is used to simplify initialization from the source-generated code

View File

@@ -28,32 +28,31 @@ public abstract class InputSchema(
public PropertyBinding Property { get; } = property; public PropertyBinding Property { get; } = property;
/// <summary> /// <summary>
/// Optional binding converter for this input. /// Binding converter used for this input.
/// </summary> /// </summary>
public IBindingConverter Converter { get; } = converter; public IBindingConverter Converter { get; } = converter;
/// <summary> /// <summary>
/// Optional binding validator(s) for this input. /// Binding validator(s) used for this input.
/// </summary> /// </summary>
public IReadOnlyList<IBindingValidator> Validators { get; } = validators; public IReadOnlyList<IBindingValidator> 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<BindingValidationError>(); var errors = Validators
.Select(validator => validator.Validate(value))
foreach (var validator in validators) .OfType<BindingValidationError>()
{ .ToArray();
var error = validator.Validate(value);
if (error is not null)
errors.Add(error);
}
if (errors.Any()) if (errors.Any())
{ {
throw CliFxException.UserError( 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): Error(s):
{errors.Select(e => "- " + e.Message).JoinToString(Environment.NewLine)} {errors.Select(e => "- " + e.Message).JoinToString(Environment.NewLine)}
""" """
@@ -61,34 +60,48 @@ public abstract class InputSchema(
} }
} }
internal void Set(ICommand command, IReadOnlyList<string?> rawInputs) internal void Activate(ICommand instance, IReadOnlyList<string?> rawInputs)
{ {
var formatProvider = CultureInfo.InvariantCulture; var formatProvider = CultureInfo.InvariantCulture;
// Multiple values expected, single or multiple values provided try
if (IsSequence)
{ {
var value = rawInputs.Select(v => Converter.Convert(v, formatProvider)).ToArray(); // Multiple values expected, single or multiple values provided
Validate(value); if (IsSequence)
{
var value = rawInputs.Select(v => Converter.Convert(v, formatProvider)).ToArray();
Validate(value);
Property.Set(command, value); Property.SetValue(instance, value);
} }
// Single value expected, single value provided // Single value expected, single value provided
else if (rawInputs.Count <= 1) else if (rawInputs.Count <= 1)
{ {
var value = Converter.Convert(rawInputs.SingleOrDefault(), formatProvider); var value = Converter.Convert(rawInputs.SingleOrDefault(), formatProvider);
Validate(value); 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 catch (Exception ex) when (ex is not CliFxException) // don't wrap CliFxException
else
{ {
throw CliFxException.UserError( throw CliFxException.UserError(
$""" $"""
{memberSchema.GetKind()} {memberSchema.GetFormattedIdentifier()} expects a single argument, but provided with multiple: {GetKind()} {GetFormattedIdentifier()} cannot be set from the provided argument(s):
{rawInputs.Select(v => '<' + v + '>').JoinToString(" ")} {rawInputs.Select(v => '<' + v + '>').JoinToString(" ")}
""" Error: {ex.Message}
""",
ex
); );
} }
} }

View File

@@ -59,7 +59,9 @@ public class OptionSchema(
!string.IsNullOrWhiteSpace(EnvironmentVariable) !string.IsNullOrWhiteSpace(EnvironmentVariable)
&& string.Equals(EnvironmentVariable, environmentVariableName, StringComparison.Ordinal); && string.Equals(EnvironmentVariable, environmentVariableName, StringComparison.Ordinal);
internal string GetFormattedIdentifier() internal override string GetKind() => "Option";
internal override string GetFormattedIdentifier()
{ {
var buffer = new StringBuilder(); var buffer = new StringBuilder();

View File

@@ -37,7 +37,9 @@ public class ParameterSchema(
/// </summary> /// </summary>
public string? Description { get; } = description; 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 // Generic version of the type is used to simplify initialization from the source-generated code

View File

@@ -13,8 +13,8 @@ public class PropertyBinding(
DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods
)] )]
Type type, Type type,
Func<object, object?> get, Func<object, object?> getValue,
Action<object, object?> set Action<object, object?> setValue
) )
{ {
/// <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? Get(object instance) => get(instance); public object? GetValue(object instance) => getValue(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 Set(object instance, object? value) => set(instance, value); public void SetValue(object instance, object? value) => setValue(instance, value);
internal IReadOnlyList<object?>? TryGetValidValues() internal IReadOnlyList<object?>? TryGetValidValues()
{ {
@@ -65,9 +65,9 @@ public class PropertyBinding<
DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods
)] )]
TProperty TProperty
>(Func<TObject, TProperty?> get, Action<TObject, TProperty?> set) >(Func<TObject, TProperty?> getValue, Action<TObject, TProperty?> setValue)
: PropertyBinding( : PropertyBinding(
typeof(TProperty), typeof(TProperty),
o => get((TObject)o), o => getValue((TObject)o),
(o, v) => set((TObject)o, (TProperty?)v) (o, v) => setValue((TObject)o, (TProperty?)v)
); );

View File

@@ -1,5 +1,4 @@
using System; using System.Collections;
using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
@@ -14,16 +13,6 @@ internal static class CollectionExtensions
yield return (o, i++); yield return (o, i++);
} }
public static IEnumerable<T> WhereNotNull<T>(this IEnumerable<T?> source)
where T : class
{
foreach (var i in source)
{
if (i is not null)
yield return i;
}
}
public static IEnumerable<string> WhereNotNullOrWhiteSpace(this IEnumerable<string?> source) public static IEnumerable<string> WhereNotNullOrWhiteSpace(this IEnumerable<string?> source)
{ {
foreach (var i in source) foreach (var i in source)
@@ -47,14 +36,4 @@ internal static class CollectionExtensions
dictionary dictionary
.Cast<DictionaryEntry>() .Cast<DictionaryEntry>()
.ToDictionary(entry => (TKey)entry.Key, entry => (TValue)entry.Value!, comparer); .ToDictionary(entry => (TKey)entry.Key, entry => (TValue)entry.Value!, comparer);
public static Array ToNonGenericArray<T>(this IEnumerable<T> source, Type elementType)
{
var sourceAsCollection = source as ICollection ?? source.ToArray();
var array = Array.CreateInstance(elementType, sourceAsCollection.Count);
sourceAsCollection.CopyTo(array, 0);
return array;
}
} }

View File

@@ -3,20 +3,11 @@ using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Linq; using System.Linq;
using System.Reflection;
namespace CliFx.Utils.Extensions; namespace CliFx.Utils.Extensions;
internal static class TypeExtensions 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( public static Type? TryGetEnumerableUnderlyingType(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] this Type type [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] this Type type
) )
@@ -39,23 +30,11 @@ internal static class TypeExtensions
.MaxBy(t => t != typeof(object)); .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( public static bool IsToStringOverriden(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] this Type type [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] this Type type
) )
{ {
var toStringMethod = type.GetMethod(nameof(ToString), Type.EmptyTypes); var toStringMethod = type.GetMethod(nameof(ToString), Type.EmptyTypes);
return toStringMethod?.GetBaseDefinition()?.DeclaringType != toStringMethod?.DeclaringType; return toStringMethod?.GetBaseDefinition().DeclaringType != toStringMethod?.DeclaringType;
} }
} }