Skip to content

Commit

Permalink
Refactor CLI Project for AOT Compatibility and Trimming Support
Browse files Browse the repository at this point in the history
Summary: This commit refactors the Spectre.Console.Cli project to replace reflection-based object creation with explicit and type-safe constructs to enable support for Ahead-Of-Time (AOT) compilation and code trimming. AOT mode is only enabled for .NET 9.

Introduce DynamicallyAccessedMembers Annotations:
* Annotated various types and generic parameters (e.g., CommandApp, CommandSettings) with [DynamicallyAccessedMembers] to ensure necessary metadata is preserved during trimming for AOT scenarios.

Explicit Object Creation:
* Introduced CreateInstanceHelpers to encapsulate type-safe and explicit instance creation logic. This replaces Activator.CreateInstance for array, multimap, and command settings instantiation.

Refactor Command Attributes:
* Updated constructors of CommandArgumentAttribute and CommandOptionAttribute to explicitly accept Type for argument and option types, with corresponding logic to register instance builders for these types.

Remove Reflection-Heavy Logic:
* Replaced uses of Activator.CreateInstance across multiple files with explicit methods from CreateInstanceHelpers.
* Removed reflection-reliant dynamic code annotations ([RequiresDynamicCode]) from critical paths where alternatives are now implemented.

Enhanced Type Conversion:
* Consolidated type conversion logic into TypeConverterHelper with static converters for intrinsic and complex types.
* Ensured compatibility by defaulting to explicit conversion mechanisms where possible.

Array and Collection Initialization:
* Migrated array and collection initialization to CreateInstanceHelpers to handle both primitive and complex types without runtime reflection.

Builder and Registrar Adjustments:
* Refactored ITypeRegistrar and related classes to include explicit type member annotations, ensuring required metadata survives trimming.
* Adjusted configurators (Configurator, IConfigurator) to support type-safe default and added commands.

Testing Adjustments:
* Added internal visibility to support AOT testing scenarios.
* Introduced new project files for trimming-specific tests.

Risks
=========================
Unit Testing: I tried my best to figure out if I could get the TrimTest console project to run the Cli unit tests when published to AOT via xunit.runner.console, but I'm just not smart enough. I'd feel infinitely better about things if it could do so. As is, the features included there do all work. This project is needed to light up the analyzers properly.

Explicit Type Requirement: Attributes now require explicit Type definitions for non-intrinsic types (e.g., DirectoryInfo). Users must specify the argumentType or optionType parameter for such types. Failure to do so will result in runtime exceptions or incorrect behavior.

Compatibility with Existing Registrations:

Type Registrar Changes: Registrations now require explicit [DynamicallyAccessedMembers] annotations on implementation types. Users with their own custom registration and resolver will need to adapt to match

Array parameters: Users who for whatever reason might have created their own struct and are using them as IEnumerable parameters are going to get failures in AOT scenarios. We can't dynamically create those arrays.
  • Loading branch information
