This commit is contained in:
Tyrrrz
2024-05-28 21:20:09 +03:00
parent 57db910489
commit cad1c14474
27 changed files with 300 additions and 487 deletions

View File

@@ -14,10 +14,10 @@
<PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" />
<PackageReference Include="GitHubActionsTestLogger" Version="2.3.3" PrivateAssets="all" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.2" />
<PackageReference Include="xunit" Version="2.8.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.0" PrivateAssets="all" />
<PackageReference Include="xunit" Version="2.8.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.1" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>

View File

@@ -21,7 +21,7 @@
<!-- Make sure to target the lowest possible version of the compiler for wider support -->
<PackageReference Include="Microsoft.CodeAnalysis" Version="3.0.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.0.0" PrivateAssets="all" />
<PackageReference Include="PolyShim" Version="1.10.0" PrivateAssets="all" />
<PackageReference Include="PolyShim" Version="1.12.0" PrivateAssets="all" />
</ItemGroup>
</Project>

View File

@@ -17,10 +17,10 @@
<PackageReference Include="GitHubActionsTestLogger" Version="2.3.3" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="PolyShim" Version="1.10.0" PrivateAssets="all" />
<PackageReference Include="xunit" Version="2.8.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="PolyShim" Version="1.12.0" PrivateAssets="all" />
<PackageReference Include="xunit" Version="2.8.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.1" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using CliFx.Schema;
namespace CliFx;
@@ -7,15 +6,15 @@ namespace CliFx;
/// Configuration of an application.
/// </summary>
public class ApplicationConfiguration(
IReadOnlyList<Type> commandTypes,
ApplicationSchema schema,
bool isDebugModeAllowed,
bool isPreviewModeAllowed
)
{
/// <summary>
/// Command types defined in the application.
/// Application schema.
/// </summary>
public IReadOnlyList<Type> CommandTypes { get; } = commandTypes;
public ApplicationSchema Schema { get; } = schema;
/// <summary>
/// Whether debug mode is allowed in the application.

View File

@@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using CliFx.Exceptions;
using CliFx.Formatting;
@@ -175,15 +174,14 @@ public class CliApplication(
{
try
{
var applicationSchema = ApplicationSchema.Resolve(Configuration.CommandTypes);
var commandInput = CommandInput.Parse(
commandLineArguments,
environmentVariables,
applicationSchema.GetCommandNames()
return await RunAsync(
Configuration.Schema,
CommandInput.Parse(
commandLineArguments,
environmentVariables,
Configuration.Schema.GetCommandNames()
)
);
return await RunAsync(applicationSchema, commandInput);
}
// To prevent the app from showing the annoying troubleshooting dialog on Windows,
// we handle all exceptions ourselves and print them to the console.

View File

@@ -1,9 +1,8 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Reflection;
using CliFx.Attributes;
using CliFx.Infrastructure;
using CliFx.Schema;
using CliFx.Utils;
@@ -16,7 +15,7 @@ namespace CliFx;
/// </summary>
public partial class CliApplicationBuilder
{
private readonly HashSet<Type> _commandTypes = [];
private readonly HashSet<CommandSchema> _commandSchemas = [];
private bool _isDebugModeAllowed = true;
private bool _isPreviewModeAllowed = true;
@@ -30,71 +29,12 @@ public partial class CliApplicationBuilder
/// <summary>
/// Adds a command to the application.
/// </summary>
public CliApplicationBuilder AddCommand(Type commandType)
public CliApplicationBuilder AddCommand(CommandSchema commandSchema)
{
_commandTypes.Add(commandType);
_commandSchemas.Add(commandSchema);
return this;
}
/// <summary>
/// Adds a command to the application.
/// </summary>
public CliApplicationBuilder AddCommand<TCommand>()
where TCommand : ICommand => AddCommand(typeof(TCommand));
/// <summary>
/// Adds multiple commands to the application.
/// </summary>
public CliApplicationBuilder AddCommands(IEnumerable<Type> commandTypes)
{
foreach (var commandType in commandTypes)
AddCommand(commandType);
return this;
}
/// <summary>
/// Adds commands from the specified assembly to the application.
/// </summary>
/// <remarks>
/// This method looks for public non-abstract classes that implement <see cref="ICommand" />
/// and are annotated by <see cref="CommandAttribute" />.
/// </remarks>
public CliApplicationBuilder AddCommandsFrom(Assembly commandAssembly)
{
foreach (
var commandType in commandAssembly.ExportedTypes.Where(CommandSchema.IsCommandType)
)
AddCommand(commandType);
return this;
}
/// <summary>
/// Adds commands from the specified assemblies to the application.
/// </summary>
/// <remarks>
/// This method looks for public non-abstract classes that implement <see cref="ICommand" />
/// and are annotated by <see cref="CommandAttribute" />.
/// </remarks>
public CliApplicationBuilder AddCommandsFrom(IEnumerable<Assembly> commandAssemblies)
{
foreach (var commandAssembly in commandAssemblies)
AddCommandsFrom(commandAssembly);
return this;
}
/// <summary>
/// Adds commands from the calling assembly to the application.
/// </summary>
/// <remarks>
/// This method looks for public non-abstract classes that implement <see cref="ICommand" />
/// and are annotated by <see cref="CommandAttribute" />.
/// </remarks>
public CliApplicationBuilder AddCommandsFromThisAssembly() =>
AddCommandsFrom(Assembly.GetCallingAssembly());
/// <summary>
/// Specifies whether debug mode (enabled with the [debug] directive) is allowed in the application.
/// </summary>
@@ -189,15 +129,6 @@ public partial class CliApplicationBuilder
// Null returns are handled by DelegateTypeActivator
UseTypeActivator(serviceProvider.GetService!);
/// <summary>
/// Configures the application to use the specified service provider for activating types.
/// This method takes a delegate that receives the list of all added command types, so that you can
/// easily register them with the service provider.
/// </summary>
public CliApplicationBuilder UseTypeActivator(
Func<IReadOnlyList<Type>, IServiceProvider> getServiceProvider
) => UseTypeActivator(getServiceProvider(_commandTypes.ToArray()));
/// <summary>
/// Creates a configured instance of <see cref="CliApplication" />.
/// </summary>
@@ -211,7 +142,7 @@ public partial class CliApplicationBuilder
);
var configuration = new ApplicationConfiguration(
_commandTypes.ToArray(),
new ApplicationSchema(_commandSchemas.ToArray()),
_isDebugModeAllowed,
_isPreviewModeAllowed
);
@@ -241,15 +172,17 @@ public partial class CliApplicationBuilder
return entryAssemblyName;
}
[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."
)]
private static string GetDefaultExecutableName()
{
var entryAssemblyFilePath = EnvironmentEx.EntryAssembly?.Location;
var processFilePath = EnvironmentEx.ProcessPath;
if (
string.IsNullOrWhiteSpace(entryAssemblyFilePath)
|| string.IsNullOrWhiteSpace(processFilePath)
)
// Process file path should generally always be available
if (string.IsNullOrWhiteSpace(processFilePath))
{
throw new InvalidOperationException(
"Failed to infer the default application executable name. "
@@ -257,15 +190,22 @@ public partial class CliApplicationBuilder
);
}
// If the process path matches the entry assembly path, it's a legacy .NET Framework app
// or a self-contained .NET Core app.
var entryAssemblyFilePath = EnvironmentEx.EntryAssembly?.Location;
// Single file application: entry assembly is not on disk and doesn't have a file path
if (string.IsNullOrWhiteSpace(entryAssemblyFilePath))
{
return Path.GetFileNameWithoutExtension(processFilePath);
}
// Legacy .NET Framework application: entry assembly has the same file path as the process
if (PathEx.AreEqual(entryAssemblyFilePath, processFilePath))
{
return Path.GetFileNameWithoutExtension(entryAssemblyFilePath);
}
// If the process path has the same name and parent directory as the entry assembly path,
// but different extension, it's a framework-dependent .NET Core app launched through the apphost.
// .NET Core application launched through the native application host:
// entry assembly has the same file path as the process, but with a different extension.
if (
PathEx.AreEqual(Path.ChangeExtension(entryAssemblyFilePath, "exe"), processFilePath)
|| PathEx.AreEqual(
@@ -277,7 +217,7 @@ public partial class CliApplicationBuilder
return Path.GetFileNameWithoutExtension(entryAssemblyFilePath);
}
// Otherwise, it's a framework-dependent .NET Core app launched through the .NET CLI
// .NET Core application launched through the .NET CLI
return "dotnet " + Path.GetFileName(entryAssemblyFilePath);
}

View File

@@ -25,7 +25,7 @@
<ItemGroup>
<PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="all" />
<PackageReference Include="PolyShim" Version="1.10.0" PrivateAssets="all" />
<PackageReference Include="PolyShim" Version="1.12.0" PrivateAssets="all" />
<PackageReference Include="System.Threading.Tasks.Extensions" Version="4.5.4" Condition="'$(TargetFramework)' == 'netstandard2.0'" />
</ItemGroup>

View File

@@ -16,14 +16,12 @@ internal class CommandBinder(ITypeActivator typeActivator)
{
private readonly IFormatProvider _formatProvider = CultureInfo.InvariantCulture;
private object? ConvertSingle(IMemberSchema memberSchema, string? rawValue, Type targetType)
private object? ConvertSingle(IInputSchema inputSchema, string? rawValue, Type targetType)
{
// Custom converter
if (memberSchema.ConverterType is not null)
if (inputSchema.Converter is not null)
{
var converter = typeActivator.CreateInstance<IBindingConverter>(
memberSchema.ConverterType
);
var converter = typeActivator.CreateInstance<IBindingConverter>(inputSchema.Converter);
return converter.Convert(rawValue);
}
@@ -71,7 +69,7 @@ internal class CommandBinder(ITypeActivator typeActivator)
if (nullableUnderlyingType is not null)
{
return !string.IsNullOrWhiteSpace(rawValue)
? ConvertSingle(memberSchema, rawValue, nullableUnderlyingType)
? ConvertSingle(inputSchema, rawValue, nullableUnderlyingType)
: null;
}
@@ -98,7 +96,7 @@ internal class CommandBinder(ITypeActivator typeActivator)
throw CliFxException.InternalError(
$"""
{memberSchema.GetKind()} {memberSchema.GetFormattedIdentifier()} has an unsupported underlying property type.
{inputSchema.GetKind()} {inputSchema.GetFormattedIdentifier()} has an unsupported underlying property type.
There is no known way to convert a string value into an instance of type `{targetType.FullName}`.
To fix this, either change the property to use a supported type or configure a custom converter.
"""
@@ -106,14 +104,14 @@ internal class CommandBinder(ITypeActivator typeActivator)
}
private object? ConvertMultiple(
IMemberSchema memberSchema,
IInputSchema inputSchema,
IReadOnlyList<string> rawValues,
Type targetEnumerableType,
Type targetElementType
)
{
var array = rawValues
.Select(v => ConvertSingle(memberSchema, v, targetElementType))
.Select(v => ConvertSingle(inputSchema, v, targetElementType))
.ToNonGenericArray(targetElementType);
var arrayType = array.GetType();
@@ -133,30 +131,27 @@ internal class CommandBinder(ITypeActivator typeActivator)
throw CliFxException.InternalError(
$"""
{memberSchema.GetKind()} {memberSchema.GetFormattedIdentifier()} has an unsupported underlying property type.
{inputSchema.GetKind()} {inputSchema.GetFormattedIdentifier()} has an unsupported underlying property type.
There is no known way to convert an array of `{targetElementType.FullName}` into an instance of type `{targetEnumerableType.FullName}`.
To fix this, change the property to use a type which can be assigned from an array or a type which has a constructor that accepts an array.
"""
);
}
private object? ConvertMember(IMemberSchema memberSchema, IReadOnlyList<string> rawValues)
private object? ConvertMember(IInputSchema inputSchema, IReadOnlyList<string> rawValues)
{
try
{
// Non-scalar
var enumerableUnderlyingType =
memberSchema.Property.Type.TryGetEnumerableUnderlyingType();
inputSchema.Property.Type.TryGetEnumerableUnderlyingType();
if (
enumerableUnderlyingType is not null
&& memberSchema.Property.Type != typeof(string)
)
if (enumerableUnderlyingType is not null && inputSchema.Property.Type != typeof(string))
{
return ConvertMultiple(
memberSchema,
inputSchema,
rawValues,
memberSchema.Property.Type,
inputSchema.Property.Type,
enumerableUnderlyingType
);
}
@@ -165,9 +160,9 @@ internal class CommandBinder(ITypeActivator typeActivator)
if (rawValues.Count <= 1)
{
return ConvertSingle(
memberSchema,
inputSchema,
rawValues.SingleOrDefault(),
memberSchema.Property.Type
inputSchema.Property.Type
);
}
}
@@ -181,7 +176,7 @@ internal class CommandBinder(ITypeActivator typeActivator)
throw CliFxException.UserError(
$"""
{memberSchema.GetKind()} {memberSchema.GetFormattedIdentifier()} cannot be set from the provided argument(s):
{inputSchema.GetKind()} {inputSchema.GetFormattedIdentifier()} cannot be set from the provided argument(s):
{rawValues.Select(v => '<' + v + '>').JoinToString(" ")}
Error: {errorMessage}
""",
@@ -192,17 +187,17 @@ internal class CommandBinder(ITypeActivator typeActivator)
// Mismatch (scalar but too many values)
throw CliFxException.UserError(
$"""
{memberSchema.GetKind()} {memberSchema.GetFormattedIdentifier()} expects a single argument, but provided with multiple:
{inputSchema.GetKind()} {inputSchema.GetFormattedIdentifier()} expects a single argument, but provided with multiple:
{rawValues.Select(v => '<' + v + '>').JoinToString(" ")}
"""
);
}
private void ValidateMember(IMemberSchema memberSchema, object? convertedValue)
private void ValidateMember(IInputSchema inputSchema, object? convertedValue)
{
var errors = new List<BindingValidationError>();
foreach (var validatorType in memberSchema.ValidatorTypes)
foreach (var validatorType in inputSchema.Validators)
{
var validator = typeActivator.CreateInstance<IBindingValidator>(validatorType);
var error = validator.Validate(convertedValue);
@@ -215,7 +210,7 @@ internal class CommandBinder(ITypeActivator typeActivator)
{
throw CliFxException.UserError(
$"""
{memberSchema.GetKind()} {memberSchema.GetFormattedIdentifier()} has been provided with an invalid value.
{inputSchema.GetKind()} {inputSchema.GetFormattedIdentifier()} has been provided with an invalid value.
Error(s):
{errors.Select(e => "- " + e.Message).JoinToString(Environment.NewLine)}
"""
@@ -224,15 +219,15 @@ internal class CommandBinder(ITypeActivator typeActivator)
}
private void BindMember(
IMemberSchema memberSchema,
IInputSchema inputSchema,
ICommand commandInstance,
IReadOnlyList<string> rawValues
)
{
var convertedValue = ConvertMember(memberSchema, rawValues);
ValidateMember(memberSchema, convertedValue);
var convertedValue = ConvertMember(inputSchema, rawValues);
ValidateMember(inputSchema, convertedValue);
memberSchema.Property.SetValue(commandInstance, convertedValue);
inputSchema.Property.SetValue(commandInstance, convertedValue);
}
private void BindParameters(

View File

@@ -1,8 +1,16 @@
namespace CliFx.Extensibility;
// Used internally to simplify the usage from reflection
internal interface IBindingConverter
/// <summary>
/// Defines a custom conversion for binding command-line arguments to command inputs.
/// </summary>
/// <remarks>
/// To implement your own converter, inherit from <see cref="BindingConverter{T}" /> instead.
/// </remarks>
public interface IBindingConverter
{
/// <summary>
/// Parses the value from a raw command-line argument.
/// </summary>
object? Convert(string? rawValue);
}
@@ -12,7 +20,7 @@ internal interface IBindingConverter
public abstract class BindingConverter<T> : IBindingConverter
{
/// <summary>
/// Parses value from a raw command-line argument.
/// Parses the value from a raw command-line argument.
/// </summary>
public abstract T Convert(string? rawValue);

View File

@@ -1,8 +1,17 @@
namespace CliFx.Extensibility;
// Used internally to simplify the usage from reflection
internal interface IBindingValidator
/// <summary>
/// Defines a custom validation rules for values bound from command-line arguments.
/// </summary>
/// <remarks>
/// To implement your own validator, inherit from <see cref="BindingValidator{T}" /> instead.
/// </remarks>
public interface IBindingValidator
{
/// <summary>
/// Validates the value bound to a parameter or an option.
/// Returns null if validation is successful, or an error in case of failure.
/// </summary>
BindingValidationError? Validate(object? value);
}

View File

@@ -9,12 +9,15 @@ 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 class FallbackDefaultCommand : ICommand
internal partial class FallbackDefaultCommand : ICommand
{
public static CommandSchema Schema { get; } =
CommandSchema.Resolve(typeof(FallbackDefaultCommand));
// Never actually executed
[ExcludeFromCodeCoverage]
public ValueTask ExecuteAsync(IConsole console) => default;
}
internal partial class FallbackDefaultCommand
{
public static CommandSchema Schema { get; } =
new(typeof(FallbackDefaultCommand), null, null, [], []);
}

View File

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

View File

@@ -1,4 +1,5 @@
using System;
using System.Diagnostics.CodeAnalysis;
using CliFx.Exceptions;
namespace CliFx.Infrastructure;
@@ -10,7 +11,9 @@ namespace CliFx.Infrastructure;
public class DefaultTypeActivator : ITypeActivator
{
/// <inheritdoc />
public object CreateInstance(Type type)
public object CreateInstance(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type
)
{
try
{

View File

@@ -1,4 +1,5 @@
using System;
using System.Diagnostics.CodeAnalysis;
using CliFx.Exceptions;
namespace CliFx.Infrastructure;
@@ -9,7 +10,9 @@ namespace CliFx.Infrastructure;
public class DelegateTypeActivator(Func<Type, object> createInstance) : ITypeActivator
{
/// <inheritdoc />
public object CreateInstance(Type type) =>
public object CreateInstance(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type
) =>
createInstance(type)
?? throw CliFxException.InternalError(
$"""

View File

@@ -1,4 +1,5 @@
using System;
using System.Diagnostics.CodeAnalysis;
using CliFx.Exceptions;
namespace CliFx.Infrastructure;
@@ -11,12 +12,17 @@ public interface ITypeActivator
/// <summary>
/// Creates an instance of the specified type.
/// </summary>
object CreateInstance(Type type);
object CreateInstance(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type
);
}
internal static class TypeActivatorExtensions
{
public static T CreateInstance<T>(this ITypeActivator activator, Type type)
public static T CreateInstance<T>(
this ITypeActivator activator,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type
)
{
if (!typeof(T).IsAssignableFrom(type))
{

View File

@@ -33,10 +33,6 @@ internal partial class CommandInput(
public bool IsDebugDirectiveSpecified => Directives.Any(d => d.IsDebugDirective);
public bool IsPreviewDirectiveSpecified => Directives.Any(d => d.IsPreviewDirective);
public bool IsHelpOptionSpecified => Options.Any(o => o.IsHelpOption);
public bool IsVersionOptionSpecified => Options.Any(o => o.IsVersionOption);
}
internal partial class CommandInput

View File

@@ -1,5 +1,4 @@
using System.Collections.Generic;
using CliFx.Schema;
namespace CliFx.Input;
@@ -9,10 +8,6 @@ internal class OptionInput(string identifier, IReadOnlyList<string> values)
public IReadOnlyList<string> Values { get; } = values;
public bool IsHelpOption => OptionSchema.HelpOption.MatchesIdentifier(Identifier);
public bool IsVersionOption => OptionSchema.VersionOption.MatchesIdentifier(Identifier);
public string GetFormattedIdentifier() =>
Identifier switch
{

View File

@@ -5,33 +5,39 @@ using CliFx.Utils.Extensions;
namespace CliFx.Schema;
internal partial class ApplicationSchema(IReadOnlyList<CommandSchema> commands)
/// <summary>
/// Describes the structure of a command-line application.
/// </summary>
public class ApplicationSchema(IReadOnlyList<CommandSchema> commands)
{
/// <summary>
/// Commands defined in the application.
/// </summary>
public IReadOnlyList<CommandSchema> Commands { get; } = commands;
public IReadOnlyList<string> GetCommandNames() =>
internal IReadOnlyList<string> GetCommandNames() =>
Commands.Select(c => c.Name).WhereNotNullOrWhiteSpace().ToArray();
public CommandSchema? TryFindDefaultCommand() => Commands.FirstOrDefault(c => c.IsDefault);
internal CommandSchema? TryFindDefaultCommand() => Commands.FirstOrDefault(c => c.IsDefault);
public CommandSchema? TryFindCommand(string commandName) =>
internal CommandSchema? TryFindCommand(string commandName) =>
Commands.FirstOrDefault(c => c.MatchesName(commandName));
private IReadOnlyList<CommandSchema> GetDescendantCommands(
IReadOnlyList<CommandSchema> potentialParentCommandSchemas,
IReadOnlyList<CommandSchema> potentialDescendantCommands,
string? parentCommandName
)
{
var result = new List<CommandSchema>();
foreach (var potentialParentCommandSchema in potentialParentCommandSchemas)
foreach (var potentialDescendantCommand in potentialDescendantCommands)
{
// Default commands can't be descendant of anything
if (string.IsNullOrWhiteSpace(potentialParentCommandSchema.Name))
if (string.IsNullOrWhiteSpace(potentialDescendantCommand.Name))
continue;
// Command can't be its own descendant
if (potentialParentCommandSchema.MatchesName(parentCommandName))
if (potentialDescendantCommand.MatchesName(parentCommandName))
continue;
var isDescendant =
@@ -39,22 +45,22 @@ internal partial class ApplicationSchema(IReadOnlyList<CommandSchema> commands)
string.IsNullOrWhiteSpace(parentCommandName)
||
// Otherwise a command is a descendant if it starts with the same name segments
potentialParentCommandSchema.Name.StartsWith(
potentialDescendantCommand.Name.StartsWith(
parentCommandName + ' ',
StringComparison.OrdinalIgnoreCase
);
if (isDescendant)
result.Add(potentialParentCommandSchema);
result.Add(potentialDescendantCommand);
}
return result;
}
public IReadOnlyList<CommandSchema> GetDescendantCommands(string? parentCommandName) =>
internal IReadOnlyList<CommandSchema> GetDescendantCommands(string? parentCommandName) =>
GetDescendantCommands(Commands, parentCommandName);
public IReadOnlyList<CommandSchema> GetChildCommands(string? parentCommandName)
internal IReadOnlyList<CommandSchema> GetChildCommands(string? parentCommandName)
{
var descendants = GetDescendantCommands(parentCommandName);
@@ -62,16 +68,8 @@ internal partial class ApplicationSchema(IReadOnlyList<CommandSchema> commands)
// Filter out descendants of descendants, leave only direct children
foreach (var descendant in descendants)
{
result.RemoveRange(GetDescendantCommands(descendants, descendant.Name));
}
return result;
}
}
internal partial class ApplicationSchema
{
public static ApplicationSchema Resolve(IReadOnlyList<Type> commandTypes) =>
new(commandTypes.Select(CommandSchema.Resolve).ToArray());
}

View File

@@ -1,40 +0,0 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using CliFx.Utils.Extensions;
namespace CliFx.Schema;
internal class BindablePropertyDescriptor(PropertyInfo property) : IPropertyDescriptor
{
public Type Type => property.PropertyType;
public object? GetValue(ICommand commandInstance) => property.GetValue(commandInstance);
public void SetValue(ICommand commandInstance, object? value) =>
property.SetValue(commandInstance, value);
public IReadOnlyList<object?> GetValidValues()
{
static Type GetUnderlyingType(Type type)
{
var enumerableUnderlyingType = type.TryGetEnumerableUnderlyingType();
if (enumerableUnderlyingType is not null)
return GetUnderlyingType(enumerableUnderlyingType);
var nullableUnderlyingType = type.TryGetNullableUnderlyingType();
if (nullableUnderlyingType is not null)
return GetUnderlyingType(nullableUnderlyingType);
return type;
}
var underlyingType = GetUnderlyingType(Type);
// We can only get valid values for enums
if (underlyingType.IsEnum)
return Enum.GetNames(underlyingType);
return Array.Empty<object?>();
}
}

View File

@@ -1,45 +1,59 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Utils.Extensions;
using System.Diagnostics.CodeAnalysis;
namespace CliFx.Schema;
internal partial class CommandSchema(
Type type,
/// <summary>
/// Describes an individual command.
/// </summary>
public class CommandSchema(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type,
string? name,
string? description,
IReadOnlyList<ParameterSchema> parameters,
IReadOnlyList<OptionSchema> options
)
{
/// <summary>
/// Command's CLR type.
/// </summary>
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]
public Type Type { get; } = type;
/// <summary>
/// Command name.
/// </summary>
public string? Name { get; } = name;
/// <summary>
/// Command description.
/// </summary>
public string? Description { get; } = description;
/// <summary>
/// Command parameters.
/// </summary>
public IReadOnlyList<ParameterSchema> Parameters { get; } = parameters;
/// <summary>
/// Command options.
/// </summary>
public IReadOnlyList<OptionSchema> Options { get; } = options;
public bool IsDefault => string.IsNullOrWhiteSpace(Name);
/// <summary>
/// Whether this command is the application's default command.
/// </summary>
public bool IsDefault { get; } = string.IsNullOrWhiteSpace(name);
public bool IsHelpOptionAvailable => Options.Contains(OptionSchema.HelpOption);
public bool IsVersionOptionAvailable => Options.Contains(OptionSchema.VersionOption);
public bool MatchesName(string? name) =>
internal bool MatchesName(string? name) =>
!string.IsNullOrWhiteSpace(Name)
? string.Equals(name, Name, StringComparison.OrdinalIgnoreCase)
: string.IsNullOrWhiteSpace(name);
public IReadOnlyDictionary<IMemberSchema, object?> GetValues(ICommand instance)
internal IReadOnlyDictionary<IInputSchema, object?> GetValues(ICommand instance)
{
var result = new Dictionary<IMemberSchema, object?>();
var result = new Dictionary<IInputSchema, object?>();
foreach (var parameterSchema in Parameters)
{
@@ -56,78 +70,3 @@ internal partial class CommandSchema(
return result;
}
}
internal partial class CommandSchema
{
public static bool IsCommandType(Type type) =>
type.Implements(typeof(ICommand))
&& type.IsDefined(typeof(CommandAttribute))
&& type is { IsAbstract: false, IsInterface: false };
public static CommandSchema? TryResolve(Type type)
{
if (!IsCommandType(type))
return null;
var attribute = type.GetCustomAttribute<CommandAttribute>();
var name = attribute?.Name?.Trim();
var description = attribute?.Description?.Trim();
var implicitOptionSchemas = string.IsNullOrWhiteSpace(name)
? new[] { OptionSchema.HelpOption, OptionSchema.VersionOption }
: new[] { OptionSchema.HelpOption };
var properties = type
// Get properties directly on the command type
.GetProperties()
// Get non-abstract properties on interfaces (to support default interfaces members)
.Union(
type.GetInterfaces()
// Only interfaces implementing ICommand for explicitness
.Where(i => i != typeof(ICommand) && i.IsAssignableTo(typeof(ICommand)))
.SelectMany(i =>
i.GetProperties()
.Where(p =>
p.GetMethod is not null
&& !p.GetMethod.IsAbstract
&& p.SetMethod is not null
&& !p.SetMethod.IsAbstract
)
)
)
.ToArray();
var parameterSchemas = properties
.Select(ParameterSchema.TryResolve)
.WhereNotNull()
.ToArray();
var optionSchemas = properties
.Select(OptionSchema.TryResolve)
.WhereNotNull()
.Concat(implicitOptionSchemas)
.ToArray();
return new CommandSchema(type, name, description, parameterSchemas, optionSchemas);
}
public static CommandSchema Resolve(Type type)
{
var schema = TryResolve(type);
if (schema is null)
{
throw CliFxException.InternalError(
$"""
Type `{type.FullName}` is not a valid command type.
In order to be a valid command type, it must:
- Implement `{typeof(ICommand).FullName}`
- Be annotated with `{typeof(CommandAttribute).FullName}`
- Not be an abstract class
"""
);
}
return schema;
}
}

View File

@@ -0,0 +1,36 @@
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>
/// Information about the property that this input is bound to.
/// </summary>
PropertyDescriptor Property { get; }
/// <summary>
/// Whether this input is a scalar (single value) or a sequence (multiple values).
/// </summary>
bool IsScalar { get; }
/// <summary>
/// Valid values for this input, if applicable.
/// If the input does not have a predefined set of valid values, this property is <c>null</c>.
/// </summary>
IReadOnlyList<object?>? ValidValues { 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

@@ -1,26 +0,0 @@
using System;
using System.Collections.Generic;
namespace CliFx.Schema;
internal interface IMemberSchema
{
IPropertyDescriptor Property { get; }
Type? ConverterType { get; }
IReadOnlyList<Type> ValidatorTypes { get; }
string GetFormattedIdentifier();
}
internal static class MemberSchemaExtensions
{
public static string GetKind(this IMemberSchema memberSchema) =>
memberSchema switch
{
ParameterSchema => "Parameter",
OptionSchema => "Option",
_ => throw new ArgumentOutOfRangeException(nameof(memberSchema))
};
}

View File

@@ -1,23 +0,0 @@
using System;
using System.Collections.Generic;
using CliFx.Utils.Extensions;
namespace CliFx.Schema;
internal interface IPropertyDescriptor
{
Type Type { get; }
object? GetValue(ICommand commandInstance);
void SetValue(ICommand commandInstance, object? value);
IReadOnlyList<object?> GetValidValues();
}
internal static class PropertyDescriptorExtensions
{
public static bool IsScalar(this IPropertyDescriptor propertyDescriptor) =>
propertyDescriptor.Type == typeof(string)
|| propertyDescriptor.Type.TryGetEnumerableUnderlyingType() is null;
}

View File

@@ -1,20 +0,0 @@
using System;
using System.Collections.Generic;
namespace CliFx.Schema;
internal partial class NullPropertyDescriptor : IPropertyDescriptor
{
public Type Type { get; } = typeof(object);
public object? GetValue(ICommand commandInstance) => null;
public void SetValue(ICommand commandInstance, object? value) { }
public IReadOnlyList<object?> GetValidValues() => Array.Empty<object?>();
}
internal partial class NullPropertyDescriptor
{
public static NullPropertyDescriptor Instance { get; } = new();
}

View File

@@ -1,54 +1,79 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Text;
using CliFx.Attributes;
using CliFx.Utils.Extensions;
using CliFx.Extensibility;
namespace CliFx.Schema;
internal partial class OptionSchema(
IPropertyDescriptor property,
/// <summary>
/// Describes a command's option.
/// </summary>
public class OptionSchema(
PropertyDescriptor property,
string? name,
char? shortName,
string? environmentVariable,
bool isRequired,
string? description,
Type? converterType,
IReadOnlyList<Type> validatorTypes
) : IMemberSchema
IBindingConverter? converter,
IReadOnlyList<IBindingValidator> validators
) : IInputSchema
{
public IPropertyDescriptor Property { get; } = property;
/// <inheritdoc />
public PropertyDescriptor Property { get; } = property;
/// <inheritdoc />
public bool IsScalar { get; }
/// <inheritdoc />
public IReadOnlyList<object?>? ValidValues { get; }
/// <summary>
/// Option name.
/// </summary>
public string? Name { get; } = name;
/// <summary>
/// Option short name.
/// </summary>
public char? ShortName { get; } = shortName;
/// <summary>
/// Environment variable that can be used as a fallback for this option.
/// </summary>
public string? EnvironmentVariable { get; } = environmentVariable;
/// <summary>
/// Whether the option is required.
/// </summary>
public bool IsRequired { get; } = isRequired;
/// <summary>
/// Option description.
/// </summary>
public string? Description { get; } = description;
public Type? ConverterType { get; } = converterType;
/// <inheritdoc />
public IBindingConverter? Converter { get; } = converter;
public IReadOnlyList<Type> ValidatorTypes { get; } = validatorTypes;
/// <inheritdoc />
public IReadOnlyList<IBindingValidator> Validators { get; } = validators;
public bool MatchesName(string? name) =>
internal bool MatchesName(string? name) =>
!string.IsNullOrWhiteSpace(Name)
&& string.Equals(Name, name, StringComparison.OrdinalIgnoreCase);
public bool MatchesShortName(char? shortName) =>
internal bool MatchesShortName(char? shortName) =>
ShortName is not null && ShortName == shortName;
public bool MatchesIdentifier(string identifier) =>
internal bool MatchesIdentifier(string identifier) =>
MatchesName(identifier) || identifier.Length == 1 && MatchesShortName(identifier[0]);
public bool MatchesEnvironmentVariable(string environmentVariableName) =>
internal bool MatchesEnvironmentVariable(string environmentVariableName) =>
!string.IsNullOrWhiteSpace(EnvironmentVariable)
&& string.Equals(EnvironmentVariable, environmentVariableName, StringComparison.Ordinal);
public string GetFormattedIdentifier()
internal string GetFormattedIdentifier()
{
var buffer = new StringBuilder();
@@ -73,57 +98,3 @@ internal partial class OptionSchema(
return buffer.ToString();
}
}
internal partial class OptionSchema
{
public static OptionSchema? TryResolve(PropertyInfo property)
{
var attribute = property.GetCustomAttribute<CommandOptionAttribute>();
if (attribute is null)
return null;
// The user may mistakenly specify dashes, thinking it's required, so trim them
var name = attribute.Name?.TrimStart('-').Trim();
var environmentVariable = attribute.EnvironmentVariable?.Trim();
var isRequired = attribute.IsRequired || property.IsRequired();
var description = attribute.Description?.Trim();
return new OptionSchema(
new BindablePropertyDescriptor(property),
name,
attribute.ShortName,
environmentVariable,
isRequired,
description,
attribute.Converter,
attribute.Validators
);
}
}
internal partial class OptionSchema
{
public static OptionSchema HelpOption { get; } =
new(
NullPropertyDescriptor.Instance,
"help",
'h',
null,
false,
"Shows help text.",
null,
Array.Empty<Type>()
);
public static OptionSchema VersionOption { get; } =
new(
NullPropertyDescriptor.Instance,
"version",
null,
null,
false,
"Shows version information.",
null,
Array.Empty<Type>()
);
}

View File

@@ -1,58 +1,50 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using CliFx.Attributes;
using CliFx.Utils.Extensions;
using System.Collections.Generic;
using CliFx.Extensibility;
namespace CliFx.Schema;
internal partial class ParameterSchema(
IPropertyDescriptor property,
/// <summary>
/// Describes a command's parameter.
/// </summary>
public class ParameterSchema(
PropertyDescriptor property,
int order,
string name,
bool isRequired,
string? description,
Type? converterType,
IReadOnlyList<Type> validatorTypes
) : IMemberSchema
IBindingConverter? converter,
IReadOnlyList<IBindingValidator> validators
) : IInputSchema
{
public IPropertyDescriptor Property { get; } = property;
/// <inheritdoc />
public PropertyDescriptor Property { get; } = property;
/// <summary>
/// Order, in which the parameter is bound from the command-line arguments.
/// </summary>
public int Order { get; } = order;
/// <summary>
/// Parameter name.
/// </summary>
public string Name { get; } = name;
/// <summary>
/// Whether the parameter is required.
/// </summary>
public bool IsRequired { get; } = isRequired;
/// <summary>
/// Parameter description.
/// </summary>
public string? Description { get; } = description;
public Type? ConverterType { get; } = converterType;
/// <inheritdoc />
public IBindingConverter? Converter { get; } = converter;
public IReadOnlyList<Type> ValidatorTypes { get; } = validatorTypes;
/// <inheritdoc />
public IReadOnlyList<IBindingValidator> Validators { get; } = validators;
public string GetFormattedIdentifier() => Property.IsScalar() ? $"<{Name}>" : $"<{Name}...>";
}
internal partial class ParameterSchema
{
public static ParameterSchema? TryResolve(PropertyInfo property)
{
var attribute = property.GetCustomAttribute<CommandParameterAttribute>();
if (attribute is null)
return null;
var name = attribute.Name?.Trim() ?? property.Name.ToLowerInvariant();
var isRequired = attribute.IsRequired || property.IsRequired();
var description = attribute.Description?.Trim();
return new ParameterSchema(
new BindablePropertyDescriptor(property),
attribute.Order,
name,
isRequired,
description,
attribute.Converter,
attribute.Validators
);
}
internal string GetFormattedIdentifier() =>
!Property.IsEnumerable ? $"<{Name}>" : $"<{Name}...>";
}

View File

@@ -0,0 +1,31 @@
using System;
using System.Diagnostics.CodeAnalysis;
namespace CliFx.Schema;
/// <summary>
/// Describes a CLR property.
/// </summary>
public class PropertyDescriptor(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)]
Type type,
Func<object, object?> getValue,
Action<object, object?> setValue
)
{
/// <summary>
/// Property's CLR type.
/// </summary>
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)]
public Type Type { get; } = type;
/// <summary>
/// Gets the current value of the property on the specified instance.
/// </summary>
public object? GetValue(object instance) => getValue(instance);
/// <summary>
/// Sets the value of the property on the specified instance.
/// </summary>
public void SetValue(object instance, object? value) => setValue(instance, value);
}