| | | 1 | | using System.Collections.Generic; |
| | | 2 | | using System.Linq; |
| | | 3 | | |
| | | 4 | | using Microsoft.CodeAnalysis; |
| | | 5 | | |
| | | 6 | | using NexusLabs.Needlr.Generators.Models; |
| | | 7 | | using NexusLabs.Needlr.Roslyn.Shared; |
| | | 8 | | |
| | | 9 | | namespace NexusLabs.Needlr.Generators; |
| | | 10 | | |
| | | 11 | | /// <summary> |
| | | 12 | | /// Discovers <c>[RegisterClosedOverImplementationsOf]</c> markers and expands each into concrete closed |
| | | 13 | | /// registrations — one per discovered concrete closed implementation of the designated open generic |
| | | 14 | | /// interface — by closing the composition type over the same type argument(s) via Roslyn |
| | | 15 | | /// <see cref="INamedTypeSymbol.Construct(ITypeSymbol[])"/> and resolving its constructor dependencies. |
| | | 16 | | /// </summary> |
| | | 17 | | internal static class ComposedRegistrationDiscoveryHelper |
| | | 18 | | { |
| | | 19 | | private const string AttributeName = "RegisterClosedOverImplementationsOfAttribute"; |
| | | 20 | | private const string AttributeNamespace = "NexusLabs.Needlr.Generators"; |
| | | 21 | | private const string FromKeyedServicesAttributeFullName = "Microsoft.Extensions.DependencyInjection.FromKeyedService |
| | | 22 | | |
| | 1 | 23 | | private static readonly SymbolDisplayFormat FullyQualified = SymbolDisplayFormat.FullyQualifiedFormat; |
| | | 24 | | |
| | | 25 | | /// <summary> |
| | | 26 | | /// A single discovered marker (before assembly/source metadata is attached by the caller). |
| | | 27 | | /// </summary> |
| | | 28 | | public readonly struct ComposedMarkerInfo |
| | | 29 | | { |
| | | 30 | | public ComposedMarkerInfo( |
| | | 31 | | INamedTypeSymbol compositionType, |
| | | 32 | | INamedTypeSymbol sourceOpenGenericInterface, |
| | | 33 | | INamedTypeSymbol? asServiceType, |
| | | 34 | | GeneratorLifetime lifetime) |
| | | 35 | | { |
| | 68 | 36 | | CompositionType = compositionType; |
| | 68 | 37 | | SourceOpenGenericInterface = sourceOpenGenericInterface; |
| | 68 | 38 | | AsServiceType = asServiceType; |
| | 68 | 39 | | Lifetime = lifetime; |
| | 68 | 40 | | } |
| | | 41 | | |
| | 68 | 42 | | public INamedTypeSymbol CompositionType { get; } |
| | 68 | 43 | | public INamedTypeSymbol SourceOpenGenericInterface { get; } |
| | 68 | 44 | | public INamedTypeSymbol? AsServiceType { get; } |
| | 68 | 45 | | public GeneratorLifetime Lifetime { get; } |
| | | 46 | | } |
| | | 47 | | |
| | | 48 | | /// <summary> |
| | | 49 | | /// Reads all valid <c>[RegisterClosedOverImplementationsOf]</c> attributes from a type. |
| | | 50 | | /// Invalid shapes (non-open-generic source interface, missing facade) are skipped here and surfaced |
| | | 51 | | /// to the user by the companion analyzer. |
| | | 52 | | /// </summary> |
| | | 53 | | public static IReadOnlyList<ComposedMarkerInfo> GetComposedMarkers(INamedTypeSymbol typeSymbol) |
| | | 54 | | { |
| | 1581865 | 55 | | var result = new List<ComposedMarkerInfo>(); |
| | | 56 | | |
| | 7600890 | 57 | | foreach (var attribute in typeSymbol.GetAttributes()) |
| | | 58 | | { |
| | 2218580 | 59 | | var attrClass = attribute.AttributeClass; |
| | 2218580 | 60 | | if (attrClass is null) |
| | | 61 | | continue; |
| | | 62 | | |
| | 2218580 | 63 | | if (attrClass.Name != AttributeName) |
| | | 64 | | continue; |
| | | 65 | | |
| | 68 | 66 | | if (attrClass.ContainingNamespace?.ToDisplayString() != AttributeNamespace) |
| | | 67 | | continue; |
| | | 68 | | |
| | 68 | 69 | | if (attribute.ConstructorArguments.Length < 1) |
| | | 70 | | continue; |
| | | 71 | | |
| | 68 | 72 | | if (attribute.ConstructorArguments[0].Value is not INamedTypeSymbol sourceInterface) |
| | | 73 | | continue; |
| | | 74 | | |
| | | 75 | | // Only open generic interfaces drive discovery; the analyzer reports other shapes. |
| | 68 | 76 | | if (!sourceInterface.IsUnboundGenericType || sourceInterface.TypeKind != TypeKind.Interface) |
| | | 77 | | continue; |
| | | 78 | | |
| | 68 | 79 | | INamedTypeSymbol? asServiceType = null; |
| | 68 | 80 | | var lifetime = GeneratorLifetime.Singleton; |
| | | 81 | | |
| | 276 | 82 | | foreach (var namedArg in attribute.NamedArguments) |
| | | 83 | | { |
| | 70 | 84 | | if (namedArg.Key == "As" && namedArg.Value.Value is INamedTypeSymbol asSymbol) |
| | | 85 | | { |
| | 68 | 86 | | asServiceType = asSymbol; |
| | | 87 | | } |
| | 2 | 88 | | else if (namedArg.Key == "Lifetime" && namedArg.Value.Value is int lifetimeValue) |
| | | 89 | | { |
| | 2 | 90 | | lifetime = (GeneratorLifetime)lifetimeValue; |
| | | 91 | | } |
| | | 92 | | } |
| | | 93 | | |
| | 68 | 94 | | result.Add(new ComposedMarkerInfo(typeSymbol, sourceInterface, asServiceType, lifetime)); |
| | | 95 | | } |
| | | 96 | | |
| | 1581865 | 97 | | return result; |
| | | 98 | | } |
| | | 99 | | |
| | | 100 | | /// <summary> |
| | | 101 | | /// Expands each marker into closed registrations using the discovered candidate implementation types, |
| | | 102 | | /// appending resolvable registrations to <paramref name="registrations"/> and constraint violations |
| | | 103 | | /// to <paramref name="violations"/>. |
| | | 104 | | /// </summary> |
| | | 105 | | public static void Expand( |
| | | 106 | | IReadOnlyList<DiscoveredComposedMarker> markers, |
| | | 107 | | IReadOnlyList<INamedTypeSymbol> candidateTypes, |
| | | 108 | | List<DiscoveredComposedRegistration> registrations, |
| | | 109 | | List<ComposedConstraintViolation> violations) |
| | | 110 | | { |
| | 270 | 111 | | foreach (var marker in markers) |
| | | 112 | | { |
| | | 113 | | // The facade is required; absence is reported by the analyzer, skip emission here. |
| | 68 | 114 | | if (marker.AsServiceType is null) |
| | | 115 | | continue; |
| | | 116 | | |
| | 68 | 117 | | var facadeTypeName = marker.AsServiceType.ToDisplayString(FullyQualified); |
| | | 118 | | |
| | | 119 | | // Distinct closed implementations of the source interface, ordered for deterministic output. |
| | 68 | 120 | | var closedInterfaces = FindClosedSourceInterfaces(marker.SourceOpenGenericInterface, candidateTypes) |
| | 38 | 121 | | .OrderBy(i => i.ToDisplayString(FullyQualified), System.StringComparer.Ordinal) |
| | 68 | 122 | | .ToList(); |
| | | 123 | | |
| | 312 | 124 | | foreach (var closedInterface in closedInterfaces) |
| | | 125 | | { |
| | 88 | 126 | | var typeArguments = closedInterface.TypeArguments; |
| | | 127 | | |
| | | 128 | | // Only fully closed implementations (concrete type arguments) participate. |
| | 183 | 129 | | if (typeArguments.Any(t => t.TypeKind == TypeKind.TypeParameter)) |
| | | 130 | | continue; |
| | | 131 | | |
| | | 132 | | // Arity between the source interface and the composition must align to close the composition. |
| | 88 | 133 | | if (typeArguments.Length != marker.CompositionType.TypeParameters.Length) |
| | | 134 | | continue; |
| | | 135 | | |
| | 88 | 136 | | if (!SatisfiesConstraints(marker.CompositionType, typeArguments)) |
| | | 137 | | { |
| | 34 | 138 | | violations.Add(new ComposedConstraintViolation( |
| | 34 | 139 | | marker.CompositionType.ToDisplayString(FullyQualified), |
| | 36 | 140 | | string.Join(", ", typeArguments.Select(t => t.ToDisplayString(FullyQualified))), |
| | 34 | 141 | | marker.SourceOpenGenericInterface.ToDisplayString(FullyQualified), |
| | 34 | 142 | | marker.SourceFilePath)); |
| | 34 | 143 | | continue; |
| | | 144 | | } |
| | | 145 | | |
| | 54 | 146 | | var closedComposition = marker.CompositionType.Construct(typeArguments.ToArray()); |
| | | 147 | | |
| | | 148 | | // A composition type using [GenerateConstructor]/field-triggered generation has |
| | | 149 | | // its effective constructor derived from the shared field model instead of |
| | | 150 | | // Roslyn's InstanceConstructors, which would otherwise only see the implicit |
| | | 151 | | // parameterless constructor visible before the sibling GeneratedConstructorGenerator |
| | | 152 | | // pass emits the real one within this compilation. |
| | 54 | 153 | | if (GeneratedConstructorEligibility.IsEligibleForGeneratedConstructor(closedComposition)) |
| | | 154 | | { |
| | 3 | 155 | | var generatedFields = GeneratedConstructorEligibility.GetEligibleConstructorFields(closedComposition |
| | 3 | 156 | | var generatedArguments = generatedFields |
| | 4 | 157 | | .Select(f => BuildResolutionExpression(f.Type, serviceKey: null)) |
| | 3 | 158 | | .ToList(); |
| | | 159 | | |
| | 3 | 160 | | registrations.Add(new DiscoveredComposedRegistration( |
| | 3 | 161 | | facadeTypeName, |
| | 3 | 162 | | closedComposition.ToDisplayString(FullyQualified), |
| | 3 | 163 | | generatedArguments, |
| | 3 | 164 | | marker.Lifetime, |
| | 3 | 165 | | marker.AssemblyName, |
| | 3 | 166 | | marker.SourceFilePath)); |
| | 3 | 167 | | continue; |
| | | 168 | | } |
| | | 169 | | |
| | 51 | 170 | | var constructor = SelectConstructor(closedComposition); |
| | 51 | 171 | | if (constructor is null) |
| | | 172 | | continue; |
| | | 173 | | |
| | 51 | 174 | | var arguments = constructor.Parameters |
| | 51 | 175 | | .Select(BuildResolutionExpression) |
| | 51 | 176 | | .ToList(); |
| | | 177 | | |
| | 51 | 178 | | registrations.Add(new DiscoveredComposedRegistration( |
| | 51 | 179 | | facadeTypeName, |
| | 51 | 180 | | closedComposition.ToDisplayString(FullyQualified), |
| | 51 | 181 | | arguments, |
| | 51 | 182 | | marker.Lifetime, |
| | 51 | 183 | | marker.AssemblyName, |
| | 51 | 184 | | marker.SourceFilePath)); |
| | | 185 | | } |
| | | 186 | | } |
| | 67 | 187 | | } |
| | | 188 | | |
| | | 189 | | private static List<INamedTypeSymbol> FindClosedSourceInterfaces( |
| | | 190 | | INamedTypeSymbol sourceOpenGenericInterface, |
| | | 191 | | IReadOnlyList<INamedTypeSymbol> candidateTypes) |
| | | 192 | | { |
| | 68 | 193 | | var sourceDefinition = sourceOpenGenericInterface.OriginalDefinition; |
| | 68 | 194 | | var result = new List<INamedTypeSymbol>(); |
| | 68 | 195 | | var seen = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default); |
| | | 196 | | |
| | 450 | 197 | | foreach (var candidate in candidateTypes) |
| | | 198 | | { |
| | 157 | 199 | | if (candidate.IsAbstract || candidate.TypeKind != TypeKind.Class) |
| | | 200 | | continue; |
| | | 201 | | |
| | 518 | 202 | | foreach (var iface in candidate.AllInterfaces) |
| | | 203 | | { |
| | 102 | 204 | | if (!iface.IsGenericType) |
| | | 205 | | continue; |
| | | 206 | | |
| | 94 | 207 | | if (!SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, sourceDefinition)) |
| | | 208 | | continue; |
| | | 209 | | |
| | 88 | 210 | | if (seen.Add(iface)) |
| | 88 | 211 | | result.Add(iface); |
| | | 212 | | } |
| | | 213 | | } |
| | | 214 | | |
| | 68 | 215 | | return result; |
| | | 216 | | } |
| | | 217 | | |
| | | 218 | | // A composition type is now handled above, before this method is reached, when it is |
| | | 219 | | // eligible for generated-constructor generation (see |
| | | 220 | | // GeneratedConstructorEligibility.IsEligibleForGeneratedConstructor in Expand). This |
| | | 221 | | // method remains the resolution path for a composition type with a hand-written |
| | | 222 | | // constructor. |
| | | 223 | | private static IMethodSymbol? SelectConstructor(INamedTypeSymbol closedComposition) |
| | | 224 | | { |
| | 51 | 225 | | IMethodSymbol? best = null; |
| | | 226 | | |
| | 204 | 227 | | foreach (var ctor in closedComposition.InstanceConstructors) |
| | | 228 | | { |
| | 51 | 229 | | if (ctor.IsStatic) |
| | | 230 | | continue; |
| | | 231 | | |
| | 51 | 232 | | if (ctor.DeclaredAccessibility != Accessibility.Public) |
| | | 233 | | continue; |
| | | 234 | | |
| | 51 | 235 | | if (best is null || ctor.Parameters.Length > best.Parameters.Length) |
| | 51 | 236 | | best = ctor; |
| | | 237 | | } |
| | | 238 | | |
| | 51 | 239 | | return best; |
| | | 240 | | } |
| | | 241 | | |
| | | 242 | | private static string BuildResolutionExpression(IParameterSymbol parameter) |
| | | 243 | | { |
| | 57 | 244 | | return BuildResolutionExpression(parameter.Type, GetFromKeyedServicesKey(parameter)); |
| | | 245 | | } |
| | | 246 | | |
| | | 247 | | /// <summary> |
| | | 248 | | /// Builds a <c>sp.GetRequiredService<T>()</c> (or keyed-service) resolution |
| | | 249 | | /// expression for a type, independent of whether it came from a hand-written |
| | | 250 | | /// constructor's <see cref="IParameterSymbol"/> or a generated constructor's |
| | | 251 | | /// <see cref="IFieldSymbol"/>. |
| | | 252 | | /// </summary> |
| | | 253 | | private static string BuildResolutionExpression(ITypeSymbol type, string? serviceKey) |
| | | 254 | | { |
| | 61 | 255 | | var typeName = type.ToDisplayString(FullyQualified); |
| | | 256 | | |
| | 61 | 257 | | return serviceKey is null |
| | 61 | 258 | | ? $"sp.GetRequiredService<{typeName}>()" |
| | 61 | 259 | | : $"sp.GetRequiredKeyedService<{typeName}>(\"{GeneratorHelpers.EscapeStringLiteral(serviceKey)}\")"; |
| | | 260 | | } |
| | | 261 | | |
| | | 262 | | private static string? GetFromKeyedServicesKey(IParameterSymbol parameter) |
| | | 263 | | { |
| | 116 | 264 | | foreach (var attr in parameter.GetAttributes()) |
| | | 265 | | { |
| | 2 | 266 | | if (attr.AttributeClass?.ToDisplayString() != FromKeyedServicesAttributeFullName) |
| | | 267 | | continue; |
| | | 268 | | |
| | 2 | 269 | | if (attr.ConstructorArguments.Length > 0 && attr.ConstructorArguments[0].Value is string keyValue) |
| | 2 | 270 | | return keyValue; |
| | | 271 | | } |
| | | 272 | | |
| | 55 | 273 | | return null; |
| | | 274 | | } |
| | | 275 | | |
| | | 276 | | private static bool SatisfiesConstraints( |
| | | 277 | | INamedTypeSymbol composition, |
| | | 278 | | System.Collections.Immutable.ImmutableArray<ITypeSymbol> typeArguments) |
| | | 279 | | { |
| | 88 | 280 | | var typeParameters = composition.TypeParameters; |
| | 88 | 281 | | if (typeParameters.Length != typeArguments.Length) |
| | 0 | 282 | | return false; |
| | | 283 | | |
| | 298 | 284 | | for (var i = 0; i < typeParameters.Length; i++) |
| | | 285 | | { |
| | 95 | 286 | | if (!SatisfiesConstraint(typeParameters[i], typeArguments[i])) |
| | 34 | 287 | | return false; |
| | | 288 | | } |
| | | 289 | | |
| | 54 | 290 | | return true; |
| | | 291 | | } |
| | | 292 | | |
| | | 293 | | private static bool SatisfiesConstraint( |
| | | 294 | | ITypeParameterSymbol typeParameter, |
| | | 295 | | ITypeSymbol typeArgument) |
| | | 296 | | { |
| | 95 | 297 | | if (typeParameter.HasReferenceTypeConstraint && !typeArgument.IsReferenceType) |
| | 6 | 298 | | return false; |
| | | 299 | | |
| | 89 | 300 | | if (typeParameter.HasValueTypeConstraint && |
| | 89 | 301 | | (!typeArgument.IsValueType || IsNullableValueType(typeArgument))) |
| | 6 | 302 | | return false; |
| | | 303 | | |
| | 83 | 304 | | if (typeParameter.HasNotNullConstraint && IsNullableValueType(typeArgument)) |
| | 4 | 305 | | return false; |
| | | 306 | | |
| | 79 | 307 | | if (typeParameter.HasUnmanagedTypeConstraint && !typeArgument.IsUnmanagedType) |
| | 2 | 308 | | return false; |
| | | 309 | | |
| | 77 | 310 | | if (typeParameter.HasConstructorConstraint && !SatisfiesNewConstraint(typeArgument)) |
| | 10 | 311 | | return false; |
| | | 312 | | |
| | 176 | 313 | | foreach (var constraintType in typeParameter.ConstraintTypes) |
| | | 314 | | { |
| | | 315 | | // Only non-generic constraint types are validated here: exact-match assignability is |
| | | 316 | | // variance-immune and reliable for them. Generic constraint types — whether self-referential |
| | | 317 | | // (where T : IComparable<T>), variant (where T : IProducer<Animal>), or invariant — are |
| | | 318 | | // deferred to the C# compiler so a variance/substitution subtlety can never skip a valid |
| | | 319 | | // registration. A bare type-parameter constraint (where T : U) is likewise deferred. |
| | 24 | 320 | | if (constraintType is INamedTypeSymbol { IsGenericType: true } || ContainsTypeParameter(constraintType)) |
| | | 321 | | continue; |
| | | 322 | | |
| | 16 | 323 | | if (!IsAssignableTo(typeArgument, constraintType)) |
| | 6 | 324 | | return false; |
| | | 325 | | } |
| | | 326 | | |
| | 61 | 327 | | return true; |
| | | 328 | | } |
| | | 329 | | |
| | | 330 | | private static bool IsAssignableTo(ITypeSymbol type, ITypeSymbol target) |
| | | 331 | | { |
| | 16 | 332 | | if (SymbolEqualityComparer.Default.Equals(type, target)) |
| | 2 | 333 | | return true; |
| | | 334 | | |
| | 14 | 335 | | if (target.TypeKind == TypeKind.Interface) |
| | 16 | 336 | | return type.AllInterfaces.Any(i => SymbolEqualityComparer.Default.Equals(i, target)); |
| | | 337 | | |
| | 12 | 338 | | for (var baseType = type.BaseType; baseType is not null; baseType = baseType.BaseType) |
| | | 339 | | { |
| | 4 | 340 | | if (SymbolEqualityComparer.Default.Equals(baseType, target)) |
| | 2 | 341 | | return true; |
| | | 342 | | } |
| | | 343 | | |
| | 2 | 344 | | return false; |
| | | 345 | | } |
| | | 346 | | |
| | | 347 | | private static bool SatisfiesNewConstraint(ITypeSymbol typeArgument) |
| | | 348 | | { |
| | 18 | 349 | | if (typeArgument.IsValueType) |
| | 2 | 350 | | return true; |
| | | 351 | | |
| | 16 | 352 | | if (typeArgument is not INamedTypeSymbol named) |
| | 2 | 353 | | return false; |
| | | 354 | | |
| | 14 | 355 | | if (named.IsAbstract) |
| | 2 | 356 | | return false; |
| | | 357 | | |
| | 12 | 358 | | return named.InstanceConstructors.Any(c => |
| | 24 | 359 | | !c.IsStatic && |
| | 24 | 360 | | c.Parameters.Length == 0 && |
| | 24 | 361 | | c.DeclaredAccessibility == Accessibility.Public); |
| | | 362 | | } |
| | | 363 | | |
| | | 364 | | private static bool IsNullableValueType(ITypeSymbol typeArgument) => |
| | 20 | 365 | | typeArgument is INamedTypeSymbol named && |
| | 20 | 366 | | named.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T; |
| | | 367 | | |
| | | 368 | | private static bool ContainsTypeParameter(ITypeSymbol type) |
| | | 369 | | { |
| | 18 | 370 | | if (type.TypeKind == TypeKind.TypeParameter) |
| | 2 | 371 | | return true; |
| | | 372 | | |
| | 16 | 373 | | return type is INamedTypeSymbol named && |
| | 16 | 374 | | named.TypeArguments.Any(ContainsTypeParameter); |
| | | 375 | | } |
| | | 376 | | } |