This commit is contained in:
Tyrrrz
2024-06-16 01:31:34 +03:00
parent 2323a57c39
commit 3fc7054f80
21 changed files with 195 additions and 148 deletions

View File

@@ -6,7 +6,7 @@ namespace CliFx.Attributes;
/// Annotates a type that defines a command.
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class CommandAttribute : Attribute
public class CommandAttribute : Attribute
{
/// <summary>
/// Initializes an instance of <see cref="CommandAttribute" />.

View File

@@ -0,0 +1,16 @@
namespace CliFx.Attributes;
/// <summary>
/// Annotates a property that defines the help option for a command.
/// </summary>
public class CommandHelpOptionAttribute : CommandOptionAttribute
{
/// <summary>
/// Initializes an instance of <see cref="CommandHelpOptionAttribute" />.
/// </summary>
public CommandHelpOptionAttribute()
: base("help", 'h')
{
Description = "Show help for this command.";
}
}

View File

@@ -7,7 +7,7 @@ namespace CliFx.Attributes;
/// Annotates a property that defines a command option.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public sealed class CommandOptionAttribute : Attribute
public class CommandOptionAttribute : Attribute
{
/// <summary>
/// Initializes an instance of <see cref="CommandOptionAttribute" />.

View File

@@ -7,7 +7,7 @@ namespace CliFx.Attributes;
/// Annotates a property that defines a command parameter.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public sealed class CommandParameterAttribute(int order) : Attribute
public class CommandParameterAttribute(int order) : Attribute
{
/// <summary>
/// Parameter order.

View File

@@ -0,0 +1,16 @@
namespace CliFx.Attributes;
/// <summary>
/// Annotates a property that defines the version option for a command.
/// </summary>
public class CommandVersionOptionAttribute : CommandOptionAttribute
{
/// <summary>
/// Initializes an instance of <see cref="CommandVersionOptionAttribute" />.
/// </summary>
public CommandVersionOptionAttribute()
: base("version")
{
Description = "Show application version.";
}
}

View File

@@ -41,16 +41,6 @@ public class CliApplication(
private bool IsPreviewModeEnabled(CommandInput commandInput) =>
Configuration.IsPreviewModeAllowed && commandInput.IsPreviewDirectiveSpecified;
private bool ShouldShowHelpText(CommandSchema commandSchema, CommandInput commandInput) =>
commandSchema.IsHelpOptionAvailable && commandInput.IsHelpOptionSpecified
||
// Show help text also if the fallback default command is executed without any arguments
commandSchema == FallbackDefaultCommand.Schema
&& !commandInput.HasArguments;
private bool ShouldShowVersionText(CommandSchema commandSchema, CommandInput commandInput) =>
commandSchema.IsVersionOptionAvailable && commandInput.IsVersionOptionSpecified;
private async ValueTask PromptDebuggerAsync()
{
using (console.WithForegroundColor(ConsoleColor.Green))
@@ -119,30 +109,36 @@ public class CliApplication(
commandSchema.GetValues(commandInstance)
);
// Handle the help option
if (ShouldShowHelpText(commandSchema, commandInput))
{
console.WriteHelpText(helpContext);
return 0;
}
// Handle the version option
if (ShouldShowVersionText(commandSchema, commandInput))
{
console.WriteLine(Metadata.Version);
return 0;
}
// Starting from this point, we may produce exceptions that are meant for the
// end-user of the application (i.e. invalid input, command exception, etc).
// Catch these exceptions here, print them to the console, and don't let them
// propagate further.
try
{
// Bind and execute the command
// Bind the command input to the command instance
_commandBinder.Bind(commandInput, commandSchema, commandInstance);
await commandInstance.ExecuteAsync(console);
// Handle the version option
if (commandInstance is ICommandWithVersionOption { IsVersionRequested: true })
{
console.WriteLine(Metadata.Version);
return 0;
}
// Handle the help option
if (
commandInstance
is ICommandWithHelpOption { IsHelpRequested: true }
// Fallback default command always shows help, even if the option is not specified
or FallbackDefaultCommand
)
{
console.WriteHelpText(helpContext);
return 0;
}
// Execute the command
await commandInstance.ExecuteAsync(console);
return 0;
}
catch (CliFxException ex)

View File

@@ -175,7 +175,7 @@ public partial class CliApplicationBuilder
[UnconditionalSuppressMessage(
"SingleFile",
"IL3000:Avoid accessing Assembly file path when publishing as a single file",
Justification = "The return value of the method is checked to ensure the assembly location is available."
Justification = "The file path is checked to ensure the assembly location is available."
)]
private static string GetDefaultExecutableName()
{

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netstandard2.0;netstandard2.1;net8.0</TargetFrameworks>
<TargetFrameworks>netstandard2.0;netstandard2.1;net7.0;net8.0</TargetFrameworks>
<IsPackable>true</IsPackable>
<IsTrimmable Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net6.0'))">true</IsTrimmable>
<IsAotCompatible Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net7.0'))">true</IsAotCompatible>

View File

@@ -16,7 +16,7 @@ internal class CommandBinder(ITypeActivator typeActivator)
{
private readonly IFormatProvider _formatProvider = CultureInfo.InvariantCulture;
private object? ConvertSingle(IInputSchema inputSchema, string? rawValue, Type targetType)
private object? ConvertSingle(InputSchema inputSchema, string? rawValue, Type targetType)
{
// Custom converter
if (inputSchema.Converter is not null)
@@ -103,7 +103,7 @@ internal class CommandBinder(ITypeActivator typeActivator)
}
private object? ConvertMultiple(
IInputSchema inputSchema,
InputSchema inputSchema,
IReadOnlyList<string> rawValues,
Type targetEnumerableType,
Type targetElementType
@@ -137,7 +137,7 @@ internal class CommandBinder(ITypeActivator typeActivator)
);
}
private object? ConvertMember(IInputSchema inputSchema, IReadOnlyList<string> rawValues)
private object? ConvertMember(InputSchema inputSchema, IReadOnlyList<string> rawValues)
{
try
{
@@ -192,7 +192,7 @@ internal class CommandBinder(ITypeActivator typeActivator)
);
}
private void ValidateMember(IInputSchema inputSchema, object? convertedValue)
private void ValidateMember(InputSchema inputSchema, object? convertedValue)
{
var errors = new List<BindingValidationError>();
@@ -218,7 +218,7 @@ internal class CommandBinder(ITypeActivator typeActivator)
}
private void BindMember(
IInputSchema inputSchema,
InputSchema inputSchema,
ICommand commandInstance,
IReadOnlyList<string> rawValues
)
@@ -335,7 +335,7 @@ internal class CommandBinder(ITypeActivator typeActivator)
// Environment variable
else if (environmentVariableInput is not null)
{
var rawValues = optionSchema.IsScalar
var rawValues = optionSchema.IsSequence
? [environmentVariableInput.Value]
: environmentVariableInput.SplitValues();

View File

@@ -9,8 +9,14 @@ 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 : ICommand
internal partial class FallbackDefaultCommand : ICommandWithHelpOption, ICommandWithVersionOption
{
[CommandHelpOption]
public bool IsHelpRequested { get; init; }
[CommandVersionOption]
public bool IsVersionRequested { get; init; }
// Never actually executed
[ExcludeFromCodeCoverage]
public ValueTask ExecuteAsync(IConsole console) => default;

View File

@@ -69,7 +69,7 @@ internal class HelpConsoleFormatter(ConsoleWriter consoleWriter, HelpContext con
{
Write(
ConsoleColor.DarkCyan,
parameter.Property.IsScalar() ? $"<{parameter.Name}>" : $"<{parameter.Name}...>"
parameter.IsSequence ? $"<{parameter.Name}...>" : $"<{parameter.Name}>"
);
Write(' ');
}
@@ -85,7 +85,7 @@ internal class HelpConsoleFormatter(ConsoleWriter consoleWriter, HelpContext con
);
Write(' ');
Write(ConsoleColor.White, option.Property.IsScalar() ? "<value>" : "<values...>");
Write(ConsoleColor.White, option.IsSequence ? "<values...>" : "<value>");
Write(' ');
}
@@ -170,8 +170,8 @@ internal class HelpConsoleFormatter(ConsoleWriter consoleWriter, HelpContext con
}
// Valid values
var validValues = parameterSchema.Property.GetValidValues();
if (validValues.Any())
var validValues = parameterSchema.Property.TryGetValidValues();
if (validValues?.Any() == true)
{
Write(ConsoleColor.White, "Choices: ");
@@ -257,8 +257,8 @@ internal class HelpConsoleFormatter(ConsoleWriter consoleWriter, HelpContext con
}
// Valid values
var validValues = optionSchema.Property.GetValidValues();
if (validValues.Any())
var validValues = optionSchema.Property.TryGetValidValues();
if (validValues?.Any() == true)
{
Write(ConsoleColor.White, "Choices: ");
@@ -305,7 +305,7 @@ internal class HelpConsoleFormatter(ConsoleWriter consoleWriter, HelpContext con
}
}
private void WriteDefaultValue(IMemberSchema schema)
private void WriteDefaultValue(InputSchema schema)
{
var defaultValue = context.CommandDefaultValues.GetValueOrDefault(schema);
if (defaultValue is not null)

View File

@@ -7,7 +7,7 @@ internal class HelpContext(
ApplicationMetadata applicationMetadata,
ApplicationSchema applicationSchema,
CommandSchema commandSchema,
IReadOnlyDictionary<IInputSchema, object?> commandDefaultValues
IReadOnlyDictionary<InputSchema, object?> commandDefaultValues
)
{
public ApplicationMetadata ApplicationMetadata { get; } = applicationMetadata;
@@ -16,6 +16,6 @@ internal class HelpContext(
public CommandSchema CommandSchema { get; } = commandSchema;
public IReadOnlyDictionary<IInputSchema, object?> CommandDefaultValues { get; } =
public IReadOnlyDictionary<InputSchema, object?> CommandDefaultValues { get; } =
commandDefaultValues;
}

View File

@@ -0,0 +1,12 @@
namespace CliFx;
/// <summary>
/// Command definition that includes the help option.
/// </summary>
public interface ICommandWithHelpOption : ICommand
{
/// <summary>
/// Whether the user requested help for this command (via the `-h|--help` option).
/// </summary>
bool IsHelpRequested { get; }
}

View File

@@ -0,0 +1,12 @@
namespace CliFx;
/// <summary>
/// Command definition that includes the version option.
/// </summary>
public interface ICommandWithVersionOption : ICommand
{
/// <summary>
/// Whether the user requested the version information (via the `--version` option).
/// </summary>
bool IsVersionRequested { get; }
}

View File

@@ -16,7 +16,7 @@ public class CommandSchema(
)
{
/// <summary>
/// Command's CLR type.
/// Underlying CLR type of the command.
/// </summary>
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]
public Type Type { get; } = type;
@@ -26,6 +26,11 @@ public class CommandSchema(
/// </summary>
public string? Name { get; } = name;
/// <summary>
/// Whether this command is the application's default command.
/// </summary>
public bool IsDefault { get; } = string.IsNullOrWhiteSpace(name);
/// <summary>
/// Command description.
/// </summary>
@@ -41,19 +46,14 @@ public class CommandSchema(
/// </summary>
public IReadOnlyList<OptionSchema> Options { get; } = options;
/// <summary>
/// Whether this command is the application's default command.
/// </summary>
public bool IsDefault { get; } = string.IsNullOrWhiteSpace(name);
internal bool MatchesName(string? name) =>
!string.IsNullOrWhiteSpace(Name)
? string.Equals(name, Name, StringComparison.OrdinalIgnoreCase)
: string.IsNullOrWhiteSpace(name);
internal IReadOnlyDictionary<IInputSchema, object?> GetValues(ICommand instance)
internal IReadOnlyDictionary<InputSchema, object?> GetValues(ICommand instance)
{
var result = new Dictionary<IInputSchema, object?>();
var result = new Dictionary<InputSchema, object?>();
foreach (var parameterSchema in Parameters)
{

View File

@@ -1,25 +0,0 @@
using System.Collections.Generic;
using CliFx.Extensibility;
namespace CliFx.Schema;
/// <summary>
/// Describes an input of a command, which can be either a parameter or an option.
/// </summary>
public interface IInputSchema
{
/// <summary>
/// Describes the binding of this input to a CLR property.
/// </summary>
PropertyBinding Property { get; }
/// <summary>
/// Optional binding converter for this input.
/// </summary>
IBindingConverter? Converter { get; }
/// <summary>
/// Optional binding validator(s) for this input.
/// </summary>
IReadOnlyList<IBindingValidator> Validators { get; }
}

View File

@@ -0,0 +1,35 @@
using System.Collections.Generic;
using CliFx.Extensibility;
namespace CliFx.Schema;
/// <summary>
/// Describes an input of a command, which can be either a parameter or an option.
/// </summary>
public abstract class InputSchema(
PropertyBinding property,
bool isSequence,
IBindingConverter? converter,
IReadOnlyList<IBindingValidator> validators
)
{
/// <summary>
/// CLR property to which this input is bound.
/// </summary>
public PropertyBinding Property { get; } = property;
/// <summary>
/// Whether this 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;
/// <summary>
/// Optional binding validator(s) for this input.
/// </summary>
public IReadOnlyList<IBindingValidator> Validators { get; } = validators;
}

View File

@@ -6,12 +6,11 @@ using CliFx.Extensibility;
namespace CliFx.Schema;
/// <summary>
/// Describes a command's option.
/// Describes an option input of a command.
/// </summary>
public class OptionSchema(
PropertyBinding property,
bool isScalar,
IReadOnlyList<object?>? validValues,
bool isSequence,
string? name,
char? shortName,
string? environmentVariable,
@@ -19,17 +18,8 @@ public class OptionSchema(
string? description,
IBindingConverter? converter,
IReadOnlyList<IBindingValidator> validators
) : IInputSchema
) : InputSchema(property, isSequence, converter, validators)
{
/// <inheritdoc />
public PropertyBinding Property { get; } = property;
/// <inheritdoc />
public bool IsScalar { get; } = isScalar;
/// <inheritdoc />
public IReadOnlyList<object?>? ValidValues { get; } = validValues;
/// <summary>
/// Option name.
/// </summary>
@@ -55,12 +45,6 @@ public class OptionSchema(
/// </summary>
public string? Description { get; } = description;
/// <inheritdoc />
public IBindingConverter? Converter { get; } = converter;
/// <inheritdoc />
public IReadOnlyList<IBindingValidator> Validators { get; } = validators;
internal bool MatchesName(string? name) =>
!string.IsNullOrWhiteSpace(Name)
&& string.Equals(Name, name, StringComparison.OrdinalIgnoreCase);

View File

@@ -4,29 +4,19 @@ using CliFx.Extensibility;
namespace CliFx.Schema;
/// <summary>
/// Describes a command's parameter.
/// Describes a parameter input of a command.
/// </summary>
public class ParameterSchema(
PropertyBinding property,
bool isScalar,
IReadOnlyList<object?>? validValues,
bool isSequence,
int order,
string name,
bool isRequired,
string? description,
IBindingConverter? converter,
IReadOnlyList<IBindingValidator> validators
) : IInputSchema
) : InputSchema(property, isSequence, converter, validators)
{
/// <inheritdoc />
public PropertyBinding Property { get; } = property;
/// <inheritdoc />
public bool IsScalar { get; } = isScalar;
/// <inheritdoc />
public IReadOnlyList<object?>? ValidValues { get; } = validValues;
/// <summary>
/// Order, in which the parameter is bound from the command-line arguments.
/// </summary>
@@ -47,12 +37,5 @@ public class ParameterSchema(
/// </summary>
public string? Description { get; } = description;
/// <inheritdoc />
public IBindingConverter? Converter { get; } = converter;
/// <inheritdoc />
public IReadOnlyList<IBindingValidator> Validators { get; } = validators;
internal string GetFormattedIdentifier() =>
IsScalar ? $"<{Name}>" : $"<{Name}...>";
internal string GetFormattedIdentifier() => IsSequence ? $"<{Name}>" : $"<{Name}...>";
}

View File

@@ -5,18 +5,18 @@ using System.Linq;
namespace CliFx.Schema;
/// <summary>
/// Describes a CLR property.
/// Represents a binding to a CLR property.
/// </summary>
public class PropertyBinding(
Type propertyType,
Type type,
Func<object, object?> getValue,
Action<object, object?> setValue
)
{
/// <summary>
/// Underlying property type.
/// Underlying CLR type of the property.
/// </summary>
public Type PropertyType { get; } = propertyType;
public Type Type { get; } = type;
/// <summary>
/// Gets the current value of the property on the specified instance.
@@ -27,12 +27,23 @@ public class PropertyBinding(
/// Sets the value of the property on the specified instance.
/// </summary>
public void SetValue(object instance, object? value) => setValue(instance, value);
internal IReadOnlyList<object?>? TryGetValidValues()
{
if (Type.IsEnum)
{
var values =
#if NET7_0_OR_GREATER
Type.GetEnumValuesAsUnderlyingType();
#else
// AOT-compatible APIs are not available here, but it's unlikely
// someone will be AOT-compiling a net6.0 or older app anyway.
Type.GetEnumValues();
#endif
return values.Cast<object?>().ToArray();
}
internal static class PropertyBindingExtensions
{
public static IReadOnlyList<object?>? TryGetValidValues(this PropertyBinding binding) =>
binding.PropertyType.IsEnum
? binding.PropertyType.GetEnumValuesAsUnderlyingType().Cast<object?>().ToArray()
: null;
return null;
}
}

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
@@ -8,13 +9,17 @@ namespace CliFx.Utils.Extensions;
internal static class TypeExtensions
{
public static bool Implements(this Type type, Type interfaceType) =>
type.GetInterfaces().Contains(interfaceType);
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(this Type type)
public static Type? TryGetEnumerableUnderlyingType(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] this Type type
)
{
if (type.IsPrimitive)
return null;
@@ -35,24 +40,20 @@ internal static class TypeExtensions
}
public static MethodInfo? TryGetStaticParseMethod(
this Type type,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] this Type type,
bool withFormatProvider = false
)
{
var argumentTypes = withFormatProvider
? new[] { typeof(string), typeof(IFormatProvider) }
: new[] { typeof(string) };
return type.GetMethod(
) =>
type.GetMethod(
"Parse",
BindingFlags.Public | BindingFlags.Static,
null,
argumentTypes,
withFormatProvider ? [typeof(string), typeof(IFormatProvider)] : [typeof(string)],
null
);
}
public static bool IsToStringOverriden(this Type type)
public static bool IsToStringOverriden(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] this Type type
)
{
var toStringMethod = type.GetMethod(nameof(ToString), Type.EmptyTypes);
return toStringMethod?.GetBaseDefinition()?.DeclaringType != toStringMethod?.DeclaringType;