diff --git a/Docs/pages/09-diagnostics.md b/Docs/pages/09-diagnostics.md index bc21942..491a838 100644 --- a/Docs/pages/09-diagnostics.md +++ b/Docs/pages/09-diagnostics.md @@ -1257,6 +1257,47 @@ public static partial class CoffeeShop; Where [AWT188](#awt188) is about an interface the match cannot be exposed under, this one is about the match itself, so no `ScanAs` flag rescues it: every registration has to name the implementation. Make the type public, grant the container's assembly `InternalsVisibleTo`, or exclude it with a `NamePatterns`/`NamespacePatterns` entry (the `Exclude` type list cannot name an inaccessible type). Reported only for a type the marker matched and the scan's filters kept, so an unrelated internal type in a scanned assembly stays silent, as does one you already excluded. +### AWT198 + +:::danger[Error] +A generic lifecycle hook on an open-generic `[Scan]` marker could bind a match through more than one closed marker form, so its type arguments are ambiguous. +::: + +```csharp +public interface IView; +public sealed class DualView : IView, IView; // closes IView<> twice + +[Container] +[Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] +public static partial class CoffeeShop +{ + private static void Wire(IView view) { } // TViewModel would be Orders or Payments? +} +``` + +A [generic scan hook](./registration/scanning#lifecycle-hooks) binds its type argument from a closed marker form, so it needs exactly one it can bind. `DualView`, which closes `IView<>` at both `Orders` and `Payments`, offers two; the same happens when two scans bind the same generic hook through differently-closed markers of one type. Only forms the hook could actually bind count: matching arity, type arguments the generated container can access, satisfied constraints, and a first parameter that accepts the match. If exactly one form remains after those checks the hook binds it, so a `where TViewModel : class` constraint can settle a family that closes the marker at one class and one struct, and a closing at another assembly's internal type yields to an accessible sibling. Register the type explicitly with the intended hook, or split the family so only one closed form binds it. Reported only when the ambiguous generic method is the sole usable overload of the hook name: if another overload also accepts the match, the collision is between overloads rather than closings (settling the closings would still leave two usable overloads), so it is [AWT190](#awt190) instead. Only a *generic* hook is affected: a non-generic hook (its parameter typed as the marker or `object`) takes no type argument, so a match with several closings is fine for it, as is a match closing the marker several times *without* any hook (each closed form registers as its own collection member). + +### AWT199 + +:::warning[Warning] +Two `[Scan]` attributes match one implementation with conflicting lifecycle hooks; the first scan's hook is used. +::: + +```csharp +public sealed class Widget : IPlugin, IHandler; // matched by both scans + +[Container] +[Scan(typeof(IPlugin), OnActivated = nameof(PluginStarted))] +[Scan(typeof(IHandler), OnActivated = nameof(HandlerStarted))] // two OnActivated hooks for Widget +public static partial class CoffeeShop +{ + private static void PluginStarted(object instance) { } + private static void HandlerStarted(object instance) { } +} +``` + +Which method ran for `Widget` would depend on attribute order, so the contradiction is surfaced instead, mirroring [AWT142](#awt142) for lifetimes. Overlapping scans that agree are fine: two scans naming the *same* method merge, and so do scans hooking *different* slots (one scan's `OnActivated` combines with another's `OnRelease`). An explicit registration of the type is not reported either: a scan yields to it wholesale, hooks included, so the explicit registration's hooks (or its deliberate lack of them) replace the scan's. + ## Modules ### AWT149 diff --git a/Docs/pages/lifetime/05-lifecycle-hooks.md b/Docs/pages/lifetime/05-lifecycle-hooks.md index 0d89e10..3bf9b94 100644 --- a/Docs/pages/lifetime/05-lifecycle-hooks.md +++ b/Docs/pages/lifetime/05-lifecycle-hooks.md @@ -58,7 +58,12 @@ Combine `Eager` with async and `SyncResolveAfterInit`, and the singleton is cons *Note: hooks are for services the container owns. Setting one on a pre-built `Instance` registration is an error ([AWT165](../diagnostics#awt165)). Conflicting coalesced directives across registrations of one implementation are caught too ([AWT166](../diagnostics#awt166)).* +## Hooks on a scan + +A `[Scan]` can name the same `OnActivated`/`OnRelease` hooks, applied to every match, so a whole scanned family shares one routine. See [Scanning → Lifecycle hooks](../registration/scanning#lifecycle-hooks). + ## Where to go next - [Async initialization](../async-initialization) for `InitializeAsync`. - [Disposal](./disposal) for teardown order. +- [Scanning](../registration/scanning#lifecycle-hooks) to apply a hook to a whole matched family. diff --git a/Docs/pages/registration/07-scanning.md b/Docs/pages/registration/07-scanning.md index b0595d2..b58d935 100644 --- a/Docs/pages/registration/07-scanning.md +++ b/Docs/pages/registration/07-scanning.md @@ -100,6 +100,42 @@ An unbound generic marker matches closed forms, the way Autofac's closed-types-o [Scan(typeof(IView<>), As = ScanAs.Marker)] ``` +## Lifecycle hooks + +A scan can name `OnActivated` and `OnRelease` hooks, applied to every match, so a whole family shares one activation or teardown routine without a registration line per type. + +```csharp +[Container] +[Scan(Lifetime = AwaitenLifetime.Singleton, OnActivated = nameof(Prime))] +public static partial class CoffeeShop +{ + private static void Prime(IDrink drink) => drink.Prime(); +} +``` + +The hook's first parameter is the match, so it must accept every one: type it as the scanned marker (or `object`). Parameters after it are resolved from the graph exactly as for an [explicit registration's hook](../lifetime/lifecycle-hooks#hook-parameters), and the same rules and diagnostics apply: an unusable hook name is [AWT164](../diagnostics#awt164), an unregistered parameter is [AWT101](../diagnostics#awt101). + +When two scans match the same type, their hooks merge: one scan's `OnActivated` combines with another's `OnRelease`, and both naming the same method is fine. Two scans naming *different* methods for the same slot contradict each other, so the first scan's method is used and the contradiction is surfaced as [AWT199](../diagnostics#awt199). An [explicit registration](#overriding-a-scanned-type) of a scanned type is different: it replaces the scan's hooks along with everything else, so name the hook on the explicit registration too if the special-cased type should keep it, and leave it off to deliberately opt that type out. The replacement follows the type, not the service: an explicit registration of just the concrete type, say `[Transient]` beside a marker scan, strips the scan's hooks from `Espresso` even where the scan still supplies its marker mapping. + +### Generic hooks on an open marker + +An [open generic marker](#open-generic-markers) knows each match's closed type argument at compile time, so a hook can be *generic* and receive it directly, with no reflection and no `object`. The classic case is a WPF-style view/view-model family: bind each view to its matching view model as it is activated. + +```csharp +[Container] +[Singleton] +[Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(WireView))] +public static partial class App +{ + private static void WireView(IView view, TViewModel viewModel) + => view.DataContext = viewModel; +} +``` + +For `MainWindow : IView` the generator dispatches `WireView(mainWindow, viewModel)`, resolving the matching `IMainViewModel` from the graph. The type argument is visible to graph analysis, so an unregistered view model fails the build ([AWT101](../diagnostics#awt101)) rather than at runtime. A non-generic method works too (its parameters typed as the marker or `object`); the type argument only applies when the method is generic. + +Because a *generic* hook takes its type argument from a closed marker form, it needs exactly one it can bind. A match offering several, by closing the marker more than once (`Dual : IView, IView`), leaves that argument ambiguous and is reported as [AWT198](../diagnostics#awt198): register such a type explicitly with the hook it needs, or split the family so only one closed form binds it. Only forms the hook could actually bind count, so a constraint (`where TViewModel : class`) that rules out all closings but one settles the choice, as does accessibility: a closing at another assembly's internal type cannot be dispatched and yields to an accessible sibling. If the hook name is overloaded and another overload also accepts the match, the collision is between overloads rather than closings and is reported as [AWT190](../diagnostics#awt190) instead. A non-generic hook takes no type argument, so a match with several closings is fine for it. + ## Overriding a scanned type An explicit registration of a scanned type wins over the scan, so you can special-case one drink while scanning the rest. diff --git a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md index 3f2705c..1d8cba8 100644 --- a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -99,6 +99,8 @@ AWT195 | Awaiten | Error | A self-compiled module [Scan] match has a constructor parameter type inaccessible outside the module's assembly AWT196 | Awaiten | Warning | A self-compiled module [Scan] match has no exposure interface accessible outside the module's assembly, so it is skipped AWT197 | Awaiten | Error | A self-compiled module [Scan] match would be exposed under multiple interfaces, which a single-exposure factory cannot express + AWT198 | Awaiten | Error | A generic lifecycle hook on an open-generic [Scan] marker could bind a match through more than one closed marker form, so its type arguments are ambiguous + AWT199 | Awaiten | Warning | Two [Scan] attributes match one implementation with conflicting OnActivated/OnRelease hooks; the first scan's hook is used AWT200 | Awaiten | Error | A self-compiled module [Scan] match carries [Inject]/[Arg] metadata the generated factory cannot mirror AWT201 | Awaiten | Error | A generic [Module] (or one nested in a generic type) declares a [Scan], so no closed module exists for a consumer to import AWT202 | Awaiten | Error | A module [Scan] declares InAssembliesOf, but a self-compiled scan sweeps only the module's own assembly diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs index c722c7b..4bbd970 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs @@ -48,6 +48,7 @@ private static (List Order, Dictionary ServiceToIm HashSet reportedConflicts = new(StringComparer.Ordinal); HashSet reportedProductionConflicts = new(StringComparer.Ordinal); HashSet reportedDirectiveConflicts = new(StringComparer.Ordinal); + HashSet reportedHookConflicts = new(StringComparer.Ordinal); // Collection membership: every registration of a service, keyed by (service type, key) and deduped by // implementation, in registration order. serviceMemberOrder preserves first-seen order for emission. @@ -149,7 +150,7 @@ private static (List Order, Dictionary ServiceToIm // Every registration is a member of the collection for its (service type, key), built even when it // loses the single-resolution slot, since it is reachable through the collection. AddCollectionMember(serviceMembers, serviceMemberOrder, serviceKey, registration.ImplementationType); - EnsureImpl(implInfos, implOrder, registration); + MergeScanHooks(EnsureImpl(implInfos, implOrder, registration), registration, reportedHookConflicts, diagnostics); // A keyed registration is also a member of its service's keyed collection, indexed by its [Key]. AddKeyedMember(keyedMembers, keyedMemberOrder, registration, alreadyChosen); @@ -197,6 +198,77 @@ static ImplInfo EnsureImpl(Dictionary implInfos, List + /// Merges a scan registration's lifecycle hooks into its implementation's coalesced info, per hook slot. + /// Two scans matching one type combine: a hook fills a slot no earlier scan claimed (so one scan's + /// OnActivated and another's OnRelease both apply), and a scan restating the slot's hook name + /// contributes its closed marker forms to that slot's set, so a generic hook bound by two open-generic scans + /// that close their markers differently is seen as ambiguous in ResolveHook (AWT198) rather than + /// silently fixed to the first-seen closing. Naming a different method for a claimed slot is + /// AWT199 (once per implementation and slot): which method ran + /// would depend on attribute order, the same order-dependence AWT142 surfaces for lifetimes. When the + /// implementation's winning registration is not a scan the whole merge is skipped: a scan yields to an + /// explicit registration, whose hooks (or deliberate lack of them) replace the scan's like its other options. + /// + private static void MergeScanHooks( + ImplInfo info, + RawRegistration registration, + HashSet reportedHookConflicts, + List diagnostics) + { + if (!registration.IsScan || !info.IsScan) + { + return; + } + + info.OnActivated = MergeScanHookSlot(registration, "OnActivated", info.OnActivated, registration.OnActivated, info.OnActivatedMarkers, reportedHookConflicts, diagnostics); + info.OnRelease = MergeScanHookSlot(registration, "OnRelease", info.OnRelease, registration.OnRelease, info.OnReleaseMarkers, reportedHookConflicts, diagnostics); + } + + /// + /// Merges one hook slot (see ): returns the slot's coalesced hook name, unioning + /// the registration's closed marker forms into the slot's set when it fills or restates the slot, and + /// reporting AWT199 (keeping the first-seen name) when it contradicts it. + /// + private static string? MergeScanHookSlot( + RawRegistration registration, + string slot, + string? current, + string? name, + List slotMarkers, + HashSet reportedHookConflicts, + List diagnostics) + { + if (name is null) + { + return current; + } + + if (current is not null && !string.Equals(current, name, StringComparison.Ordinal)) + { + if (reportedHookConflicts.Add(registration.ImplementationType + "\0" + slot)) + { + diagnostics.Add(new DiagnosticInfo( + Diagnostics.ScanHookConflict, + LocationInfo.From(registration.Location), + new EquatableArray([Display(registration.ImplementationType), slot, current, name,]))); + } + + return current; + } + + if (registration.HookClosedMarkers is { } markers) + { + foreach (INamedTypeSymbol marker in markers.Where(marker => + !slotMarkers.Any(seen => SymbolEqualityComparer.Default.Equals(seen, marker)))) + { + slotMarkers.Add(marker); + } + } + + return name; + } + /// /// The synthetic resolution key a contextual (WhenInjectedInto) registration is stored under: unique per /// consumer type and prefixed so it is very unlikely to collide with a user Key. Reached only from diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.ModuleScan.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.ModuleScan.cs index fcb9684..83c70de 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.ModuleScan.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.ModuleScan.cs @@ -100,7 +100,11 @@ private static void ExpandModuleScan( return; } - ScanMatch match = new(ScanExposureOf(attribute), ScanLifetime(attribute), location, ScanSkipsUnconstructable(attribute)); + // A self-compiled module scan does not carry OnActivated/OnRelease hooks: the hook pipeline resolves and + // emits against the container (Origin == null), so module-declared scan hooks need Origin-qualified wiring + // that does not exist yet. Pass null here until that lands. + ScanMatch match = new(ScanExposureOf(attribute), ScanLifetime(attribute), location, ScanSkipsUnconstructable(attribute), + OnActivated: null, OnRelease: null); ScanFilters filters = ScanFiltersOf(attribute); if ((match.Exposure & ScanExposures.All) == 0) diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs index f3ecb4b..2cb4a22 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs @@ -1,7 +1,9 @@ +using System.Collections.Immutable; using Awaiten.SourceGenerators.Entities; using Awaiten.SourceGenerators.Internals; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; namespace Awaiten.SourceGenerators; @@ -286,7 +288,16 @@ private static InstanceModel BuildPrebuiltInstance( /// to AWT164. Reports AWT164 and returns /// (null, empty) when no accessible ordinary void method of that name accepts the implementation type /// as its first parameter, and AWT190 (also returning - /// (null, empty)) when more than one does, so the choice would be order-dependent. + /// (null, empty)) when more than one does, so the choice would be order-dependent. A generic hook + /// (named on an open-generic [Scan] marker) binds its type arguments from the closed marker forms + /// kept per hook slot on ImplInfo: exactly one bindable form (matching arity, accessible type + /// arguments, satisfied constraints, a first parameter accepting the match; see + /// ) dispatches the + /// constructed method, none leaves it unusable like any other shape mismatch (falling through to AWT164), + /// and more than one is reported as AWT198 when it + /// is the only usable overload (a non-generic hook, needing no arguments, is unaffected). A multi-form + /// generic overload beside another usable overload is an overload collision, AWT190, since settling the + /// closings would still leave two usable overloads. /// private static (string? Hook, EquatableArray Parameters) ResolveHook( ImplInfo info, @@ -299,35 +310,103 @@ private static (string? Hook, EquatableArray Parameters) Resolve return (null, default); } + (List matches, List> ambiguousMethods) = + CollectHookOverloads(info, hookName, release, context); + + if (matches.Count == 1 && ambiguousMethods.Count == 0) + { + return (QualifiedHook(info, hookName, matches[0]), ClassifyHookParameters(matches[0], info, release, context)); + } + + // A generic hook whose only obstacle is an ambiguous closed marker, with no other usable overload, is + // AWT198, distinct from an unusable name (AWT164) or an overload the container cannot pick between (AWT190): + // several closed forms could bind the hook, so its type arguments cannot be chosen. When another overload + // is also usable the collision is between methods, not closings (settling the closings would still leave + // two usable overloads), so it falls to AWT190 below - exactly as if the generic overload bound uniquely. + if (matches.Count == 0 && ambiguousMethods.Count == 1) + { + context.Diagnostics.Add(new DiagnosticInfo( + Diagnostics.GenericHookAmbiguousMarker, + info.Location, + new EquatableArray([ + Display(info.ImplementationType), + hookName, + string.Join(", ", ambiguousMethods[0].Select(form => Display(form.ToDisplayString(FullyQualified)))), + ]))); + return (null, default); + } + + // No usable match is AWT164 (unusable hook); more than one is AWT190 (an overload the container cannot pick + // between), where a generic overload with several bindable closings counts as usable - it could dispatch, + // so silently preferring its sibling would let an extra marker closing change which method runs. Either way + // no hook is emitted - the error fails the build, and picking one arbitrarily would only add a confusing + // secondary diagnostic from the parameters of the guessed overload. + context.Diagnostics.Add(new DiagnosticInfo( + matches.Count + ambiguousMethods.Count == 0 ? Diagnostics.InvalidLifecycleHook : Diagnostics.AmbiguousLifecycleHook, + info.Location, + new EquatableArray([Display(info.OwningServiceOrImpl), hookName, DescribeOwner(info),]))); + return (null, default); + } + + /// + /// Collects the usable overloads of for : the + /// dispatchable methods (non-generic ones whose first parameter accepts the instance, and generic ones bound + /// to a single closed marker form), and separately the closed-form sets of any generic overload that several + /// forms could bind, which stay usable (an AWT190 collision when a sibling also matches) yet cannot be + /// dispatched (AWT198 when they are the only candidate). The forms of each ambiguous overload are kept so the + /// AWT198 message can name them. + /// + private static (List Matches, List> AmbiguousMethods) CollectHookOverloads( + ImplInfo info, + string hookName, + bool release, + BuildContext context) + { INamedTypeSymbol containerSymbol = context.ContainerSymbol; Compilation compilation = context.Compilation; + IReadOnlyList closedMarkers = release ? info.OnReleaseMarkers : info.OnActivatedMarkers; List matches = new(); + List> ambiguousMethods = new(); foreach (ISymbol member in AccessibleMembers(info.Origin ?? containerSymbol, hookName, compilation)) { // A module hook must also be accessible from the generated container (its own private members are // reachable from the partial, a module's are not); an inaccessible module method is not a usable hook. - if (member is IMethodSymbol { MethodKind: MethodKind.Ordinary, IsStatic: true, ReturnsVoid: true, Parameters.Length: >= 1, } method - && compilation.HasImplicitConversion(info.Symbol, method.Parameters[0].Type) - && (info.Origin is null || compilation.IsSymbolAccessibleWithin(method, containerSymbol))) + if (member is not IMethodSymbol { MethodKind: MethodKind.Ordinary, IsStatic: true, ReturnsVoid: true, Parameters.Length: >= 1, } method + || (info.Origin is not null && !compilation.IsSymbolAccessibleWithin(method, containerSymbol))) { - matches.Add(method); + continue; } - } - if (matches.Count == 1) - { - return (QualifiedHook(info, hookName), ClassifyHookParameters(matches[0], info, release, context)); + // A non-generic method needs no type arguments (whatever the marker closings): it is a usable hook + // whenever its first parameter accepts the instance. + if (method.Arity == 0) + { + if (compilation.HasImplicitConversion(info.Symbol, method.Parameters[0].Type)) + { + matches.Add(method); + } + + continue; + } + + // A generic hook binds its type parameters from a closed marker form, so WireView is + // dispatched as WireView. Exactly one bindable form is that dispatch; more than one + // leaves the type arguments ambiguous (AWT198, with the forms recorded for the message) yet still a + // usable overload, so it counts toward AWT190 when a sibling also matches; none means the method is + // not a usable hook and falls through to AWT164 with any other shape mismatch. + List<(INamedTypeSymbol Form, IMethodSymbol Constructed)> bindable = BindableHookConstructions(method, closedMarkers, info.Symbol, containerSymbol, compilation); + if (bindable.Count == 1) + { + matches.Add(bindable[0].Constructed); + } + else if (bindable.Count > 1) + { + ambiguousMethods.Add(bindable.Select(candidate => candidate.Form).ToList()); + } } - // No usable match is AWT164 (unusable hook); more than one is AWT190 (an overload the container cannot pick - // between). Either way no hook is emitted - the error fails the build, and picking one arbitrarily would only - // add a confusing secondary diagnostic from the parameters of the guessed overload. - context.Diagnostics.Add(new DiagnosticInfo( - matches.Count == 0 ? Diagnostics.InvalidLifecycleHook : Diagnostics.AmbiguousLifecycleHook, - info.Location, - new EquatableArray([Display(info.OwningServiceOrImpl), hookName, DescribeOwner(info),]))); - return (null, default); + return (matches, ambiguousMethods); } /// @@ -410,10 +489,181 @@ private static EquatableArray ClassifyHookParameters(IMethodSymb /// /// A module's lifecycle hook is emitted qualified with the module type (the generated container is another /// class, so the simple name would not bind); the container's own hooks stay unqualified, in scope inside - /// the generated partial. Mirrors QualifiedProductionMember for Factory/Instance members. + /// the generated partial. Mirrors QualifiedProductionMember for Factory/Instance members. A generic + /// hook carries its bound type arguments (WireView<global::App.IMainViewModel>) so the emitter, + /// which writes the name verbatim, needs no generic awareness. /// - private static string QualifiedHook(ImplInfo info, string hookName) - => info.Origin is { } origin ? $"{origin.ToDisplayString(FullyQualified)}.{hookName}" : hookName; + private static string QualifiedHook(ImplInfo info, string hookName, IMethodSymbol hook) + { + string name = info.Origin is { } origin ? $"{origin.ToDisplayString(FullyQualified)}.{hookName}" : hookName; + if (hook.TypeArguments.Length == 0) + { + return name; + } + + string arguments = string.Join(", ", hook.TypeArguments.Select(argument => argument.ToDisplayString(FullyQualified))); + return $"{name}<{arguments}>"; + } + + /// + /// The closed marker forms a generic hook method can actually bind, each paired with the method constructed + /// from its type arguments. A form is bindable when its arity matches the method's, the form (including its + /// type arguments, which become the constructed call's) is accessible from the generated container (an + /// internal type argument from a scanned foreign assembly would otherwise leak CS0122 into the generated + /// source), its type arguments satisfy the method's constraints (see , so + /// the constructed call compiles rather than erroring inside the generated source), and the constructed + /// method's first parameter accepts the implementation. Two forms constructing the identical method (say + /// IView<int> and IEditor<int> both yielding Wire<int>) count once: + /// the dispatch is the same, so there is nothing to be ambiguous about. An empty result means the method is + /// not a usable hook (AWT164); more than one entry is the ambiguity AWT198 reports. Like a constraint, the + /// accessibility check can settle a family: an inaccessible closing beside an accessible one leaves a single + /// bindable form rather than an ambiguity. + /// + private static List<(INamedTypeSymbol Form, IMethodSymbol Constructed)> BindableHookConstructions( + IMethodSymbol method, + IReadOnlyList closedMarkers, + INamedTypeSymbol implementation, + INamedTypeSymbol containerSymbol, + Compilation compilation) + { + List<(INamedTypeSymbol Form, IMethodSymbol Constructed)> bindable = new(); + foreach (INamedTypeSymbol form in closedMarkers.Where(form => + method.Arity == form.TypeArguments.Length + && compilation.IsSymbolAccessibleWithin(form, containerSymbol) + && SatisfiesConstraints(method, form.TypeArguments, compilation))) + { + IMethodSymbol constructed = method.Construct(form.TypeArguments.ToArray()); + if (compilation.HasImplicitConversion(implementation, constructed.Parameters[0].Type) + && !bindable.Any(candidate => SymbolEqualityComparer.Default.Equals(candidate.Constructed, constructed))) + { + bindable.Add((form, constructed)); + } + } + + return bindable; + } + + /// + /// Whether the type arguments satisfy a generic hook method's constraints, checked before + /// (which does not validate them, so a violation would + /// otherwise surface as a raw compiler error inside the generated source). Covers the constraint kinds C# + /// has: class, struct (excluding Nullable<T>), unmanaged, new(), + /// and constraint types, with the method's own type parameters substituted so where T : IComparable<T> + /// is checked at the bound argument. A constraint type is satisfied only by an identity, implicit reference, + /// or boxing conversion (see ), the conversions the language admits for + /// constraint satisfaction - notably not a user-defined implicit operator, which + /// HasImplicitConversion would accept and which would leak CS0311 into the generated source. + /// notnull is not checked: violating it is only a compiler warning, so the constructed call still + /// compiles. + /// + private static bool SatisfiesConstraints(IMethodSymbol method, ImmutableArray arguments, Compilation compilation) + { + for (int index = 0; index < method.TypeParameters.Length; index++) + { + ITypeParameterSymbol parameter = method.TypeParameters[index]; + ITypeSymbol argument = arguments[index]; + + if ((parameter.HasReferenceTypeConstraint && !argument.IsReferenceType) + || (parameter.HasValueTypeConstraint && (!argument.IsValueType || argument.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T)) + || (parameter.HasUnmanagedTypeConstraint && !argument.IsUnmanagedType) + || (parameter.HasConstructorConstraint && !SatisfiesConstructorConstraint(argument))) + { + return false; + } + + if (parameter.ConstraintTypes.Any(constraint => + !SatisfiesConstraintType(argument, SubstituteTypeParameters(constraint, method, arguments, compilation), compilation))) + { + return false; + } + } + + return true; + } + + /// + /// Whether a type argument satisfies one constraint type under the language's constraint-satisfaction rules: + /// an identity, implicit reference, or boxing conversion. A user-defined implicit operator does not count + /// (the compiler rejects such a closing with CS0311), which is why this is not + /// Compilation.HasImplicitConversion. + /// + private static bool SatisfiesConstraintType(ITypeSymbol argument, ITypeSymbol constraint, Compilation compilation) + { + Conversion conversion = ((CSharpCompilation)compilation).ClassifyConversion(argument, constraint); + return conversion.IsIdentity || (conversion.IsImplicit && (conversion.IsReference || conversion.IsBoxing)); + } + + /// + /// Whether a type argument satisfies new(): any value type, or a non-abstract type with a public + /// parameterless instance constructor. + /// + private static bool SatisfiesConstructorConstraint(ITypeSymbol argument) + => argument.IsValueType + || (argument is INamedTypeSymbol { IsAbstract: false, } named + && named.InstanceConstructors.Any(constructor => constructor is { Parameters.Length: 0, DeclaredAccessibility: Accessibility.Public, })); + + /// + /// Substitutes a generic hook method's own type parameters with the bound arguments inside a constraint type, + /// so where T : IComparable<T> becomes IComparable<int> for the conversion check in + /// . Descends into generic type arguments, array element types + /// (where T : IEnumerable<T[]>), and the containing types of a nested constraint type + /// (where T : Outer<T>.IInner, whose type parameter lives on the container rather than the + /// nested type itself); anything else that is not the method's type parameter passes through unchanged. + /// + private static ITypeSymbol SubstituteTypeParameters(ITypeSymbol type, IMethodSymbol method, ImmutableArray arguments, Compilation compilation) + { + if (type is ITypeParameterSymbol parameter && SymbolEqualityComparer.Default.Equals(parameter.DeclaringMethod, method)) + { + return arguments[parameter.Ordinal]; + } + + if (type is INamedTypeSymbol named) + { + return SubstituteNamedType(named, method, arguments, compilation); + } + + if (type is IArrayTypeSymbol array) + { + return compilation.CreateArrayTypeSymbol(SubstituteTypeParameters(array.ElementType, method, arguments, compilation), array.Rank); + } + + return type; + } + + /// + /// Substitutes through a named constraint type (see ): the containing + /// chain first, so a nested type's outer arguments close too (Outer<T>.IInner becomes + /// Outer<int>.IInner by re-finding IInner on the substituted container), then the type's + /// own arguments. A container lookup miss fails safe by returning the type unsubstituted - the conversion + /// check in then simply rejects the closing (AWT164) instead of + /// constructing a call that would not compile. + /// + private static INamedTypeSymbol SubstituteNamedType(INamedTypeSymbol named, IMethodSymbol method, ImmutableArray arguments, Compilation compilation) + { + INamedTypeSymbol generic; + if (named.ContainingType is { } outer) + { + INamedTypeSymbol? member = SubstituteNamedType(outer, method, arguments, compilation) + .GetTypeMembers(named.Name, named.Arity) + .FirstOrDefault(); + if (member is null) + { + return named; + } + + generic = member; + } + else + { + generic = named.ConstructedFrom; + } + + return named.Arity == 0 + ? generic + : generic.Construct(named.TypeArguments + .Select(argument => SubstituteTypeParameters(argument, method, arguments, compilation)) + .ToArray()); + } /// /// Reports AWT106 when a synchronous diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Registrations.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Registrations.cs index 715e05a..656f0aa 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Registrations.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Registrations.cs @@ -45,7 +45,8 @@ internal sealed record RawRegistration( string? OnRelease = null, bool SuppressDisposal = false, string? WhenInjectedInto = null, - bool GreedyConstructor = false); + bool GreedyConstructor = false, + IReadOnlyList? HookClosedMarkers = null); /// /// An imported module: its symbol and the location of the container's [Import] attribute that @@ -172,13 +173,32 @@ public ImplInfo( /// /// The names of the static void M(TImplementation) container methods to run once the instance /// is constructed () and when its owner is disposed (), - /// or when the winning registration named none. Resolved against the container - /// in BuildInstance (AWT164 when no matching method exists). + /// or when no registration named one. Set from the winning registration; for a + /// scanned implementation a later scan's hook fills a slot the winner left unset (see + /// MergeScanHooks, AWT199 when two scans contradict). Resolved against the container in + /// BuildInstance (AWT164 when no matching method exists). /// - public string? OnActivated { get; init; } + public string? OnActivated { get; set; } /// - public string? OnRelease { get; init; } + public string? OnRelease { get; set; } + + /// + /// The closed marker forms the hook could bind its type arguments from: the + /// union, across every scan registration of this implementation naming that same hook, of the closings + /// its [Scan(typeof(IView<>))] open-generic marker contributed. Kept per hook slot (its + /// twin is ) so a second scan hooking the other slot does not widen this + /// one's set. Exactly one bindable closing (say MainWindow : IView<IMainViewModel> carries + /// IView<IMainViewModel>) dispatches a generic hook as WireView<IMainViewModel>; + /// more than one leaves its type arguments ambiguous (AWT198), decided in ResolveHook where the + /// hook's arity and constraints are known. Empty when no scan hook bound a marker (a closed or markerless + /// scan, a non-scan, or a scan with no hook), so a generic hook has nothing to bind and is unusable while + /// a non-generic hook is unaffected. + /// + public List OnActivatedMarkers { get; } = new(); + + /// + public List OnReleaseMarkers { get; } = new(); /// /// Whether the winning registration opted out of the container's built-in disposal diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs index e27ff92..bc72d1b 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs @@ -18,7 +18,10 @@ partial class AwaitenGenerator /// instead, narrowed by those same filters. Reports /// AWT138/AWT139/AWT140/AWT143/AWT172/AWT173/AWT174/AWT182/AWT183/AWT184/AWT185/AWT187/AWT188/AWT193. The synthesized /// registrations are , so an explicit registration wins single - /// resolution while every match still joins its collection. + /// resolution while every match still joins its collection. A scan's lifecycle hooks merge per slot across + /// scans in coalescing (MergeScanHooks, AWT199 when two scans contradict) and are resolved later in + /// ResolveHook like any other (AWT164/AWT190), where a generic hook bound by an open-generic marker may + /// also report AWT198 if more than one closed marker form could bind it. /// private static List CollectScans( INamedTypeSymbol containerSymbol, @@ -82,7 +85,13 @@ private static void ExpandScan( return; } - ScanMatch match = new(ScanExposureOf(attribute), ScanLifetime(attribute), location, ScanSkipsUnconstructable(attribute)); + ScanMatch match = new( + ScanExposureOf(attribute), + ScanLifetime(attribute), + location, + ScanSkipsUnconstructable(attribute), + ScanHook(attribute, "OnActivated"), + ScanHook(attribute, "OnRelease")); ScanFilters filters = ScanFiltersOf(attribute); // AWT185: As resolved to no recognized ScanAs flag (e.g. `Self & Marker`, or an out-of-range cast), so the @@ -140,7 +149,8 @@ private static void ExpandScan( continue; } - produced += RegisterScanMatch(candidate.Type, ScanContracts(candidate.Type, match, marker, openMarker, markerDefinition, compilation), markerDisplay, match, result, diagnostics); + IReadOnlyList? hookClosedMarkers = ScanHookMarkers(candidate.Type, match, openMarker, markerDefinition); + produced += RegisterScanMatch(candidate.Type, ScanContracts(candidate.Type, match, marker, openMarker, markerDefinition, compilation), markerDisplay, match, hookClosedMarkers, result, diagnostics); } ReportScanFilterDiagnostics(filters, hits, new ScanFilterCounts(assignable, registered, produced), assemblies, markerDisplay, location, diagnostics); @@ -270,6 +280,7 @@ private static int RegisterScanMatch( ScanContractSet contracts, string? markerDisplay, ScanMatch match, + IReadOnlyList? hookClosedMarkers, List result, List diagnostics) { @@ -278,13 +289,13 @@ private static int RegisterScanMatch( if (match.RegisterSelf) { - result.Add(ScanRegistration(typeName, typeName, type, type, match)); + result.Add(ScanRegistration(typeName, typeName, type, type, match, hookClosedMarkers)); produced++; } foreach (INamedTypeSymbol contract in contracts.Contracts) { - result.Add(ScanRegistration(contract.ToDisplayString(FullyQualified), typeName, type, contract, match)); + result.Add(ScanRegistration(contract.ToDisplayString(FullyQualified), typeName, type, contract, match, hookClosedMarkers)); produced++; } @@ -385,7 +396,13 @@ private static List ClosedMarkerInterfaces(INamedTypeSymbol ty /// the attribute location for diagnostics, and whether an unconstructable match is skipped with a warning /// (SkipUnconstructable) instead of erroring. Bundled so the per-match registration takes one handle. /// - private sealed record ScanMatch(ScanExposures Exposure, Lifetime Lifetime, Location? Location, bool SkipUnconstructable) + private sealed record ScanMatch( + ScanExposures Exposure, + Lifetime Lifetime, + Location? Location, + bool SkipUnconstructable, + string? OnActivated, + string? OnRelease) { public bool RegisterSelf => (Exposure & ScanExposures.Self) != 0; @@ -604,8 +621,34 @@ private static bool HasOpenTypeParameters(INamedTypeSymbol type) /// service's collection, carrying the scan's SkipUnconstructable opt-in for the /// unconstructable-match prune. /// - private static RawRegistration ScanRegistration(string service, string implementation, INamedTypeSymbol type, INamedTypeSymbol serviceSymbol, ScanMatch match) - => new(service, implementation, match.Lifetime, type, match.Location, ProductionKind.Constructor, null, false, null, serviceSymbol, true, match.SkipUnconstructable); + private static RawRegistration ScanRegistration(string service, string implementation, INamedTypeSymbol type, INamedTypeSymbol serviceSymbol, ScanMatch match, IReadOnlyList? hookClosedMarkers) + => new(service, implementation, match.Lifetime, type, match.Location, ProductionKind.Constructor, null, false, null, serviceSymbol, true, match.SkipUnconstructable, + OnActivated: match.OnActivated, OnRelease: match.OnRelease, HookClosedMarkers: hookClosedMarkers); + + /// + /// The closed marker forms a match's lifecycle hook may bind its type argument from. For an open-generic marker + /// that names a hook, these are the forms the match closes the marker at, so + /// MainWindow : IView<IMainViewModel> yields IView<IMainViewModel> and the hook is + /// dispatched as WireView<IMainViewModel>. Coalescing files the forms under the slot(s) whose + /// hook this scan names (MergeScanHooks). A match that closes the marker more than once yields several + /// forms; whether that is an error is decided in ResolveHook, where the hook's arity and constraints + /// are known: a generic hook that could bind more than one form is ambiguous (AWT198), a non-generic one is + /// unaffected. A closed or markerless scan, or a scan with no hook, binds no marker (), + /// so a generic hook there is unusable and a non-generic one resolves as-is. + /// + private static IReadOnlyList? ScanHookMarkers( + INamedTypeSymbol type, + ScanMatch match, + bool openMarker, + INamedTypeSymbol? markerDefinition) + { + if (!openMarker || (match.OnActivated is null && match.OnRelease is null)) + { + return null; + } + + return ClosedMarkerForms(type, markerDefinition!); + } /// /// The scanned marker: the type argument of the generic [Scan<TMarker>], or the @@ -662,6 +705,24 @@ private static bool ScanSkipsUnconstructable(AttributeData attribute) return false; } + /// + /// The OnActivated / OnRelease lifecycle hook name () declared on a + /// [Scan], applied to every match, or when unset. Resolved against the + /// container in BuildInstance like any other hook (AWT164/AWT189/AWT190/AWT191). + /// + private static string? ScanHook(AttributeData attribute, string name) + { + foreach (KeyValuePair argument in attribute.NamedArguments) + { + if (argument.Key == name && argument.Value.Value is string value) + { + return value; + } + } + + return null; + } + /// /// The exposure named on a [Scan] (As = ScanAs.X); its underlying int lines up with the /// generator's ScanExposures enum. Defaults to Self when unset, matching the attribute default. diff --git a/Source/Awaiten.SourceGenerators/Diagnostics.cs b/Source/Awaiten.SourceGenerators/Diagnostics.cs index 1b8a68f..a580e7b 100644 --- a/Source/Awaiten.SourceGenerators/Diagnostics.cs +++ b/Source/Awaiten.SourceGenerators/Diagnostics.cs @@ -1501,4 +1501,36 @@ internal static class Diagnostics "Awaiten", DiagnosticSeverity.Error, isEnabledByDefault: true); + + /// + /// A generic lifecycle hook (an OnActivated/OnRelease named on a + /// [Scan(typeof(IView<>))] open-generic marker) binds its type arguments from a closed marker + /// form, so it needs exactly one it can bind: this match offers several (implementing one marker at two + /// closings, say IView<A> and IView<B>, or two scans binding the same hook through + /// differently-closed markers), so the type arguments would be ambiguous. Register the type explicitly with + /// the intended hook, or split the family so only one closed form binds the hook. + /// + public static readonly DiagnosticDescriptor GenericHookAmbiguousMarker = new( + "AWT198", + "Ambiguous generic hook marker", + "The generic lifecycle hook '{1}' could bind '{0}' through more than one closed marker form ({2}), so its type arguments are ambiguous; register the type explicitly with the intended hook, or ensure only one closed form binds it", + "Awaiten", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + /// + /// Two [Scan] attributes match the same implementation and name different methods for the same + /// lifecycle hook slot (OnActivated or OnRelease); the first scan's hook wins, so the + /// contradiction is surfaced rather than silently resolved by attribute order - mirroring AWT142 for + /// lifetimes. Two scans naming the same method, or hooking different slots, merge cleanly and are not + /// reported; neither is an explicit registration of the implementation, which a scan deliberately yields + /// to, hooks included. + /// + public static readonly DiagnosticDescriptor ScanHookConflict = new( + "AWT199", + "Scans register one implementation with conflicting lifecycle hooks", + "'{0}' is matched by scans naming conflicting {1} hooks ('{2}' and '{3}'); the first scan's '{2}' is used", + "Awaiten", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); } diff --git a/Source/Awaiten/ScanAttribute.cs b/Source/Awaiten/ScanAttribute.cs index d881bd0..94aaf9c 100644 --- a/Source/Awaiten/ScanAttribute.cs +++ b/Source/Awaiten/ScanAttribute.cs @@ -60,6 +60,22 @@ public sealed class ScanAttribute : Attribute /// public bool SkipUnconstructable { get; set; } + /// + /// The name of a static void method on the container invoked once each match has been constructed - a + /// synchronous post-construction hook applied to every match. Its first parameter is the match, so it must + /// accept every one: type it as the scanned marker (or object), and any parameters after it are + /// resolved from the graph like a constructor's. + /// + public string? OnActivated { get; set; } + + /// + /// The name of a static void method on the container invoked when each match's owning container or + /// scope is disposed - in reverse creation order and before the instance's own disposal (in addition to, not + /// instead of, it). Its first parameter is the match (type it as the scanned marker or object); any + /// parameters after it are resolved from the graph. + /// + public string? OnRelease { get; set; } + /// /// Widens the scan to the assemblies containing the listed types instead of the container's own assembly. /// Each entry names one type whose is searched, typically a @@ -124,6 +140,22 @@ public sealed class ScanAttribute : Attribute /// public bool SkipUnconstructable { get; set; } + /// + /// The name of a static void method on the container invoked once each match has been constructed - a + /// synchronous post-construction hook applied to every match. Its first parameter is the match, so it must + /// accept every one: type it as (or object), and any parameters after + /// it are resolved from the graph like a constructor's. + /// + public string? OnActivated { get; set; } + + /// + /// The name of a static void method on the container invoked when each match's owning container or + /// scope is disposed - in reverse creation order and before the instance's own disposal (in addition to, not + /// instead of, it). Its first parameter is the match (type it as or + /// object); any parameters after it are resolved from the graph. + /// + public string? OnRelease { get; set; } + /// /// Widens the scan to the assemblies containing the listed types instead of the container's own assembly. /// Each entry names one type whose is searched, typically a diff --git a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt index 2bd7745..0572beb 100644 --- a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt +++ b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt @@ -240,6 +240,8 @@ namespace Awaiten public Awaiten.AwaitenLifetime Lifetime { get; set; } public string[]? NamePatterns { get; set; } public string[]? NamespacePatterns { get; set; } + public string? OnActivated { get; set; } + public string? OnRelease { get; set; } public bool SkipUnconstructable { get; set; } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] @@ -252,6 +254,8 @@ namespace Awaiten public Awaiten.AwaitenLifetime Lifetime { get; set; } public string[]? NamePatterns { get; set; } public string[]? NamespacePatterns { get; set; } + public string? OnActivated { get; set; } + public string? OnRelease { get; set; } public bool SkipUnconstructable { get; set; } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] diff --git a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt index b4b7f75..f4ffdd5 100644 --- a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt +++ b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt @@ -240,6 +240,8 @@ namespace Awaiten public Awaiten.AwaitenLifetime Lifetime { get; set; } public string[]? NamePatterns { get; set; } public string[]? NamespacePatterns { get; set; } + public string? OnActivated { get; set; } + public string? OnRelease { get; set; } public bool SkipUnconstructable { get; set; } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] @@ -252,6 +254,8 @@ namespace Awaiten public Awaiten.AwaitenLifetime Lifetime { get; set; } public string[]? NamePatterns { get; set; } public string[]? NamespacePatterns { get; set; } + public string? OnActivated { get; set; } + public string? OnRelease { get; set; } public bool SkipUnconstructable { get; set; } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] diff --git a/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt b/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt index 294e31f..c812f41 100644 --- a/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt +++ b/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt @@ -239,6 +239,8 @@ namespace Awaiten public Awaiten.AwaitenLifetime Lifetime { get; set; } public string[]? NamePatterns { get; set; } public string[]? NamespacePatterns { get; set; } + public string? OnActivated { get; set; } + public string? OnRelease { get; set; } public bool SkipUnconstructable { get; set; } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] @@ -251,6 +253,8 @@ namespace Awaiten public Awaiten.AwaitenLifetime Lifetime { get; set; } public string[]? NamePatterns { get; set; } public string[]? NamespacePatterns { get; set; } + public string? OnActivated { get; set; } + public string? OnRelease { get; set; } public bool SkipUnconstructable { get; set; } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt164InvalidLifecycleHook.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt164InvalidLifecycleHook.cs index 76326d7..c0ed989 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt164InvalidLifecycleHook.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt164InvalidLifecycleHook.cs @@ -166,5 +166,198 @@ private static void Stopping(object instance) { } await That(result.Diagnostics).DoesNotContain("*AWT164*").AsWildcard() .Because("a void method accepting the implementation (or a base type) is a usable hook"); } + + [Fact] + public async Task ReportsForAScanHookThatNamesNoMethod() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IPlugin { } + public sealed class AlphaPlugin : IPlugin { } + + [Container] + [Scan(typeof(IPlugin), OnActivated = "DoesNotExist")] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).Contains("*AWT164*").AsWildcard() + .Because("a scan's OnActivated is resolved against the container like any other hook"); + } + + [Fact] + public async Task DoesNotReportForAScanHookAcceptingTheMarker() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IPlugin { } + public sealed class AlphaPlugin : IPlugin { } + public sealed class BetaPlugin : IPlugin { } + + [Container] + [Scan(typeof(IPlugin), OnActivated = nameof(Started), OnRelease = nameof(Stopping))] + public static partial class MyContainer + { + private static void Started(IPlugin plugin) { } + private static void Stopping(IPlugin plugin) { } + } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT164*").AsWildcard() + .Because("a hook typed as the scanned marker accepts every match"); + } + + [Fact] + public async Task ReportsForAGenericScanHookWhoseConstraintRejectsTheClosing() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IView { } + public sealed class IntView : IView { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(IView view) where TViewModel : class { } + } + """); + + await That(result.Diagnostics).Contains("*AWT164*").AsWildcard() + .Because("the class constraint rejects the int closing, so the hook cannot be constructed for IntView"); + await That(result.Diagnostics).DoesNotContain("*error CS*").AsWildcard() + .Because("the violation is caught before construction rather than surfacing as a compiler error inside the generated source"); + } + + [Fact] + public async Task DoesNotReportForAGenericScanHookConstraintContainingAnArrayOfTheTypeParameter() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System.Collections; + using System.Collections.Generic; + + namespace MyCode; + + public interface IView { } + public sealed class Rows : IEnumerable + { + public IEnumerator GetEnumerator() => null!; + IEnumerator IEnumerable.GetEnumerator() => null!; + } + public sealed class RowsView : IView { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(IView view) where TViewModel : IEnumerable { } + } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT164*").AsWildcard() + .Because("Rows satisfies IEnumerable, so the constraint check must substitute the type parameter inside the array element type too"); + await That(result.Diagnostics).IsEmpty() + .Because("the closing binds cleanly, so the hook is constructed as Wire without any diagnostic"); + } + + [Fact] + public async Task ReportsForAGenericScanHookClosingAtAnInaccessibleTypeArgument() + { + GeneratorResult result = Generator.RunWithReferencedAssembly(""" + namespace Lib; + + public interface IView { } + internal sealed class SecretVm { } + public sealed class TheView : IView { } + """, """ + using Awaiten; + using Lib; + + namespace MyCode; + + [Container] + [Scan(typeof(IView<>), InAssembliesOf = new[] { typeof(IView<>) }, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(IView view) { } + } + """); + + await That(result.Diagnostics).Contains("*AWT164*").AsWildcard() + .Because("the container cannot name the internal SecretVm as a type argument, so the closing is unbindable and the hook unusable"); + await That(result.Diagnostics).DoesNotContain("*error CS*").AsWildcard() + .Because("the inaccessible closing is rejected before construction rather than leaking CS0122 into the generated source"); + } + + [Fact] + public async Task ReportsForAGenericScanHookConstraintSatisfiedOnlyByAUserDefinedConversion() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public class Widget { } + public sealed class WidgetModel + { + public static implicit operator Widget(WidgetModel model) => new Widget(); + } + public interface IView { } + public sealed class WidgetView : IView { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(IView view) where TViewModel : Widget { } + } + """); + + await That(result.Diagnostics).Contains("*AWT164*").AsWildcard() + .Because("constraint satisfaction admits only identity, reference, and boxing conversions, so the user-defined operator does not make WidgetModel satisfy the Widget constraint"); + await That(result.Diagnostics).DoesNotContain("*error CS*").AsWildcard() + .Because("the violation is caught before construction rather than leaking CS0311 into the generated source"); + } + + [Fact] + public async Task DoesNotReportForAGenericScanHookConstraintNamingANestedTypeOfTheTypeParameter() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public class Outer + { + public interface IInner { } + } + public sealed class Model : Outer.IInner { } + public interface IView { } + public sealed class ModelView : IView { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(IView view) where TViewModel : Outer.IInner { } + } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT164*").AsWildcard() + .Because("Model satisfies Outer.IInner, so the constraint check must substitute the type parameter through the nested type's containing type too"); + await That(result.Diagnostics).IsEmpty() + .Because("the closing binds cleanly, so the hook is constructed as Wire without any diagnostic"); + } } } diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt190AmbiguousLifecycleHook.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt190AmbiguousLifecycleHook.cs index ae829c9..fcb653c 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt190AmbiguousLifecycleHook.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt190AmbiguousLifecycleHook.cs @@ -79,6 +79,56 @@ await That(result.Diagnostics).DoesNotContain("*AWT164*").AsWildcard() .Because("the int overload does not accept the instance, so there is exactly one usable hook and no ambiguity"); } + [Fact] + public async Task ReportsWhenAGenericScanHookOverloadBindsBesideANonGenericOne() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IView { } + public sealed class MainWindow : IView { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(IView view) { } + private static void Wire(object instance) { } + } + """); + + await That(result.Diagnostics).Contains("*AWT190*").AsWildcard() + .Because("the generic overload binds the single closing and the object overload also accepts the match, so the container cannot choose one"); + } + + [Fact] + public async Task ReportsWhenAnAmbiguousGenericScanHookOverloadSitsBesideANonGenericOne() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IView { } + public sealed class DualView : IView, IView { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(IView view) { } + private static void Wire(object instance) { } + } + """); + + await That(result.Diagnostics).Contains("*AWT190*").AsWildcard() + .Because("the generic overload could dispatch through either closing and the object overload also accepts the match, so silently preferring the sibling would let an extra closing change which method runs"); + await That(result.Diagnostics).DoesNotContain("*AWT198*").AsWildcard() + .Because("the collision is between overloads, not closings: settling the closings would still leave two usable overloads"); + } + [Fact] public async Task DoesNotReportWhenTheSameNameServesDistinctRegistrationsOneMatchEach() { diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt198GenericHookAmbiguousMarker.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt198GenericHookAmbiguousMarker.cs new file mode 100644 index 0000000..feeb1b1 --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt198GenericHookAmbiguousMarker.cs @@ -0,0 +1,287 @@ +using System.Linq; + +namespace Awaiten.SourceGenerators.Tests; + +public partial class DiagnosticTests +{ + public class Awt198GenericHookAmbiguousMarker + { + [Fact] + public async Task ReportsWhenAMatchClosesTheMarkerMoreThanOnce() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IView { } + public sealed class DualView : IView, IView { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(IView view) { } + } + """); + + await That(result.Diagnostics).Contains("*AWT198*").AsWildcard() + .Because("DualView closes IView<> at both int and string, so a generic hook's type argument is ambiguous"); + } + + [Fact] + public async Task DoesNotReportForASingleClosedForm() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IView { } + public interface IMainViewModel { } + public sealed class MainViewModel : IMainViewModel { } + public sealed class MainWindow : IView { } + + [Container] + [Singleton] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(IView view, TViewModel viewModel) { } + } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT198*").AsWildcard() + .Because("MainWindow closes IView<> exactly once, so the generic hook's type argument is unambiguous"); + await That(result.Diagnostics).DoesNotContain("*AWT164*").AsWildcard() + .Because("the generic hook binds its type argument from the single closed marker form and is usable"); + } + + [Fact] + public async Task DoesNotReportForANonGenericHookOnAMatchClosingTheMarkerMoreThanOnce() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IView { } + public sealed class DualView : IView, IView { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Log))] + public static partial class MyContainer + { + private static void Log(object instance) { } + } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT198*").AsWildcard() + .Because("a non-generic hook needs no type argument, so a match closing the marker several times is not ambiguous"); + await That(result.Diagnostics).DoesNotContain("*AWT164*").AsWildcard() + .Because("the non-generic hook accepts every match as object and is a usable hook"); + } + + [Fact] + public async Task ReportsWhenTwoOpenMarkersBindOneGenericHookToDifferentClosings() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IView { } + public interface IEditor { } + public sealed class Dual : IView, IEditor { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + [Scan(typeof(IEditor<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(object instance) { } + } + """); + + await That(result.Diagnostics).Contains("*AWT198*").AsWildcard() + .Because("two open-generic scans bind the same generic hook to different closings of Dual, so its type argument is ambiguous rather than silently the first-seen one"); + } + + [Fact] + public async Task DoesNotReportWhenTwoMarkersConstructTheIdenticalMethod() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IView { } + public interface IEditor { } + public sealed class Dual : IView, IEditor { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + [Scan(typeof(IEditor<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(object instance) { } + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("both closings construct the identical Wire, so the dispatch is the same and nothing is ambiguous"); + await That(result.Sources.Values.Any(source => source.Contains("Wire"))).IsTrue() + .Because("the single constructed dispatch is emitted"); + } + + [Fact] + public async Task DoesNotReportWhenNoHookIsNamed() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IView { } + public sealed class DualView : IView, IView { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker)] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT198*").AsWildcard() + .Because("a match closing the marker several times is ordinary without a hook to bind a type argument for"); + } + + [Fact] + public async Task DoesNotReportWhenTwoScansHookDifferentSlots() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IView { } + public interface IEditor { } + public sealed class Dual : IView, IEditor { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + [Scan(typeof(IEditor<>), As = ScanAs.Marker, OnRelease = nameof(Cleanup))] + public static partial class MyContainer + { + private static void Wire(IView view) { } + private static void Cleanup(object instance) { } + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("each hook binds through its own scan's marker: Wire sees only IView, so nothing is ambiguous and both hooks apply"); + } + + [Fact] + public async Task ReportsTheAmbiguousClosedFormsInTheMessage() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IView { } + public sealed class DualView : IView, IView { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(IView view) { } + } + """); + + await That(result.Diagnostics).Contains("*AWT198*'Wire'*MyCode.IView, MyCode.IView*").AsWildcard() + .Because("the message names the hook and every closed form it could bind, which is what makes the ambiguity actionable"); + } + + [Fact] + public async Task DoesNotReportWhenAConstraintLeavesASingleBindableForm() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IView { } + public sealed class Payments { } + public sealed class DualView : IView, IView { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(IView view) where TViewModel : class { } + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("the class constraint rules out the int closing, so only IView can bind the hook and nothing is ambiguous"); + } + + [Fact] + public async Task DoesNotReportWhenAccessibilityLeavesASingleBindableForm() + { + GeneratorResult result = Generator.RunWithReferencedAssembly(""" + namespace Lib; + + public interface IView { } + public sealed class PublicVm { } + internal sealed class SecretVm { } + public sealed class DualView : IView, IView { } + """, """ + using Awaiten; + using Lib; + + namespace MyCode; + + [Container] + [Scan(typeof(IView<>), InAssembliesOf = new[] { typeof(IView<>) }, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(IView view) { } + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("the container cannot name the internal SecretVm, so like a constraint the accessibility check settles the family on IView"); + await That(result.Sources.Values.Any(source => source.Contains("Wire"))).IsTrue() + .Because("the single accessible closing is the one dispatched"); + } + + [Fact] + public async Task ReportsAwt164WhenTheGenericHookAritiesNeverMatchTheMarker() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IView { } + public sealed class DualView : IView, IView { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(object instance) { } + } + """); + + await That(result.Diagnostics).Contains("*AWT164*").AsWildcard() + .Because("a two-parameter generic hook can never bind a one-argument marker closing, so the name is unusable, not ambiguous"); + await That(result.Diagnostics).DoesNotContain("*AWT198*").AsWildcard() + .Because("ambiguity only applies to a hook that could actually bind more than one closed form"); + } + } +} diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt199ScanHookConflict.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt199ScanHookConflict.cs new file mode 100644 index 0000000..db7201a --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt199ScanHookConflict.cs @@ -0,0 +1,169 @@ +using System.Linq; + +namespace Awaiten.SourceGenerators.Tests; + +public partial class DiagnosticTests +{ + public class Awt199ScanHookConflict + { + [Fact] + public async Task ReportsWhenTwoScansNameDifferentOnActivatedHooks() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IPlugin { } + public interface IHandler { } + public sealed class Widget : IPlugin, IHandler { } + + [Container] + [Scan(typeof(IPlugin), OnActivated = nameof(PluginStarted))] + [Scan(typeof(IHandler), OnActivated = nameof(HandlerStarted))] + public static partial class MyContainer + { + private static void PluginStarted(object instance) { } + private static void HandlerStarted(object instance) { } + } + """); + + await That(result.Diagnostics).Contains("*AWT199*'PluginStarted'*'HandlerStarted'*").AsWildcard() + .Because("which OnActivated ran for Widget would depend on attribute order, so the contradiction is surfaced with the first scan's winner named"); + } + + [Fact] + public async Task ReportsWhenTwoScansNameDifferentOnReleaseHooks() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IPlugin { } + public interface IHandler { } + public sealed class Widget : IPlugin, IHandler { } + + [Container] + [Scan(typeof(IPlugin), Lifetime = AwaitenLifetime.Singleton, OnRelease = nameof(PluginStopped))] + [Scan(typeof(IHandler), Lifetime = AwaitenLifetime.Singleton, OnRelease = nameof(HandlerStopped))] + public static partial class MyContainer + { + private static void PluginStopped(object instance) { } + private static void HandlerStopped(object instance) { } + } + """); + + await That(result.Diagnostics).Contains("*AWT199*OnRelease*").AsWildcard() + .Because("the OnRelease slot contradicts across the two scans exactly like OnActivated"); + } + + [Fact] + public async Task DoesNotReportWhenTwoScansNameTheSameHook() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IPlugin { } + public interface IHandler { } + public sealed class Widget : IPlugin, IHandler { } + + [Container] + [Scan(typeof(IPlugin), OnActivated = nameof(Started))] + [Scan(typeof(IHandler), OnActivated = nameof(Started))] + public static partial class MyContainer + { + private static void Started(object instance) { } + } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT199*").AsWildcard() + .Because("both scans agree on the method, so there is nothing order-dependent to surface"); + } + + [Fact] + public async Task DoesNotReportWhenTwoScansHookDifferentSlots() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IStartable { } + public interface IStoppable { } + public sealed class Worker : IStartable, IStoppable { } + + [Container] + [Scan(typeof(IStartable), Lifetime = AwaitenLifetime.Singleton, OnActivated = nameof(Started))] + [Scan(typeof(IStoppable), Lifetime = AwaitenLifetime.Singleton, OnRelease = nameof(Stopped))] + public static partial class MyContainer + { + private static void Started(object instance) { } + private static void Stopped(object instance) { } + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("one scan's OnActivated and the other's OnRelease occupy different slots, so they merge instead of conflicting"); + await That(result.Sources.Values.Any(source => source.Contains("Started") && source.Contains("Stopped"))).IsTrue() + .Because("the merged registration carries both scans' hooks, not just the first scan's"); + } + + [Fact] + public async Task DoesNotReportWhenAnExplicitRegistrationOverridesTheScanHook() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IPlugin { } + public sealed class AlphaPlugin : IPlugin { } + + [Container] + [Singleton(OnActivated = nameof(Special))] + [Scan(typeof(IPlugin), OnActivated = nameof(FromScan))] + public static partial class MyContainer + { + private static void Special(IPlugin plugin) { } + private static void FromScan(IPlugin plugin) { } + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("a scan yields to an explicit registration of the same type, hooks included, so overriding is not a conflict"); + await That(result.Sources.Values.Any(source => source.Contains("Special"))).IsTrue() + .Because("the explicit registration's hook is the one that runs"); + await That(result.Sources.Values.Any(source => source.Contains("FromScan("))).IsFalse() + .Because("the scan's hook is replaced, not merged in beside the explicit one"); + } + + [Fact] + public async Task DoesNotReportWhenAnExplicitRegistrationWithoutHooksOptsOutOfTheScanHook() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IPlugin { } + public sealed class AlphaPlugin : IPlugin { } + + [Container] + [Singleton] + [Scan(typeof(IPlugin), OnActivated = nameof(FromScan))] + public static partial class MyContainer + { + private static void FromScan(IPlugin plugin) { } + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("an explicit registration replaces the scan's hooks along with everything else"); + await That(result.Sources.Values.Any(source => source.Contains("FromScan("))).IsFalse() + .Because("leaving the hook off the explicit registration deliberately opts the type out of it"); + } + } +} diff --git a/Tests/Awaiten.Tests/ScanTests.cs b/Tests/Awaiten.Tests/ScanTests.cs index f2669f7..344404f 100644 --- a/Tests/Awaiten.Tests/ScanTests.cs +++ b/Tests/Awaiten.Tests/ScanTests.cs @@ -102,6 +102,228 @@ public sealed class SalesReport : IReport; [Scan(typeof(IReport), As = ScanAs.Self | ScanAs.Marker, Lifetime = AwaitenLifetime.Singleton)] public static partial class SelfAndInterfaceScanContainer; + [Fact] + public async Task Scan_LifecycleHooks_RunForEveryMatch_WithGraphResolvedParameters() + { + HookProbe.Log.Clear(); + using (HookScanContainer.Root container = new()) + { + container.Resolve(); + container.Resolve(); + + // The scan's OnActivated ran once each match was constructed, receiving its graph-resolved HookSettings + // (a scan hook goes through the same parameter pipeline as an explicit registration's hook). + await That(HookProbe.Log).Contains("activated:AlphaHooked:True"); + await That(HookProbe.Log).Contains("activated:BetaHooked:True"); + await That(HookProbe.Log).DoesNotContain("released:AlphaHooked") + .Because("nothing is released while the container is alive"); + } + + // The scan's OnRelease ran for every match on disposal. + await That(HookProbe.Log).Contains("released:AlphaHooked"); + await That(HookProbe.Log).Contains("released:BetaHooked"); + } + + public interface IHooked; + + public sealed class AlphaHooked : IHooked; + + public sealed class BetaHooked : IHooked; + + public sealed class HookSettings; + + private static class HookProbe + { + public static readonly List Log = new(); + } + + [Container] + [Singleton] + [Scan(typeof(IHooked), Lifetime = AwaitenLifetime.Singleton, OnActivated = nameof(Activated), OnRelease = nameof(Released))] + public static partial class HookScanContainer + { + private static void Activated(IHooked instance, HookSettings settings) + => HookProbe.Log.Add($"activated:{instance.GetType().Name}:{settings is not null}"); + + private static void Released(IHooked instance) => HookProbe.Log.Add("released:" + instance.GetType().Name); + } + + [Fact] + public async Task Scan_OpenGenericMarker_DispatchesAGenericHookWithTheClosedTypeArgument() + { + HookProbe.Log.Clear(); + using HookViewContainer.Root container = new(); + + IHookView view = container.Resolve>(); + + await That(view).IsNotNull(); + // The scan's open marker closes as IHookView for HookWindow, so the generic hook is dispatched + // as WireView - binding the matching view model, resolved from the graph, with no reflection. + await That(HookProbe.Log).Contains("wired:HookWindow:HookViewModel") + .Because("a generic scan hook binds its type argument from the match's closed marker form"); + } + + public interface IHookView; + + public interface IHookViewModel; + + public sealed class HookViewModel : IHookViewModel; + + public sealed class HookWindow : IHookView; + + [Container] + [Singleton] + [Scan(typeof(IHookView<>), As = ScanAs.Marker, Lifetime = AwaitenLifetime.Singleton, OnActivated = nameof(WireView))] + public static partial class HookViewContainer + { + private static void WireView(IHookView view, TViewModel viewModel) + => HookProbe.Log.Add($"wired:{view.GetType().Name}:{viewModel!.GetType().Name}"); + } + + [Fact] + public async Task Scan_OpenGenericMarker_RunsANonGenericHookForAMatchClosingTheMarkerMoreThanOnce() + { + HookProbe.Log.Clear(); + using DualPanelContainer.Root container = new(); + + DualPanel panel = container.Resolve(); + + await That(panel).IsNotNull(); + // DualPanel closes IPanel<> at both int and string, but a non-generic hook needs no type argument, so it + // is not ambiguous (no AWT198) and runs once for the single instance. + await That(HookProbe.Log).Contains("tracked:DualPanel") + .Because("a non-generic scan hook accepts every match as object, whatever closings the match implements"); + } + + public interface IPanel; + + public sealed class DualPanel : IPanel, IPanel; + + [Container] + [Scan(typeof(IPanel<>), As = ScanAs.Self | ScanAs.Marker, Lifetime = AwaitenLifetime.Singleton, OnActivated = nameof(Track))] + public static partial class DualPanelContainer + { + private static void Track(object instance) => HookProbe.Log.Add("tracked:" + instance.GetType().Name); + } + + [Fact] + public async Task Scan_TwoScansHookingDifferentSlots_MergeSoBothHooksRun() + { + HookProbe.Log.Clear(); + using (MergeHookContainer.Root container = new()) + { + container.Resolve(); + + // MergeWorker is matched by both scans: one names OnActivated, the other OnRelease. The hooks occupy + // different slots, so they merge instead of the second scan's being dropped (or conflicting, AWT199). + await That(HookProbe.Log).Contains("merge-activated:MergeWorker"); + } + + await That(HookProbe.Log).Contains("merge-released:MergeWorker") + .Because("the second scan's OnRelease merged onto the coalesced registration and ran at disposal"); + } + + public interface IMergeStart; + + public interface IMergeStop; + + public sealed class MergeWorker : IMergeStart, IMergeStop; + + [Container] + [Scan(typeof(IMergeStart), Lifetime = AwaitenLifetime.Singleton, OnActivated = nameof(Started))] + [Scan(typeof(IMergeStop), Lifetime = AwaitenLifetime.Singleton, OnRelease = nameof(Stopped))] + public static partial class MergeHookContainer + { + private static void Started(object instance) => HookProbe.Log.Add("merge-activated:" + instance.GetType().Name); + + private static void Stopped(object instance) => HookProbe.Log.Add("merge-released:" + instance.GetType().Name); + } + + [Fact] + public async Task Scan_OpenGenericMarker_DispatchesAGenericReleaseHookWithTheClosedTypeArgument() + { + HookProbe.Log.Clear(); + using (UnwireViewContainer.Root container = new()) + { + container.Resolve>(); + + await That(HookProbe.Log).DoesNotContain("unwired:IUnwireViewModel") + .Because("nothing is released while the container is alive"); + } + + // The marker closes as IUnwireView for UnwireWindow, so the generic OnRelease hook is + // dispatched as UnwireView at disposal, exactly like a generic OnActivated at construction. + await That(HookProbe.Log).Contains("unwired:IUnwireViewModel") + .Because("a generic scan hook binds its type argument on the release slot too"); + } + + public interface IUnwireView; + + public interface IUnwireViewModel; + + public sealed class UnwireWindow : IUnwireView; + + [Container] + [Scan(typeof(IUnwireView<>), As = ScanAs.Marker, Lifetime = AwaitenLifetime.Singleton, OnRelease = nameof(UnwireView))] + public static partial class UnwireViewContainer + { + private static void UnwireView(IUnwireView view) + => HookProbe.Log.Add("unwired:" + typeof(TViewModel).Name); + } + + [Fact] + public async Task Scan_OpenGenericMarker_DispatchesAGenericHookWithTwoTypeArguments() + { + HookProbe.Log.Clear(); + using PairBinderContainer.Root container = new(); + + container.Resolve>(); + + // The marker closes at two type arguments, so the hook is constructed with both: BindPair. + await That(HookProbe.Log).Contains("bound:String:Int32") + .Because("a generic scan hook binds every type parameter of a multi-argument marker closing"); + } + + public interface IPairBinder; + + public sealed class PriceBinder : IPairBinder; + + [Container] + [Scan(typeof(IPairBinder<,>), As = ScanAs.Marker, Lifetime = AwaitenLifetime.Singleton, OnActivated = nameof(BindPair))] + public static partial class PairBinderContainer + { + private static void BindPair(IPairBinder binder) + => HookProbe.Log.Add($"bound:{typeof(TSource).Name}:{typeof(TTarget).Name}"); + } + + [Fact] + public async Task Scan_OpenGenericBaseClassMarker_DispatchesAGenericHookWithTheClosedTypeArgument() + { + HookProbe.Log.Clear(); + using BasePanelContainer.Root container = new(); + + container.Resolve(); + + // OrdersPanel closes the base-class marker as PanelBase, so the generic hook binds the + // type argument from a base type exactly as it does from an implemented interface. + await That(HookProbe.Log).Contains("panel:OrdersPanelModel") + .Because("a marker closed through the inheritance chain feeds a generic hook like an interface closing"); + } + + public sealed class OrdersPanelModel; + + public abstract class PanelBase; + + public sealed class OrdersPanel : PanelBase; + + [Container] + [Scan(typeof(PanelBase<>), Lifetime = AwaitenLifetime.Singleton, OnActivated = nameof(WirePanel))] + public static partial class BasePanelContainer + { + private static void WirePanel(PanelBase panel) + => HookProbe.Log.Add("panel:" + typeof(TModel).Name); + } + [Fact] public async Task GenericScan_RegistersMatchesLikeTheTypeofForm() {