Skip to content

Commit 2cca95c

Browse files
authored
Merge pull request #24 from yllibed/dev/cdb/issue-23-global-options-di
Fix typed global options DI binding
2 parents e750f75 + f3ebc45 commit 2cca95c

15 files changed

Lines changed: 315 additions & 58 deletions

src/Repl.Core/CoreReplApp.Execution.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -844,6 +844,7 @@ private InvocationBindingContext CreateInvocationBindingContext(
844844
_options.Parsing.NumericFormatProvider,
845845
serviceProvider,
846846
_options.Interaction,
847+
_implicitServiceParameters,
847848
cancellationToken);
848849
}
849850

src/Repl.Core/CoreReplApp.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public sealed partial class CoreReplApp : ICoreReplApp
1919
private readonly List<RouteDefinition> _routes = [];
2020
private readonly List<Func<ReplExecutionContext, ReplNext, ValueTask>> _middleware = [];
2121
private readonly ReplOptions _options = new();
22+
private readonly ImplicitServiceParameterRegistry _implicitServiceParameters = new();
2223
private readonly List<ModuleRegistration> _moduleRegistrations = [];
2324
private readonly Stack<int> _moduleMappingScope = new();
2425
private int _nextModuleId = 1;
@@ -37,6 +38,7 @@ public sealed partial class CoreReplApp : ICoreReplApp
3738
internal string? Description => _description;
3839
internal IGlobalOptionsAccessor GlobalOptionsAccessor => _globalOptionsSnapshot;
3940
internal GlobalOptionsSnapshot GlobalOptionsSnapshotInstance => _globalOptionsSnapshot;
41+
internal ImplicitServiceParameterRegistry ImplicitServiceParameters => _implicitServiceParameters;
4042
internal ShellCompletionRuntime ShellCompletionRuntimeInstance => _shellCompletionRuntime;
4143
internal IReplExecutionObserver? ExecutionObserver { get; set; }
4244
internal List<ContextDefinition> Contexts => _contexts;
@@ -61,6 +63,12 @@ private CoreReplApp()
6163
static context => context.Channel is ReplRuntimeChannel.Cli or ReplRuntimeChannel.Interactive);
6264
}
6365

66+
internal void RegisterGlobalOptionsType(Type optionsType)
67+
{
68+
ArgumentNullException.ThrowIfNull(optionsType);
69+
_implicitServiceParameters.AddGlobalOptionsType(optionsType);
70+
}
71+
6472
/// <summary>
6573
/// Creates a dependency-free REPL application instance.
6674
/// </summary>
@@ -172,7 +180,7 @@ public CommandBuilder Map(string route, Delegate handler)
172180
.Select(existingRoute => existingRoute.Template));
173181

174182
_commands.Add(command);
175-
var optionSchema = OptionSchemaBuilder.Build(template, command, _options.Parsing);
183+
var optionSchema = OptionSchemaBuilder.Build(template, command, _options.Parsing, _implicitServiceParameters);
176184
var routeDefinition = new RouteDefinition(template, command, moduleId, optionSchema);
177185
_routes.Add(routeDefinition);
178186
InvalidateRouting();

src/Repl.Core/Documentation/DocumentationEngine.cs

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ private ReplDocCommand BuildDocumentationCommand(RouteDefinition route)
238238
!string.IsNullOrWhiteSpace(parameter.Name)
239239
&& parameter.ParameterType != typeof(CancellationToken)
240240
&& !routeParameterNames.Contains(parameter.Name!)
241-
&& !IsFrameworkInjectedParameter(parameter.ParameterType)
241+
&& !app.ImplicitServiceParameters.IsImplicitServiceParameter(parameter.ParameterType)
242242
&& parameter.GetCustomAttribute<FromServicesAttribute>() is null
243243
&& parameter.GetCustomAttribute<FromContextAttribute>() is null
244244
&& !Attribute.IsDefined(parameter.ParameterType, typeof(ReplOptionsGroupAttribute), inherit: true))
@@ -298,19 +298,6 @@ internal ReplDocApp BuildDocumentationApp()
298298
return new ReplDocApp(name, version, description);
299299
}
300300

