This commit is contained in:
Tyrrrz
2024-09-03 02:03:50 +03:00
parent 0532d724a1
commit a62ce71424
29 changed files with 124 additions and 208 deletions

View File

@@ -22,7 +22,8 @@ public class LibraryProvider
var data = File.ReadAllText(StorageFilePath);
return JsonSerializer.Deserialize(data, LibraryJsonContext.Default.Library) ?? Library.Empty;
return JsonSerializer.Deserialize(data, LibraryJsonContext.Default.Library)
?? Library.Empty;
}
public Book? TryGetBook(string title) =>

View File

@@ -4,9 +4,10 @@ namespace CliFx.Attributes;
/// <summary>
/// Annotates a type that defines a command.
/// If a command is named, then the user must provide its name through the command-line arguments in order to execute it.
/// If a command is not named, then it is treated as the application's default command and is executed when no other
/// command is specified.
/// If the command is named, then the user must provide its name through the
/// command-line arguments in order to execute it.
/// If the command is not named, then it is treated as the application's
/// default command and is executed whenever the user does not provide a command name.
/// </summary>
/// <remarks>
/// Only one default command is allowed per application.

View File

@@ -4,7 +4,7 @@
/// Binds a property to the help option of a command.
/// </summary>
/// <remarks>
/// This attribute is applied automatically by the framework and should not be used explicitly.
/// This attribute is applied automatically by the framework and should not need to be used explicitly.
/// </remarks>
public class CommandHelpOptionAttribute : CommandOptionAttribute
{

View File

@@ -1,5 +1,4 @@
using System;
using CliFx.Extensibility;
namespace CliFx.Attributes;
@@ -7,7 +6,8 @@ namespace CliFx.Attributes;
/// Binds a property to a command option — a command-line input that is identified by a name and/or a short name.
/// </summary>
/// <remarks>
/// All options in a command must have unique names (comparison IS NOT case-sensitive) and short names (comparison IS case-sensitive).
/// All options in a command must have unique names (comparison IS NOT case-sensitive)
/// and short names (comparison IS case-sensitive).
/// </remarks>
[AttributeUsage(AttributeTargets.Property)]
public class CommandOptionAttribute : CommandInputAttribute
@@ -51,7 +51,7 @@ public class CommandOptionAttribute : CommandInputAttribute
/// <summary>
/// Whether this option is required (default: <c>false</c>).
/// If an option is required, the user will get an error if they don't set it.
/// If an option is required, the user will get an error when they don't set it.
/// </summary>
/// <remarks>
/// You can use the <c>required</c> keyword on the property (introduced in C# 11) to implicitly

View File

@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using CliFx.Extensibility;
namespace CliFx.Attributes;
@@ -10,7 +9,7 @@ namespace CliFx.Attributes;
/// </summary>
/// <remarks>
/// All parameters in a command must have unique order values.
/// If a parameter is bound to a property whose type is a sequence (e.g. Array, <see cref="List{T}" />; except <see cref="string" />),
/// If a parameter is bound to a property whose type is a sequence (i.e. implements <see cref="IEnumerable{T}"/>; except <see cref="string" />),
/// then it must have the highest order in the command.
/// Only one sequential parameter is allowed per command.
/// </remarks>
@@ -24,7 +23,7 @@ public class CommandParameterAttribute(int order) : CommandInputAttribute
/// <summary>
/// Whether this parameter is required (default: <c>true</c>).
/// If a parameter is required, the user will get an error if they don't set it.
/// If a parameter is required, the user will get an error when they don't set it.
/// </summary>
/// <remarks>
/// Parameter marked as non-required must have the highest order in the command.

View File

@@ -4,7 +4,7 @@
/// Binds a property to the version option of a command.
/// </summary>
/// <remarks>
/// This attribute is applied automatically by the framework and should not be used explicitly.
/// This attribute is applied automatically by the framework and should not need to be used explicitly.
/// </remarks>
public class CommandVersionOptionAttribute : CommandOptionAttribute
{

View File

@@ -3,7 +3,7 @@
namespace CliFx.Exceptions;
/// <summary>
/// Exception thrown when there is an error during application execution.
/// Exception thrown within <see cref="CliFx" />.
/// </summary>
public partial class CliFxException(
string message,

View File

@@ -3,7 +3,7 @@
namespace CliFx.Extensibility;
/// <summary>
/// Converter for binding command inputs to properties of type <see cref="bool" />.
/// Converter for activating command inputs bound to properties of type <see cref="bool" />.
/// </summary>
public class BoolBindingConverter : BindingConverter<bool>
{

View File

@@ -3,7 +3,7 @@
namespace CliFx.Extensibility;
/// <summary>
/// Converter for binding command inputs to properties whose types implement <see cref="IConvertible" />.
/// Converter for activating command inputs bound to properties whose types implement <see cref="IConvertible" />.
/// </summary>
public class ConvertibleBindingConverter<T> : BindingConverter<T>
where T : IConvertible

View File

@@ -3,7 +3,7 @@
namespace CliFx.Extensibility;
/// <summary>
/// Converter for binding command inputs to properties of type <see cref="DateTimeOffset" />.
/// Converter for activating command inputs bound to properties of type <see cref="DateTimeOffset" />.
/// </summary>
public class DateTimeOffsetBindingConverter : BindingConverter<DateTimeOffset>
{

View File

@@ -3,7 +3,7 @@
namespace CliFx.Extensibility;
/// <summary>
/// Converter for binding command inputs to properties using a custom delegate.
/// Converter for activating command inputs bound to properties using a custom delegate.
/// </summary>
public class DelegateBindingConverter<T>(Func<string?, IFormatProvider?, T> convert)
: BindingConverter<T>

View File

@@ -3,7 +3,7 @@
namespace CliFx.Extensibility;
/// <summary>
/// Converter for binding command inputs to properties of type <see cref="Enum" />.
/// Converter for activating command inputs bound to properties of type <see cref="Enum" />.
/// </summary>
public class EnumBindingConverter<T> : BindingConverter<T>
where T : struct, Enum

View File

@@ -3,7 +3,7 @@
namespace CliFx.Extensibility;
/// <summary>
/// Converter for binding command inputs to properties without any conversion.
/// Converter for activating command inputs bound to properties without performing any conversion.
/// </summary>
public class NoopBindingConverter : IBindingConverter
{

View File

@@ -3,7 +3,7 @@
namespace CliFx.Extensibility;
/// <summary>
/// Converter for binding command inputs to properties of type <see cref="Nullable{T}" />.
/// Converter for activating command inputs bound to properties of type <see cref="Nullable{T}" />.
/// </summary>
public class NullableBindingConverter<T>(BindingConverter<T> innerConverter) : BindingConverter<T?>
where T : struct

View File

@@ -3,7 +3,7 @@
namespace CliFx.Extensibility;
/// <summary>
/// Converter for binding command inputs to properties of type <see cref="TimeSpan" />.
/// Converter for activating command inputs bound to properties of type <see cref="TimeSpan" />.
/// </summary>
public class TimeSpanBindingConverter : BindingConverter<TimeSpan>
{

View File

@@ -1,5 +1,4 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@@ -307,48 +306,14 @@ internal class HelpConsoleFormatter(ConsoleWriter consoleWriter, HelpContext con
if (defaultValue is null)
return;
// Non-Scalar
if (defaultValue is not string && defaultValue is IEnumerable defaultValues)
if (schema.Property.Type.IsToStringOverriden())
{
var elementType =
schema.Property.Type.TryGetEnumerableUnderlyingType() ?? typeof(object);
Write(ConsoleColor.White, "Default: ");
if (elementType.IsToStringOverriden())
{
Write(ConsoleColor.White, "Default: ");
var isFirst = true;
foreach (var element in defaultValues)
{
if (isFirst)
{
isFirst = false;
}
else
{
Write(", ");
}
Write('"');
Write(element.ToString(CultureInfo.InvariantCulture));
Write('"');
}
Write('.');
}
}
else
{
if (schema.Property.Type.IsToStringOverriden())
{
Write(ConsoleColor.White, "Default: ");
Write('"');
Write(defaultValue.ToString(CultureInfo.InvariantCulture));
Write('"');
Write('.');
}
Write('"');
Write(defaultValue.ToString(CultureInfo.InvariantCulture));
Write('"');
Write('.');
}
}

View File

@@ -7,7 +7,7 @@ using System.Threading.Tasks;
namespace CliFx.Infrastructure;
/// <summary>
/// Implements a <see cref="TextReader" /> for reading characters or binary data from a console stream.
/// Implements a <see cref="StreamReader" /> for reading characters or binary data from a console stream.
/// </summary>
// Both the underlying stream AND the stream reader must be synchronized!
// https://github.com/Tyrrrz/CliFx/issues/123

View File

@@ -8,7 +8,7 @@ using CliFx.Utils;
namespace CliFx.Infrastructure;
/// <summary>
/// Implements a <see cref="TextWriter" /> for writing characters or binary data to a console stream.
/// Implements a <see cref="StreamWriter" /> for writing characters or binary data to a console stream.
/// </summary>
// Both the underlying stream AND the stream writer must be synchronized!
// https://github.com/Tyrrrz/CliFx/issues/123

View File

@@ -5,7 +5,7 @@ using CliFx.Exceptions;
namespace CliFx.Infrastructure;
/// <summary>
/// Implementation of <see cref="ITypeActivator" /> that instantiates an object by using its parameterless constructor.
/// Implementation of <see cref="ITypeActivator" /> that instantiates a type by using its parameterless constructor.
/// </summary>
public class DefaultTypeActivator : ITypeActivator
{

View File

@@ -5,7 +5,7 @@ using CliFx.Exceptions;
namespace CliFx.Infrastructure;
/// <summary>
/// Implementation of <see cref="ITypeActivator" /> that instantiates an object by using a predefined delegate.
/// Implementation of <see cref="ITypeActivator" /> that instantiates a type by using a predefined delegate.
/// </summary>
public class DelegateTypeActivator(Func<Type, object> createInstance) : ITypeActivator
{

View File

@@ -5,7 +5,7 @@ using CliFx.Exceptions;
namespace CliFx.Infrastructure;
/// <summary>
/// Abstraction for a service that can instantiate objects at run-time.
/// Abstraction for a service that can instantiate types at run-time.
/// </summary>
public interface ITypeActivator
{

View File

@@ -14,23 +14,21 @@ namespace CliFx.Schema;
/// </summary>
public abstract class CommandInputSchema(
PropertyBinding property,
bool isSequence,
string? description,
IBindingConverter converter,
IReadOnlyList<IBindingValidator> validators
)
{
internal abstract string Kind { get; }
internal abstract string FormattedIdentifier { get; }
/// <summary>
/// CLR property to which this input is bound.
/// </summary>
public PropertyBinding Property { get; } = property;
internal bool IsSequence { get; } =
property.Type != typeof(string)
&& property.Type.TryGetEnumerableUnderlyingType() is not null;
/// <summary>
/// Whether this input is a sequence (i.e. multiple values can be provided).
/// </summary>
public bool IsSequence { get; } = isSequence;
/// <summary>
/// Input description, used in the help text.
@@ -51,14 +49,14 @@ public abstract class CommandInputSchema(
{
var errors = Validators
.Select(validator => validator.Validate(value))
.OfType<BindingValidationError>()
.WhereNotNull()
.ToArray();
if (errors.Any())
{
throw CliFxException.UserError(
$"""
{Kind} {FormattedIdentifier} has been provided with an invalid value.
{this.GetKind()} {this.GetFormattedIdentifier()} has been provided with an invalid value.
Error(s):
{errors.Select(e => "- " + e.Message).JoinToString(Environment.NewLine)}
"""
@@ -72,7 +70,7 @@ public abstract class CommandInputSchema(
try
{
// Multiple values expected, single or multiple values provided
// Sequential input; zero or more values provided
if (IsSequence)
{
var values = rawValues.Select(v => Converter.Convert(v, formatProvider)).ToArray();
@@ -80,23 +78,22 @@ public abstract class CommandInputSchema(
// TODO: cast array to the proper type
Validate(values);
Property.Set(instance, values);
Property.SetValue(instance, values);
}
// Single value expected, single value provided
// Non-sequential input; zero or one value provided
else if (rawValues.Count <= 1)
{
var value = Converter.Convert(rawValues.SingleOrDefault(), formatProvider);
Validate(value);
Property.Set(instance, value);
Validate(value);
Property.SetValue(instance, value);
}
// Single value expected, multiple values provided
// Non-sequential input; more than one value provided
else
{
throw CliFxException.UserError(
$"""
{Kind} {FormattedIdentifier} expects a single value, but provided with multiple:
{this.GetKind()} {this.GetFormattedIdentifier()} expects a single value, but provided with multiple:
{rawValues.Select(v => '<' + v + '>').JoinToString(" ")}
"""
);
@@ -106,7 +103,7 @@ public abstract class CommandInputSchema(
{
throw CliFxException.UserError(
$"""
{Kind} {FormattedIdentifier} cannot be set from the provided value(s):
{this.GetKind()} {this.GetFormattedIdentifier()} cannot be set from the provided value(s):
{rawValues.Select(v => '<' + v + '>').JoinToString(" ")}
Error: {ex.Message}
""",
@@ -117,7 +114,7 @@ public abstract class CommandInputSchema(
/// <inheritdoc />
[ExcludeFromCodeCoverage]
public override string ToString() => FormattedIdentifier;
public override string ToString() => this.GetFormattedIdentifier();
}
/// <inheritdoc cref="CommandInputSchema" />
@@ -134,8 +131,41 @@ public abstract class CommandInputSchema<
TProperty
>(
PropertyBinding<TCommand, TProperty> property,
bool isSequence,
string? description,
BindingConverter<TProperty> converter,
IReadOnlyList<BindingValidator<TProperty>> validators
) : CommandInputSchema(property, description, converter, validators)
) : CommandInputSchema(property, isSequence, description, converter, validators)
where TCommand : ICommand;
// Define these as extension methods to avoid exposing them as protected members (i.e. essentially public API)
internal static class CommandInputSchemaExtensions
{
public static string GetKind(this CommandInputSchema schema) =>
schema switch
{
CommandParameterSchema => "Parameter",
CommandOptionSchema => "Option",
_ => throw new InvalidOperationException("Unknown input schema type.")
};
public static string GetFormattedIdentifier(this CommandInputSchema schema) =>
schema switch
{
CommandParameterSchema parameter
=> parameter.IsSequence ? $"<{parameter.Name}>" : $"<{parameter.Name}...>",
CommandOptionSchema option
=> option switch
{
{ Name: not null, ShortName: not null }
=> $"-{option.ShortName}|--{option.Name}",
{ Name: not null } => $"--{option.Name}",
{ ShortName: not null } => $"-{option.ShortName}",
_
=> throw new InvalidOperationException(
"Option must have a name or a short name."
)
},
_ => throw new ArgumentOutOfRangeException(nameof(schema))
};
}

View File

@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using CliFx.Extensibility;
namespace CliFx.Schema;
@@ -11,6 +10,7 @@ namespace CliFx.Schema;
/// </summary>
public class CommandOptionSchema(
PropertyBinding property,
bool isSequence,
string? name,
char? shortName,
string? environmentVariable,
@@ -18,38 +18,8 @@ public class CommandOptionSchema(
string? description,
IBindingConverter converter,
IReadOnlyList<IBindingValidator> validators
) : CommandInputSchema(property, description, converter, validators)
) : CommandInputSchema(property, isSequence, description, converter, validators)
{
internal override string Kind => "Option";
internal override string FormattedIdentifier
{
get
{
var buffer = new StringBuilder();
// Short name
if (ShortName is not null)
{
buffer.Append('-').Append(ShortName);
}
// Separator
if (!string.IsNullOrWhiteSpace(Name) && ShortName is not null)
{
buffer.Append('|');
}
// Name
if (!string.IsNullOrWhiteSpace(Name))
{
buffer.Append("--").Append(Name);
}
return buffer.ToString();
}
}
/// <summary>
/// Option name.
/// </summary>
@@ -99,6 +69,7 @@ public class CommandOptionSchema<
TProperty
>(
PropertyBinding<TCommand, TProperty> property,
bool isSequence,
string? name,
char? shortName,
string? environmentVariable,
@@ -109,6 +80,7 @@ public class CommandOptionSchema<
)
: CommandOptionSchema(
property,
isSequence,
name,
shortName,
environmentVariable,

View File

@@ -9,20 +9,17 @@ namespace CliFx.Schema;
/// </summary>
public class CommandParameterSchema(
PropertyBinding property,
bool isSequence,
int order,
string name,
bool isRequired,
string? description,
IBindingConverter converter,
IReadOnlyList<IBindingValidator> validators
) : CommandInputSchema(property, description, converter, validators)
) : CommandInputSchema(property, isSequence, description, converter, validators)
{
internal override string Kind => "Parameter";
internal override string FormattedIdentifier => IsSequence ? $"<{Name}>" : $"<{Name}...>";
/// <summary>
/// Order, in which the parameter is bound from the command-line arguments.
/// Order, in which the parameter is activated from the command-line arguments.
/// </summary>
public int Order { get; } = order;
@@ -51,11 +48,22 @@ public class CommandParameterSchema<
TProperty
>(
PropertyBinding<TCommand, TProperty> property,
bool isSequence,
int order,
string name,
bool isRequired,
string? description,
BindingConverter<TProperty> converter,
IReadOnlyList<BindingValidator<TProperty>> validators
) : CommandParameterSchema(property, order, name, isRequired, description, converter, validators)
)
: CommandParameterSchema(
property,
isSequence,
order,
name,
isRequired,
description,
converter,
validators
)
where TCommand : ICommand;

View File

@@ -62,24 +62,8 @@ public class CommandSchema(
? string.Equals(name, Name, StringComparison.OrdinalIgnoreCase)
: string.IsNullOrWhiteSpace(name);
internal IReadOnlyDictionary<CommandInputSchema, object?> GetValues(ICommand instance)
{
var result = new Dictionary<CommandInputSchema, object?>();
foreach (var parameter in Parameters)
{
var value = parameter.Property.Get(instance);
result[parameter] = value;
}
foreach (var option in Options)
{
var value = option.Property.Get(instance);
result[option] = value;
}
return result;
}
internal IReadOnlyDictionary<CommandInputSchema, object?> GetValues(ICommand instance) =>
Inputs.ToDictionary(input => input, input => input.Property.GetValue(instance));
private void ActivateParameters(ICommand instance, CommandArguments arguments)
{
@@ -91,21 +75,20 @@ public class CommandSchema(
foreach (var parameter in Parameters.OrderBy(p => p.Order))
{
// Break when there are no remaining inputs
// Break when there are no remaining tokens
if (position >= arguments.Parameters.Count)
break;
// Sequence: take all remaining inputs starting from the current position
// Sequential: take all remaining tokens starting from the current position
if (parameter.IsSequence)
{
var parameterTokens = arguments.Parameters.Skip(position).ToArray();
parameter.Activate(instance, parameterTokens.Select(p => p.Value).ToArray());
position += parameterTokens.Length;
remainingParameterTokens.RemoveRange(parameterTokens);
}
// Non-sequence: take one input at the current position
// Non-sequential: take one token at the current position
else
{
var parameterToken = arguments.Parameters[position];
@@ -132,9 +115,9 @@ public class CommandSchema(
{
throw CliFxException.UserError(
$"""
Missing equired parameter(s):
Missing required parameter(s):
{remainingRequiredParameters
.Select(p => p.FormattedIdentifier)
.Select(p => p.GetFormattedIdentifier())
.JoinToString(" ")}
"""
);
@@ -153,7 +136,7 @@ public class CommandSchema(
foreach (var option in Options)
{
var optionToken = arguments
var optionTokens = arguments
.Options.Where(o => option.MatchesIdentifier(o.Identifier))
.ToArray();
@@ -161,10 +144,10 @@ public class CommandSchema(
option.MatchesEnvironmentVariable(v.Key)
);
// Direct input
if (optionToken.Any())
// From arguments
if (optionTokens.Any())
{
var rawValues = optionToken.SelectMany(o => o.Values).ToArray();
var rawValues = optionTokens.SelectMany(o => o.Values).ToArray();
option.Activate(instance, rawValues);
@@ -172,7 +155,7 @@ public class CommandSchema(
if (rawValues.Any())
remainingRequiredOptions.Remove(option);
}
// Environment variable
// From environment
else if (!string.IsNullOrEmpty(environmentVariable.Value))
{
var rawValues = !option.IsSequence
@@ -194,7 +177,7 @@ public class CommandSchema(
continue;
}
remainingOptionTokens.RemoveRange(optionToken);
remainingOptionTokens.RemoveRange(optionTokens);
}
if (remainingOptionTokens.Any())
@@ -213,7 +196,7 @@ public class CommandSchema(
$"""
Missing required option(s):
{remainingRequiredOptions
.Select(o => o.FormattedIdentifier)
.Select(o => o.GetFormattedIdentifier())
.JoinToString(", ")}
"""
);

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()
{
@@ -67,9 +67,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,20 +0,0 @@
using System;
using System.Linq;
using System.Reflection;
namespace CliFx.Utils.Extensions;
internal static class PropertyExtensions
{
public static bool IsRequired(this PropertyInfo propertyInfo) =>
// Match attribute by name to avoid depending on .NET 7.0+ and to allow polyfilling
propertyInfo
.GetCustomAttributes()
.Any(a =>
string.Equals(
a.GetType().FullName,
"System.Runtime.CompilerServices.RequiredMemberAttribute",
StringComparison.Ordinal
)
);
}

View File

@@ -1,33 +1,10 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
namespace CliFx.Utils.Extensions;
internal static class TypeExtensions
{
public static Type? TryGetEnumerableUnderlyingType(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] this Type type
) =>
type.GetInterfaces()
.Select(i =>
{
if (i == typeof(IEnumerable))
return typeof(object);
if (i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>))
return i.GetGenericArguments().FirstOrDefault();
return null;
})
.WhereNotNull()
// 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
// IEnumerable<object> and nothing else, then we'll just return that.
.MaxBy(t => t != typeof(object));
public static bool IsToStringOverriden(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] this Type type
)