diff --git a/CHANGELOG.md b/CHANGELOG.md index 742e695..b315afe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,20 +5,27 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.0.0-rc9330] - 2026-08-14 +## [1.0.0-rc9340] - 2026-08-16 Everything since `1.0.0-rc9230`. The theme is registrations that were silently not happening: an attribute the generator declined to recognise, a `Replace` that depended on how a class was named, a mock that replaced the wrong slot, and three shapes it refused without saying so. One of those refusals turned out to be unnecessary, and generic services can now be intercepted. +The other half came from building five applications against the released package and writing down +everything that got in the way — which turned up a generated file that could break a consumer's build +on an ordinary signature, an attribute that did nothing where it was written, and several documented +examples that did not compile. + Still a release candidate. `DecoratorExpansion.Expand` changed shape, but only on the generator extension points, which are documented as unversioned. -**Upgrade note:** three new warnings. A project building with `TreatWarningsAsErrors` may go red on -work that was previously green and quietly not doing anything — which is the point of them, but it is -a build break rather than a nudge. `NoWarn` takes them per-project; note that `.editorconfig` does not -(see below). +**Upgrade note:** four new warnings, and one thing that will stop compiling. A project building with +`TreatWarningsAsErrors` may go red on work that was previously green and quietly not doing anything — +which is the point of them, but it is a build break rather than a nudge. `NoWarn` takes them +per-project; note that `.editorconfig` does not (see below). Separately, `[InjectValues]` is now +restricted to parameters, so a usage anywhere else is `CS0592` where it used to compile and be +ignored. Both are cases where the build going red is the fix arriving, not a regression. ### Added @@ -88,6 +95,21 @@ a build break rather than a nudge. `NoWarn` takes them per-project; note that `. `Unable to create a generic service … because 'System.Int32' is a ValueType`. A plain `[SingletonService]` on a generic class behaves identically, which the AOT guide now says. +- **`DM0016`, for an assembly-level module attribute whose namespace nothing imports.** A module + generates its attribute in the module's own namespace, and an assembly attribute has no namespace + context to inherit — a `using` inside a namespace declaration cannot reach it, because assembly + attributes precede every namespace in the file. So `[assembly: ApplicationModule]` without the + import fails with `CS0246` naming `ApplicationModuleAttribute`: a type the developer never wrote, + generated into a namespace the error does not mention. Every part of that message points away from + the one-line fix, and both the testing guide and this README used to show the shape without it. + + Alone among these it is read from syntax rather than from the semantic model, and has to be — the + attribute is written by the generator that is running, so it does not exist in the compilation + being examined and nothing about it resolves. The question it can answer is "is there a module by + this name, and could this file see it", which is why it stays quiet for an attribute matching no + module in the compilation, a module in the global namespace, a usage already written qualified, and + a namespace a `global using` supplies from any file. + ### Fixed - **An attribute the generator declined to recognise, depending on how it was spelled.** Attribute @@ -143,8 +165,36 @@ a build break rather than a nudge. `NoWarn` takes them per-project; note that `. non-generic decorator named against an open generic service produced CS7003 and CS1503 the same way. Both are now `DM0014` and `DM0013`. +- **A nullable type argument in a service type broke the build, and the consumer could not fix it.** + A registered service type carries whatever nullable annotation its declaration used, so + `class GetBookHandler : IHandler` emits `typeof(…Book?)`. Roslyn requires generated + code to open a nullable context explicitly however the consuming project is configured, and the + registrations file was the one generated file that never did — the module, attribute and interceptor + writers all already called `EnableNullable`. + + The result was `CS8669` on a find-by-id handler, which is about as ordinary a shape as exists: a + warning nobody could silence from their own source without dropping the annotation from their own + domain signatures, and a hard failure under `TreatWarningsAsErrors`. It reproduced on the attribute + and the convention path alike, because the convention path emits through the same writer — which is + also why one call fixes both files. + + The annotation is still emitted. It is inert inside a `typeof`, since `typeof(Book?)` and + `typeof(Book)` are one runtime type, and removing it would mean changing type modelling that + decoration and interception depend on: `ConstructorArgumentWriter` reads nullability to choose + `GetService` over `GetRequiredService`, and an interceptor wrapper needs the annotations to keep + implementing what it wraps. A decorator declared against `IStore` still matches a + registration of `IStore`, so the difference is cosmetic rather than a silent miss. + ### Changed +- **Breaking, and deliberately so: `[InjectValues]` is restricted to parameters.** It was the only + one of the three testing attributes without an `AttributeUsage` — `[Mock]` is pinned to parameters + and `[TestExport]` to methods — so writing it on a test method compiled, was never read, and then + failed inside `ActivatorUtilities` with *"Multiple constructors accepting all given argument types + have been found in type 'System.String'"*, naming neither the parameter nor the mistake. It is now + `CS0592` at the attribute itself. Code that was silently doing nothing will stop compiling, which + is the point. + - **`CSharpAuthor` 1.1.1010**, for `AddConstraint`. A `where` clause was previously assembled as a string and assigned to `WhereStatement`, which put C#'s ordering rules — one primary constraint first, `new()` last — in this generator. Two places needed them once a class could carry constraints @@ -192,6 +242,30 @@ a build break rather than a nudge. `NoWarn` takes them per-project; note that `. `DecoratorHelper`; that guard is unreachable from generated code, because the expansion drops the decorator before anything is emitted, so the guide now points at `DM0013`. +- **The README leads with the problem rather than the mechanism.** It opened by naming the + implementation, which says what the thing is before the reader knows why they want one, and the + most persuasive artefact in it — a generated registration that is plainly ordinary C# — sat at the + bottom below four hundred lines of reference. It now opens with the hook, a link to the + documentation site, the attribute beside the code it generates, and a table answering the question + every reader of a .NET DI library arrives with, which is why not Scrutor. The reference the site + covers in depth is a lookup table that links out, and the samples in `integ-tests/` are pointed at + rather than left to be discovered. + +- **Three documented examples did not compile, all found by building them.** The README quick start + omitted `using DependencyModules.Runtime.Attributes;`. The README and the modules guide both showed + top-level statements naming `ApplicationModule` without importing the root namespace it is generated + into — `integ-tests/ConsoleTestProject` has always carried that `using`; the docs omitted it and + sent the reader into a `CS0246` naming a type they never wrote. The testing bootstrap referenced a + module without importing its namespace, which fails the same way on the generated attribute. + +- **`[InjectValues]` was documented as being for the one thing it cannot do.** It was introduced as + taking "a string, an id, a record combining both", and the comparison table said "the parameter is + data, not a service". The values are the parameter type's *constructor arguments*, so asking for a + bare `string` tries to construct `System.String` from a string and fails. The prose beneath it was + already correct; only the framing promised otherwise. Data rows are what a parameter that simply + *is* a value wants, and `[InlineData]` composing with `[ModuleTest]` is now shown, since nothing + said so. + - **Breaking, for anyone building on the generator extension points:** `DecoratorExpansion.Expand` takes an additional `out IReadOnlyList` parameter, carrying the decorators it refused so the caller can report them. These are the extension points diff --git a/Directory.Build.props b/Directory.Build.props index 98a1848..1210bb8 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -11,7 +11,7 @@ The release candidate label. Assembly and file versions carry no prerelease part, so they stay put until the version they do carry changes. --> - rc9330 + rc9340 1.0.0.0 1.0.0.0 diff --git a/README.md b/README.md index 9033ec0..b39526c 100644 --- a/README.md +++ b/README.md @@ -5,309 +5,261 @@ [![coverage](https://raw.githubusercontent.com/ipjohnson/DependencyModules/badges/coverage.svg)](https://github.com/ipjohnson/DependencyModules/actions/workflows/build-package.yaml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE.txt) -DependencyModules is a C# source generator package that uses attributes to create -dependency injection registration modules. These modules can then be used to populate -an IServiceCollection instance. +**Your DI registrations, written as attributes and compiled into your assembly.** +No reflection, no assembly scanning, no startup cost — and Native AOT works, because +there is nothing left to trim away. -Registration code is generated at compile time, so there is no reflection or assembly -scanning at run time. +📖 **[Documentation](https://ipjohnson.github.io/DependencyModules/)** · +[Getting started](https://ipjohnson.github.io/DependencyModules/guide/getting-started) · +[Conventions](https://ipjohnson.github.io/DependencyModules/guide/conventions) · +[Decorators](https://ipjohnson.github.io/DependencyModules/guide/decorators) · +[Testing](https://ipjohnson.github.io/DependencyModules/guide/testing) · +[AOT](https://ipjohnson.github.io/DependencyModules/guide/aot) -## Installation +## The whole trick -```shell -dotnet add package DependencyModules.Runtime -dotnet add package DependencyModules.SourceGenerator -``` - -Requires .NET 8.0 or later. The packages ship both `net8.0` and `net10.0` assemblies, so a project on -either LTS release gets one built against its own framework. See [CHANGELOG.md](CHANGELOG.md) for -release notes. - -## Service Attributes - -* `[DependencyModule]` - used to attribute class that will become dependency module (must be partial) -* `[SingletonService]` - registers service as `AddSingleton` -* `[ScopedService]` - registers service as `AddScoped` -* `[TransientService]` - registers service as `AddTransient` -* `[CrossWireService]` - registers implementation and interfaces with the same lifetime +You mark a class: ```csharp -// Registration example -[DependencyModule] -public partial class ApplicationModule; - -// registers SomeClass implementation for ISomeService [SingletonService] -public class SomeClass : ISomeService -{ - public string SomeProp => "SomeString"; -} - -// registers OtherService implementation -[TransientService] -public class OtherService -{ - public OtherService(ISomeService service) - { - SomeProp = service.SomeProp; - } - public string SomeProp { get; } -} +public class SmtpEmailSender : IEmailSender; ``` -Note: `[DependencyModule]` is not required for [Top-level](https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/program-structure/top-level-statements) statement applications. - -Note: a `[DependencyModule]` class must be declared directly in a namespace, not nested inside -another type. A nested module generates a separate, detached class rather than completing the -partial declaration, so its registrations never run. Services registered with -`[SingletonService]` and friends may be nested freely. -## Container Instantiation - -* `AddModule` - method adds root module to service collection -* `AddModules` - add a list of modules to the service collection +At build time the generator writes the registration you would have written yourself: ```csharp -// AddModule and AddModules are extension methods in the DependencyModules.Runtime namespace -using DependencyModules.Runtime; +// ApplicationModule.Dependencies.g.cs +services.AddSingleton( + typeof(global::MyApp.IEmailSender), + typeof(global::MyApp.SmtpEmailSender) +); +``` -var serviceCollection = new ServiceCollection(); +That is the entire mechanism. The output is ordinary C# that you can read, grep, set a +breakpoint in, and check into a review. Nothing inspects your assembly at run time, so +there is no startup scan to pay for and nothing for the trimmer to guess about. -serviceCollection.AddModule(); -// or -serviceCollection.AddModules(new ApplicationModule(), ...); +## Why not a runtime scanner? -var provider = serviceCollection.BuildServiceProvider(); +If you have used Scrutor, Autofac modules, or hand-written `AddScoped` lists, this is what +changes: -var service = provider.GetService(); -``` +| | Runtime scanning | DependencyModules | +|---|---|---| +| When registration is decided | First request to the container | `dotnet build` | +| A convention that matches nothing | Silent | [`DM0005`](https://ipjohnson.github.io/DependencyModules/reference/diagnostics) at build | +| A service that cannot be constructed | `InvalidOperationException`, eventually | [`DM0002`](https://ipjohnson.github.io/DependencyModules/reference/diagnostics) at build | +| Trimming / Native AOT | Types disappear; scanner finds nothing | Literal `typeof()`, so the trimmer keeps them | +| Startup cost | Proportional to assembly size | None | +| What actually got registered | Debugger, at run time | A file you can open | -Note: to avoid duplicate modules it's recommended to only call AddModule(s) once in an application and never inside a Module. -## Factories +The interesting half is the third row. A trimmer removes `CreateOrderHandler` because +nothing statically references it — a scanner that would have found it by reflection does +not count. Emitting `typeof(CreateOrderHandler)` into your assembly is a static reference, +which is why this approach and Native AOT get along. -Sometimes it's not possible to construct all types through normal registration. -Factories can be registered with a module using the registration attributes. +## Install -```csharp -public class SomeClass : ISomeInterface { - public SomeClass(IDep one, IDepTwo two, DateTime dateTime) { ... } - - [SingletonService] - public static ISomeInterface Factory(IDep one, IDepTwo two) { - return new SomeClass(one, two, DateTime.Now()); - } -} +```shell +dotnet add package DependencyModules.Runtime +dotnet add package DependencyModules.SourceGenerator ``` -## Module Re-use -DependencyModules creates an `Attribute` class that can be used to apply sub dependencies. +Requires .NET 8.0 or later. The packages ship `net8.0` and `net10.0` assemblies, so a +project on either LTS gets one built against its own framework. Console applications also +want `Microsoft.Extensions.DependencyInjection`. + +## Quick start + +Mark the services, declare a module, load it once: ```csharp -// Modules can be re-used with the generated attributes -[DependencyModule] -[ApplicationModule] -public partial class AnotherModule; -``` +// Services.cs +using DependencyModules.Runtime.Attributes; -## Parameters +namespace MyApp; -Sometimes you want to provide extra registration for your module. -This can be achieved by adding a constructor to your module or optional properties. -Note these parameters and properties will be correspondingly implemented in the module attribute. +[SingletonService] +public class SmtpEmailSender : IEmailSender; + +[ScopedService] +public class OrderRepository : IOrderRepository; +``` ```csharp -[DependencyModule] -public partial class SomeModule(bool someFlag) : IServiceCollectionConfiguration -{ - public string OptionalString { get; set; } = ""; - - public void ConfigureServices(IServiceCollection services) - { - if (someFlag) - { - // custom registration - } - } -} +// Program.cs +using MyApp; // the generated module lives in your root namespace +using DependencyModules.Runtime; +using Microsoft.Extensions.DependencyInjection; -[DependencyModule] -[SomeModule(true, OptionalString = "otherString")] -public partial class SomeOtherModule; +var services = new ServiceCollection(); +services.AddModule(); + +var provider = services.BuildServiceProvider(); ``` -## Module Features -Because module configuration happens before the dependency injection container is instantiated it's impossible to use the container for configuration. -To support configuration discovery before registration, the feature interface can be -implemented in modules and be passed to a handler at registration time. Features are applied before services and decorators. +`ApplicationModule` is generated for you in a project whose entry point is a top-level +`Program.cs`. Anywhere else — a class library, or a project that wants more than one module — +declare your own: ```csharp -// feature interface -public interface IFeature { } - -[DependencyModule] -public partial class ModuleImplementation : ISomeFeature -{ -} - [DependencyModule] -[ModuleImplementation] -public partial class FeatureHandlerModule : IDependencyModuleFeature -{ - public void HandleFeature(IServiceCollection collection, IEnumerable features) - { - // invoked with service collection and one instance of the ModuleImplementation class - } -} +public partial class ApplicationModule; ``` -## Managing duplicate registration +A module must be `partial`, and must be declared directly in a namespace rather than nested +inside another type. Services marked with `[SingletonService]` and friends may be nested freely. + +> **Coming from top-level statements?** The generated module takes your project's +> `RootNamespace`, and top-level statements sit in the global namespace — so `Program.cs` +> needs `using YourRootNamespace;` before it can name `ApplicationModule`. -By default a module will only be loaded once, assuming attributes are used or the modules are specified in the same `AddModules` call. Separate calls to `AddModule` will result in modules being loaded multiple times. If a module uses parameters it can be useful to load a module more than once. That can be accomplished by overriding the `Equals` and `GetHashcode` methods to allow for multiple loads. +## Registering forty things without writing forty attributes + +Declare the rule once. It is matched by the compiler, against the types that exist at build +time: ```csharp -// CustomModule will be loaded as long as someString is unique. -// Duplicate modules with the same someString value will be ignored [DependencyModule] -public partial class CustomModule(string someString) : IServiceCollectionConfiguration -{ - public void ConfigureServices(IServiceCollection services) - { - // custom logic - } - - // Re-exposed as a member: a primary constructor parameter is captured, not a property, so - // module.someString would not compile. - private string Key => someString; - - public override bool Equals(object? obj) - { - if (obj is CustomModule module) - { - return someString.Equals(module.Key); +public partial class HandlerModule : IConventionModule { + void IConventionModule.Conventions(IConventionDefinitions conventions) { + conventions.RegisterAll(typeof(IRequestHandler<,>)).AsScoped(); + + conventions.RegisterAll(typeof(IValidator<>)) + .IncludeBaseClasses() + .AlsoAsSelf() + .AsScoped(); } - - return false; - } - - public override int GetHashCode() - { - return someString.GetHashCode(); - } } ``` -Services will be registered using an `Add` method by default. This can be overridden with the `Using` property on individual service or at the `DependencyModule` level. Note: the following are valid registration types Add, Try, TryEnumerable, Replace. +Every handler in the project is registered against the closed interface it implements. Add a +handler tomorrow and it joins; delete one and the registration goes with it. A convention that +stops matching anything is a build warning rather than a runtime surprise. -```csharp -[SingletonService(Using = RegistrationType.Try)] -public class SomeService; +The body of `Conventions` is never executed — it is read from source at compile time, which is +why only the documented calls can appear in it. See the +[conventions guide](https://ipjohnson.github.io/DependencyModules/guide/conventions). -[DependencyModule(Using = RegistrationType.Try)] -public partial class SomeModule; -``` +## Composing modules -## Realm - -By default, all dependencies are registered in all modules within the same assembly. -The realm allows the developer to scope down the registration within a given module. +A module generates an attribute of the same name, so modules compose by attribute: ```csharp -// register only dependencies specifically marked for this realm -[DependencyModule(OnlyRealm = true)] -public partial class AnotherModule; - -[SingletonService(Realm = typeof(AnotherModule))] -public class SomeDep : ISomeInterface { } +[DependencyModule] +[DomainModule] +[InfrastructureModule(useInMemory: true, ConnectionName = "primary")] +public partial class ApiModule; ``` -## Keyed Registration +Constructor parameters and settable properties on a module are mirrored onto its generated +attribute, so a module can be configured by whoever composes it. For anything the attributes +cannot express, implement `IServiceCollectionConfiguration` and write the registrations by hand. + +## Decorators and interception -Registration attributes have a `Key` property that allows for specifying the key at registration time. +Wrap a service without touching it or its callers. The first constructor parameter is the +wrapped instance; the rest resolve normally: ```csharp -[SingletonService(Key = "SomeKey")] -public class KeyService : IKeyService { } +[Decorator(Order = 2000)] +public class CachingRepository(IRepository inner, IMemoryCache cache) : IRepository; + +[Decorator(Order = 1000)] +public class TracingRepository(IRepository inner, ILogger log) : IRepository; -// yields this registration line -services.AddKeyedSingleton(typeof(IKeyService), "SomeKey", typeof(KeyService)); +// resolves as CachingRepository(TracingRepository(SqlRepository)) ``` -## As Registration +Lower orders sit closer to the implementation. Ordering is global across every module in an +`AddModule(s)` call, so an application's decorators can wrap those a library contributed — +by convention framework code uses 0–999 and application code starts at 1000. -Sometimes it's useful to register a type with a specific type vs. letting auto-registration pick a type. -The `As` property allows you to control the service type for the registration. +For cross-cutting behaviour across every member of a service, `[Intercept]` generates a typed +wrapper rather than a dynamic proxy. See +[decorators and interception](https://ipjohnson.github.io/DependencyModules/guide/decorators). -```csharp -[SingletonService(As = typeof(ISomeOtherInterface))] -public class KeyService : IKeyService, ISomeOtherInterface { } +## Testing -// yields this registration line -services.AddSingleton(typeof(KeyService)); -``` +Tests receive their dependencies as method parameters, against the real registration graph: -## Try, Replace, TryEnumerable +```csharp +[assembly: ApplicationModule] +[assembly: NSubstituteSupport] -By default registrations are done using a standard `Add___` method. -It can be useful to change the registration to `Try`, `Replace`, and `TryEnumerable` with the `Using` property. +public class OrderTests { + [ModuleTest] + public async Task PlaceOrder_PricesThroughTheChannel( + IRequestHandler handler, + [Mock] IBookRepository books) { -```csharp -[SingletonService(Using = RegistrationType.Try)] -public class KeyService : IKeyService { } + books.Find("isbn-1", Arg.Any()) + .Returns(new Book("isbn-1", 20m)); -// yields this registration line -services.TryAddSingleton(typeof(IKeyService), typeof(KeyService)); + var order = await handler.Handle(new PlaceOrder("isbn-1", 10), default); + + Assert.Equal(140m, order.Total); + } +} ``` -## Autogenerated Modules +```shell +dotnet add package DependencyModules.xUnit # or DependencyModules.NUnit +dotnet add package DependencyModules.NSubstitute # or .Moq, or .FakeItEasy +``` -To simplify registration for [Top-Level](https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/program-structure/top-level-statements) statement applications, an `ApplicationModule` will be autogenerated for file named Program.cs. +Each test gets its own provider, so singletons cannot leak between them. See the +[testing guide](https://ipjohnson.github.io/DependencyModules/guide/testing). -```csharp -[assembly: SomeOtherModule] +## Native AOT -var serviceCollection = new ServiceCollection(); +Verified end to end: a console application using conventions, keyed registrations, decorators, +a static factory and an intercepted open generic publishes to a **2.2 MB** self-contained +binary with **zero IL trim or AOT warnings**, behaving identically to the JIT build. -// load SomeOtherModule as well as all registrations in the current project -serviceCollection.AddModule(); -``` +The one limitation is not this library's to fix: the container cannot close an open generic +over a value type without dynamic code, so `IRepository` resolves and `IRepository` +throws. Setting `PublishAot` makes that fail in an ordinary `dotnet run` rather than only after +publishing. See the [AOT guide](https://ipjohnson.github.io/DependencyModules/guide/aot). -## Unit testing & Mocking +## Feature reference -DependencyModules provides an xUnit extension to make testing much easier. -It handles the population and construction of a service provider using specified modules. +| | | +|---|---| +| `[SingletonService]` `[ScopedService]` `[TransientService]` | Register with the matching lifetime | +| `[CrossWireService]` | One instance shared across the implementation and its interfaces | +| `As = typeof(IFoo)` | Choose the service type explicitly | +| `Key = "primary"` | Keyed registration | +| `Using = RegistrationType.Try` | `Add`, `Try`, `TryEnumerable` or `Replace` | +| `Realm = typeof(SomeModule)` | Restrict a registration to one module | +| `[IfEnvironment("Development")]` | Register only in named environments | +| `[Decorator]` `[Decorate]` `[Intercept]` | Wrap a service, or one you do not own | +| A `static` method carrying a service attribute | Factory, for types the container cannot build | -```shell -dotnet add package DependencyModules.xUnit -dotnet add package DependencyModules.NSubstitute -``` +Full details for each, with the rules and the edge cases, are in the +[documentation](https://ipjohnson.github.io/DependencyModules/). -Mocking is supplied by a separate package, so use whichever library you already have — -`DependencyModules.NSubstitute`, `DependencyModules.Moq` or `DependencyModules.FakeItEasy` — and -apply its `[NSubstituteSupport]`, `[MoqSupport]` or `[FakeItEasySupport]` attribute. +## Samples -```csharp -// applies module & nsubstitute support to all tests. -// test attributes can be applied at the assembly, class, and test method level -[assembly: MyModule] -[assembly: NSubstituteSupport] +The [`integ-tests/`](https://github.com/ipjohnson/DependencyModules/tree/main/integ-tests) +directory is a working sample gallery, built and tested on every commit: -public class OtherServiceTests -{ - [ModuleTest] - public void SomeTest(OtherService test, [Mock]ISomeService service) - { - service.SomeProp.Returns("some mock value"); - Assert.Equals("some mock value", test.SomeProp); - } -} -``` +| Sample | Shows | +|---|---| +| [`SutProject`](https://github.com/ipjohnson/DependencyModules/tree/main/integ-tests/SutProject) | Every registration shape, in one project | +| [`SutProject.Tests`](https://github.com/ipjohnson/DependencyModules/tree/main/integ-tests/SutProject.Tests) | Conventions, realms, keyed services, cross-wiring, factories, features, and all three mocking libraries | +| [`ConsoleTestProject`](https://github.com/ipjohnson/DependencyModules/tree/main/integ-tests/ConsoleTestProject) | Top-level statements and the generated `ApplicationModule` | +| [`web/WebApiApp`](https://github.com/ipjohnson/DependencyModules/tree/main/integ-tests/web/WebApiApp) | An ASP.NET Core host, with its own test project | ## Reporting a problem -If services are not being registered as you expect, these three steps produce almost everything +If services are not registered as you expect, these three steps produce almost everything needed to diagnose it: -1. **Look at the generated code.** Set `true` - and read the files under `obj/`. The registrations the generator produced are the ground truth. +1. **Read the generated code.** Set `true` + and look under `obj/`. The registrations the generator produced are the ground truth. + (Point `CompilerGeneratedFilesOutputPath` inside `obj/` — a folder in the project directory + gets compiled as ordinary source on the next build.) 2. **Turn on the generator log**, which records the configuration in effect, every module and service discovered, and anything skipped along with the reason: ```xml @@ -315,104 +267,14 @@ needed to diagnose it: $(MSBuildProjectDirectory)/dmlogs ``` -3. **Check for `DM####` warnings** in the build output. The generator reports these for mistakes it - can detect, such as a service type that cannot be constructed or a module missing `partial`. - -Please include the log and the generated file in any [issue](https://github.com/ipjohnson/DependencyModules/issues). - -## Implementation +3. **Check for `DM####` warnings** in the build output. The generator reports these for mistakes + it can detect — see the + [diagnostics reference](https://ipjohnson.github.io/DependencyModules/reference/diagnostics). -Behind the scenes the library generates registration code that can be used with any `IServiceCollection` compatible DI container. +Please include the log and the generated file in any +[issue](https://github.com/ipjohnson/DependencyModules/issues). -Example generated code for [SutModule.cs](integ-tests/SutProject/SutModule.cs) -```csharp - // SutModule.Dependencies.g.cs - public partial class SutModule - { - [DynamicDependency(nameof(ModuleDependencies))] - private static int moduleField = global::DependencyModules.Runtime.Helpers.DependencyRegistry.Add(ModuleDependencies); - - private static void ModuleDependencies(global::Microsoft.Extensions.DependencyInjection.IServiceCollection services) - { - services.AddTransient( - typeof(global::SutProject.IDependencyOne), - typeof(global::SutProject.DependencyOne) - ); - services.AddSingleton( - typeof(global::SutProject.IGenericInterface<>), - typeof(global::SutProject.GenericClass<>) - ); - services.AddKeyedTransient( - typeof(global::SutProject.KeyedService), - Constants.StringValue, - typeof(global::SutProject.KeyedService) - ); - services.AddScoped( - typeof(global::SutProject.IScopedService), - typeof(global::SutProject.ScopedService) - ); - services.AddSingleton( - typeof(global::SutProject.ISingletonService), - typeof(global::SutProject.SingletonService) - ); - services.AddSingleton( - typeof(global::SutProject.IGenericInterface), - typeof(global::SutProject.StringGeneric) - ); - } - } +## License - // SutModule.Modules.g.cs -namespace SutProject -{ - #nullable enable - public partial class SutModule : global::DependencyModules.Runtime.Interfaces.IDependencyModule - { - - static SutModule() - { - } - - public void PopulateServiceCollection(global::Microsoft.Extensions.DependencyInjection.IServiceCollection services) - { - global::DependencyModules.Runtime.Helpers.DependencyRegistry.LoadModules(services, this); - } - - [Browsable(false)] - void global::DependencyModules.Runtime.Interfaces.IDependencyModule.InternalApplyServices(global::Microsoft.Extensions.DependencyInjection.IServiceCollection services) - { - global::DependencyModules.Runtime.Helpers.DependencyRegistry.ApplyServices(services); - } - - [Browsable(false)] - global::System.Collections.Generic.IEnumerable global::DependencyModules.Runtime.Interfaces.IDependencyModule.InternalGetModules() - { - return global::DependencyModules.Runtime.Helpers.DependencyRegistry.GetModules(); - } - - public override bool Equals(object? obj) - { - return obj is SutModule; - } - - public override int GetHashCode() - { - return HashCode.Combine(base.GetHashCode()); - } - } - #nullable disable - - [global::System.AttributeUsage(global::System.AttributeTargets.Class | global::System.AttributeTargets.Assembly | global::System.AttributeTargets.Method | global::System.AttributeTargets.Parameter, AllowMultiple = true)] - #nullable enable - public partial class SutModuleAttribute : global::System.Attribute, global::DependencyModules.Runtime.Interfaces.IDependencyModuleProvider - { - - public global::DependencyModules.Runtime.Interfaces.IDependencyModule GetModule() - { - var newModule = new global::SutProject.SutModule(); - return newModule; - } - } - #nullable disable -} -``` +MIT. See [LICENSE.txt](https://github.com/ipjohnson/DependencyModules/blob/main/LICENSE.txt) +and [CHANGELOG.md](https://github.com/ipjohnson/DependencyModules/blob/main/CHANGELOG.md). diff --git a/src/DependencyModules.SourceGenerator.Impl/AnalyzerReleases.Unshipped.md b/src/DependencyModules.SourceGenerator.Impl/AnalyzerReleases.Unshipped.md index e62eb69..74d2e29 100644 --- a/src/DependencyModules.SourceGenerator.Impl/AnalyzerReleases.Unshipped.md +++ b/src/DependencyModules.SourceGenerator.Impl/AnalyzerReleases.Unshipped.md @@ -20,3 +20,4 @@ DM0012 | DependencyModules | Warning | An environment condition names nothing to DM0013 | DependencyModules | Warning | A service registered as an open generic cannot be decorated. DM0014 | DependencyModules | Warning | A generic type cannot be cross-wired. DM0015 | DependencyModules | Warning | An interceptor does not apply to every member it was applied to. +DM0016 | DependencyModules | Warning | An assembly-level module attribute's namespace is not imported. diff --git a/src/DependencyModules.SourceGenerator.Impl/DependencyFileWriter.cs b/src/DependencyModules.SourceGenerator.Impl/DependencyFileWriter.cs index 1bf3fb4..cc5b72f 100644 --- a/src/DependencyModules.SourceGenerator.Impl/DependencyFileWriter.cs +++ b/src/DependencyModules.SourceGenerator.Impl/DependencyFileWriter.cs @@ -69,6 +69,14 @@ private void GenerateClass(ModuleEntryPointModel entryPointModel, classDefinition.Modifiers |= ComponentModifier.Partial; + // A registered service type carries whatever nullable annotation its declaration used, so + // `class GetBookHandler : IRequestHandler` emits typeof(...Book?). Roslyn + // requires generated code to open a nullable context explicitly whatever the project sets, + // and without one that annotation is CS8669 — a warning the consumer cannot fix from their + // own source, and a build break under TreatWarningsAsErrors. The module and attribute + // writers already do this; the registrations file is where the annotations actually land. + classDefinition.EnableNullable(); + if (configurationModel.ExcludeGeneratedCodeFromCoverage && !_coverageAttributeOnMethod) { classDefinition.AddAttribute( TypeDefinition.Get("System.Diagnostics.CodeAnalysis", "ExcludeFromCodeCoverage")); diff --git a/src/DependencyModules.SourceGenerator.Impl/DependencyModuleDiagnostics.cs b/src/DependencyModules.SourceGenerator.Impl/DependencyModuleDiagnostics.cs index a003b2b..5505421 100644 --- a/src/DependencyModules.SourceGenerator.Impl/DependencyModuleDiagnostics.cs +++ b/src/DependencyModules.SourceGenerator.Impl/DependencyModuleDiagnostics.cs @@ -309,4 +309,33 @@ public static class DependencyModuleDiagnostics { defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true); + /// + /// Raised for an assembly-level module attribute whose namespace the file does not import. + /// + /// + /// A module generates its attribute in the module's own namespace, and an assembly-level + /// attribute has no namespace context to inherit — a using inside a namespace declaration + /// cannot apply to it, because assembly attributes precede every namespace in the file. + /// + /// So [assembly: ApplicationModule] in a file that does not import the module's namespace + /// fails with CS0246 naming ApplicationModuleAttribute — a type the developer never + /// wrote, generated into a namespace the error does not mention, by a generator whose output they + /// have probably never looked at. Every part of that error points away from the fix. + /// + /// This cannot be decided by asking the compiler, because the attribute does not exist yet while + /// the generator that writes it is running. It is read from syntax instead: an assembly attribute + /// whose name matches a module this compilation declares, written unqualified, in a file that + /// imports neither that namespace nor anything global using supplies. + /// + public static readonly DiagnosticDescriptor ModuleAttributeNamespaceNotImported = new( + id: "DM0016", + title: "Assembly-level module attribute needs its namespace imported", + messageFormat: + "'{0}' is declared in '{1}', and an assembly-level attribute has no namespace context, so " + + "this does not compile. Add 'using {1};' to this file, or write it qualified as " + + "'[assembly: {1}.{0}]'.", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + } diff --git a/src/DependencyModules.SourceGenerator/AssemblyModuleAttributeDiagnostics.cs b/src/DependencyModules.SourceGenerator/AssemblyModuleAttributeDiagnostics.cs new file mode 100644 index 0000000..6b0c742 --- /dev/null +++ b/src/DependencyModules.SourceGenerator/AssemblyModuleAttributeDiagnostics.cs @@ -0,0 +1,193 @@ +using System.Collections.Immutable; +using DependencyModules.SourceGenerator.Impl; +using DependencyModules.SourceGenerator.Impl.Models; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace DependencyModules.SourceGenerator; + +/// +/// Reports — DM0016. +/// +/// +/// +/// Read from syntax rather than from the semantic model, which is not optional here: the attribute +/// this checks for is generated by the very generator that is running, so it does not exist in the +/// compilation being examined and nothing about it can be resolved. Every assembly-level module +/// attribute looks unresolved from here, which is why the check is "is there a module by that name, +/// and could this file see it" rather than "did it bind". +/// +/// +/// It stays quiet unless it can name the fix. An attribute that matches no module in this +/// compilation is somebody else's; a module in the global namespace needs no import; a qualified +/// usage already says where to look. +/// +/// +internal static class AssemblyModuleAttributeDiagnostics { + + /// + /// One compilation unit's assembly attributes and the namespaces in scope for them. + /// + internal sealed class UnitModel : IEquatable { + + public UnitModel( + ImmutableArray usages, + ImmutableArray fileUsings, + ImmutableArray globalUsings) { + Usages = usages; + FileUsings = fileUsings; + GlobalUsings = globalUsings; + } + + public ImmutableArray Usages { get; } + + /// Namespaces this file imports, which apply only to this file. + public ImmutableArray FileUsings { get; } + + /// Namespaces this file imports for the whole compilation. + public ImmutableArray GlobalUsings { get; } + + public bool Equals(UnitModel? other) => + other != null && + Usages.SequenceEqual(other.Usages) && + FileUsings.SequenceEqual(other.FileUsings) && + GlobalUsings.SequenceEqual(other.GlobalUsings); + + public override bool Equals(object? obj) => Equals(obj as UnitModel); + + public override int GetHashCode() => Usages.Length * 397 ^ FileUsings.Length; + } + + internal sealed class Usage : IEquatable { + + public Usage(string name, Location location) { + Name = name; + Location = location; + } + + /// The attribute name exactly as written, without any qualification. + public string Name { get; } + + public Location Location { get; } + + public bool Equals(Usage? other) => + other != null && Name == other.Name && Location.Equals(other.Location); + + public override bool Equals(object? obj) => Equals(obj as Usage); + + public override int GetHashCode() => Name.GetHashCode(); + } + + internal static IncrementalValueProvider> Collect( + IncrementalGeneratorInitializationContext context) => + context.SyntaxProvider.CreateSyntaxProvider( + static (node, _) => node is CompilationUnitSyntax, + static (syntaxContext, cancellation) => Read(syntaxContext, cancellation)) + .Where(static model => !model.Usages.IsEmpty || !model.GlobalUsings.IsEmpty) + .Collect(); + + private static UnitModel Read(GeneratorSyntaxContext context, CancellationToken cancellation) { + cancellation.ThrowIfCancellationRequested(); + + var unit = (CompilationUnitSyntax)context.Node; + + var usages = ImmutableArray.CreateBuilder(); + var fileUsings = ImmutableArray.CreateBuilder(); + var globalUsings = ImmutableArray.CreateBuilder(); + + foreach (var usingDirective in unit.Usings) { + // An alias imports one name rather than a namespace, so it cannot bring a module + // attribute into scope under the name this checks for. + if (usingDirective.Alias != null || usingDirective.Name == null) { + continue; + } + + var target = usingDirective.GlobalKeyword.IsKind(SyntaxKind.GlobalKeyword) + ? globalUsings + : fileUsings; + + target.Add(usingDirective.Name.ToString()); + } + + foreach (var attributeList in unit.AttributeLists) { + if (!attributeList.Target?.Identifier.IsKind(SyntaxKind.AssemblyKeyword) ?? true) { + continue; + } + + foreach (var attribute in attributeList.Attributes) { + // A qualified usage already names where the attribute lives. + if (attribute.Name is not SimpleNameSyntax simpleName) { + continue; + } + + usages.Add(new Usage(simpleName.Identifier.Text, attribute.GetLocation())); + } + } + + return new UnitModel(usages.ToImmutable(), fileUsings.ToImmutable(), globalUsings.ToImmutable()); + } + + internal static void Report( + SourceProductionContext context, + (ImmutableArray<(ModuleEntryPointModel Left, DependencyModuleConfigurationModel Right)> Modules, + ImmutableArray Units) input) { + + if (input.Units.IsDefaultOrEmpty || input.Modules.IsDefaultOrEmpty) { + return; + } + + // A module in the global namespace needs no import, and an auto-generated ApplicationModule + // is never written by hand at the assembly level, so neither can produce this mistake. + var modulesByName = new Dictionary(StringComparer.Ordinal); + + foreach (var (module, _) in input.Modules) { + var moduleNamespace = module.EntryPointType.Namespace; + + if (string.IsNullOrEmpty(moduleNamespace) || + module.ModuleFeatures.HasFlag(ModuleEntryPointFeatures.AutoGenerateModule)) { + continue; + } + + modulesByName[module.EntryPointType.Name] = moduleNamespace; + } + + if (modulesByName.Count == 0) { + return; + } + + var globalUsings = new HashSet(StringComparer.Ordinal); + + foreach (var unit in input.Units) { + foreach (var globalUsing in unit.GlobalUsings) { + globalUsings.Add(globalUsing); + } + } + + foreach (var unit in input.Units) { + foreach (var usage in unit.Usages) { + context.CancellationToken.ThrowIfCancellationRequested(); + + // Written as [assembly: Foo] or [assembly: FooAttribute]; both name module Foo. + if (!modulesByName.TryGetValue(usage.Name, out var moduleNamespace) && + !(usage.Name.EndsWith("Attribute", StringComparison.Ordinal) && + modulesByName.TryGetValue( + usage.Name.Substring(0, usage.Name.Length - "Attribute".Length), + out moduleNamespace))) { + continue; + } + + if (unit.FileUsings.Contains(moduleNamespace) || globalUsings.Contains(moduleNamespace)) { + continue; + } + + context.ReportDiagnostic( + Diagnostic.Create( + DependencyModuleDiagnostics.ModuleAttributeNamespaceNotImported, + usage.Location, + usage.Name, + moduleNamespace)); + } + } + } +} diff --git a/src/DependencyModules.SourceGenerator/SourceGenerator.cs b/src/DependencyModules.SourceGenerator/SourceGenerator.cs index 4f146e8..eba2b1f 100644 --- a/src/DependencyModules.SourceGenerator/SourceGenerator.cs +++ b/src/DependencyModules.SourceGenerator/SourceGenerator.cs @@ -27,5 +27,11 @@ protected override void SetupRootGenerator(IncrementalGeneratorInitializationCon IncrementalValueProvider> valuesProvider) { context.RegisterSourceOutput(valuesProvider, new DependencyModuleWriter(true).GenerateSource); + + // DM0016. Registered here rather than on the base class so that a framework generator loaded + // alongside this one does not report the same usage twice. + context.RegisterSourceOutput( + valuesProvider.Combine(AssemblyModuleAttributeDiagnostics.Collect(context)), + AssemblyModuleAttributeDiagnostics.Report); } } \ No newline at end of file diff --git a/src/DependencyModules.Testing/Attributes/InjectValuesAttribute.cs b/src/DependencyModules.Testing/Attributes/InjectValuesAttribute.cs index 43ff8d5..c2d9f74 100644 --- a/src/DependencyModules.Testing/Attributes/InjectValuesAttribute.cs +++ b/src/DependencyModules.Testing/Attributes/InjectValuesAttribute.cs @@ -14,6 +14,19 @@ namespace DependencyModules.Testing.Attributes; /// This attribute can be applied to method parameters and is resolved when /// the method is invoked, enabling runtime value injection. /// +/// +/// The values are the parameter type's constructor arguments, not the parameter's value. +/// They are combined with whatever the container supplies, so +/// [InjectValues("wholesale")] OrderRequest constructs an +/// OrderRequest(IValidator<PlaceOrder>, string) with the validator resolved and the +/// string supplied. A parameter that should simply be a value wants a data row — +/// [InlineData] or [TestCase] — which composes with [ModuleTest]; asking for a +/// bare string here fails, because System.String has no matching constructor. +/// +/// Restricted to parameters. Applied anywhere else it is never read, and the test then fails inside +/// ActivatorUtilities naming the parameter's type rather than the misplaced attribute. +/// +[AttributeUsage(AttributeTargets.Parameter)] public class InjectValuesAttribute(params object[] value) : Attribute, IInjectValueAttribute { /// diff --git a/tests/DependencyModules.Tests/GeneratorTests/AssemblyModuleAttributeDiagnosticsTests.cs b/tests/DependencyModules.Tests/GeneratorTests/AssemblyModuleAttributeDiagnosticsTests.cs new file mode 100644 index 0000000..90a548a --- /dev/null +++ b/tests/DependencyModules.Tests/GeneratorTests/AssemblyModuleAttributeDiagnosticsTests.cs @@ -0,0 +1,124 @@ +using DependencyModules.Tests.Infrastructure; +using Xunit; + +namespace DependencyModules.Tests.GeneratorTests; + +/// +/// DM0016. A module generates its attribute in the module's own namespace, and an assembly-level +/// attribute has no namespace context to inherit, so [assembly: ApplicationModule] without a +/// using fails with CS0246 naming a type the developer never wrote. +/// +/// The check is syntactic and cannot be otherwise: the attribute is written by the generator that is +/// running, so it does not exist in the compilation being examined and nothing about it resolves. +/// That makes the false positives the interesting cases, and most of these tests are one. +/// +public class AssemblyModuleAttributeDiagnosticsTests { + + private const string ModuleInNamespace = + """ + namespace MyApp.Composition; + + [DependencyModules.Runtime.Attributes.DependencyModule] + public partial class ApplicationModule; + """; + + [Fact] + public void MissingUsing_IsReported() { + var result = Run("[assembly: ApplicationModule]"); + + var diagnostic = Assert.Single(result.GeneratorDiagnostics, d => d.Id == "DM0016"); + + Assert.Contains("MyApp.Composition", diagnostic.GetMessage()); + Assert.Contains("using MyApp.Composition;", diagnostic.GetMessage()); + } + + /// The suffixed spelling names the same module. + [Fact] + public void MissingUsing_IsReported_ForTheAttributeSuffixedSpelling() { + var result = Run("[assembly: ApplicationModuleAttribute]"); + + Assert.Single(result.GeneratorDiagnostics, d => d.Id == "DM0016"); + } + + [Fact] + public void TheUsingBeingPresent_IsSilent() { + var result = Run( + """ + using MyApp.Composition; + + [assembly: ApplicationModule] + """); + + Assert.DoesNotContain(result.GeneratorDiagnostics, d => d.Id == "DM0016"); + } + + /// + /// A global using in any file supplies the namespace everywhere, so reading only the file the + /// attribute sits in would report a build that is already correct. + /// + [Fact] + public void AGlobalUsingInAnotherFile_IsSilent() { + var result = GeneratorTestHarness.Run( + new Dictionary { + ["Module.cs"] = ModuleInNamespace, + ["GlobalUsings.cs"] = "global using MyApp.Composition;", + ["Bootstrap.cs"] = "[assembly: ApplicationModule]" + }); + + Assert.DoesNotContain(result.GeneratorDiagnostics, d => d.Id == "DM0016"); + } + + [Fact] + public void AQualifiedUsage_IsSilent() { + var result = Run("[assembly: MyApp.Composition.ApplicationModule]"); + + Assert.DoesNotContain(result.GeneratorDiagnostics, d => d.Id == "DM0016"); + } + + /// An attribute this compilation declares no module for belongs to somebody else. + [Fact] + public void AnUnrelatedAssemblyAttribute_IsSilent() { + var result = Run("[assembly: System.Reflection.AssemblyMetadata(\"key\", \"value\")]"); + + Assert.DoesNotContain(result.GeneratorDiagnostics, d => d.Id == "DM0016"); + } + + /// A module in the global namespace has no namespace to import. + [Fact] + public void AModuleInTheGlobalNamespace_IsSilent() { + var result = GeneratorTestHarness.Run( + new Dictionary { + ["Module.cs"] = + """ + [DependencyModules.Runtime.Attributes.DependencyModule] + public partial class ApplicationModule; + """, + ["Bootstrap.cs"] = "[assembly: ApplicationModule]" + }); + + Assert.DoesNotContain(result.GeneratorDiagnostics, d => d.Id == "DM0016"); + } + + /// + /// A using alias imports one name rather than a namespace, so it does not bring the attribute + /// into scope under the name written here and the report still stands. + /// + [Fact] + public void AUsingAlias_DoesNotCountAsTheImport() { + var result = Run( + """ + using Composition = MyApp.Composition; + + [assembly: ApplicationModule] + """); + + Assert.Single(result.GeneratorDiagnostics, d => d.Id == "DM0016"); + } + + private static GeneratorResult Run(string bootstrap) => + GeneratorTestHarness.Run( + new Dictionary { + ["Module.cs"] = ModuleInNamespace, + ["Bootstrap.cs"] = bootstrap + }); +} diff --git a/tests/DependencyModules.Tests/GeneratorTests/GeneratedCodeRobustnessTests.cs b/tests/DependencyModules.Tests/GeneratorTests/GeneratedCodeRobustnessTests.cs index bb50c69..d3e3398 100644 --- a/tests/DependencyModules.Tests/GeneratorTests/GeneratedCodeRobustnessTests.cs +++ b/tests/DependencyModules.Tests/GeneratorTests/GeneratedCodeRobustnessTests.cs @@ -91,6 +91,70 @@ public partial class TestModule; string.Join(Environment.NewLine, warnings.Select(w => $" {w.Id} {w.GetMessage()}"))); } + /// + /// Regression test: a registered service type keeps whatever nullable annotation its declaration + /// used, so IHandler<Query, Result?> emits typeof(…Result?). Roslyn requires + /// generated code to open a nullable context explicitly however the consuming project is + /// configured, and the registrations file was the one generated file that never did — making a + /// find-by-id handler, the most ordinary shape there is, CS8669 and a build break under + /// TreatWarningsAsErrors that the consumer could not fix from their own source. + /// + /// GeneratedCode_ProducesNoWarnings above covers the same ground but registers a service whose + /// type arguments carry no annotation, which is why it stayed green throughout. + /// + [Theory] + [InlineData("attribute", "[SingletonService]")] + [InlineData("convention", "")] + public void GeneratedCode_ProducesNoWarnings_ForANullableTypeArgument(string _, string attribute) { + var result = GeneratorTestHarness.Run( + $$""" + using DependencyModules.Runtime.Attributes; + using DependencyModules.Runtime.Conventions; + + namespace TestNamespace; + + public class Book; + + public interface IHandler { + TResult Handle(TQuery query); + } + + public record GetBook(string Isbn); + + {{attribute}} + public class GetBookHandler : IHandler { + public Book? Handle(GetBook query) => null; + } + + [DependencyModule] + public partial class TestModule : IConventionModule { + void IConventionModule.Conventions(IConventionDefinitions conventions) { + conventions.RegisterAll(typeof(IHandler<,>)).AsScoped(); + } + } + """); + + result.AssertNoErrors(); + + AssertNoWarningsFromGeneratedCode(result); + } + + private static void AssertNoWarningsFromGeneratedCode(GeneratorResult result) { + var generatedTreePaths = result.Compilation.SyntaxTrees + .Where(tree => tree.FilePath.EndsWith(".g.cs", StringComparison.Ordinal)) + .Select(tree => tree.FilePath) + .ToHashSet(StringComparer.Ordinal); + + var warnings = result.CompilationDiagnostics + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Warning) + .Where(diagnostic => generatedTreePaths.Contains(diagnostic.Location.SourceTree?.FilePath ?? "")) + .ToArray(); + + Assert.True(warnings.Length == 0, + "Generated code produced warnings:" + Environment.NewLine + + string.Join(Environment.NewLine, warnings.Select(w => $" {w.Id} {w.GetMessage()}"))); + } + [Fact] public void GeneratedCode_QualifiesReferencesToTheRuntime() { var result = GeneratorTestHarness.Run( diff --git a/tests/DependencyModules.Tests/GeneratorTests/InterceptorGenerationTests.cs b/tests/DependencyModules.Tests/GeneratorTests/InterceptorGenerationTests.cs index a2169fc..6431b36 100644 --- a/tests/DependencyModules.Tests/GeneratorTests/InterceptorGenerationTests.cs +++ b/tests/DependencyModules.Tests/GeneratorTests/InterceptorGenerationTests.cs @@ -48,12 +48,15 @@ public void VoidMethod_RunsThroughThePipeline() { public async Task AsyncMethod_ExitsAfterTheWorkCompletes() { var generated = GeneratedAssembly.Create(Source( "System.Threading.Tasks.Task Compute(int a);", - "public async System.Threading.Tasks.Task Compute(int a) { await System.Threading.Tasks.Task.Delay(20); return a * 2; }")); + "public async System.Threading.Tasks.Task Compute(int a) { await Recorder.Gate.Task; return a * 2; }")); var task = (Task)Invoke(generated.ResolveRequired("IWork"), "Compute", 21)!; + // The work is suspended at a gate nothing has released, so this cannot have happened yet. Assert.DoesNotContain("exit Compute", Log(generated)); + Gate(generated).SetResult(); + var result = await task; Assert.Equal(42, result); @@ -807,6 +810,13 @@ public partial class TestModule; private static IReadOnlyList Log(GeneratedAssembly generated) => (IReadOnlyList)generated.Type("Recorder").GetField("Entries")!.GetValue(null)!; + /// + /// The gate belongs to the generated assembly, so each compilation gets its own and no test can + /// release another's. + /// + private static TaskCompletionSource Gate(GeneratedAssembly generated) => + (TaskCompletionSource)generated.Type("Recorder").GetField("Gate")!.GetValue(null)!; + private static object? Invoke(object target, string method, params object?[] arguments) => target.GetType().GetMethod(method)!.Invoke(target, arguments); @@ -821,6 +831,12 @@ namespace TestNamespace; public static class Recorder { public static readonly List Entries = new(); + + // Released by the test. An implementation awaiting this is suspended until the test says + // otherwise, which is what "the work has not finished yet" needs in order to be a fact + // rather than a race against a timer. + public static readonly TaskCompletionSource Gate = + new(TaskCreationOptions.RunContinuationsAsynchronously); } """; diff --git a/tests/DependencyModules.Tests/RuntimeTests/InvocationPipelineTests.cs b/tests/DependencyModules.Tests/RuntimeTests/InvocationPipelineTests.cs index 77f8013..2eace70 100644 --- a/tests/DependencyModules.Tests/RuntimeTests/InvocationPipelineTests.cs +++ b/tests/DependencyModules.Tests/RuntimeTests/InvocationPipelineTests.cs @@ -157,11 +157,16 @@ public void AnException_PropagatesThroughThePipeline() { [Fact] public async Task AsyncMember_ExitsWhenTheWorkFinishesRatherThanWhenTheTaskIsHandedBack() { var fixture = new Fixture(); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + fixture.Implementation.ComputeGate = gate.Task; var task = fixture.Service.ComputeAsync(21); + // The work is suspended at a gate nothing has released, so this cannot have happened yet. Assert.DoesNotContain(fixture.Log, entry => entry.Contains("exit")); + gate.SetResult(); + var result = await task; Assert.Equal(42, result); @@ -243,8 +248,15 @@ public void Record(string entry) { Calls.Add($"Record({entry})"); } + /// + /// Held open by the test rather than by a timer. A test asserting that the work has not + /// finished yet is asserting about a suspension, and a sleep only makes that likely. + /// Defaults to already-completed so a test that does not care cannot deadlock on it. + /// + public Task ComputeGate { get; set; } = Task.CompletedTask; + public async Task ComputeAsync(int value) { - await Task.Delay(20); + await ComputeGate; Calls.Add($"ComputeAsync({value})"); diff --git a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.GenericServiceRegistrations.verified.txt b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.GenericServiceRegistrations.verified.txt index 76a40db..0d67ae6 100644 --- a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.GenericServiceRegistrations.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.GenericServiceRegistrations.verified.txt @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; namespace TestNamespace { + #nullable enable [ExcludeFromCodeCoverage] public partial class TestModule { @@ -23,6 +24,7 @@ namespace TestNamespace ); } } + #nullable disable } // ---- TestModule.Module.g.cs ---- diff --git a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.KeyedAndAsRegistrations.verified.txt b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.KeyedAndAsRegistrations.verified.txt index 08a36cd..d1c736e 100644 --- a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.KeyedAndAsRegistrations.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.KeyedAndAsRegistrations.verified.txt @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; namespace TestNamespace { + #nullable enable [ExcludeFromCodeCoverage] public partial class TestModule { @@ -24,6 +25,7 @@ namespace TestNamespace ); } } + #nullable disable } // ---- TestModule.Module.g.cs ---- diff --git a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithAllServiceLifetimes.verified.txt b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithAllServiceLifetimes.verified.txt index 2b459f6..8f5d2b5 100644 --- a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithAllServiceLifetimes.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithAllServiceLifetimes.verified.txt @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; namespace TestNamespace { + #nullable enable [ExcludeFromCodeCoverage] public partial class TestModule { @@ -27,6 +28,7 @@ namespace TestNamespace ); } } + #nullable disable } // ---- TestModule.Module.g.cs ---- diff --git a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithConstructorParametersAndProperties.verified.txt b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithConstructorParametersAndProperties.verified.txt index 23838a3..611fd2f 100644 --- a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithConstructorParametersAndProperties.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithConstructorParametersAndProperties.verified.txt @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; namespace TestNamespace { + #nullable enable [ExcludeFromCodeCoverage] public partial class TestModule { @@ -19,6 +20,7 @@ namespace TestNamespace ); } } + #nullable disable } // ---- TestModule.Module.g.cs ---- diff --git a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithCoverageExclusionDisabled.verified.txt b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithCoverageExclusionDisabled.verified.txt index 5df12c9..43e545c 100644 --- a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithCoverageExclusionDisabled.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithCoverageExclusionDisabled.verified.txt @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; namespace TestNamespace { + #nullable enable public partial class TestModule { [DynamicDependency(nameof(ModuleDependencies))] @@ -18,6 +19,7 @@ namespace TestNamespace ); } } + #nullable disable } // ---- TestModule.Module.g.cs ---- diff --git a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithEnvironmentConditions.verified.txt b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithEnvironmentConditions.verified.txt index ea3a3fe..f2c2cdc 100644 --- a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithEnvironmentConditions.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithEnvironmentConditions.verified.txt @@ -6,6 +6,7 @@ using System.Diagnostics.CodeAnalysis; namespace TestNamespace { + #nullable enable [ExcludeFromCodeCoverage] public partial class TestModule { @@ -38,6 +39,7 @@ namespace TestNamespace } } } + #nullable disable } // ---- TestModule.Module.g.cs ---- diff --git a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.RecordModule.verified.txt b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.RecordModule.verified.txt index 6c9448d..4c1f3fe 100644 --- a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.RecordModule.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.RecordModule.verified.txt @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; namespace TestNamespace { + #nullable enable [ExcludeFromCodeCoverage] public partial record class TestModule { @@ -19,6 +20,7 @@ namespace TestNamespace ); } } + #nullable disable } // ---- TestModule.Module.g.cs ---- diff --git a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.RegistrationTypeVariants.verified.txt b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.RegistrationTypeVariants.verified.txt index 8d135a3..f9ccb30 100644 --- a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.RegistrationTypeVariants.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.RegistrationTypeVariants.verified.txt @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; namespace TestNamespace { + #nullable enable [ExcludeFromCodeCoverage] public partial class TestModule { @@ -29,6 +30,7 @@ namespace TestNamespace ); } } + #nullable disable } // ---- TestModule.Module.g.cs ---- diff --git a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.SimpleModule.verified.txt b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.SimpleModule.verified.txt index 9bfcacc..59ecd15 100644 --- a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.SimpleModule.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.SimpleModule.verified.txt @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; namespace TestNamespace { + #nullable enable [ExcludeFromCodeCoverage] public partial class TestModule { @@ -19,6 +20,7 @@ namespace TestNamespace ); } } + #nullable disable } // ---- TestModule.Module.g.cs ---- diff --git a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.SourceGeneratorApi.verified.txt b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.SourceGeneratorApi.verified.txt index 5052420..ccfaa07 100644 --- a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.SourceGeneratorApi.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.SourceGeneratorApi.verified.txt @@ -1090,6 +1090,7 @@ namespace DependencyModules.SourceGenerator.Impl public static readonly Microsoft.CodeAnalysis.DiagnosticDescriptor ExposedByConvention; public static readonly Microsoft.CodeAnalysis.DiagnosticDescriptor GeneratorFailure; public static readonly Microsoft.CodeAnalysis.DiagnosticDescriptor InterceptorCannotServeMembers; + public static readonly Microsoft.CodeAnalysis.DiagnosticDescriptor ModuleAttributeNamespaceNotImported; public static readonly Microsoft.CodeAnalysis.DiagnosticDescriptor ModuleMustBePartial; public static readonly Microsoft.CodeAnalysis.DiagnosticDescriptor OpenGenericCannotBeDecorated; public static readonly Microsoft.CodeAnalysis.DiagnosticDescriptor RegisteredConditionally; diff --git a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.TestingApi.verified.txt b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.TestingApi.verified.txt index 9028398..24253f7 100644 --- a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.TestingApi.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.TestingApi.verified.txt @@ -1,5 +1,6 @@ namespace DependencyModules.Testing.Attributes { + [System.AttributeUsage(System.AttributeTargets.Parameter)] public class InjectValuesAttribute : System.Attribute, DependencyModules.Testing.Attributes.Interfaces.IInjectValueAttribute { public InjectValuesAttribute(params object[] value) { } diff --git a/website/guide/modules.md b/website/guide/modules.md index 3451320..07d77b7 100644 --- a/website/guide/modules.md +++ b/website/guide/modules.md @@ -88,6 +88,9 @@ For applications using [top-level statements](https://learn.microsoft.com/en-us/ an `ApplicationModule` is generated for you from `Program.cs`: ```csharp +using MyApp; // the generated module takes your RootNamespace +using DependencyModules.Runtime; + [assembly: SomeOtherModule] // compose other modules at the assembly level var services = new ServiceCollection(); @@ -96,6 +99,13 @@ var services = new ServiceCollection(); services.AddModule(); ``` +::: warning The first `using` is not optional +The generated module takes the project's `RootNamespace`, and top-level statements sit in the +global namespace — so `Program.cs` cannot see `ApplicationModule` until it imports that namespace. +Leave it out and the build fails with `CS0246: The type or namespace name 'ApplicationModule' could +not be found`, which does not hint at the cause. +::: + This is why the ASP.NET sample in this repository never declares a module — the web project's `Program.cs` gets one automatically, and the test project composes it by name. diff --git a/website/guide/testing.md b/website/guide/testing.md index 5a192c2..33284ba 100644 --- a/website/guide/testing.md +++ b/website/guide/testing.md @@ -101,11 +101,18 @@ every test needs in one file at the assembly level: ```csharp // Bootstrap.cs using DependencyModules.NSubstitute; +using MyApp.Tests; // the namespace the module is declared in [assembly: ApplicationModule] [assembly: NSubstituteSupport] // or [MoqSupport] / [FakeItEasySupport] ``` +That second `using` is easy to miss. A module generates its attribute in the module's own +namespace, and an assembly-level attribute has no namespace context to inherit — so without it the +build fails with `CS0246: The type or namespace name 'ApplicationModuleAttribute' could not be +found`, naming a type you never wrote. Importing the namespace or writing the attribute qualified, +`[assembly: MyApp.Tests.ApplicationModule]`, both work. + Every test in the project now gets `ApplicationModule` without saying so: ```csharp @@ -210,8 +217,8 @@ whatever order the two are declared in — see [ordering](/guide/testing-mocking ## When the parameter is not a service at all -Sometimes a test parameter is data — a string, an id, a record combining both. `[InjectValues]` -supplies the parts the container cannot: +Sometimes a test parameter is a type the container cannot build on its own, because part of it is +data rather than a service. `[InjectValues]` supplies the parts the container cannot: ```csharp public record InjectModel(IDependencyOne DependencyOne, string StringValue); @@ -226,13 +233,30 @@ public void InjectTestValue([InjectValues("Hello World!")] InjectModel model) { The values are matched against the constructor parameters the container **cannot** supply, so you list only what it could not work out for itself. +They are the parameter type's *constructor arguments*, not the parameter's own value — so a +parameter that should simply **be** a value wants a data row instead. `[InlineData]` and NUnit's +`[TestCase]` both compose with `[ModuleTest]`, and the container fills whatever the row does not: + +```csharp +[ModuleTest] +[InlineData("978-0132350884")] +[InlineData("978-0201616224")] +public async Task GetBook_FindsEachIsbn(string isbn, IRequestHandler handler) { + // isbn came from the row, handler from the container +} +``` + +Asking for a bare `string` through `[InjectValues]` fails with *"A suitable constructor for type +'System.String' could not be located"*, because that is exactly what it tried to do. + ## Choosing between the three | | Reach for it when | |---|---| | [`[Mock]`](/guide/testing-mocking) | you want to assert on the interaction — what was called, with what | | `[TestExport]` | you want a real object with different behaviour, constructed by the container | -| `[InjectValues]` | the parameter is data, not a service | +| `[InjectValues]` | the parameter is a type the container cannot finish building, because part of it is data | +| `[InlineData]` / `[TestCase]` | the parameter simply **is** a value — one test per row | ## What is worth testing diff --git a/website/reference/diagnostics.md b/website/reference/diagnostics.md index 10c056a..77668c9 100644 --- a/website/reference/diagnostics.md +++ b/website/reference/diagnostics.md @@ -38,6 +38,7 @@ means they appear in the IDE and never in `dotnet build` at any verbosity. The r | [DM0013](#dm0013) | Warning | A service registered as an open generic cannot be decorated | | [DM0014](#dm0014) | Warning | A generic type cannot be cross-wired | | [DM0015](#dm0015) | Warning | An interceptor does not apply to every member | +| [DM0016](#dm0016) | Warning | An assembly-level module attribute's namespace is not imported | ## DM0001 {#dm0001} @@ -214,3 +215,36 @@ Implement the missing interface on the interceptor, or apply it to a service wit Reported once per interceptor and member shape, so a wide interface produces one line rather than one per member. See [Interception](/guide/interception). + +## DM0016 {#dm0016} + +**An assembly-level module attribute's namespace is not imported.** + +A module generates its attribute in the module's own namespace, and an assembly-level attribute has +no namespace context to inherit — a `using` written inside a namespace declaration cannot apply to +it, because assembly attributes precede every namespace in the file. + +```csharp +// Bootstrap.cs +using DependencyModules.NSubstitute; + +[assembly: ApplicationModule] // DM0016 — nothing brings MyApp.Composition into scope +[assembly: NSubstituteSupport] +``` + +Left alone this is `CS0246: The type or namespace name 'ApplicationModuleAttribute' could not be +found` — a type you never wrote, generated into a namespace the error does not name. Every part of +that message points away from the fix, which is one line: + +```csharp +using MyApp.Composition; // or write it as [assembly: MyApp.Composition.ApplicationModule] +``` + +Unlike the other diagnostics here this one is read from syntax rather than from the compiler's view +of your code, and it has to be: the attribute is written by the generator that is running, so it does +not exist in the compilation being examined and nothing about it can be resolved. The check is +therefore "is there a module by this name, and could this file see it" — which is why it stays quiet +for an attribute matching no module in the compilation, a module in the global namespace, a usage +already written qualified, and a namespace supplied by a `global using` in any file. + +See [Testing](/guide/testing#stop-repeating-the-module-list) and [Modules](/guide/modules).