diff --git a/CliFx/Attributes/CommandAttribute.cs b/CliFx/Attributes/CommandAttribute.cs index 2066428..d7cbb1d 100644 --- a/CliFx/Attributes/CommandAttribute.cs +++ b/CliFx/Attributes/CommandAttribute.cs @@ -6,7 +6,7 @@ namespace CliFx.Attributes; /// Annotates a type that defines a command. /// [AttributeUsage(AttributeTargets.Class, Inherited = false)] -public sealed class CommandAttribute : Attribute +public class CommandAttribute : Attribute { /// /// Initializes an instance of . diff --git a/CliFx/Attributes/CommandHelpOptionAttribute.cs b/CliFx/Attributes/CommandHelpOptionAttribute.cs new file mode 100644 index 0000000..2c7ba04 --- /dev/null +++ b/CliFx/Attributes/CommandHelpOptionAttribute.cs @@ -0,0 +1,16 @@ +namespace CliFx.Attributes; + +/// +/// Annotates a property that defines the help option for a command. +/// +public class CommandHelpOptionAttribute : CommandOptionAttribute +{ + /// + /// Initializes an instance of . + /// + public CommandHelpOptionAttribute() + : base("help", 'h') + { + Description = "Show help for this command."; + } +} diff --git a/CliFx/Attributes/CommandOptionAttribute.cs b/CliFx/Attributes/CommandOptionAttribute.cs index 517a56b..4cd8ea6 100644 --- a/CliFx/Attributes/CommandOptionAttribute.cs +++ b/CliFx/Attributes/CommandOptionAttribute.cs @@ -7,7 +7,7 @@ namespace CliFx.Attributes; /// Annotates a property that defines a command option. /// [AttributeUsage(AttributeTargets.Property)] -public sealed class CommandOptionAttribute : Attribute +public class CommandOptionAttribute : Attribute { /// /// Initializes an instance of . diff --git a/CliFx/Attributes/CommandParameterAttribute.cs b/CliFx/Attributes/CommandParameterAttribute.cs index 3314403..c377105 100644 --- a/CliFx/Attributes/CommandParameterAttribute.cs +++ b/CliFx/Attributes/CommandParameterAttribute.cs @@ -7,7 +7,7 @@ namespace CliFx.Attributes; /// Annotates a property that defines a command parameter. /// [AttributeUsage(AttributeTargets.Property)] -public sealed class CommandParameterAttribute(int order) : Attribute +public class CommandParameterAttribute(int order) : Attribute { /// /// Parameter order. diff --git a/CliFx/Attributes/CommandVersionOptionAttribute.cs b/CliFx/Attributes/CommandVersionOptionAttribute.cs new file mode 100644 index 0000000..44cce02 --- /dev/null +++ b/CliFx/Attributes/CommandVersionOptionAttribute.cs @@ -0,0 +1,16 @@ +namespace CliFx.Attributes; + +/// +/// Annotates a property that defines the version option for a command. +/// +public class CommandVersionOptionAttribute : CommandOptionAttribute +{ + /// + /// Initializes an instance of . + /// + public CommandVersionOptionAttribute() + : base("version") + { + Description = "Show application version."; + } +} diff --git a/CliFx/CliApplication.cs b/CliFx/CliApplication.cs index a67d821..57a4063 100644 --- a/CliFx/CliApplication.cs +++ b/CliFx/CliApplication.cs @@ -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) diff --git a/CliFx/CliApplicationBuilder.cs b/CliFx/CliApplicationBuilder.cs index 107a84f..9a8dc14 100644 --- a/CliFx/CliApplicationBuilder.cs +++ b/CliFx/CliApplicationBuilder.cs @@ -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() { diff --git a/CliFx/CliFx.csproj b/CliFx/CliFx.csproj index 67fbfe4..d584fd7 100644 --- a/CliFx/CliFx.csproj +++ b/CliFx/CliFx.csproj @@ -1,7 +1,7 @@  - netstandard2.0;netstandard2.1;net8.0 + netstandard2.0;netstandard2.1;net7.0;net8.0 true true true diff --git a/CliFx/CommandBinder.cs b/CliFx/CommandBinder.cs index 56bc4ed..202f123 100644 --- a/CliFx/CommandBinder.cs +++ b/CliFx/CommandBinder.cs @@ -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 rawValues, Type targetEnumerableType, Type targetElementType @@ -137,7 +137,7 @@ internal class CommandBinder(ITypeActivator typeActivator) ); } - private object? ConvertMember(IInputSchema inputSchema, IReadOnlyList rawValues) + private object? ConvertMember(InputSchema inputSchema, IReadOnlyList 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(); @@ -218,7 +218,7 @@ internal class CommandBinder(ITypeActivator typeActivator) } private void BindMember( - IInputSchema inputSchema, + InputSchema inputSchema, ICommand commandInstance, IReadOnlyList 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(); diff --git a/CliFx/FallbackDefaultCommand.cs b/CliFx/FallbackDefaultCommand.cs index ef175f1..8dab98c 100644 --- a/CliFx/FallbackDefaultCommand.cs +++ b/CliFx/FallbackDefaultCommand.cs @@ -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; diff --git a/CliFx/Formatting/HelpConsoleFormatter.cs b/CliFx/Formatting/HelpConsoleFormatter.cs index ba4fec1..a66f568 100644 --- a/CliFx/Formatting/HelpConsoleFormatter.cs +++ b/CliFx/Formatting/HelpConsoleFormatter.cs @@ -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() ? "" : ""); + Write(ConsoleColor.White, option.IsSequence ? "" : ""); 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) diff --git a/CliFx/Formatting/HelpContext.cs b/CliFx/Formatting/HelpContext.cs index c898ec6..9f6b84e 100644 --- a/CliFx/Formatting/HelpContext.cs +++ b/CliFx/Formatting/HelpContext.cs @@ -7,7 +7,7 @@ internal class HelpContext( ApplicationMetadata applicationMetadata, ApplicationSchema applicationSchema, CommandSchema commandSchema, - IReadOnlyDictionary commandDefaultValues + IReadOnlyDictionary commandDefaultValues ) { public ApplicationMetadata ApplicationMetadata { get; } = applicationMetadata; @@ -16,6 +16,6 @@ internal class HelpContext( public CommandSchema CommandSchema { get; } = commandSchema; - public IReadOnlyDictionary CommandDefaultValues { get; } = + public IReadOnlyDictionary CommandDefaultValues { get; } = commandDefaultValues; } diff --git a/CliFx/ICommandWithHelpOption.cs b/CliFx/ICommandWithHelpOption.cs new file mode 100644 index 0000000..bc24477 --- /dev/null +++ b/CliFx/ICommandWithHelpOption.cs @@ -0,0 +1,12 @@ +namespace CliFx; + +/// +/// Command definition that includes the help option. +/// +public interface ICommandWithHelpOption : ICommand +{ + /// + /// Whether the user requested help for this command (via the `-h|--help` option). + /// + bool IsHelpRequested { get; } +} diff --git a/CliFx/ICommandWithVersionOption.cs b/CliFx/ICommandWithVersionOption.cs new file mode 100644 index 0000000..3ad23d8 --- /dev/null +++ b/CliFx/ICommandWithVersionOption.cs @@ -0,0 +1,12 @@ +namespace CliFx; + +/// +/// Command definition that includes the version option. +/// +public interface ICommandWithVersionOption : ICommand +{ + /// + /// Whether the user requested the version information (via the `--version` option). + /// + bool IsVersionRequested { get; } +} diff --git a/CliFx/Schema/CommandSchema.cs b/CliFx/Schema/CommandSchema.cs index 5659737..1715582 100644 --- a/CliFx/Schema/CommandSchema.cs +++ b/CliFx/Schema/CommandSchema.cs @@ -16,7 +16,7 @@ public class CommandSchema( ) { /// - /// Command's CLR type. + /// Underlying CLR type of the command. /// [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] public Type Type { get; } = type; @@ -26,6 +26,11 @@ public class CommandSchema( /// public string? Name { get; } = name; + /// + /// Whether this command is the application's default command. + /// + public bool IsDefault { get; } = string.IsNullOrWhiteSpace(name); + /// /// Command description. /// @@ -41,19 +46,14 @@ public class CommandSchema( /// public IReadOnlyList Options { get; } = options; - /// - /// Whether this command is the application's default command. - /// - 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 GetValues(ICommand instance) + internal IReadOnlyDictionary GetValues(ICommand instance) { - var result = new Dictionary(); + var result = new Dictionary(); foreach (var parameterSchema in Parameters) { diff --git a/CliFx/Schema/IInputSchema.cs b/CliFx/Schema/IInputSchema.cs deleted file mode 100644 index 8ca594c..0000000 --- a/CliFx/Schema/IInputSchema.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Collections.Generic; -using CliFx.Extensibility; - -namespace CliFx.Schema; - -/// -/// Describes an input of a command, which can be either a parameter or an option. -/// -public interface IInputSchema -{ - /// - /// Describes the binding of this input to a CLR property. - /// - PropertyBinding Property { get; } - - /// - /// Optional binding converter for this input. - /// - IBindingConverter? Converter { get; } - - /// - /// Optional binding validator(s) for this input. - /// - IReadOnlyList Validators { get; } -} diff --git a/CliFx/Schema/InputSchema.cs b/CliFx/Schema/InputSchema.cs new file mode 100644 index 0000000..fe57764 --- /dev/null +++ b/CliFx/Schema/InputSchema.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using CliFx.Extensibility; + +namespace CliFx.Schema; + +/// +/// Describes an input of a command, which can be either a parameter or an option. +/// +public abstract class InputSchema( + PropertyBinding property, + bool isSequence, + IBindingConverter? converter, + IReadOnlyList validators +) +{ + /// + /// CLR property to which this input is bound. + /// + public PropertyBinding Property { get; } = property; + + /// + /// Whether this input can accept more than one value. + /// + public bool IsSequence { get; } = isSequence; + + /// + /// Optional binding converter for this input. + /// + public IBindingConverter? Converter { get; } = converter; + + /// + /// Optional binding validator(s) for this input. + /// + public IReadOnlyList Validators { get; } = validators; +} diff --git a/CliFx/Schema/OptionSchema.cs b/CliFx/Schema/OptionSchema.cs index 7e93bdd..493760e 100644 --- a/CliFx/Schema/OptionSchema.cs +++ b/CliFx/Schema/OptionSchema.cs @@ -6,12 +6,11 @@ using CliFx.Extensibility; namespace CliFx.Schema; /// -/// Describes a command's option. +/// Describes an option input of a command. /// public class OptionSchema( PropertyBinding property, - bool isScalar, - IReadOnlyList? validValues, + bool isSequence, string? name, char? shortName, string? environmentVariable, @@ -19,17 +18,8 @@ public class OptionSchema( string? description, IBindingConverter? converter, IReadOnlyList validators -) : IInputSchema +) : InputSchema(property, isSequence, converter, validators) { - /// - public PropertyBinding Property { get; } = property; - - /// - public bool IsScalar { get; } = isScalar; - - /// - public IReadOnlyList? ValidValues { get; } = validValues; - /// /// Option name. /// @@ -55,12 +45,6 @@ public class OptionSchema( /// public string? Description { get; } = description; - /// - public IBindingConverter? Converter { get; } = converter; - - /// - public IReadOnlyList Validators { get; } = validators; - internal bool MatchesName(string? name) => !string.IsNullOrWhiteSpace(Name) && string.Equals(Name, name, StringComparison.OrdinalIgnoreCase); diff --git a/CliFx/Schema/ParameterSchema.cs b/CliFx/Schema/ParameterSchema.cs index b986de4..b52f40d 100644 --- a/CliFx/Schema/ParameterSchema.cs +++ b/CliFx/Schema/ParameterSchema.cs @@ -4,29 +4,19 @@ using CliFx.Extensibility; namespace CliFx.Schema; /// -/// Describes a command's parameter. +/// Describes a parameter input of a command. /// public class ParameterSchema( PropertyBinding property, - bool isScalar, - IReadOnlyList? validValues, + bool isSequence, int order, string name, bool isRequired, string? description, IBindingConverter? converter, IReadOnlyList validators -) : IInputSchema +) : InputSchema(property, isSequence, converter, validators) { - /// - public PropertyBinding Property { get; } = property; - - /// - public bool IsScalar { get; } = isScalar; - - /// - public IReadOnlyList? ValidValues { get; } = validValues; - /// /// Order, in which the parameter is bound from the command-line arguments. /// @@ -47,12 +37,5 @@ public class ParameterSchema( /// public string? Description { get; } = description; - /// - public IBindingConverter? Converter { get; } = converter; - - /// - public IReadOnlyList Validators { get; } = validators; - - internal string GetFormattedIdentifier() => - IsScalar ? $"<{Name}>" : $"<{Name}...>"; + internal string GetFormattedIdentifier() => IsSequence ? $"<{Name}>" : $"<{Name}...>"; } diff --git a/CliFx/Schema/PropertyBinding.cs b/CliFx/Schema/PropertyBinding.cs index 61d4f0c..e8d901d 100644 --- a/CliFx/Schema/PropertyBinding.cs +++ b/CliFx/Schema/PropertyBinding.cs @@ -5,19 +5,19 @@ using System.Linq; namespace CliFx.Schema; /// -/// Describes a CLR property. +/// Represents a binding to a CLR property. /// public class PropertyBinding( - Type propertyType, + Type type, Func getValue, Action setValue - ) +) { /// - /// Underlying property type. + /// Underlying CLR type of the property. /// - public Type PropertyType { get; } = propertyType; - + public Type Type { get; } = type; + /// /// 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. /// public void SetValue(object instance, object? value) => setValue(instance, value); -} -internal static class PropertyBindingExtensions -{ - public static IReadOnlyList? TryGetValidValues(this PropertyBinding binding) => - binding.PropertyType.IsEnum - ? binding.PropertyType.GetEnumValuesAsUnderlyingType().Cast().ToArray() - : null; -} \ No newline at end of file + internal IReadOnlyList? 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().ToArray(); + } + + return null; + } +} diff --git a/CliFx/Utils/Extensions/TypeExtensions.cs b/CliFx/Utils/Extensions/TypeExtensions.cs index 70d0e28..108cd88 100644 --- a/CliFx/Utils/Extensions/TypeExtensions.cs +++ b/CliFx/Utils/Extensions/TypeExtensions.cs @@ -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;