phil-scott-78 committed Dec 1, 2024
1 parent 92daeb7 commit adaac27
Show file tree
Hide file tree
Showing 52 changed files with 889 additions and 101 deletions.
14 changes: 13 additions & 1 deletion src/Spectre.Console.Cli/Annotations/CommandArgumentAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ namespace Spectre.Console.Cli;
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public sealed class CommandArgumentAttribute : Attribute
{
[DynamicallyAccessedMembers(PublicConstructors)]
internal Type? ArgumentType { get; }

/// <summary>
/// Gets the argument position.
/// </summary>
Expand All @@ -32,7 +35,8 @@ public sealed class CommandArgumentAttribute : Attribute
/// </summary>
/// <param name="position">The argument position.</param>
/// <param name="template">The argument template. Wrap in &lt;&gt; for required arguments, [] for optional ones. For example "[MyArgument]".</param>
public CommandArgumentAttribute(int position, string template)
/// <param name="argumentType">The type of the parameter. Required for AOT scenarios.</param>
public CommandArgumentAttribute(int position, string template, [DynamicallyAccessedMembers(PublicConstructors)] Type? argumentType = null)
{
if (template == null)
{
Expand All @@ -46,5 +50,13 @@ public CommandArgumentAttribute(int position, string template)
Position = position;
ValueName = result.Value;
IsRequired = result.Required;

// if someone was explicit about the type of option, then we need to register an
// explicit builder for this type to be used.
ArgumentType = argumentType;
if (ArgumentType != null)
{
CreateInstanceHelpers.RegisterNewInstanceBuilder(ArgumentType);
}
}
}
14 changes: 13 additions & 1 deletion src/Spectre.Console.Cli/Annotations/CommandOptionAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ namespace Spectre.Console.Cli;
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public sealed class CommandOptionAttribute : Attribute
{
[DynamicallyAccessedMembers(PublicConstructors)]
internal Type? OptionType { get; }

/// <summary>
/// Gets the long names of the option.
/// </summary>
Expand Down Expand Up @@ -39,7 +42,8 @@ public sealed class CommandOptionAttribute : Attribute
/// Initializes a new instance of the <see cref="CommandOptionAttribute"/> class.
/// </summary>
/// <param name="template">The option template.</param>
public CommandOptionAttribute(string template)
/// <param name="optionType">The type of the parameter. Required for AOT scenarios.</param>
public CommandOptionAttribute(string template, [DynamicallyAccessedMembers(PublicConstructors)] Type? optionType = null)
{
if (template == null)
{
Expand All @@ -54,6 +58,14 @@ public CommandOptionAttribute(string template)
ShortNames = result.ShortNames;
ValueName = result.Value;
ValueIsOptional = result.ValueIsOptional;

// if someone was explicit about the type of option, then we need to register an
// explicit builder for this type to be used.
OptionType = optionType;
if (OptionType != null)
{
CreateInstanceHelpers.RegisterNewInstanceBuilder(OptionType);
}
}

internal bool IsMatch(string name)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ public sealed class PairDeconstructorAttribute : Attribute
/// pair deconstructor class to use for data conversion for the
/// object this attribute is bound to.
/// </summary>
public Type Type { get; }
public Type Type
{
[return: DynamicallyAccessedMembers(PublicConstructors | PublicProperties)]
get;
}

/// <summary>
/// Initializes a new instance of the <see cref="PairDeconstructorAttribute"/> class.
Expand All @@ -21,7 +25,7 @@ public sealed class PairDeconstructorAttribute : Attribute
/// A System.Type that represents the type of the pair deconstructor
/// class to use for data conversion for the object this attribute is bound to.
/// </param>
public PairDeconstructorAttribute(Type type)
public PairDeconstructorAttribute([DynamicallyAccessedMembers(PublicConstructors | PublicProperties)] Type type)
{
Type = type ?? throw new ArgumentNullException(nameof(type));
}
Expand Down
2 changes: 1 addition & 1 deletion src/Spectre.Console.Cli/AsyncCommandOfT.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ namespace Spectre.Console.Cli;
/// Base class for an asynchronous command.
/// </summary>
/// <typeparam name="TSettings">The settings type.</typeparam>
public abstract class AsyncCommand<TSettings> : ICommand<TSettings>
public abstract class AsyncCommand<[DynamicallyAccessedMembers(PublicConstructors)] TSettings> : ICommand<TSettings>
where TSettings : CommandSettings
{
/// <summary>
Expand Down
5 changes: 1 addition & 4 deletions src/Spectre.Console.Cli/CommandApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@ namespace Spectre.Console.Cli;
/// <summary>
/// The entry point for a command line application.
/// </summary>
#if !NETSTANDARD2_0
[RequiresDynamicCode("Spectre.Console.Cli relies on reflection. Use during trimming and AOT compilation is not supported and may result in unexpected behaviors.")]
#endif
public sealed class CommandApp : ICommandApp
{
private readonly Configurator _configurator;
Expand Down Expand Up @@ -45,7 +42,7 @@ public void Configure(Action<IConfigurator> configuration)
/// </summary>
/// <typeparam name="TCommand">The command type.</typeparam>
/// <returns>A <see cref="DefaultCommandConfigurator"/> that can be used to configure the default command.</returns>
public DefaultCommandConfigurator SetDefaultCommand<TCommand>()
public DefaultCommandConfigurator SetDefaultCommand<[DynamicallyAccessedMembers(PublicConstructors | Interfaces)] TCommand>()
where TCommand : class, ICommand
{
return new DefaultCommandConfigurator(GetConfigurator().SetDefaultCommand<TCommand>());
Expand Down
5 changes: 1 addition & 4 deletions src/Spectre.Console.Cli/CommandAppOfT.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,7 @@ namespace Spectre.Console.Cli;
/// The entry point for a command line application with a default command.
/// </summary>
/// <typeparam name="TDefaultCommand">The type of the default command.</typeparam>
#if !NETSTANDARD2_0
[RequiresDynamicCode("Spectre.Console.Cli relies on reflection. Use during trimming and AOT compilation is not supported and may result in unexpected behaviors.")]
#endif
public sealed class CommandApp<TDefaultCommand> : ICommandApp
public sealed class CommandApp<[DynamicallyAccessedMembers(PublicConstructors | Interfaces)] TDefaultCommand> : ICommandApp
where TDefaultCommand : class, ICommand
{
private readonly CommandApp _app;
Expand Down
2 changes: 1 addition & 1 deletion src/Spectre.Console.Cli/CommandOfT.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ namespace Spectre.Console.Cli;
/// </summary>
/// <typeparam name="TSettings">The settings type.</typeparam>
/// <seealso cref="AsyncCommand{TSettings}"/>
public abstract class Command<TSettings> : ICommand<TSettings>
public abstract class Command<[DynamicallyAccessedMembers(PublicConstructors)] TSettings> : ICommand<TSettings>
where TSettings : CommandSettings
{
/// <summary>
Expand Down
2 changes: 1 addition & 1 deletion src/Spectre.Console.Cli/ConfiguratorExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public static IConfigurator SetHelpProvider(this IConfigurator configurator, IHe
/// <param name="configurator">The configurator.</param>
/// <typeparam name="T">The type of the help provider to instantiate at runtime and use.</typeparam>
/// <returns>A configurator that can be used to configure the application further.</returns>
public static IConfigurator SetHelpProvider<T>(this IConfigurator configurator)
public static IConfigurator SetHelpProvider<[DynamicallyAccessedMembers(PublicConstructors | PublicProperties)] T>(this IConfigurator configurator)
where T : IHelpProvider
{
if (configurator == null)
Expand Down
204 changes: 204 additions & 0 deletions src/Spectre.Console.Cli/CreateInstanceHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
using System.Runtime.CompilerServices;

namespace Spectre.Console.Cli;

internal static class CreateInstanceHelpers
{
private static readonly Dictionary<Type, Func<int, Array>> _arrayBuilder;
private static readonly Dictionary<Type, Func<IMultiMap>> _multiMapBuilder;
private static readonly Dictionary<Type, Func<object[], object>> _instanceBuilder = new();

static CreateInstanceHelpers()
{
_arrayBuilder = new Dictionary<Type, Func<int, Array>>
{
[typeof(bool)] = size => new bool[size],
[typeof(byte)] = size => new byte[size],
[typeof(sbyte)] = size => new sbyte[size],
[typeof(char)] = size => new char[size],
[typeof(double)] = size => new double[size],
[typeof(string)] = size => new string[size],
[typeof(int)] = size => new int[size],
[typeof(short)] = size => new short[size],
[typeof(long)] = size => new long[size],
[typeof(float)] = size => new float[size],
[typeof(ushort)] = size => new ushort[size],
[typeof(uint)] = size => new uint[size],
[typeof(ulong)] = size => new ulong[size],
[typeof(DateTime)] = size => new DateTime[size],
[typeof(DateTimeOffset)] = size => new DateTimeOffset[size],
[typeof(decimal)] = size => new decimal[size],
[typeof(TimeSpan)] = size => new TimeSpan[size],
[typeof(Guid)] = size => new Guid[size],
#if !NETSTANDARD2_0
[typeof(Int128)] = size => new Int128[size],
[typeof(Half)] = size => new Half[size],
[typeof(UInt128)] = size => new UInt128[size],
[typeof(DateOnly)] = size => new DateOnly[size],
[typeof(TimeOnly)] = size => new TimeOnly[size],
#endif
};

_multiMapBuilder = new Dictionary<Type, Func<IMultiMap>>
{
[typeof(bool)] = () => new MultiMap<string, bool>(),
[typeof(bool)] = () => new MultiMap<string, bool>(),
[typeof(byte)] = () => new MultiMap<string, byte>(),
[typeof(sbyte)] = () => new MultiMap<string, sbyte>(),
[typeof(char)] = () => new MultiMap<string, char>(),
[typeof(double)] = () => new MultiMap<string, double>(),
[typeof(string)] = () => new MultiMap<string, string>(),
[typeof(int)] = () => new MultiMap<string, int>(),
[typeof(short)] = () => new MultiMap<string, short>(),
[typeof(long)] = () => new MultiMap<string, long>(),
[typeof(float)] = () => new MultiMap<string, float>(),
[typeof(ushort)] = () => new MultiMap<string, ushort>(),
[typeof(uint)] = () => new MultiMap<string, uint>(),
[typeof(ulong)] = () => new MultiMap<string, ulong>(),
[typeof(DateTime)] = () => new MultiMap<string, DateTime>(),
[typeof(DateTimeOffset)] = () => new MultiMap<string, DateTimeOffset>(),
[typeof(decimal)] = () => new MultiMap<string, decimal>(),
[typeof(TimeSpan)] = () => new MultiMap<string, TimeSpan>(),
[typeof(Guid)] = () => new MultiMap<string, Guid>(),
#if !NETSTANDARD2_0
[typeof(Int128)] = () => new MultiMap<string, Int128>(),
[typeof(Half)] = () => new MultiMap<string, Half>(),
[typeof(UInt128)] = () => new MultiMap<string, UInt128>(),
[typeof(DateOnly)] = () => new MultiMap<string, DateOnly>(),
[typeof(TimeOnly)] = () => new MultiMap<string, TimeOnly>(),
#endif
};
}

/// <summary>
/// Add a new known type instance builder.
/// </summary>
/// <param name="type">The type to build.</param>
public static void RegisterNewInstanceBuilder([DynamicallyAccessedMembers(PublicConstructors)] Type type)
{
_instanceBuilder.Add(type, input =>
{
var constructor = type.GetConstructor(BindingFlags.Public | BindingFlags.Instance, null, input.Select(i => i.GetType()).ToArray(), null);
if (constructor == null)
{
throw new InvalidOperationException("Could not create single parameter instance.");
}

return constructor.Invoke(input);
});
}

public static bool TryGetInstance(Type type, object[] parameters, [NotNullWhen(true)] out object? result)
{
if (_instanceBuilder.TryGetValue(type, out var factory))
{
result = factory(parameters);
return true;
}

if (CanDoCreateInstance)
{
result = BuildInstance(type, parameters);
if (result != null)
{
return true;
}
}

result = null;
return false;
}

private static object? BuildInstance(Type type, object[] parameters)
{
if (CanDoUnreferencedCode)
{
var constructor = type.GetConstructor(BindingFlags.Public | BindingFlags.Instance, null, parameters.Select(i => i.GetType()).ToArray(), null);
if (constructor == null)
{
throw new InvalidOperationException("Could not create single parameter instance.");
}

return constructor.Invoke(parameters);
}

return null;
}

public static Array CreateArrayInstance(Type type, int size)
{
#if NET9_0_OR_GREATER
return Array.CreateInstanceFromArrayType(type, size);
#else
var elementType = type.GetElementType();
if (elementType == null)
{
throw new InvalidOperationException("Could not create an array of type " + type.FullName + ".");
}

return CreateArrayInstanceFromElementType(elementType, size);
#endif
}

public static IMultiMap? CreateMultiMapInstance(Type type1, Type type2)
{
if (type1 == typeof(string))
{
if (_multiMapBuilder.TryGetValue(type2, out var multiMapBuilder))
{
return multiMapBuilder.Invoke();
}
}

if (CanDoCreateInstance)
{
return Activator.CreateInstance(typeof(MultiMap<,>).MakeGenericType(type1, type2)) as IMultiMap;
}

throw new InvalidOperationException("Could not create a multi map of type " + type1.FullName + " and " + type2.FullName + ". If you are running in AOT, only dictionaries with a string key and an .NET primitive value are supported.");
}

[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "We are only creating an array instance dynamically for non-value types not in our dictionary.")]
public static Array CreateArrayInstanceFromElementType(Type getType, int sourceArrayLength)
{
if (_arrayBuilder.TryGetValue(getType, out var value))
{
return value(sourceArrayLength);
}

if (!getType.IsValueType || CanDoCreateInstance)
{
return Array.CreateInstance(getType, sourceArrayLength);
}

throw new InvalidOperationException("Cannot create array instance of type " + getType.FullName + ". ");
}

[FeatureGuard(typeof(RequiresUnreferencedCodeAttribute))]
public static bool CanDoUnreferencedCode
{
get
{
#if NET9_0_OR_GREATER
#pragma warning disable IL4000
return RuntimeFeature.IsDynamicCodeSupported;
#pragma warning restore IL4000
#else
return true;
#endif
}
}

[FeatureGuard(typeof(RequiresDynamicCodeAttribute))]
public static bool CanDoCreateInstance
{
get
{
#if NET9_0_OR_GREATER
return RuntimeFeature.IsDynamicCodeSupported;
#else
return true;
#endif
}
}
}
2 changes: 1 addition & 1 deletion src/Spectre.Console.Cli/ICommandOfT.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ namespace Spectre.Console.Cli;
/// Represents a command.
/// </summary>
/// <typeparam name="TSettings">The settings type.</typeparam>
public interface ICommand<TSettings> : ICommandLimiter<TSettings>
public interface ICommand<[DynamicallyAccessedMembers(PublicConstructors)] TSettings> : ICommandLimiter<TSettings>
where TSettings : CommandSettings
{
/// <summary>
Expand Down
1 change: 1 addition & 0 deletions src/Spectre.Console.Cli/ICommandParameterInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public interface ICommandParameterInfo
/// <summary>
/// Gets the parameter type.
/// </summary>
[DynamicallyAccessedMembers(PublicConstructors | Interfaces)]
public Type ParameterType { get; }

/// <summary>
Expand Down
4 changes: 2 additions & 2 deletions src/Spectre.Console.Cli/IConfigurator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public interface IConfigurator
/// Sets the help provider for the application.
/// </summary>
/// <typeparam name="T">The type of the help provider to instantiate at runtime and use.</typeparam>
public void SetHelpProvider<T>()
public void SetHelpProvider<[DynamicallyAccessedMembers(PublicConstructors | PublicProperties)] T>()
where T : IHelpProvider;

/// <summary>
Expand All @@ -35,7 +35,7 @@ public void SetHelpProvider<T>()
/// <typeparam name="TCommand">The command type.</typeparam>
/// <param name="name">The name of the command.</param>
/// <returns>A command configurator that can be used to configure the command further.</returns>
ICommandConfigurator AddCommand<TCommand>(string name)
ICommandConfigurator AddCommand<[DynamicallyAccessedMembers(PublicConstructors | Interfaces)] TCommand>(string name)
where TCommand : class, ICommand;

/// <summary>
Expand Down
Loading

0 comments on commit adaac27

Please sign in to comment.