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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions Docs/pages/09-diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TViewModel>;
public sealed class DualView : IView<Orders>, IView<Payments>; // closes IView<> twice

[Container]
[Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))]
public static partial class CoffeeShop
{
private static void Wire<TViewModel>(IView<TViewModel> 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
Expand Down
5 changes: 5 additions & 0 deletions Docs/pages/lifetime/05-lifecycle-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
36 changes: 36 additions & 0 deletions Docs/pages/registration/07-scanning.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<IDrink>(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<Espresso>]` 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<MainViewModel, IMainViewModel>]
[Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(WireView))]
public static partial class App
{
private static void WireView<TViewModel>(IView<TViewModel> view, TViewModel viewModel)
=> view.DataContext = viewModel;
}
```

For `MainWindow : IView<IMainViewModel>` the generator dispatches `WireView<IMainViewModel>(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<A>, IView<B>`), 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.
Expand Down
2 changes: 2 additions & 0 deletions Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,5 @@
AWT191 | Awaiten | Error | An OnRelease hook parameter is a Func/Lazy relationship, which would defer resolution past the owner's teardown
AWT192 | Awaiten | Error | SuppressDisposal is set on a pre-built Instance, which the container does not own or dispose, so it has no effect
AWT193 | Awaiten | Warning | A [Scan] matched a type that is inaccessible to the generated container, so it is not registered
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
76 changes: 75 additions & 1 deletion Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
HashSet<string> reportedConflicts = new(StringComparer.Ordinal);
HashSet<string> reportedProductionConflicts = new(StringComparer.Ordinal);
HashSet<string> reportedDirectiveConflicts = new(StringComparer.Ordinal);
HashSet<string> 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.
Expand Down Expand Up @@ -149,7 +150,7 @@
// 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);
Expand Down Expand Up @@ -196,6 +197,79 @@
}
}

/// <summary>
/// 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
/// <c>OnActivated</c> and another's <c>OnRelease</c> 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 <c>ResolveHook</c> (AWT198) rather than
/// silently fixed to the first-seen closing. Naming a <em>different</em> method for a claimed slot is
/// <see cref="Diagnostics.ScanHookConflict">AWT199</see> (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.
/// </summary>
private static void MergeScanHooks(
ImplInfo info,
RawRegistration registration,
HashSet<string> reportedHookConflicts,
List<DiagnosticInfo> 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);
}

/// <summary>
/// Merges one hook slot (see <see cref="MergeScanHooks" />): 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.
/// </summary>
private static string? MergeScanHookSlot(
RawRegistration registration,
string slot,
string? current,
string? name,
List<INamedTypeSymbol> slotMarkers,
HashSet<string> reportedHookConflicts,
List<DiagnosticInfo> 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<string>([Display(registration.ImplementationType), slot, current, name,])));
}

return current;
}

if (registration.HookClosedMarkers is { } markers)
{
foreach (INamedTypeSymbol marker in markers)

Check warning on line 261 in Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Loops should be simplified using the "Where" LINQ method

See more on https://sonarcloud.io/project/issues?id=Testably_Awaiten&issues=AZ9woH4jwJ29FAlTwQJr&open=AZ9woH4jwJ29FAlTwQJr&pullRequest=118
{
if (!slotMarkers.Any(seen => SymbolEqualityComparer.Default.Equals(seen, marker)))
{
slotMarkers.Add(marker);
}
}
}

return name;
}

/// <summary>
/// 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 <c>Key</c>. Reached only from
Expand Down
Loading
Loading