Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/Repl.Defaults/IReplApp.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Diagnostics.CodeAnalysis;

namespace Repl;

/// <summary>
Expand All @@ -19,7 +21,7 @@ public interface IReplApp : ICoreReplApp
/// </summary>
/// <typeparam name="TModule">Module type.</typeparam>
/// <returns>The same app contract for fluent chaining.</returns>
IReplApp MapModule<TModule>()
IReplApp MapModule<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TModule>()
where TModule : class, IReplModule;

/// <summary>
Expand Down
20 changes: 12 additions & 8 deletions src/Repl.Defaults/ReplApp.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -132,7 +133,7 @@ public IContextBuilder Context(string segment, Action<IReplMap> configure, Deleg
/// <summary>
/// Maps a module resolved through runtime DI activation.
/// </summary>
public ReplApp MapModule<TModule>()
public ReplApp MapModule<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TModule>()
where TModule : class, IReplModule
{
var module = ResolveModuleFromServices<TModule>();
Expand Down Expand Up @@ -435,6 +436,8 @@ IReplApp IReplApp.MapModule(IReplModule module, Func<ModulePresenceContext, bool
IReplApp IReplApp.MapModule(IReplModule module, Delegate isPresent) =>
MapModule(module, isPresent);

[UnconditionalSuppressMessage("Trimming", "IL2091", Justification = "Annotation flows from IReplApp.MapModule<TModule>().")]
[UnconditionalSuppressMessage("Trimming", "IL2095", Justification = "Annotation flows from IReplApp.MapModule<TModule>().")]
IReplApp IReplApp.MapModule<TModule>() => MapModule<TModule>();

IReplApp IReplApp.WithBanner(Delegate bannerProvider) => WithBanner(bannerProvider);
Expand All @@ -460,15 +463,16 @@ IReplMap IReplMap.MapModule(IReplModule module, Func<ModulePresenceContext, bool
private ServiceProvider EnsureSharedProvider() =>
_sharedProvider ??= _services.BuildServiceProvider();

private TModule ResolveModuleFromServices<TModule>()
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<TModule>()
?? throw new InvalidOperationException(
$"Unable to resolve module '{typeof(TModule).FullName}'. Register it in services or call MapModule(IReplModule).");
return Microsoft.Extensions.DependencyInjection.ActivatorUtilities
.GetServiceOrCreateInstance<TModule>(provider);
}

private static Func<ModulePresenceContext, bool> AdaptModulePresencePredicate(Delegate isPresent)
Expand Down Expand Up @@ -704,7 +708,7 @@ public IContextBuilder Context(string segment, Action<IReplApp> configure, Deleg
validation);
}

public IReplApp MapModule<TModule>()
public IReplApp MapModule<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TModule>()
where TModule : class, IReplModule
{
return MapModule(root.ResolveModuleFromServices<TModule>());
Expand Down
18 changes: 18 additions & 0 deletions src/Repl.IntegrationTests/Given_ModuleComposition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,24 @@ public void When_ScopedModuleDependsOnDisposableService_Then_ServiceSurvives()
output.Text.Should().Contain("1");
}

[TestMethod]
[Description("MapModule<T>() 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<DisposableCounter>();
});
sut.MapModule<ModuleWithDisposableDependency>();

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)
Expand Down
Loading