301-
private static bool IsFrameworkInjectedParameter(Type parameterType) =>
302-
parameterType == typeof(IServiceProvider)
303-
|| parameterType == typeof(ICoreReplApp)
304-
|| parameterType == typeof(CoreReplApp)
305-
|| parameterType == typeof(IReplSessionState)
306-
|| parameterType == typeof(IReplInteractionChannel)
307-
|| parameterType == typeof(IReplIoContext)
308-
|| parameterType == typeof(IReplKeyReader)
309-
|| string.Equals(parameterType.FullName, "Repl.Mcp.IMcpClientRoots", StringComparison.Ordinal)
310-
|| string.Equals(parameterType.FullName, "Repl.Mcp.IMcpSampling", StringComparison.Ordinal)
311-
|| string.Equals(parameterType.FullName, "Repl.Mcp.IMcpElicitation", StringComparison.Ordinal)
312-
|| string.Equals(parameterType.FullName, "Repl.Mcp.IMcpFeedback", StringComparison.Ordinal);
313-
314301
private static bool IsRequiredParameter(ParameterInfo parameter)
315302
{
316303
if (parameter.HasDefaultValue)

src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ internal static class OptionSchemaBuilder
99
public static OptionSchema Build(
1010
RouteTemplate template,
1111
CommandBuilder command,
12-
ParsingOptions parsingOptions)
12+
ParsingOptions parsingOptions,
13+
ImplicitServiceParameterRegistry implicitServiceParameters)
1314
{
1415
ArgumentNullException.ThrowIfNull(template);
1516
ArgumentNullException.ThrowIfNull(command);
1617
ArgumentNullException.ThrowIfNull(parsingOptions);
18+
ArgumentNullException.ThrowIfNull(implicitServiceParameters);
1719

1820
var routeParameterNames = template.Segments
1921
.OfType<DynamicRouteSegment>()
@@ -25,7 +27,7 @@ public static OptionSchema Build(
2527
var groupPositionalPropertyNames = new List<string>();
2628
foreach (var parameter in command.Handler.Method.GetParameters())
2729
{
28-
if (ShouldSkipSchemaParameter(parameter, routeParameterNames))
30+
if (ShouldSkipSchemaParameter(parameter, routeParameterNames, implicitServiceParameters))
2931
{
3032
continue;
3133
}
@@ -57,11 +59,29 @@ public static OptionSchema Build(
5759

5860
private static bool ShouldSkipSchemaParameter(
5961
ParameterInfo parameter,
60-
HashSet<string> routeParameterNames)
62+
HashSet<string> routeParameterNames,
63+
ImplicitServiceParameterRegistry implicitServiceParameters)
6164
{
65+
var optionAttribute = parameter.GetCustomAttribute<ReplOptionAttribute>(inherit: true);
66+
var argumentAttribute = parameter.GetCustomAttribute<ReplArgumentAttribute>(inherit: true);
67+
if (implicitServiceParameters.TryGetGlobalOptionsServiceType(parameter.ParameterType, out var globalOptionsType))
68+
{
69+
if (optionAttribute is not null || argumentAttribute is not null)
70+
{
71+
var attributeName = optionAttribute is not null
72+
? nameof(ReplOptionAttribute).Replace("Attribute", string.Empty, StringComparison.Ordinal)
73+
: nameof(ReplArgumentAttribute).Replace("Attribute", string.Empty, StringComparison.Ordinal);
74+
throw new InvalidOperationException(
75+
$"Parameter '{parameter.Name}' uses typed global options '{globalOptionsType.Name}' registered through "
76+
+ $"UseGlobalOptions<T>() and cannot declare [{attributeName}]. Remove the attribute or use a separate command options type.");
77+
}
78+
79+
return true;
80+
}
81+
6282
if (string.IsNullOrWhiteSpace(parameter.Name)
6383
|| parameter.ParameterType == typeof(CancellationToken)
64-
|| IsFrameworkInjectedParameter(parameter)
84+
|| implicitServiceParameters.IsImplicitServiceParameter(parameter.ParameterType)
6585
|| parameter.GetCustomAttribute<FromContextAttribute>() is not null
6686
|| parameter.GetCustomAttribute<FromServicesAttribute>() is not null)
6787
{
@@ -73,8 +93,6 @@ private static bool ShouldSkipSchemaParameter(
7393
return false;
7494
}
7595

76-
var optionAttribute = parameter.GetCustomAttribute<ReplOptionAttribute>(inherit: true);
77-
var argumentAttribute = parameter.GetCustomAttribute<ReplArgumentAttribute>(inherit: true);
7896
if (optionAttribute is null && argumentAttribute is null)
7997
{
8098
return true;
@@ -190,19 +208,6 @@ private static void AppendValueAliases(
190208
}
191209
}
192210

193-
private static bool IsFrameworkInjectedParameter(ParameterInfo parameter) =>
194-
parameter.ParameterType == typeof(IServiceProvider)
195-
|| parameter.ParameterType == typeof(ICoreReplApp)
196-
|| parameter.ParameterType == typeof(CoreReplApp)
197-
|| parameter.ParameterType == typeof(IReplSessionState)
198-
|| parameter.ParameterType == typeof(IReplInteractionChannel)
199-
|| parameter.ParameterType == typeof(IReplIoContext)
200-
|| parameter.ParameterType == typeof(IReplKeyReader)
201-
|| string.Equals(parameter.ParameterType.FullName, "Repl.Mcp.IMcpClientRoots", StringComparison.Ordinal)
202-
|| string.Equals(parameter.ParameterType.FullName, "Repl.Mcp.IMcpSampling", StringComparison.Ordinal)
203-
|| string.Equals(parameter.ParameterType.FullName, "Repl.Mcp.IMcpElicitation", StringComparison.Ordinal)
204-
|| string.Equals(parameter.ParameterType.FullName, "Repl.Mcp.IMcpFeedback", StringComparison.Ordinal);
205-
206211
private static ReplArity ResolveArity(ParameterInfo parameter, ReplOptionAttribute? optionAttribute)
207212
{
208213
if (optionAttribute?.Arity is { } explicitArity)

src/Repl.Core/Parsing/GlobalOptionDefinition.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ internal sealed record GlobalOptionDefinition(
55
string CanonicalToken,
66
IReadOnlyList<string> Aliases,
77
string? DefaultValue,
8-
Type ValueType);
8+
Type ValueType,
9+
Type? OwnerType);

src/Repl.Core/Parsing/HandlerArgumentBinder.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,19 @@ internal static class HandlerArgumentBinder
5353
$"Unable to bind parameter '{parameter.Name}' ({parameter.ParameterType.Name}).");
5454
}
5555

56+
if (context.ImplicitServiceParameters.TryGetGlobalOptionsServiceType(parameter.ParameterType, out var globalOptionsServiceType))
57+
{
58+
var globalOptions = context.ServiceProvider.GetService(globalOptionsServiceType);
59+
if (globalOptions is not null)
60+
{
61+
return globalOptions;
62+
}
63+
64+
throw new InvalidOperationException(
65+
$"Unable to resolve typed global options parameter '{parameter.Name}' ({globalOptionsServiceType.Name}) from services. "
66+
+ $"Ensure the type is registered through UseGlobalOptions<{globalOptionsServiceType.Name}>() before mapping commands.");
67+
}
68+
5669
#pragma warning disable IL2072
5770
if (IsOptionsGroupParameter(parameter))
5871
{
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
namespace Repl;
2+
3+
internal sealed class ImplicitServiceParameterRegistry
4+
{
5+
private readonly HashSet<Type> _globalOptionsTypes = [];
6+
7+
public void AddGlobalOptionsType(Type optionsType)
8+
{
9+
ArgumentNullException.ThrowIfNull(optionsType);
10+
_globalOptionsTypes.Add(optionsType);
11+
}
12+
13+
public bool IsImplicitServiceParameter(Type parameterType) =>
14+
IsFrameworkInjectedParameter(parameterType)
15+
|| TryGetGlobalOptionsServiceType(parameterType, out _);
16+
17+
public bool TryGetGlobalOptionsServiceType(Type parameterType, out Type serviceType)
18+
{
19+
ArgumentNullException.ThrowIfNull(parameterType);
20+
21+
serviceType = typeof(void);
22+
if (_globalOptionsTypes.Contains(parameterType))
23+
{
24+
serviceType = parameterType;
25+
return true;
26+
}
27+
28+
if (parameterType == typeof(object))
29+
{
30+
return false;
31+
}
32+
33+
var matches = _globalOptionsTypes
34+
.Where(parameterType.IsAssignableFrom)
35+
.OrderBy(static type => type.FullName, StringComparer.Ordinal)
36+
.ToArray();
37+
if (matches.Length == 0)
38+
{
39+
return false;
40+
}
41+
42+
if (matches.Length > 1)
43+
{
44+
throw new InvalidOperationException(
45+
$"Ambiguous typed global options binding for parameter type '{parameterType.Name}'. "
46+
+ $"Registered matching types: {string.Join(", ", matches.Select(static type => type.Name))}. "
47+
+ "Use the concrete registered options type or an explicit [FromServices] parameter.");
48+
}
49+
50+
serviceType = matches[0];
51+
return true;
52+
}
53+
54+
private static bool IsFrameworkInjectedParameter(Type parameterType) =>
55+
parameterType == typeof(IServiceProvider)
56+
|| parameterType == typeof(ICoreReplApp)
57+
|| parameterType == typeof(CoreReplApp)
58+
|| parameterType == typeof(IGlobalOptionsAccessor)
59+
|| parameterType == typeof(IReplSessionState)
60+
|| parameterType == typeof(IReplInteractionChannel)
61+
|| parameterType == typeof(IReplIoContext)
62+
|| parameterType == typeof(IReplKeyReader)
63+
|| string.Equals(parameterType.FullName, "Repl.Mcp.IMcpClientRoots", StringComparison.Ordinal)
64+
|| string.Equals(parameterType.FullName, "Repl.Mcp.IMcpSampling", StringComparison.Ordinal)
65+
|| string.Equals(parameterType.FullName, "Repl.Mcp.IMcpElicitation", StringComparison.Ordinal)
66+
|| string.Equals(parameterType.FullName, "Repl.Mcp.IMcpFeedback", StringComparison.Ordinal);
67+
}

src/Repl.Core/Parsing/InvocationBindingContext.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ internal sealed class InvocationBindingContext(
1212
IFormatProvider numericFormatProvider,
1313
IServiceProvider serviceProvider,
1414
InteractionOptions interactionOptions,
15+
ImplicitServiceParameterRegistry implicitServiceParameters,
1516
CancellationToken cancellationToken)
1617
{
1718
public IReadOnlyDictionary<string, string> RouteValues { get; } = routeValues;
@@ -33,4 +34,7 @@ internal sealed class InvocationBindingContext(
3334
public InteractionOptions InteractionOptions { get; } = interactionOptions;
3435

3536
public CancellationToken CancellationToken { get; } = cancellationToken;
37+
38+
public ImplicitServiceParameterRegistry ImplicitServiceParameters { get; } =
39+
implicitServiceParameters ?? throw new ArgumentNullException(nameof(implicitServiceParameters));
3640
}

src/Repl.Core/ParsingOptions.cs

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,16 +117,16 @@ public void AddGlobalOption<T>(string name, string[]? aliases = null, T? default
117117
public void AddGlobalOption(string name, string constraintOrTypeName, string[]? aliases = null, string? defaultValue = null) =>
118118
AddGlobalOptionCore(name, ResolveConstraintOrTypeName(constraintOrTypeName, _customRouteConstraints), aliases, defaultValue);
119119

120-
internal void AddGlobalOptionCore(string name, Type valueType, string[]? aliases, string? defaultValue)
120+
internal void AddGlobalOptionCore(string name, Type valueType, string[]? aliases, string? defaultValue, Type? ownerType = null)
121121
{
122122
name = string.IsNullOrWhiteSpace(name)
123123
? throw new ArgumentException("Global option name cannot be empty.", nameof(name))
124124
: name.Trim();
125125

126126
var normalizedCanonical = NormalizeLongToken(name);
127-
if (_globalOptions.ContainsKey(name))
127+
if (_globalOptions.TryGetValue(name, out var existing))
128128
{
129-
throw new InvalidOperationException($"A global option named '{name}' is already registered.");
129+
throw new InvalidOperationException(BuildDuplicateGlobalOptionMessage(name, existing.OwnerType, ownerType));
130130
}
131131

132132
var normalizedAliases = (aliases ?? [])
@@ -141,7 +141,24 @@ internal void AddGlobalOptionCore(string name, Type valueType, string[]? aliases
141141
CanonicalToken: normalizedCanonical,
142142
Aliases: normalizedAliases,
143143
DefaultValue: defaultValue,
144-
ValueType: valueType);
144+
ValueType: valueType,
145+
OwnerType: ownerType);
146+
}
147+
148+
private static string BuildDuplicateGlobalOptionMessage(string name, Type? existingOwner, Type? newOwner)
149+
{
150+
if (existingOwner is null && newOwner is null)
151+
{
152+
return $"A global option named '{name}' is already registered.";
153+
}
154+
155+
var existingSource = existingOwner is null
156+
? "another registration"
157+
: $"typed global options '{existingOwner.Name}'";
158+
var newSource = newOwner is null
159+
? "this registration"
160+
: $"typed global options '{newOwner.Name}'";
161+
return $"A global option named '{name}' is already registered by {existingSource} and cannot also be registered by {newSource}.";
145162
}
146163

147164
private static Type ResolveConstraintOrTypeName(

src/Repl.Core/Routing/RoutingEngine.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ internal async ValueTask<ContextValidationOutcome> ValidateContextAsync(
189189
app.OptionsSnapshot.Parsing.NumericFormatProvider,
190190
serviceProvider,
191191
app.OptionsSnapshot.Interaction,
192+
app.ImplicitServiceParameters,
192193
cancellationToken);
193194
var arguments = HandlerArgumentBinder.Bind(contextMatch.Context.Validation, bindingContext);
194195
var validationResult = await CommandInvoker
@@ -340,6 +341,7 @@ internal async ValueTask InvokeBannerAsync(
340341
numericFormatProvider: app.OptionsSnapshot.Parsing.NumericFormatProvider,
341342
serviceProvider: serviceProvider,
342343
interactionOptions: app.OptionsSnapshot.Interaction,
344+
implicitServiceParameters: app.ImplicitServiceParameters,
343345
cancellationToken: cancellationToken);
344346
var arguments = HandlerArgumentBinder.Bind(banner, bindingContext);
345347
var result = await CommandInvoker.InvokeAsync(banner, arguments).ConfigureAwait(false);

0 commit comments

Comments
 (0)