diff --git a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md index 22582a1..048f9ed 100644 --- a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -24,3 +24,5 @@ AWT120 | Awaiten | Error | A synchronous Func/Lazy/Owned relationship reaches an async-tainted service transitively AWT121 | Awaiten | Error | An Owned disposal handle is requested through a Lazy> or Lazy>> relationship AWT122 | Awaiten | Error | A collection dependency has an async-tainted member but is materialized synchronously + AWT123 | Awaiten | Error | A [Decorate] names a service with no registration to decorate + AWT124 | Awaiten | Error | A decorator has no single constructor parameter assignable to the decorated service type diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs index 2711a71..0a6def8 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs @@ -200,6 +200,7 @@ internal static GraphModel BuildGraph( compilation.GetTypeByMetadataName("Awaiten.IAsyncInitializable")); List raw = ContainerRegistrations.Collect(containerSymbol); + List decorators = ContainerRegistrations.CollectDecorators(containerSymbol); // Coalesce registrations by (service type, key): the first registration per key wins, and // registrations of the same implementation share one instance. Declaring one implementation with @@ -208,6 +209,17 @@ internal static GraphModel BuildGraph( (List implOrder, Dictionary serviceToImpl, Dictionary> serviceMembers, List serviceMemberOrder) = CoalesceByImplementation(raw, diagnostics); + // Decorator chains: for each [Decorate]d service, move the base implementation(s) onto a synthetic key + // and register each decorator as a chain link whose inner parameter is redirected to the next-lower key, + // rewriting the public winner and collection membership to the outermost decorator. decoratorInner is + // consumed by ClassifyParameters below to key each link's inner parameter to the link it wraps. + Dictionary decoratorInner = new(StringComparer.Ordinal); + if (decorators.Count > 0) + { + new DecoratorChainBuilder(containerSymbol, compilation, serviceToImpl, implOrder, serviceMembers, decoratorInner, diagnostics) + .Build(decorators); + } + List instances = new(); List instanceLocations = new(); Dictionary implToIndex = new(StringComparer.Ordinal); @@ -216,7 +228,7 @@ internal static GraphModel BuildGraph( foreach (ImplInfo info in implOrder) { cancellationToken.ThrowIfCancellationRequested(); - InstanceModel? instance = BuildInstance(info, containerSymbol, compilation, serviceToImpl, wellKnown, diagnostics); + InstanceModel? instance = BuildInstance(info, containerSymbol, compilation, serviceToImpl, decoratorInner, wellKnown, diagnostics); if (instance is not null) { implToIndex[info.ImplementationType] = instances.Count; @@ -692,11 +704,331 @@ private static void AddCollectionMemberEdges( } } + // A decorator chain link's synthetic key and identity are built from this prefix; DisplayInstance also keys + // off it to trim the synthetic suffix out of diagnostics. Kept on the enclosing type so both the nested + // DecoratorChainBuilder and DisplayInstance can reach it (a nested type's private member is not visible here). + private const string DecoratorKeyPrefix = "__dec:"; + + /// + /// Builds the decorator chains after coalescing. For each decorated service it locates the base + /// implementation(s) — every collection member, or the single-dispatch winner — and over each base impl + /// synthesizes a chain of synthetic-keyed links: the base implementation is moved onto a synthetic key + /// (__dec:S:k:0), each decorator is registered as a fresh instance whose single inner parameter is + /// redirected to the next-lower key, and the public unkeyed winner is rewritten to the outermost decorator. + /// The existing keyed resolver then produces D2(D1(Real)) for free. The collection membership of the + /// service is rewritten so a collection view yields the decorated chain, never the bare base impl (the + /// decorator is unbypassable). Reports AWT123 (nothing to decorate) and AWT124 (no single inner parameter). + /// + /// + /// Holds the mutable graph state (the coalesced serviceToImpl, implOrder, collection + /// membership and the decorator-inner map) as fields so the chain-building steps read as small focused + /// methods rather than one deeply nested loop. + /// + private sealed class DecoratorChainBuilder + { + private readonly INamedTypeSymbol _containerSymbol; + private readonly Compilation _compilation; + private readonly Dictionary _serviceToImpl; + private readonly List _implOrder; + private readonly Dictionary> _serviceMembers; + private readonly Dictionary _decoratorInner; + private readonly List _diagnostics; + + // The coalesced implementations by identity, so a base impl's ImplInfo can be moved onto a synthetic key + // and each new chain link's ImplInfo can be appended for BuildInstance to build. + private readonly Dictionary _byImpl; + + public DecoratorChainBuilder( + INamedTypeSymbol containerSymbol, + Compilation compilation, + Dictionary serviceToImpl, + List implOrder, + Dictionary> serviceMembers, + Dictionary decoratorInner, + List diagnostics) + { + _containerSymbol = containerSymbol; + _compilation = compilation; + _serviceToImpl = serviceToImpl; + _implOrder = implOrder; + _serviceMembers = serviceMembers; + _decoratorInner = decoratorInner; + _diagnostics = diagnostics; + + _byImpl = new Dictionary(StringComparer.Ordinal); + foreach (ImplInfo info in implOrder) + { + _byImpl[info.ImplementationType] = info; + } + } + + public void Build(List decorators) + { + foreach ((string service, List chain) in GroupByService(decorators)) + { + ProcessService(service, chain); + } + } + + // Group decorators by decorated service, preserving first-seen service order for determinism. + private static List<(string Service, List Chain)> GroupByService(List decorators) + { + List<(string, List)> ordered = new(); + Dictionary> byService = new(StringComparer.Ordinal); + foreach (DecorateRegistration decorator in decorators) + { + if (!byService.TryGetValue(decorator.Service, out List? list)) + { + list = new List(); + byService.Add(decorator.Service, list); + ordered.Add((decorator.Service, list)); + } + + list.Add(decorator); + } + + return ordered; + } + + // The base implementations to wrap: every unkeyed collection member (so the collection view is also + // decorated), or the single-dispatch winner when the service has no collection membership. + private static List CollectBaseImpls(List? members, string? winner) + { + if (members is { Count: > 0, }) + { + return new List(members); + } + + return winner is null ? new List() : new List { winner, }; + } + + private void ProcessService(string service, List chain) + { + ServiceKey publicKey = new(service, null); + _serviceToImpl.TryGetValue(publicKey, out string? winner); + List? members = _serviceMembers.TryGetValue(publicKey, out List? m) ? m : null; + List baseImpls = CollectBaseImpls(members, winner); + + // AWT123: the decorated service has no registration to wrap. + if (baseImpls.Count == 0) + { + Report(Diagnostics.DecoratedServiceNotRegistered, chain[0].Location, + service, chain[0].Decorator.ToDisplayString(FullyQualified)); + return; + } + + // Order the chain by (Order, declaration) — innermost first, outermost last. + List ordered = chain + .OrderBy(d => d.Order) + .ThenBy(d => d.DeclarationOrder) + .ToList(); + + // AWT124: each decorator must have exactly one constructor parameter assignable to the service. + List? innerParameterTypes = ResolveInnerParameterTypes(service, ordered); + if (innerParameterTypes is null) + { + return; + } + + ServiceChain sc = new(service, winner, members, ordered, innerParameterTypes); + for (int k = 0; k < baseImpls.Count; k++) + { + WrapBaseImpl(sc, baseImpls[k], k); + } + } + + // Each decorator's inner-parameter type in chain order, or null (having reported AWT124) when any + // decorator has no single constructor parameter that can receive the inner instance. + private List? ResolveInnerParameterTypes(string service, List ordered) + { + List innerParameterTypes = new(); + bool valid = true; + foreach (DecorateRegistration decorator in ordered) + { + string? innerType = SingleInnerParameterType(decorator.Decorator, decorator.ServiceSymbol); + if (innerType is null) + { + Report(Diagnostics.DecoratorMissingInnerParameter, decorator.Location, + decorator.Decorator.ToDisplayString(FullyQualified), service); + valid = false; + continue; + } + + innerParameterTypes.Add(innerType); + } + + return valid ? innerParameterTypes : null; + } + + private void WrapBaseImpl(ServiceChain chain, string baseImpl, int baseIndex) + { + string service = chain.Service; + bool isWinnerChain = baseImpl == chain.Winner; + + // Move the base implementation off the public service onto the chain's lowest synthetic key, so the + // first decorator reaches it by key and the public dispatch no longer hits it directly. + string baseKey = DecoratorKey(service, baseIndex, 0); + MoveBaseToSyntheticKey(service, baseImpl, baseKey); + + ImplInfo? baseInfo = _byImpl.TryGetValue(baseImpl, out ImplInfo? bi) ? bi : null; + Lifetime lifetime = baseInfo?.Lifetime ?? Lifetime.Transient; + LocationInfo? location = baseInfo?.Location; + + // Register each decorator as a fresh instance with a synthetic identity (so one decorator type can + // wrap several base impls as distinct instances), inheriting the base registration's lifetime. + string innerKey = baseKey; + string outermostIdentity = baseImpl; + for (int i = 0; i < chain.Ordered.Count; i++) + { + bool isPublic = isWinnerChain && i == chain.Ordered.Count - 1; + outermostIdentity = AddChainLink(chain, baseIndex, i, isPublic, lifetime, location, innerKey); + innerKey = DecoratorKey(service, baseIndex, i + 1); + } + + // Collection membership is unbypassable: replace the base impl with the chain's outermost decorator + // identity, so a collection view yields the full chain as a single element rather than the bare impl. + RewriteCollectionMembership(chain.Members, baseImpl, outermostIdentity); + } + + // Moves the base implementation off the public service onto the chain's lowest synthetic key. + private void MoveBaseToSyntheticKey(string service, string baseImpl, string baseKey) + { + if (_byImpl.TryGetValue(baseImpl, out ImplInfo? baseInfo)) + { + baseInfo.Services.Remove(new ServiceKey(service, null)); + ServiceKey synthetic = new(service, baseKey); + if (!baseInfo.Services.Contains(synthetic)) + { + baseInfo.Services.Add(synthetic); + } + } + + _serviceToImpl[new ServiceKey(service, baseKey)] = baseImpl; + } + + // Registers one chain link (the decorator at ) and returns its synthetic + // identity. The outermost link of the winner chain () takes the public + // service key; every other link is keyed so it is reached only as the inner of the link above it (or as + // a rewritten collection member). + private string AddChainLink(ServiceChain chain, int baseIndex, int linkIndex, bool isPublic, Lifetime lifetime, LocationInfo? location, string innerKey) + { + string service = chain.Service; + int link = linkIndex + 1; + INamedTypeSymbol decorator = chain.Ordered[linkIndex].Decorator; + string identity = DecoratorIdentity(decorator.ToDisplayString(FullyQualified), service, baseIndex, link); + + ImplInfo info = new(identity, decorator, lifetime, location, ProductionKind.Constructor, null); + _byImpl[identity] = info; + _implOrder.Add(info); + + ServiceKey ownKey = isPublic + ? new ServiceKey(service, null) + : new ServiceKey(service, DecoratorKey(service, baseIndex, link)); + info.Services.Add(ownKey); + _serviceToImpl[ownKey] = identity; + + // Redirect this link's inner parameter to the link below it. The chain links are all registered under + // the decorated service type, so the redirect keys to (service, innerKey) - not to the parameter's own + // declared type, which may be a base of the service and is not a registration key. + _decoratorInner[identity] = new DecoratorInner(chain.InnerParameterTypes[linkIndex], service, innerKey); + + return identity; + } + + private static void RewriteCollectionMembership(List? members, string baseImpl, string outermostIdentity) + { + int index = members?.IndexOf(baseImpl) ?? -1; + if (index >= 0) + { + members![index] = outermostIdentity; + } + } + + private void Report(DiagnosticDescriptor descriptor, Location? location, params string[] messageArgs) + { + string[] displayed = new string[messageArgs.Length]; + for (int i = 0; i < messageArgs.Length; i++) + { + displayed[i] = Display(messageArgs[i]); + } + + _diagnostics.Add(new DiagnosticInfo(descriptor, LocationInfo.From(location), new EquatableArray(displayed))); + } + + // The per-service state threaded through WrapBaseImpl / AddChainLink: the decorated service, its + // single-dispatch winner (if any), its collection membership (if any), the decorators innermost-first, + // and each decorator's inner-parameter type (positionally matching Ordered). + private readonly record struct ServiceChain( + string Service, + string? Winner, + List? Members, + List Ordered, + List InnerParameterTypes); + + /// + /// The fully-qualified type of a decorator's single constructor parameter that receives the inner + /// instance, or when there is none or it is ambiguous (AWT124). The constructor + /// is chosen by the same the container uses to build the decorator, so + /// this validation can never inspect a different constructor than the one constructed - a divergence + /// would leave the inner parameter un-redirected and the link resolving itself. The returned type string + /// is what produces for that parameter, so the inner-parameter redirect + /// in can match it. + /// + private string? SingleInnerParameterType(INamedTypeSymbol decorator, INamedTypeSymbol service) + { + IMethodSymbol? constructor = SelectConstructor(decorator, _containerSymbol, _serviceToImpl.Keys.Select(k => k.Service)); + if (constructor is null) + { + return null; + } + + // The inner is an instance of the decorated service, so its parameter must accept it: the service is + // implicitly convertible to the parameter's type (the parameter is the service, or a base of it). A + // [FromKey] parameter is excluded - it deliberately selects a specific keyed registration, so it is a + // separate dependency, never the chain inner (which is redirected by key and would ignore the [FromKey] + // anyway). More than one parameter can be assignable at once - e.g. a plain `object` state parameter + // alongside the inner - so the inner is the most-derived of them: the one every other assignable + // parameter is a base of. Exactly one such maximum makes the inner unambiguous; a tie (two + // equally-derived assignable parameters, e.g. two `IService`) is genuinely ambiguous and reported as + // AWT124, as is a decorator whose only service-assignable parameter is [FromKey]-ed. + List assignable = constructor.Parameters + .Where(p => FromKey(p) is null && _compilation.HasImplicitConversion(service, p.Type)) + .ToList(); + + IParameterSymbol? inner = null; + foreach (IParameterSymbol candidate in assignable) + { + bool isMaximum = assignable.All(other => + ReferenceEquals(other, candidate) || _compilation.HasImplicitConversion(candidate.Type, other.Type)); + if (!isMaximum) + { + continue; + } + + if (inner is not null) + { + return null; + } + + inner = candidate; + } + + return inner?.Type.ToDisplayString(FullyQualified); + } + + private static string DecoratorKey(string service, int baseIndex, int link) + => $"{DecoratorKeyPrefix}{service}:{baseIndex}:{link}"; + + private static string DecoratorIdentity(string decoratorType, string service, int baseIndex, int link) + => $"{decoratorType}@{DecoratorKeyPrefix}{service}:{baseIndex}:{link}"; + } + private static InstanceModel? BuildInstance( ImplInfo info, INamedTypeSymbol containerSymbol, Compilation compilation, Dictionary serviceToImpl, + Dictionary decoratorInner, WellKnownTypes wellKnown, List diagnostics) { @@ -743,7 +1075,7 @@ private static void AddCollectionMemberEdges( // A factory's parameters resolve from the graph exactly like a constructor's. An async factory // additionally forwards the resolve-time CancellationToken (the async creator's) into a matching // parameter rather than resolving it from the graph. - List parameters = ClassifyParameters(producer, info, asyncFactory, serviceToImpl, diagnostics); + List parameters = ClassifyParameters(producer, info, asyncFactory, serviceToImpl, decoratorInner, diagnostics); // Disposability follows the type the container actually owns: a factory's produced type (which may // implement IDisposable behind a non-disposable service interface; for an async factory this is the @@ -791,6 +1123,11 @@ private static void AddCollectionMemberEdges( ReportFactoryHidingAsyncInitialization(producer, compilation, wellKnown.AsyncInitializable, diagnostics); } + // A decorator chain link carries a synthetic ImplementationType identity (so one decorator type can be + // several distinct instances); the real type to construct is then its symbol, kept apart in EmitType. + string realType = info.Symbol.ToDisplayString(FullyQualified); + string? emitType = info.ImplementationType == realType ? null : realType; + return new InstanceModel( info.ImplementationType, info.Symbol.Name, @@ -804,7 +1141,8 @@ private static void AddCollectionMemberEdges( asyncInit, IsAsyncFactory: asyncFactory, RuntimeDisposalCheck: runtimeDisposalCheck, - IsAsyncDisposable: asyncDisposable); + IsAsyncDisposable: asyncDisposable, + EmitType: emitType); static bool ImplementsInterface(ITypeSymbol type, INamedTypeSymbol @interface) { @@ -988,6 +1326,30 @@ private static IEnumerable CollectReturnExpressions(MethodDecl } } + /// + /// Redirects a decorator chain link's single inner parameter (a direct, unkeyed dependency assignable to + /// the decorated service type) to the synthetic key of the next-lower link - so the keyed resolver + /// produces D2(D1(Real)) rather than the link resolving its own public service (itself) and + /// recursing. Both the service type and key are rewritten: the links are registered under the decorated + /// service type, so a parameter declared as a base of the service still resolves the link (its own + /// declared type is not a registration key). A non-link instance, or any other parameter, is returned + /// unchanged and resolves from the graph. + /// + private static ParameterModel RedirectDecoratorInner( + ParameterModel parameterModel, + ImplInfo info, + Dictionary decoratorInner) + { + if (parameterModel is { Kind: DependencyKind.Direct, Key: null, } + && decoratorInner.TryGetValue(info.ImplementationType, out DecoratorInner inner) + && parameterModel.ServiceType == inner.ParameterServiceType) + { + return parameterModel with { ServiceType = inner.ServiceType, Key = inner.InnerKey, }; + } + + return parameterModel; + } + /// /// Classifies the producer's parameters (a constructor's or a factory method's) and reports /// AWT101 for any non-[Arg] parameter whose @@ -999,12 +1361,14 @@ private static List ClassifyParameters( ImplInfo info, bool asyncFactory, Dictionary serviceToImpl, + Dictionary decoratorInner, List diagnostics) { List parameters = new(); foreach (IParameterSymbol parameter in producer.Parameters) { ParameterModel parameterModel = ClassifyParameter(parameter, asyncFactory); + parameterModel = RedirectDecoratorInner(parameterModel, info, decoratorInner); // A collection type (IEnumerable and friends, or T[]) is normally synthesized from the registrations // of its element type under the parameter's key. But if any collection shape of that element type is @@ -1042,7 +1406,7 @@ private static List ClassifyParameters( info.Location, new EquatableArray([ Display(info.OwningServiceOrImpl), - Display(info.ImplementationType), + DisplayInstance(info.ImplementationType), DisplayKeyed(parameterModel.ServiceType, parameterModel.Key), ]))); } @@ -1444,9 +1808,9 @@ private static void DetectSynchronousAsyncResolution( Diagnostics.SynchronousAsyncResolution, location, new EquatableArray([ - Display(instances[i].ImplementationType), + DisplayInstance(instances[i].ImplementationType), parameter.Kind.ToString(), - Display(instances[target].ImplementationType), + DisplayInstance(instances[target].ImplementationType), ]))); } else @@ -1456,7 +1820,7 @@ private static void DetectSynchronousAsyncResolution( Diagnostics.AsyncDependencyOnSyncPath, location, new EquatableArray([ - Display(instances[i].ImplementationType), + DisplayInstance(instances[i].ImplementationType), path, ]))); } @@ -1526,9 +1890,9 @@ private static void ReportAsyncTaintedMembers( Diagnostics.AsyncCollectionResolution, instanceLocations[consumer], new EquatableArray([ - Display(instances[consumer].ImplementationType), + DisplayInstance(instances[consumer].ImplementationType), Display(parameter.ServiceType), - Display(instances[memberIndex].ImplementationType), + DisplayInstance(instances[memberIndex].ImplementationType), ]))); } } @@ -1570,7 +1934,7 @@ private static string AsyncTaintPath(List instances, Dictionary ", chain.Select(index => Display(instances[index].ImplementationType))); + return string.Join(" -> ", chain.Select(index => DisplayInstance(instances[index].ImplementationType))); } // Whether a type is a System.Threading.Tasks.Task, yielding its result type T. Used to recognize the @@ -1774,7 +2138,7 @@ private static void ValidateDependency( diagnostics.Add(new DiagnosticInfo( Diagnostics.ParameterizedRequiresFunc, location, - new EquatableArray([Display(parameter.ServiceType), Display(consumer.ImplementationType),]))); + new EquatableArray([Display(parameter.ServiceType), DisplayInstance(consumer.ImplementationType),]))); } } @@ -1832,7 +2196,7 @@ private static void ReportCapturedScoped( Diagnostics.CaptiveDependency, instanceLocations[singleton], new EquatableArray([ - Display(instances[singleton].ImplementationType), + DisplayInstance(instances[singleton].ImplementationType), DisplayKeyed(referenced.Service, referenced.Key), ]))); break; @@ -1918,7 +2282,7 @@ void ReportCycle(int cycleStart) return; } - string rendered = string.Join(" -> ", cycle.Select(index => Display(instances[index].ImplementationType))); + string rendered = string.Join(" -> ", cycle.Select(index => DisplayInstance(instances[index].ImplementationType))); diagnostics.Add(new DiagnosticInfo( Diagnostics.DependencyCycle, containerLocation, @@ -1941,6 +2305,25 @@ private static string KeywordOf(INamedTypeSymbol symbol) internal static string Display(string fullyQualified) => fullyQualified.Replace("global::", string.Empty); + // Renders an instance identity for diagnostics. A decorator chain link carries a synthetic + // '@__dec:…' identity (see DecoratorIdentity); trim the synthetic suffix so an error names the real + // decorator type ('MyCode.Deco') rather than the internal key ('MyCode.Deco@__dec:MyCode.IService:0:1'). + internal static string DisplayInstance(string implementationType) + { + int marker = implementationType.IndexOf("@" + DecoratorKeyPrefix, StringComparison.Ordinal); + return Display(marker >= 0 ? implementationType.Substring(0, marker) : implementationType); + } + + /// + /// Redirects a decorator instance's inner parameter to the next-lower chain link. + /// is the parameter's own declared type (as classifies it), used to pick the single + /// inner parameter out of the constructor. is the decorated service type - the type under + /// which every chain link is registered - so the redirect keys the parameter to (ServiceType, InnerKey) even + /// when the parameter is declared as a base type of the service (its declared type is not a registration key). + /// Consumed by when it classifies the decorator link. + /// + private readonly record struct DecoratorInner(string ParameterServiceType, string ServiceType, string InnerKey); + private sealed class ImplInfo { public ImplInfo( diff --git a/Source/Awaiten.SourceGenerators/ContainerRegistrations.cs b/Source/Awaiten.SourceGenerators/ContainerRegistrations.cs index 8c26320..2edf3ef 100644 --- a/Source/Awaiten.SourceGenerators/ContainerRegistrations.cs +++ b/Source/Awaiten.SourceGenerators/ContainerRegistrations.cs @@ -68,6 +68,46 @@ public static List Collect(INamedTypeSymbol containerSymbol) return result; } + /// + /// Reads the [Decorate<TDecorator, TService>] registrations declared on a container, in + /// declaration order. Each carries its declaration index so equal Order values fall back to + /// declaration order when the chain is built. Collected apart from the lifetime registrations because a + /// decorator wraps an existing registration after coalescing rather than introducing a new service. + /// + public static List CollectDecorators(INamedTypeSymbol containerSymbol) + { + List result = new(); + foreach (AttributeData attribute in containerSymbol.GetAttributes()) + { + if (attribute.AttributeClass is not { Name: "DecorateAttribute", IsGenericType: true, TypeArguments.Length: 2, } attributeClass + || attributeClass.ContainingNamespace?.ToDisplayString() != AttributeNamespace + || attributeClass.TypeArguments[0] is not INamedTypeSymbol decorator + || attributeClass.TypeArguments[1] is not INamedTypeSymbol service) + { + continue; + } + + int order = 0; + foreach (KeyValuePair argument in attribute.NamedArguments) + { + if (argument.Key == "Order" && argument.Value.Value is int value) + { + order = value; + } + } + + result.Add(new DecorateRegistration( + service.ToDisplayString(FullyQualified), + service, + decorator, + order, + result.Count, + attribute.ApplicationSyntaxReference?.GetSyntax().GetLocation())); + } + + return result; + } + /// /// Resolves how a registration produces its instance. A Factory argument names a container /// method that produces it; an Instance argument names a pre-built container member to diff --git a/Source/Awaiten.SourceGenerators/DecorateRegistration.cs b/Source/Awaiten.SourceGenerators/DecorateRegistration.cs new file mode 100644 index 0000000..09cc0f7 --- /dev/null +++ b/Source/Awaiten.SourceGenerators/DecorateRegistration.cs @@ -0,0 +1,24 @@ +using Microsoft.CodeAnalysis; + +namespace Awaiten.SourceGenerators; + +/// +/// A single [Decorate<TDecorator, TService>] registration read from a container: the +/// decorated service type name, the service and decorator symbols, the requested chain +/// , and the declaration index used to break ties between equal orders (so +/// decorators chain in declaration order by default). Collected apart from the lifetime registrations +/// and expanded after coalescing by AwaitenGenerator.BuildDecoratorChains into synthetic-keyed +/// chain links. +/// +/// +/// Like this is an intermediate type consumed within a single analysis +/// pass, so it carries the live Roslyn (not an equatable +/// LocationInfo) and never flows through the generator's incremental cache. +/// +internal sealed record DecorateRegistration( + string Service, + INamedTypeSymbol ServiceSymbol, + INamedTypeSymbol Decorator, + int Order, + int DeclarationOrder, + Location? Location); diff --git a/Source/Awaiten.SourceGenerators/Diagnostics.cs b/Source/Awaiten.SourceGenerators/Diagnostics.cs index f0f8f6a..9b9eb52 100644 --- a/Source/Awaiten.SourceGenerators/Diagnostics.cs +++ b/Source/Awaiten.SourceGenerators/Diagnostics.cs @@ -331,4 +331,29 @@ internal static class Diagnostics "Awaiten", DiagnosticSeverity.Error, isEnabledByDefault: true); + + /// + /// A [Decorate<TDecorator, TService>] names a service that has no registration to + /// decorate, so there is no inner implementation to wrap. + /// + public static readonly DiagnosticDescriptor DecoratedServiceNotRegistered = new( + "AWT123", + "Decorated service not registered", + "'{0}' is decorated by '{1}' but has no registration to decorate; register the service before decorating it", + "Awaiten", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + /// + /// A decorator's constructor has no — or more than one ambiguous — parameter assignable to the + /// decorated service type, so Awaiten cannot tell which parameter receives the inner instance + /// (e.g. LoggingDecorator(IService a, IService b) or one that takes no IService at all). + /// + public static readonly DiagnosticDescriptor DecoratorMissingInnerParameter = new( + "AWT124", + "Decorator has no single inner parameter", + "The decorator '{0}' must have exactly one constructor parameter assignable to the decorated service '{1}'; it has none or several", + "Awaiten", + DiagnosticSeverity.Error, + isEnabledByDefault: true); } diff --git a/Source/Awaiten.SourceGenerators/Emitter.cs b/Source/Awaiten.SourceGenerators/Emitter.cs index 956c9ca..aeff1f6 100644 --- a/Source/Awaiten.SourceGenerators/Emitter.cs +++ b/Source/Awaiten.SourceGenerators/Emitter.cs @@ -619,7 +619,7 @@ private static void EmitCacheFields(StringBuilder builder, int depth, InstanceMo if (!instance.IsAsyncTainted) { string modifier = instance.IsReferenceType ? "private volatile " : "private "; - Indent(builder, depth).Append(modifier).Append(instance.ImplementationType) + Indent(builder, depth).Append(modifier).Append(instance.ConstructedType) .Append("? ").Append(names.Field(i)).AppendLine(";"); } @@ -627,7 +627,7 @@ private static void EmitCacheFields(StringBuilder builder, int depth, InstanceMo // once; it is volatile so the lock-free read cannot observe it before its writes are visible. if (instance.IsAsyncTainted) { - Indent(builder, depth).Append("private volatile global::System.Threading.Tasks.Task<").Append(instance.ImplementationType) + Indent(builder, depth).Append("private volatile global::System.Threading.Tasks.Task<").Append(instance.ConstructedType) .Append(">? ").Append(names.AsyncField(i)).AppendLine(";"); } } @@ -1158,7 +1158,7 @@ private static void EmitScopeResolver(StringBuilder builder, int depth, int inde InstanceModel instance = context.Instances[index]; Names names = context.Names; bool asyncDisposal = context.AsyncDisposal; - string type = instance.ImplementationType; + string type = instance.ConstructedType; string resolver = names.Resolver(index); if (instance.IsParameterized) @@ -1314,7 +1314,7 @@ private static void EmitRootResolver(StringBuilder builder, int depth, int index { InstanceModel instance = context.Instances[index]; Names names = context.Names; - string type = instance.ImplementationType; + string type = instance.ConstructedType; string resolver = names.Resolver(index); if (instance.Production == ProductionKind.Instance) @@ -1433,7 +1433,7 @@ private static void EmitAsyncScopeResolver(StringBuilder builder, int depth, int if (instance.Lifetime == Lifetime.Singleton) { - Indent(builder, depth).Append("protected virtual ").Append(task).Append('<').Append(instance.ImplementationType) + Indent(builder, depth).Append("protected virtual ").Append(task).Append('<').Append(instance.ConstructedType) .Append("> ").Append(names.AsyncResolver(index)).Append('(').Append(ct).Append(") => __root.") .Append(names.AsyncResolver(index)).AppendLine("(cancellationToken);"); return; @@ -1467,7 +1467,7 @@ private static void EmitDelegatingSyncResolver(StringBuilder builder, int depth, string signature = string.Join(", ", argTypes.Select((t, i) => $"{t} a{i}")); string forward = string.Join("", argTypes.Select((_, i) => "a" + i + ", ")); - Indent(builder, depth).Append("internal ").Append(instance.ImplementationType).Append(' ') + Indent(builder, depth).Append("internal ").Append(instance.ConstructedType).Append(' ') .Append(names.Resolver(index)).Append('(').Append(signature).AppendLine(")"); Indent(builder, depth).AppendLine("{"); EmitDisposedGuard(builder, depth + 1); @@ -1499,7 +1499,7 @@ private static void EmitAsyncCachingResolver(StringBuilder builder, int depth, i { InstanceModel instance = context.Instances[index]; Names names = context.Names; - string type = instance.ImplementationType; + string type = instance.ConstructedType; string asyncResolver = names.AsyncResolver(index); string asyncField = names.AsyncField(index); string creator = names.AsyncCreator(index); @@ -1553,7 +1553,7 @@ private static void EmitAsyncFreshResolver(StringBuilder builder, int depth, int { InstanceModel instance = context.Instances[index]; Names names = context.Names; - string type = instance.ImplementationType; + string type = instance.ConstructedType; const string task = "global::System.Threading.Tasks.Task"; const string ct = "global::System.Threading.CancellationToken cancellationToken"; @@ -1827,7 +1827,7 @@ private static string EmitConstruction(InstanceModel instance, InstanceModel[] i return $"{instance.ProductionMember}({arguments})"; } - return $"new {instance.ImplementationType}({arguments})"; + return $"new {instance.ConstructedType}({arguments})"; } /// diff --git a/Source/Awaiten.SourceGenerators/Internals/InstanceModel.cs b/Source/Awaiten.SourceGenerators/Internals/InstanceModel.cs index 54c4bf0..db006df 100644 --- a/Source/Awaiten.SourceGenerators/Internals/InstanceModel.cs +++ b/Source/Awaiten.SourceGenerators/Internals/InstanceModel.cs @@ -41,8 +41,19 @@ internal sealed record InstanceModel( bool IsAsyncTainted = false, bool IsAsyncFactory = false, bool RuntimeDisposalCheck = false, - bool IsAsyncDisposable = false) + bool IsAsyncDisposable = false, + string? EmitType = null) { + /// + /// The concrete type to construct (new …) and to use for cache fields and resolver return + /// types. Normally the same as , but a decorator chain link reuses + /// one decorator type across several distinct instances, so it carries a synthetic + /// identity while holds the real type. Every + /// other use of stays the instance identity (keying, caching and + /// collection tracking), so ordinary registrations - where is null - are unaffected. + /// + public string ConstructedType => EmitType ?? ImplementationType; + /// /// Whether the container owns this instance for disposal in either sense - its declared type implements /// IDisposable or IAsyncDisposable - so it is tracked for teardown and its on-demand diff --git a/Source/Awaiten/DecorateAttribute.cs b/Source/Awaiten/DecorateAttribute.cs new file mode 100644 index 0000000..6da88b5 --- /dev/null +++ b/Source/Awaiten/DecorateAttribute.cs @@ -0,0 +1,59 @@ +using System; + +namespace Awaiten; + +// S2326: the type parameters are the Awaiten source generator's input — it reads the service and +// decorator types from the attribute's type arguments via Roslyn symbols, so TDecorator is +// intentionally not referenced in the attribute body (only in the type constraint). +#pragma warning disable S2326 + +/// +/// Wraps the registration of in +/// , so every consumer of +/// receives new TDecorator(inner) instead of the bare implementation. The decorator's +/// single constructor parameter of type is supplied the +/// decorated registration (its other constructor parameters resolve from the graph as usual), +/// and the decorator inherits the decorated registration's lifetime. +/// +/// +/// Multiple decorators of the same service chain in declaration order: the last +/// [Decorate] declared is the outermost. With [Decorate<D1, IService>] +/// followed by [Decorate<D2, IService>], resolving IService yields +/// D2(D1(Real)). Use to position a decorator explicitly rather than +/// by declaration order. Decorating a service also decorates every collection +/// (IEnumerable<IService>, IService[], …) view of it, so the decorator cannot +/// be bypassed: a service with several registrations is decorated member by member. Only the unkeyed +/// registration of is decorated; a service registered solely under a +/// [FromKey] key has no unkeyed registration to wrap and reports AWT123. +/// +/// The type parameters are ordered then +/// - the concrete type first, then the service it is exposed +/// as - to match the lifetime attributes ([Transient<TImplementation, TService>] +/// and friends). +/// +/// +/// The decorator and the implementation it wraps are each owned by the container and +/// disposed independently, outermost first (a decorator is built after its inner, so it is +/// disposed before it). A decorator that also disposes the inner instance handed to it would +/// therefore dispose it twice, so leave the inner's disposal to the container - or make the +/// decorator's Dispose idempotent if it must forward. +/// +/// +/// +/// The decorator implementation; it must have exactly one constructor parameter assignable to +/// . +/// +/// The service type to decorate. +/// [Decorate<LoggingDecorator, IService>] +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] +public sealed class DecorateAttribute : Attribute + where TDecorator : class, TService +{ + /// + /// The decorator's position in the chain (ascending, outermost last). Decorators are ordered + /// by first, then by declaration order, so a lower value sits closer to the + /// decorated implementation and a higher value closer to the consumer. Leave unset to chain + /// purely by declaration order. + /// + public int Order { get; set; } +} diff --git a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt index 4be3310..49fdc86 100644 --- a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt +++ b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt @@ -26,6 +26,13 @@ namespace Awaiten public Awaiten.LifetimeSafety LifetimeSafety { get; set; } public bool SyncResolveAfterInit { get; set; } } + [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] + public sealed class DecorateAttribute : System.Attribute + where TDecorator : class, TService + { + public DecorateAttribute() { } + public int Order { get; set; } + } [System.AttributeUsage(System.AttributeTargets.Parameter, AllowMultiple=false, Inherited=false)] public sealed class FromKeyAttribute : System.Attribute { diff --git a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt index 0ad86be..fa15068 100644 --- a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt +++ b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt @@ -26,6 +26,13 @@ namespace Awaiten public Awaiten.LifetimeSafety LifetimeSafety { get; set; } public bool SyncResolveAfterInit { get; set; } } + [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] + public sealed class DecorateAttribute : System.Attribute + where TDecorator : class, TService + { + public DecorateAttribute() { } + public int Order { get; set; } + } [System.AttributeUsage(System.AttributeTargets.Parameter, AllowMultiple=false, Inherited=false)] public sealed class FromKeyAttribute : System.Attribute { diff --git a/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt b/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt index f388dca..fc4f135 100644 --- a/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt +++ b/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt @@ -26,6 +26,13 @@ namespace Awaiten public Awaiten.LifetimeSafety LifetimeSafety { get; set; } public bool SyncResolveAfterInit { get; set; } } + [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] + public sealed class DecorateAttribute : System.Attribute + where TDecorator : class, TService + { + public DecorateAttribute() { } + public int Order { get; set; } + } [System.AttributeUsage(System.AttributeTargets.Parameter, AllowMultiple=false, Inherited=false)] public sealed class FromKeyAttribute : System.Attribute { diff --git a/Tests/Awaiten.SourceGenerators.Tests/DecoratorTests.cs b/Tests/Awaiten.SourceGenerators.Tests/DecoratorTests.cs new file mode 100644 index 0000000..98a1e78 --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/DecoratorTests.cs @@ -0,0 +1,96 @@ +using System.Linq; + +namespace Awaiten.SourceGenerators.Tests; + +/// +/// The generated shape of decorators: [Decorate<D, IService>] is model-building over the +/// existing keyed-resolution plumbing — each chain link is a synthetic-keyed instance whose inner +/// parameter resolves the link below it, and the public dispatch and every consumer reach the outermost +/// decorator. The collection view is rewritten to the decorated chain, so the decorator is unbypassable. +/// +public class DecoratorTests +{ + [Fact] + public async Task Decorator_EmitsTheChainedDispatchOutermostFirst() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IService { } + public sealed class Real : IService { } + public sealed class LoggingDecorator : IService { public LoggingDecorator(IService inner) { } } + public sealed class D2 : IService { public D2(IService inner) { } } + public sealed class Consumer { public Consumer(IService service) { } } + + [Container] + [Transient] + [Transient] + [Decorate] + [Decorate] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).IsEmpty(); + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + + // The chain is built from synthetic-keyed links: D2(LoggingDecorator(Real)). Each link constructs the + // link below it, so the outermost decorator's resolver constructs the inner one, down to the bare Real. + await That(source).Contains("new global::MyCode.D2(ResolveLoggingDecorator())") + .Because("the outermost decorator wraps the next-lower link"); + await That(source).Contains("new global::MyCode.LoggingDecorator(ResolveReal())") + .Because("the inner decorator wraps the bare implementation"); + + // The public IService dispatch and every consumer reach the outermost decorator, never the bare Real. + await That(source).Contains("instance = ResolveD2(); return true;") + .Because("the public IService dispatch resolves the outermost decorator"); + await That(source).Contains("new global::MyCode.Consumer(ResolveD2())") + .Because("a consumer of IService receives the outermost decorator"); + + // The collection view is decorated too — one element, the full chain, never the bare Real. + await That(source).Contains("new global::MyCode.IService[] { ResolveD2() }") + .Because("the collection view yields the decorated chain, so the decorator is unbypassable"); + await That(source).DoesNotContain("instance = ResolveReal(); return true;") + .Because("the bare implementation is no longer publicly dispatched — only the decorator chain is"); + } + + [Fact] + public async Task Decorator_WrapsEachRegistrationOfAMultiplyRegisteredService() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System.Collections.Generic; + + namespace MyCode; + + public interface IService { } + public sealed class Real1 : IService { } + public sealed class Real2 : IService { } + public sealed class LoggingDecorator : IService { public LoggingDecorator(IService inner) { } } + public sealed class Host { public Host(IEnumerable services) { } } + + [Container] + [Transient] + [Transient] + [Transient] + [Decorate] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).IsEmpty(); + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + + // A service with several registrations is decorated member by member: the collection materializes one + // distinct LoggingDecorator instance per base impl, so the decorator type is emitted twice. + int decoratorConstructions = source.Split(new[] { "new global::MyCode.LoggingDecorator(", }, System.StringSplitOptions.None).Length - 1; + await That(decoratorConstructions).IsEqualTo(2) + .Because("each of the two registrations gets its own decorator instance"); + await That(source).Contains("new global::MyCode.LoggingDecorator(ResolveReal1())"); + await That(source).Contains("new global::MyCode.LoggingDecorator(ResolveReal2())"); + } +} diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt123Awt124Decorator.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt123Awt124Decorator.cs new file mode 100644 index 0000000..a5c087d --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt123Awt124Decorator.cs @@ -0,0 +1,244 @@ +using System.Linq; + +namespace Awaiten.SourceGenerators.Tests; + +public partial class DiagnosticTests +{ + public class Awt123Awt124Decorator + { + [Fact] + public async Task ReportsAwt123WhenTheDecoratedServiceHasNoRegistration() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IService { } + // IService has no registration to decorate. + public sealed class LoggingDecorator : IService { public LoggingDecorator(IService inner) { } } + + [Container] + [Decorate] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics.Any(d => d.Contains("AWT123"))).IsTrue() + .Because("a [Decorate] over a service with no registration has nothing to wrap"); + } + + [Fact] + public async Task ReportsAwt124WhenTheDecoratorHasNoSingleInnerParameter() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IService { } + public sealed class Real : IService { } + // Two IService parameters: ambiguous which one receives the inner instance. + public sealed class BadDecorator : IService { public BadDecorator(IService a, IService b) { } } + + [Container] + [Transient] + [Decorate] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics.Any(d => d.Contains("AWT124"))).IsTrue() + .Because("a decorator with more than one parameter assignable to the service is ambiguous"); + } + + [Fact] + public async Task ReportsAwt124WhenTheDecoratorTakesNoInnerParameter() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IService { } + public sealed class Real : IService { } + // No IService parameter at all: nothing receives the inner instance. + public sealed class BadDecorator : IService { public BadDecorator() { } } + + [Container] + [Transient] + [Decorate] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics.Any(d => d.Contains("AWT124"))).IsTrue() + .Because("a decorator with no parameter assignable to the service cannot receive the inner instance"); + } + + [Fact] + public async Task DoesNotReportForAWellFormedDecorator() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IService { } + public sealed class Real : IService { } + public sealed class LoggingDecorator : IService { public LoggingDecorator(IService inner) { } } + + [Container] + [Transient] + [Decorate] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("a decorator with exactly one inner parameter over a registered service is well-formed"); + } + + [Fact] + public async Task DoesNotReportAwt124WhenAnExtraParameterIsAlsoAssignableFromTheService() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IService { } + public sealed class Real : IService { } + // (IService inner, object state): `object` is technically assignable-from IService, + // but the inner is the most-derived assignable parameter, so it is unambiguous. + public sealed class LoggingDecorator : IService { public LoggingDecorator(IService inner, object state) { } } + + [Container] + [Transient] + [Decorate] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics.Any(d => d.Contains("AWT124"))).IsFalse() + .Because("the inner is the most-derived assignable parameter, so an unrelated `object` sibling is not ambiguous"); + } + + [Fact] + public async Task DoesNotReportAwt124ForADecoratorWithAnInternalConstructor() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IService { } + public sealed class Real : IService { } + // The container builds the decorator through its internal (same-assembly) constructor, + // so validation must consider it too - a public-only check would wrongly report AWT124. + public sealed class LoggingDecorator : IService { internal LoggingDecorator(IService inner) { } } + + [Container] + [Transient] + [Decorate] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("the decorator's internal same-assembly constructor is the one the container constructs, so it is well-formed"); + } + + [Fact] + public async Task ReportsAwt124WhenTheOnlyServiceAssignableParameterIsKeyed() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IService { } + public sealed class Real : IService { } + // The single IService parameter is [FromKey]-ed: it selects a specific keyed + // registration, so it is a separate dependency, not the chain inner - leaving the + // decorator with no unkeyed parameter to receive the inner instance. + public sealed class Deco : IService { public Deco([FromKey("k")] IService inner) { } } + + [Container] + [Transient] + [Decorate] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics.Any(d => d.Contains("AWT124"))).IsTrue() + .Because("a [FromKey] parameter is not the chain inner, so the decorator has no parameter to receive it"); + } + + [Fact] + public async Task DiagnosticsNameTheRealDecoratorTypeNotTheSyntheticIdentity() + { + GeneratorResult result = Generator.Run(""" + using System.Collections.Generic; + using Awaiten; + + namespace MyCode; + + public interface IService { } + public sealed class Real : IService { } + // Depending on the collection of the decorated service closes a cycle (the collection + // now yields the decorator itself), so this reports AWT102 naming the decorator. + public sealed class Deco : IService { public Deco(IService inner, IEnumerable all) { } } + + [Container] + [Transient] + [Decorate] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics.Any(d => d.Contains("AWT102") && d.Contains("MyCode.Deco"))).IsTrue() + .Because("the cycle diagnostic must name the real decorator type"); + await That(result.Diagnostics.Any(d => d.Contains("@__dec:"))).IsFalse() + .Because("the internal synthetic '@__dec:' identity must not leak into user-facing diagnostics"); + } + + [Fact] + public async Task CaptiveDependencyDiagnosticNamesTheRealDecoratorType() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IService { } + public sealed class Real : IService { } + public sealed class Narrower { } + // The decorator inherits Real's singleton lifetime and takes a scoped side-dependency, + // so the singleton decorator captively holds the scoped Narrower - reported as AWT105. + public sealed class Deco : IService { public Deco(IService inner, Narrower narrower) { } } + + [Container] + [Singleton] + [Scoped] + [Decorate] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics.Any(d => d.Contains("AWT105") && d.Contains("MyCode.Deco"))).IsTrue() + .Because("the captive-dependency diagnostic must name the real decorator type"); + await That(result.Diagnostics.Any(d => d.Contains("@__dec:"))).IsFalse() + .Because("the internal synthetic '@__dec:' identity must not leak into the captive-dependency diagnostic"); + } + } +} diff --git a/Tests/Awaiten.Tests/DecoratorTests.cs b/Tests/Awaiten.Tests/DecoratorTests.cs new file mode 100644 index 0000000..ea5e5d7 --- /dev/null +++ b/Tests/Awaiten.Tests/DecoratorTests.cs @@ -0,0 +1,374 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Awaiten.Tests; + +/// +/// Runtime behavior of decorators: [Decorate<D, IService>] wraps the registered service +/// so consumers receive D(inner). Multiple decorators chain in declaration order (last declared +/// is outermost), a decorated service injected anywhere receives the outermost decorator, and a +/// collection view of the service yields the decorated chain (the decorator is unbypassable). The +/// containers and services are nested types, so the enclosing class is partial. +/// +public partial class DecoratorTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task SingleDecorator_WrapsTheRegisteredImplementation() + { + using SingleContainer.Root container = new(); + + IService service = container.Resolve(); + + await That(service).Is(); + await That(service.Describe()).IsEqualTo("Logging(Real)"); + } + + [Fact] + public async Task MultipleDecorators_ChainInDeclarationOrderOutermostLast() + { + using MultiContainer.Root container = new(); + + IService service = container.Resolve(); + + // [Decorate] then [Decorate] => D2(D1(Real)). + await That(service.Describe()).IsEqualTo("D2(D1(Real))"); + } + + [Fact] + public async Task Decorator_ResolvesItsOtherDependenciesFromTheGraph() + { + using ExtraDepsContainer.Root container = new(); + + IService service = container.Resolve(); + + await That(service.Describe()).IsEqualTo("Audited(Real, by clock)"); + } + + [Fact] + public async Task DecoratedService_InjectedIntoAnotherConsumer_ReceivesTheOutermostDecorator() + { + using ConsumerContainer.Root container = new(); + + Consumer consumer = container.Resolve(); + + await That(consumer.Service).Is(); + await That(consumer.Service.Describe()).IsEqualTo("Logging(Real)"); + } + + [Fact] + public async Task DecoratedService_AsCollection_YieldsOneDecoratedChainForASingleBaseImpl() + { + using SingleContainer.Root container = new(); + + IService[] services = container.Resolve(); + + // A single base impl yields exactly one element — the full decorated chain, never the bare Real. + await That(services).HasCount(1); + await That(services[0]).Is(); + await That(services[0].Describe()).IsEqualTo("Logging(Real)"); + } + + [Fact] + public async Task DecoratedService_AsCollection_WrapsEachBaseImplForMultipleRegistrations() + { + using MultiImplContainer.Root container = new(); + + string[] described = container.Resolve>().Select(s => s.Describe()).ToArray(); + + // A decorator decorates every registration (member by member): [D(Real1), D(Real2)]. + await That(described).HasCount(2); + await That(described[0]).IsEqualTo("Logging(Real1)"); + await That(described[1]).IsEqualTo("Logging(Real2)"); + await That(container.Resolve>().All(s => s is LoggingDecorator)).IsTrue(); + } + + [Fact] + public async Task MultipleRegistrations_SingleDispatch_ReturnsTheDecoratedFirstWins() + { + using MultiImplContainer.Root container = new(); + + IService service = container.Resolve(); + + await That(service).Is(); + await That(service.Describe()).IsEqualTo("Logging(Real1)"); + } + + [Fact] + public async Task Decorator_InheritsTheDecoratedRegistrationsSingletonLifetime() + { + using SingletonLifetimeContainer.Root container = new(); + + IService first = container.Resolve(); + IService second = container.Resolve(); + + await That(first).IsSameAs(second); + } + + [Fact] + public async Task Decorator_InheritsTheDecoratedRegistrationsTransientLifetime() + { + using TransientLifetimeContainer.Root container = new(); + + IService first = container.Resolve(); + IService second = container.Resolve(); + + await That(ReferenceEquals(first, second)).IsFalse(); + } + + [Fact] + public async Task ExplicitOrder_PositionsTheDecoratorRatherThanDeclarationOrder() + { + using ExplicitOrderContainer.Root container = new(); + + IService service = container.Resolve(); + + // D2 is declared first but carries Order = 1, so it sits outside D1 (Order 0, the default): D2(D1(Real)). + await That(service.Describe()).IsEqualTo("D2(D1(Real))"); + } + + [Fact] + public async Task Decorator_WithInnerParameterTypedAsABaseOfTheService_ResolvesThroughTheChain() + { + using BaseTypedInnerContainer.Root container = new(); + + IDerivedService service = container.Resolve(); + + // The decorator's inner parameter is IBase, a base of the decorated IDerivedService. The chain link is + // registered under IDerivedService, so the redirect must key to that, not to the parameter's own base type. + await That(service).Is(); + await That(service.Describe()).IsEqualTo("Base(Real)"); + } + + [Fact] + public async Task Decorator_InheritsTheDecoratedRegistrationsScopedLifetime() + { + using ScopedLifetimeContainer.Root container = new(); + using IAwaitenScope scope1 = container.CreateScope(); + using IAwaitenScope scope2 = container.CreateScope(); + + IService a = scope1.Resolve(); + IService b = scope1.Resolve(); + IService c = scope2.Resolve(); + + await That(a).Is(); + await That(a).IsSameAs(b).Because("a scoped decorator is one instance within a scope"); + await That(a).IsNotSameAs(c).Because("each scope builds its own decorated instance"); + } + + [Fact] + public async Task Decorator_OfAnAsyncInitializableService_InitializesTheInnerThenWrapsItAsync() + { + using AsyncInnerContainer.Root container = new(); + + // The inner is IAsyncInitializable, so the whole chain is async-tainted: reachable only through + // ResolveAsync (the redirect keeps the taint flowing from the inner up through the decorator). + await That(() => container.Resolve()).Throws() + .Because("an async-tainted decorated service is not exposed to synchronous resolution"); + + IService service = await container.ResolveAsync(Ct); + + await That(service).Is(); + await That(service.Describe()).IsEqualTo("Logging(Real(init))") + .Because("the async-initializable inner is initialized before the decorator wraps it"); + } + + [Fact] + public async Task Dispose_DisposesBothTheDecoratorAndItsInner_OutermostFirst() + { + DisposalLog log; + using (DisposalOrderContainer.Root container = new()) + { + log = container.Resolve(); + container.Resolve(); + } + + // Both the decorator and the implementation it wraps are container-owned and disposed; the decorator is + // built after its inner, so it is disposed first (outermost-first) - see the DecorateAttribute remarks. + await That(log.Order).HasCount(2); + await That(log.Order[0]).IsEqualTo("Decorator"); + await That(log.Order[1]).IsEqualTo("Real"); + } + + public interface IService + { + string Describe(); + } + + public interface IBase + { + string Describe(); + } + + public interface IDerivedService : IBase; + + public sealed class DerivedReal : IDerivedService + { + public string Describe() => "Real"; + } + + // A decorator whose single inner parameter is typed as IBase, a base of the decorated IDerivedService. + public sealed class BaseTypedDecorator(IBase inner) : IDerivedService + { + public string Describe() => $"Base({inner.Describe()})"; + } + + public sealed class ScopedReal : IService + { + public string Describe() => "Real"; + } + + // An async-initializable base implementation, to check the async taint flows up through the decorator chain. + public sealed class AsyncReal : IService, IAsyncInitializable + { + private bool _initialized; + + public string Describe() => _initialized ? "Real(init)" : "Real"; + + public Task InitializeAsync(CancellationToken cancellationToken) + { + _initialized = true; + return Task.CompletedTask; + } + } + + // A shared sink recording disposal order; both the inner and the decorator take it as an extra dependency. + public sealed class DisposalLog + { + public List Order { get; } = new(); + } + + public sealed class DisposableReal(DisposalLog log) : IService, IDisposable + { + public string Describe() => "Real"; + + public void Dispose() => log.Order.Add("Real"); + } + + public sealed class DisposableDecorator(IService inner, DisposalLog log) : IService, IDisposable + { + public string Describe() => $"Disposable({inner.Describe()})"; + + public void Dispose() => log.Order.Add("Decorator"); + } + + public sealed class Real : IService + { + public string Describe() => "Real"; + } + + public sealed class Real1 : IService + { + public string Describe() => "Real1"; + } + + public sealed class Real2 : IService + { + public string Describe() => "Real2"; + } + + public sealed class LoggingDecorator(IService inner) : IService + { + public string Describe() => $"Logging({inner.Describe()})"; + } + + public sealed class D1(IService inner) : IService + { + public string Describe() => $"D1({inner.Describe()})"; + } + + public sealed class D2(IService inner) : IService + { + public string Describe() => $"D2({inner.Describe()})"; + } + + // An extra dependency the AuditingDecorator resolves from the graph and calls as an instance member; it + // carries instance state so Now() is genuinely instance-bound (not a static utility). + public sealed class Clock + { + private readonly string _label = "clock"; + + public string Now() => _label; + } + + // A decorator with an extra (non-decorated) dependency resolved from the graph. + public sealed class AuditingDecorator(IService inner, Clock clock) : IService + { + public string Describe() => $"Audited({inner.Describe()}, by {clock.Now()})"; + } + + public sealed class Consumer(IService service) + { + public IService Service => service; + } + + [Container] + [Transient] + [Decorate] + public static partial class SingleContainer; + + [Container] + [Transient] + [Decorate] + [Decorate] + public static partial class MultiContainer; + + [Container] + [Transient] + [Transient] + [Decorate] + public static partial class ExtraDepsContainer; + + [Container] + [Transient] + [Transient] + [Decorate] + public static partial class ConsumerContainer; + + [Container] + [Transient] + [Transient] + [Decorate] + public static partial class MultiImplContainer; + + [Container] + [Singleton] + [Decorate] + public static partial class SingletonLifetimeContainer; + + [Container] + [Transient] + [Decorate] + public static partial class TransientLifetimeContainer; + + [Container] + [Transient] + [Decorate(Order = 1)] + [Decorate] + public static partial class ExplicitOrderContainer; + + [Container] + [Transient] + [Decorate] + public static partial class BaseTypedInnerContainer; + + [Container] + [Scoped] + [Decorate] + public static partial class ScopedLifetimeContainer; + + [Container] + [Singleton] + [Decorate] + public static partial class AsyncInnerContainer; + + [Container] + [Singleton] + [Singleton] + [Decorate] + public static partial class DisposalOrderContainer; +}