diff --git a/Docs/pages/09-diagnostics.md b/Docs/pages/09-diagnostics.md index 14ac436..4bdc760 100644 --- a/Docs/pages/09-diagnostics.md +++ b/Docs/pages/09-diagnostics.md @@ -1333,19 +1333,22 @@ public static class EquipmentModule ### AWT154 :::danger[Error] -An imported module declares a `[Scan]`, which is not collected from modules. +An imported module declares a `[Scan]`, but its assembly carries no generated expansion. ::: ```csharp +// In a referenced library that was compiled WITHOUT the Awaiten source generator: [Module] -[Scan] // scans are not gathered from modules -public static class MenuModule; +[Scan] // never expanded, so it would contribute nothing +public static partial class MenuModule; [Container] [Import(typeof(MenuModule))] public static partial class CoffeeShop; ``` +*(Repurposed: this ID previously rejected any `[Scan]` on a module.)* A `[Scan]` on a `[Module]` is [self-compiled in the module's own build](./registration/modules#self-compiled-scans), which stamps the module with a generated marker even when the scan matched nothing. A module whose metadata carries a `[Scan]` but no marker was compiled without the Awaiten generator, or with a version predating self-compiled scans, so its scan would silently contribute nothing. Rebuild the library with the Awaiten generator referenced. Reported at the consumer's `[Import]`. The self-compilation constraints themselves are reported as [AWT194](#awt194)–[AWT197](#awt197), [AWT200](#awt200)–[AWT202](#awt202). + ### AWT155 :::warning[Warning] @@ -1367,6 +1370,119 @@ public static class ModuleB; public static partial class CoffeeShop; ``` +### AWT194 + +:::danger[Error] +A `[Module]` that declares a `[Scan]` is not `partial`, so its scan cannot be self-compiled. +::: + +```csharp +[Module] +[Scan(As = ScanAs.MatchingInterface)] +public static class PluginModule; // must be partial to receive the generated factories +``` + +A [self-compiled module scan](./registration/modules#self-compiled-scans) emits a factory method and a registration attribute into the module's partial. Add the `partial` modifier. Reported in the module's own build. + +### AWT195 + +:::danger[Error] +A self-compiled module `[Scan]` match has a constructor parameter of a type inaccessible outside the module's assembly. +::: + +```csharp +internal sealed class Secret; +internal sealed class Roaster(Secret secret) : IRoaster; // Secret is internal + +[Module] +[Scan(As = ScanAs.MatchingInterface)] +public static partial class PluginModule; +``` + +The generated factory is a `public` method whose parameters are resolved from the *consuming* container's graph, so each parameter type has to be nameable by the consumer. Widen the parameter type's accessibility, or exclude the match. (A v1 limitation: a self-compiled scan cannot construct a match through an inaccessible parameter.) + +### AWT196 + +:::warning[Warning] +A self-compiled module `[Scan]` match has no exposure interface accessible outside the module's assembly, so it is skipped. +::: + +```csharp +internal sealed class Roaster : IPlugin; + +[Module] +[Scan(As = ScanAs.Self)] // Self exposes the internal type, which a consumer cannot name +public static partial class PluginModule; +``` + +A consumer resolves a self-compiled match only through an accessible interface. `ScanAs.Self` over an `internal` implementation exposes nothing nameable, so the match registers nothing and is skipped, mirroring the warning severity a container scan gives a match it cannot register ([AWT182](#awt182)/[AWT188](#awt188)/[AWT193](#awt193)). Expose it through a public interface (`ScanAs.MatchingInterface` or `ScanAs.Marker`), or exclude the match. + +### AWT197 + +:::danger[Error] +A self-compiled module `[Scan]` match would be exposed under more than one interface. +::: + +```csharp +internal sealed class Roaster : IPlugin, IRoaster; + +[Module] +[Scan(As = ScanAs.Marker | ScanAs.MatchingInterface)] // IPlugin and IRoaster both apply +public static partial class PluginModule; +``` + +A self-compiled match is reached through a generated factory that returns a single accessible interface, so a shared instance across several interfaces cannot be expressed (unlike a container `[Scan]`, whose matches coalesce on the concrete type). The same applies across scans: two `[Scan]`s on one module that expose the same type under two different interfaces would emit two factories and silently split the instance, so that overlap is also rejected. Narrow the exposure to a single interface (typically `ScanAs.MatchingInterface`), or exclude the match. This is a v1 limitation. + +### AWT200 + +:::danger[Error] +A self-compiled module `[Scan]` match carries injection metadata the generated factory cannot mirror. +::: + +```csharp +internal sealed class Roaster : IRoaster +{ + public Roaster(IClock clock) { } + + [Inject] // keys, optionality and deferral live on this attribute + public IGrinder Grinder { get; set; } +} + +[Module] +[Scan(As = ScanAs.MatchingInterface)] +public static partial class PluginModule; +``` + +The generated factory reduces a match to a plain parameter list resolved from the consuming container's graph. An `[Inject]` property, or a constructor parameter marked `[Inject]` or `[Arg]`, carries per-dependency semantics that plain parameters cannot express, so the consumer would silently construct the match differently than a container `[Scan]` would. Remove the attribute, exclude the match, or register the type through a hand-written module factory. + +### AWT201 + +:::danger[Error] +A generic `[Module]` (or one nested in a generic type) declares a `[Scan]`. +::: + +```csharp +[Module] +[Scan(As = ScanAs.MatchingInterface)] +public static partial class PluginModule; // no closed PluginModule exists to [Import] +``` + +A consumer imports a module by `typeof`, so there is no single closed module type to import from a generic declaration, and the generated partial could not re-open it by its bare name. Move the `[Scan]` onto a non-generic module. + +### AWT202 + +:::danger[Error] +A module `[Scan]` declares `InAssembliesOf`, but a self-compiled scan sweeps only the module's own assembly. +::: + +```csharp +[Module] +[Scan(As = ScanAs.Marker, InAssembliesOf = new[] { typeof(OtherLibMarker) })] +public static partial class PluginModule; +``` + +A self-compiled scan exists to reach the module's own `internal` types. Sweeping another assembly from a module would see only that assembly's public types, which a container `[Scan]` with `InAssembliesOf` already covers, so the module form would silently do less than the container form. Remove `InAssembliesOf` to scan the module's own assembly, or move the `[Scan]` onto the container. + ## Keyed collections ### AWT159 diff --git a/Docs/pages/registration/07-scanning.md b/Docs/pages/registration/07-scanning.md index 4f1afce..b0595d2 100644 --- a/Docs/pages/registration/07-scanning.md +++ b/Docs/pages/registration/07-scanning.md @@ -110,6 +110,10 @@ An explicit registration of a scanned type wins over the scan, so you can specia Prefer explicit registrations. A scan trades away the property that makes the composition root useful: the whole graph visible in one place. Reach for a scan only for a large, uniform family that grows on its own, like message handlers, validators, or plug-ins, where listing each one adds churn without adding clarity. For a handful of services, spell them out, see [Design principles](../design-principles#when-power-becomes-a-smell). ::: +## Scan from a module + +A `[Scan]` may also sit on a `[Module]`, where it is compiled in the module's own build so a library can scan its `internal` implementations and expose them to consumers through their interfaces. See [self-compiled scans](./modules#self-compiled-scans). + ## Where to go next - [Modules](./modules) to group registrations for reuse. diff --git a/Docs/pages/registration/08-modules.md b/Docs/pages/registration/08-modules.md index d2630f3..89ed15f 100644 --- a/Docs/pages/registration/08-modules.md +++ b/Docs/pages/registration/08-modules.md @@ -54,9 +54,32 @@ public static class ProductionModule } ``` +## Self-compiled scans + +A library often keeps its implementations `internal` and exposes only interfaces. A consuming container cannot construct an inaccessible type, so it could never register one — unless the library hand-wrote a factory per type. A `[Scan]` on a `[Module]` closes that gap: the module compiles its own scan **in its own build**, emitting a factory per match that constructs the implementation (which its own assembly can see) and returns the accessible interface. A consuming container reads each match like a container [`[Scan]`](./scanning) match, so a self-compiled scan behaves as close to a container scan as the assembly boundary allows. + +```csharp +public interface IClock; +public interface IPlugin; +public interface IRoaster; +internal sealed class Roaster(IClock clock) : IPlugin, IRoaster; // stays internal + +[Module] +[Scan(As = ScanAs.MatchingInterface, Lifetime = AwaitenLifetime.Singleton)] +public static partial class PluginModule; // partial, so the generator can add the factory +``` + +A consuming container `[Import]`s the module and resolves `IRoaster` without ever naming `Roaster`. Each match is registered like a container scan match: an explicit registration of the same service in the container (or another module) **overrides** it, and when several matches expose the **same** interface they **collect** — a `[Scan(As = ScanAs.Marker)]` over two internal plug-ins resolves as `IEnumerable`, exactly as it would on a container. Because diagnostics are reported in the *library's* build, the library author — not the consumer — sees any problem. + +The module must be static ([AWT152](../diagnostics#awt152)), `partial` ([AWT194](../diagnostics#awt194)) and non-generic ([AWT201](../diagnostics#awt201)), and its scan sweeps the module's own assembly only ([AWT202](../diagnostics#awt202)). A few v1 limitations apply, each reported at the library's source. A match registers through exactly one accessible interface: a match with none is skipped with a warning ([AWT196](../diagnostics#awt196)), and one that would expose several is rejected ([AWT197](../diagnostics#awt197)), even when the exposures come from two different scans of the same module. Several matches under one interface still collect; what a self-compiled match cannot do is share a single instance across several interfaces the way a container scan can. A match's constructor parameters must be types a consumer can name ([AWT195](../diagnostics#awt195)), without `[Inject]`/`[Arg]` metadata the factory could not mirror ([AWT200](../diagnostics#awt200)). `SkipUnconstructable` works as on a container scan: it travels with each generated registration, so a consumer that cannot satisfy a match's dependencies drops the match with a warning ([AWT141](../diagnostics#awt141)) instead of failing the build. + +The expansion also stamps the module with a generated marker, present even when the scan matched nothing. A consuming container uses it as a version guard: importing a module whose metadata carries a `[Scan]` but no expansion, because the library was built without the Awaiten generator or with a version predating this feature, is an error ([AWT154](../diagnostics#awt154)) rather than a silent drop. + +When the module lives in the **same assembly as the container**, there is no assembly boundary to bridge: the container expands the module's `[Scan]` directly, exactly as if it were declared on the container itself, with full container-scan semantics (multi-interface exposure, direct construction of `internal` matches). The self-compiled factories are still generated into the module, so the same module keeps working for any *other* assembly that imports it; but note that the v1 limitations above are checked in the module's build regardless, since the module cannot know whether a cross-assembly consumer exists. + ## One level deep -Imports are not transitive. A module's own `[Import]` is not followed, and a `[Scan]` inside a module is not collected. Keep the container as the single place that composes modules. +Imports are not transitive. A module's own `[Import]` is not followed. Keep the container as the single place that composes modules. (A module's own `[Scan]`, by contrast, does reach the importing container: expanded directly by the container for a same-assembly module, or self-compiled in the module's build across assemblies — see above.) *Note: importing a type that is not a `[Module]` is an error ([AWT149](../diagnostics#awt149)), and a module must be static ([AWT152](../diagnostics#awt152)).* diff --git a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md index fd0f4fc..25a0d42 100644 --- a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -55,7 +55,7 @@ AWT151 | Awaiten | Warning | An imported module declares no registrations AWT152 | Awaiten | Error | An imported [Module] class is not declared static AWT153 | Awaiten | Error | A module Factory/Instance member is not accessible from the generated container - AWT154 | Awaiten | Error | An imported module declares a [Scan], which is not collected from modules + AWT154 | Awaiten | Error | An imported module declares a [Scan] but its assembly carries no generated expansion, so it was built without the Awaiten generator (or a version predating self-compiled module scans) AWT155 | Awaiten | Warning | Two imported modules strongly register the same service with different implementations AWT156 | Awaiten | Warning | A generated Root/Scope is disposed synchronously although its container owns a service that implements IAsyncDisposable but not IDisposable AWT157 | Awaiten | Error | An [Inject(Optional = true)] property is required and cannot be omitted from the object initializer @@ -95,3 +95,10 @@ 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 + AWT194 | Awaiten | Error | A [Module] that declares a [Scan] is not partial, so its scan cannot be self-compiled + 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 + 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.Analysis.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Analysis.cs index d08bb47..a36602e 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Analysis.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Analysis.cs @@ -1299,6 +1299,13 @@ internal static string Display(string fullyQualified) internal static string DisplayInstance(string implementationType) { int marker = implementationType.IndexOf("@" + DecoratorKeyPrefix, StringComparison.Ordinal); + if (marker < 0) + { + // A self-compiled module-scan match carries a synthetic '@scan:' identity (see + // CollectGeneratedScanRegistration); trim it so a diagnostic names the registered service. + marker = implementationType.IndexOf(ScanKeyMarker, StringComparison.Ordinal); + } + return Display(marker >= 0 ? implementationType.Substring(0, marker) : implementationType); } } diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs index 20ac8fe..c2ab42a 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs @@ -7,6 +7,13 @@ namespace Awaiten.SourceGenerators; partial class AwaitenGenerator { + /// + /// The marker inside a self-compiled module-scan match's synthetic implementation identity + /// (<service>@scan:<qualified factory>, see ). + /// DisplayInstance keys off it to trim the synthetic suffix from diagnostics. + /// + private const string ScanKeyMarker = "@scan:"; + private static (List Raw, HashSet ConstraintRejectedServices) Collect( INamedTypeSymbol containerSymbol, List modules, @@ -28,10 +35,14 @@ private static (List Raw, HashSet ConstraintRejectedSer // [Import(typeof(Module))] pulls a module's registrations in after the container's own, so the container // wins ties and a module's overridable defaults (Fallback.Warn/Silent) only fill the gaps it leaves. Resolved - // one level deep; a module's own [Import] is not followed. + // one level deep; a module's own [Import] is not followed. A same-compilation module's + // [GeneratedScanRegistration] attributes are skipped: the generator itself never sees them (its own output), + // but an analyzer running over the post-generation compilation does, and collecting them alongside the + // direct scan expansion below would double every match in its graph. foreach (ImportedModule module in modules) { - CollectLifetimeRegistrations(module.Symbol, result, open, diagnostics, origin: module.Symbol, fallbackLocation: module.ImportLocation); + bool sameCompilation = SymbolEqualityComparer.Default.Equals(module.Symbol.ContainingAssembly, containerSymbol.ContainingAssembly); + CollectLifetimeRegistrations(module.Symbol, result, open, diagnostics, origin: module.Symbol, fallbackLocation: module.ImportLocation, skipGeneratedScanRegistrations: sameCompilation); } // Assembly scanning contributes overridable registrations for every concrete type assignable to a @@ -40,6 +51,22 @@ private static (List Raw, HashSet ConstraintRejectedSer List scans = CollectScans(containerSymbol, compilation, diagnostics); result.AddRange(scans); + // A same-compilation module's [Scan] is expanded here, exactly as if it were declared on the container: + // the generator cannot see its own output, so the module's self-compiled [GeneratedScanRegistration] + // attributes are invisible within the compilation that declares the module — but there is no assembly + // boundary either, so the container can evaluate the scan directly, with full container-scan semantics. + // A referenced-assembly module is excluded: its metadata carries the self-compiled expansion instead, and + // re-running its scan here would double-register every match. The expansion's diagnostics are discarded: + // the module pipeline (BuildModuleModel) already reports on the same [Scan] at the same location, and + // reporting here too would double every scan-level warning. + foreach (ImportedModule module in modules) + { + if (SymbolEqualityComparer.Default.Equals(module.Symbol.ContainingAssembly, containerSymbol.ContainingAssembly)) + { + result.AddRange(CollectScans(module.Symbol, compilation, new List())); + } + } + // Expand open generic registrations: for every closed generic service required from the graph // whose open form is registered but which has no concrete registration, synthesize the closed // implementation (iterating to a fixpoint over its own generic dependencies). @@ -49,11 +76,13 @@ private static (List Raw, HashSet ConstraintRejectedSer } // ...then moved back to the end: coalescing is first-wins per service, so the explicit registrations and - // the closed registrations expanded from them must precede the overridable scan ones. - if (scans.Count > 0) + // the closed registrations expanded from them must precede the overridable scan ones — the container's own + // [Scan] matches and a module's self-compiled [GeneratedScanRegistration] matches alike, both IsScan. + List scanMatches = result.FindAll(registration => registration.IsScan); + if (scanMatches.Count > 0) { result.RemoveAll(registration => registration.IsScan); - result.AddRange(scans); + result.AddRange(scanMatches); } return (result, constraintRejected); @@ -73,7 +102,8 @@ private static void CollectLifetimeRegistrations( List open, List diagnostics, INamedTypeSymbol? origin, - Location? fallbackLocation) + Location? fallbackLocation, + bool skipGeneratedScanRegistrations = false) { foreach (AttributeData attribute in symbol.GetAttributes()) { @@ -83,6 +113,20 @@ private static void CollectLifetimeRegistrations( continue; } + // A [GeneratedScanRegistration(factory, Lifetime = …)] is what a [Module] self-compiled from + // its own [Scan] (one per match). Read it back like a container [Scan] match rather than a hand-written + // factory registration, so several matches under one interface collect instead of colliding. Skipped + // for a same-compilation module, whose [Scan] the container expands directly (see Collect). + if (attributeClass is { Name: "GeneratedScanRegistrationAttribute", IsGenericType: true, }) + { + if (!skipGeneratedScanRegistrations) + { + CollectGeneratedScanRegistration(attribute, attributeClass, result, origin, fallbackLocation); + } + + continue; + } + Lifetime? lifetime = attributeClass.Name switch { "SingletonAttribute" => Lifetime.Singleton, @@ -159,6 +203,65 @@ private static void CollectLifetimeRegistrations( } } + /// + /// Reads one [GeneratedScanRegistration<TService>(factory, Lifetime = …)] - what a + /// [Module] self-compiled from its own [Scan], one per match - into an IsScan factory + /// registration, so the consuming container treats it exactly like a container [Scan] match: + /// collection-eligible (several matches under one interface resolve as an IEnumerable) and overridable + /// by an explicit registration (scans rank last). Each match gets a synthetic implementation identity keyed + /// by its module-qualified factory name, distinct from the service's own type so the emitter still constructs + /// through the accessible service; without it two matches of one interface would collapse into one + /// implementation and collide as conflicting factories. The module qualification matters: factory names are + /// only unique per module, so two imported modules whose matches share a simple type name would otherwise + /// collapse into one member, silently dropping the later module's match from the collection. + /// + private static void CollectGeneratedScanRegistration( + AttributeData attribute, + INamedTypeSymbol attributeClass, + List result, + INamedTypeSymbol? origin, + Location? fallbackLocation) + { + if (attributeClass.TypeArguments.Length != 1 + || attributeClass.TypeArguments[0] is not INamedTypeSymbol service + || attribute.ConstructorArguments.Length != 1 + || attribute.ConstructorArguments[0].Value is not string factory) + { + return; + } + + Lifetime lifetime = Lifetime.Transient; + bool skipUnconstructable = false; + foreach (KeyValuePair argument in attribute.NamedArguments) + { + if (argument.Key == "Lifetime" && argument.Value.Value is int value) + { + lifetime = (Lifetime)value; + } + else if (argument.Key == "SkipUnconstructable" && argument.Value.Value is bool skip) + { + skipUnconstructable = skip; + } + } + + string serviceType = service.ToDisplayString(FullyQualified); + Location? location = attribute.ApplicationSyntaxReference?.GetSyntax().GetLocation() ?? fallbackLocation; + string qualifiedFactory = origin is null ? factory : $"{origin.ToDisplayString(FullyQualified)}.{factory}"; + + result.Add(new RawRegistration( + serviceType, + $"{serviceType}{ScanKeyMarker}{qualifiedFactory}", + lifetime, + service, + location, + ProductionKind.Factory, + factory, + ServiceSymbol: service, + IsScan: true, + ScanSkipsUnconstructable: skipUnconstructable, + Origin: origin)); + } + /// /// AWT168: a contextual binding is stored under a synthetic per-consumer key (see ContextKey), which /// overrides an explicit Key entirely, so the two together silently drop the Key. Report it @@ -223,7 +326,8 @@ private static List CollectImportedModules(INamedTypeSymbol cont // not the module declaration, which may live in another file (or, for a module compiled into a // referenced assembly, in no source at all). Location? importLocation = attribute.ApplicationSyntaxReference?.GetSyntax().GetLocation(); - if (ValidateImportedModule(module, importLocation, diagnostics)) + bool sameCompilation = SymbolEqualityComparer.Default.Equals(module.ContainingAssembly, containerSymbol.ContainingAssembly); + if (ValidateImportedModule(module, importLocation, sameCompilation, diagnostics)) { modules.Add(new ImportedModule(module, importLocation)); } @@ -233,14 +337,18 @@ private static List CollectImportedModules(INamedTypeSymbol cont } /// - /// Validates one [Import(typeof(Module))] target, reporting AWT149-154, and returns whether it is + /// Validates one [Import(typeof(Module))] target, reporting AWT149-152, and returns whether it is /// importable. A non-[Module] target (AWT149) is skipped (); the other faults /// are reported but still imported (): non-static (AWT152), a nested [Import] - /// (AWT150), a module-declared [Scan] (AWT154), or no registrations (AWT151). + /// (AWT150), or no registrations (AWT151). A module-declared [Scan] is accepted (self-compiled in the + /// module's own build, AWT154 retired) rather than rejected. marks a + /// module declared in the container's own compilation, whose [Scan] the container expands directly + /// (its self-compiled attributes are invisible here), so the scan counts as a registration for AWT151. /// private static bool ValidateImportedModule( INamedTypeSymbol module, Location? importLocation, + bool sameCompilation, List diagnostics) { LocationInfo? location = LocationInfo.From(importLocation); @@ -273,19 +381,28 @@ private static bool ValidateImportedModule( Diagnostics.NestedModuleImport, location, new EquatableArray([moduleName,]))); } - // AWT154: [Scan] sweeps an assembly relative to the container and is not collected from modules, so a - // module-declared scan would be silently ignored; reject it instead. Reported at the module's own [Scan] - // when in source, else at the container's [Import]. - if (TryGetAwaitenAttribute(moduleAttributes, "ScanAttribute", out AttributeData? scan)) + // A referenced-assembly module's [Scan] is self-compiled in the module's own build (the module emits a + // generated factory and lifetime registration per match, plus a [GeneratedScanExpansion] marker), which + // this collection reads like any other module registration. A same-compilation module's [Scan] is instead + // expanded by the container itself (see Collect); the generator cannot see the module's generated marker + // there (its own output), so the skew check below excludes it. + bool declaresScan = HasAwaitenAttribute(moduleAttributes, "ScanAttribute"); + + // AWT154: the metadata carries a [Scan] but no expansion marker, so the module assembly was built without + // the Awaiten generator (or a version predating self-compiled scans) and the scan would silently + // contribute nothing. The marker is emitted even for a scan that matched nothing, so its absence is + // conclusive, not a maybe. + if (!sameCompilation && declaresScan && !HasAwaitenAttribute(moduleAttributes, "GeneratedScanExpansionAttribute")) { diagnostics.Add(new DiagnosticInfo( - Diagnostics.ScanOnModule, - LocationInfo.From(scan?.ApplicationSyntaxReference?.GetSyntax().GetLocation()) ?? location, - new EquatableArray([moduleName,]))); + Diagnostics.ModuleScanNotExpanded, location, new EquatableArray([moduleName,]))); } - // AWT151: a module that declares no lifetime registrations imports nothing useful. - if (!DeclaresAnyRegistration(moduleAttributes)) + // AWT151: a module that declares no lifetime registrations imports nothing useful. A module with a [Scan] + // is exempt regardless of what the scan produced: the module's own build reports an empty scan at its + // declaration (AWT138/AWT184), and an unexpanded one is AWT154 above, so repeating the blame at the + // consumer's [Import] would point at code the consumer does not own. + if (!DeclaresAnyRegistration(moduleAttributes) && !declaresScan) { diagnostics.Add(new DiagnosticInfo( Diagnostics.EmptyModule, location, new EquatableArray([moduleName,]))); @@ -298,8 +415,9 @@ private static bool ValidateImportedModule( /// Whether an attribute list carries anything a module contributes to an importing container: a /// [Singleton]/[Transient]/[Scoped] lifetime registration (generic or open /// typeof form), a [Decorate], a [Composite], or [ImportServices]. Used to - /// detect a module that declares nothing to import (AWT151); a module-declared [Scan] does not - /// count, being uncollected and its own error (AWT154). + /// detect a module that declares nothing to import (AWT151). A module-declared [Scan] does not + /// count here on its own: its matches are self-compiled into [GeneratedScanRegistration] attributes + /// (which do count), so a scan-only module whose scan matched something carries those generated attributes. /// private static bool DeclaresAnyRegistration(ImmutableArray attributes) { @@ -308,7 +426,8 @@ private static bool DeclaresAnyRegistration(ImmutableArray attrib if (attribute.AttributeClass is { } attributeClass && attributeClass.ContainingNamespace?.ToDisplayString() == AttributeNamespace && attributeClass.Name is "SingletonAttribute" or "TransientAttribute" or "ScopedAttribute" - or "DecorateAttribute" or "CompositeAttribute" or "ImportServicesAttribute") + or "DecorateAttribute" or "CompositeAttribute" or "ImportServicesAttribute" + or "GeneratedScanRegistrationAttribute") { return true; } diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.ModuleScan.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.ModuleScan.cs new file mode 100644 index 0000000..d1a5766 --- /dev/null +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.ModuleScan.cs @@ -0,0 +1,358 @@ +using System.Collections.Immutable; +using Awaiten.SourceGenerators.Entities; +using Awaiten.SourceGenerators.Internals; +using Microsoft.CodeAnalysis; + +namespace Awaiten.SourceGenerators; + +partial class AwaitenGenerator +{ + /// + /// Expands every [Scan] declared on a [Module] into the factories the module self-compiles: + /// one public static method per match that constructs the (possibly internal) implementation + /// and returns its accessible exposure interface. The emitter pairs each with a + /// [GeneratedScanRegistration<IExposure>(factory, Lifetime = …)], which a consuming container + /// reads like a container [Scan] match - collection-eligible (several matches under one interface + /// resolve as an IEnumerable) and overridable by an explicit registration. Runs in the + /// module's own build, so diagnostics land at the library's source locations, and the scan sees the + /// library's internal types (the whole point: keep implementations internal, expose interfaces). + /// Reuses the container scan's candidate discovery, filters and their diagnostics + /// (AWT138/AWT143/AWT172/AWT173/AWT174/AWT183/AWT184/AWT185), and adds AWT195/AWT196/AWT197/AWT200/AWT202 + /// for the self-compilation constraints (accessible parameters, a single accessible exposure per match, no + /// injection metadata the factory could not mirror, the module's own assembly only). + /// + private static List CollectModuleScanFactories( + INamedTypeSymbol module, + Compilation compilation, + List diagnostics) + { + List factories = new(); + + foreach (AttributeData attribute in module.GetAttributes()) + { + if (attribute.AttributeClass is not { Name: "ScanAttribute", } attributeClass + || attributeClass.ContainingNamespace?.ToDisplayString() != AttributeNamespace) + { + continue; + } + + if (ScanMarker(attribute, attributeClass) is { } marker) + { + ExpandModuleScan(attribute, marker, module, compilation, factories, diagnostics); + } + else if (IsMarkerlessScan(attribute, attributeClass)) + { + ExpandModuleScan(attribute, null, module, compilation, factories, diagnostics); + } + } + + return factories; + } + + /// + /// Expands one module [Scan] over its candidates, mirroring but emitting a + /// generated factory per match instead of a . A module scans its own assembly + /// only (AWT202 rejects InAssembliesOf), so an internal match is legitimate. + /// + private static void ExpandModuleScan( + AttributeData attribute, + INamedTypeSymbol? marker, + INamedTypeSymbol module, + Compilation compilation, + List factories, + List diagnostics) + { + Location? location = attribute.ApplicationSyntaxReference?.GetSyntax().GetLocation(); + + // v1: a module scans its own assembly only. An InAssembliesOf sweep from a module would see just the + // target's public types, which a container [Scan] already covers, so it is rejected (AWT202) rather + // than silently doing less than the container form. + if (ScanAssemblies(attribute) is not null) + { + diagnostics.Add(new DiagnosticInfo( + Diagnostics.ModuleScanForeignAssemblies, + LocationInfo.From(location), + new EquatableArray([Display(module.ToDisplayString(FullyQualified)),]))); + return; + } + + ScanMatch match = new(ScanExposureOf(attribute), ScanLifetime(attribute), location, ScanSkipsUnconstructable(attribute)); + ScanFilters filters = ScanFiltersOf(attribute); + + if ((match.Exposure & ScanExposures.All) == 0) + { + diagnostics.Add(new DiagnosticInfo(Diagnostics.ScanExposesNothing, LocationInfo.From(location), new EquatableArray([]))); + return; + } + + if (marker is null && MarkerlessScanError(match.Exposure, filters, assemblies: null) is { } reason) + { + diagnostics.Add(new DiagnosticInfo(Diagnostics.MarkerlessScanInvalid, LocationInfo.From(location), new EquatableArray([reason,]))); + return; + } + + bool openMarker = marker is not null && IsOpenGenericMarker(marker); + INamedTypeSymbol? markerDefinition = marker?.OriginalDefinition; + + string? markerDisplay = null; + if (marker is not null) + { + markerDisplay = (openMarker ? markerDefinition! : marker).ToDisplayString(FullyQualified); + } + + ScanFilterHits hits = new(); + int assignable = 0; + int registered = 0; + int produced = 0; + foreach (ScanCandidate candidate in ScanCandidates(assemblies: null, compilation, marker, location, diagnostics)) + { + assignable++; + if (!PassesScanFilters(candidate.Type, filters, hits)) + { + continue; + } + + registered++; + + // A private/protected nested type the module cannot name is skipped silently: unlike a container scan + // (AWT193), a module scans its own assembly, so an internal implementation is not inaccessible here and + // is the expected match. Only a truly unnameable candidate reaches this, which no author asked for. + if (candidate.Inaccessible) + { + continue; + } + + produced += RegisterModuleScanMatch(candidate.Type, match, marker, openMarker, markerDefinition, module, compilation, factories, diagnostics); + } + + ReportScanFilterDiagnostics(filters, hits, new ScanFilterCounts(assignable, registered, produced), assemblies: null, markerDisplay, location, diagnostics); + } + + /// + /// Emits the factory for one module-scan match, or reports why it cannot: the exposure must resolve to a + /// single interface a consumer in another assembly can name (AWT196 when none, AWT197 when several, since a + /// factory returns one accessible type and a shared instance across interfaces cannot be expressed), and the + /// constructor's parameters must all be accessible outside the assembly (AWT195), because they appear on the + /// generated public factory and are resolved from the consumer's graph. A match carrying injection + /// metadata the factory cannot mirror ([Inject] properties, [Inject]/[Arg] parameters) is AWT200. A match + /// another scan of this module already exposed the same way is deduped silently (a differing lifetime + /// across the overlap is AWT142, first scan wins); one another scan exposed under a different + /// interface is AWT197, since two factories would split the shared instance. Returns the number of + /// factories contributed (0 or 1). + /// + private static int RegisterModuleScanMatch( + INamedTypeSymbol type, + ScanMatch match, + INamedTypeSymbol? marker, + bool openMarker, + INamedTypeSymbol? markerDefinition, + INamedTypeSymbol module, + Compilation compilation, + List factories, + List diagnostics) + { + string typeName = type.ToDisplayString(FullyQualified); + + // The interfaces this match would expose, restricted to those a consumer in any assembly can name. Self + // exposes the concrete type, externally usable only when the type itself is public. + List exposures = new(); + if (match.RegisterSelf && IsExternallyAccessible(type)) + { + exposures.Add(type); + } + + if (match.RegisterMarker && marker is not null) + { + exposures.AddRange( + (openMarker ? ClosedMarkerInterfaces(type, markerDefinition!) : MarkerInterfaces(type, marker, compilation)) + .Where(IsExternallyAccessible)); + } + + if (match.RegisterMatchingInterface) + { + exposures.AddRange(MatchingInterfaces(type).Where(IsExternallyAccessible)); + } + + // A combined Marker | MatchingInterface can select the same interface twice; dedup by name. + List distinct = new(); + HashSet seen = new(StringComparer.Ordinal); + foreach (INamedTypeSymbol exposure in exposures) + { + if (seen.Add(exposure.ToDisplayString(FullyQualified))) + { + distinct.Add(exposure); + } + } + + if (distinct.Count == 0) + { + diagnostics.Add(new DiagnosticInfo( + Diagnostics.ModuleScanNoAccessibleExposure, + LocationInfo.From(match.Location), + new EquatableArray([Display(typeName),]))); + return 0; + } + + if (distinct.Count > 1) + { + diagnostics.Add(new DiagnosticInfo( + Diagnostics.ModuleScanMultipleExposures, + LocationInfo.From(match.Location), + new EquatableArray([ + Display(typeName), + string.Join(", ", distinct.Select(exposure => Display(exposure.ToDisplayString(FullyQualified)))), + ]))); + return 0; + } + + INamedTypeSymbol service = distinct[0]; + string serviceName = service.ToDisplayString(FullyQualified); + + // Two [Scan]s on one module can match the same type. Under the same exposure the overlap is deduped to + // the first factory, mirroring the container's per-implementation dedup, with AWT142 when the lifetimes + // differ (first scan's lifetime wins consistently). Under different exposures it is AWT197: each factory + // constructs its own instance, so the single shared instance a container scan gives one implementation + // across several interfaces cannot be expressed, and emitting both would silently split it. + ModuleFactory? overlapping = factories.Find(factory => factory.ImplementationType == typeName); + if (overlapping is not null) + { + if (overlapping.ServiceType != serviceName) + { + diagnostics.Add(new DiagnosticInfo( + Diagnostics.ModuleScanMultipleExposures, + LocationInfo.From(match.Location), + new EquatableArray([ + Display(typeName), + $"{Display(overlapping.ServiceType)}, {Display(serviceName)}", + ]))); + return 0; + } + + if (overlapping.Lifetime != match.Lifetime) + { + diagnostics.Add(new DiagnosticInfo( + Diagnostics.ScanLifetimeConflict, + LocationInfo.From(match.Location), + new EquatableArray([ + Display(typeName), + overlapping.Lifetime.ToString(), + match.Lifetime.ToString(), + ]))); + } + + return 0; + } + + // [Inject] properties and [Inject]/[Arg] constructor parameters carry per-dependency semantics (keys, + // optionality, deferral, resolution arguments) that a mirrored factory signature cannot express, so the + // consumer would silently construct the match differently than a container scan would (AWT200). + foreach (IPropertySymbol property in InjectedProperties(type)) + { + diagnostics.Add(new DiagnosticInfo( + Diagnostics.ModuleScanInjectionMetadata, + LocationInfo.From(match.Location), + new EquatableArray([Display(typeName), $"its property '{property.Name}' is marked [Inject]",]))); + return 0; + } + + // The constructor the generated factory calls. Accessibility is asked against the module (it emits the + // 'new'), so an internal constructor in the module's own assembly qualifies. A greediest-fallback keeps a + // type with no satisfiable-here constructor building (its parameters are resolved by the consumer, not us). + IMethodSymbol? constructor = SelectConstructor( + type, module, compilation, Array.Empty(), new ExternalSurface(false, new HashSet(StringComparer.Ordinal)), _ => true); + if (constructor is null) + { + diagnostics.Add(new DiagnosticInfo( + Diagnostics.NoAccessibleConstructor, + LocationInfo.From(match.Location), + new EquatableArray([Display(typeName),]))); + return 0; + } + + List parameters = new(); + foreach (IParameterSymbol parameter in constructor.Parameters) + { + // AWT200 for parameters, same reasoning as the [Inject] property check above. + ImmutableArray parameterAttributes = parameter.GetAttributes(); + if (HasInject(parameterAttributes) || HasArgAttribute(parameterAttributes)) + { + string attributeName = HasInject(parameterAttributes) ? "[Inject]" : "[Arg]"; + diagnostics.Add(new DiagnosticInfo( + Diagnostics.ModuleScanInjectionMetadata, + LocationInfo.From(match.Location), + new EquatableArray([Display(typeName), $"its constructor parameter '{parameter.Name}' is marked {attributeName}",]))); + return 0; + } + + // The parameter type appears on the public factory signature and is resolved from the consumer's graph, + // so it must be nameable outside the assembly (AWT195). This is the v1 limitation on self-compiled scans. + if (!IsExternallyAccessible(parameter.Type)) + { + diagnostics.Add(new DiagnosticInfo( + Diagnostics.ModuleScanParameterInaccessible, + LocationInfo.From(match.Location), + new EquatableArray([Display(typeName), Display(parameter.Type.ToDisplayString(FullyQualified)),]))); + return 0; + } + + parameters.Add(new FactoryParameter(parameter.Type.ToDisplayString(FullyQualified), parameter.Name)); + } + + factories.Add(new ModuleFactory( + ModuleFactoryName(type), + serviceName, + typeName, + match.Lifetime, + match.SkipUnconstructable, + new EquatableArray(parameters.ToArray()))); + return 1; + } + + /// + /// A deterministic factory method name for one match, stable across library versions: prefixed to avoid + /// clashing with the module's own members, carrying the match's simple name for readability, and suffixed + /// with an FNV-1a hash of its fully-qualified name so two same-named matches in different namespaces stay + /// distinct. The name depends only on the match's own full name — never on scan or discovery order — so + /// adding or removing other matches in a later library version does not rename it, and a consumer compiled + /// against the older assembly still binds after a drop-in upgrade. + /// + private static string ModuleFactoryName(INamedTypeSymbol type) + { + uint hash = 2166136261; + foreach (char character in type.ToDisplayString(FullyQualified)) + { + hash = unchecked((hash ^ character) * 16777619); + } + + return $"Awaiten__Scan_{type.Name}_{hash:x8}"; + } + + /// + /// Whether a type can be named from an unrelated assembly: it (and every enclosing type) is public, + /// and any generic type arguments are themselves externally accessible. An array is accessible when its + /// element type is. Used to gate a self-compiled scan's exposure interface and its factory's parameter types, + /// which appear on a public generated signature. + /// + private static bool IsExternallyAccessible(ITypeSymbol type) + { + switch (type) + { + case IArrayTypeSymbol array: + return IsExternallyAccessible(array.ElementType); + case ITypeParameterSymbol: + return true; + case INamedTypeSymbol named: + for (INamedTypeSymbol? current = named; current is not null; current = current.ContainingType) + { + if (current.DeclaredAccessibility != Accessibility.Public) + { + return false; + } + } + + return named.TypeArguments.All(IsExternallyAccessible); + default: + return false; + } + } +} diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs index cdedc95..69474f2 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs @@ -1136,9 +1136,18 @@ private static bool PruneUnconstructableScanRound( HashSet checkedImpls = new(StringComparer.Ordinal); foreach (RawRegistration registration in raw.Where(r => r.ScanSkipsUnconstructable).ToList()) { - if (pinnedImpls.Contains(registration.ImplementationType) - || !checkedImpls.Add(registration.ImplementationType) - || FirstUnconstructableReason(registration.Implementation, containerSymbol, compilation, services, constraintRejected, external, variance) is not { } reason) + if (pinnedImpls.Contains(registration.ImplementationType) || !checkedImpls.Add(registration.ImplementationType)) + { + continue; + } + + // A self-compiled module-scan match is produced by its generated module factory, not by a constructor + // this container can see (the implementation is internal to the module's assembly), so its + // satisfiability is the factory's parameters, which mirror that constructor's. + string? reason = registration is { Production: ProductionKind.Factory, Origin: not null, ProductionMember: not null, } + ? FirstUnsatisfiableFactoryReason(registration, services, constraintRejected, external, variance) + : FirstUnconstructableReason(registration.Implementation, containerSymbol, compilation, services, constraintRejected, external, variance); + if (reason is null) { continue; } @@ -1183,7 +1192,59 @@ private static bool PruneUnconstructableScanRound( return "it has no constructor accessible to the container"; } - foreach (IParameterSymbol parameter in constructor.Parameters) + if (FirstUnsatisfiableParameterReason(constructor, services, constraintRejected, external, variance) is { } parameterReason) + { + return parameterReason; + } + + foreach (IPropertySymbol property in InjectedProperties(implementation)) + { + if (UnsatisfiableInjectedMemberReason(property, containerSymbol, compilation, services, constraintRejected, external.ServiceTypes) is { } reason) + { + return reason; + } + } + + return null; + } + + /// + /// The reason a self-compiled module-scan match cannot be produced (an AWT141 fragment), or + /// when every parameter of its generated module factory is satisfiable. The factory's + /// parameters mirror the internal implementation's constructor, plain and unattributed (the module's own + /// build rejected the rest, AWT200), so the check is the same per-parameter satisfiability a scanned + /// constructor gets. A factory member missing from the module is not a prune concern; the emitted call would + /// fail compilation with a targeted error anyway. + /// + private static string? FirstUnsatisfiableFactoryReason( + RawRegistration registration, + HashSet services, + HashSet constraintRejected, + ExternalSurface external, + VarianceState variance) + { + IMethodSymbol? factory = registration.Origin!.GetMembers(registration.ProductionMember!) + .OfType() + .FirstOrDefault(); + return factory is null + ? null + : FirstUnsatisfiableParameterReason(factory, services, constraintRejected, external, variance); + } + + /// + /// The reason one of a production method's parameters cannot be satisfied from the round's surface (an + /// AWT141 fragment), or when all are. Shared by the constructor check + /// () and the generated-module-factory check + /// (), whose parameters resolve identically. + /// + private static string? FirstUnsatisfiableParameterReason( + IMethodSymbol method, + HashSet services, + HashSet constraintRejected, + ExternalSurface external, + VarianceState variance) + { + foreach (IParameterSymbol parameter in method.Parameters) { ParameterModel model = ClassifyParameter(parameter, asyncFactory: false, external.ServiceTypes); bool satisfiable = @@ -1199,14 +1260,6 @@ model.Kind is DependencyKind.Arg or DependencyKind.External } } - foreach (IPropertySymbol property in InjectedProperties(implementation)) - { - if (UnsatisfiableInjectedMemberReason(property, containerSymbol, compilation, services, constraintRejected, external.ServiceTypes) is { } reason) - { - return reason; - } - } - return null; } diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs index 744e580..637b826 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs @@ -2,6 +2,7 @@ using Awaiten.SourceGenerators.Entities; using Awaiten.SourceGenerators.Internals; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; @@ -23,6 +24,7 @@ namespace Awaiten.SourceGenerators; public sealed partial class AwaitenGenerator : IIncrementalGenerator { private const string ContainerAttributeName = "Awaiten.ContainerAttribute"; + private const string ModuleAttributeName = "Awaiten.ModuleAttribute"; private const string AttributeNamespace = "Awaiten"; /// @@ -45,6 +47,123 @@ public void Initialize(IncrementalGeneratorInitializationContext context) spc.AddSource(model.HintName, SourceText.From(Sources.Emit(model), Encoding.UTF8)); }); + + // A second root: a [Module] that declares a [Scan] self-compiles it, emitting a factory + registration per + // match into its own partial (BuildModuleModel returns null for a plain module, which emits nothing). + IncrementalValuesProvider moduleModels = context.SyntaxProvider + .ForAttributeWithMetadataName( + ModuleAttributeName, + static (node, _) => node is ClassDeclarationSyntax, + static (ctx, ct) => BuildModuleModel(ctx, ct)) + .Where(static model => model is not null) + .Select(static (model, _) => model!); + + context.RegisterSourceOutput(moduleModels, static (spc, model) => + { + foreach (DiagnosticInfo diagnostic in model.Diagnostics.AsArray()) + { + spc.ReportDiagnostic(diagnostic.ToDiagnostic()); + } + + // An expanded module always emits, even with zero factories: the partial then carries just the + // [GeneratedScanExpansion] marker, which a consuming container needs to tell "the scan matched + // nothing" from "the scan was never expanded" (AWT154). A module rejected before expansion + // (AWT152/AWT194/AWT201) emits nothing; its error already fails the build. + if (model.Expanded) + { + spc.AddSource(model.HintName, SourceText.From(Sources.EmitModule(model), Encoding.UTF8)); + } + }); + } + + /// + /// Builds the for a [Module] that declares a [Scan] (returning + /// for a module without one, which self-compiles nothing). The module must be + /// non-generic (AWT201) and partial to receive the generated factories and registration attributes + /// (AWT194); when it is, its scans are expanded into factories in its own build (see + /// ). + /// + private static ModuleScanModel? BuildModuleModel(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) + { + if (context.TargetSymbol is not INamedTypeSymbol moduleSymbol) + { + return null; + } + + // Only a module that declares a [Scan] self-compiles; a plain [Module] carries no generated code (and pays + // no analysis cost beyond this check). + if (!HasAwaitenAttribute(moduleSymbol.GetAttributes(), "ScanAttribute")) + { + return null; + } + + Compilation compilation = context.SemanticModel.Compilation; + List diagnostics = new(); + + // The module must be partial for the generator to add the factories and registration attributes. Every part + // of a partial type carries the partial modifier, so the declaration bearing the [Module] attribute suffices. + bool isPartial = context.TargetNode is ClassDeclarationSyntax declaration + && declaration.Modifiers.Any(SyntaxKind.PartialKeyword); + + List factories = new(); + bool expanded = false; + if (HasOpenTypeParameters(moduleSymbol)) + { + // AWT201: a generic module (or one nested in a generic type) has no single closed type a consumer could + // import, and re-opening it as a bare-named partial would emit an unrelated non-generic class instead. + diagnostics.Add(new DiagnosticInfo( + Diagnostics.GenericModuleScan, + LocationInfo.From(moduleSymbol.Locations.FirstOrDefault()), + new EquatableArray([Display(moduleSymbol.ToDisplayString(FullyQualified)),]))); + } + else if (!moduleSymbol.IsStatic) + { + // AWT152, reported here in the module's own build (the import-side check only reaches a module some + // container in the same solution imports): the generated partial re-opens the module as static, so + // emitting into a non-static class would surface as a raw partial-modifier compiler error instead. + diagnostics.Add(new DiagnosticInfo( + Diagnostics.NonStaticModule, + LocationInfo.From(moduleSymbol.Locations.FirstOrDefault()), + new EquatableArray([Display(moduleSymbol.ToDisplayString(FullyQualified)),]))); + } + else if (!isPartial) + { + diagnostics.Add(new DiagnosticInfo( + Diagnostics.NonPartialModuleScan, + LocationInfo.From(moduleSymbol.Locations.FirstOrDefault()), + new EquatableArray([Display(moduleSymbol.ToDisplayString(FullyQualified)),]))); + } + else + { + factories = CollectModuleScanFactories(moduleSymbol, compilation, diagnostics); + expanded = true; + } + + string? moduleNamespace = moduleSymbol.ContainingNamespace is { IsGlobalNamespace: false, } ns + ? ns.ToDisplayString() + : null; + + List containingTypes = new(); + for (INamedTypeSymbol? outer = moduleSymbol.ContainingType; outer is not null; outer = outer.ContainingType) + { + containingTypes.Insert(0, new TypeDeclaration(KeywordOf(outer), outer.Name)); + } + + string typePath = containingTypes.Count > 0 + ? $"{string.Join("+", containingTypes.Select(t => t.Name))}+{moduleSymbol.Name}" + : moduleSymbol.Name; + string hintName = moduleNamespace is null + ? $"Awaiten.ModuleScan.{typePath}.g.cs" + : $"Awaiten.ModuleScan.{moduleNamespace}.{typePath}.g.cs"; + + return new ModuleScanModel( + moduleNamespace, + new EquatableArray(containingTypes.ToArray()), + moduleSymbol.Name, + hintName, + expanded, + new EquatableArray(factories.ToArray()), + new EquatableArray(diagnostics.ToArray())); } private static ContainerModel? BuildModel(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) @@ -208,7 +327,7 @@ internal static GraphModel BuildGraph( AsyncDisposableSupport(compilation), compilation.GetTypeByMetadataName("Awaiten.IAsyncInitializable")); - // The imported modules, resolved once. Import validation (AWT149-152, AWT154) is reported while collecting. + // The imported modules, resolved once. Import validation (AWT149-152) is reported while collecting. List modules = CollectImportedModules(containerSymbol, diagnostics); // [ImportServices]: any otherwise-unresolved direct dependency falls through to the external provider diff --git a/Source/Awaiten.SourceGenerators/Diagnostics.cs b/Source/Awaiten.SourceGenerators/Diagnostics.cs index 7855f6b..1e19c70 100644 --- a/Source/Awaiten.SourceGenerators/Diagnostics.cs +++ b/Source/Awaiten.SourceGenerators/Diagnostics.cs @@ -788,15 +788,17 @@ internal static class Diagnostics isEnabledByDefault: true); /// - /// An imported [Module] carries a [Scan]. Assembly scanning is a container concern (it - /// sweeps assemblies relative to the container) and is not collected from modules, so a module-declared - /// scan would contribute nothing. This is an error rather than a warning so the scan is not silently - /// dropped. Move it onto the container. - /// - public static readonly DiagnosticDescriptor ScanOnModule = new( + /// (Repurposed.) An imported module's metadata declares a [Scan] but carries no generated + /// expansion marker ([GeneratedScanExpansion]): the module assembly was compiled without the Awaiten + /// source generator, or with a version predating self-compiled module scans (whose AWT154 rejected a module + /// [Scan] outright). The scan would silently contribute nothing, so this stays an error, reported at + /// the consumer's [Import]. Not applicable to a same-compilation module, whose scan the container + /// expands directly and whose generated marker the generator cannot see (its own output). + /// + public static readonly DiagnosticDescriptor ModuleScanNotExpanded = new( "AWT154", - "Scan on module not supported", - "The imported module '{0}' declares a [Scan], which is not collected from modules and contributes nothing; move the [Scan] onto the container", + "Module scan was not expanded", + "The imported module '{0}' declares a [Scan] but its assembly carries no generated expansion; it was compiled without the Awaiten source generator (or with a version predating self-compiled module scans), so the scan would contribute nothing; rebuild the module's assembly with the Awaiten generator referenced", "Awaiten", DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -1394,4 +1396,108 @@ internal static class Diagnostics "Awaiten", DiagnosticSeverity.Warning, isEnabledByDefault: true); + + /// + /// A [Module] carries a [Scan] but is not declared partial, so the generator cannot + /// add the factory methods and registration attributes that self-compile the scan into it. Add the + /// partial modifier. Reported in the module's own build, at the module's declaration. + /// + public static readonly DiagnosticDescriptor NonPartialModuleScan = new( + "AWT194", + "Module with a scan is not partial", + "the module '{0}' declares a [Scan] but is not partial, so its scan cannot be self-compiled; add the partial modifier to the module class", + "Awaiten", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + /// + /// A self-compiled module [Scan] matched a type whose constructor takes a parameter of a type that + /// is not accessible outside the module's assembly. The generated factory is a public method that + /// resolves that parameter from the consuming container's graph, so the parameter type has to be nameable + /// by the consumer. Widen the parameter type's accessibility, or exclude the match from the scan. In v1 a + /// self-compiled scan cannot construct a match through an inaccessible parameter. + /// + public static readonly DiagnosticDescriptor ModuleScanParameterInaccessible = new( + "AWT195", + "Module scan match has an inaccessible constructor parameter", + "'{0}' matched the module scan, but its constructor parameter of type '{1}' is not accessible outside the module's assembly, so the generated factory cannot expose it; widen the parameter type's accessibility or exclude the type from the scan", + "Awaiten", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + /// + /// A self-compiled module [Scan] matched a type but no exposure selected an interface accessible + /// outside the module's assembly, so a consumer could never name what the match registers under and the + /// match is skipped. A ScanAs.Self exposure of an internal implementation hits this (the + /// implementation type is itself inaccessible), as does a match whose only convention/marker interface is + /// internal. Expose an accessible interface (ScanAs.MatchingInterface or ScanAs.Marker + /// over a public interface), or exclude the type from the scan. A warning, not an error, mirroring the + /// container scan's skip-with-warning for a match it cannot register (AWT182/AWT188/AWT193). + /// + public static readonly DiagnosticDescriptor ModuleScanNoAccessibleExposure = new( + "AWT196", + "Module scan match has no accessible exposure", + "'{0}' matched the module scan, but no exposure selected an interface accessible outside the module's assembly, so a consumer could not resolve it; expose it through a public interface (ScanAs.MatchingInterface or ScanAs.Marker) or exclude it from the scan", + "Awaiten", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + /// + /// A self-compiled module [Scan] matched a type that would be exposed under more than one accessible + /// interface. A self-compiled match is reached only through a generated factory that returns a single + /// accessible interface, so a shared instance across several interfaces cannot be expressed (unlike a + /// container scan, whose matches coalesce on the concrete type). In v1 narrow the exposure to a single + /// interface (typically ScanAs.MatchingInterface), or exclude the type from the scan. + /// + public static readonly DiagnosticDescriptor ModuleScanMultipleExposures = new( + "AWT197", + "Module scan match has multiple exposures", + "'{0}' matched the module scan, but it would be exposed under multiple interfaces ({1}); a self-compiled scan supports a single exposure per match, so narrow the As to one interface or exclude the type from the scan", + "Awaiten", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + /// + /// A self-compiled module [Scan] match carries injection metadata the generated factory cannot + /// mirror: an [Inject] property, or a constructor parameter marked [Inject] or [Arg]. + /// The factory reduces a match to a plain parameter list resolved from the consumer's graph, so keys, + /// optionality, deferral and resolution arguments would be silently dropped, and the same type would behave + /// differently scanned by a container. Rejected rather than silently degraded. + /// + public static readonly DiagnosticDescriptor ModuleScanInjectionMetadata = new( + "AWT200", + "Module scan match uses injection metadata", + "'{0}' matched the module scan, but {1}, which the generated factory cannot mirror and would silently drop; remove the attribute, exclude the type from the scan, or register it through a hand-written module factory", + "Awaiten", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + /// + /// A generic [Module] (or one nested in a generic type) declares a [Scan]. There is no single + /// closed module type a consumer could import, and the generated partial could not even re-open the generic + /// declaration by its bare name, so the scan could never reach a consumer. Reported instead of silently + /// emitting an unrelated non-generic partial. + /// + public static readonly DiagnosticDescriptor GenericModuleScan = new( + "AWT201", + "Module with a scan is generic", + "the module '{0}' declares a [Scan] but is generic (or nested in a generic type), so no closed module exists for a consumer to import; move the [Scan] onto a non-generic module", + "Awaiten", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + /// + /// A module [Scan] declares InAssembliesOf. A self-compiled scan exists to reach the module's + /// own internal types; sweeping another assembly from a module sees only that assembly's public + /// types, which a container [Scan] already covers, so the module form would silently do less than + /// the container form. Rejected in v1: scan the module's own assembly, or move the [Scan] onto the + /// container. + /// + public static readonly DiagnosticDescriptor ModuleScanForeignAssemblies = new( + "AWT202", + "Module scan names other assemblies", + "the module '{0}' declares a [Scan] with InAssembliesOf, but a self-compiled scan sweeps only the module's own assembly (another assembly's internal types stay invisible to it, and its public types a container [Scan] already covers); remove InAssembliesOf or move the [Scan] onto the container", + "Awaiten", + DiagnosticSeverity.Error, + isEnabledByDefault: true); } diff --git a/Source/Awaiten.SourceGenerators/Entities/ModuleScanModel.cs b/Source/Awaiten.SourceGenerators/Entities/ModuleScanModel.cs new file mode 100644 index 0000000..0802eb3 --- /dev/null +++ b/Source/Awaiten.SourceGenerators/Entities/ModuleScanModel.cs @@ -0,0 +1,40 @@ +using Awaiten.SourceGenerators.Internals; +using Microsoft.CodeAnalysis; + +namespace Awaiten.SourceGenerators.Entities; + +/// +/// The equatable model of a [Module] that compiles its own [Scan]: the partial class to +/// re-open and the factories to emit into it, one per scan match. The generator adds a partial declaration +/// carrying a generated public static factory per match (which news the — possibly +/// internal — implementation and returns its accessible exposure interface) plus a +/// [GeneratedScanRegistration] referencing that factory. A consuming container reads that attribute +/// like a container [Scan] match, so the matches collect and are overridable, as close to a container +/// scan as the assembly boundary allows. +/// +internal sealed record ModuleScanModel( + string? Namespace, + EquatableArray ContainingTypes, + string TypeName, + string HintName, + bool Expanded, + EquatableArray Factories, + EquatableArray Diagnostics); + +/// +/// One generated module-scan factory: the emitted method name, the accessible exposure interface it returns +/// (also the service the registration exposes), the concrete implementation it constructs, the lifetime and +/// SkipUnconstructable opt-in the scan declared, and the constructor parameters mirrored onto the +/// factory signature (resolved from the consuming container's graph, exactly like a hand-written module +/// factory's parameters). +/// +internal sealed record ModuleFactory( + string FactoryName, + string ServiceType, + string ImplementationType, + Lifetime Lifetime, + bool SkipUnconstructable, + EquatableArray Parameters); + +/// A mirrored constructor parameter on a generated factory: its fully-qualified type and its name. +internal readonly record struct FactoryParameter(string Type, string Name); diff --git a/Source/Awaiten.SourceGenerators/Sources/Sources.Module.cs b/Source/Awaiten.SourceGenerators/Sources/Sources.Module.cs new file mode 100644 index 0000000..9273174 --- /dev/null +++ b/Source/Awaiten.SourceGenerators/Sources/Sources.Module.cs @@ -0,0 +1,111 @@ +using System.Text; +using Awaiten.SourceGenerators.Entities; + +namespace Awaiten.SourceGenerators; + +/// +/// Emits the generated partial of a [Module] that self-compiles its [Scan]: the module class is +/// re-opened as partial, carrying one [GeneratedScanRegistration] per match and one +/// public static factory method that constructs the match and returns its accessible exposure interface. +/// The factory body news the (possibly internal) implementation - legal because this code compiles +/// in the module's own assembly - while the consumer only ever names the interface and calls the factory. +/// +internal static partial class Sources +{ + public static string EmitModule(ModuleScanModel model) + { + StringBuilder builder = new(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine(); + + int depth = 0; + bool hasNamespace = !string.IsNullOrEmpty(model.Namespace); + if (hasNamespace) + { + builder.Append("namespace ").AppendLine(model.Namespace); + builder.AppendLine("{"); + depth++; + } + + foreach (TypeDeclaration containing in model.ContainingTypes.AsArray()) + { + Indent(builder, depth).Append("partial ").Append(containing.Keyword).Append(' ').AppendLine(containing.Name); + Indent(builder, depth).AppendLine("{"); + depth++; + } + + // The expansion marker: proof the module's [Scan] passed through the generator, emitted even when the + // scan matched nothing, so a consuming container can tell "matched nothing" from "never expanded" and + // report AWT154 only for the latter (a module assembly built without the Awaiten generator). + Indent(builder, depth).AppendLine("[global::Awaiten.GeneratedScanExpansionAttribute]"); + + // One [GeneratedScanRegistration] per generated factory, applied to the re-opened partial module. The + // consuming container reads these like a container [Scan]'s matches (collection-eligible and overridable). + foreach (ModuleFactory factory in model.Factories.AsArray()) + { + Indent(builder, depth).Append('[').Append(RegistrationAttribute(factory)).AppendLine("]"); + } + + Indent(builder, depth).Append("static partial class ").AppendLine(model.TypeName); + Indent(builder, depth).AppendLine("{"); + + bool first = true; + foreach (ModuleFactory factory in model.Factories.AsArray()) + { + if (!first) + { + builder.AppendLine(); + } + + first = false; + EmitModuleFactory(builder, depth + 1, factory); + } + + Indent(builder, depth).AppendLine("}"); + + for (int i = 0; i < model.ContainingTypes.Count; i++) + { + depth--; + Indent(builder, depth).AppendLine("}"); + } + + if (hasNamespace) + { + builder.AppendLine("}"); + } + + return builder.ToString(); + } + + /// + /// The registration attribute for one factory: the service is the accessible exposure interface the + /// generated factory produces, and the lifetime is the one the scan declared. A consuming container reads + /// [GeneratedScanRegistration] like a container [Scan]'s match - collection-eligible so + /// several matches under one interface resolve as an IEnumerable, and overridable by an explicit + /// registration - so a self-compiled scan behaves as close to a container scan as the assembly boundary allows. + /// + private static string RegistrationAttribute(ModuleFactory factory) + { + string skip = factory.SkipUnconstructable ? ", SkipUnconstructable = true" : string.Empty; + return $"global::Awaiten.GeneratedScanRegistrationAttribute<{factory.ServiceType}>(\"{factory.FactoryName}\", Lifetime = global::Awaiten.AwaitenLifetime.{factory.Lifetime}{skip})"; + } + + private static void EmitModuleFactory(StringBuilder builder, int depth, ModuleFactory factory) + { + AppendXmlSummary(builder, depth, + "Generated from a module [Scan]: constructs the scanned implementation and exposes it through its", + "accessible interface, so a consuming container resolves it without naming the implementation."); + + // Parameter names are emitted verbatim-prefixed: a scanned constructor may legally name a parameter with a + // reserved keyword ('@event'), whose symbol name carries no '@', and the prefix is harmless otherwise. + FactoryParameter[] parameters = factory.Parameters.AsArray(); + string signature = string.Join(", ", parameters.Select(parameter => $"{parameter.Type} @{parameter.Name}")); + string arguments = string.Join(", ", parameters.Select(parameter => $"@{parameter.Name}")); + + Indent(builder, depth) + .Append("public static ").Append(factory.ServiceType).Append(' ').Append(factory.FactoryName) + .Append('(').Append(signature).Append(") => new ").Append(factory.ImplementationType) + .Append('(').Append(arguments).AppendLine(");"); + } +} diff --git a/Source/Awaiten/GeneratedScanExpansionAttribute.cs b/Source/Awaiten/GeneratedScanExpansionAttribute.cs new file mode 100644 index 0000000..5da6cdd --- /dev/null +++ b/Source/Awaiten/GeneratedScanExpansionAttribute.cs @@ -0,0 +1,16 @@ +using System; +using System.ComponentModel; + +namespace Awaiten; + +/// +/// Emitted by the source generator onto every [Module] whose [Scan] it expanded, even when the +/// scan matched nothing. A consuming container uses it as the version-skew guard: a referenced module whose +/// metadata carries a [Scan] but not this marker was compiled without the Awaiten generator (or with a +/// version predating self-compiled module scans), so its scan would be silently dropped; the container reports +/// AWT154 instead. +/// +/// Generated code, not intended to be written by hand. +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +[EditorBrowsable(EditorBrowsableState.Never)] +public sealed class GeneratedScanExpansionAttribute : Attribute; diff --git a/Source/Awaiten/GeneratedScanRegistrationAttribute.cs b/Source/Awaiten/GeneratedScanRegistrationAttribute.cs new file mode 100644 index 0000000..a431804 --- /dev/null +++ b/Source/Awaiten/GeneratedScanRegistrationAttribute.cs @@ -0,0 +1,49 @@ +using System; +using System.ComponentModel; + +namespace Awaiten; + +// S2326: the type parameter is the source generator's input. It reads the service type from the attribute's +// type argument via Roslyn, so the body never references it. +#pragma warning disable S2326 + +/// +/// Emitted by the source generator onto a [Module] that self-compiles a [Scan] - one per +/// match - and read back by a consuming container. It carries the accessible exposure interface +/// (), the name of the generated public static factory that +/// constructs the (possibly internal) match and returns that interface, and the lifetime the scan +/// declared. A container reads these like a container [Scan]'s matches - collection-eligible and +/// overridable by an explicit registration - so a self-compiled scan behaves as close to a container scan +/// as the assembly boundary allows. +/// +/// The accessible interface the match is exposed and resolved under. +/// Generated code, not intended to be written by hand. +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] +[EditorBrowsable(EditorBrowsableState.Never)] +public sealed class GeneratedScanRegistrationAttribute : Attribute + where TService : class +{ + /// Registers one scan match produced by . + /// + /// The name of the generated static factory method on the module that constructs the match and + /// returns ; its parameters are resolved from the consumer's graph. + /// + public GeneratedScanRegistrationAttribute(string factory) + { + Factory = factory; + } + + /// The generated factory method that constructs the match. + public string Factory { get; } + + /// The lifetime the scan declared. Defaults to , matching [Scan]. + public AwaitenLifetime Lifetime { get; set; } = AwaitenLifetime.Transient; + + /// + /// Whether the scan declared SkipUnconstructable: the consuming container drops the match with a + /// warning (AWT141) instead of an error when a factory parameter is not satisfiable from its graph. + /// + public bool SkipUnconstructable { get; set; } +} + +#pragma warning restore S2326 diff --git a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt index a5d4104..2bd7745 100644 --- a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt +++ b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt @@ -110,6 +110,20 @@ namespace Awaiten public FromKeyAttribute(string key) { } public object Key { get; } } + [System.AttributeUsage(System.AttributeTargets.Class, Inherited=false)] + public sealed class GeneratedScanExpansionAttribute : System.Attribute + { + public GeneratedScanExpansionAttribute() { } + } + [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] + public sealed class GeneratedScanRegistrationAttribute : System.Attribute + where TService : class + { + public GeneratedScanRegistrationAttribute(string factory) { } + public string Factory { get; } + public Awaiten.AwaitenLifetime Lifetime { get; set; } + public bool SkipUnconstructable { get; set; } + } public interface IAsyncInitializable { System.Threading.Tasks.Task InitializeAsync(System.Threading.CancellationToken cancellationToken); diff --git a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt index 431ad89..b4b7f75 100644 --- a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt +++ b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt @@ -110,6 +110,20 @@ namespace Awaiten public FromKeyAttribute(string key) { } public object Key { get; } } + [System.AttributeUsage(System.AttributeTargets.Class, Inherited=false)] + public sealed class GeneratedScanExpansionAttribute : System.Attribute + { + public GeneratedScanExpansionAttribute() { } + } + [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] + public sealed class GeneratedScanRegistrationAttribute : System.Attribute + where TService : class + { + public GeneratedScanRegistrationAttribute(string factory) { } + public string Factory { get; } + public Awaiten.AwaitenLifetime Lifetime { get; set; } + public bool SkipUnconstructable { get; set; } + } public interface IAsyncInitializable { System.Threading.Tasks.Task InitializeAsync(System.Threading.CancellationToken cancellationToken); diff --git a/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt b/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt index a8c5117..294e31f 100644 --- a/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt +++ b/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt @@ -110,6 +110,20 @@ namespace Awaiten public FromKeyAttribute(string key) { } public object Key { get; } } + [System.AttributeUsage(System.AttributeTargets.Class, Inherited=false)] + public sealed class GeneratedScanExpansionAttribute : System.Attribute + { + public GeneratedScanExpansionAttribute() { } + } + [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] + public sealed class GeneratedScanRegistrationAttribute : System.Attribute + where TService : class + { + public GeneratedScanRegistrationAttribute(string factory) { } + public string Factory { get; } + public Awaiten.AwaitenLifetime Lifetime { get; set; } + public bool SkipUnconstructable { get; set; } + } public interface IAsyncInitializable { System.Threading.Tasks.Task InitializeAsync(System.Threading.CancellationToken cancellationToken); diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt154ModuleScanNotExpanded.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt154ModuleScanNotExpanded.cs new file mode 100644 index 0000000..f66e145 --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt154ModuleScanNotExpanded.cs @@ -0,0 +1,107 @@ +namespace Awaiten.SourceGenerators.Tests; + +public partial class DiagnosticTests +{ + public class Awt154ModuleScanNotExpanded + { + [Fact] + public async Task Awt154_ReportsWhenTheModuleAssemblyWasBuiltWithoutTheGenerator() + { + GeneratorResult result = Generator.RunWithReferencedAssembly(""" + using Awaiten; + + namespace Lib; + + public interface IClock { } + public sealed class SystemClock : IClock { } + public interface IPlugin { } + + internal sealed class Roaster : IPlugin { } + + [Module] + [Singleton] + [Scan(As = ScanAs.Marker)] + public static partial class PluginModule { } + """, """ + using Awaiten; + using Lib; + + namespace MyCode; + + [Container] + [Import(typeof(PluginModule))] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).Contains("*AWT154*PluginModule*").AsWildcard() + .Because("the module's metadata carries a [Scan] but no generated expansion marker, so the scan would silently contribute nothing; the module declaring another registration must not mask that"); + } + + [Fact] + public async Task Awt154_ReportsForAScanOnlyModuleWithoutMisleadingAwt151() + { + GeneratorResult result = Generator.RunWithReferencedAssembly(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + internal sealed class Roaster : IPlugin { } + + [Module] + [Scan(As = ScanAs.Marker)] + public static partial class PluginModule { } + """, """ + using Awaiten; + using Lib; + + namespace MyCode; + + [Container] + [Import(typeof(PluginModule))] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).Contains("*AWT154*PluginModule*").AsWildcard() + .Because("the unexpanded scan is the actual fault"); + await That(result.Diagnostics).DoesNotContain("*AWT151*").AsWildcard() + .Because("a module declaring a [Scan] is not an empty module; AWT154 names the real problem instead"); + } + + [Fact] + public async Task NoAwt154WhenTheGeneratorExpandedAScanThatMatchedNothing() + { + GeneratorResult result = Generator.RunWithGeneratedReferencedAssembly(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + + [Module] + [Scan(As = ScanAs.Marker)] + public static partial class PluginModule { } + """, """ + using Awaiten; + using Lib; + + namespace MyCode; + + [Container] + [Import(typeof(PluginModule))] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT154*").AsWildcard() + .Because("the generated expansion marker is emitted even for a scan that matched nothing, so an empty expansion is distinguishable from a missing one"); + await That(result.Diagnostics).DoesNotContain("*AWT151*").AsWildcard() + .Because("the empty scan was already reported in the module's own build, not blamed on the consumer's [Import]"); + } + } +} diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt154ScanOnModule.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt154ScanOnModule.cs deleted file mode 100644 index 9c9c821..0000000 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt154ScanOnModule.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System.Linq; - -namespace Awaiten.SourceGenerators.Tests; - -public partial class DiagnosticTests -{ - public class Awt154ScanOnModule - { - [Fact] - public async Task ReportsWhenAnImportedModuleDeclaresAScan() - { - GeneratorResult result = Generator.Run(""" - using Awaiten; - - namespace MyCode; - - public interface IPlugin { } - public sealed class AlphaPlugin : IPlugin { } - public sealed class Logger { } - - [Module] - [Scan(typeof(IPlugin), Lifetime = AwaitenLifetime.Singleton)] - [Singleton] - public static class PluginModule { } - - [Container] - [Import(typeof(PluginModule))] - public static partial class MyContainer - { - } - """); - - await That(result.Diagnostics).Contains("*AWT154*PluginModule*").AsWildcard() - .Because("a [Scan] is not collected from modules, so it would be silently dropped without the error"); - } - - [Fact] - public async Task StillImportsTheModulesLifetimeRegistrations_AndDoesNotAlsoReportAwt151() - { - GeneratorResult result = Generator.Run(""" - using Awaiten; - - namespace MyCode; - - public interface IPlugin { } - public sealed class AlphaPlugin : IPlugin { } - public sealed class Logger { } - public sealed class Consumer - { - public Consumer(Logger logger) { } - } - - [Module] - [Scan(typeof(IPlugin), Lifetime = AwaitenLifetime.Singleton)] - [Singleton] - public static class PluginModule { } - - [Container] - [Import(typeof(PluginModule))] - [Singleton] - public static partial class MyContainer - { - } - """); - - await That(result.Diagnostics).DoesNotContain("*AWT101*").AsWildcard() - .Because("the module's lifetime registrations are still imported"); - await That(result.Diagnostics).DoesNotContain("*AWT151*").AsWildcard() - .Because("the module declares a lifetime registration, so it is not empty"); - } - - [Fact] - public async Task ReportsAlongsideAwt151ForAScanOnlyModule() - { - GeneratorResult result = Generator.Run(""" - using Awaiten; - - namespace MyCode; - - public interface IPlugin { } - public sealed class AlphaPlugin : IPlugin { } - - [Module] - [Scan(typeof(IPlugin), Lifetime = AwaitenLifetime.Singleton)] - public static class PluginModule { } - - [Container] - [Import(typeof(PluginModule))] - public static partial class MyContainer - { - } - """); - - // The scan is rejected (AWT154) and does not count as a contribution, so the module is also empty (AWT151). - await That(result.Diagnostics).Contains("*AWT154*PluginModule*").AsWildcard(); - await That(result.Diagnostics).Contains("*AWT151*PluginModule*").AsWildcard(); - } - - [Fact] - public async Task DoesNotReportForAScanOnTheContainerItself() - { - GeneratorResult result = Generator.Run(""" - using Awaiten; - - namespace MyCode; - - public interface IPlugin { } - public sealed class AlphaPlugin : IPlugin { } - public sealed class Logger { } - - [Module] - [Singleton] - public static class LoggingModule { } - - [Container] - [Import(typeof(LoggingModule))] - [Scan(typeof(IPlugin), Lifetime = AwaitenLifetime.Singleton)] - public static partial class MyContainer - { - } - """); - - await That(result.Diagnostics).DoesNotContain("*AWT154*").AsWildcard() - .Because("[Scan] on the container is the supported placement"); - } - } -} diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt194ModuleScanNotPartial.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt194ModuleScanNotPartial.cs new file mode 100644 index 0000000..4a07179 --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt194ModuleScanNotPartial.cs @@ -0,0 +1,92 @@ +using System.Linq; + +namespace Awaiten.SourceGenerators.Tests; + +public partial class DiagnosticTests +{ + public class Awt194ModuleScanNotPartial + { + [Fact] + public async Task Awt152_ReportsForANonStaticModuleWithAScanInTheModulesOwnBuild() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + internal sealed class Roaster : IPlugin { } + + [Module] + [Scan(As = ScanAs.Marker)] + public partial class PluginModule { } + """); + + await That(result.Diagnostics).Contains("*AWT152*PluginModule*").AsWildcard() + .Because("a non-static module is reported in its own build rather than surfacing as a raw partial-modifier compiler error inside the generated static partial"); + await That(result.Sources.Keys.Where(key => key.Contains("ModuleScan"))).IsEmpty() + .Because("emitting a static partial for a non-static class would not compile"); + } + + [Fact] + public async Task ReportsWhenAModuleWithAScanIsNotPartial() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + public interface IRoaster { } + internal sealed class Roaster : IPlugin, IRoaster { } + + [Module] + [Scan(As = ScanAs.MatchingInterface)] + public static class PluginModule { } + """); + + await That(result.Diagnostics).Contains("*AWT194*PluginModule*").AsWildcard() + .Because("the module declares a [Scan] but is not partial, so the generated factories cannot be added"); + } + + [Fact] + public async Task DoesNotReportForAPartialModuleWithAScan() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + public interface IRoaster { } + internal sealed class Roaster : IPlugin, IRoaster { } + + [Module] + [Scan(As = ScanAs.MatchingInterface)] + public static partial class PluginModule { } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT194*").AsWildcard() + .Because("a partial module can receive the generated factories"); + } + + [Fact] + public async Task DoesNotReportForAPlainModuleWithoutAScan() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public sealed class Logger { } + + [Module] + [Singleton] + public static class LoggingModule { } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT194*").AsWildcard() + .Because("a module without a [Scan] self-compiles nothing, so it need not be partial"); + } + } +} diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt195Awt196Awt197ModuleScanExposure.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt195Awt196Awt197ModuleScanExposure.cs new file mode 100644 index 0000000..e0fdbe5 --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt195Awt196Awt197ModuleScanExposure.cs @@ -0,0 +1,120 @@ +using System.Linq; + +namespace Awaiten.SourceGenerators.Tests; + +public partial class DiagnosticTests +{ + public class Awt195Awt196Awt197ModuleScanExposure + { + [Fact] + public async Task Awt195_ReportsWhenAMatchConstructorParameterIsInaccessible() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + public interface IRoaster { } + internal sealed class Secret { } + + internal sealed class Roaster : IPlugin, IRoaster + { + public Roaster(Secret secret) { } + } + + [Module] + [Scan(As = ScanAs.MatchingInterface)] + public static partial class PluginModule { } + """); + + await That(result.Diagnostics).Contains("*AWT195*Roaster*Secret*").AsWildcard() + .Because("the generated factory's parameter type must be nameable by a consumer, and Secret is internal"); + } + + [Fact] + public async Task Awt196_ReportsWhenNoExposureIsAccessible() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + internal sealed class Roaster : IPlugin { } + + [Module] + [Scan(As = ScanAs.Self)] + public static partial class PluginModule { } + """); + + await That(result.Diagnostics).Contains("*warning AWT196*Roaster*").AsWildcard() + .Because("a Self exposure of an internal implementation gives a consumer no accessible type to resolve; the match is skipped with a warning, mirroring the container scan's AWT182/AWT188/AWT193 severity"); + } + + [Fact] + public async Task Awt197_ReportsWhenAMatchWouldHaveMultipleExposures() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + public interface IRoaster { } + internal sealed class Roaster : IPlugin, IRoaster { } + + [Module] + [Scan(As = ScanAs.Marker | ScanAs.MatchingInterface)] + public static partial class PluginModule { } + """); + + await That(result.Diagnostics).Contains("*AWT197*Roaster*").AsWildcard() + .Because("a self-compiled match is reached through a single-interface factory, so multiple exposures are unsupported in v1"); + } + + [Fact] + public async Task Awt197_ReportsWhenTwoScansExposeOneMatchUnderDifferentInterfaces() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + public interface IRoaster { } + internal sealed class Roaster : IPlugin, IRoaster { } + + [Module] + [Scan(As = ScanAs.Marker)] + [Scan(As = ScanAs.Marker)] + public static partial class PluginModule { } + """); + + await That(result.Diagnostics).Contains("*AWT197*Roaster*IPlugin, Lib.IRoaster*").AsWildcard() + .Because("two scans exposing the same type under different interfaces would emit two factories, splitting the single shared instance a container scan gives one implementation, so the overlap is rejected like a multi-exposure within one scan"); + } + + [Fact] + public async Task DoesNotReportForASingleAccessibleExposure() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + public interface IRoaster { } + internal sealed class Roaster : IPlugin, IRoaster { } + + [Module] + [Scan(As = ScanAs.MatchingInterface)] + public static partial class PluginModule { } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT195*").AsWildcard(); + await That(result.Diagnostics).DoesNotContain("*AWT196*").AsWildcard(); + await That(result.Diagnostics).DoesNotContain("*AWT197*").AsWildcard(); + } + } +} diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt200Awt201ModuleScanLimitations.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt200Awt201ModuleScanLimitations.cs new file mode 100644 index 0000000..5d19708 --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt200Awt201ModuleScanLimitations.cs @@ -0,0 +1,108 @@ +using System.Linq; + +namespace Awaiten.SourceGenerators.Tests; + +public partial class DiagnosticTests +{ + public class Awt200Awt201ModuleScanLimitations + { + [Fact] + public async Task Awt200_ReportsWhenAMatchHasAnInjectProperty() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IGrinder { } + public interface IPlugin { } + public interface IRoaster { } + + internal sealed class Roaster : IPlugin, IRoaster + { + [Inject] + public IGrinder? Grinder { get; set; } + } + + [Module] + [Scan(As = ScanAs.MatchingInterface)] + public static partial class PluginModule { } + """); + + await That(result.Diagnostics).Contains("*AWT200*Roaster*Grinder*[Inject]*").AsWildcard() + .Because("an [Inject] property's semantics cannot be mirrored by the generated factory, so silently skipping it would construct the match differently than a container scan"); + } + + [Fact] + public async Task Awt200_ReportsWhenAMatchConstructorParameterIsMarkedArg() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + public interface IRoaster { } + + internal sealed class Roaster : IPlugin, IRoaster + { + public Roaster([Arg] int strength) { } + } + + [Module] + [Scan(As = ScanAs.MatchingInterface)] + public static partial class PluginModule { } + """); + + await That(result.Diagnostics).Contains("*AWT200*Roaster*strength*[Arg]*").AsWildcard() + .Because("an [Arg] parameter takes its value from the resolution call, which a mirrored factory parameter resolved from the graph would silently change"); + } + + [Fact] + public async Task Awt201_ReportsForAGenericModuleWithAScan() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + public interface IRoaster { } + internal sealed class Roaster : IPlugin, IRoaster { } + + [Module] + [Scan(As = ScanAs.MatchingInterface)] + public static partial class PluginModule { } + """); + + await That(result.Diagnostics).Contains("*AWT201*PluginModule*").AsWildcard() + .Because("no closed module exists for a consumer to import, and a bare-named partial would emit an unrelated non-generic class"); + await That(result.Sources.Keys.Where(key => key.Contains("ModuleScan"))).IsEmpty() + .Because("the orphan partial must not be emitted"); + } + + [Fact] + public async Task Awt201_ReportsForAModuleNestedInAGenericTypeWithAScan() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + public interface IRoaster { } + internal sealed class Roaster : IPlugin, IRoaster { } + + public static partial class Outer + { + [Module] + [Scan(As = ScanAs.MatchingInterface)] + public static partial class PluginModule { } + } + """); + + await That(result.Diagnostics).Contains("*AWT201*PluginModule*").AsWildcard() + .Because("a module nested in a generic type is just as unimportable as a generic module itself"); + } + } +} diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt202ModuleScanInAssembliesOf.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt202ModuleScanInAssembliesOf.cs new file mode 100644 index 0000000..ef36511 --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt202ModuleScanInAssembliesOf.cs @@ -0,0 +1,31 @@ +using System.Linq; + +namespace Awaiten.SourceGenerators.Tests; + +public partial class DiagnosticTests +{ + public class Awt202ModuleScanInAssembliesOf + { + [Fact] + public async Task Awt202_ReportsWhenAModuleScanNamesAssemblies() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + internal sealed class Roaster : IPlugin { } + + [Module] + [Scan(As = ScanAs.Marker, InAssembliesOf = new[] { typeof(IPlugin) })] + public static partial class PluginModule { } + """); + + await That(result.Diagnostics).Contains("*AWT202*PluginModule*").AsWildcard() + .Because("a self-compiled scan sweeps only the module's own assembly; InAssembliesOf would see just another assembly's public types, which a container [Scan] already covers"); + await That(result.Sources.Keys.Where(key => key.Contains("ModuleScan"))).IsNotEmpty() + .Because("the module still emits its expansion marker; the rejected scan just contributes no factories"); + } + } +} diff --git a/Tests/Awaiten.SourceGenerators.Tests/SelfCompiledModuleScanTests.cs b/Tests/Awaiten.SourceGenerators.Tests/SelfCompiledModuleScanTests.cs new file mode 100644 index 0000000..6fa56c2 --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/SelfCompiledModuleScanTests.cs @@ -0,0 +1,483 @@ +using System.Linq; + +namespace Awaiten.SourceGenerators.Tests; + +/// +/// A [Module] that compiles its own [Scan]: the module's build emits a factory + registration per +/// match, so a consuming container resolves an internal implementation through its accessible interface +/// without ever naming the implementation. The library keeps implementations internal and exposes only interfaces. +/// +public class SelfCompiledModuleScanTests +{ + /// + /// Mirrors the generator's factory naming (ModuleFactoryName): the match's simple name plus an + /// FNV-1a hash of its fully-qualified name, stable across library versions and scan order. + /// + private static string FactoryName(string fullyQualifiedName) + { + uint hash = 2166136261; + foreach (char character in fullyQualifiedName) + { + hash = unchecked((hash ^ character) * 16777619); + } + + string simpleName = fullyQualifiedName[(fullyQualifiedName.LastIndexOf('.') + 1)..]; + return $"Awaiten__Scan_{simpleName}_{hash:x8}"; + } + + private const string LibrarySource = """ + using Awaiten; + + namespace Lib; + + public interface IClock { } + public sealed class SystemClock : IClock { } + + public interface IPlugin { } + public interface IRoaster { } + + internal sealed class Roaster : IPlugin, IRoaster + { + public Roaster(IClock clock) { } + } + + [Module] + [Scan(As = ScanAs.MatchingInterface, Lifetime = AwaitenLifetime.Singleton)] + public static partial class PluginModule { } + """; + + [Fact] + public async Task ModuleSelfCompilesItsScanIntoAFactory() + { + (_, Microsoft.CodeAnalysis.GeneratorDriverRunResult run) = + Generator.RunGenerator(LibrarySource, [], []); + string module = run.Results + .SelectMany(r => r.GeneratedSources) + .Single(s => s.HintName.Contains("ModuleScan")) + .SourceText.ToString(); + + await That(module).Contains("static partial class PluginModule") + .Because("the module is re-opened as partial to receive the generated factory"); + await That(module).Contains("[global::Awaiten.GeneratedScanExpansionAttribute]") + .Because("the expansion marker lets a consuming container tell an expanded scan from one built without the generator (AWT154)"); + await That(module).Contains($"global::Awaiten.GeneratedScanRegistrationAttribute(\"{FactoryName("global::Lib.Roaster")}\", Lifetime = global::Awaiten.AwaitenLifetime.Singleton)") + .Because("the match is registered under its accessible MatchingInterface, as a singleton scan match"); + await That(module).Contains($"public static global::Lib.IRoaster {FactoryName("global::Lib.Roaster")}(global::Lib.IClock @clock) => new global::Lib.Roaster(@clock);") + .Because("the factory returns the interface but constructs the internal implementation in the library, with verbatim-prefixed parameter names so keyword-named parameters stay legal"); + } + + [Fact] + public async Task ConsumerResolvesTheInternalImplementationThroughItsInterface() + { + GeneratorResult result = Generator.RunWithGeneratedReferencedAssembly(LibrarySource, """ + using Awaiten; + using Lib; + + namespace MyCode; + + [Container] + [Import(typeof(PluginModule))] + [Singleton] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("the internal implementation resolves through its interface with no missing dependency"); + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + + await That(source).Contains($"global::Lib.PluginModule.{FactoryName("global::Lib.Roaster")}") + .Because("the container calls the module's generated factory to build the implementation"); + await That(source).DoesNotContain("new global::Lib.Roaster") + .Because("the consumer never constructs the internal implementation directly"); + await That(result.Diagnostics).DoesNotContain("*AWT154*").AsWildcard() + .Because("a [Scan] on a module is now supported, not rejected"); + } + + [Fact] + public async Task ConsumerOverridesTheGeneratedRegistration() + { + GeneratorResult result = Generator.RunWithGeneratedReferencedAssembly(LibrarySource, """ + using Awaiten; + using Lib; + + namespace MyCode; + + public sealed class AppRoaster : IRoaster { } + + [Container] + [Import(typeof(PluginModule))] + [Singleton] + [Singleton] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("an explicit registration outranks a scan match, so the container's own IRoaster wins without a conflict"); + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + await That(source).Contains("global::MyCode.AppRoaster") + .Because("the container's own registration wins the IRoaster slot"); + } + + [Fact] + public async Task InternalDisposableImplementationIsDisposedThroughTheRuntimeCheck() + { + GeneratorResult result = Generator.RunWithGeneratedReferencedAssembly(""" + using System; + using Awaiten; + + namespace Lib; + + public interface IClock { } + public sealed class SystemClock : IClock { } + public interface IPlugin { } + public interface IRoaster { } + + internal sealed class Roaster : IPlugin, IRoaster, IDisposable + { + public Roaster(IClock clock) { } + public void Dispose() { } + } + + [Module] + [Scan(As = ScanAs.MatchingInterface, Lifetime = AwaitenLifetime.Singleton)] + public static partial class PluginModule { } + """, """ + using Awaiten; + using Lib; + + namespace MyCode; + + [Container] + [Import(typeof(PluginModule))] + [Singleton] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).IsEmpty(); + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + await That(source).Contains("global::System.IDisposable") + .Because("a factory returning an interface tracks disposal behind a runtime IDisposable check, so the internal disposable is disposed"); + } + + private const string PluginFamilySource = """ + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + + internal sealed class Alpha : IPlugin { } + internal sealed class Bravo : IPlugin { } + + [Module] + [Scan(As = ScanAs.Marker, Lifetime = AwaitenLifetime.Singleton)] + public static partial class PluginModule { } + """; + + [Fact] + public async Task MultipleMatchesUnderOneInterfaceResolveAsACollection() + { + GeneratorResult result = Generator.RunWithGeneratedReferencedAssembly(PluginFamilySource, """ + using System.Collections.Generic; + using Awaiten; + using Lib; + + namespace MyCode; + + public sealed class Host + { + public Host(IEnumerable plugins) { } + } + + [Container] + [Import(typeof(PluginModule))] + [Singleton] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("two internal implementations of one marker interface collect instead of colliding (no AWT111)"); + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + await That(source).Contains($"global::Lib.PluginModule.{FactoryName("global::Lib.Alpha")}") + .Because("the first match is a member of the IEnumerable collection"); + await That(source).Contains($"global::Lib.PluginModule.{FactoryName("global::Lib.Bravo")}") + .Because("the second match is a member of the IEnumerable collection too, exactly as a container scan would collect them"); + } + + [Fact] + public async Task MultipleMatchesUnderOneInterfaceDoNotConflict() + { + GeneratorResult result = Generator.RunWithGeneratedReferencedAssembly(PluginFamilySource, """ + using Awaiten; + using Lib; + + namespace MyCode; + + [Container] + [Import(typeof(PluginModule))] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT111*").AsWildcard() + .Because("self-compiled scan matches are collection-eligible, not conflicting single-service factories"); + } + + [Fact] + public async Task TwoModulesWithSameNamedMatchesUnderOneInterfaceBothCollect() + { + GeneratorResult result = Generator.RunWithGeneratedReferencedAssembly(""" + using Awaiten; + + namespace Lib + { + public interface IPlugin { } + + [Module] + [Scan(As = ScanAs.Marker, NamespacePatterns = new[] { "Lib.A" })] + public static partial class ModuleA { } + + [Module] + [Scan(As = ScanAs.Marker, NamespacePatterns = new[] { "Lib.B" })] + public static partial class ModuleB { } + } + + namespace Lib.A + { + internal sealed class Handler : Lib.IPlugin { } + } + + namespace Lib.B + { + internal sealed class Handler : Lib.IPlugin { } + } + """, """ + using System.Collections.Generic; + using Awaiten; + using Lib; + + namespace MyCode; + + public sealed class Host + { + public Host(IEnumerable plugins) { } + } + + [Container] + [Import(typeof(ModuleA))] + [Import(typeof(ModuleB))] + [Singleton] + public static partial class MyContainer + { + } + """); + + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + await That(source).Contains($"global::Lib.ModuleA.{FactoryName("global::Lib.A.Handler")}") + .Because("module A's match is a member of the collection"); + await That(source).Contains($"global::Lib.ModuleB.{FactoryName("global::Lib.B.Handler")}") + .Because("module B's match must not be deduped away by sharing module A's factory name, and the name hash keeps same-named matches distinct"); + } + + [Fact] + public async Task SameCompilationModuleScanIsExpandedByTheContainer() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IClock { } + public sealed class SystemClock : IClock { } + + public interface IPlugin { } + public interface IRoaster { } + + internal sealed class Roaster : IPlugin, IRoaster + { + public Roaster(IClock clock) { } + } + + [Module] + [Scan(As = ScanAs.MatchingInterface)] + public static partial class PluginModule { } + + [Container] + [Import(typeof(PluginModule))] + [Singleton] + public static partial class MyContainer { } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("a same-compilation module's [Scan] is expanded by the container itself, so nothing is dropped and AWT151 does not misfire"); + string source = result.Sources["Awaiten.Lib.MyContainer.g.cs"]; + await That(source).Contains("new global::Lib.Roaster(") + .Because("with no assembly boundary the container constructs the internal match directly, as if the [Scan] were its own"); + } + + [Fact] + public async Task SameCompilationModuleScanReportsScanDiagnosticsOnce() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + + [Module] + [Scan] + public static partial class PluginModule { } + + [Container] + [Import(typeof(PluginModule))] + public static partial class MyContainer { } + """); + + await That(result.Diagnostics.Count(diagnostic => diagnostic.Contains("AWT138"))).IsEqualTo(1) + .Because("the module pipeline and the container both see the same [Scan], but only the module pipeline reports on it"); + } + + [Fact] + public async Task KeywordNamedConstructorParameterCompilesAndResolves() + { + GeneratorResult result = Generator.RunWithGeneratedReferencedAssembly(""" + using Awaiten; + + namespace Lib; + + public interface IClock { } + public sealed class SystemClock : IClock { } + public interface IPlugin { } + public interface IRoaster { } + + internal sealed class Roaster : IPlugin, IRoaster + { + public Roaster(IClock @event) { } + } + + [Module] + [Scan(As = ScanAs.MatchingInterface)] + public static partial class PluginModule { } + """, """ + using Awaiten; + using Lib; + + namespace MyCode; + + [Container] + [Import(typeof(PluginModule))] + [Singleton] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("a keyword-named constructor parameter must be emitted verbatim-prefixed, not break the module's build"); + } + + [Fact] + public async Task OverlappingScansOnOneModuleEmitASingleFactoryPerMatch() + { + (_, Microsoft.CodeAnalysis.GeneratorDriverRunResult run) = Generator.RunGenerator(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + public interface IRoaster { } + internal sealed class Roaster : IPlugin, IRoaster { } + + [Module] + [Scan(As = ScanAs.MatchingInterface)] + [Scan(As = ScanAs.MatchingInterface)] + public static partial class PluginModule { } + """, [], []); + string module = run.Results + .SelectMany(r => r.GeneratedSources) + .Single(s => s.HintName.Contains("ModuleScan")) + .SourceText.ToString(); + + await That(module).Contains(FactoryName("global::Lib.Roaster")) + .Because("the first scan's match is emitted"); + await That(module.Split("public static").Length - 1).IsEqualTo(1) + .Because("the second scan matched the same type under the same exposure, which a container would dedup to one collection member, so only one factory is emitted"); + } + + [Fact] + public async Task SkipUnconstructableMatchIsPrunedAtTheConsumer() + { + GeneratorResult result = Generator.RunWithGeneratedReferencedAssembly(""" + using Awaiten; + + namespace Lib; + + public interface IExotic { } + public interface IPlugin { } + public interface IRoaster { } + public interface IGrinder { } + + internal sealed class Roaster : IPlugin, IRoaster + { + public Roaster(IExotic exotic) { } + } + + internal sealed class Grinder : IPlugin, IGrinder { } + + [Module] + [Scan(As = ScanAs.MatchingInterface, SkipUnconstructable = true)] + public static partial class PluginModule { } + """, """ + using Awaiten; + using Lib; + + namespace MyCode; + + [Container] + [Import(typeof(PluginModule))] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).Contains("*AWT141*IRoaster*IExotic*").AsWildcard() + .Because("the scan's SkipUnconstructable travels on the generated registration, so the consumer prunes the match whose factory parameter it cannot resolve, with the same AWT141 a container scan gives"); + await That(result.Diagnostics).DoesNotContain("*error*").AsWildcard() + .Because("pruning replaces the missing-dependency error"); + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + await That(source).Contains($"global::Lib.PluginModule.{FactoryName("global::Lib.Grinder")}") + .Because("the constructable match survives the prune"); + } + + [Fact] + public async Task OverlappingScansWithDifferentLifetimesReportAwt142() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + public interface IRoaster { } + internal sealed class Roaster : IPlugin, IRoaster { } + + [Module] + [Scan(As = ScanAs.MatchingInterface, Lifetime = AwaitenLifetime.Singleton)] + [Scan(As = ScanAs.MatchingInterface, Lifetime = AwaitenLifetime.Transient)] + public static partial class PluginModule { } + """); + + await That(result.Diagnostics).Contains("*AWT142*Roaster*Singleton*Transient*").AsWildcard() + .Because("two scans of one module registering the same implementation with different lifetimes mirror the container's scan-lifetime conflict"); + } +} diff --git a/Tests/Awaiten.SourceGenerators.Tests/TestHelpers/Generator.cs b/Tests/Awaiten.SourceGenerators.Tests/TestHelpers/Generator.cs index a8a1ed3..f8fc1f8 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/TestHelpers/Generator.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/TestHelpers/Generator.cs @@ -40,6 +40,20 @@ public static GeneratorResult RunWithReferencedAssembly( return Run(source, [EmitToReference(referenced),]); } + /// + /// Like , but runs the generator over + /// first, so the referenced assembly contains the generated code + /// (for example a [Module] that self-compiles its [Scan] into factory methods and registration + /// attributes). Then runs the generator over against that assembly. + /// + public static GeneratorResult RunWithGeneratedReferencedAssembly( + [StringSyntax("c#-test")] string referencedSource, + [StringSyntax("c#-test")] string source) + { + (Compilation referencedOutput, _) = RunGenerator(referencedSource, [], [], "ReferencedAssembly"); + return Run(source, [EmitToReference(referencedOutput),]); + } + private static GeneratorResult Run( [StringSyntax("c#-test")] string source, MetadataReference[] additionalReferences,