diff --git a/src/Repl.Defaults/IReplApp.cs b/src/Repl.Defaults/IReplApp.cs index a7fc753..264d9c3 100644 --- a/src/Repl.Defaults/IReplApp.cs +++ b/src/Repl.Defaults/IReplApp.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace Repl; /// @@ -19,7 +21,7 @@ public interface IReplApp : ICoreReplApp /// /// Module type. /// The same app contract for fluent chaining. - IReplApp MapModule() + IReplApp MapModule<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TModule>() where TModule : class, IReplModule; /// diff --git a/src/Repl.Defaults/ReplApp.cs b/src/Repl.Defaults/ReplApp.cs index cc5fa86..9d644f1 100644 --- a/src/Repl.Defaults/ReplApp.cs +++ b/src/Repl.Defaults/ReplApp.cs @@ -1,9 +1,10 @@ +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reflection; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Repl.Documentation; -using System.Linq.Expressions; -using System.Reflection; namespace Repl; @@ -132,7 +133,7 @@ public IContextBuilder Context(string segment, Action configure, Deleg /// /// Maps a module resolved through runtime DI activation. /// - public ReplApp MapModule() + public ReplApp MapModule<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TModule>() where TModule : class, IReplModule { var module = ResolveModuleFromServices(); @@ -435,6 +436,8 @@ IReplApp IReplApp.MapModule(IReplModule module, Func MapModule(module, isPresent); + [UnconditionalSuppressMessage("Trimming", "IL2091", Justification = "Annotation flows from IReplApp.MapModule().")] + [UnconditionalSuppressMessage("Trimming", "IL2095", Justification = "Annotation flows from IReplApp.MapModule().")] IReplApp IReplApp.MapModule() => MapModule(); IReplApp IReplApp.WithBanner(Delegate bannerProvider) => WithBanner(bannerProvider); @@ -460,15 +463,16 @@ IReplMap IReplMap.MapModule(IReplModule module, Func _sharedProvider ??= _services.BuildServiceProvider(); - private TModule ResolveModuleFromServices() + private TModule ResolveModuleFromServices<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TModule>() where TModule : class, IReplModule { // Resolve from the shared provider — its lifetime spans the entire app, // so disposable dependencies captured by the module stay alive. + // Uses GetServiceOrCreateInstance so callers don't need to register the + // module explicitly — constructor dependencies are resolved from DI. var provider = EnsureSharedProvider(); - return provider.GetService() - ?? throw new InvalidOperationException( - $"Unable to resolve module '{typeof(TModule).FullName}'. Register it in services or call MapModule(IReplModule)."); + return Microsoft.Extensions.DependencyInjection.ActivatorUtilities + .GetServiceOrCreateInstance(provider); } private static Func AdaptModulePresencePredicate(Delegate isPresent) @@ -704,7 +708,7 @@ public IContextBuilder Context(string segment, Action configure, Deleg validation); } - public IReplApp MapModule() + public IReplApp MapModule<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TModule>() where TModule : class, IReplModule { return MapModule(root.ResolveModuleFromServices()); diff --git a/src/Repl.IntegrationTests/Given_ModuleComposition.cs b/src/Repl.IntegrationTests/Given_ModuleComposition.cs index e71e236..1dac356 100644 --- a/src/Repl.IntegrationTests/Given_ModuleComposition.cs +++ b/src/Repl.IntegrationTests/Given_ModuleComposition.cs @@ -191,6 +191,24 @@ public void When_ScopedModuleDependsOnDisposableService_Then_ServiceSurvives() output.Text.Should().Contain("1"); } + [TestMethod] + [Description("MapModule() auto-creates the module when it is not registered in DI, using ActivatorUtilities to inject constructor dependencies.")] + public void When_ModuleNotRegisteredInDI_Then_ActivatorUtilitiesCreatesItWithInjectedDependencies() + { + var sut = ReplApp.Create(services => + { + // Register the dependency but NOT the module itself. + services.AddSingleton(); + }); + sut.MapModule(); + + var output = ConsoleCaptureHelper.Capture( + () => sut.Run(["counter", "increment", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("1"); + } + private sealed class ScopedOpsModule : IReplModule { public void Map(IReplMap map)