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 =
commandSchema == FallbackDefaultCommand.Schema
? new FallbackDefaultCommand() // bypass the activator
: typeActivator.CreateInstance<IBindableCommand>(commandSchema.Type);
: typeActivator.CreateInstance<ICommand>(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 })

View File

@@ -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<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.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

View File

@@ -28,32 +28,31 @@ public abstract class InputSchema(
public PropertyBinding Property { get; } = property;
/// <summary>
/// Optional binding converter for this input.
/// Binding converter used for this input.
/// </summary>
public IBindingConverter Converter { get; } = converter;
/// <summary>
/// Optional binding validator(s) for this input.
/// Binding validator(s) used for this input.
/// </summary>
public IReadOnlyList<IBindingValidator> Validators { get; } = validators;
internal void Validate(object? value)
{
var errors = new List<BindingValidationError>();
internal abstract string GetKind();
foreach (var validator in validators)
{
var error = validator.Validate(value);
internal abstract string GetFormattedIdentifier();
if (error is not null)
errors.Add(error);
}
private void Validate(object? value)
{
var errors = Validators
.Select(validator => validator.Validate(value))
.OfType<BindingValidationError>()
.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,17 +60,19 @@ public abstract class InputSchema(
}
}
internal void Set(ICommand command, IReadOnlyList<string?> rawInputs)
internal void Activate(ICommand instance, IReadOnlyList<string?> rawInputs)
{
var formatProvider = CultureInfo.InvariantCulture;
try
{
// 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);
Property.SetValue(instance, value);
}
// Single value expected, single value provided
else if (rawInputs.Count <= 1)
@@ -79,19 +80,31 @@ public abstract class InputSchema(
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(
$"""
{memberSchema.GetKind()} {memberSchema.GetFormattedIdentifier()} expects a single argument, but provided with multiple:
{GetKind()} {GetFormattedIdentifier()} expects a single argument, but provided with multiple:
{rawInputs.Select(v => '<' + v + '>').JoinToString(" ")}
"""
);
}
}
catch (Exception ex) when (ex is not CliFxException) // don't wrap CliFxException
{
throw CliFxException.UserError(
$"""
{GetKind()} {GetFormattedIdentifier()} cannot be set from the provided argument(s):
{rawInputs.Select(v => '<' + v + '>').JoinToString(" ")}
Error: {ex.Message}
""",
ex
);
}
}
}
// Generic version of the type is used to simplify initialization from the source-generated code

View File

@@ -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();

View File

@@ -37,7 +37,9 @@ public class ParameterSchema(
/// </summary>
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

View File

@@ -13,8 +13,8 @@ public class PropertyBinding(
DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods
)]
Type type,
Func<object, object?> get,
Action<object, object?> set
Func<object, object?> getValue,
Action<object, object?> setValue
)
{
/// <summary>
@@ -28,12 +28,12 @@ public class PropertyBinding(
/// <summary>
/// Gets the current value of the property on the specified instance.
/// </summary>
public object? Get(object instance) => get(instance);
public object? GetValue(object instance) => getValue(instance);
/// <summary>
/// Sets the current value of the property on the specified instance.
/// </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()
{
@@ -65,9 +65,9 @@ public class PropertyBinding<
DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods
)]
TProperty
>(Func<TObject, TProperty?> get, Action<TObject, TProperty?> set)
>(Func<TObject, TProperty?> getValue, Action<TObject, TProperty?> 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)
);

View File

@@ -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<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)
{
foreach (var i in source)
@@ -47,14 +36,4 @@ internal static class CollectionExtensions
dictionary
.Cast<DictionaryEntry>()
.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.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;
}
}