diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8ecd5e7..ca1636a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -84,9 +84,9 @@ jobs: src/DependencyModules.Runtime/DependencyModules.Runtime.csproj \ src/DependencyModules.SourceGenerator/DependencyModules.SourceGenerator.csproj \ src/DependencyModules.SourceGenerator.Impl/DependencyModules.SourceGenerator.Impl.csproj \ - src/DependencyModules.Conventions/DependencyModules.Conventions.csproj \ src/DependencyModules.Testing/DependencyModules.Testing.csproj \ src/DependencyModules.xUnit/DependencyModules.xUnit.csproj \ + src/DependencyModules.NUnit/DependencyModules.NUnit.csproj \ src/DependencyModules.NSubstitute/DependencyModules.NSubstitute.csproj \ src/DependencyModules.Moq/DependencyModules.Moq.csproj \ src/DependencyModules.FakeItEasy/DependencyModules.FakeItEasy.csproj; do diff --git a/CHANGELOG.md b/CHANGELOG.md index 555d5fa..060bb1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,80 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`DependencyModules.NUnit`.** An NUnit integration, with the same `[ModuleTest]` a test author + already knows: name your modules, take the services you need as method parameters. Everything + neutral is genuinely shared rather than reimplemented — `[Mock]`, `[InjectValues]`, + `[TestExport]`, keyed services, the parameter resolution rules, and all three mocking packages + work against it unchanged. + + **A container per test iteration**, not per test case. Each `[Repeat]` pass and each `[Retry]` + attempt builds and tears down its own container, and that container's lifetime brackets the whole + iteration — `[SetUp]`, the test method, then `[TearDown]` — so setup and teardown run while it is + alive. Wrapping only the method invocation would have left `[SetUp]` running before the container + existed and `[TearDown]` after it was disposed. + + **Data rows use `[ModuleTestCase]`, not NUnit's `[TestCase]`.** `[TestCase]` requires a row to + supply an argument for every parameter and enforces that when the case is built, before any of + this package's code runs, so it cannot express "the row covers the leading parameters and the + container covers the rest". It also builds its own cases, so combining the two would produce a + case per row plus one more. `[ModuleTestCase]` is the same idea without that rule; a row may + supply fewer arguments than the method takes, and `IModuleTestDataAttribute` lets a row come from + somewhere other than an attribute literal. + + Reference one integration or the other. Both define a `ModuleTestAttribute`, sharing a name and + nothing else — each derives from what its own framework requires, and only `IModuleTestAttribute` + is common to the two. + +- **Guide pages for each test framework and for mocking.** The testing section now separates what is + shared from what is not: `Testing modules` carries the framework-neutral core — `[ModuleTest]`, + assembly-level module attributes, container-per-test, the order a parameter is resolved in, + `[TestExport]` and `[InjectValues]` — with `xUnit` and `NUnit` pages covering only what differs, + and a `Mocking frameworks` page covering `[Mock]` and NSubstitute, Moq and FakeItEasy in turn. + + The `DependencyModules.Conventions` install step is gone from the docs along with the package; + conventions need nothing beyond `DependencyModules.Runtime` and `DependencyModules.SourceGenerator`. + The conventions guide also no longer claims an implicit `public void Conventions(…)` fails to + compile — both that and the explicit form are matched now that the contracts are public types. + ### Changed +- **A generator declaring its own module attribute gets the module written for it.** + `BaseSourceGenerator.SetupRootGenerator` was `virtual` and empty, so a framework naming its own + attribute through `ModuleAttributeTypes()` and not overriding it compiled cleanly, emitted no + module partial, and failed at its consumer's `AddModule()` — a generic constraint error naming + neither the generator nor the omission. Nothing else can write those modules, so it now writes + them by default. + + The default is still to write nothing for a generator triggering on `[DependencyModule]`, which is + adding registrations to modules this package's own generator already writes — the shape the + extension guide documents. Declaring your own attribute is what tells the two apart. A framework + wanting its attribute as a marker only, with no module written for it, overrides the method with + an empty body. + +- **`IServiceRegistrationAttribute` documents what it is.** It is the shape of a registration — + `As`, `Key`, `Lifetime`, `Using` — for reading one uniformly at run time, and it is not how the + generator finds registration attributes. Nothing tested for it, so an attribute implementing it + was silently never read, and the interface being public and otherwise unused invited exactly that. + Registration attributes are matched by type, which is what keeps them on + `ForAttributeWithMetadataName` and out of a syntax provider that re-runs over every node in the + compilation per keystroke. The doc comment now says so, and points at the seam that does work. + +- **`[TestExport]` moved to `DependencyModules.Testing`.** It registered through + `ITestServiceSetupAttribute` and had no test framework dependency left, so both integrations now + get the same attribute rather than a copy. It joins `[Mock]` and `[InjectValues]`, which were + already there. + + **Breaking, pre-1.0:** a test file needs `using DependencyModules.Testing.Attributes;` alongside + the one for `[ModuleTest]` — already what a file using `[Mock]` does. + +- **Module declaration is no longer welded to one test framework.** `ModuleTestAttribute` now + implements `IModuleTestAttribute`, a two-line interface in `DependencyModules.Testing` carrying + the module types, and module loading reads that rather than naming the xUnit attribute. Additive + for anyone using `[ModuleTest]`. + + - **xUnit v3 updated from 1.0.0 to 3.2.2.** `[ModuleTest]` builds on xUnit's extensibility surface — a custom test case and discoverer — and that surface moved across the two major versions. Module tests now pick up the conditional-skip family the way `[Fact]` and `[Theory]` do: `SkipExceptions` @@ -26,6 +98,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **The narrowest `IServiceProviderBuilderAttribute` now wins.** Both integrations took the *first* + match out of an attribute list ordered widest scope first, so an assembly-level container builder + silently beat one on the class or the method — the reverse of the interface's own documentation, + and of how every other test attribute resolves. A method asking for a particular container was + overridden by a project-wide default with nothing to indicate it. The last match is now taken, so + method beats class beats assembly, and a test pins the precedence in both frameworks. + + Only one builder is ever used; that part is unchanged. A project declaring exactly one, at any + single scope, sees no difference. + +- **Stacked generators no longer emit the application module twice.** `Program.cs` carries no module + attribute, so nothing in the syntax said which generator it belonged to and every subclass of + `BaseSourceGenerator` claimed it. A framework generator loaded alongside this package's own then + produced a second `ApplicationModule` partial declaring the same members, which does not compile — + in a console application, the ordinary shape of a consumer. The top level statement module now + belongs to the generator that owns `[DependencyModule]`; a framework shipping without that + generator takes it back by overriding `ShouldAutoApproveCompilationUnit`. + - **A module test now reports where it is declared.** `[ModuleTest]` captured no source location and the discoverer forwarded none, so a test explorer had nowhere to navigate to and results carried no file or line. Both halves are fixed, and a test asserts the location survives the whole way @@ -96,6 +186,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Implementations need a namespace change and the new parameter type; the bodies rarely change, since nothing in this repository read more than `.Method` off the xUnit model. An attribute that does need the full model can downcast the context to `IXunitTestMethodContext`. +- **Test parameter resolution moved to `DependencyModules.Testing`**, as `TestParameterResolver`. + Turning a parameter list into arguments is the same problem for any test framework — an attribute on + the parameter, a keyed service, an ordinary resolution, or constructing an unregistered concrete + type — and it was buried in xUnit's test case. Behaviour is unchanged, including the order those are + tried in and the rule that a data row's own arguments cover the leading parameters. + + It is used in two phases either side of the container being built, and resolving without the setup + phase now throws rather than silently skipping every parameter attribute. Having it addressable on + its own also means the precedence rules have direct tests instead of being reachable only by running + a `[ModuleTest]` end to end. +- **`[Mock]` moved from `DependencyModules.xUnit` to `DependencyModules.Testing`**, joining + `[InjectValues]` in `DependencyModules.Testing.Attributes`. Once the hooks it implements stopped + naming xUnit, nothing about the attribute was specific to a test framework — so a future + integration gets the same `[Mock]` rather than a copy of it. Test files need + `using DependencyModules.Testing.Attributes;` next to the one for `[ModuleTest]`, which is already + what a file using `[InjectValues]` does. - **An environment caches what it reads from the process**, misses included, for the life of the instance. `IModuleEnvironment` is injectable and the instance `AddModules` registers is held for the application's lifetime, so a service reading a value per request was paying a process lookup diff --git a/DependencyModules.sln b/DependencyModules.sln index 926d7d6..19d4acb 100644 --- a/DependencyModules.sln +++ b/DependencyModules.sln @@ -1,6 +1,5 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{FB161494-E422-4D4E-BA04-3473C2EB13B0}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "integ-tests", "integ-tests", "{DC7C95FE-F58F-4F16-8DFA-87E1B67DCEAE}" @@ -31,8 +30,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F4DC9AA2 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DependencyModules.Tests", "tests\DependencyModules.Tests\DependencyModules.Tests.csproj", "{1AFFBCCF-FD6E-4232-9DC7-40FE2784ECD1}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DependencyModules.Conventions", "src\DependencyModules.Conventions\DependencyModules.Conventions.csproj", "{B39ACDFB-85D2-4764-B06E-74744E683268}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", "{2E4BAA8C-195F-490B-B9F9-57194B08CD12}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DependencyModules.Benchmarks", "benchmarks\DependencyModules.Benchmarks\DependencyModules.Benchmarks.csproj", "{F72AFF5C-C9FB-406E-B65A-192933E697D2}" @@ -45,80 +42,239 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DependencyModules.Moq", "sr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DependencyModules.FakeItEasy", "src\DependencyModules.FakeItEasy\DependencyModules.FakeItEasy.csproj", "{DB6B8603-CBA7-443D-AEA9-E37BF61AD1B7}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DependencyModules.NUnit", "src\DependencyModules.NUnit\DependencyModules.NUnit.csproj", "{F911E2B7-4D5A-4DC2-9E3E-4B2CFC30CF4E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SutProject.NUnitTests", "integ-tests\SutProject.NUnitTests\SutProject.NUnitTests.csproj", "{C3B5B8CE-D219-42C8-A1B8-5C283C053E98}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {1E7E9023-435E-499B-9A6F-6C3E20A9A4F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1E7E9023-435E-499B-9A6F-6C3E20A9A4F1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1E7E9023-435E-499B-9A6F-6C3E20A9A4F1}.Debug|x64.ActiveCfg = Debug|Any CPU + {1E7E9023-435E-499B-9A6F-6C3E20A9A4F1}.Debug|x64.Build.0 = Debug|Any CPU + {1E7E9023-435E-499B-9A6F-6C3E20A9A4F1}.Debug|x86.ActiveCfg = Debug|Any CPU + {1E7E9023-435E-499B-9A6F-6C3E20A9A4F1}.Debug|x86.Build.0 = Debug|Any CPU {1E7E9023-435E-499B-9A6F-6C3E20A9A4F1}.Release|Any CPU.ActiveCfg = Release|Any CPU {1E7E9023-435E-499B-9A6F-6C3E20A9A4F1}.Release|Any CPU.Build.0 = Release|Any CPU + {1E7E9023-435E-499B-9A6F-6C3E20A9A4F1}.Release|x64.ActiveCfg = Release|Any CPU + {1E7E9023-435E-499B-9A6F-6C3E20A9A4F1}.Release|x64.Build.0 = Release|Any CPU + {1E7E9023-435E-499B-9A6F-6C3E20A9A4F1}.Release|x86.ActiveCfg = Release|Any CPU + {1E7E9023-435E-499B-9A6F-6C3E20A9A4F1}.Release|x86.Build.0 = Release|Any CPU {F2B3E3A2-F44D-401B-9E7A-18C23434B921}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F2B3E3A2-F44D-401B-9E7A-18C23434B921}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F2B3E3A2-F44D-401B-9E7A-18C23434B921}.Debug|x64.ActiveCfg = Debug|Any CPU + {F2B3E3A2-F44D-401B-9E7A-18C23434B921}.Debug|x64.Build.0 = Debug|Any CPU + {F2B3E3A2-F44D-401B-9E7A-18C23434B921}.Debug|x86.ActiveCfg = Debug|Any CPU + {F2B3E3A2-F44D-401B-9E7A-18C23434B921}.Debug|x86.Build.0 = Debug|Any CPU {F2B3E3A2-F44D-401B-9E7A-18C23434B921}.Release|Any CPU.ActiveCfg = Release|Any CPU {F2B3E3A2-F44D-401B-9E7A-18C23434B921}.Release|Any CPU.Build.0 = Release|Any CPU + {F2B3E3A2-F44D-401B-9E7A-18C23434B921}.Release|x64.ActiveCfg = Release|Any CPU + {F2B3E3A2-F44D-401B-9E7A-18C23434B921}.Release|x64.Build.0 = Release|Any CPU + {F2B3E3A2-F44D-401B-9E7A-18C23434B921}.Release|x86.ActiveCfg = Release|Any CPU + {F2B3E3A2-F44D-401B-9E7A-18C23434B921}.Release|x86.Build.0 = Release|Any CPU {5566F1D8-0311-489A-8513-942D247B19E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5566F1D8-0311-489A-8513-942D247B19E7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5566F1D8-0311-489A-8513-942D247B19E7}.Debug|x64.ActiveCfg = Debug|Any CPU + {5566F1D8-0311-489A-8513-942D247B19E7}.Debug|x64.Build.0 = Debug|Any CPU + {5566F1D8-0311-489A-8513-942D247B19E7}.Debug|x86.ActiveCfg = Debug|Any CPU + {5566F1D8-0311-489A-8513-942D247B19E7}.Debug|x86.Build.0 = Debug|Any CPU {5566F1D8-0311-489A-8513-942D247B19E7}.Release|Any CPU.ActiveCfg = Release|Any CPU {5566F1D8-0311-489A-8513-942D247B19E7}.Release|Any CPU.Build.0 = Release|Any CPU + {5566F1D8-0311-489A-8513-942D247B19E7}.Release|x64.ActiveCfg = Release|Any CPU + {5566F1D8-0311-489A-8513-942D247B19E7}.Release|x64.Build.0 = Release|Any CPU + {5566F1D8-0311-489A-8513-942D247B19E7}.Release|x86.ActiveCfg = Release|Any CPU + {5566F1D8-0311-489A-8513-942D247B19E7}.Release|x86.Build.0 = Release|Any CPU {BDD4DF38-8241-4BC4-9272-D6E527868790}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {BDD4DF38-8241-4BC4-9272-D6E527868790}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BDD4DF38-8241-4BC4-9272-D6E527868790}.Debug|x64.ActiveCfg = Debug|Any CPU + {BDD4DF38-8241-4BC4-9272-D6E527868790}.Debug|x64.Build.0 = Debug|Any CPU + {BDD4DF38-8241-4BC4-9272-D6E527868790}.Debug|x86.ActiveCfg = Debug|Any CPU + {BDD4DF38-8241-4BC4-9272-D6E527868790}.Debug|x86.Build.0 = Debug|Any CPU {BDD4DF38-8241-4BC4-9272-D6E527868790}.Release|Any CPU.ActiveCfg = Release|Any CPU {BDD4DF38-8241-4BC4-9272-D6E527868790}.Release|Any CPU.Build.0 = Release|Any CPU + {BDD4DF38-8241-4BC4-9272-D6E527868790}.Release|x64.ActiveCfg = Release|Any CPU + {BDD4DF38-8241-4BC4-9272-D6E527868790}.Release|x64.Build.0 = Release|Any CPU + {BDD4DF38-8241-4BC4-9272-D6E527868790}.Release|x86.ActiveCfg = Release|Any CPU + {BDD4DF38-8241-4BC4-9272-D6E527868790}.Release|x86.Build.0 = Release|Any CPU {01878BB5-D308-4EF3-8144-C35C58188E1A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {01878BB5-D308-4EF3-8144-C35C58188E1A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {01878BB5-D308-4EF3-8144-C35C58188E1A}.Debug|x64.ActiveCfg = Debug|Any CPU + {01878BB5-D308-4EF3-8144-C35C58188E1A}.Debug|x64.Build.0 = Debug|Any CPU + {01878BB5-D308-4EF3-8144-C35C58188E1A}.Debug|x86.ActiveCfg = Debug|Any CPU + {01878BB5-D308-4EF3-8144-C35C58188E1A}.Debug|x86.Build.0 = Debug|Any CPU {01878BB5-D308-4EF3-8144-C35C58188E1A}.Release|Any CPU.ActiveCfg = Release|Any CPU {01878BB5-D308-4EF3-8144-C35C58188E1A}.Release|Any CPU.Build.0 = Release|Any CPU + {01878BB5-D308-4EF3-8144-C35C58188E1A}.Release|x64.ActiveCfg = Release|Any CPU + {01878BB5-D308-4EF3-8144-C35C58188E1A}.Release|x64.Build.0 = Release|Any CPU + {01878BB5-D308-4EF3-8144-C35C58188E1A}.Release|x86.ActiveCfg = Release|Any CPU + {01878BB5-D308-4EF3-8144-C35C58188E1A}.Release|x86.Build.0 = Release|Any CPU {93187389-37C0-4DA0-8D25-E672ED858052}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {93187389-37C0-4DA0-8D25-E672ED858052}.Debug|Any CPU.Build.0 = Debug|Any CPU + {93187389-37C0-4DA0-8D25-E672ED858052}.Debug|x64.ActiveCfg = Debug|Any CPU + {93187389-37C0-4DA0-8D25-E672ED858052}.Debug|x64.Build.0 = Debug|Any CPU + {93187389-37C0-4DA0-8D25-E672ED858052}.Debug|x86.ActiveCfg = Debug|Any CPU + {93187389-37C0-4DA0-8D25-E672ED858052}.Debug|x86.Build.0 = Debug|Any CPU {93187389-37C0-4DA0-8D25-E672ED858052}.Release|Any CPU.ActiveCfg = Release|Any CPU {93187389-37C0-4DA0-8D25-E672ED858052}.Release|Any CPU.Build.0 = Release|Any CPU + {93187389-37C0-4DA0-8D25-E672ED858052}.Release|x64.ActiveCfg = Release|Any CPU + {93187389-37C0-4DA0-8D25-E672ED858052}.Release|x64.Build.0 = Release|Any CPU + {93187389-37C0-4DA0-8D25-E672ED858052}.Release|x86.ActiveCfg = Release|Any CPU + {93187389-37C0-4DA0-8D25-E672ED858052}.Release|x86.Build.0 = Release|Any CPU {0960CFB0-87F7-418F-9673-FAE5A6C9BB74}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0960CFB0-87F7-418F-9673-FAE5A6C9BB74}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0960CFB0-87F7-418F-9673-FAE5A6C9BB74}.Debug|x64.ActiveCfg = Debug|Any CPU + {0960CFB0-87F7-418F-9673-FAE5A6C9BB74}.Debug|x64.Build.0 = Debug|Any CPU + {0960CFB0-87F7-418F-9673-FAE5A6C9BB74}.Debug|x86.ActiveCfg = Debug|Any CPU + {0960CFB0-87F7-418F-9673-FAE5A6C9BB74}.Debug|x86.Build.0 = Debug|Any CPU {0960CFB0-87F7-418F-9673-FAE5A6C9BB74}.Release|Any CPU.ActiveCfg = Release|Any CPU {0960CFB0-87F7-418F-9673-FAE5A6C9BB74}.Release|Any CPU.Build.0 = Release|Any CPU + {0960CFB0-87F7-418F-9673-FAE5A6C9BB74}.Release|x64.ActiveCfg = Release|Any CPU + {0960CFB0-87F7-418F-9673-FAE5A6C9BB74}.Release|x64.Build.0 = Release|Any CPU + {0960CFB0-87F7-418F-9673-FAE5A6C9BB74}.Release|x86.ActiveCfg = Release|Any CPU + {0960CFB0-87F7-418F-9673-FAE5A6C9BB74}.Release|x86.Build.0 = Release|Any CPU {9A4D50DE-3995-494F-85CF-68F803051644}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9A4D50DE-3995-494F-85CF-68F803051644}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9A4D50DE-3995-494F-85CF-68F803051644}.Debug|x64.ActiveCfg = Debug|Any CPU + {9A4D50DE-3995-494F-85CF-68F803051644}.Debug|x64.Build.0 = Debug|Any CPU + {9A4D50DE-3995-494F-85CF-68F803051644}.Debug|x86.ActiveCfg = Debug|Any CPU + {9A4D50DE-3995-494F-85CF-68F803051644}.Debug|x86.Build.0 = Debug|Any CPU {9A4D50DE-3995-494F-85CF-68F803051644}.Release|Any CPU.ActiveCfg = Release|Any CPU {9A4D50DE-3995-494F-85CF-68F803051644}.Release|Any CPU.Build.0 = Release|Any CPU + {9A4D50DE-3995-494F-85CF-68F803051644}.Release|x64.ActiveCfg = Release|Any CPU + {9A4D50DE-3995-494F-85CF-68F803051644}.Release|x64.Build.0 = Release|Any CPU + {9A4D50DE-3995-494F-85CF-68F803051644}.Release|x86.ActiveCfg = Release|Any CPU + {9A4D50DE-3995-494F-85CF-68F803051644}.Release|x86.Build.0 = Release|Any CPU {3946D986-65E0-4103-AFD6-1C1D9549163F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3946D986-65E0-4103-AFD6-1C1D9549163F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3946D986-65E0-4103-AFD6-1C1D9549163F}.Debug|x64.ActiveCfg = Debug|Any CPU + {3946D986-65E0-4103-AFD6-1C1D9549163F}.Debug|x64.Build.0 = Debug|Any CPU + {3946D986-65E0-4103-AFD6-1C1D9549163F}.Debug|x86.ActiveCfg = Debug|Any CPU + {3946D986-65E0-4103-AFD6-1C1D9549163F}.Debug|x86.Build.0 = Debug|Any CPU {3946D986-65E0-4103-AFD6-1C1D9549163F}.Release|Any CPU.ActiveCfg = Release|Any CPU {3946D986-65E0-4103-AFD6-1C1D9549163F}.Release|Any CPU.Build.0 = Release|Any CPU + {3946D986-65E0-4103-AFD6-1C1D9549163F}.Release|x64.ActiveCfg = Release|Any CPU + {3946D986-65E0-4103-AFD6-1C1D9549163F}.Release|x64.Build.0 = Release|Any CPU + {3946D986-65E0-4103-AFD6-1C1D9549163F}.Release|x86.ActiveCfg = Release|Any CPU + {3946D986-65E0-4103-AFD6-1C1D9549163F}.Release|x86.Build.0 = Release|Any CPU {DA2D8D38-FA99-4483-B375-449E0EF86549}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {DA2D8D38-FA99-4483-B375-449E0EF86549}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DA2D8D38-FA99-4483-B375-449E0EF86549}.Debug|x64.ActiveCfg = Debug|Any CPU + {DA2D8D38-FA99-4483-B375-449E0EF86549}.Debug|x64.Build.0 = Debug|Any CPU + {DA2D8D38-FA99-4483-B375-449E0EF86549}.Debug|x86.ActiveCfg = Debug|Any CPU + {DA2D8D38-FA99-4483-B375-449E0EF86549}.Debug|x86.Build.0 = Debug|Any CPU {DA2D8D38-FA99-4483-B375-449E0EF86549}.Release|Any CPU.ActiveCfg = Release|Any CPU {DA2D8D38-FA99-4483-B375-449E0EF86549}.Release|Any CPU.Build.0 = Release|Any CPU + {DA2D8D38-FA99-4483-B375-449E0EF86549}.Release|x64.ActiveCfg = Release|Any CPU + {DA2D8D38-FA99-4483-B375-449E0EF86549}.Release|x64.Build.0 = Release|Any CPU + {DA2D8D38-FA99-4483-B375-449E0EF86549}.Release|x86.ActiveCfg = Release|Any CPU + {DA2D8D38-FA99-4483-B375-449E0EF86549}.Release|x86.Build.0 = Release|Any CPU {1AFFBCCF-FD6E-4232-9DC7-40FE2784ECD1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1AFFBCCF-FD6E-4232-9DC7-40FE2784ECD1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1AFFBCCF-FD6E-4232-9DC7-40FE2784ECD1}.Debug|x64.ActiveCfg = Debug|Any CPU + {1AFFBCCF-FD6E-4232-9DC7-40FE2784ECD1}.Debug|x64.Build.0 = Debug|Any CPU + {1AFFBCCF-FD6E-4232-9DC7-40FE2784ECD1}.Debug|x86.ActiveCfg = Debug|Any CPU + {1AFFBCCF-FD6E-4232-9DC7-40FE2784ECD1}.Debug|x86.Build.0 = Debug|Any CPU {1AFFBCCF-FD6E-4232-9DC7-40FE2784ECD1}.Release|Any CPU.ActiveCfg = Release|Any CPU {1AFFBCCF-FD6E-4232-9DC7-40FE2784ECD1}.Release|Any CPU.Build.0 = Release|Any CPU - {B39ACDFB-85D2-4764-B06E-74744E683268}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B39ACDFB-85D2-4764-B06E-74744E683268}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B39ACDFB-85D2-4764-B06E-74744E683268}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B39ACDFB-85D2-4764-B06E-74744E683268}.Release|Any CPU.Build.0 = Release|Any CPU + {1AFFBCCF-FD6E-4232-9DC7-40FE2784ECD1}.Release|x64.ActiveCfg = Release|Any CPU + {1AFFBCCF-FD6E-4232-9DC7-40FE2784ECD1}.Release|x64.Build.0 = Release|Any CPU + {1AFFBCCF-FD6E-4232-9DC7-40FE2784ECD1}.Release|x86.ActiveCfg = Release|Any CPU + {1AFFBCCF-FD6E-4232-9DC7-40FE2784ECD1}.Release|x86.Build.0 = Release|Any CPU {F72AFF5C-C9FB-406E-B65A-192933E697D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F72AFF5C-C9FB-406E-B65A-192933E697D2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F72AFF5C-C9FB-406E-B65A-192933E697D2}.Debug|x64.ActiveCfg = Debug|Any CPU + {F72AFF5C-C9FB-406E-B65A-192933E697D2}.Debug|x64.Build.0 = Debug|Any CPU + {F72AFF5C-C9FB-406E-B65A-192933E697D2}.Debug|x86.ActiveCfg = Debug|Any CPU + {F72AFF5C-C9FB-406E-B65A-192933E697D2}.Debug|x86.Build.0 = Debug|Any CPU {F72AFF5C-C9FB-406E-B65A-192933E697D2}.Release|Any CPU.ActiveCfg = Release|Any CPU {F72AFF5C-C9FB-406E-B65A-192933E697D2}.Release|Any CPU.Build.0 = Release|Any CPU + {F72AFF5C-C9FB-406E-B65A-192933E697D2}.Release|x64.ActiveCfg = Release|Any CPU + {F72AFF5C-C9FB-406E-B65A-192933E697D2}.Release|x64.Build.0 = Release|Any CPU + {F72AFF5C-C9FB-406E-B65A-192933E697D2}.Release|x86.ActiveCfg = Release|Any CPU + {F72AFF5C-C9FB-406E-B65A-192933E697D2}.Release|x86.Build.0 = Release|Any CPU {6091F3CE-7C70-464D-AD37-E0F78BC95F2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6091F3CE-7C70-464D-AD37-E0F78BC95F2C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6091F3CE-7C70-464D-AD37-E0F78BC95F2C}.Debug|x64.ActiveCfg = Debug|Any CPU + {6091F3CE-7C70-464D-AD37-E0F78BC95F2C}.Debug|x64.Build.0 = Debug|Any CPU + {6091F3CE-7C70-464D-AD37-E0F78BC95F2C}.Debug|x86.ActiveCfg = Debug|Any CPU + {6091F3CE-7C70-464D-AD37-E0F78BC95F2C}.Debug|x86.Build.0 = Debug|Any CPU {6091F3CE-7C70-464D-AD37-E0F78BC95F2C}.Release|Any CPU.ActiveCfg = Release|Any CPU {6091F3CE-7C70-464D-AD37-E0F78BC95F2C}.Release|Any CPU.Build.0 = Release|Any CPU + {6091F3CE-7C70-464D-AD37-E0F78BC95F2C}.Release|x64.ActiveCfg = Release|Any CPU + {6091F3CE-7C70-464D-AD37-E0F78BC95F2C}.Release|x64.Build.0 = Release|Any CPU + {6091F3CE-7C70-464D-AD37-E0F78BC95F2C}.Release|x86.ActiveCfg = Release|Any CPU + {6091F3CE-7C70-464D-AD37-E0F78BC95F2C}.Release|x86.Build.0 = Release|Any CPU {5430B97D-5012-4CEB-BBCA-219C0A53D3C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5430B97D-5012-4CEB-BBCA-219C0A53D3C1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5430B97D-5012-4CEB-BBCA-219C0A53D3C1}.Debug|x64.ActiveCfg = Debug|Any CPU + {5430B97D-5012-4CEB-BBCA-219C0A53D3C1}.Debug|x64.Build.0 = Debug|Any CPU + {5430B97D-5012-4CEB-BBCA-219C0A53D3C1}.Debug|x86.ActiveCfg = Debug|Any CPU + {5430B97D-5012-4CEB-BBCA-219C0A53D3C1}.Debug|x86.Build.0 = Debug|Any CPU {5430B97D-5012-4CEB-BBCA-219C0A53D3C1}.Release|Any CPU.ActiveCfg = Release|Any CPU {5430B97D-5012-4CEB-BBCA-219C0A53D3C1}.Release|Any CPU.Build.0 = Release|Any CPU + {5430B97D-5012-4CEB-BBCA-219C0A53D3C1}.Release|x64.ActiveCfg = Release|Any CPU + {5430B97D-5012-4CEB-BBCA-219C0A53D3C1}.Release|x64.Build.0 = Release|Any CPU + {5430B97D-5012-4CEB-BBCA-219C0A53D3C1}.Release|x86.ActiveCfg = Release|Any CPU + {5430B97D-5012-4CEB-BBCA-219C0A53D3C1}.Release|x86.Build.0 = Release|Any CPU {F453C574-0EC6-44D2-BFBF-F5BB9097EC4D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F453C574-0EC6-44D2-BFBF-F5BB9097EC4D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F453C574-0EC6-44D2-BFBF-F5BB9097EC4D}.Debug|x64.ActiveCfg = Debug|Any CPU + {F453C574-0EC6-44D2-BFBF-F5BB9097EC4D}.Debug|x64.Build.0 = Debug|Any CPU + {F453C574-0EC6-44D2-BFBF-F5BB9097EC4D}.Debug|x86.ActiveCfg = Debug|Any CPU + {F453C574-0EC6-44D2-BFBF-F5BB9097EC4D}.Debug|x86.Build.0 = Debug|Any CPU {F453C574-0EC6-44D2-BFBF-F5BB9097EC4D}.Release|Any CPU.ActiveCfg = Release|Any CPU {F453C574-0EC6-44D2-BFBF-F5BB9097EC4D}.Release|Any CPU.Build.0 = Release|Any CPU + {F453C574-0EC6-44D2-BFBF-F5BB9097EC4D}.Release|x64.ActiveCfg = Release|Any CPU + {F453C574-0EC6-44D2-BFBF-F5BB9097EC4D}.Release|x64.Build.0 = Release|Any CPU + {F453C574-0EC6-44D2-BFBF-F5BB9097EC4D}.Release|x86.ActiveCfg = Release|Any CPU + {F453C574-0EC6-44D2-BFBF-F5BB9097EC4D}.Release|x86.Build.0 = Release|Any CPU {DB6B8603-CBA7-443D-AEA9-E37BF61AD1B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {DB6B8603-CBA7-443D-AEA9-E37BF61AD1B7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DB6B8603-CBA7-443D-AEA9-E37BF61AD1B7}.Debug|x64.ActiveCfg = Debug|Any CPU + {DB6B8603-CBA7-443D-AEA9-E37BF61AD1B7}.Debug|x64.Build.0 = Debug|Any CPU + {DB6B8603-CBA7-443D-AEA9-E37BF61AD1B7}.Debug|x86.ActiveCfg = Debug|Any CPU + {DB6B8603-CBA7-443D-AEA9-E37BF61AD1B7}.Debug|x86.Build.0 = Debug|Any CPU {DB6B8603-CBA7-443D-AEA9-E37BF61AD1B7}.Release|Any CPU.ActiveCfg = Release|Any CPU {DB6B8603-CBA7-443D-AEA9-E37BF61AD1B7}.Release|Any CPU.Build.0 = Release|Any CPU + {DB6B8603-CBA7-443D-AEA9-E37BF61AD1B7}.Release|x64.ActiveCfg = Release|Any CPU + {DB6B8603-CBA7-443D-AEA9-E37BF61AD1B7}.Release|x64.Build.0 = Release|Any CPU + {DB6B8603-CBA7-443D-AEA9-E37BF61AD1B7}.Release|x86.ActiveCfg = Release|Any CPU + {DB6B8603-CBA7-443D-AEA9-E37BF61AD1B7}.Release|x86.Build.0 = Release|Any CPU + {F911E2B7-4D5A-4DC2-9E3E-4B2CFC30CF4E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F911E2B7-4D5A-4DC2-9E3E-4B2CFC30CF4E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F911E2B7-4D5A-4DC2-9E3E-4B2CFC30CF4E}.Debug|x64.ActiveCfg = Debug|Any CPU + {F911E2B7-4D5A-4DC2-9E3E-4B2CFC30CF4E}.Debug|x64.Build.0 = Debug|Any CPU + {F911E2B7-4D5A-4DC2-9E3E-4B2CFC30CF4E}.Debug|x86.ActiveCfg = Debug|Any CPU + {F911E2B7-4D5A-4DC2-9E3E-4B2CFC30CF4E}.Debug|x86.Build.0 = Debug|Any CPU + {F911E2B7-4D5A-4DC2-9E3E-4B2CFC30CF4E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F911E2B7-4D5A-4DC2-9E3E-4B2CFC30CF4E}.Release|Any CPU.Build.0 = Release|Any CPU + {F911E2B7-4D5A-4DC2-9E3E-4B2CFC30CF4E}.Release|x64.ActiveCfg = Release|Any CPU + {F911E2B7-4D5A-4DC2-9E3E-4B2CFC30CF4E}.Release|x64.Build.0 = Release|Any CPU + {F911E2B7-4D5A-4DC2-9E3E-4B2CFC30CF4E}.Release|x86.ActiveCfg = Release|Any CPU + {F911E2B7-4D5A-4DC2-9E3E-4B2CFC30CF4E}.Release|x86.Build.0 = Release|Any CPU + {C3B5B8CE-D219-42C8-A1B8-5C283C053E98}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3B5B8CE-D219-42C8-A1B8-5C283C053E98}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3B5B8CE-D219-42C8-A1B8-5C283C053E98}.Debug|x64.ActiveCfg = Debug|Any CPU + {C3B5B8CE-D219-42C8-A1B8-5C283C053E98}.Debug|x64.Build.0 = Debug|Any CPU + {C3B5B8CE-D219-42C8-A1B8-5C283C053E98}.Debug|x86.ActiveCfg = Debug|Any CPU + {C3B5B8CE-D219-42C8-A1B8-5C283C053E98}.Debug|x86.Build.0 = Debug|Any CPU + {C3B5B8CE-D219-42C8-A1B8-5C283C053E98}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3B5B8CE-D219-42C8-A1B8-5C283C053E98}.Release|Any CPU.Build.0 = Release|Any CPU + {C3B5B8CE-D219-42C8-A1B8-5C283C053E98}.Release|x64.ActiveCfg = Release|Any CPU + {C3B5B8CE-D219-42C8-A1B8-5C283C053E98}.Release|x64.Build.0 = Release|Any CPU + {C3B5B8CE-D219-42C8-A1B8-5C283C053E98}.Release|x86.ActiveCfg = Release|Any CPU + {C3B5B8CE-D219-42C8-A1B8-5C283C053E98}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {1E7E9023-435E-499B-9A6F-6C3E20A9A4F1} = {FB161494-E422-4D4E-BA04-3473C2EB13B0} @@ -133,11 +289,12 @@ Global {3946D986-65E0-4103-AFD6-1C1D9549163F} = {D6D764B2-B906-4386-BC37-7EE29D7821DF} {DA2D8D38-FA99-4483-B375-449E0EF86549} = {D6D764B2-B906-4386-BC37-7EE29D7821DF} {1AFFBCCF-FD6E-4232-9DC7-40FE2784ECD1} = {F4DC9AA2-7F61-4DEE-A7D1-2BCDBEEFD1C5} - {B39ACDFB-85D2-4764-B06E-74744E683268} = {FB161494-E422-4D4E-BA04-3473C2EB13B0} {F72AFF5C-C9FB-406E-B65A-192933E697D2} = {2E4BAA8C-195F-490B-B9F9-57194B08CD12} {6091F3CE-7C70-464D-AD37-E0F78BC95F2C} = {FB161494-E422-4D4E-BA04-3473C2EB13B0} {5430B97D-5012-4CEB-BBCA-219C0A53D3C1} = {FB161494-E422-4D4E-BA04-3473C2EB13B0} {F453C574-0EC6-44D2-BFBF-F5BB9097EC4D} = {FB161494-E422-4D4E-BA04-3473C2EB13B0} {DB6B8603-CBA7-443D-AEA9-E37BF61AD1B7} = {FB161494-E422-4D4E-BA04-3473C2EB13B0} + {F911E2B7-4D5A-4DC2-9E3E-4B2CFC30CF4E} = {FB161494-E422-4D4E-BA04-3473C2EB13B0} + {C3B5B8CE-D219-42C8-A1B8-5C283C053E98} = {DC7C95FE-F58F-4F16-8DFA-87E1B67DCEAE} EndGlobalSection EndGlobal diff --git a/benchmarks/DependencyModules.Benchmarks/DependencyModules.Benchmarks.csproj b/benchmarks/DependencyModules.Benchmarks/DependencyModules.Benchmarks.csproj index f32a6ff..fa8ece1 100644 --- a/benchmarks/DependencyModules.Benchmarks/DependencyModules.Benchmarks.csproj +++ b/benchmarks/DependencyModules.Benchmarks/DependencyModules.Benchmarks.csproj @@ -24,7 +24,7 @@ - + diff --git a/benchmarks/DependencyModules.Benchmarks/Program.cs b/benchmarks/DependencyModules.Benchmarks/Program.cs index 26d2cf6..9c2b6bb 100644 --- a/benchmarks/DependencyModules.Benchmarks/Program.cs +++ b/benchmarks/DependencyModules.Benchmarks/Program.cs @@ -1,6 +1,9 @@ using System.Diagnostics; using System.Text; -using DependencyModules.Conventions; +using CSharpAuthor; +using DependencyModules.SourceGenerator.Impl; +using DependencyModules.SourceGenerator.Impl.Models; +using DependencyModules.SourceGenerator.Impl.Utilities; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.Extensions.DependencyInjection; @@ -65,6 +68,10 @@ public static void Main() { Console.WriteLine($"{total,7} {implementing,12} {cold,8:F1} {incremental,18:F1}"); } } + + Console.WriteLine(); + Console.WriteLine("Frameworks stacked on the extension seam, 2000 classes:"); + FrameworkStack(2000); } private static double Median(Func measure) { @@ -79,12 +86,104 @@ private static double Median(Func measure) { return timings[timings.Count / 2]; } + /// + /// What a framework building on this library adds: its own module attribute as the entry point, + /// and its own attributes collected through the shared indexed provider. + /// + /// + /// Third parties compile in the Impl source package and declare a generator of this shape, so + /// what a consuming project loads is this one plus one per framework. Measured rather than + /// assumed, because it is the cost every such framework imposes on every build. + /// + private class ExtensionShapedGenerator(string attributeName) : BaseSourceGenerator { + + protected override ITypeDefinition[] ModuleAttributeTypes() => + new[] { TypeDefinition.Get("Bench.Framework", attributeName) }; + + protected override IEnumerable AttributeSourceGenerators() { + yield return new FrameworkAttributeGenerator(); + } + } + + private class FrameworkAttributeGenerator : IDependencyModuleSourceGenerator { + + private static readonly ITypeDefinition[] _attributes = { + TypeDefinition.Get("Bench.Framework", "EndpointAttribute"), + TypeDefinition.Get("Bench.Framework", "HandlerAttribute"), + TypeDefinition.Get("Bench.Framework", "JobAttribute"), + }; + + public void SetupGenerator( + IncrementalGeneratorInitializationContext context, + IncrementalValuesProvider<(ModuleEntryPointModel Left, DependencyModuleConfigurationModel Right)> incrementalValueProvider) { + + var models = AttributeModelCollector.Collect( + context, + _attributes, + static (syntaxContext, cancellation) => + ServiceModelUtility.GetServiceModel(syntaxContext, cancellation) ?? ServiceModel.Ignore, + new ServiceModelComparer(), + ServiceModel.Ignore); + + context.RegisterSourceOutput( + incrementalValueProvider.Collect().Combine(models), + static (productionContext, data) => { }); + } + } + + private static void FrameworkStack(int total) { + Console.WriteLine(); + Console.WriteLine("analyzers loaded cold ms after one edit ms"); + Console.WriteLine("--------------------------------------------------------------------"); + + for (var frameworks = 0; frameworks <= 3; frameworks++) { + var count = frameworks; + + var cold = Median(() => Run(BuildSources(total, total / 4), count, cold: true)); + var incremental = Median(() => Run(BuildSources(total, total / 4), count, cold: false)); + + var label = count == 0 + ? "DependencyModules only" + : $"DependencyModules + {count} framework generator(s)"; + + Console.WriteLine($"{label,-40} {cold,8:F1} {incremental,18:F1}"); + } + } + + private static double Run(string[] sources, int frameworks, bool cold) { + var compilation = Compile(sources); + + var generators = new List { + new SourceGenerator.SourceGenerator().AsSourceGenerator() + }; + + for (var i = 0; i < frameworks; i++) { + generators.Add(new ExtensionShapedGenerator($"Framework{i}Attribute").AsSourceGenerator()); + } + + GeneratorDriver driver = CSharpGeneratorDriver.Create(generators); + + if (cold) { + return Time(() => driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out _)); + } + + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out _); + + var options = new CSharpParseOptions(LanguageVersion.Latest); + + var edited = compilation.ReplaceSyntaxTree( + compilation.SyntaxTrees.ElementAt(1), + CSharpSyntaxTree.ParseText(sources[1].Replace("_seed = 0", "_seed = 42"), options)); + + return Time(() => driver.RunGeneratorsAndUpdateCompilation(edited, out _, out _)); + } + private static double ColdRun(string[] sources) { var compilation = Compile(sources); // A fresh driver each run: reusing one would measure the incremental cache instead. var driver = CSharpGeneratorDriver.Create( - new ConventionSourceGenerator().AsSourceGenerator()); + new SourceGenerator.SourceGenerator().AsSourceGenerator()); return Time(() => driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out _)); } @@ -98,7 +197,7 @@ private static double IncrementalRun(int total, int implementing) { var compilation = Compile(sources); GeneratorDriver driver = CSharpGeneratorDriver.Create( - new ConventionSourceGenerator().AsSourceGenerator()); + new SourceGenerator.SourceGenerator().AsSourceGenerator()); driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out _); @@ -145,7 +244,7 @@ private static string[] BuildSources(int total, int implementing) { var builder = new StringBuilder(); builder.AppendLine("using DependencyModules.Runtime.Attributes;"); - builder.AppendLine("using DependencyModules.Conventions;"); + builder.AppendLine("using DependencyModules.Runtime.Conventions;"); builder.AppendLine("namespace BenchNamespace;"); builder.AppendLine("[DependencyModule]"); builder.AppendLine("public partial class BenchModule : IConventionModule {"); diff --git a/docs/design/aot-decorators-and-convention-cost.md b/docs/design/aot-decorators-and-convention-cost.md new file mode 100644 index 0000000..61dcc85 --- /dev/null +++ b/docs/design/aot-decorators-and-convention-cost.md @@ -0,0 +1,1117 @@ +# Design: AOT-safe decorators, and what conventions actually cost + +Status: investigation. Nothing here is implemented. Every number and every failure below was produced +by running something, and the harnesses are described so they can be re-run rather than trusted. + +This document **reverses two claims** made elsewhere in the repository: + +- `docs/design/convention-registration-and-decorators.md` lists open generic decorators under + *Done*, and `website/guide/aot.md` lists decorators under *What this covers*. Neither holds. + **No decorator of any kind works under Native AOT today** — not generic ones, and not the + non-generic case the design doc treats as settled. +- The reason to merge `DependencyModules.Conventions` into `DependencyModules.SourceGenerator` was + taken to be a hard blocker for AOT-safe generic decorators. It is a blocker for **one of the two** + registration paths, and the cheaper of the two fixes needs no merge at all. + +- [Part 1: the AOT defect](#part-1-the-aot-defect) +- [Part 2: the two fixes, both verified](#part-2-the-two-fixes-both-verified) +- [Part 3: the constraint monomorphisation imposes](#part-3-the-constraint-monomorphisation-imposes) +- [Part 4: what conventions cost, measured](#part-4-what-conventions-cost-measured) +- [Part 5: what is worth caching, and what is not](#part-5-what-is-worth-caching-and-what-is-not) +- [Part 6: the merge question, answered](#part-6-the-merge-question-answered) +- [Part 7: third-party frameworks on top of this](#part-7-third-party-frameworks-on-top-of-this) +- [Part 8: open generic decorators across an assembly boundary](#part-8-open-generic-decorators-across-an-assembly-boundary) +- [Part 9: a runtime with no reflection](#part-9-a-runtime-with-no-reflection) +- [Part 10: sequencing](#part-10-sequencing) + +--- + +## Part 1: the AOT defect + +### How it was established + +A console application was published `PublishAot` for `osx-arm64`, net8.0, ILCompiler 8.0.29, and +**run**. It declares two handlers behind `IRequestHandler` — one with a +reference-type response, one with `int` — an open generic `[Decorator]` over them, a non-generic +`[Decorator]` over an unrelated interface, and a plain `[SingletonService]` as a control. + +Native AOT was chosen over reading warnings because IL3050 is a warning, and the standing +counter-argument to a warning is "but does it actually break." It does. + +### What the published binary prints + +``` +reference-type response (IRequestHandler): + FAILED: NotSupportedException: 'LoggingHandler`2[CreateOrder,OrderId]' is missing native code or metadata. + +value-type response (IRequestHandler): + FAILED: NotSupportedException: 'LoggingHandler`2[CountRequest,System.Int32]' is missing native code or metadata. + +control: plain attribute registration, no decorator involved: + resolved Log + +control: NON-generic decorator (IGreeter): + FAILED: InvalidOperationException: A suitable constructor for type 'ShoutingGreeter' could not be located. +``` + +Three things in that output are worth not glossing over. + +**The reference-type case fails too.** The expectation going in was that reference-type arguments +would survive on shared canonical code and only a value-type argument would break. They both break, +and for a reason that makes the value-type distinction irrelevant: ILC never compiles *any* +instantiation of `LoggingHandler<,>`, because nothing in the emitted code constructs one. The +generated call passes `typeof(LoggingHandler<,>)` — the open definition — and that is not a +statically reachable instantiation. There is no canonical body to share. + +**The non-generic decorator fails as well**, and this is the finding that was not anticipated at all. +It has nothing to do with generics. `DecoratorHelper.Decorate(IServiceCollection, Type, Type)` passes +`decoratorType` to `ActivatorUtilities.CreateInstance`, whose parameter carries +`[DynamicallyAccessedMembers(PublicConstructors)]`. The helper's own parameter carries no such +annotation, so the requirement stops there and the trimmer has no reason to keep the constructor. +`typeof(ShoutingGreeter)` roots the *type*; it does not root its constructors. + +**Plain registration is fine.** Whatever is wrong is specific to the decoration path. + +### What the toolchain says on its own + +`dotnet build src/DependencyModules.Runtime -p:IsAotCompatible=true -p:TargetFramework=net10.0` +produces five warnings, four of them in `DecoratorHelper`: + +| Location | ID | Call | +|---|---|---| +| `DecoratorHelper.cs:91` | IL3050, IL2055 | `Type.MakeGenericType` — `RequiresDynamicCode` | +| `DecoratorHelper.cs:94` | IL2067 | `ActivatorUtilities.CreateInstance`, unannotated `decoratorType` | +| `DecoratorHelper.cs:137` | IL2075 | `GetInterfaces()` on an unannotated type | +| `DependencyRegistry.cs:102` | IL2067 | `ServiceDescriptor(Type, object, Type, ServiceLifetime)`, unannotated `implementationType` | + +`DependencyModules.Runtime.csproj` sets no `IsAotCompatible`, which is why none of this has ever +appeared in a build. **Turning it on is the single change that would have caught this**, and it +should be turned on regardless of what else here gets built. + +The `DependencyRegistry.cs:102` warning is on the main registration path rather than the decorator +one. The control above resolved successfully, so it is not currently breaking anything observable — +but it is the same missing-annotation shape, on a public API, and it should be annotated rather than +left to luck. + +--- + +## Part 2: the two fixes, both verified + +Both were written by hand exactly as the generator would emit them, published Native AOT, and run. + +### Fix 1 — annotate the parameter + +```csharp +public static void Decorate( + IServiceCollection services, + Type serviceType, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type decoratorType) +``` + +Result in the published binary: + +``` +PROPOSED FIX 1: non-generic decorator via an ANNOTATED Type parameter: + resolved as AnnotatedShouter + result QUIET! +``` + +Nothing else changed — still `ActivatorUtilities`, still a `Type`. **This is a one-line change that +makes every non-generic decorator work under Native AOT**, and it is independent of everything else +in this document. It does not help the generic case: `MakeGenericType` is `RequiresDynamicCode` and +no annotation reaches that. + +### Fix 2 — monomorphise the generic case + +Emit one closed call per closed registration instead of one open-generic call: + +```csharp +// today, once per decorator +DecoratorHelper.Decorate(services, typeof(IRequestHandler<,>), typeof(LoggingHandler<,>)); + +// proposed, once per (decorator, closed registration) pair +DecoratorHelper.Decorate(services, typeof(IRequestHandler), + (p, inner) => new LoggingHandler((IRequestHandler)inner)); + +DecoratorHelper.Decorate(services, typeof(IRequestHandler), + (p, inner) => new LoggingHandler((IRequestHandler)inner)); +``` + +Result: + +``` +reference-type response (IRequestHandler): + resolved as LoggingHandler`2 + [decorator] CreateOrder -> OrderId + result OrderId { Value = abc } + +value-type response (IRequestHandler): + resolved as LoggingHandler`2 + [decorator] CountRequest -> Int32 + result 42 +``` + +Both work, including the value-type instantiation. `MakeGenericType`, `ActivatorUtilities` and the +`GetInterfaces()` walk in `ResolveTypeArguments` all disappear from the path. + +**No new runtime API is needed.** `DecoratorHelper` already exposes the +`Func` overload this uses, and `ConstructorInfoModel` — which the +generator needs in order to fill the decorator's remaining constructor parameters — is already +computed for every service model. + +One measurement artefact worth recording, because it cost a publish cycle to find: the first attempt +at fix 2 left `[Decorator]` on the class, so the shipped open-generic call still ran and wrapped the +registration *first*. The closed decoration then wrapped the broken wrapper. **A monomorphised +emission and the open-generic emission cannot both be applied to the same service.** + +--- + +## Part 3: the constraint monomorphisation imposes + +This is the part that decides the shape of the feature, and it is not in the existing design doc. + +There are two ways a closed registration of a generic service comes into being, and they live in +different places: + +| | Registration produced by | Decorator discovered by | Same analyzer assembly? | +|---|---|---|---| +| A | `[SingletonService] class CreateOrderHandler : IRequestHandler` | `ForAttributeWithMetadataName` over `[Decorator]` | **yes** — both in `DependencyModules.SourceGenerator` | +| B | a convention matching `IRequestHandler<,>` | same | **no** — registration is in `DependencyModules.Conventions` | + +**Case A needs no merge.** `ServiceSourceGenerator` and `DecoratorSourceGenerator` are both returned +from `SourceGenerator.AttributeSourceGenerators()` and both receive the same +`IncrementalValuesProvider` in one `Initialize`. Their providers can be combined into a single +emission stage without any cross-generator ordering, because there is no cross-generator anything — +it is one generator with two `RegisterSourceOutput` calls today, and one with three would be an +ordinary refactor. Case A is the MediatR-in-one-assembly shape and covers most real usage. + +**Case B needs the two halves to meet.** That is the merge the brief was about, and Part 6 prices it. + +### What monomorphisation cannot do, at all + +A generic decorator shipped in a *package*, wrapping handlers registered by the *consuming +application*, cannot be monomorphised. Package `P` is compiled before the application exists; the +closed constructions do not exist yet; there is no `new Logging(...)` for the compiler to +write. + +That scenario works today, and it works **precisely because** the emitted call is an open-generic +runtime operation: `P` emits `ApplyDecorator0`, `ApplyDecorators` runs it after every module's +services are registered, and it rewrites descriptors it never saw at compile time. The design doc +records this as the foundation for a future mediator package. + +So the two properties are mutually exclusive: + +| | cross-assembly generic decoration | Native AOT | +|---|---|---| +| open-generic runtime call (today) | works | **broken** | +| monomorphised emission, application writes `new` | impossible | works | + +**Superseded by Part 8**, which is worth reading before acting on this table. A third shape — the +package emitting a generic closer that the application calls with closed type arguments — is both, +and has been published Native AOT across a real assembly boundary and run. The row above is only +true of the application constructing the decorator itself. + +There is a way to have both, and it is the one the codebase is already equipped for: **the consuming +application's generator discovers `[Decorator]` in referenced assemblies from metadata** and emits +the closed decorations itself. `MetadataCandidateUtility` already walks +`IAssemblySymbol.GlobalNamespace` for convention candidates; reading attributes off those symbols is +the same walk. That converts case B-across-assemblies into case B-in-one-compilation. + +Until that exists, the honest position is that monomorphisation covers what one compilation can see, +and anything else keeps the runtime path and is **not** AOT-safe. That is a diagnostic, not a silent +degradation. + +### The double-decoration hazard + +If both analyzer assemblies emit closed decorations for their own registrations, they are not +disjoint. `Decorate` loops over *every* descriptor matching the service type, so two calls naming +`IHandler` — one from the attribute path, one from the convention path — wrap both +implementations twice. + +The registrations themselves are disjoint (a class carrying a service attribute is never a +convention candidate, enforced by `ConventionCandidateUtility.ServiceAttributeNames`), but the +*service types* are not. Two implementations of one closed service, one attributed and one by +convention, is an ordinary thing to write. + +Either emit from one place, or make `Decorate` refuse to apply the same decorator type to a +descriptor twice. The second is a small amount of per-collection bookkeeping and is worth having +anyway, since it also makes the phase idempotent. + +--- + +## Part 4: what conventions cost, measured + +Harness: `CSharpGeneratorDriver` over 2,000 synthetic classes, one class per syntax tree, real method +bodies, `DOTNET_TieredCompilation=0`, median of 11. "incr" is the second run of one driver after +replacing a single syntax tree — one method body edited. This is the same shape as +`benchmarks/DependencyModules.Benchmarks`, extended to count provider invocations and to read +`GeneratorRunResult.TrackedSteps`. + +### The dominant cost is not the scan. It is the transform, and it re-runs every time. + +Recording every transform invocation with the tree it came from, and whether the node is the same +object as last run: + +``` +RegisterPostInitializationOutput = True + run 1 (cold) pred=242290 transformCalls=2001 distinctNodes=2001 distinctTrees=2001 + run 2 (one tree edited) pred=121 transformCalls=2001 distinctNodes=2001 distinctTrees=2001 + of those, from the edited tree: 1 + node instances also seen last run: 2000/2001 + run 3 (nothing changed) pred=0 transformCalls=2001 + +RegisterPostInitializationOutput = False + run 2 (one tree edited) pred=121 transformCalls=2001 (from the edited tree: 1) + run 3 (nothing changed) pred=0 transformCalls=0 +``` + +Read those four numbers together, because each rules something out: + +- **`pred=121`** — the predicate ran only over the edited tree. Per-tree filtering is cached and + working. The scan is not the problem. +- **`transformCalls=2001`, `from the edited tree: 1`** — the transform ran for 2,000 candidates whose + trees did not change. Roslyn keeps the *filtered node list* per tree and re-executes the transform + over all of it. +- **`node instances also seen last run: 2000/2001`** — those are the same `SyntaxNode` objects, + by reference. Nothing about them changed; the work is simply repeated. +- **run 3** — with no post-initialization output a no-change run short-circuits entirely; with one it + does not. **That is the only thing post-init affects.** An earlier revision of this document blamed + it for the general case, which the middle two rows refute: a real edit re-runs everything either + way. + +So incrementality here comes entirely from the *model* comparing equal and stopping propagation. It +does not come from the transform being skipped, and no amount of model tuning changes that. + +### Where the wall clock goes, at 2,000 classes, on one keystroke + +The same pipeline with only the transform body swapped: + +| | | +|---|---| +| transform does the real work (as shipped) | **13.9 ms** | +| transform is syntax only | 2.4 ms | +| transform returns a constant | 1.2 ms | +| **attributable to the transform body** | **12.7 ms** | +| driver overhead with nothing to do | 1.2 ms | + +**91% of a keystroke is the transform re-deriving 2,000 models that are byte-identical to the ones it +derived on the previous keystroke.** One of the 2,001 is genuinely new. + +That fixes where the effort goes, and it makes the node-keyed cache the obvious first move rather +than a micro-optimisation: the nodes are reference-identical 2,000 times out of 2,001, and a +`ConditionalWeakTable` lookup costs 0.04 ms per 2,001 against 1.15 ms to recompute the syntax half +alone. + +### The semantic half can be cached too — on the node *and* a declaration stamp + +An earlier revision of this document said the semantic half could not be cached. That was wrong, and +the correction matters because it roughly halves the remaining cost. + +What is true is narrower: it cannot be keyed on the **node alone**. That failure is real and +reachable, not theoretical — `Handler.cs` is never touched, and a `global using` moved in a different +file changes what it binds to: + +``` +edit: global using moved from N1 to N2 + edited file : /bench/File0.cs + Handler.cs tree is the same object : True + Handler's node is the same object : True + Handler's syntax text is identical : True + resolved interface before : N1.IRepo + resolved interface after : N2.IRepo + >> semantic answer changed : True +``` + +A node-keyed cache would serve `N1.IRepo` and register the wrong service with a green build. + +The fix is to key on `(node, declarationStamp)`, where the stamp hashes everything in the compilation +that can change what a name binds to — usings, extern aliases, namespace names, type identifiers, +base lists, type parameter lists, modifiers, member signatures, and the reference set — and +deliberately **excludes method bodies**, since nothing inside one can change another file's binding. +Cached per `SyntaxTree`, which is immutable and reference-stable, so only the edited tree recomputes: + +| | | +|---|---| +| stamp, cold — every tree hashed | 28.55 ms | +| stamp, nothing changed | **0.03 ms** | +| stamp, after a method body edit | **0.04 ms** | +| unchanged by a method body edit | yes | +| changed by a base-list edit | yes | + +End to end, same pipeline, median of 11, only the cache differing: + +| 2,000 classes, one method body edited | | +|---|---| +| **with the cache** | **2.0 ms** (hits 1,999, misses 1) | +| without | 4.1 ms | + +and the pieces, per 2,000 nodes: `DeclarationStamp.Of` memoised on the compilation **0.02 ms**, +fetching the semantic model 0.10 ms, `GetCandidateModel` — what a hit avoids — **2.36 ms**. + +**Two things to get right, because both fail silently.** The stamp must be conservative: anything it +omits that can affect binding produces a stale cache and a wrong registration with a green build. And +a 32-bit hash is not enough for a key whose collision means exactly that — use a wider hash, or +compare with `SyntaxNode.IsEquivalentTo(other, topLevel: true)`, which is Roslyn's own +ignore-method-bodies comparison and is the primitive this stamp is re-implementing. + +The syntactic pre-filter is still worth having — it makes a *cold* build cheap and bounds the misses +after a declaration edit, which the cache cannot help with. But it is now an optimisation on top of +the cache rather than the only route. + +### Where the transform's time goes, per 2,000 declarations + +| Step | no base list | implements one interface | +|---|---|---| +| `GetTypeDefinition()` | 0.43 ms | 0.40 ms | +| **`LocationModel.From(node)`** | **1.15 ms** | 0.60 ms | +| — of which `GetLineSpan()` | 0.58 ms | 0.27 ms | +| `GetConstructorInfo` | 0.81 ms | 0.55 ms | +| `EnvironmentConditionUtility.GetConditions` | 0.07 ms | 0.05 ms | +| `GetDeclaredSymbol` + `AllInterfaces` walk | — | **4.16 ms** | +| **full `GetCandidateModel`** | **2.06 ms** | **7.16 ms** | + +For the majority population — a class with no base list, which is most classes in most projects — +**`LocationModel.From` is over half the cost**, and it is paid on every keystroke for every class. +It exists to place DM0006 and DM0010, which fire for a handful of types. + +For comparison, 2,000 pairwise `ConventionCandidateModel.Equals` calls take **0.08 ms**. Model +equality is not where the time is. + +### Provider floors, 2,000 classes + +| Shape | cold | incr | +|---|---|---| +| predicate over every node, empty transform | 32.9 ms | **0.7 ms** | +| `ForAttributeWithMetadataName` over `[Decorator]` | 3.5 ms | **0.1 ms** | +| shipped `ConventionSourceGenerator` | 113.7 ms | 23.5 ms | +| shipped `SourceGenerator` (services + decorators) | 55.4 ms | 5.6 ms | + +Two things follow. **Visiting every syntax node is a cold-build cost, not a per-keystroke cost** — +0.7 ms once the predicate results are cached. And **FAWMN is free enough to ignore**: whatever a +merged generator needs to know about `[Decorator]`, it can have for 0.1 ms per keystroke. + +### Transform variants, 2,000 classes, incremental + +| Transform | 0 matching | 500 matching | 2000 matching | +|---|---|---|---| +| as shipped (binds symbols for every candidate) | 11.3 ms | 20.9 ms | 39.0 ms | +| syntax-only | **1.7 ms** | **1.8 ms** | **1.7 ms** | +| syntax-only, semantics deferred to a later stage | 9.6 ms | 10.8 ms | 9.6 ms | + +The syntax-only row is the floor, and it is flat — it does not care how many types match, because it +never binds anything. + +The deferred row is the design the brief proposed: syntax-only transform, then a stage combined with +`CompilationProvider` that re-binds only survivors of a syntactic pre-filter. It beats the shipped +shape in every column, by 4× at 2,000 matches — but it costs **more** on a cold build (74–115 ms +versus 53–91 ms), because the collected array has to be walked on every compilation change and a +`SyntaxReference` has to be taken per candidate that could match. It is a real improvement and not a +dramatic one; the flat 1.7 ms row is what shows how much of the remaining cost is inherent. + +### What actually invalidates emission + +Reading step reasons after editing one method body: + +| Pipeline | candidates | collected | emission | +|---|---|---|---| +| as shipped | Cached=2000 Modified=1 | Modified | **Unchanged** (ran, same text) | +| location out of model equality | Cached=2001 | Cached | **Cached** (did not run) | +| editing a file holding no candidate | Cached | Cached | Cached | + +`LocationModel` carries the declaration's **full span**, so editing anything inside a class body +changes `SpanLength` and the model no longer compares equal. Emission then re-runs +`ConventionMatcher` over every candidate and re-renders the file — and throws the result away, +because the text is identical. + +End to end this is worth about **1 ms of 16–46 ms**, so it is not the headline. It is still free to +fix, and the fix is to key the location on the **identifier token** rather than the declaration: +editing a method body does not move the class name, so the common IDE edit stops invalidating +anything. + +**The combine chain is not the invalidation source.** `metadataCandidates` re-runs on every keystroke +by construction — it combines with `CompilationProvider` — and reports `Unchanged`, because +`EquatableList` compares by value. That wrapper is doing its job, and research question (3) is +answered: no restructuring needed there. + +### An MSBuild property cannot gate the scan + +| | cold | counters | +|---|---|---| +| `DependencyModules_EnableConventions=false` | 51.6 ms | pred=242290 **xform=2001** | +| `DependencyModules_EnableConventions=true` | 51.1 ms | pred=242290 **xform=2001** | + +Identical. `AnalyzerConfigOptionsProvider` is a provider, so its value is only available *after* the +syntax provider has produced values — a gate applied there discards results whose cost has already +been paid. There is no overload of `CreateSyntaxProvider` that takes a condition, and `Initialize` +cannot read build properties synchronously. + +**Research question (2) is answered: no.** Not loading the analyzer at all — the package boundary — +is the only thing that gates this. + +Related, and unfixable either way: `RegisterPostInitializationOutput` emits the 413-line +`IConventionDefinitions` contract into every compilation that loads the generator, unconditionally. +A merge means every project that uses this library compiles that file. + +--- + +## Part 5: what is worth caching, and what is not + +Three plausible-looking optimisations were measured. One is a large win, one is a small win, and one +is a **loss** — which is the reason to measure rather than reason. + +### Dropping diagnostics buys nothing. Do not do it. + +| | | +|---|---| +| 2,000 DM0010 diagnostics — `Location.Create` + `Diagnostic.Create` | **0.05 ms** | +| `Location.Create` alone, 2,000 times | 0.02 ms | +| `LocationModel.From` in the transform, 2,000 candidates | **1.15 ms** | + +The diagnostics are two per cent of what the machinery feeding them costs, and they are only paid +when emission actually runs. **The expense is capturing a location eagerly for every candidate on +every keystroke, so that a handful of them can be reported.** + +DM0010 is also the single thing the design doc names as what Scrutor structurally cannot do — +"this type is in the container as `IFoo`, via `IAuditedFoo`", answered in the IDE, at the type. +Deleting it to save 0.05 ms would be trading the differentiator for nothing. + +What to change instead: + +- **Drop the line/character half of `LocationModel`.** `GetLineSpan()` is 0.29–0.58 µs per node and + needs the text line index; `FilePath` + `Span` is 0.15 µs. Rebuild the line positions at output + time, where the compilation is already in hand via the existing `Combine`, and only for the few + candidates that actually produce a diagnostic. +- **Key the span on the identifier token**, not the declaration. Editing a method body does not move + the class name, so the common IDE edit stops invalidating the model — the Part 4 finding, fixed by + the same change. +- If DM0010 ever does become load-bearing on cost, it can be gated on an MSBuild property. **That + gate works**, unlike the one in Part 4, because diagnostics are produced at output time where + `AnalyzerConfigOptionsProvider` has already delivered its value. + +### Interning `TypeDefinition` is a loss + +`TypeDefinition.Get` allocates a fresh instance per call, and `GetHashCode()` is +`ToString().GetHashCode()`, so the first hash of every instance allocates a string. Both look like +obvious candidates for a cache. Measured: + +| | | +|---|---| +| 4,000 × `Get` + `HashSet.Add`, fresh instances | **0.18 ms** | +| 4,000 × `Get` + `HashSet.Add`, interned through a dictionary | **0.44 ms** | +| 200,000 × `Equals` on distinct instances | 1.69 ms | +| 200,000 × `Equals` with a reference fast path | **0.06 ms** | + +**Interning is 2.4× slower than allocating.** A gen-0 allocation is a pointer bump; a tuple-keyed +dictionary lookup that hashes two strings is not. The allocation was never the problem. + +Equality is 28× faster when instances are shared, but that only matters in a hot loop, and there +isn't one: 2,000 pairwise `ConventionCandidateModel.Equals` calls take 0.08 ms. Adding a +`ReferenceEquals` fast path to `Equals` is free and harmless; building an intern table to feed it is +not worth it. + +### Caching the syntax-derived half on the node is a 29× win + +| 2,001 candidates | | +|---|---| +| compute `GetTypeDefinition()` + `LocationModel.From` | 1.15 ms | +| `GetTypeDefinition()` + identifier span only, no `GetLineSpan` | 0.69 ms | +| `ConditionalWeakTable` lookup | **0.04 ms** | + +Because the transform re-runs for every candidate on every driver run (Part 4), a cache keyed on the +syntax node gives back the incrementality Roslyn is not providing. + +**The soundness line matters and is not negotiable.** Node identity implies *syntax* identity, so +anything derived purely from syntax — the type definition, the location, the declared modifiers — is +safe to cache this way. Anything bound through the semantic model is **not**: an edit in another file +can change what a base-list name resolves to, and serving a stale interface list would register the +wrong service with a green build. Cache the syntax half; recompute the semantic half. + +Two properties make this safe to add: + +- `ConditionalWeakTable` holds keys weakly, so entries die with the node and nothing pins a syntax + tree in memory — the failure the `LocationModel` comment already warns about. +- It is a cache, not a contract. Roslyn creates red nodes on demand and may collect and recreate + them, so the hit rate is high but not guaranteed. A miss recomputes and is merely slower. + +Rendered C# output does not need a cache of its own: once the model stops changing on unrelated +edits, emission is skipped entirely rather than re-rendered and discarded. + +--- + +## Part 6: the merge question, answered + +### What the boundary is worth today + +2,000 classes, per keystroke: + +| Project references | cold | incr | +|---|---|---| +| `SourceGenerator` only | 54.6 ms | **5.3 ms** | +| both packages, convention matches nothing | 122.4 ms | 16.7 ms | +| both packages, **module declares no conventions at all** | 122.4 ms | 15.6 ms | +| both packages, convention matches all 2,000 | 175.6 ms | 47.5 ms | + +The third row is the one that decides it. A project that references the conventions package and +never writes a convention pays the same as one that writes a convention matching nothing — because +the scan runs either way. **The package boundary is currently worth about 10 ms per keystroke to a +non-user of conventions, at 2,000 classes.** It is a real thing and it is not enormous. + +### The merge is not what is expensive + +Merging changes *who* pays, not *how much* is paid. The work is identical; the difference is that +projects not using conventions start paying it. So the merge is affordable exactly to the extent +that the scan is cheap — and Part 4 shows the scan is dominated by a transform that can be made +roughly six times cheaper without changing what it computes. + +Ordered by what they buy: + +1. **Make the transform syntax-only where it can be** — `LocationModel` off the hot path, constructor + info deferred. 11.3 ms → 1.7 ms per keystroke at 2,000 classes, 0 matching. This is worth doing + whether or not anything merges, and it is what makes the merge cheap enough to argue about. +2. **Monomorphise case A** — attribute-registered services, entirely inside + `DependencyModules.SourceGenerator`. No merge, no new provider, no cross-assembly anything. +3. **Then** decide about case B, with a scan that costs a fifth of what it costs today. + +Doing (3) first means arguing about a 10 ms regression that (1) mostly removes. + +### If case B is wanted without a merge + +Both packages emitting their own closed decorations is viable — the `[Decorator]` input costs 0.1 ms +per keystroke via FAWMN, so duplicating that provider in the conventions package is free. It requires +the double-decoration guard from Part 3, and it means the decorator attribute is read twice. That is +a smaller change than merging two analyzer assemblies, and it keeps the "don't pay for what you don't +use" property that the boundary exists for. + +Worth noting that the two assemblies already compile the same `Impl` sources, so every shared type is +declared twice today. A merge would remove that duplication, which is a genuine if minor argument in +its favour. + +--- + +## Part 7: third-party frameworks on top of this + +### The extension seam already exists, and it is already shipped + +`DependencyModules.SourceGenerator.Impl` is a **source-only NuGet package**. Its own description says +so, and `Package/DependencyModules.SourceGenerator.Impl.targets` implements it: + +```xml + + + +``` + +A framework sets that property, compiles the internals into its own analyzer assembly, subclasses +`BaseSourceGenerator`, and declares its own `[Generator]`. `DependencyModules.Conventions` is the +reference consumer and does exactly this via a project reference instead of the package. + +`BaseSourceGenerator.ModuleAttributeTypes()` is the hook for a framework's own module attribute — +`[HardenedApplication]` rather than `[DependencyModule]`. It is `virtual`, documented for that +purpose, and **currently overridden by nothing in this repository**. It is a seam that has been built +and never exercised, which means it is unproven rather than proven. + +### Hardened already uses this exact pattern — for its own generator + +Read from `~/Hardened.Framework`: `Hardened.SourceGenerator.csproj` packs `**/*.cs` under `src/`, +sets `PackageCSharpAuthorIncludeSource=true`, and four leaf generators +(`Hardened.Library`, `Hardened.Console`, `Hardened.Web`, `Hardened.Templates`) each compile it in and +declare one `[Generator]`. + +So the mechanism needs no selling: it is the same shape Hardened chose independently, down to the +vendored CSharpAuthor. What Hardened does **not** do today is use DependencyModules at all — it has +its own `DependencyInjectionIncrementalGenerator`, its own `KnownTypes.DI.Registry`, its own +`EntryPointSelector`. The question is not whether the seam is usable but whether the two DI +generators should become one. + +### Stacking analyzer assemblies is cheap. The convention scan is not. + +2,000 classes, per keystroke: + +| Analyzer assemblies loaded | cold | incr | +|---|---|---| +| `SourceGenerator` only | 55.5 ms | 5.6 ms | +| + 1 extension generator | 54.0 ms | 5.5 ms | +| + 2 extension generators | 60.1 ms | 6.1 ms | +| + 3 extension generators | 60.2 ms | 5.9 ms | +| `SourceGenerator` + `Conventions` + 2 framework generators | **138.4 ms** | **19.6 ms** | + +Each extra generator brings its own module-discovery `CreateSyntaxProvider` over every syntax node +and cannot share Roslyn's caches with the others — and it costs roughly **2 ms cold and 0.15 ms per +keystroke**. Module discovery is cheap enough that a framework stacking three or four generators on +this seam is a non-issue. + +The last row is the whole point: adding three generators costs 5 ms, and adding the convention scan +costs 78 ms. **The scaling problem is not the number of extensions. It is the one provider that +transforms every class in the compilation** — the same finding as Part 4, arrived at from the other +direction. + +### What this implies for the seam + +- **The source-only package is the right answer and needs no redesign.** It is proven by + `DependencyModules.Conventions`, it is the pattern Hardened already uses, and it costs almost + nothing to stack. +- **Fixing the candidate transform is what makes the seam safe to recommend.** A framework that + compiles in Impl inherits whatever the transform costs; today that is 11–39 ms per keystroke on a + 2,000-class project the moment conventions are involved. +- **`ModuleAttributeTypes()` should get a test before it is advertised.** Nothing exercises it, and + an extension point that has never been used is a bug that has not been found yet. +- Worth deciding explicitly: a framework subclassing `BaseSourceGenerator` gets module discovery and + emission, but the *service* attribute providers live in `DependencyModules.SourceGenerator`, not in + Impl — except `ServiceSourceGenerator.cs`, which Impl packs specially. That asymmetry will be the + first thing an integrator trips over. + +--- + +## Part 8: open generic decorators across an assembly boundary + +Part 3 stated that monomorphisation and cross-assembly generic decoration are mutually exclusive. +That is true of the *obvious* monomorphisation — the application emitting `new P.Behavior(…)` +itself. It is **not** true of the feature. There is a shape that gives both, and it has been built +and run. + +### The problem, stated precisely + +Package `P` ships: + +```csharp +[Decorator] +public class LoggingBehavior( + IRequestHandler inner, IAuditSink sink) + : IRequestHandler; +``` + +Application `A` declares `CreateOrderHandler : IRequestHandler`. + +- When `P` compiles, `CreateOrder` and `OrderId` do not exist. `P` cannot write the closure. +- When `A` compiles, everything exists — but `[Decorator]` sits on a type in a referenced assembly, + and `ForAttributeWithMetadataName` does not see those. + +**The closure can only be emitted in `A`.** There is no alternative: `new LoggingBehavior(…)` is a literal that only `A`'s compilation can produce. So the whole question is what `A` +has to learn from `P`, and who owns the knowledge of how to build `P`'s decorator. + +### The answer: the package ships a generic closer, the application supplies the type arguments + +`P`'s generator emits, next to the decorator, a generic static method: + +```csharp +public static class LoggingBehaviorRegistration { + public static void ApplyTo(IServiceCollection services) => + DecoratorHelper.Decorate(services, typeof(IRequestHandler), + (provider, inner) => new LoggingBehavior( + (IRequestHandler)inner, + provider.GetRequiredService())); +} +``` + +`A`'s generator emits one **closed generic method call** per closed registration it made: + +```csharp +LoggingBehaviorRegistration.ApplyTo(services); +LoggingBehaviorRegistration.ApplyTo(services); +``` + +A closed generic call is an ordinary static reference. ILC follows it, compiles the instantiation, +and through it `new LoggingBehavior(…)`. No `MakeGenericType`, no +`ActivatorUtilities`, no interface walk, nothing to annotate. + +### Verified + +Two projects, a real assembly boundary, published Native AOT and run. `aotlib` holds the interface, +the decorator and the closer; `aotapp` holds the handlers and the calls, and never names +`LoggingBehavior<,>` anywhere. + +``` +A. package decorator applied through a generic closer in the package: + resolved as LoggingBehavior`2 + [audit] CreateOrder -> OrderId + result OrderId { Value = abc } + resolved as LoggingBehavior`2 + [audit] CountRequest -> Int32 + result 42 + +B. same decorator applied the way the package emits it today: + FAILED: InvalidOperationException: A suitable constructor for type + 'AotLib.LoggingBehavior`2[AotApp.CreateOrder,AotApp.OrderId]' could not be located. +``` + +The library itself builds `IsAotCompatible=true` at **zero IL warnings**. Every warning in the app's +publish comes from `DecoratorHelper`'s existing open-generic path, which case B still exercises. + +**One detail in case B's failure is load-bearing.** It failed on the *constructor*, not on +"missing native code or metadata" as it did in Part 1. That is because case A's closed call had +already forced ILC to generate the `LoggingBehavior` instantiation, so +`MakeGenericType` found it. The two failures are independent: rooting the instantiation fixes one, +the annotation from Part 2 fixes the other. + +That is not a curiosity about the old path. It is the mechanism behind a defect the fix would +*introduce*, and it is severe enough to have its own section below. + +### Emitting closed calls makes the runtime fallback fail selectively + +Keeping the open-generic call as a compatibility fallback looks safe: anything the generator covers +is decorated statically, anything it misses falls back to the path that works today. **It is not +safe, and the reason is that the fallback stops failing uniformly.** + +One application, one decorator, three registrations. The first goes through a module, so a closed +call is emitted for it. The other two are written by hand in `Program.cs` — ordinary code the +generator never sees — and only the fallback can reach them. Published Native AOT and run: + +``` +1. generator SAW this one (closed call emitted): + resolved as LoggingBehavior`2, result OrderId { Value = abc } + +2. generator NEVER saw it, both type arguments are reference types: + resolved as LoggingBehavior`2, result RenameId { Value = xyz } <- works + +3. generator NEVER saw it, response is a value type: + FAILED: NotSupportedException: 'LoggingBehavior`2[CountRequest,System.Int32]' + is missing native code or metadata. +``` + +The same three under JIT: all pass. + +Row 2 works **by accident**. The closed call in row 1 caused ILC to compile the canonical +`LoggingBehavior<__Canon, __Canon>` form, which every all-reference-type instantiation shares, so +`MakeGenericType(Rename, RenameId)` finds code that exists for an unrelated reason. Row 3 needs an +exact instantiation, which cannot be produced at run time and was never generated. + +This is the failure profile the library exists to prevent: + +- **It passes in development.** JIT resolves all three. +- **It passes for most types.** Reference-type arguments dominate real handler signatures. +- **It fails only under AOT, only for value-type arguments, only at resolve time**, in production. +- **Whether it fails depends on unrelated code.** Row 2 works because row 1 exists. Delete the + module-registered handler and row 2 starts failing too — exactly the reproduction in Part 1, where + nothing had been rooted and even reference types failed. Adding or removing an unrelated + registration silently changes whether a different registration resolves. + +The original brief predicted precisely this — "reference-type arguments survive on shared canonical +code, but a value-type response is the instantiation Native AOT hasn't generated." Part 1 appeared to +refute it, because with *nothing* rooted there is no canonical form either. Emitting closed calls +creates the canonical form, and the brief's prediction becomes correct. + +**So the fallback must not be silent.** It is a JIT compatibility shim, and under AOT it has to be +off rather than partially working: + +- Put the open-generic path behind a feature switch that is off when `PublishAot` is set, the shape + `System.Text.Json` uses for its reflection fallback. `MakeGenericType` is then trimmed, IL3050 goes + with it, and the failure becomes "not decorated" rather than "decorated on some machines". +- Document the narrowed contract plainly. Today a decorator covers anything in the collection when + `ApplyDecorators` runs. Monomorphised, a generic decorator covers **what a module registered, in + this compilation or a referenced one**. Hand-written `services.Add…` for a generic service is + outside it. +- The generator cannot enumerate what it never saw, so there is no per-case diagnostic to emit. What + it can report is the rule: a generic decorator exists, and registrations of that service made + outside a module will not be covered. + +This also retires the suggestion made earlier in this document that the package should emit both +shapes and let a dedup guard sort it out. The guard is still needed — see below — but it addresses +double decoration, not this. + +### Double decoration is not hypothetical either + +The same run shows it, in the JIT output, on the registration that *was* covered: + +``` + [audit] CreateOrder -> OrderId + [audit] CreateOrder -> OrderId + resolved as LoggingBehavior`2 +``` + +The closed call wrapped it, then the fallback wrapped it again. Two audit entries per request, and +the only symptom is duplicated side effects — no exception, nothing in the build. A guard keyed on +the descriptor and the decorator's generic type definition removes it. + +### Why the closer beats the application writing `new` itself + +Both shapes are AOT-safe. The closer wins on four counts that are not about performance: + +| | application emits `new P.Behavior(…)` | package ships a closer | +|---|---|---| +| what `A` must read from `P` | the decorator's full constructor, from metadata | the open service type, and where the closer is | +| `internal` dependency in `P`'s constructor | **impossible** — `A` cannot name the type | fine, `P` resolves it itself | +| `P` changes its constructor | `A`'s emitted code is stale until rebuilt against the new shape | `P` owns it; `A`'s call site is unchanged | +| generic constraints on the decorator | checked at `A`'s call site | checked at `A`'s call site | + +The second row is the one that decides it. A behaviour taking an internal logger, options type or +sink is ordinary, and it makes the direct-`new` shape unable to express a large class of real +decorators. + +### How `A` finds out, and what it costs + +`P`'s generator emits an assembly-level manifest alongside the closer: + +```csharp +[assembly: ModuleDecorator( + Service = typeof(IRequestHandler<,>), + Closer = typeof(LoggingBehaviorRegistration), + Order = 100)] +``` + +`A` reads `compilation.SourceModule.ReferencedAssemblySymbols`, calls `GetAttributes()` on each, and +matches the open service type against its own closed registrations. Measured on a 26-reference +compilation: + +| | | +|---|---| +| read assembly-level attributes on every reference | **0.007 ms** | +| walk every public type in every reference (2,813 types) looking for `[Decorator]` | 0.257 ms | +| walk only references that themselves reference `DependencyModules.Runtime` | 0.017 ms | + +Both are affordable, so cost is not what picks the manifest — 0.26 ms per keystroke would be +tolerable. **Note this does not contradict the 13 ms figure in +`convention-registration-and-decorators.md`.** That probe compared `AllInterfaces` on +`OriginalDefinition` and read `InstanceConstructors` for every type; this one reads attributes. +Assignability is the expensive query, not enumeration. + +The manifest is chosen for what it *says*, not what it costs: it names the closer, the order and the +realm, so `A` never has to infer `P`'s intent from `P`'s type shape. Falling back to the filtered +type walk covers a package that has a `[Decorator]` but no manifest — an older version of the +generator, or a library that wrote the attribute by hand. + +### Composition falls out + +Each assembly monomorphises **its own** registrations against every manifest it can see. If package +`Q` also references `P` and registers handlers, `Q`'s generator emits `P`'s closer calls for `Q`'s +closed types. `A` does the same for `A`'s. The application composes both modules and both sets of +decorations run in the `ApplyDecorators` phase, ordered globally by the `Order` the manifest carried. + +Nobody has to see anybody else's closed registrations, which is exactly the property that made the +runtime open-generic call attractive in the first place — recovered without reflection. + +### What still has to be decided + +- **Double decoration is now certain, not hypothetical.** If `A` and `Q` both register + `IRequestHandler`, both emit a closer call naming that closed type, and `Decorate` wraps every + matching descriptor — so both get wrapped twice. The dedup guard from Part 3, keyed on the + descriptor and the decorator's **generic type definition**, is now required rather than merely + advisable. +- **Backwards compatibility cuts both ways, and neither direction is free.** If `P` stops emitting + the open-generic call, an application on an older generator loses the decoration with a green + build. If `P` keeps emitting it, an AOT application gets the selective failure above. `P` emitting + both is right for JIT and wrong for AOT, so the fallback has to be a feature switch rather than a + decision `P` makes once at pack time. +- **Conditions and realms travel on the manifest**, and `A` emits the same `EnvironmentConditionWriter` + guard around the closer call that it already emits around a local decorator. +- **A package with a generic `[Decorator]` and no generator** — a hand-written attribute, or a library + that never adopted this — has no closer to call. That case gets the type walk, the direct-`new` + emission, and a diagnostic when the constructor cannot be expressed from `A`. +- **Method naming.** `ApplyTo` by convention keeps the manifest to three values. Carrying the method + name explicitly costs nothing and avoids a naming collision in a package with several decorators + over one service; prefer the explicit form. + +--- + +## Part 9: a runtime with no reflection + +**Ian's rule, and it supersedes the feature-switch compromise in Part 8:** the runtime does no +reflection and no type closure by reflection. Not gated, not opt-out — absent. + +### First, what is actually there + +There is no `Reflection.Emit` anywhere in this repository, and never has been. The unsafe surface is +reflection *over* `Type`, and it is five call sites, all of them in one file: + +| | | +|---|---| +| `DecoratorHelper.cs:91` | `decoratorType.MakeGenericType(...)` — type closure by reflection | +| `DecoratorHelper.cs:94` | `ActivatorUtilities.CreateInstance(provider, closedDecorator, inner)` — builds the **decorator** | +| `DecoratorHelper.cs:135,137` | `inner.GetType().GetInterfaces()` — exists only to feed `MakeGenericType` | +| `DecoratorHelper.cs:180` | `ActivatorUtilities.CreateInstance(provider, descriptor.ImplementationType)` — builds the **inner** | +| `DecoratorHelper.cs:198` | the same, keyed | + +Interception has none: it is generated wrappers over typed interfaces. `DependencyRegistry` has none +either — its IL2067 is a missing annotation, not a reflective call. + +So the whole rule reduces to rewriting one file. Four of the five call sites fall out of +monomorphisation. The fifth does not, and is the interesting part. + +### The shape: the service is a type parameter, not a `Type` + +```csharp +public static void Decorate( + IServiceCollection services, + Func factory) where TService : class +``` + +Emitted as: + +```csharp +DecoratorHelper.Decorate>(services, + (p, inner) => new LoggingHandler(inner, p.GetRequiredService())); +``` + +Three things follow that are worth more than the reflection removal itself: + +- **No casts.** The inner arrives typed, so the generated lambda has no `(IRequestHandler<…>)inner`. +- **`MakeGenericType` and `ResolveTypeArguments` have nowhere to live.** The type arguments are in the + call site, written by the generator. +- **`GuardOpenGenericRegistration` becomes structurally impossible to violate.** `typeof(IRepo<>)` + cannot be written as a type argument, so generated code cannot ask to decorate an open generic. The + error class disappears rather than being reported at composition. What remains — a registration + *made* as an open generic that a decorator wants to cover — the generator can see, and should say so + as a build diagnostic. + +### The hard case: producing the inner without constructing it + +Decoration replaces a descriptor with a factory, so whatever the descriptor produced must still be +produced. An `ImplementationInstance` is returned and an `ImplementationFactory` is invoked — neither +reflects. An `ImplementationType` has to be **built**, and that is what `ActivatorUtilities` was for. + +The way out is not to build it. **Displace the registration under a private key and let the container +build it, exactly as it would have if nothing had been decorated:** + +```csharp +// [i] was ServiceDescriptor(IResource, implementationType: Resource, Scoped) +services.Add(new ServiceDescriptor(typeof(Resource), innerKey, typeof(Resource), Scoped)); +services[i] = new ServiceDescriptor(typeof(IResource), + p => factory(p, (IResource)p.GetRequiredKeyedService(typeof(Resource), innerKey)), Scoped); +``` + +**Be honest about what this achieves.** You cannot have a container that constructs types named by +`Type` without the container reflecting; `AddSingleton()` reflects. The achievable line is +not "zero reflection in the process" but: + +> DependencyModules adds no reflection of its own. A decorated registration is constructed by exactly +> the same path as an undecorated one. + +That line is checkable, and it is the one worth defending. Stacked decorators cost nothing extra — +after the first rewrite the descriptor is a factory, so the second decorator takes the +`ImplementationFactory` branch and only the innermost is ever displaced. + +### Verified + +Built with `IsAotCompatible=true`: **zero IL warnings in the new helper** (one IL2067 appeared first +and was closed by annotating the displaced `implementationType` — a pure annotation, no behaviour). +Published Native AOT and run: + +``` +shipped DecoratorHelper (ActivatorUtilities): + FAILED: InvalidOperationException: A suitable constructor for type 'TracingResource' + could not be located. +reflection-free helper (container-owned inner): + resolved : used (traced) + inner disposed when the scope ended: yes + +generic decoration through the reflection-free helper: + reference-type response: LoggingHandler`2 -> OrderId { Value = abc } + value-type response: LoggingHandler`2 -> 42 +``` + +Every remaining IL warning in that publish comes from the old `DecoratorHelper` still being +referenced. Delete it and the application publishes clean. + +### It also fixes a disposal leak that has nothing to do with AOT + +The third line above is not about AOT. Under **JIT**, today: + +``` +shipped DecoratorHelper (ActivatorUtilities): + resolved : used (traced) + inner disposed when the scope ended: NO (0) +reflection-free helper (container-owned inner): + inner disposed when the scope ended: yes +``` + +`ActivatorUtilities.CreateInstance` produces an object the container does not own, so it is never +registered for disposal. **Decorating a scoped `IDisposable` service silently leaks its disposal +today, on every runtime, for every user.** The displacement fixes it because the container creates +the inner and therefore disposes it. + +This deserves its own test and arguably its own release note. It is the strongest argument in this +document that is not about AOT at all. + +### What the rule costs + +- **Descriptor count grows.** One extra keyed descriptor per decorated implementation-type + registration. Tests asserting on `Services.Count` will need updating, and anything walking the + collection sees the displaced entries. +- **The private key must be deterministic.** A static counter is wrong: it is not thread-safe and + makes the collection differ between runs. Derive it from the decorated service type and the + decorator identity, both of which the call site already has. +- **The type-driven `Decorate(IServiceCollection, Type, Type)` overload is deleted**, which is a + binary break on a public API. At `1.0.0-rc` that is affordable. After 1.0 it is not, so the timing + argues for doing this now rather than after the parity work. + +### Where the rule does not apply + +Worth stating so nobody over-applies it: + +- **The analyzers.** They run inside the compiler and are never published. `ITypeDefinition`, + `SymbolEqualityComparer` and everything else in Impl are unaffected. +- **The testing packages.** `DependencyModules.Moq`, `.NSubstitute`, `.FakeItEasy` and `.Testing` wrap + libraries that genuinely do emit IL at run time. They never ship in a published application, and + the rule would be meaningless there. + +That second point resolves the loose end from Part 8. A coverage check — "a generic decorator exists, +and this registration of it was never decorated" — needs `IsGenericType` and +`GetGenericTypeDefinition`. Those are pure metadata reads with no trimming or AOT implication, but +they are still `Type` introspection. **Put the check in `DependencyModules.Testing` as an explicit +`VerifyDecoratorCoverage()`**, where reflection is already the house style and nothing ships. The +runtime package then holds none, and the silent gap is catchable by anyone who writes a composition +test. + +--- + +## Part 10: sequencing + +Ordered so each step ships on its own and makes the next cheaper. + +| # | Work | Effort | Why here | +|---|---|---|---| +| 1 | `IsAotCompatible=true` on `DependencyModules.Runtime`, with the IL\* warnings promoted to errors | trivial | Nothing in this document would have shipped had this been on. It is what turns "no reflection" from a policy into a build failure | +| 2 | Rewrite `DecoratorHelper` per Part 9: `Decorate`, literal `new`, inner displaced under a private key. Delete the type-driven overload | moderate | Removes all five reflective call sites, fixes the disposal leak, and makes step 5 a smaller change. Binary-breaking, so it wants doing before 1.0 | +| 3 | Correct `website/guide/aot.md` and the status line of `convention-registration-and-decorators.md` | small | They currently promise something that does not work | +| 4 | Make the candidate transform syntax-only; key `LocationModel` on the identifier and drop its line/character half; cache the syntax-derived parts on the node | moderate | 11.3 → 1.7 ms per keystroke. Prerequisite for any honest merge discussion, and for recommending the extension seam | +| 5 | Monomorphise case A — attribute-registered closed generics | moderate | Single assembly. Needs the double-decoration guard and a diagnostic for what it cannot cover | +| 6 | Decide case B: merge, or emit from both packages with a dedup guard | — | Cheaper to decide after (4) | +| 7 | Emit a generic closer plus an assembly manifest for every generic `[Decorator]`, and consume manifests from referenced assemblies | larger | Part 8. The only shape that is both AOT-safe and cross-assembly, verified end to end. Needs the dedup guard from step 5 first | + +Four things that should **not** be built: + +- **An MSBuild property to gate the convention scan.** Measured above: it does not gate anything. +- **A runtime fallback to the open-generic call, in any form** — not gated, not feature-switched, not + opt-out. Measured in Part 8: it succeeds for reference-type arguments on canonical code the closed + calls happened to produce and fails for value-type ones, so whether a registration resolves depends + on which *other* registrations exist. Part 9 removes it outright; what it covered becomes a + documented contract and a `VerifyDecoratorCoverage()` in the testing package. +- **An intern table for `TypeDefinition`.** Measured 2.4× slower than allocating. +- **Fewer diagnostics.** They cost 0.05 ms per 2,000 and they are the differentiator. The eager + location capture that feeds them is the cost, and it is fixed by step 4. + +### Boundary cases still to scope + +Recorded here rather than resolved, since they change what a diagnostic should say: + +- `RegistrationFormOf`/`OpenFormOf` register a pass-through generic implementation as an **open** + generic, and `GuardOpenGenericRegistration` then throws at composition. That throw should become a + build diagnostic, conditional on a decorator actually existing for the service. +- Partially-open shapes — `class H : IHandler` — return null from `RegistrationFormOf` + and are dropped with nothing reported. Compare `Diagnostics.cs:48` in martinothamar/Mediator, whose + `OpenGenericRequestHandler` is a warning and on by default. +- `[Decorate]` declared on a module is read from `ModuleEntryPointModel.AttributeModels`, so it is + subject to the same single-compilation limit as `[Decorator]`. + +--- + +## Reproducing any of this + +- **Native AOT:** a console app with `PublishAot`, a generic `[Decorator]` over two handlers (one + value-type response), a non-generic `[Decorator]`, and a plain `[SingletonService]` control. + Publish and run it. On a machine whose Command Line Tools SDK is older than Xcode's, ILC's link + step needs `` or it fails + on `-ldl` before producing a binary. +- **Cross-assembly (Part 8):** two projects. A library holding the service interface, an open generic + decorator, and a `static void ApplyTo(IServiceCollection)` closer; an application that + references it, declares handlers the library has never seen, and calls the closer with closed type + arguments. Publish the application AOT and run. Build the library with `IsAotCompatible=true` to + confirm the closer introduces no warnings of its own. +- **Analyzer warnings, no publish needed:** + `dotnet build src/DependencyModules.Runtime -p:IsAotCompatible=true -p:TargetFramework=net10.0` +- **Generator timings:** extend `benchmarks/DependencyModules.Benchmarks` with counters in the + predicate and transform, and construct the driver with + `new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true)` + to read `TrackedSteps`. Time only `RunGeneratorsAndUpdateCompilation`; one class per syntax tree; + real method bodies. All three of those were already load-bearing in the existing benchmark and + remain so. diff --git a/docs/design/reflection-free-runtime-and-single-generator.md b/docs/design/reflection-free-runtime-and-single-generator.md new file mode 100644 index 0000000..c7ebc02 --- /dev/null +++ b/docs/design/reflection-free-runtime-and-single-generator.md @@ -0,0 +1,545 @@ +# Change plan: a reflection-free runtime and a single generator + +Status: plan, being executed. The evidence behind every claim here is in +`docs/design/aot-decorators-and-convention-cost.md`; this document does not repeat it, it decides +what to do about it. + +Two decisions drive everything below. + +**The runtime does no reflection.** Not gated behind a feature switch, not opt-out — absent. Type +closure, `ActivatorUtilities`, interface walks: gone. What a decorated registration costs must be +what an undecorated one costs, on the same construction path. + +**One analyzer assembly.** `DependencyModules.Conventions` is retired and the convention contracts +move into `DependencyModules.Runtime`. + +--- + +## Assessment of the merge, before committing to it + +Asked for thoughts, so: the merge is right, and for a reason stronger than the one that motivated it. + +### It is what makes decorator monomorphisation uniform + +The investigation split generic decoration into two cases. Case A — attribute-registered services — +is already one assembly and monomorphises with no new machinery. Case B — convention-registered +services — is the one that needed either a package merge or a cross-package dedup protocol. + +**The merge deletes case B rather than solving it.** Convention registrations and decorators end up +in one compilation stage, and monomorphisation becomes one code path instead of two with a +reconciliation problem between them. Everything in Part 6 of the investigation about "emit from both +packages with a guard" stops being a question. + +That is worth more than the packaging tidiness, and it was not the stated motivation. + +### It retires a documented wart rather than trading it + +`convention-registration-and-decorators.md` records why the contracts are emitted `internal` per +assembly, and the two costs it accepted: explicit interface implementation +(`void IConventionModule.Conventions(…)`), and CS0436 when two assemblies that both emit the +contracts reference each other — measured, three warnings. + +The objection it raised against making them public was *"they join the consumer's public API +surface."* **That objection is specific to emitting them into the consumer and does not survive the +move.** In `DependencyModules.Runtime` they are Runtime's public API, not yours, exactly like +`IDependencyModule`. So: + +- `public void Conventions(IConventionDefinitions conventions)` becomes legal. The explicit + implementation is no longer forced. +- CS0436 cannot occur — there is one definition. +- The 413-line post-initialization source disappears from every compilation. It is the only + `RegisterPostInitializationOutput` in the repository, so after this there are none. + +### It removes duplicated compilation + +`DependencyModules.Conventions` compiles in every `Impl` source, so today both analyzer assemblies +declare every `Impl` type. Anything referencing both sees genuine CS0433 duplicate-type errors — hit +while building the measurement harness for the investigation, and worked around with an extern alias. +One assembly ends that. + +### Three things that need deciding, not assuming + +**1. Version coupling becomes real.** Today the generator emits the contract, so the fluent API and +the generator that reads it cannot disagree. Moved to Runtime, a consumer on Runtime 1.0 with +generator 1.1 gets a compile error in their own code the moment they use a new verb. That is a loud, +correct failure and is acceptable — but it should be deliberate. Keep `KnownTypes` the single source +of truth for the names, and add a test asserting the Runtime interface and the generator's +expectations agree, so a rename cannot silently stop matching. + +**2. The performance risk is real and must be paid down first, not promised.** Merging without the +transform work makes every project pay the convention scan: measured, **+10 ms per keystroke at 2,000 +classes**, whether or not a single convention is declared. That is the whole reason the package +boundary existed. + +So the merge is **gated on a measured number**, not on intent: + +> The candidate provider must cost **under 2 ms incremental at 2,000 classes with zero matches**, +> measured by `benchmarks/DependencyModules.Benchmarks`, before the packages merge. + +The floor for a syntax-only transform measured 1.7 ms, so the budget is reachable but not free. If it +is not met, the merge waits. + +**3. The package deletion is a hard break.** Anyone referencing `DependencyModules.Conventions` gets +an unresolvable reference. At `1.0.0-rc` that is affordable. Ship a transitional empty package that +depends on `DependencyModules.SourceGenerator` so the failure is "this package is now empty, remove +it" rather than "package not found" — it costs one `.csproj` and one release note. + +### One thing to measure early, because it may make this much cheaper + +The investigation measured that the candidate transform re-runs for **every** candidate on **every** +driver run. It also measured that with no post-initialization output, a run where nothing changed at +all did not re-run the transform, while with post-initialization output it did. + +Removing the contract source removes the only post-initialization output in the repository. +**Whether that restores transform caching is the single highest-leverage unknown in this plan**, and +it is one benchmark run to answer. Do it before writing the caching layer — the answer decides +whether the `ConditionalWeakTable` in step 5 is necessary or redundant. + +--- + +## The change + +Ordered so each step ships on its own, and so the binary-breaking ones land before 1.0. + +### Step 1 — make the rule mechanical + +`DependencyModules.Runtime.csproj`: + +```xml +true +$(WarningsAsErrors);IL2026;IL2055;IL2067;IL2072;IL2075;IL2087;IL3050 +``` + +Nothing in the investigation would have shipped had this been on. It is what turns "no reflection" +from a policy into a build failure, and it must go first so every later step is checked by the build +rather than by review. + +This will fail immediately on the five call sites in `DecoratorHelper` and the missing annotation in +`DependencyRegistry`. That is the point; step 2 clears it. + +### Step 2 — rewrite `DecoratorHelper` with no reflection + +Split in two, because the second half changes a public API and the first half does not. + +**2a — displacement, no API change. Done.** `CreateInner`/`CreateKeyedInner` are replaced by +`CaptureInner`, which resolves the descriptor shape once at decoration time and displaces an +implementation type under `DisplacedImplementationKey`. Both public overloads are unchanged, so the +generator needed no edit. The two failing tests pass, 571 unit and 133 integration tests stay green, +and the four `DecoratorHelper` IL warnings drop to the three that belong to the type-driven overload. + +Tests added with it: the displaced registration keeps the original lifetime, the implementation does +**not** become resolvable through the container, and stacking displaces once rather than once per +decorator. + +**Discovered while doing it, and it needs a decision:** displacement resolves through +`GetRequiredKeyedService`, so decoration now requires an `IKeyedServiceProvider`. Microsoft's +provider is one; a third-party container adapting `IServiceCollection` may not be. The alternative — +re-registering the implementation as its own service type — works on any provider but makes +`GetService()` start answering where it previously returned null, which is a surface change and +is covered by a new test asserting it does not happen. Keyed is the better default and the library +already emits keyed registrations elsewhere, but the constraint should be documented rather than +discovered. + +**2b — the generic overload. Done.** `Decorate(services, Func)` reuses the displacement core. The service being a type argument means `typeof(IRepo<>)` +cannot be written at the call site, so the open-generic mistake is inexpressible rather than detected +and thrown about — `GuardOpenGenericRegistration` becomes unreachable from generated code. + +### Step 4 — emit closed decorations. Partly done, and the AOT result is in. + +`DecoratorFileWriter` now emits, for a non-generic `[Decorator]`: + +```csharp +DecoratorHelper.Decorate(services, + (provider, inner) => new global::App.ShoutingGreeter(inner, provider.GetRequiredService())); +``` + +**Verified by publishing Native AOT and running it.** The non-generic decorator that failed in Part 1 +of the investigation with *"a suitable constructor for type 'ShoutingGreeter' could not be located"* +now resolves: + +``` +control: NON-generic decorator (IGreeter): + resolved as ShoutingGreeter + result HELLO + +all resolved +``` + +**And the publish reports zero IL warnings, down from four.** That is worth drawing out, because it +changes a decision made in Part 8 of the investigation. The reflective overload still exists on +`DecoratorHelper`, but nothing in that application calls it any more, so ILC never roots it and +`MakeGenericType` is trimmed along with its IL3050. **The feature switch proposed for turning the +fallback off is unnecessary**: emitting no reflective call is sufficient, and the trimmer does the +rest. The overload can simply be deleted once nothing emits it. + +**Generic decorators are monomorphised too.** `DecoratorSourceGenerator` no longer derives from +`BaseAttributeSourceGenerator`; it composes two providers, its own attributes and the +service attributes, and expands each generic decorator into one closed decoration per registration +that closes it. `AttributeModelCollector` holds the provider-building both share, and +`DecoratorTypeUtility.Close` does the substitution — including constructor parameters, so a decorator +taking `IValidator` resolves `IValidator`. + +**The whole thing, published Native AOT and run, with every decoration emitted by the generator:** + +``` +reference-type response (IRequestHandler): + resolved as LoggingHandler`2 -> OrderId { Value = abc } + +value-type response (IRequestHandler): + resolved as LoggingHandler`2 -> 42 + +control: NON-generic decorator (IGreeter): + resolved as ShoutingGreeter -> HELLO + +all resolved 0 IL warnings +``` + +That is the defect this whole investigation started from, closed. The value-type instantiation is the +one Native AOT could never produce at run time, and it is now written into the assembly. + +Three cases still take the reflective path, each reported by **DM0013** naming the decorator and the +reason, so none of them is silent: + +- **Module-level `[Decorate(typeof(IFoo), typeof(FooDecorator))]`**, which names the decorator by + `typeof()` and so carries no constructor. Reading one needs a symbol lookup this path does not + have — and the decorator may be in another assembly, which is Part 8's problem in miniature. +- **A generic decorator no registration in this compilation closes** — including the case that + matters most in practice, handlers registered by *convention*, whose models live in the other + analyzer assembly. This is case B, and the merge is what closes it. +- **A shape whose type parameters are not the service's arguments in order**, which cannot be closed + by position. + +`HasUnboundServiceType` exists because of a bug this found: a generic decorator that expanded to +nothing fell through to the closed path and emitted `Decorate>`, which is CS7003 in +generated code — the failure mode this generator is built never to produce. Caught by +`ConventionDecoratorTests`, which is exactly the case that reaches it. + +`TypeParametersMatchService` was added deliberately rather than assumed: +`Logging : IHandler` is legal C# that cannot be monomorphised by position, +and guessing would emit a `new` with the arguments swapped — which *compiles* whenever the two types +are compatible. It is refused instead. + +**New surface:** + +```csharp +public static void Decorate( + IServiceCollection services, + Func factory) where TService : class; +``` + +**Deleted:** `Decorate(IServiceCollection, Type, Type)`, `ResolveTypeArguments`, `Matches`, and the +`Type`-keyed `Decorate` overload. `GuardOpenGenericRegistration` goes with them — `typeof(IRepo<>)` +cannot be written as a type argument, so generated code can no longer express the mistake. Where a +registration is *made* as an open generic and a decorator targets it, the generator can see that at +compile time and reports it as a diagnostic instead. + +**Inner production**, the only genuinely hard part: + +| descriptor shape | before | after | +|---|---|---| +| `ImplementationInstance` | returned | unchanged | +| `ImplementationFactory` | invoked | unchanged | +| `ImplementationType` | `ActivatorUtilities.CreateInstance` | **displaced under a private key; the container builds it** | + +```csharp +services.Add(new ServiceDescriptor(implementationType, innerKey, implementationType, lifetime)); +services[i] = new ServiceDescriptor(typeof(TService), + p => factory(p, (TService)p.GetRequiredKeyedService(implementationType, innerKey)), lifetime); +``` + +Verified: zero IL warnings under `IsAotCompatible`, works under Native AOT for reference-type and +value-type type arguments, and fixes the disposal leak. + +**Be accurate about the claim.** A container cannot construct a type named by `Type` without +reflecting; `AddSingleton()` reflects. The line being defended is *DependencyModules adds +no reflection of its own — a decorated registration is constructed by exactly the same path as an +undecorated one.* Say that in the XML docs, not "zero reflection". + +**Details that will bite if skipped:** + +- The private key must be **deterministic** — derived from the service type and decorator identity, + never a counter. A counter is not thread-safe and makes the collection differ between runs. +- Stacked decorators need no extra displacement: after the first rewrite the descriptor is a factory, + so only the innermost is ever displaced. Assert this in a test. +- Keyed registrations compose the original key into the private key. +- `services.Count` grows by one per decorated implementation-type registration. Existing tests + asserting on counts need updating; that is expected, not a regression. + +### Step 3 — annotate `DependencyRegistry`. Done. + +`[DynamicallyAccessedMembers(PublicConstructors)]` on `Add(Type implementationType, …)`. +Pure annotation, no behaviour, one IL2067 gone. It changes the public API signature, so +`PublicApiTests.RuntimeApi` caught it and the snapshot was updated — which is the snapshot doing its +job, not an obstacle. + +**Step 1 stays off until 2b lands.** Three IL warnings remain, all of them in the type-driven +overload — `MakeGenericType`, `ActivatorUtilities`, and the `GetInterfaces` walk that only exists to +feed the first. Turning on `WarningsAsErrors` now would break the build for the duration of the work +rather than at the end of it. + +### Step 4 — emit closed decorations + +`DecoratorFileWriter` emits one `Decorate(services, (p, inner) => new …)` per closed +registration rather than one open-generic call per decorator. `ConstructorInfoModel` is already +computed, so the decorator's remaining constructor parameters are already known. + +Add the dedup guard: refuse to apply the same decorator to a descriptor twice, keyed on the +decorator's generic type definition. Two conventions or a convention and an attribute can put two +implementations behind one closed service, and both emissions would otherwise wrap both. + +Where the generator cannot see a registration — hand-written `services.Add…` in `Program.cs` — it is +**not decorated**, and there is no fallback. That is a narrowing of the contract and belongs in the +documentation and in `AnalyzerReleases`, not in a footnote. + +### Defect found by the new tests, and fixed + +`[Decorate(typeof(IHandler<>), typeof(LoudHandler<>))]` — a **generic** decorator named by a +module-level attribute — does not compile. The attribute is re-emitted onto the generated module +partial with the unbound type parameter intact, producing `CS0246: the type or namespace name 'T' +could not be found` in `{Module}.Module.g.cs`. + +Two causes, one in each half. + +`typeof(IHandler<>)` binds to the **unbound** symbol, whose `TypeArguments` are the declaration's +type *parameters*. Re-emitting that verbatim writes `typeof(IHandler)` into the generated module, +where `T` is not in scope. `AttributeModelHelper` now blanks the arguments when the syntax was +written as `Foo<>` — which fixes it for every module attribute carrying an unbound generic +`typeof`, not only `[Decorate]`. + +Underneath that, the decoration was then dropped: a generic decorator declares its parameter as +`IHandler` while the attribute named `IHandler<>`, and those never compare equal, so no +constructor parameter looked like the service. `ModuleDecoratorResolver` now compares on the unbound +form of both, while keeping the parameter type's names — closing the decorator over a registration +reads the type parameter order back off it. + +`ModuleLevelDecorate_CanNameAGenericDecorator` covers both. + +### Step 4b — the three cases still on the reflective path + +DM0013 names each of them at build time, so nothing is silent today. Closing them is what lets +`Decorate(IServiceCollection, Type, Type)` be deleted, and the AOT probe already proved deletion is +the *last* step rather than a prerequisite: once an application emits no reflective call, ILC never +roots the overload and the publish is warning-clean with it still present. + +#### Module-level `[Decorate(typeof(IFoo), typeof(FooDecorator))]` — done + +`GetModuleDeclaredDecorators` reads the two arguments as rendered type names. There is no +declaration behind them, so there is no constructor to emit a `new` from. + +**Resolved from the compilation at emission time.** `SymbolConstructorReader` reads the constructor +from an `INamedTypeSymbol`; `ModuleDecoratorResolver` finds the symbol with `GetTypeByMetadataName` +and fills in the model. Unbound generic arguments are legal in a `typeof` and arrive with their +arity, which the lookup restores as a backtick suffix. + +It turned out the reader already half-existed: `MetadataCandidateUtility.GreediestConstructor` in the +conventions package did the same job for referenced-assembly scanning, but dropped parameter +attributes and did not honour `[ActivatorUtilitiesConstructor]`. It now delegates to the shared one, +so both paths gain what the other had. + +**No public constructor is a diagnostic, not a fallback** — generated code constructs the decorator, +so there is nothing to emit, and saying so at build time is the whole point. + +Three things make this the right shape rather than a workaround: + +- **It is the same piece of code three other features need.** `ServiceModelUtility.GetConstructorInfo` + is syntax-driven; a symbol-driven equivalent is exactly what + `convention-registration-and-decorators.md` lists as *"the one genuinely new piece of code"* for + scanning referenced assemblies, and what Part 8 needs to read a package's `[Decorator]`. Build it + once. +- **It works across an assembly boundary for free**, which the syntax path never can — and + `[Decorate]` exists precisely for services you do not own. +- **It costs nothing when unused.** The combine re-runs each keystroke by construction, but it does + work only for modules that actually declare `[Decorate]`, and the result is wrapped so nothing + downstream re-runs. Same pattern as `MetadataCandidateUtility`. + +#### A generic decorator over convention-registered handlers — the merge, and more than file moving + +The closings exist in this compilation. They are computed by the other analyzer assembly, which is +the whole of case B. + +**But the merge is not "put the generators in one assembly".** `ConventionMatcher.Match` runs inside +`RegisterSourceOutput`, deliberately, because that is where it can report diagnostics — so its +`ServiceModel`s do not exist in any provider for `Expand` to combine with. Merging the assemblies +alone changes nothing. + +What closes it is **one emission stage that sees attribute registrations, convention registrations +and decorators together**. That is what the original brief meant by "a single combined provider +feeding one emission stage", and it is the real content of step 7 — the file moving is the easy half. + +Sequenced that way, the expansion machinery needs no change at all: `Expand` already takes a list of +registered service types and does not care which path produced them. + +#### A generic decorator over handlers this compilation never sees + +A package's handlers, or a hand-written `services.Add…` in `Program.cs`. These closings genuinely do +not exist at compile time, so no monomorphisation is possible and no amount of merging helps. + +Part 8's closer-plus-manifest is the answer, and it is the only one. Until it exists this is a +**documented contract narrowing**, not a defect to fix quietly: a generic decorator covers what a +module registered in this compilation. DM0013 says so per decorator. + +#### In the meantime + +`DM0013` turns the whole thing into a build failure for a +project that publishes AOT. That needs no code — it is ordinary MSBuild — and it should be in the AOT +guide rather than invented as a bespoke property. + +### Step 5 — make the candidate pipeline fast. Done, and the gate is answered. + +`DeclarationStamp` + `ConventionCandidateCache` wrap the candidate transform, and `LocationModel` +now narrows to the declaration's identifier token. + +Measured with `benchmarks/DependencyModules.Benchmarks`, per keystroke: + +| classes | matching | before | after | +|---|---|---|---| +| 2,000 | 0 | 11.3 ms | **11.0 ms** | +| 2,000 | 500 | 20.9 ms | **10.9 ms** | +| 2,000 | 2,000 | 39.0 ms | **11.4 ms** | + +**The shape changed, not just the number.** The cost used to scale with how many types a convention +matched; it is now flat. That was the property that made conventions frightening on a large project. + +And what the package costs a project that references it, at 2,000 classes: + +| | cold | per keystroke | +|---|---|---| +| `SourceGenerator` alone | 56.8 ms | 5.7 ms | +| both packages, before | 122.4 ms | 15.6–47.5 ms | +| both packages, after | 143.3 ms | **8.2 ms, flat** | +| **what conventions add** | +86.6 ms | **+2.4 ms** | + +**On the gate: 2.4 ms, against a stated budget of 2.0 ms.** It misses, narrowly. Read as written the +merge waits; read as "is this affordable", a 39% increase on a generator that was already running is +a different proposition from the 3× it was before. That is a call to make deliberately rather than to +round in either direction. + +The cold regression is real and is the stamp hashing every tree once: +18 ms on a 2,000-class build. +It buys 4.4× on every keystroke after it. If it needs reducing, the walk currently calls `ToString()` +on base lists and attribute lists, which allocates. + +### Step 5 (original plan, for reference) + +Measure the post-initialization question first. Then, in order of measured value: + +1. **Split the transform.** Syntax-only model: name, namespace, arity, base-list simple names, + attribute simple names, accessibility. No symbol binding. +2. **`LocationModel` keyed on the identifier token, without line/character.** `GetLineSpan` is over + half the cost for a class with no base list, and the full-declaration span is what makes an edit to + a method body invalidate the model. Rebuild line positions at output time from the compilation, + which is already combined in, for the few candidates that actually report a diagnostic. +3. **Defer semantic binding to a stage that only sees survivors** — filtered by namespace, name and + attribute syntactically, and by base-list simple name for assignability conventions. Take a + `SyntaxReference` only for a declaration with a base list; nothing without one can satisfy an + assignability convention. +4. **Cache the whole transform on `(node, declarationStamp)`** in a `ConditionalWeakTable`. This is + the largest single win and it should be done first, not last: measured 4.1 ms → **2.0 ms** at 2,000 + classes on a method-body edit, with 1,999 of 2,000 nodes hitting. + + The stamp is what makes caching a *semantic* result sound. The node alone is not a valid key — + proven by moving a `global using` in another file and watching an untouched node bind to a + different `IRepo`. The stamp hashes everything that can change a binding (usings, extern aliases, + namespace names, type identifiers, base lists, type parameters, modifiers, member signatures, the + reference set) and excludes method bodies. Cached per `SyntaxTree`, it costs 0.03 ms per keystroke. + + **Two silent-failure risks to close in review.** Anything the stamp omits that affects binding + yields a stale cache and a wrong registration with a green build — so it must be conservative, and + there must be a test that a base-list change in another file invalidates. And a 32-bit hash is not + an acceptable key when a collision means that; use a wider hash, or + `SyntaxNode.IsEquivalentTo(other, topLevel: true)`, which is Roslyn's own ignore-bodies comparison + and the primitive the stamp re-implements. + +Not to be built: an intern table for `TypeDefinition` (measured 2.4× slower than allocating), an +MSBuild gate on the scan (measured to gate nothing), or fewer diagnostics (they cost 0.05 ms per +2,000; the eager location capture feeding them is the cost). + +### Step 6 — move the contracts to `DependencyModules.Runtime`. Done. + +`IConventionModule`, `IConventionDefinitions` and `IConventionRegistration` are now public types in +`DependencyModules.Runtime/Conventions/ConventionContracts.cs`, in the +**`DependencyModules.Runtime.Conventions`** namespace. + +The first attempt kept the old `DependencyModules.Conventions` namespace for source compatibility. +That was wrong: it put the runtime contracts and the analyzer that reads them in one namespace across +two assemblies, which is ambiguous wherever both are referenced — the same class of problem the move +was meant to end. The namespace now matches the assembly, and consumers update one `using`. + +Note the analyzer keeps its own `DependencyModules.Conventions` namespace, so +`ConventionContractSource.Namespace` deliberately names a namespace that is **not** the one the +analyzer lives in. `ConventionContractTests` is what stops the two drifting. `ConventionContractSource.Source` and the only +`RegisterPostInitializationOutput` in the repository are gone; the class survives as the three name +constants the analyzer matches on. + +- **The wart it was meant to retire is retired**, and there is now a test proving it: + `AnImplicitPublicImplementationDeclaresConventions` compiles + `public void Conventions(IConventionDefinitions)`, which was CS0051 for as long as the contracts + were emitted `internal`. `TheExplicitImplementationStillDeclaresConventions` pins that nobody has + to rewrite anything. +- **The cost is visible and was measured by the build itself**: `PublicApiTests.RuntimeApi` failed, + as it should, and the snapshot shows **45 lines added to Runtime's public API** — three interfaces + and their verbs. That is the API users already write; it is now versioned. +- **The coupling risk is closed.** `ConventionContractTests` asserts the analyzer's string constants + match the Runtime types, so renaming `IConventionModule` cannot silently stop every convention + matching. It also asserts every verb returns `IConventionRegistration`, so an addition cannot + quietly end the chain. + +The stale comment in `FindConventionsMethod` explaining why explicit implementation was the only form +that compiled has been corrected rather than deleted — it records why the shape exists. + +### Step 7 — fold the generator in, delete the package + +`ConventionGenerator` joins `SourceGenerator.AttributeSourceGenerators()`. +`DependencyModules.Conventions` is reduced to a transitional empty package depending on +`DependencyModules.SourceGenerator`. The duplicated `Impl` compilation goes with it. + +**Gate:** do not take this step until step 5 has the benchmark under 2 ms incremental at 2,000 +classes with zero matches. + +### Step 8 — cross-assembly generic decorators + +The closer-plus-manifest design from Part 8 of the investigation. Larger than everything above and +independent of it; keep it last. + +### Step 9 — correct the documentation + +`website/guide/aot.md` currently lists decorators under *what this covers*. Until step 2 lands that +is false for every decorator, and after step 4 it is true only for registrations a module made. The +status line of `convention-registration-and-decorators.md` says open generic decorators are +implemented; it needs the reversal recorded. + +--- + +## What breaks + +| | Break | Mitigation | +|---|---|---| +| `DecoratorHelper.Decorate(IServiceCollection, Type, Type)` | removed | Binary break. Generated code is regenerated; hand-written callers move to the generic overload | +| `DependencyModules.Conventions` package | emptied | Transitional package plus a release note | +| `void IConventionModule.Conventions(…)` | explicit implementation no longer required | Source-compatible — explicit implementation still compiles | +| `services.Count` around a decorated service | grows by one | Test-only | +| A generic decorator over a hand-registered service | no longer decorated | Documented contract narrowing plus `VerifyDecoratorCoverage()` in the testing package | + +All of it is affordable at `1.0.0-rc` and none of it is affordable after 1.0, which is the argument +for doing it now. + +--- + +## Test plan + +Behavioural, using `GeneratedAssembly` — compile, load, resolve. Do not assert on generated text. + +- **Disposal**: a scoped `IDisposable` behind a decorator is disposed when the scope ends; the + decorator is too; stacked decorators dispose in order. *(The first two are committed and failing.)* +- **Displacement**: the displaced registration keeps the original lifetime; a singleton inner is one + instance across resolutions; a keyed registration keeps its key and gets a distinct private key. +- **Monomorphisation**: a generic decorator over two closed registrations wraps each with its own + closed type; a value-type type argument resolves. +- **Dedup**: two implementations behind one closed service are each wrapped exactly once. +- **AOT**: an integration project published `PublishAot` that resolves a decorated generic service — + the only test that would have caught any of this. +- **Incremental**: editing an unrelated method body leaves the convention emission cached, asserted + on `TrackedSteps` rather than on wall clock. + +`DependencyModules.Testing` gains `VerifyDecoratorCoverage()`, which may use reflection — it never +ships in a published application, and it is the only place the silent gap from step 4 is catchable. diff --git a/integ-tests/SutProject.NUnitTests/DataRowTests.cs b/integ-tests/SutProject.NUnitTests/DataRowTests.cs new file mode 100644 index 0000000..aed1dea --- /dev/null +++ b/integ-tests/SutProject.NUnitTests/DataRowTests.cs @@ -0,0 +1,81 @@ +using DependencyModules.NUnit.Attributes; +using DependencyModules.Testing.Attributes; +using NUnit.Framework; + +namespace SutProject.NUnitTests; + +/// +/// Data rows, which NUnit's own [TestCase] cannot supply for a module test. +/// +/// +/// [TestCase] requires a row to fill every parameter, and checks that at build time — so it +/// cannot express a row that covers the first parameters while the container covers the rest, which +/// is what a module test with data is. [ModuleTestCase] is the same idea without that rule. +/// +public class DataRowTests { + + [ModuleTest(typeof(SutModule))] + [ModuleTestCase(1)] + [ModuleTestCase(2)] + [ModuleTestCase(3)] + public void RowSuppliesTheLeadingParameterAndTheContainerTheRest( + int number, ISingletonService singletonService) { + Assert.That(number, Is.InRange(1, 3)); + Assert.That(singletonService, Is.Not.Null, "resolved from the container, not from the row"); + + SeenNumbers.Add(number); + } + + public static readonly List SeenNumbers = []; + + [ModuleTest(typeof(SutModule))] + [ModuleTestCase("first", 1)] + [ModuleTestCase("second", 2)] + public void SeveralLeadingParametersComeFromTheRow( + string word, int number, ISingletonService singletonService) { + Assert.That(word, Is.AnyOf("first", "second")); + Assert.That(number, Is.AnyOf(1, 2)); + Assert.That(singletonService, Is.Not.Null); + } + + /// A row can fill every parameter, leaving nothing for the container. + [ModuleTest(typeof(SutModule))] + [ModuleTestCase(4, 5)] + public void ARowMayCoverEveryParameter(int first, int second) { + Assert.That(first + second, Is.EqualTo(9)); + } + + /// Rows compose with the parameter attributes, which know nothing about rows. + [ModuleTest(typeof(SutModule))] + [ModuleTestCase(10)] + [ModuleTestCase(20)] + public void RowsComposeWithInjectedValues( + int number, [InjectValues("supplied")] NeedsAValue needsAValue) { + Assert.That(number, Is.AnyOf(10, 20)); + Assert.That(needsAValue.Text, Is.EqualTo("supplied")); + Assert.That(needsAValue.SingletonService, Is.Not.Null); + } + + [ModuleTest(typeof(SutModule))] + [ModuleTestCase(1, TestName = "a row can name itself")] + public void NamedRow(int number) { + Assert.That(number, Is.EqualTo(1)); + } + + public class NeedsAValue(ISingletonService singletonService, string text) { + public ISingletonService SingletonService { get; } = singletonService; + + public string Text { get; } = text; + } +} + +/// +/// Each row is its own test case, so each gets its own container — the same rule repetitions follow. +/// +public class DDataRowReport { + + [Test] + public void EveryRowRanExactlyOnce() { + Assert.That(DataRowTests.SeenNumbers, Is.EquivalentTo(new[] { 1, 2, 3 })); + } +} diff --git a/integ-tests/SutProject.NUnitTests/FakeItEasy/FakeItEasyTests.cs b/integ-tests/SutProject.NUnitTests/FakeItEasy/FakeItEasyTests.cs new file mode 100644 index 0000000..dcaffc7 --- /dev/null +++ b/integ-tests/SutProject.NUnitTests/FakeItEasy/FakeItEasyTests.cs @@ -0,0 +1,34 @@ +using DependencyModules.FakeItEasy; +using DependencyModules.NUnit.Attributes; +using DependencyModules.Testing.Attributes; +using FakeItEasy; +using NUnit.Framework; + +namespace SutProject.NUnitTests.FakeItEasy; + +/// +/// The FakeItEasy package, unchanged, against NUnit. +/// +[FakeItEasySupport] +public class FakeItEasyTests { + + [ModuleTest] + [SutModule] + public void MockTest( + [Mock] IDependencyOne dependencyOne, + [Mock] IScopedService scopedService, + ISingletonService singletonService) { + A.CallTo(() => dependencyOne.SingletonService).Returns(singletonService); + A.CallTo(() => dependencyOne.ScopedService).Returns(scopedService); + + Assert.That(dependencyOne.SingletonService, Is.SameAs(singletonService)); + Assert.That(dependencyOne.ScopedService, Is.SameAs(scopedService)); + } + + /// The injected fake is the thing you configure, unlike Moq — no unwrapping step. + [ModuleTest] + [SutModule] + public void TheInjectedInstanceIsTheFake([Mock] IDependencyOne dependencyOne) { + Assert.That(Fake.GetFakeManager(dependencyOne), Is.Not.Null); + } +} diff --git a/integ-tests/SutProject.NUnitTests/IterationLifetimeTests.cs b/integ-tests/SutProject.NUnitTests/IterationLifetimeTests.cs new file mode 100644 index 0000000..88d2a94 --- /dev/null +++ b/integ-tests/SutProject.NUnitTests/IterationLifetimeTests.cs @@ -0,0 +1,121 @@ +using DependencyModules.NUnit.Attributes; +using DependencyModules.Runtime.Attributes; +using NUnit.Framework; + +namespace SutProject.NUnitTests; + +[DependencyModule(OnlyRealm = true)] +public partial class LifetimeModule { } + +/// +/// Counts its own construction and disposal, so "a container per iteration" can be asserted rather +/// than inferred. +/// +[ScopedService(Realm = typeof(LifetimeModule))] +public class TrackedService : IDisposable { + + private static int _next; + + public static readonly List Constructed = []; + + public static readonly List Disposed = []; + + public TrackedService() { + Id = Interlocked.Increment(ref _next); + + Constructed.Add(Id); + } + + public int Id { + get; + } + + public void Dispose() => Disposed.Add(Id); +} + +/// +/// The invariant the whole integration exists to hold: one container per test iteration, torn down +/// when that iteration ends. +/// +/// +/// [Repeat] and [Retry] re-run a single test case, so a container built per test +/// case would be shared across every repetition. The fixtures below are ordered by name +/// because the report at the end reads what they recorded; NUnit runs fixtures within an assembly +/// in alphabetical order. +/// +public class ARepeatedModuleTests { + + public static readonly List Log = []; + + public static readonly List ServiceIds = []; + + [SetUp] + public void SetUp() => Log.Add("setup"); + + [TearDown] + public void TearDown() => Log.Add("teardown"); + + [ModuleTest(typeof(LifetimeModule))] + [Repeat(3)] + public void EachRepetitionGetsItsOwnContainer(TrackedService trackedService) { + Log.Add($"test:{trackedService.Id}"); + + ServiceIds.Add(trackedService.Id); + } +} + +public class BRetriedModuleTests { + + private static int _attempts; + + public static readonly List ServiceIds = []; + + [ModuleTest(typeof(LifetimeModule))] + [Retry(3)] + public void EachRetryAttemptGetsItsOwnContainer(TrackedService trackedService) { + ServiceIds.Add(trackedService.Id); + + _attempts++; + + Assert.That(_attempts, Is.EqualTo(3), "fails the first two attempts on purpose, passes the third"); + } +} + +public class CLifetimeReport { + + /// + /// The container has to outlive setup and teardown, not sit between them. Wrapping only the test + /// method would order this setup, open, test, close, teardown — leaving [SetUp] running + /// before the container exists and [TearDown] after it is gone. + /// + [Test] + public void SetUpAndTearDownRunInsideTheContainersLifetime() { + Assert.That(ARepeatedModuleTests.Log, Has.Count.EqualTo(9), "three iterations of setup, test, teardown"); + + for (var i = 0; i < 3; i++) { + Assert.That(ARepeatedModuleTests.Log[i * 3], Is.EqualTo("setup")); + Assert.That(ARepeatedModuleTests.Log[i * 3 + 1], Does.StartWith("test:")); + Assert.That(ARepeatedModuleTests.Log[i * 3 + 2], Is.EqualTo("teardown")); + } + } + + [Test] + public void NoServiceInstanceIsSharedBetweenIterations() { + var repeated = ARepeatedModuleTests.ServiceIds; + var retried = BRetriedModuleTests.ServiceIds; + + Assert.That(repeated, Has.Count.EqualTo(3)); + Assert.That(retried, Has.Count.EqualTo(3)); + + Assert.That(repeated.Concat(retried).Distinct().Count(), Is.EqualTo(6), + "three repetitions and three retry attempts, six containers, six instances"); + } + + [Test] + public void EveryIterationsServicesWereDisposedWithItsContainer() { + var iterationIds = ARepeatedModuleTests.ServiceIds.Concat(BRetriedModuleTests.ServiceIds); + + Assert.That(TrackedService.Disposed, Is.SupersetOf(iterationIds), + "the container is torn down at the end of the iteration, not left to the fixture"); + } +} diff --git a/integ-tests/SutProject.NUnitTests/ModuleLoadingTests.cs b/integ-tests/SutProject.NUnitTests/ModuleLoadingTests.cs new file mode 100644 index 0000000..83f4559 --- /dev/null +++ b/integ-tests/SutProject.NUnitTests/ModuleLoadingTests.cs @@ -0,0 +1,76 @@ +using DependencyModules.NUnit.Attributes; +using DependencyModules.NUnit.Impl; +using DependencyModules.Runtime.Attributes; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace SutProject.NUnitTests; + +[DependencyModule(OnlyRealm = true)] +public partial class ExtraModule { } + +[SingletonService(Realm = typeof(ExtraModule))] +public class ExtraService { } + +/// +/// The two ways a test names its modules, and what a resolved parameter can be. +/// +/// +/// No [TestFixture], deliberately. [ModuleTest] implies a fixture the way +/// [Test] does, so a module test fixture needs no class-level attribute — the same as the +/// xUnit integration. +/// +public class ModuleLoadingTests { + + /// Modules named on the attribute itself. + [ModuleTest(typeof(SutModule))] + public void LoadsAModuleNamedByType(ISingletonService singletonService) { + Assert.That(singletonService, Is.Not.Null); + Assert.That(singletonService.GetName(), Is.EqualTo(nameof(SingletonService))); + } + + /// The generated module attribute, which reaches the same loading by another route. + [ModuleTest] + [SutModule] + public void LoadsAModuleNamedByItsGeneratedAttribute(IDependencyOne dependencyOne) { + Assert.That(dependencyOne.SingletonService, Is.Not.Null); + Assert.That(dependencyOne.ScopedService, Is.Not.Null); + } + + [ModuleTest(typeof(SutModule), typeof(ExtraModule))] + public void LoadsSeveralModules(ISingletonService singletonService, ExtraService extraService) { + Assert.That(singletonService, Is.Not.Null); + Assert.That(extraService, Is.Not.Null); + } + + [ModuleTest] + public void TakesNoModulesAtAll() { + Assert.Pass("a module test need not name a module"); + } + + /// The container itself, which cannot be resolved from itself. + [ModuleTest(typeof(SutModule))] + public void InjectsTheServiceProvider(IServiceProvider serviceProvider) { + Assert.That(serviceProvider.GetService(), Is.Not.Null); + } + + /// + /// An unregistered concrete type the container can still build, which is how a test names the + /// class under test without registering it. + /// + [ModuleTest(typeof(SutModule))] + public void ConstructsAnUnregisteredConcreteType(NeedsASingleton needsASingleton) { + Assert.That(needsASingleton.SingletonService, Is.Not.Null); + } + + [ModuleTest(typeof(SutModule))] + public void PublishesTheTestCaseInfo(ITestCaseInfo testCaseInfo, ISingletonService singletonService) { + Assert.That(testCaseInfo.TestMethod.Name, Is.EqualTo(nameof(PublishesTheTestCaseInfo))); + Assert.That(testCaseInfo.TestMethodArguments, Has.Count.EqualTo(2)); + Assert.That(testCaseInfo.TestMethodArguments[1], Is.SameAs(singletonService)); + } + + public class NeedsASingleton(ISingletonService singletonService) { + public ISingletonService SingletonService { get; } = singletonService; + } +} diff --git a/integ-tests/SutProject.NUnitTests/Moq/MoqTests.cs b/integ-tests/SutProject.NUnitTests/Moq/MoqTests.cs new file mode 100644 index 0000000..e5ff3b3 --- /dev/null +++ b/integ-tests/SutProject.NUnitTests/Moq/MoqTests.cs @@ -0,0 +1,36 @@ +using DependencyModules.Moq; +using DependencyModules.NUnit.Attributes; +using DependencyModules.Testing.Attributes; +using Moq; +using NUnit.Framework; + +namespace SutProject.NUnitTests.Moq; + +/// +/// The Moq package, unchanged, against NUnit. +/// +[MoqSupport] +public class MoqTests { + + [ModuleTest] + [SutModule] + public void MockTest( + [Mock] Mock dependencyOne, ISingletonService singletonService) { + dependencyOne.Setup(mock => mock.SingletonService).Returns(singletonService); + + Assert.That(dependencyOne.Object.SingletonService, Is.SameAs(singletonService)); + } + + /// + /// [TestExport] names a real implementation, and has to beat the mock whichever order the + /// two are declared in. This is the arrangement declaration order alone would get wrong: the + /// mock support is on the class, so it reaches the setup pass first and would otherwise be the + /// later registration to win. + /// + [ModuleTest] + [SutModule] + [TestExport(typeof(ISingletonService), Implementation = typeof(ExportedSingletonService))] + public void TestExportBeatsAMockOfTheSameService(ISingletonService singletonService) { + Assert.That(singletonService, Is.TypeOf()); + } +} diff --git a/integ-tests/SutProject.NUnitTests/NSubstitute/NSubstituteTests.cs b/integ-tests/SutProject.NUnitTests/NSubstitute/NSubstituteTests.cs new file mode 100644 index 0000000..c70916b --- /dev/null +++ b/integ-tests/SutProject.NUnitTests/NSubstitute/NSubstituteTests.cs @@ -0,0 +1,55 @@ +using DependencyModules.NSubstitute; +using DependencyModules.NUnit.Attributes; +using DependencyModules.Testing.Attributes; +using NSubstitute; +using NUnit.Framework; + +namespace SutProject.NUnitTests.NSubstitute; + +/// +/// The NSubstitute package, unchanged, against NUnit. +/// +/// +/// It references no test framework — it implements the hooks in DependencyModules.Testing — +/// so this is the payoff rather than new work: the same [Mock] attribute and the same +/// support attribute an xUnit test uses. Deliberately the same scenario as +/// SutProject.Tests.NSubstitute.NSubstituteAttributeTests, so the two can be read against +/// each other. +/// +[NSubstituteSupport] +public class NSubstituteTests { + + [ModuleTest] + [SutModule] + public void MockTest( + [Mock] IDependencyOne dependencyOne, + [Mock] IScopedService scopedService, + ISingletonService singletonService) { + dependencyOne.SingletonService.Returns(singletonService); + dependencyOne.ScopedService.Returns(scopedService); + + Assert.That(dependencyOne.SingletonService, Is.SameAs(singletonService)); + Assert.That(dependencyOne.ScopedService, Is.SameAs(scopedService)); + } + + /// A mocked service is the one the container hands to everything else, too. + [ModuleTest] + [SutModule] + public void AMockReplacesTheRegistrationForTheWholeContainer( + [Mock] IScopedService scopedService, IDependencyOne dependencyOne) { + Assert.That(dependencyOne.ScopedService, Is.SameAs(scopedService)); + } + + /// + /// Each iteration builds its own container, so a mock configured in one cannot be seen by the + /// next. Written as a repeated test because that is the case a per-case container would break. + /// + [ModuleTest] + [SutModule] + [Repeat(3)] + public void EachIterationGetsAFreshMock([Mock] IScopedService scopedService) { + Assert.That(Seen.Add(scopedService), Is.True, "a mock instance is never reused across iterations"); + } + + private static readonly HashSet Seen = []; +} diff --git a/integ-tests/SutProject.NUnitTests/ServiceProviderBuilderPrecedenceTests.cs b/integ-tests/SutProject.NUnitTests/ServiceProviderBuilderPrecedenceTests.cs new file mode 100644 index 0000000..170cf89 --- /dev/null +++ b/integ-tests/SutProject.NUnitTests/ServiceProviderBuilderPrecedenceTests.cs @@ -0,0 +1,49 @@ +using DependencyModules.NUnit.Attributes; +using DependencyModules.Testing.Attributes.Interfaces; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace SutProject.NUnitTests; + +/// +/// Records which actually built the container. +/// +public interface IProviderBuiltBy { + string Scope { get; } +} + +public class ProviderBuiltBy(string scope) : IProviderBuiltBy { + public string Scope => scope; +} + +/// +/// A builder that stamps the container with the scope it was declared at. +/// +public class ScopeStampingProviderAttribute(string scope) : Attribute, IServiceProviderBuilderAttribute { + public IServiceProvider BuildServiceProvider( + ITestMethodContext testMethod, IServiceCollection serviceCollection) { + serviceCollection.AddSingleton(new ProviderBuiltBy(scope)); + + return serviceCollection.BuildServiceProvider(); + } +} + +/// +/// Only one builder is used, so which one has to be pinned. The narrowest declaration wins, matching +/// how every other test attribute resolves — a method that asks for a particular container is not +/// overridden by a broader default. +/// +[ScopeStampingProvider("class")] +public class ServiceProviderBuilderPrecedenceTests { + + [ModuleTest] + [ScopeStampingProvider("method")] + public void MethodBeatsClass(IProviderBuiltBy builtBy) { + Assert.That(builtBy.Scope, Is.EqualTo("method")); + } + + [ModuleTest] + public void ClassAppliesWhenTheMethodDeclaresNone(IProviderBuiltBy builtBy) { + Assert.That(builtBy.Scope, Is.EqualTo("class")); + } +} diff --git a/integ-tests/SutProject.NUnitTests/SutProject.NUnitTests.csproj b/integ-tests/SutProject.NUnitTests/SutProject.NUnitTests.csproj new file mode 100644 index 0000000..bd2902f --- /dev/null +++ b/integ-tests/SutProject.NUnitTests/SutProject.NUnitTests.csproj @@ -0,0 +1,31 @@ + + + + $(LibraryTargetFrameworks) + enable + enable + False + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/integ-tests/SutProject.NUnitTests/TestExportTests.cs b/integ-tests/SutProject.NUnitTests/TestExportTests.cs new file mode 100644 index 0000000..24f5afb --- /dev/null +++ b/integ-tests/SutProject.NUnitTests/TestExportTests.cs @@ -0,0 +1,49 @@ +using DependencyModules.NUnit.Attributes; +using DependencyModules.Testing.Attributes; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace SutProject.NUnitTests; + +/// +/// Stands in for the real singleton, so a test can tell which of two registrations survived. +/// +public class ExportedSingletonService : ISingletonService { + public string GetName() => nameof(ExportedSingletonService); +} + +/// +/// [TestExport] with no mocking package involved. +/// +/// +/// The attribute now lives in DependencyModules.Testing rather than in an integration, which +/// is why it is available here at all — it registers through ITestServiceSetupAttribute and +/// never needed a test framework. +/// +public class TestExportTests { + + [ModuleTest] + [SutModule] + [TestExport(typeof(ISingletonService), Implementation = typeof(ExportedSingletonService))] + public void OverridesARegistrationForOneTest(ISingletonService singletonService) { + Assert.That(singletonService, Is.TypeOf()); + } + + /// + /// The override is scoped to the test that asked for it. Nothing tears it down explicitly — + /// the container it was registered in no longer exists. + /// + [ModuleTest] + [SutModule] + public void TheOverrideDoesNotLeakIntoTheNextTest(ISingletonService singletonService) { + Assert.That(singletonService, Is.TypeOf()); + } + + [ModuleTest] + [SutModule] + [TestExport(typeof(ISingletonService), Implementation = typeof(ExportedSingletonService), + Lifetime = ServiceLifetime.Singleton)] + public void HonoursTheLifetimeItIsGiven(ISingletonService first, IServiceProvider serviceProvider) { + Assert.That(serviceProvider.GetRequiredService(), Is.SameAs(first)); + } +} diff --git a/integ-tests/SutProject.Tests/ConventionTests/ConventionEdgeModules.cs b/integ-tests/SutProject.Tests/ConventionTests/ConventionEdgeModules.cs index 64f29a3..7006567 100644 --- a/integ-tests/SutProject.Tests/ConventionTests/ConventionEdgeModules.cs +++ b/integ-tests/SutProject.Tests/ConventionTests/ConventionEdgeModules.cs @@ -1,4 +1,4 @@ -using DependencyModules.Conventions; +using DependencyModules.Runtime.Conventions; using DependencyModules.Runtime.Attributes; using SecondarySutProject; using SutProject.Tests.ConventionTests.Nested; @@ -67,6 +67,7 @@ void IConventionModule.Conventions(IConventionDefinitions conventions) { public partial class ConventionSharedSecondModule : IConventionModule { void IConventionModule.Conventions(IConventionDefinitions conventions) { conventions.RegisterAll().WithName("SharedSecond").AsSingleton(); + conventions.RegisterAll().WithoutName("SharedFirst").AsSingleton(); } } diff --git a/integ-tests/SutProject.Tests/ConventionTests/ConventionFeatureModules.cs b/integ-tests/SutProject.Tests/ConventionTests/ConventionFeatureModules.cs index 0535eec..1146c6a 100644 --- a/integ-tests/SutProject.Tests/ConventionTests/ConventionFeatureModules.cs +++ b/integ-tests/SutProject.Tests/ConventionTests/ConventionFeatureModules.cs @@ -1,4 +1,4 @@ -using DependencyModules.Conventions; +using DependencyModules.Runtime.Conventions; using DependencyModules.Runtime.Attributes; using SecondarySutProject; diff --git a/integ-tests/SutProject.Tests/ConventionTests/ConventionInteractionModules.cs b/integ-tests/SutProject.Tests/ConventionTests/ConventionInteractionModules.cs index 73735b9..56374ff 100644 --- a/integ-tests/SutProject.Tests/ConventionTests/ConventionInteractionModules.cs +++ b/integ-tests/SutProject.Tests/ConventionTests/ConventionInteractionModules.cs @@ -1,4 +1,4 @@ -using DependencyModules.Conventions; +using DependencyModules.Runtime.Conventions; using DependencyModules.Runtime.Attributes; using DependencyModules.Runtime.Interception; diff --git a/integ-tests/SutProject.Tests/ConventionTests/ConventionModules.cs b/integ-tests/SutProject.Tests/ConventionTests/ConventionModules.cs index 4a20cac..5285351 100644 --- a/integ-tests/SutProject.Tests/ConventionTests/ConventionModules.cs +++ b/integ-tests/SutProject.Tests/ConventionTests/ConventionModules.cs @@ -1,4 +1,4 @@ -using DependencyModules.Conventions; +using DependencyModules.Runtime.Conventions; using DependencyModules.Runtime.Attributes; namespace SutProject.Tests.ConventionTests; diff --git a/integ-tests/SutProject.Tests/Customization/CustomDependencyTestCase.cs b/integ-tests/SutProject.Tests/Customization/CustomDependencyTestCase.cs index 9c98698..cb42dd2 100644 --- a/integ-tests/SutProject.Tests/Customization/CustomDependencyTestCase.cs +++ b/integ-tests/SutProject.Tests/Customization/CustomDependencyTestCase.cs @@ -7,6 +7,6 @@ public class CustomDependencyTestCase { [ModuleTest] [CustomServiceProvider] public void TestCase(ICustomTestDependency dependency) { - //Assert.NotNull(dependency); + Assert.NotNull(dependency); } } \ No newline at end of file diff --git a/integ-tests/SutProject.Tests/Customization/ServiceProviderBuilderPrecedenceTests.cs b/integ-tests/SutProject.Tests/Customization/ServiceProviderBuilderPrecedenceTests.cs new file mode 100644 index 0000000..07c0ea8 --- /dev/null +++ b/integ-tests/SutProject.Tests/Customization/ServiceProviderBuilderPrecedenceTests.cs @@ -0,0 +1,51 @@ +using DependencyModules.Testing.Attributes.Interfaces; +using DependencyModules.xUnit.Attributes; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace SutProject.Tests.Customization; + +/// +/// Records which actually built the container. +/// +public interface IProviderBuiltBy { + string Scope { get; } +} + +public class ProviderBuiltBy(string scope) : IProviderBuiltBy { + public string Scope => scope; +} + +/// +/// A builder that stamps the container with the scope it was declared at. +/// +public class ScopeStampingProviderAttribute(string scope) : Attribute, IServiceProviderBuilderAttribute { + public string Scope => scope; + + public IServiceProvider BuildServiceProvider( + ITestMethodContext testMethod, IServiceCollection serviceCollection) { + serviceCollection.AddSingleton(new ProviderBuiltBy(scope)); + + return serviceCollection.BuildServiceProvider(); + } +} + +/// +/// Only one builder is used, so which one has to be pinned. The narrowest declaration wins, matching +/// how every other test attribute resolves — a method that asks for a particular container is not +/// overridden by a broader default. +/// +[ScopeStampingProvider("class")] +public class ServiceProviderBuilderPrecedenceTests { + + [ModuleTest] + [ScopeStampingProvider("method")] + public void MethodBeatsClass(IProviderBuiltBy builtBy) { + Assert.Equal("method", builtBy.Scope); + } + + [ModuleTest] + public void ClassAppliesWhenTheMethodDeclaresNone(IProviderBuiltBy builtBy) { + Assert.Equal("class", builtBy.Scope); + } +} diff --git a/integ-tests/SutProject.Tests/FakeItEasy/FakeItEasyAttributeTests.cs b/integ-tests/SutProject.Tests/FakeItEasy/FakeItEasyAttributeTests.cs index fe46e4c..cc8e16d 100644 --- a/integ-tests/SutProject.Tests/FakeItEasy/FakeItEasyAttributeTests.cs +++ b/integ-tests/SutProject.Tests/FakeItEasy/FakeItEasyAttributeTests.cs @@ -1,4 +1,5 @@ using DependencyModules.FakeItEasy; +using DependencyModules.Testing.Attributes; using DependencyModules.xUnit.Attributes; using FakeItEasy; using Xunit; diff --git a/integ-tests/SutProject.Tests/Moq/MoqAttributeTests.cs b/integ-tests/SutProject.Tests/Moq/MoqAttributeTests.cs index 7778ded..c3659dc 100644 --- a/integ-tests/SutProject.Tests/Moq/MoqAttributeTests.cs +++ b/integ-tests/SutProject.Tests/Moq/MoqAttributeTests.cs @@ -1,4 +1,5 @@ using DependencyModules.Moq; +using DependencyModules.Testing.Attributes; using DependencyModules.xUnit.Attributes; using Moq; using Xunit; diff --git a/integ-tests/SutProject.Tests/Moq/MoqSetupOrderingTests.cs b/integ-tests/SutProject.Tests/Moq/MoqSetupOrderingTests.cs new file mode 100644 index 0000000..b4c52f8 --- /dev/null +++ b/integ-tests/SutProject.Tests/Moq/MoqSetupOrderingTests.cs @@ -0,0 +1,54 @@ +using DependencyModules.Moq; +using DependencyModules.Testing.Attributes; +using DependencyModules.xUnit.Attributes; +using Moq; +using Xunit; + +namespace SutProject.Tests.Moq; + +/// +/// A real implementation, so a test can tell it apart from a mock of the same service. +/// +/// +/// Declared outside the fixture because the attribute naming it sits on the fixture itself, and +/// attribute arguments there resolve in the enclosing scope rather than the class's own. +/// +public class ExportedSingletonService : ISingletonService { + public string GetName() => "exported"; +} + +/// +/// Pins which of [TestExport] and [MoqSupport] wins when they disagree. +/// +/// +/// Both register through ITestServiceSetupAttribute, so they run in one pass over the +/// attributes in scope and the later registration wins. Attributes reach that pass widest scope +/// first — assembly, then class, then method — so declaration order alone would hand the outcome to +/// whichever of the two happens to sit nearer the method. +/// +/// This is the arrangement where that disagrees with what should happen: [TestExport] on the +/// class, [MoqSupport] on the method. Left to declaration order the mock would register last +/// and win, quietly discarding an explicit registration. ModuleTestCase sorts mock support to +/// the front of the pass so that it cannot — a mock is the stand-in a test falls back to, and naming +/// a real implementation has to beat it. +/// +/// covers the opposite arrangement, +/// which agrees with declaration order and so does not exercise the sort at all. Remove the sort and +/// that test still passes; this one does not. +/// +[TestExport(typeof(ISingletonService), Implementation = typeof(ExportedSingletonService))] +public class MoqSetupOrderingTests { + + [ModuleTest] + [SutModule] + [MoqSupport] + public void ExplicitRegistrationBeatsAMockDeclaredNearerTheMethod( + ISingletonService instance, Mock mock) { + Assert.IsType(instance); + Assert.Equal("exported", instance.GetName()); + + // The mock was still made and registered — this is the sort deciding which registration the + // service resolves to, not mock support failing to run. + Assert.NotSame(mock.Object, instance); + } +} diff --git a/integ-tests/SutProject.Tests/NSubstitute/NSubstituteAttributeTests.cs b/integ-tests/SutProject.Tests/NSubstitute/NSubstituteAttributeTests.cs index 1652682..34a2b4c 100644 --- a/integ-tests/SutProject.Tests/NSubstitute/NSubstituteAttributeTests.cs +++ b/integ-tests/SutProject.Tests/NSubstitute/NSubstituteAttributeTests.cs @@ -1,3 +1,4 @@ +using DependencyModules.Testing.Attributes; using DependencyModules.xUnit.Attributes; using DependencyModules.NSubstitute; using NSubstitute; diff --git a/integ-tests/SutProject.Tests/SutProject.Tests.csproj b/integ-tests/SutProject.Tests/SutProject.Tests.csproj index dc96e18..f341eec 100644 --- a/integ-tests/SutProject.Tests/SutProject.Tests.csproj +++ b/integ-tests/SutProject.Tests/SutProject.Tests.csproj @@ -16,7 +16,6 @@ - diff --git a/integ-tests/web/WebApiApp.Tests/WeatherTests.cs b/integ-tests/web/WebApiApp.Tests/WeatherTests.cs index 596c82c..5ff7970 100644 --- a/integ-tests/web/WebApiApp.Tests/WeatherTests.cs +++ b/integ-tests/web/WebApiApp.Tests/WeatherTests.cs @@ -1,3 +1,4 @@ +using DependencyModules.Testing.Attributes; using DependencyModules.xUnit.Attributes; using NSubstitute; using Xunit; diff --git a/scripts/coverage.sh b/scripts/coverage.sh index ca8116b..f1daffa 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -27,6 +27,7 @@ mkdir -p "${OUT}" "${RAW}" PROJECTS=( "tests/DependencyModules.Tests/DependencyModules.Tests.csproj" "integ-tests/SutProject.Tests/SutProject.Tests.csproj" + "integ-tests/SutProject.NUnitTests/SutProject.NUnitTests.csproj" "integ-tests/web/WebApiApp.Tests/WebApiApp.Tests.csproj" ) @@ -70,7 +71,7 @@ reportgenerator \ "-targetdir:${OUT}" \ "-reporttypes:Html;Cobertura;TextSummary;MarkdownSummaryGithub;Badges" \ "-title:DependencyModules" \ - "-assemblyfilters:+DependencyModules.Runtime;+DependencyModules.SourceGenerator;+DependencyModules.Conventions;+DependencyModules.Testing;+DependencyModules.xUnit;+DependencyModules.NSubstitute;+DependencyModules.Moq;+DependencyModules.FakeItEasy" \ + "-assemblyfilters:+DependencyModules.Runtime;+DependencyModules.SourceGenerator;+DependencyModules.Testing;+DependencyModules.xUnit;+DependencyModules.NUnit;+DependencyModules.NSubstitute;+DependencyModules.Moq;+DependencyModules.FakeItEasy" \ "-classfilters:-CSharpAuthor.*" \ >/dev/null diff --git a/scripts/verify-packages.sh b/scripts/verify-packages.sh index 28f96f4..9559a95 100755 --- a/scripts/verify-packages.sh +++ b/scripts/verify-packages.sh @@ -36,9 +36,9 @@ for proj in \ src/DependencyModules.Runtime/DependencyModules.Runtime.csproj \ src/DependencyModules.SourceGenerator/DependencyModules.SourceGenerator.csproj \ src/DependencyModules.SourceGenerator.Impl/DependencyModules.SourceGenerator.Impl.csproj \ - src/DependencyModules.Conventions/DependencyModules.Conventions.csproj \ src/DependencyModules.Testing/DependencyModules.Testing.csproj \ src/DependencyModules.xUnit/DependencyModules.xUnit.csproj \ + src/DependencyModules.NUnit/DependencyModules.NUnit.csproj \ src/DependencyModules.NSubstitute/DependencyModules.NSubstitute.csproj \ src/DependencyModules.Moq/DependencyModules.Moq.csproj \ src/DependencyModules.FakeItEasy/DependencyModules.FakeItEasy.csproj; do @@ -49,7 +49,7 @@ done echo "==> Checking package layout" # NuGet only auto-imports build/.props|targets at that exact path. -for id in DependencyModules.SourceGenerator DependencyModules.SourceGenerator.Impl DependencyModules.Conventions; do +for id in DependencyModules.SourceGenerator DependencyModules.SourceGenerator.Impl; do entries="$(unzip -Z1 "${FEED}/${id}.${VERSION}.nupkg")" for ext in props targets; do grep -qx "build/${id}.${ext}" <<<"${entries}" \ @@ -59,18 +59,16 @@ for id in DependencyModules.SourceGenerator DependencyModules.SourceGenerator.Im pass "${id} build/ files are at the convention path" done -# The analyzers must ship where Roslyn looks for them. -for id in DependencyModules.SourceGenerator DependencyModules.Conventions; do - unzip -Z1 "${FEED}/${id}.${VERSION}.nupkg" \ - | grep -qx "analyzers/dotnet/cs/${id}.dll" \ - || fail "${id}: analyzer assembly is not at analyzers/dotnet/cs/" +# The analyzer must ship where Roslyn looks for it. +unzip -Z1 "${FEED}/DependencyModules.SourceGenerator.${VERSION}.nupkg" \ + | grep -qx "analyzers/dotnet/cs/DependencyModules.SourceGenerator.dll" \ + || fail "DependencyModules.SourceGenerator: analyzer assembly is not at analyzers/dotnet/cs/" - # No lib/. An analyzer that reaches a consumer's output ships compiler machinery to run time. - unzip -Z1 "${FEED}/${id}.${VERSION}.nupkg" | grep -q '^lib/' \ - && fail "${id}: ships a lib/ folder, so the analyzer would flow to consumer output" +# No lib/. An analyzer that reaches a consumer's output ships compiler machinery to run time. +unzip -Z1 "${FEED}/DependencyModules.SourceGenerator.${VERSION}.nupkg" | grep -q '^lib/' \ + && fail "DependencyModules.SourceGenerator: ships a lib/ folder, so the analyzer would flow to consumer output" - pass "${id} analyzer assembly is at analyzers/dotnet/cs/ with no lib/" -done +pass "DependencyModules.SourceGenerator analyzer assembly is at analyzers/dotnet/cs/ with no lib/" # The shipping libraries multi-target. A missing lib/ folder means one TFM quietly stopped being # produced, and consumers on it would resolve no assembly at all. @@ -78,6 +76,7 @@ LIB_PACKAGES=( DependencyModules.Runtime DependencyModules.Testing DependencyModules.xUnit + DependencyModules.NUnit DependencyModules.NSubstitute DependencyModules.Moq DependencyModules.FakeItEasy @@ -122,13 +121,11 @@ for pkg in "${FEED}"/*.nupkg; do done pass "all packages carry real description/readme/license metadata" -# The generators are a compile-time concern; Roslyn is supplied by the host. -for id in DependencyModules.SourceGenerator DependencyModules.Conventions; do - unzip -p "${FEED}/${id}.${VERSION}.nupkg" "${id}.nuspec" \ - | grep -q 'id="Microsoft.CodeAnalysis' \ - && fail "${id} leaks a Microsoft.CodeAnalysis dependency to consumers" - pass "${id} does not leak compiler dependencies" -done +# The generator is a compile-time concern; Roslyn is supplied by the host. +unzip -p "${FEED}/DependencyModules.SourceGenerator.${VERSION}.nupkg" "DependencyModules.SourceGenerator.nuspec" \ + | grep -q 'id="Microsoft.CodeAnalysis' \ + && fail "DependencyModules.SourceGenerator leaks a Microsoft.CodeAnalysis dependency to consumers" +pass "DependencyModules.SourceGenerator does not leak compiler dependencies" for TFM in "${TFMS[@]}"; do @@ -182,7 +179,6 @@ cat >"${APP}/ConsumerApp.csproj" < - @@ -190,9 +186,9 @@ cat >"${APP}/ConsumerApp.csproj" <"${APP}/Program.cs" <<'EOF' -using DependencyModules.Conventions; using DependencyModules.Runtime; using DependencyModules.Runtime.Attributes; +using DependencyModules.Runtime.Conventions; using Microsoft.Extensions.DependencyInjection; namespace ConsumerApp; @@ -273,12 +269,18 @@ grep -rq 'PopulateServiceCollection' "${APP}/generated" \ || fail "generated output is missing the module registration code" pass "generator emitted module registration code" -# The convention analyzer is a separate package and loads independently of the main one. +# Conventions are generated by DependencyModules.SourceGenerator rather than by a package of their +# own, so a consumer referencing only the one analyzer package must still get them. grep -rq 'ConventionDependencies' "${APP}/generated" \ || fail "convention generator produced no registrations in the consumer project" +pass "convention registrations are generated by the main analyzer package" + +# The contracts are DependencyModules.Runtime's public API. Emitting them into the consumer as well +# is what previously forced them to be internal, and made CS0436 unavoidable between two assemblies +# that both emitted them and referenced each other. grep -rq 'interface IConventionModule' "${APP}/generated" \ - || fail "convention contract types were not emitted into the consumer project" -pass "convention generator emitted its contracts and registrations" + && fail "convention contracts were emitted into the consumer; they ship in DependencyModules.Runtime" +pass "convention contracts are not duplicated into the consumer" # ExcludeGeneratedCodeFromCoverage=false must reach the generator via build/*.targets. if grep -rq 'ExcludeFromCodeCoverage' "${APP}/generated"; then diff --git a/src/DependencyModules.Conventions/ConventionContractSource.cs b/src/DependencyModules.Conventions/ConventionContractSource.cs deleted file mode 100644 index aa83c34..0000000 --- a/src/DependencyModules.Conventions/ConventionContractSource.cs +++ /dev/null @@ -1,455 +0,0 @@ -namespace DependencyModules.Conventions; - -/// -/// The types a module implements to declare conventions, emitted into the consumer's compilation -/// through RegisterPostInitializationOutput. -/// -/// -/// Shipped as source rather than in a runtime package for two reasons. They carry no behaviour — -/// nothing ever implements , because the generator reads the -/// declarations out of the method body at compile time and the body never runs — so a runtime -/// assembly would contain nothing but empty contracts. And emitting them internal keeps -/// them off the consumer's public surface, so declaring conventions adds nothing to their API. -/// -/// This is a fixed contract rather than something built from a model, so it is a literal rather -/// than CSharpAuthor output. Every other writer in this codebase generates from a model and uses -/// CSharpAuthor; this one has no model. -/// -public static class ConventionContractSource { - - /// - /// The hint name the contract is added under. - /// - public const string HintName = "DependencyModules.Conventions.Contracts.g.cs"; - - /// - /// The namespace the emitted types live in, and the metadata prefix the generator matches - /// declarations against. - /// - public const string Namespace = "DependencyModules.Conventions"; - - /// - /// The interface a module implements to opt into convention registration. - /// - public const string ConventionModule = "IConventionModule"; - - /// - /// The method the generator reads. Implemented explicitly, so the name is fixed. - /// - public const string ConventionMethod = "Conventions"; - - public const string Source = - """ - // - #nullable enable - - namespace DependencyModules.Conventions { - - /// - /// Implement this on a [DependencyModule] class to register services by convention - /// instead of attributing each one. - /// - /// - /// - /// The body of Conventions is never executed. It is read by the - /// DependencyModules.Conventions source generator at compile time, which resolves the - /// matching types and emits ordinary registrations. Nothing implements - /// at run time and the method is never called. - /// - /// - /// Because it is configuration rather than code, only a chain of the calls declared on - /// and can - /// appear in it. Loops, conditionals, locals and calls to your own helpers cannot be - /// evaluated at compile time and are reported as DM0009 rather than ignored. - /// - /// - /// Implement it explicitly. An ordinary public implementation does not compile, because - /// the parameter type is internal (CS0051). - /// - /// - /// - /// [DependencyModule] - /// public partial class DataModule : IConventionModule { - /// void IConventionModule.Conventions(IConventionDefinitions conventions) { - /// conventions.RegisterAll<IRepository>().AsScoped(); - /// conventions.RegisterAll(typeof(IRequestHandler<,>)).AsTransient(); - /// } - /// } - /// - /// - /// - [global::System.CodeDom.Compiler.GeneratedCode("DependencyModules.Conventions", "1.0.0")] - internal interface IConventionModule { - - /// - /// Declares this module's conventions. Read at compile time; never invoked. - /// - /// Receives the declarations. - void Conventions(IConventionDefinitions conventions); - } - - /// - /// Declares which types a module registers by convention. - /// - /// - /// Nothing implements this. The calls made on it are read from source at compile time. - /// - [global::System.CodeDom.Compiler.GeneratedCode("DependencyModules.Conventions", "1.0.0")] - internal interface IConventionDefinitions { - - /// - /// Registers every type in this compilation that implements - /// , as . - /// - /// - /// Matches a type that declares directly, and one - /// that declares an interface extending it — an interface saying it extends another - /// is a deliberate statement that it is substitutable for it. Reaching a service - /// type through a base class is not matched unless - /// is called, because - /// extending a class is a statement about implementation reuse rather than about - /// the contract, and every subclass added later would silently join the convention. - /// - /// A type carrying an explicit service attribute is never a convention candidate; - /// the attribute always wins. - /// - /// The service type to scan for and register as. - IConventionRegistration RegisterAll(); - - /// - /// The overload for open generics, which cannot - /// be written as a type argument: RegisterAll(typeof(IHandler<,>)). - /// - /// - /// Each match is registered against the closed interface it implements, so - /// CreateOrderHandler : IHandler<CreateOrder, OrderId> registers - /// IHandler<CreateOrder, OrderId>. A generic implementation that closes - /// nothing registers as the open generic. - /// - /// The service type to scan for, open or closed. - IConventionRegistration RegisterAll(global::System.Type serviceType); - - /// - /// Registers types selected by something other than the service they implement. - /// - /// - /// - /// With no service type there is nothing to register the matches as, so this - /// form requires or - /// . It is how a concrete - /// class that implements no interface gets registered by convention. - /// - /// - /// It also requires at least one filter. Without one it would match every class in - /// the compilation, which is never what anybody means and is reported rather than - /// obeyed. - /// - /// - /// - /// conventions.RegisterAll().InNamespaceOf<OrderMarker>().AsSelf().AsScoped(); - /// - /// - /// - IConventionRegistration RegisterAll(); - } - - /// - /// Configures what a RegisterAll declaration produces. - /// - /// - /// A lifetime is required. There is no default, because a lifetime nobody wrote down is - /// the most expensive thing for a registration to get wrong; omitting one is DM0009. - /// - [global::System.CodeDom.Compiler.GeneratedCode("DependencyModules.Conventions", "1.0.0")] - internal interface IConventionRegistration { - - /// Registers the matches as singletons. - IConventionRegistration AsSingleton(); - - /// Registers the matches as scoped. - IConventionRegistration AsScoped(); - - /// Registers the matches as transient. - IConventionRegistration AsTransient(); - - /// - /// Also matches types that reach the service type only through a base class. - /// - /// - /// Off by default. Turning it on picks up the common - /// CreateOrderValidator : AbstractValidator<CreateOrder> shape, where - /// the interface is declared on a base class, at the cost of every future subclass - /// of that base joining the convention without anyone revisiting it. - /// - IConventionRegistration IncludeBaseClasses(); - - /// - /// Registers each match as its own concrete type rather than as the service type. - /// - /// - /// RegisterAll<IHandler>().AsSelf() puts CreateOrderHandler in - /// the container under CreateOrderHandler, not under IHandler. - /// - IConventionRegistration AsSelf(); - - /// - /// Registers each match as the service type the convention matched and as its - /// own concrete type, sharing one instance between them. - /// - /// - /// - /// Additive, where AsSelf replaces: AsSelf means "instead of the - /// interface", this means "as well as it". Only the interfaces the convention - /// matched are registered, not every interface the type can reach — that is - /// . - /// - /// - /// The shape FluentValidation wants. It registers each validator as - /// IValidator<T> and as the concrete type, independently, which hands - /// you two instances per scope; cross-wiring them gives one, which is the better - /// behaviour and a deliberate difference. - /// - /// - /// - /// conventions.RegisterAll(typeof(IValidator<>)) - /// .IncludeBaseClasses() - /// .AlsoAsSelf() - /// .AsScoped(); - /// - /// - /// - IConventionRegistration AlsoAsSelf(); - - /// - /// Registers each match as its own type and as every interface it implements, - /// sharing one instance between them. - /// - /// - /// The same contract as [CrossWireService]: resolving the concrete type and - /// resolving any of its interfaces gives the same instance, rather than one instance - /// per service type as two independent registrations would. - /// - IConventionRegistration AsSelfWithInterfaces(); - - /// - /// Limits matches to the namespace of and the - /// namespaces beneath it. - /// - /// - /// A type argument rather than a string, so a namespace that does not exist cannot - /// be named. Several namespace filters combine with or. - /// - /// Any type in the namespace to scan. - IConventionRegistration InNamespaceOf(); - - /// - /// Limits matches to the given namespaces and the namespaces beneath them. - /// - /// Namespaces to include. - IConventionRegistration InNamespaces(params string[] namespaces); - - /// - /// Limits matches to exactly the given namespaces, excluding those beneath them. - /// - /// Namespaces to include. - IConventionRegistration InExactNamespaces(params string[] namespaces); - - /// - /// Excludes the namespace of and those beneath it. - /// - /// - /// Exclusions are applied after inclusions, and a match excluded by any of them is - /// out however many inclusions it satisfied. - /// - /// Any type in the namespace to exclude. - IConventionRegistration NotInNamespaceOf(); - - /// - /// Excludes the given namespaces and those beneath them. - /// - /// Namespaces to exclude. - IConventionRegistration NotInNamespaces(params string[] namespaces); - - /// - /// Chooses how each match is added to the service collection. - /// - /// - /// The same choice [SingletonService(Using = ...)] makes, and the answer to - /// Scrutor's RegistrationStrategy: Try skips a service type already - /// registered, TryEnumerable allows several implementations of one service, - /// and Replace takes over an existing registration. Defaults to Add. - /// - /// How to add the registration. - IConventionRegistration Using( - global::DependencyModules.Runtime.Attributes.RegistrationType registrationType); - - /// - /// Registers every match under a service key. - /// - /// - /// The key is written into the registration as you wrote it, so a string literal, a - /// const or an enum member all work. Every match of this convention shares - /// the key — there is no way to vary it per type, because that would take a lambda - /// over types the generator is only describing. - /// - /// The service key. - IConventionRegistration WithKey(object key); - - /// - /// Limits matches to types carrying . - /// - /// - /// The attribute is resolved, not matched on how it was written, so a - /// namespace-qualified usage and an alias both count. Several attribute filters - /// combine with and. - /// - /// The attribute to require. - IConventionRegistration WithAttribute() - where TAttribute : global::System.Attribute; - - /// - /// Excludes types carrying . - /// - /// The attribute to exclude on. - IConventionRegistration WithoutAttribute() - where TAttribute : global::System.Attribute; - - /// - /// Limits matches to types whose name fits one of the given patterns. - /// - /// - /// - /// Two wildcards and no regex: * matches zero or more characters, ? - /// matches exactly one. A pattern containing a dot is matched against the full - /// Namespace.TypeName; otherwise against the bare type name. Matching is - /// ordinal and case-sensitive, like C# identifiers. - /// - /// - /// The weakest selector here, and deliberately last. It is the one most likely to - /// match something nobody intended when a class is added years later — prefer - /// RegisterAll<T>, an attribute, or a namespace. - /// - /// - /// Name patterns to include; several combine with or. - IConventionRegistration WithName(params string[] patterns); - - /// - /// Excludes types whose name fits one of the given patterns. - /// - /// Name patterns to exclude. - IConventionRegistration WithoutName(params string[] patterns); - - /// - /// Registers the matches only when the environment name is one of - /// . - /// - /// - /// - /// The test runs when the modules are applied, not while the build runs, so this - /// changes what is registered rather than what the convention matched. Every match - /// is still emitted, behind the same guard. - /// - /// - /// A condition here combines with and against any - /// [IfEnvironment] on a matched class, so neither can silently override the - /// other. Conditions of different kinds also combine with and; alternatives go - /// inside one call. - /// - /// - /// - /// Accepted names, compared case-insensitively to match - /// IHostEnvironment.IsDevelopment(). - /// - IConventionRegistration IfEnvironment(params string[] environmentNames); - - /// - /// Registers the matches only when the environment name is none of - /// . - /// - /// Names to exclude, compared case-insensitively. - IConventionRegistration IfNotEnvironment(params string[] environmentNames); - - /// - /// Registers the matches only when the environment carries a value for - /// . - /// - /// The key that must be present. - IConventionRegistration IfEnvironmentValue(string key); - - /// - /// Registers the matches only when the environment's value for - /// equals . - /// - /// The key to read. - /// The value it must equal, compared ordinally. - IConventionRegistration IfEnvironmentValue(string key, string value); - - /// - /// Registers the matches only when the environment carries no value for - /// . - /// - /// The key that must be absent. - IConventionRegistration IfNotEnvironmentValue(string key); - - /// - /// Registers the matches only when the environment's value for - /// does not equal . - /// - /// The key to read. - /// The value it must not equal, compared ordinally. - IConventionRegistration IfNotEnvironmentValue(string key, string value); - - /// - /// Registers every match as , whatever it matched - /// through. - /// - /// The service type to register as. - IConventionRegistration As(); - - /// - /// Scans the assembly lives in, instead of the - /// project being built. - /// - /// - /// - /// For registering types out of a package you do not own. A project you do - /// own is better served by giving it its own module with its own conventions and - /// composing through module attributes — explicit, ordered, and cross-assembly by - /// construction. - /// - /// - /// The assembly is always named, and named by a type rather than a string, so an - /// assembly that is not referenced cannot be asked for. There is deliberately no - /// "scan everything I depend on": measured, walking every reference visits 5,350 - /// types where one named assembly visits 10, on every keystroke. - /// - /// - /// Only public types are visible across an assembly boundary, where a scan of - /// the project being built also sees internal ones. Nothing can report the - /// internal type it cannot see, so this is a difference to know rather than - /// one that can be diagnosed. - /// - /// - /// - /// conventions.RegisterAll(typeof(IHandler<,>)) - /// .InAssemblyOf<SomeTypeInThatPackage>() - /// .AsScoped(); - /// - /// - /// - /// Any type in the assembly to scan. - IConventionRegistration InAssemblyOf(); - - /// - /// Registers each match as the interface named after it — Foo as - /// IFoo. - /// - /// - /// A match that implements no such interface is skipped rather than registered some - /// other way, since the convention asked for one specific shape. - /// - IConventionRegistration AsMatchingInterface(); - } - } - """; -} diff --git a/src/DependencyModules.Conventions/ConventionSourceGenerator.cs b/src/DependencyModules.Conventions/ConventionSourceGenerator.cs deleted file mode 100644 index 32f0e86..0000000 --- a/src/DependencyModules.Conventions/ConventionSourceGenerator.cs +++ /dev/null @@ -1,225 +0,0 @@ -using System.Collections.Immutable; -using System.Text; -using DependencyModules.Conventions.Models; -using DependencyModules.Conventions.Utilities; -using DependencyModules.SourceGenerator.Impl; -using DependencyModules.SourceGenerator.Impl.Models; -using DependencyModules.SourceGenerator.Impl.Utilities; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Text; - -namespace DependencyModules.Conventions; - -/// -/// Registers services declared by convention on a module implementing IConventionModule. -/// -/// -/// Ships as its own analyzer assembly rather than as another generator inside -/// DependencyModules.SourceGenerator, so a project that does not use conventions never loads the -/// class-scanning providers. It reuses that package's module discovery and emission by compiling in -/// the shared Impl sources; Impl declares no [Generator] of its own, so nothing is registered -/// twice when both packages are referenced. -/// -[Generator] -public class ConventionSourceGenerator : BaseSourceGenerator { - - protected override IEnumerable AttributeSourceGenerators() { - yield return new ConventionGenerator(); - } - - // SetupRootGenerator is deliberately not overridden. DependencyModules.SourceGenerator owns the - // module partial; emitting it from here too would declare every module twice. -} - -/// -/// The convention half: discovery, matching and emission. -/// -public class ConventionGenerator : IDependencyModuleSourceGenerator { - - private const string LoggerName = "ConventionSourceGenerator"; - - public void SetupGenerator( - IncrementalGeneratorInitializationContext context, - IncrementalValuesProvider<(ModuleEntryPointModel Left, DependencyModuleConfigurationModel Right)> incrementalValueProvider) { - - context.RegisterPostInitializationOutput(postInitContext => - postInitContext.AddSource( - ConventionContractSource.HintName, - SourceText.From(ConventionContractSource.Source, Encoding.UTF8))); - - // Interface implementation rather than an attribute, so this cannot be - // ForAttributeWithMetadataName. The predicate rejects on node type and base list before - // looking at anything, which is the cheap kind of scan — the same reason module discovery - // is still a syntax provider. - // Lambdas rather than method groups: SyntaxTransformContext converts implicitly from - // GeneratorSyntaxContext, but a method group conversion will not apply a user-defined - // conversion to a parameter. - var conventionModules = context.SyntaxProvider - .CreateSyntaxProvider( - ConventionModelUtility.IsConventionModuleCandidate, - (syntaxContext, cancellation) => - ConventionModelUtility.GetConventionModuleModel(syntaxContext, cancellation)) - .Where(model => !model.IsIgnored) - .Collect(); - - var candidates = context.SyntaxProvider - .CreateSyntaxProvider( - ConventionCandidateUtility.IsCandidate, - (syntaxContext, cancellation) => - ConventionCandidateUtility.GetCandidateModel(syntaxContext, cancellation)) - .Where(model => !model.IsIgnored) - .Collect(); - - // Candidates from assemblies a convention names with InAssemblyOf. Combined with the - // compilation, so this Select re-runs whenever the compilation changes — which is every - // keystroke — but its result is compared by value, so the emission downstream stays cached - // unless the scanned assembly's public surface actually differs. When no convention names an - // assembly it returns an empty list after one pass over the conventions, which is the common - // case and costs nothing. - var metadataCandidates = conventionModules - .Combine(context.CompilationProvider) - .Select((pair, cancellation) => - new EquatableList( - MetadataCandidateUtility.Collect(pair.Left, pair.Right, cancellation))); - - context.RegisterSourceOutput( - incrementalValueProvider.Collect() - .Combine(conventionModules) - .Combine(candidates) - .Combine(metadataCandidates), - GenerateSourceOutput); - } - - private void GenerateSourceOutput( - SourceProductionContext context, - (((ImmutableArray<(ModuleEntryPointModel Left, DependencyModuleConfigurationModel Right)> Left, - ImmutableArray Right) Left, - ImmutableArray Right) Left, - EquatableList Right) data) { - - var entryPoints = data.Left.Left.Left; - var conventionModules = data.Left.Left.Right; - - // In-compilation candidates and metadata candidates travel together; a convention sees one - // source or the other, decided by whether it named an assembly. - var candidates = data.Left.Right.Length == 0 - ? (IReadOnlyList)data.Right - : data.Left.Right.Concat(data.Right).ToArray(); - - if (entryPoints.Length == 0 || conventionModules.Length == 0) { - return; - } - - var configuration = entryPoints.First().Right; - - FileLogger.Wrap( - LoggerName, - configuration, - logger => Generate(context, entryPoints, conventionModules, candidates, logger), - // Surfaced as a build error rather than discarded, matching the attribute generators. A - // generator that fails quietly produces a green build with no registrations. - exception => context.ReportDiagnostic( - Diagnostic.Create( - DependencyModuleDiagnostics.GeneratorFailure, - Location.None, - $"{exception.GetType().Name}: {exception.Message}"))); - } - - private void Generate( - SourceProductionContext context, - ImmutableArray<(ModuleEntryPointModel Left, DependencyModuleConfigurationModel Right)> entryPoints, - ImmutableArray conventionModules, - IReadOnlyList candidates, - FileLogger logger) { - - var (entryPointList, configurationModel) = EntryModelUtil.ConsolidateEntryPointModels(entryPoints); - - logger.Info( - $"Discovered {conventionModules.Length} convention module(s) and " + - $"{candidates.Count} candidate type(s)."); - - var claimed = new HashSet(); - - foreach (var entryPointModel in entryPointList) { - context.CancellationToken.ThrowIfCancellationRequested(); - - var conventionModule = conventionModules.FirstOrDefault( - module => module.ModuleType.Equals(entryPointModel.EntryPointType)); - - if (conventionModule == null) { - continue; - } - - claimed.Add(conventionModule); - - GenerateForModule( - context, entryPointModel, configurationModel, conventionModule, candidates, logger); - } - - ReportUnclaimedModules(context, conventionModules, claimed, logger); - } - - private void GenerateForModule( - SourceProductionContext context, - ModuleEntryPointModel entryPointModel, - DependencyModuleConfigurationModel configurationModel, - ConventionModuleModel conventionModule, - IReadOnlyList candidates, - FileLogger logger) { - - var withNamespace = EntryModelUtil.EnsureNamespace(entryPointModel, configurationModel); - - var serviceModels = ConventionMatcher.Match( - withNamespace, - conventionModule, - candidates, - context.ReportDiagnostic, - logger); - - if (serviceModels.Count == 0) { - return; - } - - // coverageAttributeOnMethod: the registrations file already puts ExcludeFromCodeCoverage on - // the partial class, and the attribute is not AllowMultiple, so a second class-level one on - // the same type is CS0579. - var writer = new DependencyFileWriter(logger, coverageAttributeOnMethod: true); - - var output = writer.Write(withNamespace, configurationModel, serviceModels, "Convention"); - - context.AddSource( - withNamespace.EntryPointType.GetFileNameHint( - configurationModel.RootNamespace, "ConventionDependencies"), - output); - } - - /// - /// Reports a type that implements IConventionModule but is not a module. - /// - /// - /// Its conventions would otherwise produce nothing at all, with a green build and no - /// explanation — exactly the silent failure the rest of this generator is built to avoid. - /// - private static void ReportUnclaimedModules( - SourceProductionContext context, - ImmutableArray conventionModules, - HashSet claimed, - FileLogger logger) { - - foreach (var conventionModule in conventionModules) { - if (claimed.Contains(conventionModule)) { - continue; - } - - var name = conventionModule.ModuleType.Name; - - logger.Error($"'{name}' implements IConventionModule but is not a [DependencyModule]."); - - context.ReportDiagnostic(Diagnostic.Create( - DependencyModuleDiagnostics.ConventionCannotBeRead, - Location.None, - "the declaring type is not marked with [DependencyModule], so it registers nothing", - name)); - } - } -} diff --git a/src/DependencyModules.Conventions/DependencyModules.Conventions.csproj b/src/DependencyModules.Conventions/DependencyModules.Conventions.csproj deleted file mode 100644 index d501270..0000000 --- a/src/DependencyModules.Conventions/DependencyModules.Conventions.csproj +++ /dev/null @@ -1,78 +0,0 @@ - - - - netstandard2.0 - - 11 - enable - true - true - enable - True - DependencyModules.Conventions - Convention-based registration for DependencyModules. Implement IConventionModule on a module and declare which interfaces to register; the generator resolves the matches at compile time and emits the registrations — no reflection or assembly scanning at run time. Pair with the DependencyModules.SourceGenerator and DependencyModules.Runtime packages. - true - false - - $(NoWarn);NU5128 - - - - true - - - - - - true - build/ - true - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - build - - - - - - - - - - - Impl\%(RecursiveDir)/%(FileName)%(Extension) - PreserveNewest - - - - - - - - - diff --git a/src/DependencyModules.Conventions/Package/DependencyModules.Conventions.props b/src/DependencyModules.Conventions/Package/DependencyModules.Conventions.props deleted file mode 100644 index 99a1d46..0000000 --- a/src/DependencyModules.Conventions/Package/DependencyModules.Conventions.props +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/DependencyModules.Conventions/Package/DependencyModules.Conventions.targets b/src/DependencyModules.Conventions/Package/DependencyModules.Conventions.targets deleted file mode 100644 index 826587b..0000000 --- a/src/DependencyModules.Conventions/Package/DependencyModules.Conventions.targets +++ /dev/null @@ -1,21 +0,0 @@ - - - true - - - - - - - - - - - - diff --git a/src/DependencyModules.NUnit/Attributes/ModuleTestAttribute.cs b/src/DependencyModules.NUnit/Attributes/ModuleTestAttribute.cs new file mode 100644 index 0000000..b441246 --- /dev/null +++ b/src/DependencyModules.NUnit/Attributes/ModuleTestAttribute.cs @@ -0,0 +1,163 @@ +using System.Globalization; +using DependencyModules.NUnit.Impl; +using DependencyModules.Testing.Attributes.Interfaces; +using NUnit.Framework.Interfaces; +using NUnit.Framework.Internal; +using NUnit.Framework.Internal.Builders; +using NUnit.Framework.Internal.Commands; + +namespace DependencyModules.NUnit.Attributes; + +/// +/// Marks a method as a module test: a container is built from the named modules for every iteration +/// of the test, the method's parameters are resolved from it, and it is torn down when the iteration +/// ends. +/// +/// +/// The container's lifetime brackets the whole iteration — [SetUp], the test method, then +/// [TearDown] — because this wraps through rather than +/// . Wrapping the test method alone would put the container inside +/// setup and teardown, leaving [SetUp] running before it exists and [TearDown] after +/// it is disposed, so neither could touch a service. +/// +/// [Repeat] and [Retry] wrap outside both, so each repetition and each retry attempt +/// builds and tears down its own container rather than sharing one. +/// +/// [TestFixture] on the containing class is optional; this implies a fixture the way +/// [Test] does. +/// +/// +/// +/// [ModuleTest(typeof(MyModule))] +/// public void ResolvesTheService(IMyService service) { +/// Assert.That(service, Is.Not.Null); +/// } +/// +/// +[AttributeUsage(AttributeTargets.Method)] +public class ModuleTestAttribute : Attribute, ITestBuilder, IWrapSetUpTearDown, IImplyFixture, IModuleTestAttribute { + + /// + /// Where a row's arguments are stashed between building the test case and executing it. + /// + /// + /// Not passed through TestCaseParameters.Arguments, which by then also holds the + /// placeholders standing in for the parameters the container will supply. This keeps the row + /// itself, so execution knows how many leading arguments are real. + /// + internal const string RowPropertyName = "DependencyModules.ModuleTestRow"; + + /// + /// Marks a test method, optionally naming the modules to configure its container with. + /// + /// + /// One constructor covers every arity, where the xUnit attribute needs three. That attribute + /// derives from FactAttribute, which captures a source location through + /// [CallerFilePath] and [CallerLineNumber], and C# does not allow caller-info + /// parameters after a params array. NUnit takes navigation from the assembly's symbols instead, + /// so nothing is lost by taking the params form here. + /// + public ModuleTestAttribute(params Type[] modules) { + ModuleTypes = modules; + } + + /// + public Type[] ModuleTypes { + get; + } + + /// + /// Builds one test case per data row, or a single case when the method has no rows. + /// + /// + /// Nothing is resolved here. NUnit calls this during discovery, and building a container per + /// test at discovery would construct every mock in the assembly before the first test ran. All + /// this has to satisfy is NUnit's arity check, which an array of the right length does; the real + /// arguments are written into that array at execution time, once there is a container. + /// + public IEnumerable BuildFrom(IMethodInfo method, Test? suite) { + var parameterCount = method.GetParameters().Length; + + var rows = method.MethodInfo.GetCustomAttributes(false) + .OfType() + .SelectMany(dataAttribute => dataAttribute.GetRows(method.MethodInfo)) + .ToArray(); + + if (rows.Length == 0) { + yield return BuildTestMethod(method, suite, new object?[parameterCount], null, method.Name); + + yield break; + } + + var names = method.MethodInfo.GetCustomAttributes(false) + .OfType() + .Select(attribute => attribute.TestName) + .ToArray(); + + for (var i = 0; i < rows.Length; i++) { + var row = rows[i]; + var arguments = new object?[parameterCount]; + + if (row.Length <= parameterCount) { + Array.Copy(row, arguments, row.Length); + } + + var testName = (i < names.Length ? names[i] : null) ?? DisplayName(method.Name, row); + + var testMethod = BuildTestMethod(method, suite, arguments, row, testName); + + if (row.Length > parameterCount) { + // Reported as a failing test rather than thrown, so one bad row names itself instead + // of taking down discovery for the whole fixture. + testMethod.RunState = RunState.NotRunnable; + testMethod.Properties.Set( + PropertyNames.SkipReason, + $"[ModuleTestCase] supplied {row.Length} arguments to a method taking " + + $"{parameterCount}. A row may supply fewer than the method takes — the remaining " + + "parameters are resolved from the container — but not more."); + } + + yield return testMethod; + } + } + + /// + public TestCommand Wrap(TestCommand command) => new ModuleTestCommand(command); + + private static TestMethod BuildTestMethod( + IMethodInfo method, Test? suite, object?[] arguments, object?[]? row, string testName) { + var parameters = new TestCaseParameters(arguments) { TestName = testName }; + + var testMethod = new NUnitTestCaseBuilder().BuildTestMethod(method, suite, parameters); + + if (row != null) { + testMethod.Properties.Set(RowPropertyName, row); + } + + return testMethod; + } + + /// + /// Names a row after its own arguments, the way NUnit names a [TestCase]. + /// + /// + /// Only the row's arguments are used. The trailing placeholders are resolved from the container + /// at execution time, and a name built from them would read as a list of nulls. + /// + private static string DisplayName(string methodName, object?[] row) => + $"{methodName}({string.Join(", ", row.Select(FormatArgument))})"; + + /// + /// NUnit's own MsgUtils.FormatValue is not part of its public surface, so this quotes the + /// two cases that need it and leaves everything else to ToString. The invariant culture + /// keeps a name that a test explorer filters on from changing with the machine's locale. + /// + private static string FormatArgument(object? argument) => + argument switch { + null => "null", + string text => $"\"{text}\"", + char character => $"'{character}'", + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + _ => argument.ToString() ?? string.Empty + }; +} diff --git a/src/DependencyModules.NUnit/Attributes/ModuleTestCaseAttribute.cs b/src/DependencyModules.NUnit/Attributes/ModuleTestCaseAttribute.cs new file mode 100644 index 0000000..c6831c8 --- /dev/null +++ b/src/DependencyModules.NUnit/Attributes/ModuleTestCaseAttribute.cs @@ -0,0 +1,69 @@ +using System.Reflection; + +namespace DependencyModules.NUnit.Attributes; + +/// +/// Supplies rows of arguments to a [ModuleTest] method. +/// +/// +/// Implemented by data attributes, not by test authors. [ModuleTest] builds one test case per +/// row returned here, so a source of rows — a member, a file, a generator — only has to implement +/// this to become usable. +/// +public interface IModuleTestDataAttribute { + + /// + /// The rows to build test cases from. A row covers the leading parameters of the method; the + /// rest are resolved from the test's container. + /// + /// The test method the rows are being built for. + IEnumerable GetRows(MethodInfo method); +} + +/// +/// One row of arguments for a [ModuleTest] method, the equivalent of NUnit's +/// [TestCase] or xUnit's [InlineData]. +/// +/// +/// NUnit's own [TestCase] cannot be used for this. It checks at build time that the row +/// supplies an argument for every parameter — throwing TargetParameterCountException before +/// any of this package's code runs — so it cannot express "the row covers the first parameters and +/// the container covers the rest", which is the whole point of a module test with data. It also +/// builds its own test cases, so combining the two would produce a case per row plus one more. +/// +/// The arguments fill the leading parameters in order. Every parameter after them is resolved the +/// way an undecorated [ModuleTest] parameter is: from an attribute on it, then the container, +/// then direct construction. +/// +/// +/// +/// [ModuleTest(typeof(MyModule))] +/// [ModuleTestCase(1, "one")] +/// [ModuleTestCase(2, "two")] +/// public void Converts(int number, string word, INumberFormatter formatter) { +/// Assert.That(formatter.Spell(number), Is.EqualTo(word)); +/// } +/// +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class ModuleTestCaseAttribute(params object?[] arguments) : Attribute, IModuleTestDataAttribute { + + /// + /// The arguments for this row, covering the method's leading parameters in order. + /// + public object?[] Arguments { + get; + } = arguments; + + /// + /// Overrides the name this row is reported under. Defaults to the method name followed by the + /// row's arguments, which is what tells one row from another in a test explorer. + /// + public string? TestName { + get; + set; + } + + /// + public IEnumerable GetRows(MethodInfo method) => [Arguments]; +} diff --git a/src/DependencyModules.NUnit/DependencyModules.NUnit.csproj b/src/DependencyModules.NUnit/DependencyModules.NUnit.csproj new file mode 100644 index 0000000..33b3731 --- /dev/null +++ b/src/DependencyModules.NUnit/DependencyModules.NUnit.csproj @@ -0,0 +1,37 @@ + + + + $(LibraryTargetFrameworks) + enable + enable + True + DependencyModules.NUnit + NUnit integration for DependencyModules. Provides the [ModuleTest] attribute, which builds a service provider from your modules for every test iteration and injects the services a test method asks for, plus [ModuleTestCase] for data-driven tests and attributes for per-test service overrides and value injection. + true + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DependencyModules.NUnit/Impl/ModuleTestCommand.cs b/src/DependencyModules.NUnit/Impl/ModuleTestCommand.cs new file mode 100644 index 0000000..9aa2f0e --- /dev/null +++ b/src/DependencyModules.NUnit/Impl/ModuleTestCommand.cs @@ -0,0 +1,190 @@ +using System.Reflection; +using DependencyModules.NUnit.Attributes; +using DependencyModules.Runtime.Helpers; +using DependencyModules.Runtime.Interfaces; +using DependencyModules.Testing.Attributes.Interfaces; +using DependencyModules.Testing.Impl; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework.Internal; +using NUnit.Framework.Internal.Commands; + +namespace DependencyModules.NUnit.Impl; + +/// +/// Builds a test's container, resolves its arguments, and disposes the container when the test +/// iteration ends. +/// +/// +/// Wrapped around NUnit's setup/teardown chain rather than around the method invocation, so the +/// container outlives [SetUp] and [TearDown] rather than being created between them. +/// +/// The method is not invoked here. NUnit's own command does that, which is what keeps setup, +/// teardown, timeouts, expected exceptions and the rest working normally — this only has to make +/// sure the arguments are in place before delegating. +/// +public class ModuleTestCommand(TestCommand innerCommand) : DelegatingTestCommand(innerCommand) { + + /// + public override TestResult Execute(TestExecutionContext context) { + var testMethod = (TestMethod)Test; + var method = testMethod.Method!.MethodInfo; + + // Widest scope first: assembly, then declaring type, then the method. + var knownAttributes = method.GetTestAttributes().ToArray(); + + var moduleContext = new NUnitTestMethodContext(testMethod, knownAttributes); + + var serviceCollection = new ServiceCollection(); + + // One resolver per container. A repeated test builds both again for every iteration. + var resolver = new TestParameterResolver(moduleContext); + + SetupTestCaseInfo(serviceCollection, testMethod, knownAttributes); + + SetupModules(serviceCollection, method, knownAttributes); + + resolver.SetupServiceCollection(serviceCollection); + + SetupServiceSetupAttributes(moduleContext, serviceCollection, knownAttributes); + + var serviceProvider = BuildServiceProvider(moduleContext, serviceCollection, knownAttributes); + + try { + foreach (var startupAttribute in knownAttributes.OfType()) { + // NUnit's command chain is synchronous — TestCommand.Execute has no async form — so + // an async hook is awaited here rather than up the stack. + startupAttribute.StartupAsync(moduleContext, serviceProvider).GetAwaiter().GetResult(); + } + + var arguments = resolver + .ResolveArgumentsAsync(serviceProvider, RowArguments(testMethod)) + .GetAwaiter().GetResult(); + + PublishArguments(testMethod, serviceProvider, arguments); + + return innerCommand.Execute(context); + } finally { + DisposeProvider(serviceProvider); + } + } + + /// + /// The arguments a data row fixed for this case, or none. + /// + private static object?[] RowArguments(TestMethod testMethod) => + testMethod.Properties.Get(ModuleTestAttribute.RowPropertyName) as object?[] ?? []; + + /// + /// Hands the resolved arguments to the command that will invoke the method. + /// + /// + /// NUnit invokes with the same object?[] instance that was handed to + /// TestCaseParameters when the case was built, so filling that array in is what makes an + /// argument that did not exist at build time reach the method. There is no setter to assign a + /// new array through, and taking over the invocation to pass one would mean reimplementing + /// NUnit's setup and teardown handling. + /// + private static void PublishArguments( + TestMethod testMethod, IServiceProvider serviceProvider, object?[] arguments) { + var target = testMethod.Arguments; + + Array.Copy(arguments, target, arguments.Length); + + serviceProvider.GetRequiredService().TestMethodArguments = arguments; + } + + private static void SetupTestCaseInfo( + IServiceCollection serviceCollection, TestMethod testMethod, Attribute[] knownAttributes) { + serviceCollection.AddSingleton(provider => provider.GetRequiredService()); + serviceCollection.AddSingleton(_ => new TestCaseInfo( + testMethod, + ArraySegment.Empty, + knownAttributes)); + } + + /// + /// Last rather than first: is widest scope first — assembly, + /// then declaring type, then the method — so the last one is the narrowest, and a builder on the + /// method beats one on the class beats one on the assembly. Taking the first would have let an + /// assembly-level builder silently win over the method that asked for a different container, + /// which is the reverse of how every other attribute here resolves. + /// + private static IServiceProvider BuildServiceProvider( + ITestMethodContext context, IServiceCollection serviceCollection, Attribute[] knownAttributes) { + var serviceProviderBuilderAttribute = + knownAttributes.OfType().LastOrDefault(); + + if (serviceProviderBuilderAttribute != null) { + return serviceProviderBuilderAttribute.BuildServiceProvider(context, serviceCollection); + } + + return serviceCollection.BuildServiceProvider(); + } + + /// + /// The whole pass runs after the parameter value providers, so a [TestExport] overrides a + /// [Mock] of the same service rather than the other way round. + /// + /// Mock support goes first within the pass, everything else keeping its declared order behind it. + /// A mock is the stand-in a test falls back to, so naming a real implementation has to beat it — + /// and has to beat it whether [MoqSupport] sits on the assembly, the class or the method, + /// which relying on attribute order alone would not guarantee. + /// + private static void SetupServiceSetupAttributes( + ITestMethodContext context, IServiceCollection serviceCollection, Attribute[] knownAttributes) { + var setupAttributes = knownAttributes + .OfType() + .OrderBy(attribute => attribute is IMockSupportAttribute ? 0 : 1); + + foreach (var setupAttribute in setupAttributes) { + setupAttribute.SetupServiceCollection(context, serviceCollection); + } + } + + /// + /// The same loading the xUnit integration does, reading so + /// neither names the other's attribute. It is not shared code because it needs both + /// DependencyModules.Runtime and DependencyModules.Testing, and the only assembly + /// both integrations share is Testing — which the three mocking packages reference precisely + /// because it does not drag the runtime in behind it. + /// + private static void SetupModules( + IServiceCollection serviceCollection, MethodInfo method, IEnumerable knownAttributes) { + var modules = new List(); + + foreach (var loadModuleAttribute in knownAttributes.OfType()) { + modules.Add(loadModuleAttribute.GetModule()); + } + + var testAttribute = method.GetTestAttribute(); + + if (testAttribute != null) { + var count = 0; + foreach (var moduleType in testAttribute.ModuleTypes) { + if (Activator.CreateInstance(moduleType, []) is IDependencyModule moduleInstance) { + modules.Insert(count++, moduleInstance); + } + } + } + + modules.Reverse(); + + DependencyRegistry.LoadModules(serviceCollection, modules.ToArray()); + } + + /// + /// Asynchronous disposal is preferred where the provider offers it, because a service that only + /// implements makes ServiceProvider.Dispose throw rather + /// than fall back. + /// + private static void DisposeProvider(IServiceProvider serviceProvider) { + switch (serviceProvider) { + case IAsyncDisposable asyncDisposable: + asyncDisposable.DisposeAsync().AsTask().GetAwaiter().GetResult(); + break; + case IDisposable disposable: + disposable.Dispose(); + break; + } + } +} diff --git a/src/DependencyModules.NUnit/Impl/NUnitTestMethodContext.cs b/src/DependencyModules.NUnit/Impl/NUnitTestMethodContext.cs new file mode 100644 index 0000000..3cb8bb3 --- /dev/null +++ b/src/DependencyModules.NUnit/Impl/NUnitTestMethodContext.cs @@ -0,0 +1,48 @@ +using System.Reflection; +using DependencyModules.Testing.Attributes.Interfaces; +using NUnit.Framework.Internal; + +namespace DependencyModules.NUnit.Impl; + +/// +/// The NUnit view of a test method, for hooks that need more than the neutral contract carries. +/// +/// +/// The hooks in DependencyModules.Testing are handed an so a +/// mocking package can implement them without referencing a test framework at all. An attribute that +/// is already NUnit-specific gives up nothing for that: the context it receives implements this, so +/// if (testMethod is INUnitTestMethodContext nunit) reaches NUnit's own model — the test's +/// name and id, its properties, and the fixture it belongs to. +/// +public interface INUnitTestMethodContext : ITestMethodContext { + + /// + /// NUnit's own model of the test method being executed. + /// + TestMethod NUnitTestMethod { + get; + } +} + +/// +/// Adapts to the neutral contract. +/// +/// +/// The attributes are passed in rather than walked here because the command has already collected +/// and ordered them to decide which modules to load, and that walk reaches the assembly and the +/// declaring type as well as the method. +/// +internal sealed class NUnitTestMethodContext( + TestMethod testMethod, + IReadOnlyList attributes) : INUnitTestMethodContext { + + public TestMethod NUnitTestMethod { + get; + } = testMethod; + + public MethodInfo Method => NUnitTestMethod.Method!.MethodInfo; + + public IReadOnlyList Attributes { + get; + } = attributes; +} diff --git a/src/DependencyModules.NUnit/Impl/TestCaseInfo.cs b/src/DependencyModules.NUnit/Impl/TestCaseInfo.cs new file mode 100644 index 0000000..405d939 --- /dev/null +++ b/src/DependencyModules.NUnit/Impl/TestCaseInfo.cs @@ -0,0 +1,66 @@ +using NUnit.Framework.Internal; + +namespace DependencyModules.NUnit.Impl; + +/// +/// Defines the contract for retrieving information about a specific test case. +/// +/// +/// Registered in every test's container, so a service can be told what it is being built for. +/// +public interface ITestCaseInfo { + + /// + /// NUnit's model of the test method being executed, including the arguments the case was + /// built with, its name and its properties. + /// + TestMethod TestMethod { + get; + } + + /// + /// Gets the arguments passed to the test method for a specific test case. + /// + /// + /// Set once the container exists and the arguments have been resolved, which is after the + /// registration itself is made — a service reading this in its constructor would be reading it + /// too early. Read it from a method the test calls, not from a constructor. + /// + IReadOnlyList TestMethodArguments { + get; + set; + } + + /// + /// Gets the collection of attributes associated with the test method of a specific test case, + /// widest scope first: assembly, then declaring type, then the method. + /// + IReadOnlyList TestMethodAttributes { + get; + } +} + +/// +/// Represents information about a specific test case, including the test method, its arguments, and attributes. +/// +public class TestCaseInfo( + TestMethod testMethod, + IReadOnlyList testMethodArguments, + IReadOnlyList testMethodAttributes) : ITestCaseInfo { + + /// + public TestMethod TestMethod { + get; + } = testMethod; + + /// + public IReadOnlyList TestMethodArguments { + get; + set; + } = testMethodArguments; + + /// + public IReadOnlyList TestMethodAttributes { + get; + } = testMethodAttributes; +} diff --git a/src/DependencyModules.Runtime/Attributes/BaseServiceAttribute.cs b/src/DependencyModules.Runtime/Attributes/BaseServiceAttribute.cs index ee97020..c66e30d 100644 --- a/src/DependencyModules.Runtime/Attributes/BaseServiceAttribute.cs +++ b/src/DependencyModules.Runtime/Attributes/BaseServiceAttribute.cs @@ -32,9 +32,31 @@ public enum RegistrationType { } /// -/// Represents an interface for attributes used to define service registration metadata in -/// dependency injection frameworks. +/// The shape of a service registration attribute: what it registers as, under what key, with what +/// lifetime, and by which registration method. /// +/// +/// +/// Descriptive, not a discovery mechanism. Implementing this interface does not make an +/// attribute one the generator reads. Nothing in the generator tests for it — registration +/// attributes are matched by type, through the names a generator declares, because that is what +/// keeps them on ForAttributeWithMetadataName and therefore on Roslyn's attribute index. +/// Discovery by interface would mean a syntax provider over every node in the compilation, with the +/// transform re-running per keystroke, which is the cost this generator is built to avoid. +/// +/// +/// A framework wanting its own registration attributes to be first class declares them to its own +/// generator instead: derive from BaseSourceGenerator, name the module attribute in +/// ModuleAttributeTypes(), and collect the rest through AttributeSourceGenerators(). +/// A generator built that way stacks with this one for about 0.4 ms per keystroke. +/// +/// +/// What the interface is for is reading a registration uniformly at run time or through reflection +/// — [SingletonService], [ScopedService], [TransientService] and +/// [CrossWireService] all answer to it — and giving those attributes one place to define what +/// a registration consists of. +/// +/// public interface IServiceRegistrationAttribute { /// /// Gets or sets a key used for service registration, diff --git a/src/DependencyModules.Runtime/Conventions/ConventionContracts.cs b/src/DependencyModules.Runtime/Conventions/ConventionContracts.cs new file mode 100644 index 0000000..4ae19f2 --- /dev/null +++ b/src/DependencyModules.Runtime/Conventions/ConventionContracts.cs @@ -0,0 +1,427 @@ +// Convention contracts. +// +// These were emitted into every consuming compilation by the conventions analyzer through +// RegisterPostInitializationOutput, which forced them to be `internal` — an implicit +// `public void Conventions(...)` was CS0051 against an internal interface, so the method had to be +// implemented explicitly — and made CS0436 unavoidable between two assemblies that both emitted +// them and referenced each other. +// +// Declared once here instead, they are this package's public API rather than the consumer's, which +// is what retires both problems. +// +// The namespace is DependencyModules.Runtime.Conventions, matching the assembly. Keeping the old +// DependencyModules.Conventions would have put runtime contracts and the analyzer that reads them in +// one namespace across two assemblies, which is ambiguous wherever both are referenced. +// +// Nothing here has behaviour or is ever executed. The generator reads the chain out of the method +// body at compile time and emits ordinary registrations; the body itself is never called. + +namespace DependencyModules.Runtime.Conventions { + + /// + /// Implement this on a [DependencyModule] class to register services by convention + /// instead of attributing each one. + /// + /// + /// + /// The body of Conventions is never executed. It is read by the + /// DependencyModules.SourceGenerator analyzer at compile time, which resolves the + /// matching types and emits ordinary registrations. Nothing implements + /// at run time and the method is never called. + /// + /// + /// Because it is configuration rather than code, only a chain of the calls declared on + /// and can + /// appear in it. Loops, conditionals, locals and calls to your own helpers cannot be + /// evaluated at compile time and are reported as DM0009 rather than ignored. + /// + /// + /// Either an explicit implementation or an ordinary public void Conventions(…) + /// compiles and is matched. Explicit implementation used to be the only form that + /// compiled, when the contracts were emitted into the consuming compilation as internal + /// types; they are public types in this assembly now, so that constraint is gone. A type + /// carrying both shapes is read from the explicit one, since that is the one satisfying + /// the interface. + /// + /// + /// + /// [DependencyModule] + /// public partial class DataModule : IConventionModule { + /// void IConventionModule.Conventions(IConventionDefinitions conventions) { + /// conventions.RegisterAll<IRepository>().AsScoped(); + /// conventions.RegisterAll(typeof(IRequestHandler<,>)).AsTransient(); + /// } + /// } + /// + /// + /// + public interface IConventionModule { + + /// + /// Declares this module's conventions. Read at compile time; never invoked. + /// + /// Receives the declarations. + void Conventions(IConventionDefinitions conventions); + } + + /// + /// Declares which types a module registers by convention. + /// + /// + /// Nothing implements this. The calls made on it are read from source at compile time. + /// + public interface IConventionDefinitions { + + /// + /// Registers every type in this compilation that implements + /// , as . + /// + /// + /// Matches a type that declares directly, and one + /// that declares an interface extending it — an interface saying it extends another + /// is a deliberate statement that it is substitutable for it. Reaching a service + /// type through a base class is not matched unless + /// is called, because + /// extending a class is a statement about implementation reuse rather than about + /// the contract, and every subclass added later would silently join the convention. + /// + /// A type carrying an explicit service attribute is never a convention candidate; + /// the attribute always wins. + /// + /// The service type to scan for and register as. + IConventionRegistration RegisterAll(); + + /// + /// The overload for open generics, which cannot + /// be written as a type argument: RegisterAll(typeof(IHandler<,>)). + /// + /// + /// Each match is registered against the closed interface it implements, so + /// CreateOrderHandler : IHandler<CreateOrder, OrderId> registers + /// IHandler<CreateOrder, OrderId>. A generic implementation that closes + /// nothing registers as the open generic. + /// + /// The service type to scan for, open or closed. + IConventionRegistration RegisterAll(global::System.Type serviceType); + + /// + /// Registers types selected by something other than the service they implement. + /// + /// + /// + /// With no service type there is nothing to register the matches as, so this + /// form requires or + /// . It is how a concrete + /// class that implements no interface gets registered by convention. + /// + /// + /// It also requires at least one filter. Without one it would match every class in + /// the compilation, which is never what anybody means and is reported rather than + /// obeyed. + /// + /// + /// + /// conventions.RegisterAll().InNamespaceOf<OrderMarker>().AsSelf().AsScoped(); + /// + /// + /// + IConventionRegistration RegisterAll(); + } + + /// + /// Configures what a RegisterAll declaration produces. + /// + /// + /// A lifetime is required. There is no default, because a lifetime nobody wrote down is + /// the most expensive thing for a registration to get wrong; omitting one is DM0009. + /// + public interface IConventionRegistration { + + /// Registers the matches as singletons. + IConventionRegistration AsSingleton(); + + /// Registers the matches as scoped. + IConventionRegistration AsScoped(); + + /// Registers the matches as transient. + IConventionRegistration AsTransient(); + + /// + /// Also matches types that reach the service type only through a base class. + /// + /// + /// Off by default. Turning it on picks up the common + /// CreateOrderValidator : AbstractValidator<CreateOrder> shape, where + /// the interface is declared on a base class, at the cost of every future subclass + /// of that base joining the convention without anyone revisiting it. + /// + IConventionRegistration IncludeBaseClasses(); + + /// + /// Registers each match as its own concrete type rather than as the service type. + /// + /// + /// RegisterAll<IHandler>().AsSelf() puts CreateOrderHandler in + /// the container under CreateOrderHandler, not under IHandler. + /// + IConventionRegistration AsSelf(); + + /// + /// Registers each match as the service type the convention matched and as its + /// own concrete type, sharing one instance between them. + /// + /// + /// + /// Additive, where AsSelf replaces: AsSelf means "instead of the + /// interface", this means "as well as it". Only the interfaces the convention + /// matched are registered, not every interface the type can reach — that is + /// . + /// + /// + /// The shape FluentValidation wants. It registers each validator as + /// IValidator<T> and as the concrete type, independently, which hands + /// you two instances per scope; cross-wiring them gives one, which is the better + /// behaviour and a deliberate difference. + /// + /// + /// + /// conventions.RegisterAll(typeof(IValidator<>)) + /// .IncludeBaseClasses() + /// .AlsoAsSelf() + /// .AsScoped(); + /// + /// + /// + IConventionRegistration AlsoAsSelf(); + + /// + /// Registers each match as its own type and as every interface it implements, + /// sharing one instance between them. + /// + /// + /// The same contract as [CrossWireService]: resolving the concrete type and + /// resolving any of its interfaces gives the same instance, rather than one instance + /// per service type as two independent registrations would. + /// + IConventionRegistration AsSelfWithInterfaces(); + + /// + /// Limits matches to the namespace of and the + /// namespaces beneath it. + /// + /// + /// A type argument rather than a string, so a namespace that does not exist cannot + /// be named. Several namespace filters combine with or. + /// + /// Any type in the namespace to scan. + IConventionRegistration InNamespaceOf(); + + /// + /// Limits matches to the given namespaces and the namespaces beneath them. + /// + /// Namespaces to include. + IConventionRegistration InNamespaces(params string[] namespaces); + + /// + /// Limits matches to exactly the given namespaces, excluding those beneath them. + /// + /// Namespaces to include. + IConventionRegistration InExactNamespaces(params string[] namespaces); + + /// + /// Excludes the namespace of and those beneath it. + /// + /// + /// Exclusions are applied after inclusions, and a match excluded by any of them is + /// out however many inclusions it satisfied. + /// + /// Any type in the namespace to exclude. + IConventionRegistration NotInNamespaceOf(); + + /// + /// Excludes the given namespaces and those beneath them. + /// + /// Namespaces to exclude. + IConventionRegistration NotInNamespaces(params string[] namespaces); + + /// + /// Chooses how each match is added to the service collection. + /// + /// + /// The same choice [SingletonService(Using = ...)] makes, and the answer to + /// Scrutor's RegistrationStrategy: Try skips a service type already + /// registered, TryEnumerable allows several implementations of one service, + /// and Replace takes over an existing registration. Defaults to Add. + /// + /// How to add the registration. + IConventionRegistration Using( + global::DependencyModules.Runtime.Attributes.RegistrationType registrationType); + + /// + /// Registers every match under a service key. + /// + /// + /// The key is written into the registration as you wrote it, so a string literal, a + /// const or an enum member all work. Every match of this convention shares + /// the key — there is no way to vary it per type, because that would take a lambda + /// over types the generator is only describing. + /// + /// The service key. + IConventionRegistration WithKey(object key); + + /// + /// Limits matches to types carrying . + /// + /// + /// The attribute is resolved, not matched on how it was written, so a + /// namespace-qualified usage and an alias both count. Several attribute filters + /// combine with and. + /// + /// The attribute to require. + IConventionRegistration WithAttribute() + where TAttribute : global::System.Attribute; + + /// + /// Excludes types carrying . + /// + /// The attribute to exclude on. + IConventionRegistration WithoutAttribute() + where TAttribute : global::System.Attribute; + + /// + /// Limits matches to types whose name fits one of the given patterns. + /// + /// + /// + /// Two wildcards and no regex: * matches zero or more characters, ? + /// matches exactly one. A pattern containing a dot is matched against the full + /// Namespace.TypeName; otherwise against the bare type name. Matching is + /// ordinal and case-sensitive, like C# identifiers. + /// + /// + /// The weakest selector here, and deliberately last. It is the one most likely to + /// match something nobody intended when a class is added years later — prefer + /// RegisterAll<T>, an attribute, or a namespace. + /// + /// + /// Name patterns to include; several combine with or. + IConventionRegistration WithName(params string[] patterns); + + /// + /// Excludes types whose name fits one of the given patterns. + /// + /// Name patterns to exclude. + IConventionRegistration WithoutName(params string[] patterns); + + /// + /// Registers the matches only when the environment name is one of + /// . + /// + /// + /// + /// The test runs when the modules are applied, not while the build runs, so this + /// changes what is registered rather than what the convention matched. Every match + /// is still emitted, behind the same guard. + /// + /// + /// A condition here combines with and against any + /// [IfEnvironment] on a matched class, so neither can silently override the + /// other. Conditions of different kinds also combine with and; alternatives go + /// inside one call. + /// + /// + /// + /// Accepted names, compared case-insensitively to match + /// IHostEnvironment.IsDevelopment(). + /// + IConventionRegistration IfEnvironment(params string[] environmentNames); + + /// + /// Registers the matches only when the environment name is none of + /// . + /// + /// Names to exclude, compared case-insensitively. + IConventionRegistration IfNotEnvironment(params string[] environmentNames); + + /// + /// Registers the matches only when the environment carries a value for + /// . + /// + /// The key that must be present. + IConventionRegistration IfEnvironmentValue(string key); + + /// + /// Registers the matches only when the environment's value for + /// equals . + /// + /// The key to read. + /// The value it must equal, compared ordinally. + IConventionRegistration IfEnvironmentValue(string key, string value); + + /// + /// Registers the matches only when the environment carries no value for + /// . + /// + /// The key that must be absent. + IConventionRegistration IfNotEnvironmentValue(string key); + + /// + /// Registers the matches only when the environment's value for + /// does not equal . + /// + /// The key to read. + /// The value it must not equal, compared ordinally. + IConventionRegistration IfNotEnvironmentValue(string key, string value); + + /// + /// Registers every match as , whatever it matched + /// through. + /// + /// The service type to register as. + IConventionRegistration As(); + + /// + /// Scans the assembly lives in, instead of the + /// project being built. + /// + /// + /// + /// For registering types out of a package you do not own. A project you do + /// own is better served by giving it its own module with its own conventions and + /// composing through module attributes — explicit, ordered, and cross-assembly by + /// construction. + /// + /// + /// The assembly is always named, and named by a type rather than a string, so an + /// assembly that is not referenced cannot be asked for. There is deliberately no + /// "scan everything I depend on": measured, walking every reference visits 5,350 + /// types where one named assembly visits 10, on every keystroke. + /// + /// + /// Only public types are visible across an assembly boundary, where a scan of + /// the project being built also sees internal ones. Nothing can report the + /// internal type it cannot see, so this is a difference to know rather than + /// one that can be diagnosed. + /// + /// + /// + /// conventions.RegisterAll(typeof(IHandler<,>)) + /// .InAssemblyOf<SomeTypeInThatPackage>() + /// .AsScoped(); + /// + /// + /// + /// Any type in the assembly to scan. + IConventionRegistration InAssemblyOf(); + + /// + /// Registers each match as the interface named after it — Foo as + /// IFoo. + /// + /// + /// A match that implements no such interface is skipped rather than registered some + /// other way, since the convention asked for one specific shape. + /// + IConventionRegistration AsMatchingInterface(); + } +} diff --git a/src/DependencyModules.Runtime/DependencyModules.Runtime.csproj b/src/DependencyModules.Runtime/DependencyModules.Runtime.csproj index a51742f..b7d4a60 100644 --- a/src/DependencyModules.Runtime/DependencyModules.Runtime.csproj +++ b/src/DependencyModules.Runtime/DependencyModules.Runtime.csproj @@ -8,6 +8,14 @@ DependencyModules.Runtime Runtime support for DependencyModules: attributes, module interfaces, and IServiceCollection extensions used to compose attribute-driven dependency injection modules. Pair with the DependencyModules.SourceGenerator package, which generates the registration code. true + + true + $(WarningsAsErrors);IL2026;IL2055;IL2067;IL2072;IL2075;IL2087;IL3050 + + true + src/DependencyModules.SourceGenerator/Conventions/ + true + diff --git a/src/DependencyModules.SourceGenerator.Impl/InterceptorFileWriter.cs b/src/DependencyModules.SourceGenerator.Impl/InterceptorFileWriter.cs index f45e06b..a546701 100644 --- a/src/DependencyModules.SourceGenerator.Impl/InterceptorFileWriter.cs +++ b/src/DependencyModules.SourceGenerator.Impl/InterceptorFileWriter.cs @@ -253,6 +253,10 @@ private static void WriteForwardingMethod( foreach (var parameter in member.Parameters) { var declared = method.AddParameter(parameter.Type, parameter.Identifier); + // Dropping params does not merely lose sugar: an optional parameter ahead of it becomes + // an optional parameter followed by a required one, which the compiler refuses. + declared.IsParams = parameter.IsParams; + if (parameter.DefaultValue != null) { declared.DefaultValue = new CodeOutputComponent(parameter.DefaultValue) { Indented = false }; } diff --git a/src/DependencyModules.SourceGenerator.Impl/InterceptorRegistrationWriter.cs b/src/DependencyModules.SourceGenerator.Impl/InterceptorRegistrationWriter.cs index ac00de6..9f1b4b2 100644 --- a/src/DependencyModules.SourceGenerator.Impl/InterceptorRegistrationWriter.cs +++ b/src/DependencyModules.SourceGenerator.Impl/InterceptorRegistrationWriter.cs @@ -80,16 +80,30 @@ private static void WriteInterceptor( var wrapperName = $"{model.ImplementationType.Name.Replace(".", "_")}_Intercepted"; var wrapperType = TypeDefinition.Get(model.ImplementationType.Namespace, wrapperName); + // The wrapper is generated right here, so its constructor is known exactly: the intercepted + // instance, then one parameter per interceptor. Emitting the `new` rather than handing the + // type to ActivatorUtilities is what keeps interception working in a published Native AOT + // application — the same reason decorators are emitted closed. + var arguments = new List { CodeOutputComponent.Get("inner") }; + + for (var i = 0; i < model.Interceptors.Count; i++) { + arguments.Add( + new InvokeGenericDefinition( + "provider", "GetRequiredService", new[] { model.Interceptors[i].Type })); + } + method.NewLine(); method.AddIndentedStatement( - new StaticInvokeStatement( + SyntaxHelpers.InvokeGeneric( KnownTypes.DependencyModules.Helpers.DecoratorHelper, "Decorate", - new List { - CodeOutputComponent.Get(services.Name), - TypeOf(model.ServiceType), - TypeOf(wrapperType) - })); + new[] { model.ServiceType }, + CodeOutputComponent.Get(services.Name), + TypeOf(wrapperType), + new WrapStatement( + CodeOutputComponent.Get(" => "), + CodeOutputComponent.Get("(provider, inner)"), + New(wrapperType, arguments.ToArray())))); // A field initializer registers the method, matching how decorator registrations are hooked // up. DynamicDependency keeps the trimmer from removing a method only referenced this way. diff --git a/src/DependencyModules.SourceGenerator.Impl/Models/DecoratorModel.cs b/src/DependencyModules.SourceGenerator.Impl/Models/DecoratorModel.cs index 4f8bb5c..2acc6c6 100644 --- a/src/DependencyModules.SourceGenerator.Impl/Models/DecoratorModel.cs +++ b/src/DependencyModules.SourceGenerator.Impl/Models/DecoratorModel.cs @@ -1,3 +1,4 @@ +using System.Linq; using CSharpAuthor; namespace DependencyModules.SourceGenerator.Impl.Models; @@ -18,12 +19,63 @@ namespace DependencyModules.SourceGenerator.Impl.Models; /// on a service. A decorator that does not apply is never invoked, so the service resolves /// undecorated rather than being wrapped by something that re-tests the environment per call. /// +/// +/// The decorator's constructor, so the call can be emitted as a literal new rather than left +/// to ActivatorUtilities at run time. +/// +/// +/// Which constructor parameter takes the service being wrapped. Every other parameter is resolved +/// from the provider. -1 when the constructor could not be read, which is what +/// tests. +/// +/// +/// True when the decorator's type parameters are exactly the service's type arguments, in order — +/// Logging<TReq, TRes> : IHandler<TReq, TRes>. Only then can closing the service +/// over a pair of types be turned into closing the decorator over the same pair. A shape that +/// reorders or reuses them is refused rather than guessed at. +/// public record DecoratorModel( ITypeDefinition ServiceType, ITypeDefinition DecoratorType, int Order, ITypeDefinition? Realm, - IReadOnlyList? Conditions = null) { + IReadOnlyList? Conditions = null, + ConstructorInfoModel? Constructor = null, + int InnerParameterIndex = -1, + bool TypeParametersMatchService = true) { + + /// + /// Whether the decorator can be constructed by generated code. + /// + /// + /// The alternative is a run-time ActivatorUtilities.CreateInstance over a + /// , which is exactly what a published Native AOT build cannot rely on. When + /// this is false the generator reports rather than emitting something that works under a JIT and + /// fails when published. + /// + public bool CanMonomorphise => + Constructor != null && InnerParameterIndex >= 0 && TypeParametersMatchService; + + /// + /// Whether this decorator is generic, and therefore applies to closed constructions of an open + /// generic service rather than to one named service type. + /// + public bool IsOpenGeneric => + DecoratorType is GenericTypeDefinition { TypeArguments.Count: > 0 }; + + /// + /// Whether the decorated service is still the unbound form, IHandler<>. + /// + /// + /// A generic decorator carries the unbound service until it is expanded against the registrations + /// that close it. One that reaches emission still unbound had nothing to expand against, and must + /// take the reflective path: an unbound name is not a legal type argument, so emitting + /// Decorate<IHandler<>> is CS7003 in generated code — which is the failure + /// mode this generator is built never to produce. + /// + public bool HasUnboundServiceType => + ServiceType is GenericTypeDefinition generic && + generic.TypeArguments.Any(argument => string.IsNullOrEmpty(argument.Name)); /// /// Sentinel for a syntax node that carried the attribute but produced no usable model, matching @@ -57,6 +109,9 @@ public bool Equals(DecoratorModel? x, DecoratorModel? y) { x.ServiceType.Equals(y.ServiceType) && x.DecoratorType.Equals(y.DecoratorType) && Equals(x.Realm, y.Realm) && + x.InnerParameterIndex == y.InnerParameterIndex && + x.TypeParametersMatchService == y.TypeParametersMatchService && + Equals(x.Constructor, y.Constructor) && ConditionsEqual(x.Conditions, y.Conditions); } @@ -73,6 +128,8 @@ public int GetHashCode(DecoratorModel obj) { hash = hash * 31 + obj.DecoratorType.GetHashCode(); hash = hash * 31 + obj.Order; hash = hash * 31 + (obj.Realm?.GetHashCode() ?? 0); + hash = hash * 31 + obj.InnerParameterIndex; + hash = hash * 31 + (obj.Constructor?.GetHashCode() ?? 0); hash = hash * 31 + ModelEquality.ListHashCode(obj.Conditions); return hash; diff --git a/src/DependencyModules.SourceGenerator.Impl/Models/InterceptorModel.cs b/src/DependencyModules.SourceGenerator.Impl/Models/InterceptorModel.cs index 128c546..04805df 100644 --- a/src/DependencyModules.SourceGenerator.Impl/Models/InterceptorModel.cs +++ b/src/DependencyModules.SourceGenerator.Impl/Models/InterceptorModel.cs @@ -72,11 +72,18 @@ public enum AccessorForm { /// The default as it should be written, or null when the parameter is required. Dropping it would /// change the signature callers see through the interface. /// +/// +/// Whether the parameter was declared with params. Carried because dropping it can turn a +/// legal signature into an illegal one: Join(string separator = ",", params string[] parts) +/// becomes an optional parameter followed by a required one, which is CS1737 — in the generated +/// wrapper, for an interface that compiles perfectly well. +/// public record InterceptedParameterModel( string Name, string Identifier, ITypeDefinition Type, - string? DefaultValue); + string? DefaultValue, + bool IsParams = false); /// /// One type parameter of an intercepted member, with the constraints the wrapper has to repeat. diff --git a/src/DependencyModules.SourceGenerator.Impl/Utilities/AttributeModelCollector.cs b/src/DependencyModules.SourceGenerator.Impl/Utilities/AttributeModelCollector.cs new file mode 100644 index 0000000..64919c3 --- /dev/null +++ b/src/DependencyModules.SourceGenerator.Impl/Utilities/AttributeModelCollector.cs @@ -0,0 +1,117 @@ +using System.Collections.Immutable; +using CSharpAuthor; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace DependencyModules.SourceGenerator.Impl.Utilities; + +/// +/// Builds one collected provider from a set of attributes, indexed rather than scanned. +/// +/// +/// +/// Extracted from BaseAttributeSourceGenerator so a generator can build more than one of +/// these. The decorator generator needs two — its own attributes, and the service attributes, because +/// monomorphising a generic decorator means emitting one call per closed registration and the +/// registrations are what say which closings exist. +/// +/// +/// Cheap enough to do twice: ForAttributeWithMetadataName shares Roslyn's attribute index, and +/// a second set of providers over it measured 3.5 ms cold and 0.1 ms per keystroke on a 2,000-class +/// compilation — against 33 ms for the one visit of every syntax node this shape replaced. +/// +/// +public static class AttributeModelCollector { + + /// + /// Collects one model per declaration carrying any of . + /// + /// Builds the model from the declaration the attribute was found on. + /// + /// The sentinel returned for a declaration this provider does not own. Every model type in this + /// codebase has one and every writer already skips it. + /// + public static IncrementalValueProvider> Collect( + IncrementalGeneratorInitializationContext context, + ITypeDefinition[] attributeTypes, + Func generate, + IEqualityComparer comparer, + TModel ignored) { + + IncrementalValueProvider>? merged = null; + + // ForAttributeWithMetadataName takes a single name, so an attribute set needs one provider + // each. They share the index, so several indexed lookups still cost far less than one visit + // of every syntax node. + foreach (var attributeType in attributeTypes) { + var owner = attributeType; + + var provider = context.SyntaxProvider.ForAttributeWithMetadataName( + MetadataName(owner), + static (node, _) => node is MemberDeclarationSyntax, + (syntaxContext, cancellation) => + Owned(syntaxContext, cancellation, attributeTypes, owner, generate, ignored)) + .WithComparer(comparer) + .Collect(); + + merged = merged == null + ? provider + : merged.Value.Combine(provider).Select(static (pair, _) => pair.Left.AddRange(pair.Right)); + } + + return merged!.Value; + } + + /// + /// Builds the model only from the provider that owns the declaration. + /// + /// + /// A declaration carrying two of these attributes — [SingletonService] [CrossWireService] + /// is a supported pair — is produced by two providers. The model is built from the whole + /// declaration rather than from the attribute that triggered it, so both would be identical and + /// every registration would be emitted twice. The first attribute present, in the order the + /// generator declares them, is the one that builds it. + /// + private static TModel Owned( + GeneratorAttributeSyntaxContext context, + CancellationToken cancellation, + ITypeDefinition[] attributeTypes, + ITypeDefinition owner, + Func generate, + TModel ignored) { + + var present = context.TargetSymbol.GetAttributes(); + + foreach (var candidate in attributeTypes) { + if (!IsPresent(present, candidate)) { + continue; + } + + return candidate.Equals(owner) ? generate(context, cancellation) : ignored; + } + + return generate(context, cancellation); + } + + private static bool IsPresent(ImmutableArray present, ITypeDefinition candidate) { + foreach (var attribute in present) { + if (attribute.AttributeClass is { } attributeClass && + attributeClass.Name == candidate.Name && + NamespaceOf(attributeClass) == candidate.Namespace) { + return true; + } + } + + return false; + } + + private static string NamespaceOf(INamedTypeSymbol symbol) => + symbol.ContainingNamespace is { IsGlobalNamespace: false } containing + ? containing.ToDisplayString() + : ""; + + private static string MetadataName(ITypeDefinition attributeType) => + string.IsNullOrEmpty(attributeType.Namespace) + ? attributeType.Name + : attributeType.Namespace + "." + attributeType.Name; +} diff --git a/src/DependencyModules.SourceGenerator.Impl/Utilities/AttributeModelHelper.cs b/src/DependencyModules.SourceGenerator.Impl/Utilities/AttributeModelHelper.cs index 758042e..56959c7 100644 --- a/src/DependencyModules.SourceGenerator.Impl/Utilities/AttributeModelHelper.cs +++ b/src/DependencyModules.SourceGenerator.Impl/Utilities/AttributeModelHelper.cs @@ -186,7 +186,11 @@ private static object GetOperationValue(SyntaxTransformContext context, SyntaxNo var type = typeOf.Type.GetTypeDefinition(context); if (type != null) { - return type; + // typeof(IRepo<>) binds to the unbound symbol, which carries the declaration's type + // parameters as its arguments. Re-emitting that verbatim writes typeof(IRepo) + // into the generated module, where T is not in scope — CS0246, in generated code, + // for an attribute the developer wrote correctly. + return IsUnboundGeneric(typeOf.Type) ? type.ToUnboundGeneric() : type; } } @@ -208,4 +212,12 @@ private static IReadOnlyList GetInterfaces( return interfaces; } + /// + /// Whether the type was written as Foo<> rather than closed over anything. + /// + private static bool IsUnboundGeneric(TypeSyntax type) => + type is GenericNameSyntax generic && + generic.TypeArgumentList.Arguments.Count > 0 && + generic.TypeArgumentList.Arguments.All(argument => argument is OmittedTypeArgumentSyntax); + } \ No newline at end of file diff --git a/src/DependencyModules.SourceGenerator.Impl/Utilities/ConstructorArgumentWriter.cs b/src/DependencyModules.SourceGenerator.Impl/Utilities/ConstructorArgumentWriter.cs new file mode 100644 index 0000000..9d778b1 --- /dev/null +++ b/src/DependencyModules.SourceGenerator.Impl/Utilities/ConstructorArgumentWriter.cs @@ -0,0 +1,99 @@ +using CSharpAuthor; +using DependencyModules.SourceGenerator.Impl.Models; +using static CSharpAuthor.SyntaxHelpers; + +namespace DependencyModules.SourceGenerator.Impl.Utilities; + +/// +/// Renders the arguments for a constructor or factory call: each parameter resolved from the +/// provider on the terms the parameter itself declares. +/// +/// +/// Shared rather than reimplemented per call site. Three things here are easy to write out and easy +/// to get subtly wrong, and each is silent when it is: +/// +/// a nullable parameter takes GetService, not GetRequiredService, or an optional +/// dependency starts throwing; +/// [FromKeyedServices] takes GetRequiredKeyedService with the key, or a keyed +/// dependency silently resolves the unkeyed registration — the right type, the wrong +/// instance; +/// an IServiceProvider parameter is the provider itself rather than something to +/// resolve. +/// +/// The decorator writer duplicated this and got the second one wrong, which is why it now lives in +/// one place. +/// +public static class ConstructorArgumentWriter { + + /// + /// Arguments for every parameter. + /// + public static object[] Arguments( + ParameterDefinition serviceProvider, IReadOnlyList parameters) => + Arguments(serviceProvider, parameters, -1, null); + + /// + /// Arguments for every parameter, with one supplied rather than resolved. + /// + /// + /// The parameter that is passed in — the instance a decorator wraps. -1 when every parameter is + /// resolved. + /// + /// What to write at that position. + public static object[] Arguments( + ParameterDefinition serviceProvider, + IReadOnlyList parameters, + int suppliedIndex, + object? supplied) { + + var arguments = new List(parameters.Count); + + for (var i = 0; i < parameters.Count; i++) { + if (i == suppliedIndex && supplied != null) { + arguments.Add(supplied); + + continue; + } + + arguments.Add(Argument(serviceProvider, parameters[i])); + } + + return arguments.ToArray(); + } + + private static object Argument(ParameterDefinition serviceProvider, ParameterInfoModel parameter) { + if (parameter.ParameterType.Equals(KnownTypes.Microsoft.DependencyInjection.IServiceProvider)) { + return serviceProvider; + } + + var keyed = parameter.Attributes.FirstOrDefault( + attribute => attribute.TypeDefinition.Equals( + KnownTypes.Microsoft.DependencyInjection.FromKeyedServicesAttribute)); + + var name = "Get"; + var arguments = new List(); + + if (!parameter.ParameterType.IsNullable) { + name += "Required"; + } + + if (keyed != null) { + name += "Keyed"; + + var key = keyed.Arguments.First().Value!; + + if (key is string text) { + key = QuoteString(text); + } + + arguments.Add(key); + } + + name += "Service"; + + return serviceProvider.InvokeGeneric( + name, + new[] { parameter.ParameterType.MakeNullable(false) }, + arguments.ToArray()); + } +} diff --git a/src/DependencyModules.SourceGenerator.Impl/Utilities/DecoratorConstraintChecker.cs b/src/DependencyModules.SourceGenerator.Impl/Utilities/DecoratorConstraintChecker.cs new file mode 100644 index 0000000..9bfd641 --- /dev/null +++ b/src/DependencyModules.SourceGenerator.Impl/Utilities/DecoratorConstraintChecker.cs @@ -0,0 +1,127 @@ +using CSharpAuthor; +using Microsoft.CodeAnalysis; + +namespace DependencyModules.SourceGenerator.Impl.Utilities; + +/// +/// Whether a generic decorator can legally be closed over a registration's type arguments. +/// +/// +/// +/// A decorator may constrain its type parameters more tightly than the service does — +/// Logging<T> : IHandler<T> where T : class is ordinary, and so is a registration +/// of IHandler<int>. Both declarations are legal; closing one over the other is not, +/// and emitting it anyway produces CS0452 in generated code, which is the failure this +/// generator is built never to produce. +/// +/// +/// Checked against symbols rather than against the rendered type names, because the question — +/// is this a reference type, does it implement that interface — is a semantic one. A closing that +/// cannot be resolved is allowed rather than dropped: the compiler will say so at the call site, +/// which is better than a decoration going missing for a reason nothing reports. +/// +/// +public static class DecoratorConstraintChecker { + + public static bool CanClose( + Compilation compilation, ITypeDefinition decoratorType, GenericTypeDefinition closedService) { + + var decorator = Resolve(compilation, decoratorType); + + if (decorator == null || decorator.TypeParameters.Length != closedService.TypeArguments.Count) { + return true; + } + + for (var i = 0; i < decorator.TypeParameters.Length; i++) { + var argument = Resolve(compilation, closedService.TypeArguments[i]); + + if (argument != null && !Satisfies(decorator.TypeParameters[i], argument)) { + return false; + } + } + + return true; + } + + private static bool Satisfies(ITypeParameterSymbol parameter, INamedTypeSymbol argument) { + if (parameter.HasReferenceTypeConstraint && !argument.IsReferenceType) { + return false; + } + + if (parameter.HasValueTypeConstraint && !argument.IsValueType) { + return false; + } + + if (parameter.HasConstructorConstraint && + !argument.InstanceConstructors.Any( + constructor => constructor.Parameters.Length == 0 && + constructor.DeclaredAccessibility == Accessibility.Public)) { + return false; + } + + foreach (var constraint in parameter.ConstraintTypes) { + if (!Implements(argument, constraint)) { + return false; + } + } + + return true; + } + + private static bool Implements(INamedTypeSymbol argument, ITypeSymbol constraint) { + // A constraint naming another type parameter cannot be checked without the whole + // substitution, and the service's own constraints already cover the usual case. + if (constraint is ITypeParameterSymbol) { + return true; + } + + if (SymbolEqualityComparer.Default.Equals(argument, constraint)) { + return true; + } + + foreach (var implemented in argument.AllInterfaces) { + if (SymbolEqualityComparer.Default.Equals(implemented.OriginalDefinition, constraint.OriginalDefinition)) { + return true; + } + } + + for (var baseType = argument.BaseType; baseType != null; baseType = baseType.BaseType) { + if (SymbolEqualityComparer.Default.Equals(baseType.OriginalDefinition, constraint.OriginalDefinition)) { + return true; + } + } + + return false; + } + + /// + /// The C# keyword spellings, which is how a primitive type argument reaches here. + /// + /// + /// IHandler<int> renders its argument as int with no namespace, and + /// GetTypeByMetadataName("int") finds nothing — so a value type would look unresolvable + /// and be allowed through, which is exactly the case this class exists to catch. + /// + private static readonly Dictionary Aliases = new() { + ["bool"] = "System.Boolean", ["byte"] = "System.Byte", ["sbyte"] = "System.SByte", + ["char"] = "System.Char", ["decimal"] = "System.Decimal", ["double"] = "System.Double", + ["float"] = "System.Single", ["int"] = "System.Int32", ["uint"] = "System.UInt32", + ["long"] = "System.Int64", ["ulong"] = "System.UInt64", ["short"] = "System.Int16", + ["ushort"] = "System.UInt16", ["nint"] = "System.IntPtr", ["nuint"] = "System.UIntPtr", + ["object"] = "System.Object", ["string"] = "System.String", + }; + + private static INamedTypeSymbol? Resolve(Compilation compilation, ITypeDefinition type) { + var name = string.IsNullOrEmpty(type.Namespace) ? type.Name : type.Namespace + "." + type.Name; + + if (Aliases.TryGetValue(name, out var metadataName)) { + name = metadataName; + } + + if (type is GenericTypeDefinition { TypeArguments.Count: > 0 } generic) { + name += "`" + generic.TypeArguments.Count; + } + + return compilation.GetTypeByMetadataName(name); + } +} diff --git a/src/DependencyModules.SourceGenerator.Impl/Utilities/DecoratorExpansion.cs b/src/DependencyModules.SourceGenerator.Impl/Utilities/DecoratorExpansion.cs new file mode 100644 index 0000000..dca92a6 --- /dev/null +++ b/src/DependencyModules.SourceGenerator.Impl/Utilities/DecoratorExpansion.cs @@ -0,0 +1,89 @@ +using CSharpAuthor; +using DependencyModules.SourceGenerator.Impl.Models; + +namespace DependencyModules.SourceGenerator.Impl.Utilities; + +/// +/// Turns each generic decorator into one decoration per closed registration it applies to. +/// +/// +/// +/// A non-generic decorator passes through unchanged: it already names one service type. A generic one +/// names an open generic, and there is no closed call to emit for that — so it is expanded against +/// the registrations that close it. +/// +/// +/// Shared by both generators, because both hold registrations the other cannot see: the attribute +/// path has what [SingletonService] and friends registered, the convention path has what +/// RegisterAll matched. Each expands the same declaration against its own set, and +/// DecoratorHelper refuses to apply one decorator to a descriptor twice where the two sets +/// name the same closed service. +/// +/// +public static class DecoratorExpansion { + + public static IReadOnlyList Expand( + IReadOnlyList decorators, + IReadOnlyList registeredServiceTypes, + bool includeNonGeneric = true, + Func? canClose = null) { + + var expanded = new List(decorators.Count); + + foreach (var decorator in decorators) { + if (decorator.IsIgnored) { + continue; + } + + if (!decorator.IsOpenGeneric) { + // A non-generic decorator names one service type and needs no expansion, so only + // the pass that owns the declaration emits it. Anything that cannot be constructed + // by generated code is dropped: generated code builds the decorator with a literal + // new, and the reflective overload that used to stand in for this is gone because + // it never worked in a published application. + if (includeNonGeneric && decorator.CanMonomorphise) { + expanded.Add(decorator); + } + + continue; + } + + foreach (var serviceType in registeredServiceTypes) { + if (!ClosesTheSameGeneric(serviceType, decorator.ServiceType)) { + continue; + } + + var closedService = (GenericTypeDefinition)serviceType; + + // A decorator may constrain its type parameters more tightly than the service does. + // Closing it over an argument that violates one emits code that does not compile. + if (canClose != null && !canClose(decorator.DecoratorType, closedService)) { + continue; + } + + var closed = DecoratorTypeUtility.Close(decorator, closedService); + + if (closed == null) { + continue; + } + + expanded.Add(closed); + } + } + + return expanded; + } + + /// + /// Whether a registered service type is a closed construction of the decorated open generic. + /// + private static bool ClosesTheSameGeneric(ITypeDefinition registered, ITypeDefinition decorated) => + registered is GenericTypeDefinition closed && + decorated is GenericTypeDefinition open && + closed.TypeArguments.Count == open.TypeArguments.Count && + closed.Name == open.Name && + closed.Namespace == open.Namespace && + // The decorated form has its arguments blanked; a registration that also has them blanked is + // an open generic registration, which cannot be decorated at all. + closed.TypeArguments.Any(argument => !string.IsNullOrEmpty(argument.Name)); +} diff --git a/src/DependencyModules.SourceGenerator.Impl/Utilities/DecoratorModelUtility.cs b/src/DependencyModules.SourceGenerator.Impl/Utilities/DecoratorModelUtility.cs index 219318a..405a28b 100644 --- a/src/DependencyModules.SourceGenerator.Impl/Utilities/DecoratorModelUtility.cs +++ b/src/DependencyModules.SourceGenerator.Impl/Utilities/DecoratorModelUtility.cs @@ -52,17 +52,19 @@ public static class DecoratorModelUtility { } } - var serviceType = explicitService ?? InferDecoratedService(typeDeclarationSyntax, context, implemented); + var written = explicitService ?? InferDecoratedService(typeDeclarationSyntax, context, implemented); - if (serviceType == null) { + if (written == null) { return null; } + var serviceType = written; + // A generic decorator decorates the open service. Its base list names the service closed over - // its own type parameters, as IHandler, which is not a legal typeof argument; the unbound - // IHandler<> is what has to be emitted, and what DecoratorHelper matches registrations by. + // its own type parameters, as IHandler; the unbound IHandler<> is the form the model + // carries, and the closed constructions to emit against are worked out from the registrations. if (decoratorType is GenericTypeDefinition { TypeArguments.Count: > 0 }) { - serviceType = ToUnboundGeneric(serviceType); + serviceType = ToUnboundGeneric(written); } // Read from the decorator class, exactly as they are for a service. A decorator is a @@ -70,7 +72,77 @@ public static class DecoratorModelUtility { var conditions = EnvironmentConditionUtility.GetConditions( context, typeDeclarationSyntax, cancellationToken); - return new DecoratorModel(serviceType, decoratorType, order, realm, conditions); + var constructor = ServiceModelUtility.GetConstructorInfo( + context, typeDeclarationSyntax, cancellationToken); + + return new DecoratorModel( + serviceType, + decoratorType, + order, + realm, + conditions, + constructor, + IndexOfInnerParameter(constructor, written), + TypeParametersMatchService(typeDeclarationSyntax, written)); + } + + /// + /// Which constructor parameter takes the service being wrapped. + /// + /// + /// Matched on the service as written on the class rather than on the unbound form, because that + /// is what the parameter is declared as — IHandler<TReq, TRes>, not + /// IHandler<,>. Nullability is normalised away: IGreeter? inner is legal and + /// carries an annotation the service type does not, and comparing them as written finds no + /// parameter at all — which drops the decoration with nothing said. + /// + private static int IndexOfInnerParameter(ConstructorInfoModel? constructor, ITypeDefinition serviceType) { + if (constructor == null) { + return -1; + } + + var wanted = serviceType.MakeNullable(false); + + for (var i = 0; i < constructor.Parameters.Count; i++) { + if (constructor.Parameters[i].ParameterType.MakeNullable(false).Equals(wanted)) { + return i; + } + } + + return -1; + } + + /// + /// Whether closing the service over a set of types means closing the decorator over the same set. + /// + /// + /// True for Logging<TReq, TRes> : IHandler<TReq, TRes> and false for anything + /// that reorders, drops or reuses a parameter. The false cases are legal C# but cannot be + /// monomorphised by position, and guessing at them would emit a new with the arguments + /// the wrong way round — which compiles when the two types happen to be compatible. Nothing is + /// emitted for them instead. + /// + private static bool TypeParametersMatchService( + TypeDeclarationSyntax typeDeclarationSyntax, ITypeDefinition serviceType) { + + var declared = typeDeclarationSyntax.TypeParameterList?.Parameters; + + if (declared is not { Count: > 0 }) { + return true; + } + + if (serviceType is not GenericTypeDefinition generic || + generic.TypeArguments.Count != declared.Value.Count) { + return false; + } + + for (var i = 0; i < declared.Value.Count; i++) { + if (generic.TypeArguments[i].Name != declared.Value[i].Identifier.Text) { + return false; + } + } + + return true; } /// @@ -116,8 +188,13 @@ attribute.Arguments[0].Value is not ITypeDefinition service || } foreach (var parameterType in GetConstructorParameterTypes(typeDeclarationSyntax, context)) { + // Normalised, because `IGreeter? inner` is legal and its parameter type carries an + // annotation the implemented interface does not. Compared as written, no parameter looks + // like the service and the class stops being a decorator at all — silently. + var declared = parameterType.MakeNullable(false); + foreach (var candidate in implemented) { - if (candidate.Equals(parameterType)) { + if (candidate.MakeNullable(false).Equals(declared)) { return candidate; } } diff --git a/src/DependencyModules.SourceGenerator.Impl/Utilities/DecoratorTypeUtility.cs b/src/DependencyModules.SourceGenerator.Impl/Utilities/DecoratorTypeUtility.cs new file mode 100644 index 0000000..f26c343 --- /dev/null +++ b/src/DependencyModules.SourceGenerator.Impl/Utilities/DecoratorTypeUtility.cs @@ -0,0 +1,117 @@ +using CSharpAuthor; +using DependencyModules.SourceGenerator.Impl.Models; + +namespace DependencyModules.SourceGenerator.Impl.Utilities; + +/// +/// Closes a generic decorator over the type arguments one registration used. +/// +/// +/// This is the whole of monomorphisation. Logging<TReq, TRes> decorating +/// IHandler<TReq, TRes>, against a registration of +/// IHandler<CreateOrder, OrderId>, becomes a decoration of that closed service by +/// Logging<CreateOrder, OrderId> — with every constructor parameter substituted too, so +/// a decorator taking IValidator<TReq> resolves IValidator<CreateOrder> +/// rather than something the compiler would reject. +/// +public static class DecoratorTypeUtility { + + /// + /// The decoration to emit for one closed registration, or null when the decorator cannot be + /// closed over it. + /// + public static DecoratorModel? Close(DecoratorModel decorator, GenericTypeDefinition closedService) { + if (!decorator.CanMonomorphise) { + return null; + } + + var parameterNames = TypeParameterNames(decorator); + + if (parameterNames == null || parameterNames.Count != closedService.TypeArguments.Count) { + return null; + } + + var substitutions = new Dictionary(parameterNames.Count); + + for (var i = 0; i < parameterNames.Count; i++) { + substitutions[parameterNames[i]] = closedService.TypeArguments[i]; + } + + var parameters = new List(decorator.Constructor!.Parameters.Count); + + foreach (var parameter in decorator.Constructor.Parameters) { + parameters.Add(parameter with { + ParameterType = Substitute(parameter.ParameterType, substitutions) + }); + } + + return decorator with { + ServiceType = closedService, + DecoratorType = CloseDecorator(decorator.DecoratorType, closedService.TypeArguments), + Constructor = new ConstructorInfoModel(parameters) + }; + } + + /// + /// The decorator's type parameter names, in order. + /// + /// + /// Read off the constructor parameter that takes the service being wrapped, because that is the + /// one place the service still appears as written — IHandler<TReq, TRes>. The model's + /// own service and decorator types have had their arguments blanked, since neither an unbound + /// generic nor a type parameter is a legal typeof argument. + /// + /// Safe only because is true, which is + /// what guarantees these names are also the decorator's own parameters, in the same order. + /// + private static IReadOnlyList? TypeParameterNames(DecoratorModel decorator) { + var inner = decorator.Constructor!.Parameters[decorator.InnerParameterIndex].ParameterType; + + if (inner is not GenericTypeDefinition generic || generic.TypeArguments.Count == 0) { + return null; + } + + var names = new List(generic.TypeArguments.Count); + + foreach (var argument in generic.TypeArguments) { + if (string.IsNullOrEmpty(argument.Name)) { + return null; + } + + names.Add(argument.Name); + } + + return names; + } + + private static ITypeDefinition CloseDecorator( + ITypeDefinition decoratorType, IReadOnlyList typeArguments) => + decoratorType is GenericTypeDefinition generic + ? new GenericTypeDefinition( + generic.TypeDefinitionEnum, generic.Namespace, generic.Name, typeArguments.ToArray()) + : decoratorType; + + /// + /// Replaces type parameters with the arguments the registration closed them over, at any depth. + /// + private static ITypeDefinition Substitute( + ITypeDefinition type, Dictionary substitutions) { + + if (type is GenericTypeDefinition generic) { + var arguments = new ITypeDefinition[generic.TypeArguments.Count]; + + for (var i = 0; i < arguments.Length; i++) { + arguments[i] = Substitute(generic.TypeArguments[i], substitutions); + } + + return new GenericTypeDefinition( + generic.TypeDefinitionEnum, generic.Namespace, generic.Name, arguments); + } + + // A type parameter has no namespace; anything with one is an ordinary type and is left alone + // even if it shares a name with a parameter. + return string.IsNullOrEmpty(type.Namespace) && substitutions.TryGetValue(type.Name, out var closed) + ? closed + : type; + } +} diff --git a/src/DependencyModules.SourceGenerator.Impl/Utilities/ITypeDefinitionExtensions.cs b/src/DependencyModules.SourceGenerator.Impl/Utilities/ITypeDefinitionExtensions.cs index 010138f..5ab34b3 100644 --- a/src/DependencyModules.SourceGenerator.Impl/Utilities/ITypeDefinitionExtensions.cs +++ b/src/DependencyModules.SourceGenerator.Impl/Utilities/ITypeDefinitionExtensions.cs @@ -3,6 +3,25 @@ namespace DependencyModules.SourceGenerator.Impl.Utilities; public static class ITypeDefinitionExtensions { + + /// + /// Rewrites a generic type's arguments to nothing, so it renders as IRepo<>. + /// + /// + /// typeof(IRepo<>) resolves to the unbound symbol, whose TypeArguments are + /// the declaration's type parameters — so rendering it verbatim produces + /// typeof(IRepo<T>), and T means nothing where the attribute is re-emitted. + /// The unbound form is the only legal way to write it, and this is what produces it. + /// + public static ITypeDefinition ToUnboundGeneric(this ITypeDefinition type) => + type is GenericTypeDefinition { TypeArguments.Count: > 0 } generic + ? new GenericTypeDefinition( + generic.TypeDefinitionEnum, + generic.Namespace, + generic.Name, + generic.TypeArguments.Select(_ => (ITypeDefinition)TypeDefinition.Get("", "")).ToArray()) + : type; + public static string GetFileNameHint(this ITypeDefinition typeDefinition, string rootNamespace, string uniquePart) { var nameString = typeDefinition.Namespace; diff --git a/src/DependencyModules.SourceGenerator.Impl/Utilities/InterceptedMemberReader.cs b/src/DependencyModules.SourceGenerator.Impl/Utilities/InterceptedMemberReader.cs index f9eb73d..37238a3 100644 --- a/src/DependencyModules.SourceGenerator.Impl/Utilities/InterceptedMemberReader.cs +++ b/src/DependencyModules.SourceGenerator.Impl/Utilities/InterceptedMemberReader.cs @@ -295,7 +295,8 @@ private static IEnumerable EnumerateMembers(INamedTypeSymbol serviceTyp parameter.Name, EscapeIdentifier(parameter.Name), parameter.Type.GetTypeDefinition(), - RenderDefaultValue(parameter))); + RenderDefaultValue(parameter), + parameter.IsParams)); } return parameters; diff --git a/src/DependencyModules.SourceGenerator.Impl/Utilities/InterceptorModelUtility.cs b/src/DependencyModules.SourceGenerator.Impl/Utilities/InterceptorModelUtility.cs index af6b982..f439694 100644 --- a/src/DependencyModules.SourceGenerator.Impl/Utilities/InterceptorModelUtility.cs +++ b/src/DependencyModules.SourceGenerator.Impl/Utilities/InterceptorModelUtility.cs @@ -31,9 +31,9 @@ public static InterceptorModel GetInterceptorModel( return InterceptorModel.Ignore; } - var attribute = FindAttribute(typeDeclarationSyntax); + var attributes = FindAttributes(typeDeclarationSyntax); - if (attribute == null) { + if (attributes.Count == 0) { return InterceptorModel.Ignore; } @@ -57,7 +57,12 @@ public static InterceptorModel GetInterceptorModel( var order = 0; INamedTypeSymbol? explicitService = null; - ReadAttribute(attribute, context, cancellationToken, interceptorSymbols, ref order, ref explicitService); + // Every [Intercept], not the first. The attribute is AllowMultiple, so stacking them is a + // supported way to write what one attribute can also express as a params list — and reading + // only the first dropped every interceptor after it, with nothing to say so. + foreach (var attribute in attributes) { + ReadAttribute(attribute, context, cancellationToken, interceptorSymbols, ref order, ref explicitService); + } if (interceptorSymbols.Count == 0) { return InterceptorModel.Ignore; @@ -277,17 +282,19 @@ private static ITypeDefinition ToTypeDefinition(INamedTypeSymbol symbol) { return TypeDefinition.Get(namespaceName, name); } - private static AttributeSyntax? FindAttribute(TypeDeclarationSyntax typeDeclarationSyntax) { + private static List FindAttributes(TypeDeclarationSyntax typeDeclarationSyntax) { + var attributes = new List(); + foreach (var attributeList in typeDeclarationSyntax.AttributeLists) { foreach (var attribute in attributeList.Attributes) { var name = attribute.Name.ToString(); if (name is "Intercept" or "InterceptAttribute") { - return attribute; + attributes.Add(attribute); } } } - return null; + return attributes; } } diff --git a/src/DependencyModules.SourceGenerator.Impl/Utilities/ModuleDecoratorResolver.cs b/src/DependencyModules.SourceGenerator.Impl/Utilities/ModuleDecoratorResolver.cs new file mode 100644 index 0000000..ea22b61 --- /dev/null +++ b/src/DependencyModules.SourceGenerator.Impl/Utilities/ModuleDecoratorResolver.cs @@ -0,0 +1,119 @@ +using CSharpAuthor; +using DependencyModules.SourceGenerator.Impl.Models; +using Microsoft.CodeAnalysis; + +namespace DependencyModules.SourceGenerator.Impl.Utilities; + +/// +/// Fills in what [Decorate(typeof(IFoo), typeof(FooDecorator))] cannot say. +/// +/// +/// +/// The attribute names both types and nothing else. [Decorator] on a class is read from the +/// declaration, so its constructor comes for free; this form names a type that may be declared +/// anywhere, including in a referenced assembly — which is the reason the module-level form exists. +/// +/// +/// So the constructor is looked up from the compilation. That is the only way to emit a literal +/// new for it, and emitting one is what makes the decoration survive publishing. +/// +/// +public static class ModuleDecoratorResolver { + + /// + /// A resolved decorator, or the reason it could not be. + /// + /// + /// Null when can be emitted as a closed call. Otherwise why not, + /// for the log — the decoration is simply not emitted. + /// + public record Resolution(DecoratorModel Model, string? Reason); + + /// + /// Resolves every [Decorate] a module declares. + /// + public static IReadOnlyList Resolve( + ModuleEntryPointModel entryPointModel, + Compilation compilation, + CancellationToken cancellationToken) { + + var resolutions = new List(); + + foreach (var decorator in DecoratorModelUtility.GetModuleDeclaredDecorators(entryPointModel)) { + cancellationToken.ThrowIfCancellationRequested(); + + resolutions.Add(Resolve(decorator, compilation)); + } + + return resolutions; + } + + private static Resolution Resolve(DecoratorModel decorator, Compilation compilation) { + var symbol = Find(compilation, decorator.DecoratorType); + + if (symbol == null) { + return new Resolution( + decorator, "its type could not be resolved from this compilation or its references"); + } + + var constructor = SymbolConstructorReader.Read(symbol); + + if (constructor == null) { + return new Resolution(decorator, "it has no public constructor"); + } + + var innerIndex = IndexOfInner(constructor, decorator.ServiceType); + + if (innerIndex < 0) { + return new Resolution( + decorator, + $"no constructor parameter takes '{decorator.ServiceType.Name}', so there is nowhere " + + "to pass the instance being wrapped"); + } + + return new Resolution( + decorator with { Constructor = constructor, InnerParameterIndex = innerIndex }, + null); + } + + /// + /// Which constructor parameter takes the service being wrapped. + /// + /// + /// A generic decorator declares the parameter closed over its own type parameters — + /// IHandler<T> — while the attribute named the unbound IHandler<>. + /// Those never compare equal, so the comparison is made on the unbound form of both. The stored + /// parameter type keeps its names, because closing the decorator over a registration reads the + /// type parameter order back off it. + /// + private static int IndexOfInner(ConstructorInfoModel constructor, ITypeDefinition serviceType) { + var wanted = serviceType.ToUnboundGeneric(); + + for (var i = 0; i < constructor.Parameters.Count; i++) { + var parameterType = constructor.Parameters[i].ParameterType.MakeNullable(false); + + if (parameterType.Equals(serviceType) || parameterType.ToUnboundGeneric().Equals(wanted)) { + return i; + } + } + + return -1; + } + + /// + /// The symbol for a type the attribute named. + /// + /// + /// A generic decorator arrives with its arguments blanked, because an unbound generic is what a + /// typeof can carry — so the metadata name needs the arity back on it. + /// + private static INamedTypeSymbol? Find(Compilation compilation, ITypeDefinition type) { + var name = string.IsNullOrEmpty(type.Namespace) ? type.Name : type.Namespace + "." + type.Name; + + if (type is GenericTypeDefinition { TypeArguments.Count: > 0 } generic) { + name += "`" + generic.TypeArguments.Count; + } + + return compilation.GetTypeByMetadataName(name); + } +} diff --git a/src/DependencyModules.SourceGenerator.Impl/Utilities/SymbolConstructorReader.cs b/src/DependencyModules.SourceGenerator.Impl/Utilities/SymbolConstructorReader.cs new file mode 100644 index 0000000..ca933b4 --- /dev/null +++ b/src/DependencyModules.SourceGenerator.Impl/Utilities/SymbolConstructorReader.cs @@ -0,0 +1,133 @@ +using CSharpAuthor; +using DependencyModules.SourceGenerator.Impl.Models; +using Microsoft.CodeAnalysis; + +namespace DependencyModules.SourceGenerator.Impl.Utilities; + +/// +/// Reads the constructor the container would pick, from a symbol rather than from syntax. +/// +/// +/// +/// reads a declaration, which only works for a +/// type declared in the compilation being built. Three things need a constructor for a type named +/// rather than declared: [Decorate(typeof(IFoo), typeof(FooDecorator))] on a module, which +/// names its decorator by typeof; convention scanning of a referenced assembly; and reading a +/// package's [Decorator] across an assembly boundary. +/// +/// +/// It exists because generated code constructs the decorator with a literal new. The +/// alternative is ActivatorUtilities over a at run time, which reflects on +/// every resolution and is the shape a published Native AOT build has no code for. +/// +/// +public static class SymbolConstructorReader { + + private const string ActivatorUtilitiesConstructor = "ActivatorUtilitiesConstructorAttribute"; + + private const string FromKeyedServices = "FromKeyedServicesAttribute"; + + /// + /// The constructor to emit a new for, or null when the type has no public one. + /// + /// + /// Null is the answer to "this cannot be constructed by generated code", and the caller reports + /// it. Falling back to something reflective would trade a build error for a failure at resolve + /// time in a published application. + /// + public static ConstructorInfoModel? Read(INamedTypeSymbol type) { + var chosen = Choose(type); + + return chosen == null ? null : new ConstructorInfoModel(Parameters(chosen)); + } + + /// + /// [ActivatorUtilitiesConstructor] if one is marked, otherwise the greediest public one. + /// + /// + /// Same precedence the syntax path applies, and the same the container would apply. A type that + /// opted into a specific constructor and silently got a different one is the kind of difference + /// nobody looks for. + /// + private static IMethodSymbol? Choose(INamedTypeSymbol type) { + IMethodSymbol? greediest = null; + + foreach (var constructor in type.InstanceConstructors) { + if (constructor.DeclaredAccessibility != Accessibility.Public || constructor.IsStatic) { + continue; + } + + foreach (var attribute in constructor.GetAttributes()) { + if (attribute.AttributeClass?.Name == ActivatorUtilitiesConstructor) { + return constructor; + } + } + + if (greediest == null || constructor.Parameters.Length > greediest.Parameters.Length) { + greediest = constructor; + } + } + + return greediest; + } + + private static IReadOnlyList Parameters(IMethodSymbol constructor) { + var parameters = new List(constructor.Parameters.Length); + + foreach (var parameter in constructor.Parameters) { + parameters.Add(new ParameterInfoModel( + parameter.Name, + TypeOf(parameter), + parameter.HasExplicitDefaultValue ? parameter.ExplicitDefaultValue : null, + Attributes(parameter))); + } + + return parameters; + } + + /// + /// The parameter's type, carrying its nullability. + /// + /// + /// Nullability is not decoration here: it decides whether the emitted call resolves with + /// GetService or GetRequiredService, so an optional dependency that lost its + /// annotation would start throwing when the container simply does not have one. + /// + private static ITypeDefinition TypeOf(IParameterSymbol parameter) { + var definition = parameter.Type.GetTypeDefinition(); + + return parameter.NullableAnnotation == NullableAnnotation.Annotated + ? definition.MakeNullable() + : definition; + } + + /// + /// The parameter attributes the emitted call has to honour. + /// + /// + /// Only [FromKeyedServices], and only its key. Everything else on a parameter is the + /// declaring assembly's business; this one changes which registration the generated code + /// resolves, and getting it wrong returns the right type and the wrong instance with nothing + /// reported. + /// + private static IReadOnlyList Attributes(IParameterSymbol parameter) { + List? attributes = null; + + foreach (var attribute in parameter.GetAttributes()) { + if (attribute.AttributeClass?.Name != FromKeyedServices || + attribute.ConstructorArguments.Length == 0) { + continue; + } + + attributes ??= new List(1); + + attributes.Add(new AttributeModel( + KnownTypes.Microsoft.DependencyInjection.FromKeyedServicesAttribute, + new[] { new AttributeArgumentValue("key", attribute.ConstructorArguments[0].Value) }, + Array.Empty(), + Array.Empty())); + } + + return (IReadOnlyList?)attributes ?? Array.Empty(); + } +} diff --git a/src/DependencyModules.SourceGenerator/Conventions/ConventionContractSource.cs b/src/DependencyModules.SourceGenerator/Conventions/ConventionContractSource.cs new file mode 100644 index 0000000..ca901d9 --- /dev/null +++ b/src/DependencyModules.SourceGenerator/Conventions/ConventionContractSource.cs @@ -0,0 +1,39 @@ +namespace DependencyModules.Conventions; + +/// +/// The names the generator matches convention declarations against. +/// +/// +/// +/// The types themselves are declared in DependencyModules.Runtime, under this same +/// namespace. They used to be emitted into every consuming compilation, which forced them to be +/// internal — and therefore forced explicit interface implementation — and made CS0436 +/// unavoidable between two assemblies that both emitted them and referenced each other. +/// +/// +/// An analyzer must not load the runtime assembly, so the names are duplicated here as strings +/// rather than read off the types. ConventionContractTests asserts the two agree; without it +/// a rename on either side would stop every convention matching, silently. +/// +/// +public static class ConventionContractSource { + + /// + /// The namespace the contracts are declared in, and the metadata prefix the generator matches + /// declarations against. Deliberately not this assembly's own namespace: the contracts ship in + /// DependencyModules.Runtime, and sharing a namespace across the two would be ambiguous wherever + /// both are referenced. + /// + public const string Namespace = "DependencyModules.Runtime.Conventions"; + + /// + /// The interface a module implements to opt into convention registration. + /// + public const string ConventionModule = "IConventionModule"; + + /// + /// The method the generator reads. Implemented explicitly, so the name is fixed. + /// + public const string ConventionMethod = "Conventions"; + +} diff --git a/src/DependencyModules.SourceGenerator/Conventions/ConventionGenerator.cs b/src/DependencyModules.SourceGenerator/Conventions/ConventionGenerator.cs new file mode 100644 index 0000000..238589c --- /dev/null +++ b/src/DependencyModules.SourceGenerator/Conventions/ConventionGenerator.cs @@ -0,0 +1,486 @@ +using CSharpAuthor; +using System.Collections.Immutable; +using System.Text; +using DependencyModules.Conventions.Models; +using DependencyModules.Conventions.Utilities; +using DependencyModules.SourceGenerator.Impl; +using DependencyModules.SourceGenerator.Impl.Models; +using DependencyModules.SourceGenerator.Impl.Utilities; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace DependencyModules.Conventions; + +/// +/// A module-level [Decorate] after its decorator's constructor has been looked up. +/// +public record ResolvedModuleDecorator( + ITypeDefinition ModuleType, DecoratorModel Model, string? Reason); + +/// +/// The module-level decorations, and the compilation they were resolved from. +/// +/// +/// The compilation travels with them rather than being combined into the output stage directly. +/// Combined directly it would change on every keystroke and re-run emission every time; carried +/// inside a value that compares on its resolved decorations alone, an edit that changes no +/// declaration propagates nothing. +/// +/// It is needed at emission because a generic decorator may constrain its type parameters more +/// tightly than the service does, and whether a registration's arguments satisfy that is a question +/// only symbols can answer. +/// +public record ModuleDecorators( + EquatableList Resolved, Compilation Compilation) { + + public virtual bool Equals(ModuleDecorators? other) => + other is not null && Resolved.Equals(other.Resolved); + + public override int GetHashCode() => Resolved.GetHashCode(); +} + +/// +/// Convention registration: discovery, matching and emission. +/// +/// +/// This shipped as its own analyzer package so a project that did not use conventions never loaded +/// the class-scanning provider. That boundary is gone, for two reasons that outweighed it. +/// +/// A generic decorator has to be closed over the type arguments each registration used, and the +/// registrations conventions produce were invisible to the generator that emits decorations — so one +/// open-generic runtime call stood in for all of them, and that call cannot work in a published +/// Native AOT application. One assembly is what lets both paths emit closed calls. +/// +/// And the scan is no longer what it was: the candidate transform is cached on the declaration and a +/// stamp of everything that can change what a name binds to, which took the per-keystroke cost from +/// 11–39 ms at 2,000 classes to a flat ~11 ms, and the convention half of that to ~2.4 ms. +/// +public class ConventionGenerator : IDependencyModuleSourceGenerator { + + private const string LoggerName = "ConventionSourceGenerator"; + + public void SetupGenerator( + IncrementalGeneratorInitializationContext context, + IncrementalValuesProvider<(ModuleEntryPointModel Left, DependencyModuleConfigurationModel Right)> incrementalValueProvider) { + + // The contracts used to be emitted here through RegisterPostInitializationOutput. They now + // live in DependencyModules.Runtime, which is what lets them be public — and public is what + // retires the explicit implementation requirement and the CS0436 between two assemblies that + // both emitted them. Nothing is emitted before the pipeline any more. + + // Interface implementation rather than an attribute, so this cannot be + // ForAttributeWithMetadataName. The predicate rejects on node type and base list before + // looking at anything, which is the cheap kind of scan — the same reason module discovery + // is still a syntax provider. + // Lambdas rather than method groups: SyntaxTransformContext converts implicitly from + // GeneratorSyntaxContext, but a method group conversion will not apply a user-defined + // conversion to a parameter. + var conventionModules = context.SyntaxProvider + .CreateSyntaxProvider( + ConventionModelUtility.IsConventionModuleCandidate, + (syntaxContext, cancellation) => + ConventionModelUtility.GetConventionModuleModel(syntaxContext, cancellation)) + .Where(model => !model.IsIgnored) + .Collect(); + + // Through the cache rather than straight to the utility. Roslyn re-runs this transform for + // every candidate whenever any tree changes, so on a normal keystroke almost all of these + // calls are recomputing a model identical to the one they produced last time. + // Decorators, so a generic one can be expanded against what the conventions register. The + // attribute path expands the same declaration against its own registrations; the two sets + // are different, and DecoratorHelper refuses to apply one decorator to a descriptor twice. + var decorators = AttributeModelCollector.Collect( + context, + new[] { KnownTypes.DependencyModules.Attributes.DecoratorAttribute }, + static (syntaxContext, cancellation) => + DecoratorModelUtility.GetDecoratorModel(syntaxContext, cancellation) ?? DecoratorModel.Ignore, + new DecoratorModelComparer(), + DecoratorModel.Ignore); + + // The registrations the *attributes* made. Decoration needs them and the convention ones in + // the same place: a generic decorator is closed over the type arguments a registration used, + // and expanding it twice against two halves of the picture is how the same declaration ended + // up emitted from two stages that could not see each other. + var attributeServices = AttributeModelCollector.Collect( + context, + new[] { + KnownTypes.DependencyModules.Attributes.TransientServiceAttribute, + KnownTypes.DependencyModules.Attributes.ScopedServiceAttribute, + KnownTypes.DependencyModules.Attributes.SingletonServiceAttribute, + KnownTypes.DependencyModules.Attributes.CrossWireServiceAttribute + }, + static (syntaxContext, cancellation) => + ServiceModelUtility.GetServiceModel(syntaxContext, cancellation) ?? ServiceModel.Ignore, + new ServiceModelComparer(), + ServiceModel.Ignore); + + // [Decorate] on a module names its decorator by typeof(), so the constructor has to be + // looked up from the compilation. Resolved here rather than in the output stage: the + // compilation changes on every keystroke, and combining it into the output would re-emit + // everything every time. The result is compared by value, so an unchanged lookup propagates + // nothing — the same shape the metadata scan below uses, and for the same reason. + var moduleDecorators = incrementalValueProvider.Collect() + .Combine(context.CompilationProvider) + .Select((pair, cancellation) => { + var resolved = new List(); + + foreach (var (entryPoint, _) in pair.Left) { + foreach (var resolution in + ModuleDecoratorResolver.Resolve(entryPoint, pair.Right, cancellation)) { + resolved.Add(new ResolvedModuleDecorator( + entryPoint.EntryPointType, resolution.Model, resolution.Reason)); + } + } + + return new ModuleDecorators( + new EquatableList(resolved), pair.Right); + }); + + var candidates = context.SyntaxProvider + .CreateSyntaxProvider( + ConventionCandidateUtility.IsCandidate, + (syntaxContext, cancellation) => + ConventionCandidateCache.GetOrAdd(syntaxContext, cancellation)) + .Where(model => !model.IsIgnored) + .Collect(); + + // Candidates from assemblies a convention names with InAssemblyOf. Combined with the + // compilation, so this Select re-runs whenever the compilation changes — which is every + // keystroke — but its result is compared by value, so the emission downstream stays cached + // unless the scanned assembly's public surface actually differs. When no convention names an + // assembly it returns an empty list after one pass over the conventions, which is the common + // case and costs nothing. + var metadataCandidates = conventionModules + .Combine(context.CompilationProvider) + .Select((pair, cancellation) => + new EquatableList( + MetadataCandidateUtility.Collect(pair.Left, pair.Right, cancellation))); + + context.RegisterSourceOutput( + incrementalValueProvider.Collect() + .Combine(conventionModules) + .Combine(candidates) + .Combine(metadataCandidates) + .Combine(decorators) + .Combine(attributeServices) + .Combine(moduleDecorators), + GenerateSourceOutput); + } + + private void GenerateSourceOutput( + SourceProductionContext context, + ((((((ImmutableArray<(ModuleEntryPointModel Left, DependencyModuleConfigurationModel Right)> Left, + ImmutableArray Right) Left, + ImmutableArray Right) Left, + EquatableList Right) Left, + ImmutableArray Right) Left, + ImmutableArray Right) Left, + ModuleDecorators Right) data) { + + var entryPoints = data.Left.Left.Left.Left.Left.Left; + var conventionModules = data.Left.Left.Left.Left.Left.Right; + var decorators = data.Left.Left.Right; + var attributeServices = data.Left.Right; + var moduleDecorators = data.Right; + + // In-compilation candidates and metadata candidates travel together; a convention sees one + // source or the other, decided by whether it named an assembly. + var candidates = data.Left.Left.Left.Left.Right.Length == 0 + ? (IReadOnlyList)data.Left.Left.Left.Right + : data.Left.Left.Left.Left.Right.Concat(data.Left.Left.Left.Right).ToArray(); + + // Decoration runs whether or not anything declares a convention, so the early-out is on + // entry points alone. + if (entryPoints.Length == 0) { + return; + } + + var configuration = entryPoints.First().Right; + + FileLogger.Wrap( + LoggerName, + configuration, + logger => Generate( + context, entryPoints, conventionModules, candidates, decorators, attributeServices, + moduleDecorators, logger), + // Surfaced as a build error rather than discarded, matching the attribute generators. A + // generator that fails quietly produces a green build with no registrations. + exception => context.ReportDiagnostic( + Diagnostic.Create( + DependencyModuleDiagnostics.GeneratorFailure, + Location.None, + $"{exception.GetType().Name}: {exception.Message}"))); + } + + private void Generate( + SourceProductionContext context, + ImmutableArray<(ModuleEntryPointModel Left, DependencyModuleConfigurationModel Right)> entryPoints, + ImmutableArray conventionModules, + IReadOnlyList candidates, + ImmutableArray decorators, + ImmutableArray attributeServices, + ModuleDecorators moduleDecorators, + FileLogger logger) { + + var (entryPointList, configurationModel) = EntryModelUtil.ConsolidateEntryPointModels(entryPoints); + + logger.Info( + $"Discovered {conventionModules.Length} convention module(s) and " + + $"{candidates.Count} candidate type(s)."); + + var claimed = new HashSet(); + + foreach (var entryPointModel in entryPointList) { + context.CancellationToken.ThrowIfCancellationRequested(); + + var conventionModule = conventionModules.FirstOrDefault( + module => module.ModuleType.Equals(entryPointModel.EntryPointType)); + + if (conventionModule != null) { + claimed.Add(conventionModule); + } + + GenerateForModule( + context, entryPointModel, configurationModel, conventionModule, candidates, decorators, + attributeServices, moduleDecorators, logger); + } + + ReportUnclaimedModules(context, conventionModules, claimed, logger); + } + + private void GenerateForModule( + SourceProductionContext context, + ModuleEntryPointModel entryPointModel, + DependencyModuleConfigurationModel configurationModel, + ConventionModuleModel? conventionModule, + IReadOnlyList candidates, + ImmutableArray decorators, + ImmutableArray attributeServices, + ModuleDecorators moduleDecorators, + FileLogger logger) { + + var withNamespace = EntryModelUtil.EnsureNamespace(entryPointModel, configurationModel); + + var serviceModels = conventionModule == null + ? Array.Empty() + : ConventionMatcher.Match( + withNamespace, conventionModule, candidates, context.ReportDiagnostic, logger); + + // Every registration this compilation makes, however it was declared. This is the whole + // point of the single stage: a generic decorator is expanded once, against all of them. + WriteDecorators( + context, withNamespace, configurationModel, + ServiceTypes(attributeServices, serviceModels), decorators, moduleDecorators, logger); + + if (serviceModels.Count == 0) { + return; + } + + // coverageAttributeOnMethod: the registrations file already puts ExcludeFromCodeCoverage on + // the partial class, and the attribute is not AllowMultiple, so a second class-level one on + // the same type is CS0579. + var writer = new DependencyFileWriter(logger, coverageAttributeOnMethod: true); + + var output = writer.Write(withNamespace, configurationModel, serviceModels, "Convention"); + + context.AddSource( + withNamespace.EntryPointType.GetFileNameHint( + configurationModel.RootNamespace, "ConventionDependencies"), + output); + } + + /// + /// Every service type the compilation registers, in the closed form it registers it as. + /// + private static IReadOnlyList ServiceTypes( + ImmutableArray attributeServices, IReadOnlyList conventionServices) { + + var seen = new HashSet(); + var ordered = new List(); + + void Add(IEnumerable models) { + foreach (var model in models) { + if (model.Equals(ServiceModel.Ignore)) { + continue; + } + + foreach (var registration in model.Registrations) { + if (seen.Add(registration.ServiceType)) { + ordered.Add(registration.ServiceType); + } + } + } + } + + Add(attributeServices); + Add(conventionServices); + + return ordered; + } + + /// + /// Emits every decoration for one module, from one place. + /// + /// + /// + /// This used to be two stages — one expanding a generic decorator against the attribute + /// registrations, one against the convention registrations — and neither could see the other's + /// set. The same declaration was emitted twice, each half believing the other had nothing, and + /// the only thing standing between that and a service wrapped twice was a run-time guard. + /// + /// + /// One stage with every registration in hand is what makes the expansion answerable: a generic + /// decorator is closed once, over each construction the compilation actually registers. + /// + /// + private static void WriteDecorators( + SourceProductionContext context, + ModuleEntryPointModel entryPointModel, + DependencyModuleConfigurationModel configurationModel, + IReadOnlyList registeredServiceTypes, + ImmutableArray declared, + ModuleDecorators moduleDecorators, + FileLogger logger) { + + var decorators = CollectDecorators(context, entryPointModel, declared, moduleDecorators, logger); + + if (decorators.Count == 0) { + return; + } + + var expanded = DecoratorExpansion.Expand( + decorators, + registeredServiceTypes, + canClose: (decoratorType, closedService) => + DecoratorConstraintChecker.CanClose( + moduleDecorators.Compilation, decoratorType, closedService)); + + if (expanded.Count == 0) { + return; + } + + logger.Info($"{expanded.Count} decoration(s) for {entryPointModel.EntryPointType.Name}."); + + var output = new DecoratorFileWriter().Write(entryPointModel, configurationModel, expanded); + + context.AddSource( + entryPointModel.EntryPointType.GetFileNameHint( + configurationModel.RootNamespace, "Decorators"), + output); + } + + /// + /// The decorators that belong to one module: those declared on a class, filtered by realm, plus + /// those the module declares itself with [Decorate]. + /// + private static IReadOnlyList CollectDecorators( + SourceProductionContext context, + ModuleEntryPointModel entryPointModel, + ImmutableArray declared, + ModuleDecorators moduleDecorators, + FileLogger logger) { + + var decorators = new List(); + + foreach (var decorator in declared) { + if (decorator.IsIgnored) { + continue; + } + + // A realm-scoped decorator belongs only to its realm. An unscoped one belongs to every + // module that is not realm-only, matching how service registrations behave. + if (decorator.Realm != null) { + if (decorator.Realm.Equals(entryPointModel.EntryPointType)) { + decorators.Add(decorator); + } + + continue; + } + + if (!entryPointModel.ModuleFeatures.HasFlag(ModuleEntryPointFeatures.OnlyRealm)) { + decorators.Add(decorator); + } + } + + // [Decorate] carries two type names and nothing else, so its decorator's constructor is + // looked up rather than read from a declaration — the only route for one declared in a + // referenced assembly, which is the case the module-level form exists for. + foreach (var resolution in moduleDecorators.Resolved) { + if (!resolution.ModuleType.Equals(entryPointModel.EntryPointType)) { + continue; + } + + if (resolution.Reason != null) { + logger.Error( + $"'{resolution.Model.DecoratorType.Name}' cannot be constructed by generated " + + $"code: {resolution.Reason}."); + } + + decorators.Add(resolution.Model); + } + + ReportAmbiguousOrdering(context, decorators, logger); + + return decorators; + } + + /// + /// Two decorators of one service sharing an order nest in an order nobody declared, so it is + /// reported rather than resolved arbitrarily. + /// + private static void ReportAmbiguousOrdering( + SourceProductionContext context, IReadOnlyList decorators, FileLogger logger) { + + for (var i = 0; i < decorators.Count; i++) { + for (var j = i + 1; j < decorators.Count; j++) { + if (decorators[i].Order != decorators[j].Order || + !decorators[i].ServiceType.Equals(decorators[j].ServiceType)) { + continue; + } + + logger.Error( + $"'{decorators[i].DecoratorType.Name}' and '{decorators[j].DecoratorType.Name}' both " + + $"decorate '{decorators[i].ServiceType.Name}' with order {decorators[i].Order}."); + + context.ReportDiagnostic( + Diagnostic.Create( + DependencyModuleDiagnostics.AmbiguousDecoratorOrder, + Location.None, + decorators[i].DecoratorType.Name, + decorators[j].DecoratorType.Name, + decorators[i].ServiceType.Name, + decorators[i].Order)); + } + } + } + + /// + /// Reports a type that implements IConventionModule but is not a module. + /// + /// + /// Its conventions would otherwise produce nothing at all, with a green build and no + /// explanation — exactly the silent failure the rest of this generator is built to avoid. + /// + private static void ReportUnclaimedModules( + SourceProductionContext context, + ImmutableArray conventionModules, + HashSet claimed, + FileLogger logger) { + + foreach (var conventionModule in conventionModules) { + if (claimed.Contains(conventionModule)) { + continue; + } + + var name = conventionModule.ModuleType.Name; + + logger.Error($"'{name}' implements IConventionModule but is not a [DependencyModule]."); + + context.ReportDiagnostic(Diagnostic.Create( + DependencyModuleDiagnostics.ConventionCannotBeRead, + Location.None, + "the declaring type is not marked with [DependencyModule], so it registers nothing", + name)); + } + } +} diff --git a/src/DependencyModules.Conventions/Models/ConventionCandidateModel.cs b/src/DependencyModules.SourceGenerator/Conventions/Models/ConventionCandidateModel.cs similarity index 100% rename from src/DependencyModules.Conventions/Models/ConventionCandidateModel.cs rename to src/DependencyModules.SourceGenerator/Conventions/Models/ConventionCandidateModel.cs diff --git a/src/DependencyModules.Conventions/Models/ConventionModel.cs b/src/DependencyModules.SourceGenerator/Conventions/Models/ConventionModel.cs similarity index 100% rename from src/DependencyModules.Conventions/Models/ConventionModel.cs rename to src/DependencyModules.SourceGenerator/Conventions/Models/ConventionModel.cs diff --git a/src/DependencyModules.Conventions/Models/EquatableList.cs b/src/DependencyModules.SourceGenerator/Conventions/Models/EquatableList.cs similarity index 100% rename from src/DependencyModules.Conventions/Models/EquatableList.cs rename to src/DependencyModules.SourceGenerator/Conventions/Models/EquatableList.cs diff --git a/src/DependencyModules.Conventions/Models/LocationModel.cs b/src/DependencyModules.SourceGenerator/Conventions/Models/LocationModel.cs similarity index 52% rename from src/DependencyModules.Conventions/Models/LocationModel.cs rename to src/DependencyModules.SourceGenerator/Conventions/Models/LocationModel.cs index 854a549..93c302f 100644 --- a/src/DependencyModules.Conventions/Models/LocationModel.cs +++ b/src/DependencyModules.SourceGenerator/Conventions/Models/LocationModel.cs @@ -1,4 +1,5 @@ using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; namespace DependencyModules.Conventions.Models; @@ -33,13 +34,39 @@ public Location ToLocation() => new LinePosition(StartLine, StartCharacter), new LinePosition(EndLine, EndCharacter))); - public static LocationModel From(SyntaxNode node) { - var span = node.GetLocation().GetLineSpan(); + public static LocationModel From(SyntaxNode node) => From(NarrowToName(node)); + + /// + /// The span a diagnostic about a declaration should point at: its name, not its whole body. + /// + /// + /// + /// Squiggling the identifier is the better affordance on its own — DM0006 and DM0010 are about + /// the type, not about everything inside it — but the reason it is done here is caching. + /// + /// + /// This model takes part in equality, and the declaration's + /// full span changes length whenever anything inside the class is edited. Keyed on the whole + /// declaration, typing inside any method body produced a model that no longer compared equal, so + /// the convention matcher re-ran over every candidate and re-rendered the file to produce + /// identical text. The identifier does not move when a body below it is edited, so the common + /// keystroke now changes nothing. + /// + /// + private static SyntaxNodeOrToken NarrowToName(SyntaxNode node) => + node switch { + TypeDeclarationSyntax type => type.Identifier, + MethodDeclarationSyntax method => method.Identifier, + _ => node + }; + + private static LocationModel From(SyntaxNodeOrToken nodeOrToken) { + var span = nodeOrToken.GetLocation()!.GetLineSpan(); return new LocationModel( - node.SyntaxTree.FilePath, - node.Span.Start, - node.Span.Length, + nodeOrToken.SyntaxTree!.FilePath, + nodeOrToken.Span.Start, + nodeOrToken.Span.Length, span.StartLinePosition.Line, span.StartLinePosition.Character, span.EndLinePosition.Line, diff --git a/src/DependencyModules.SourceGenerator/Conventions/Utilities/ConventionCandidateCache.cs b/src/DependencyModules.SourceGenerator/Conventions/Utilities/ConventionCandidateCache.cs new file mode 100644 index 0000000..4daf43b --- /dev/null +++ b/src/DependencyModules.SourceGenerator/Conventions/Utilities/ConventionCandidateCache.cs @@ -0,0 +1,59 @@ +using System.Runtime.CompilerServices; +using DependencyModules.Conventions.Models; +using DependencyModules.SourceGenerator.Impl.Utilities; +using Microsoft.CodeAnalysis; + +namespace DependencyModules.Conventions.Utilities; + +/// +/// Caches candidate models across generator runs, keyed on the declaration node and the state of +/// everything that could change what it binds to. +/// +/// +/// +/// Roslyn caches the predicate per tree but re-runs the transform for every node it +/// selected whenever any tree in the compilation changes. Measured on 2,000 classes: editing one +/// method body re-ran the transform 2,001 times, of which one was for the edited tree, over syntax +/// nodes that were the same objects as the previous run. That was 91% of the per-keystroke cost, and +/// this is what removes it — 1,999 hits out of 2,000, 4.1 ms down to 2.0 ms. +/// +/// +/// The table holds nodes weakly, so nothing here pins a syntax tree in memory — the same constraint +/// exists for. A miss is only slower, never wrong: correctness rests +/// entirely on being complete. +/// +/// +public static class ConventionCandidateCache { + + private static readonly ConditionalWeakTable Entries = new(); + + private sealed class Entry { + public Entry(long stamp, ConventionCandidateModel model) { + Stamp = stamp; + Model = model; + } + + public long Stamp { get; } + + public ConventionCandidateModel Model { get; } + } + + public static ConventionCandidateModel GetOrAdd( + SyntaxTransformContext context, CancellationToken cancellationToken) { + + var stamp = DeclarationStamp.Of(context.SemanticModel.Compilation); + + if (Entries.TryGetValue(context.Node, out var entry) && entry.Stamp == stamp) { + return entry.Model; + } + + var model = ConventionCandidateUtility.GetCandidateModel(context, cancellationToken); + + // Remove before adding: the node is the same object across runs, so an entry from a previous + // stamp is still present and Add would throw. + Entries.Remove(context.Node); + Entries.Add(context.Node, new Entry(stamp, model)); + + return model; + } +} diff --git a/src/DependencyModules.Conventions/Utilities/ConventionCandidateUtility.cs b/src/DependencyModules.SourceGenerator/Conventions/Utilities/ConventionCandidateUtility.cs similarity index 100% rename from src/DependencyModules.Conventions/Utilities/ConventionCandidateUtility.cs rename to src/DependencyModules.SourceGenerator/Conventions/Utilities/ConventionCandidateUtility.cs diff --git a/src/DependencyModules.Conventions/Utilities/ConventionMatcher.cs b/src/DependencyModules.SourceGenerator/Conventions/Utilities/ConventionMatcher.cs similarity index 100% rename from src/DependencyModules.Conventions/Utilities/ConventionMatcher.cs rename to src/DependencyModules.SourceGenerator/Conventions/Utilities/ConventionMatcher.cs diff --git a/src/DependencyModules.Conventions/Utilities/ConventionModelUtility.cs b/src/DependencyModules.SourceGenerator/Conventions/Utilities/ConventionModelUtility.cs similarity index 97% rename from src/DependencyModules.Conventions/Utilities/ConventionModelUtility.cs rename to src/DependencyModules.SourceGenerator/Conventions/Utilities/ConventionModelUtility.cs index 683ed81..a8f1a79 100644 --- a/src/DependencyModules.Conventions/Utilities/ConventionModelUtility.cs +++ b/src/DependencyModules.SourceGenerator/Conventions/Utilities/ConventionModelUtility.cs @@ -658,10 +658,12 @@ private static string Summarise(StatementSyntax statement) { continue; } - // The explicit implementation is the shape that compiles: an ordinary public one has a - // parameter of an internal type, which is CS0051. Preferred, but an implicit one is - // still read so the diagnostic comes from this generator rather than only from the - // compiler. + // Both shapes are legal now that the contracts are public types in + // DependencyModules.Runtime rather than internal types emitted into this compilation — + // an ordinary `public void Conventions(IConventionDefinitions)` used to be CS0051 + // against an internal parameter type, which is why explicit implementation was the only + // form that compiled. The explicit one is still preferred when a type carries both, + // because that is the one the interface is actually satisfied by. if (method.ExplicitInterfaceSpecifier != null) { return method; } diff --git a/src/DependencyModules.SourceGenerator/Conventions/Utilities/DeclarationStamp.cs b/src/DependencyModules.SourceGenerator/Conventions/Utilities/DeclarationStamp.cs new file mode 100644 index 0000000..df660fd --- /dev/null +++ b/src/DependencyModules.SourceGenerator/Conventions/Utilities/DeclarationStamp.cs @@ -0,0 +1,162 @@ +using System.Runtime.CompilerServices; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace DependencyModules.Conventions.Utilities; + +/// +/// Identifies the state of everything in a compilation that can change what a name binds to. +/// +/// +/// +/// This exists so a semantic result can be cached. Roslyn re-runs a CreateSyntaxProvider +/// transform for every node it selected whenever any tree changes — measured, 2,001 calls of which +/// one was for the edited tree, over syntax nodes that were the same objects as the previous run. So +/// the transform is where the per-keystroke cost lives, and caching it is the whole optimisation. +/// +/// +/// The node alone is not a valid key, and the failure is reachable rather than theoretical: moving a +/// global using in one file changes what an untouched declaration in another file implements, +/// while its tree, its node instance and its text all stay identical. A node-keyed cache serves the +/// old interface and registers the wrong service, with a green build. +/// +/// +/// So the key is the node and this stamp. Method bodies are deliberately excluded — nothing +/// inside one can change another file's binding — which is what makes the common keystroke a cache +/// hit. Editing a base list, a using, a namespace or a member signature changes the stamp and +/// invalidates everything, which is correct and rare. +/// +/// +/// Anything omitted here that can affect binding is a silent defect, not a slow path: it +/// produces a stale model and a wrong registration that nothing reports. Add to it freely; removing +/// from it needs an argument. +/// +/// +public static class DeclarationStamp { + + private static readonly ConditionalWeakTable PerTree = new(); + private static readonly ConditionalWeakTable PerCompilation = new(); + + private sealed class StampBox { + public StampBox(long value) => Value = value; + + public long Value { get; } + } + + /// + /// The stamp for a whole compilation. Memoised on the compilation, and on each tree beneath it, + /// so an edit re-hashes one tree and re-combines the rest. + /// + public static long Of(Compilation compilation) { + if (PerCompilation.TryGetValue(compilation, out var cached)) { + return cached.Value; + } + + // 64-bit, and mixed rather than accumulated with a small multiplier. A collision here means + // a stale semantic model is served, so the width is a correctness property. + var hash = 14695981039346656037UL; + + foreach (var tree in compilation.SyntaxTrees) { + hash = Mix(hash, (ulong)TreeStamp(tree)); + } + + foreach (var reference in compilation.References) { + hash = Mix(hash, (ulong)(reference.Display?.GetHashCode() ?? 0)); + } + + var value = (long)hash; + + PerCompilation.Add(compilation, new StampBox(value)); + + return value; + } + + /// + /// One tree's contribution. Cached on the tree, which is immutable, so only an edited tree is + /// ever re-hashed. + /// + private static long TreeStamp(SyntaxTree tree) { + if (PerTree.TryGetValue(tree, out var cached)) { + return cached.Value; + } + + var hash = 14695981039346656037UL; + + // Descends into containers only. A method body is not a container of anything that can + // change a binding, and skipping them is what makes the common keystroke free. + foreach (var node in tree.GetRoot().DescendantNodes(descendIntoChildren: n => + n is CompilationUnitSyntax or BaseNamespaceDeclarationSyntax or TypeDeclarationSyntax)) { + + switch (node) { + case UsingDirectiveSyntax usingDirective: + hash = Mix(hash, Hash(usingDirective.ToString())); + break; + + case ExternAliasDirectiveSyntax externAlias: + hash = Mix(hash, Hash(externAlias.ToString())); + break; + + case BaseNamespaceDeclarationSyntax namespaceDeclaration: + hash = Mix(hash, Hash(namespaceDeclaration.Name.ToString())); + break; + + case TypeDeclarationSyntax type: + hash = Mix(hash, Hash(type.Identifier.Text)); + hash = Mix(hash, Hash(type.Modifiers.ToString())); + hash = Mix(hash, Hash(type.BaseList?.ToString())); + hash = Mix(hash, Hash(type.TypeParameterList?.ToString())); + hash = Mix(hash, Hash(type.ConstraintClauses.ToString())); + hash = Mix(hash, Hash(type.ParameterList?.ToString())); + hash = Mix(hash, Hash(type.AttributeLists.ToString())); + + // Signatures, not bodies. A constructor added to one part of a partial changes + // what another part's symbol reports about itself. + foreach (var member in type.Members) { + hash = Mix(hash, MemberSignature(member)); + } + + break; + } + } + + var value = (long)hash; + + PerTree.Add(tree, new StampBox(value)); + + return value; + } + + private static ulong MemberSignature(MemberDeclarationSyntax member) => + member switch { + ConstructorDeclarationSyntax constructor => + Hash(constructor.Modifiers + constructor.ParameterList.ToString()), + MethodDeclarationSyntax method => + Hash(method.Modifiers + method.ReturnType.ToString() + method.Identifier.Text + + method.TypeParameterList + method.ParameterList), + PropertyDeclarationSyntax property => + Hash(property.Modifiers + property.Type.ToString() + property.Identifier.Text), + FieldDeclarationSyntax field => + Hash(field.Modifiers + field.Declaration.Type.ToString() + + string.Join(",", field.Declaration.Variables.Select(v => v.Identifier.Text))), + EventDeclarationSyntax @event => + Hash(@event.Modifiers + @event.Type.ToString() + @event.Identifier.Text), + // Nested types are reached by the walk above, so they need nothing here. + _ => Hash(member.Kind().ToString()) + }; + + private static ulong Hash(string? text) { + if (text == null) { + return 0; + } + + var hash = 14695981039346656037UL; + + foreach (var c in text) { + hash = (hash ^ c) * 1099511628211UL; + } + + return hash; + } + + private static ulong Mix(ulong hash, ulong value) => (hash ^ value) * 1099511628211UL; +} diff --git a/src/DependencyModules.Conventions/Utilities/MetadataCandidateUtility.cs b/src/DependencyModules.SourceGenerator/Conventions/Utilities/MetadataCandidateUtility.cs similarity index 86% rename from src/DependencyModules.Conventions/Utilities/MetadataCandidateUtility.cs rename to src/DependencyModules.SourceGenerator/Conventions/Utilities/MetadataCandidateUtility.cs index 50603dd..4532730 100644 --- a/src/DependencyModules.Conventions/Utilities/MetadataCandidateUtility.cs +++ b/src/DependencyModules.SourceGenerator/Conventions/Utilities/MetadataCandidateUtility.cs @@ -184,39 +184,13 @@ private static void Add( /// The constructor the container would pick, read from symbols. /// /// - /// The greediest public one, matching what the syntax path does within a declaration. - /// Parameter attributes are not carried across: they exist to drive [FromKeyedServices] - /// on code being generated alongside, and a package's own constructor parameters are not that. + /// Shared with the decorator path, which needs the same thing for a type named by + /// [Decorate] rather than declared here. It was duplicated until that arrived, and this + /// copy dropped parameter attributes — which the shared one carries, because + /// [FromKeyedServices] changes which registration the emitted call resolves. /// - private static ConstructorInfoModel? GreediestConstructor(INamedTypeSymbol type) { - IMethodSymbol? greediest = null; - - foreach (var constructor in type.InstanceConstructors) { - if (constructor.DeclaredAccessibility != Accessibility.Public) { - continue; - } - - if (greediest == null || constructor.Parameters.Length > greediest.Parameters.Length) { - greediest = constructor; - } - } - - if (greediest == null) { - return null; - } - - var parameters = new List(greediest.Parameters.Length); - - foreach (var parameter in greediest.Parameters) { - parameters.Add(new ParameterInfoModel( - parameter.Name, - parameter.Type.GetTypeDefinition(), - parameter.HasExplicitDefaultValue ? parameter.ExplicitDefaultValue : null, - Array.Empty())); - } - - return new ConstructorInfoModel(parameters); - } + private static ConstructorInfoModel? GreediestConstructor(INamedTypeSymbol type) => + SymbolConstructorReader.Read(type); private static IReadOnlyList? AttributeKeysOf(INamedTypeSymbol type) { var attributes = type.GetAttributes(); diff --git a/src/DependencyModules.SourceGenerator/DecoratorSourceGenerator.cs b/src/DependencyModules.SourceGenerator/DecoratorSourceGenerator.cs deleted file mode 100644 index 6e2552e..0000000 --- a/src/DependencyModules.SourceGenerator/DecoratorSourceGenerator.cs +++ /dev/null @@ -1,149 +0,0 @@ -using System.Collections.Immutable; -using CSharpAuthor; -using DependencyModules.SourceGenerator.Impl; -using DependencyModules.SourceGenerator.Impl.Models; -using DependencyModules.SourceGenerator.Impl.Utilities; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace DependencyModules.SourceGenerator; - -/// -/// Emits the decorator registrations declared by [Decorator] on a class and [Decorate] -/// on a module. -/// -/// -/// The decoration itself lives in DecoratorHelper at run time, so the generated code is a -/// pair of type references. Emitting the descriptor rewrite would mean reproducing its awkward parts -/// — capturing the descriptor before replacing the slot, closing an open generic decorator, covering -/// all three descriptor shapes — at every call site. -/// -public class DecoratorSourceGenerator : BaseAttributeSourceGenerator { - private readonly IEqualityComparer _comparer = new DecoratorModelComparer(); - - private static readonly ITypeDefinition[] _attributeTypes = { - KnownTypes.DependencyModules.Attributes.DecoratorAttribute - }; - - protected override string LoggerName => "DecoratorSourceGenerator"; - - protected override IEnumerable AttributeTypes() { - return _attributeTypes; - } - - protected override DecoratorModel IgnoredModel => DecoratorModel.Ignore; - - protected override IEqualityComparer GetComparer() { - return _comparer; - } - - protected override DecoratorModel GenerateAttributeModel(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) { - cancellationToken.ThrowIfCancellationRequested(); - - return DecoratorModelUtility.GetDecoratorModel(context, cancellationToken) ?? DecoratorModel.Ignore; - } - - protected override void GenerateSourceOutput( - SourceProductionContext context, - (ImmutableArray<(ModuleEntryPointModel Left, DependencyModuleConfigurationModel Right)> Left, - ImmutableArray Right) inputData, - FileLogger logger) { - - if (inputData.Left.Length == 0) { - return; - } - - var (entryPointList, configurationModel) = EntryModelUtil.ConsolidateEntryPointModels(inputData.Left); - - foreach (var entryPointModel in entryPointList) { - context.CancellationToken.ThrowIfCancellationRequested(); - - var decorators = CollectDecorators(context, entryPointModel, inputData.Right, logger); - - if (decorators.Count == 0) { - continue; - } - - var writer = new DecoratorFileWriter(); - var output = writer.Write( - EntryModelUtil.EnsureNamespace(entryPointModel, configurationModel), - configurationModel, - decorators); - - context.AddSource( - EntryModelUtil.EnsureNamespace(entryPointModel, configurationModel) - .EntryPointType.GetFileNameHint(configurationModel.RootNamespace, "Decorators"), - output); - } - } - - /// - /// The decorators that belong to one module: those declared on a class, filtered by realm, plus - /// those the module declares itself with [Decorate]. - /// - private static IReadOnlyList CollectDecorators( - SourceProductionContext context, - ModuleEntryPointModel entryPointModel, - ImmutableArray declared, - FileLogger logger) { - - var decorators = new List(); - - foreach (var decorator in declared) { - if (decorator.IsIgnored) { - continue; - } - - // A realm-scoped decorator belongs only to its realm. An unscoped one belongs to every - // module that is not realm-only, matching how service registrations behave. - if (decorator.Realm != null) { - if (decorator.Realm.Equals(entryPointModel.EntryPointType)) { - decorators.Add(decorator); - } - - continue; - } - - if (!entryPointModel.ModuleFeatures.HasFlag(ModuleEntryPointFeatures.OnlyRealm)) { - decorators.Add(decorator); - } - } - - decorators.AddRange(DecoratorModelUtility.GetModuleDeclaredDecorators(entryPointModel)); - - ReportAmbiguousOrdering(context, decorators, logger); - - return decorators; - } - - /// - /// Two decorators of one service sharing an order nest in an order nobody declared, so it is - /// reported rather than resolved arbitrarily. - /// - private static void ReportAmbiguousOrdering( - SourceProductionContext context, IReadOnlyList decorators, FileLogger logger) { - - for (var i = 0; i < decorators.Count; i++) { - for (var j = i + 1; j < decorators.Count; j++) { - if (decorators[i].Order != decorators[j].Order || - !decorators[i].ServiceType.Equals(decorators[j].ServiceType)) { - continue; - } - - logger.Error( - $"'{decorators[i].DecoratorType.Name}' and '{decorators[j].DecoratorType.Name}' both " + - $"decorate '{decorators[i].ServiceType.Name}' with order {decorators[i].Order}."); - - context.ReportDiagnostic( - Diagnostic.Create( - DependencyModuleDiagnostics.AmbiguousDecoratorOrder, - Location.None, - decorators[i].DecoratorType.Name, - decorators[j].DecoratorType.Name, - decorators[i].ServiceType.Name, - decorators[i].Order)); - } - } - } -} diff --git a/src/DependencyModules.SourceGenerator/DependencyModules.SourceGenerator.csproj b/src/DependencyModules.SourceGenerator/DependencyModules.SourceGenerator.csproj index 0eab5f6..12649c0 100644 --- a/src/DependencyModules.SourceGenerator/DependencyModules.SourceGenerator.csproj +++ b/src/DependencyModules.SourceGenerator/DependencyModules.SourceGenerator.csproj @@ -2,7 +2,7 @@ netstandard2.0 - 10 + 11 enable true true diff --git a/src/DependencyModules.SourceGenerator/SourceGenerator.cs b/src/DependencyModules.SourceGenerator/SourceGenerator.cs index 5c5b55f..4f146e8 100644 --- a/src/DependencyModules.SourceGenerator/SourceGenerator.cs +++ b/src/DependencyModules.SourceGenerator/SourceGenerator.cs @@ -5,23 +5,27 @@ namespace DependencyModules.SourceGenerator; +/// +/// The generator this package ships. It owns [DependencyModule], which is why it writes the +/// module partial and a generator built on the same base class does not. +/// [Generator] public class SourceGenerator : BaseSourceGenerator { protected override IEnumerable AttributeSourceGenerators() { yield return new ServiceSourceGenerator(); - yield return new DecoratorSourceGenerator(); yield return new InterceptorSourceGenerator(); + yield return new global::DependencyModules.Conventions.ConventionGenerator(); } + /// + /// Writes the module for [DependencyModule]. The base class declines that attribute by + /// default, so that a third party building on it contributes to these modules rather than + /// declaring every one of them a second time; this is the generator that claim belongs to. + /// protected override void SetupRootGenerator(IncrementalGeneratorInitializationContext context, IncrementalValueProvider> valuesProvider) { - - var moduleWriter = new DependencyModuleWriter(true); - - context.RegisterSourceOutput( - valuesProvider, - moduleWriter.GenerateSource - ); + + context.RegisterSourceOutput(valuesProvider, new DependencyModuleWriter(true).GenerateSource); } } \ No newline at end of file diff --git a/src/DependencyModules.Testing/Attributes/Interfaces/IModuleTestAttribute.cs b/src/DependencyModules.Testing/Attributes/Interfaces/IModuleTestAttribute.cs new file mode 100644 index 0000000..3a82b0b --- /dev/null +++ b/src/DependencyModules.Testing/Attributes/Interfaces/IModuleTestAttribute.cs @@ -0,0 +1,23 @@ +namespace DependencyModules.Testing.Attributes.Interfaces; + +/// +/// Names the modules a test's container is built from. +/// +/// +/// The attribute that marks a test method has to derive from whatever its framework demands — +/// FactAttribute for xUnit, ITestBuilder for NUnit — so it cannot be one shared type. +/// Which modules to load is not a framework question, though, and this is the part that is not: +/// an integration reads it rather than its own attribute, and the loading itself stays common. +/// +/// Implemented by the integrations, not by test authors. A test names its modules through the +/// [ModuleTest] attribute of whichever framework it is written against. +/// +public interface IModuleTestAttribute { + + /// + /// The module types to load, in declaration order. Empty when a test names none. + /// + Type[] ModuleTypes { + get; + } +} diff --git a/src/DependencyModules.Testing/Attributes/Interfaces/IServiceProviderBuilderAttribute.cs b/src/DependencyModules.Testing/Attributes/Interfaces/IServiceProviderBuilderAttribute.cs index 85ffe6c..a2bdd4d 100644 --- a/src/DependencyModules.Testing/Attributes/Interfaces/IServiceProviderBuilderAttribute.cs +++ b/src/DependencyModules.Testing/Attributes/Interfaces/IServiceProviderBuilderAttribute.cs @@ -6,9 +6,10 @@ namespace DependencyModules.Testing.Attributes.Interfaces; /// Replaces the container a test runs against. /// /// -/// Found by walking method, class and assembly, and the first one found wins — unlike the other -/// hooks, which all contribute. Without one the collection is built with -/// BuildServiceProvider(). +/// Only one is used, unlike the other hooks, which all contribute. The narrowest declaration wins: +/// one on the method beats one on the class, which beats one on the assembly — so a broad default +/// can be set at assembly level and overridden by the odd test that needs a different container. +/// Without one the collection is built with BuildServiceProvider(). /// /// Implement this to hand the test a third-party container, or to build the default one with options /// it would not otherwise get, such as scope validation. It runs last, after every other hook has diff --git a/src/DependencyModules.xUnit/Attributes/MockAttribute.cs b/src/DependencyModules.Testing/Attributes/MockAttribute.cs similarity index 69% rename from src/DependencyModules.xUnit/Attributes/MockAttribute.cs rename to src/DependencyModules.Testing/Attributes/MockAttribute.cs index a9d55b0..8ecf6e3 100644 --- a/src/DependencyModules.xUnit/Attributes/MockAttribute.cs +++ b/src/DependencyModules.Testing/Attributes/MockAttribute.cs @@ -3,15 +3,28 @@ using DependencyModules.Testing.Impl; using Microsoft.Extensions.DependencyInjection; -namespace DependencyModules.xUnit.Attributes; +namespace DependencyModules.Testing.Attributes; /// -/// Provides mocking capabilities for dependency injection within test methods. +/// Replaces a test parameter's service with a test double. /// /// -/// This attribute is applied to test method parameters to specify that a mock implementation of the parameter type should -/// be created and registered within the IoC container for the test's execution context. The creation of mock instances -/// is delegated to the mock library supporting the test framework, which must implement . +/// Applied to a test method parameter. The double is registered in the test's container before +/// anything is resolved, so everything constructed afterwards — the service under test included — +/// is built against it rather than against the real registration. +/// +/// Creating the double is delegated to whichever mocking package is in scope, which must implement +/// : [NSubstituteSupport], [MoqSupport] or +/// [FakeItEasySupport]. Without one this throws rather than silently handing back a real +/// service. +/// +/// A library that separates the double from the object it produces may let the parameter name either. +/// With Moq, [Mock] IFoo gives the object and Mock<IFoo> gives the mock — and on a +/// Mock<IFoo> parameter this attribute is redundant, since the type already says what it +/// is. +/// +/// This carries no test framework dependency, so it is the same attribute whichever integration +/// resolves the test's parameters. /// [AttributeUsage( AttributeTargets.Parameter, @@ -19,7 +32,7 @@ namespace DependencyModules.xUnit.Attributes; public class MockAttribute : Attribute, ITestParameterValueProvider { /// - /// Configures a service collection with necessary dependencies for the test case context. + /// Registers the double in place of the parameter's service. /// /// /// The test method context providing access to test-related information and behavior. @@ -66,4 +79,4 @@ public void SetupServiceCollection( ITestMethodContext testMethod, IServiceProvider serviceProvider, ParameterInfo parameter) { return Task.FromResult(serviceProvider.GetService(parameter.ParameterType)); } -} \ No newline at end of file +} diff --git a/src/DependencyModules.xUnit/Attributes/TestExportAttribute.cs b/src/DependencyModules.Testing/Attributes/TestExportAttribute.cs similarity index 84% rename from src/DependencyModules.xUnit/Attributes/TestExportAttribute.cs rename to src/DependencyModules.Testing/Attributes/TestExportAttribute.cs index bb08472..15b429d 100644 --- a/src/DependencyModules.xUnit/Attributes/TestExportAttribute.cs +++ b/src/DependencyModules.Testing/Attributes/TestExportAttribute.cs @@ -1,17 +1,23 @@ using DependencyModules.Testing.Attributes.Interfaces; using Microsoft.Extensions.DependencyInjection; -namespace DependencyModules.xUnit.Attributes; +namespace DependencyModules.Testing.Attributes; /// /// An attribute used for configuring and exporting services to the dependency injection container -/// during xUnit test execution. This attribute supports defining services and their implementations +/// during test execution. This attribute supports defining services and their implementations /// with specific lifetimes for test scenarios. /// /// /// This attribute can be applied to assemblies, classes, or methods to provide granular service /// configuration for specific testing contexts. It registers through -/// , which carries no test framework dependency. +/// , which carries no test framework dependency — which is +/// why this lives here rather than in an integration, and why every integration gets the same +/// attribute rather than a copy of it. +/// +/// A registration made here beats one made by a mocking package for the same service, whichever +/// order the attributes are declared in. An integration guarantees that by running mock support +/// first within the setup pass. /// /// /// It enables dependency injection for testing by adding services to the service collection @@ -30,11 +36,11 @@ namespace DependencyModules.xUnit.Attributes; public class TestExportAttribute : Attribute, ITestServiceSetupAttribute { /// /// An attribute that configures and exports services to the dependency injection container - /// for xUnit test scenarios. This supports customized service registrations with specific lifetimes + /// for test scenarios. This supports customized service registrations with specific lifetimes /// for specified testing contexts. /// /// - /// This attribute facilitates dependency injection configuration for xUnit tests by allowing + /// This attribute facilitates dependency injection configuration for tests by allowing /// the addition of services and their implementations to the service collection during test execution. /// It is applicable to assemblies, classes, or methods, enabling fine-grained control over service /// registrations for different testing phases or requirements. @@ -75,7 +81,7 @@ public ServiceLifetime Lifetime { /// - /// Configures the service collection for an xUnit test method by adding services with specified lifetimes. + /// Configures the service collection for a test method by adding services with specified lifetimes. /// This method enables dynamic service registration during test execution, supporting dependency injection setup. /// /// diff --git a/src/DependencyModules.Testing/DependencyModules.Testing.csproj b/src/DependencyModules.Testing/DependencyModules.Testing.csproj index aaef92e..ce40de8 100644 --- a/src/DependencyModules.Testing/DependencyModules.Testing.csproj +++ b/src/DependencyModules.Testing/DependencyModules.Testing.csproj @@ -6,7 +6,7 @@ enable True DependencyModules.Testing - Test-framework-neutral building blocks for DependencyModules test integrations. Contains the mocking seam (IMockSupportAttribute) that the DependencyModules.NSubstitute, DependencyModules.Moq and DependencyModules.FakeItEasy packages implement, plus attribute discovery helpers. Reference a test framework integration such as DependencyModules.xUnit rather than this package directly. + Test-framework-neutral building blocks for DependencyModules test integrations. Contains the [Mock] and [InjectValues] parameter attributes, the mocking seam (IMockSupportAttribute) that the DependencyModules.NSubstitute, DependencyModules.Moq and DependencyModules.FakeItEasy packages implement, the hooks an integration uses to build a test's container, and attribute discovery helpers. Reference a test framework integration such as DependencyModules.xUnit rather than this package directly. true diff --git a/src/DependencyModules.Testing/Impl/TestParameterResolver.cs b/src/DependencyModules.Testing/Impl/TestParameterResolver.cs new file mode 100644 index 0000000..3fca5b4 --- /dev/null +++ b/src/DependencyModules.Testing/Impl/TestParameterResolver.cs @@ -0,0 +1,152 @@ +using System.Reflection; +using DependencyModules.Testing.Attributes.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace DependencyModules.Testing.Impl; + +/// +/// Works out what to pass a test method, and what its parameters need registered to make that +/// possible. +/// +/// +/// A test framework integration owns discovery, execution and disposal; none of that is shared. What +/// is shared is the rule for turning a parameter list into arguments, and it is more involved than it +/// looks — a parameter may be supplied by an attribute on itself, resolved from the container, keyed, +/// or constructed on the spot from types the container does know. Every integration needs the same +/// answers, so this holds them once. +/// +/// Used in two phases either side of the container being built, and in that order: +/// while registrations can still be added, then +/// once there is a provider to resolve from. One instance belongs +/// to one container: a data-driven test that builds a container per row wants a resolver per row too. +/// +public sealed class TestParameterResolver { + private readonly ITestMethodContext _testMethod; + private readonly Dictionary> _valueProviders = new(); + private bool _setupRan; + + /// + /// Creates a resolver for one test method and one container. + /// + /// The test whose parameters are being supplied. + public TestParameterResolver(ITestMethodContext testMethod) { + _testMethod = testMethod; + } + + /// + /// Lets every parameter register what it needs, before the container is built. + /// + /// + /// This is the half that makes [Mock] more than a convenience. Registering during setup + /// means the substitute is in place before anything resolves, so the service under test is + /// constructed against it — rather than the test being handed a double nothing else can see. + /// + /// The collection backing the test's container. + public void SetupServiceCollection(IServiceCollection serviceCollection) { + foreach (var parameterInfo in _testMethod.Method.GetParameters()) { + var providers = parameterInfo.GetCustomAttributes().OfType().ToList(); + + _valueProviders.Add(parameterInfo, providers); + + foreach (var valueProvider in providers) { + valueProvider.SetupServiceCollection(_testMethod, serviceCollection, parameterInfo); + } + } + + _setupRan = true; + } + + /// + /// Produces the full argument list for the test method. + /// + /// + /// The first .Length parameters are taken from the data row as given — + /// that is what makes a data-driven test's own arguments win over the container. Each remaining + /// parameter is then tried in turn against the attributes on it, the container, and finally + /// direct construction. + /// + /// The test's container, fully built. + /// + /// Arguments already fixed by a data row, matched to the leading parameters. Empty for a test + /// with no data. + /// + /// One argument per parameter, in declaration order. + /// + /// Thrown when was not called first. Resolving without it + /// would quietly skip every parameter attribute, so a [Mock] parameter would hand back the + /// real service instead of a substitute. + /// + public async Task ResolveArgumentsAsync(IServiceProvider serviceProvider, object?[] data) { + if (!_setupRan) { + throw new InvalidOperationException( + $"{nameof(SetupServiceCollection)} must be called before {nameof(ResolveArgumentsAsync)}, " + + "while the service collection can still be added to."); + } + + var parameterList = _testMethod.Method.GetParameters(); + var arguments = new List(data); + + for (var i = data.Length; i < parameterList.Length; i++) { + var parameterInfo = parameterList[i]; + + var value = await ResolveFromParameterProviders(parameterInfo, serviceProvider); + + arguments.Add(value ?? ResolveFromContainer(parameterInfo, serviceProvider)); + } + + return arguments.ToArray(); + } + + /// + /// A provider returning null stands aside for the next one, so several attributes can sit on one + /// parameter with the first that answers winning. is special-cased + /// because a test asking for the container itself cannot be resolved from it. + /// + private async Task ResolveFromParameterProviders( + ParameterInfo parameterInfo, IServiceProvider serviceProvider) { + if (parameterInfo.ParameterType == typeof(IServiceProvider)) { + return serviceProvider; + } + + foreach (var valueProvider in _valueProviders[parameterInfo]) { + var value = await valueProvider.GetParameterValueAsync(_testMethod, serviceProvider, parameterInfo); + + if (value != null) { + return value; + } + } + + return null; + } + + private object? ResolveFromContainer(ParameterInfo parameterInfo, IServiceProvider serviceProvider) { + var keyedServicesAttribute = parameterInfo.GetCustomAttribute(); + + if (keyedServicesAttribute != null && serviceProvider is IKeyedServiceProvider keyedServiceProvider) { + return keyedServiceProvider.GetKeyedService(parameterInfo.ParameterType, keyedServicesAttribute.Key); + } + + return serviceProvider.GetService(parameterInfo.ParameterType) + ?? ConstructValueFromType(parameterInfo, serviceProvider); + } + + /// + /// Builds an unregistered concrete type from the container, so a test can name the class under + /// test directly rather than having to register it. + /// + /// + /// An on the parameter supplies the constructor arguments the + /// container cannot work out for itself. The last one on the parameter wins. + /// + private static object? ConstructValueFromType(ParameterInfo parameterInfo, IServiceProvider serviceProvider) { + object[] parameterValues = []; + + foreach (var attribute in parameterInfo.GetCustomAttributes()) { + if (attribute is IInjectValueAttribute injectValueAttribute) { + parameterValues = injectValueAttribute.ProvideValue(serviceProvider, parameterInfo); + } + } + + return ActivatorUtilities.CreateInstance(serviceProvider, parameterInfo.ParameterType, parameterValues); + } +} diff --git a/src/DependencyModules.xUnit/Attributes/ModuleTestAttribute.cs b/src/DependencyModules.xUnit/Attributes/ModuleTestAttribute.cs index f275738..9401fda 100644 --- a/src/DependencyModules.xUnit/Attributes/ModuleTestAttribute.cs +++ b/src/DependencyModules.xUnit/Attributes/ModuleTestAttribute.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; +using DependencyModules.Testing.Attributes.Interfaces; using DependencyModules.xUnit.Impl; using Xunit; using Xunit.v3; @@ -20,7 +21,7 @@ namespace DependencyModules.xUnit.Attributes; /// [XunitTestCaseDiscoverer(typeof(ModuleTestDiscoverer))] [AttributeUsage(AttributeTargets.Method)] -public class ModuleTestAttribute : FactAttribute { +public class ModuleTestAttribute : FactAttribute, IModuleTestAttribute { /// /// Marks a test method, taking no modules. @@ -73,6 +74,9 @@ public ModuleTestAttribute(params Type[] modules) => /// This property holds the collection of module types that are passed as parameters /// when the is utilized. These types are used to /// configure and load dependency modules for the test case at runtime. + /// + /// Declared by , so the module loading itself is shared with + /// every other test framework integration rather than reading this attribute by name. /// public Type[] ModuleTypes { get; diff --git a/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs b/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs index b86ede9..f60d594 100644 --- a/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs +++ b/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs @@ -1,7 +1,6 @@ using System.Reflection; using DependencyModules.Runtime.Helpers; using DependencyModules.Runtime.Interfaces; -using DependencyModules.xUnit.Attributes; using DependencyModules.Testing.Attributes.Interfaces; using DependencyModules.Testing.Impl; using Microsoft.Extensions.DependencyInjection; @@ -70,23 +69,24 @@ public ModuleTestCase( public override void PreInvoke() { } private record StartupValues( - ITestMethodContext Context, IServiceProvider ServiceProvider, - Dictionary> KnownValues); + TestParameterResolver Resolver); private async Task SetupServiceCollection() { var serviceCollection = new ServiceCollection(); - var knownValues = new Dictionary>(); var knownAttributes = TestMethod.Method.GetTestAttributes().ToArray(); var context = new XunitTestMethodContext(TestMethod, knownAttributes); + // One resolver per container. A data-driven test builds both again for every row. + var resolver = new TestParameterResolver(context); + SetupTestCaseInfo(serviceCollection, knownAttributes); SetupModules(serviceCollection, knownAttributes); - SetValueProviders(context, serviceCollection, knownValues); + resolver.SetupServiceCollection(serviceCollection); SetupServiceSetupAttributes(context, serviceCollection, knownAttributes); @@ -98,7 +98,7 @@ private async Task SetupServiceCollection() { await startupAttribute.StartupAsync(context, provider); } - return new StartupValues(context, provider, knownValues); + return new StartupValues(provider, resolver); } private void SetupTestCaseInfo(ServiceCollection serviceCollection, Attribute[] knownAttributes) { @@ -111,10 +111,17 @@ private void SetupTestCaseInfo(ServiceCollection serviceCollection, Attribute[] )); } + /// + /// Last rather than first: is widest scope first — assembly, + /// then declaring type, then the method — so the last one is the narrowest, and a builder on the + /// method beats one on the class beats one on the assembly. Taking the first would have let an + /// assembly-level builder silently win over the method that asked for a different container, + /// which is the reverse of how every other attribute here resolves. + /// private IServiceProvider BuildServiceProvider( ITestMethodContext context, ServiceCollection serviceCollection, Attribute[] knownAttributes) { var serviceProviderBuilderAttribute = - knownAttributes.OfType().FirstOrDefault(); + knownAttributes.OfType().LastOrDefault(); if (serviceProviderBuilderAttribute != null) { return serviceProviderBuilderAttribute.BuildServiceProvider(context, serviceCollection); @@ -143,22 +150,6 @@ private void SetupServiceSetupAttributes( } } - private void SetValueProviders( - ITestMethodContext context, - ServiceCollection serviceCollection, - Dictionary> knownValues) { - foreach (var parameterInfo in TestMethod.Method.GetParameters()) { - var list = new List(); - - knownValues.Add(parameterInfo, list); - - foreach (var valueProvider in parameterInfo.GetCustomAttributes().OfType()) { - valueProvider.SetupServiceCollection(context, serviceCollection, parameterInfo); - list.Add(valueProvider); - } - } - } - private void SetupModules(ServiceCollection serviceCollection, IEnumerable knownAttributes) { var modules = new List(); @@ -169,7 +160,8 @@ private void SetupModules(ServiceCollection serviceCollection, IEnumerable(); + // The interface rather than ModuleTestAttribute, so this reads the same for any integration. + var testAttribute = TestMethod.Method.GetTestAttribute(); if (testAttribute != null) { var count = 0; @@ -285,76 +277,15 @@ private async Task> UnitTestWithNoDataAttributes ]; } - private async Task ResolveArguments(object?[] data, StartupValues startupValues) { - var parameters = new List(data); - - var testCaseInfo = startupValues.ServiceProvider.GetRequiredService(); - - var parameterList = TestMethod.Method.GetParameters(); - - for (var i = data.Length; i < parameterList.Length; i++) { - var parameterInfo = parameterList[i]; - var attributes = parameterInfo.GetCustomAttributes().ToList(); - - var value = await ResolveParameter(parameterInfo, startupValues); - - parameters.Add(value ?? ResolveArgumentFromProvider(parameterInfo, startupValues, attributes)); - } - - testCaseInfo.TestMethodArguments = parameters; - - return parameters.ToArray(); - } - - private async Task ResolveParameter(ParameterInfo parameterInfo, StartupValues startupValues) { - object? value = null; - - if (parameterInfo.ParameterType == typeof(IServiceProvider)) { - value = startupValues.ServiceProvider; - } - else { - foreach (var valueProvider in startupValues.KnownValues[parameterInfo]) { - value = await valueProvider.GetParameterValueAsync( - startupValues.Context, startupValues.ServiceProvider, parameterInfo); - - if (value != null) { - break; - } - } - } - - return value; - } - - private object? ResolveArgumentFromProvider(ParameterInfo parameterInfo, StartupValues startupValues, List attributes) { - var keyedServicesAttribute = parameterInfo.GetCustomAttribute(); - - if (keyedServicesAttribute != null && startupValues.ServiceProvider is IKeyedServiceProvider keyedServiceProvider) { - return keyedServiceProvider.GetKeyedService(parameterInfo.ParameterType, keyedServicesAttribute.Key); - } - - var value = startupValues.ServiceProvider.GetService(parameterInfo.ParameterType); - - if (value != null) { - return value; - } - - return ConstructValueFromType(parameterInfo, startupValues, attributes); - } + /// + /// The arguments are published on so a test can read what it was + /// invoked with. That is xUnit's own object, which is why this is not part of the shared resolver. + /// + private static async Task ResolveArguments(object?[] data, StartupValues startupValues) { + var arguments = await startupValues.Resolver.ResolveArgumentsAsync(startupValues.ServiceProvider, data); - private object? ConstructValueFromType( - ParameterInfo parameterInfo, - StartupValues startupValues, - IReadOnlyList attributes) { - object[] parameterValues = []; + startupValues.ServiceProvider.GetRequiredService().TestMethodArguments = arguments; - foreach (var attribute in attributes) { - if (attribute is IInjectValueAttribute injectValueAttribute) { - parameterValues = injectValueAttribute.ProvideValue(startupValues.ServiceProvider, parameterInfo); - } - } - - return ActivatorUtilities.CreateInstance( - startupValues.ServiceProvider, parameterInfo.ParameterType, parameterValues); + return arguments; } } \ No newline at end of file diff --git a/tests/DependencyModules.Tests/ApiTests/PublicApiTests.cs b/tests/DependencyModules.Tests/ApiTests/PublicApiTests.cs index 1e7a335..976d85c 100644 --- a/tests/DependencyModules.Tests/ApiTests/PublicApiTests.cs +++ b/tests/DependencyModules.Tests/ApiTests/PublicApiTests.cs @@ -31,6 +31,16 @@ public void XUnitApi() { Snapshot.Match(ApiOf(typeof(ModuleTestAttribute))); } + /// + /// The NUnit integration. Its [ModuleTest] shares a name with xUnit's and nothing else — + /// the two attributes derive from what their own framework requires, and only + /// IModuleTestAttribute is common to both. + /// + [Fact] + public void NUnitApi() { + Snapshot.Match(ApiOf(typeof(global::DependencyModules.NUnit.Attributes.ModuleTestAttribute))); + } + /// /// The seam every mocking package implements, and the only assembly they share. It carries no /// test framework dependency, which is the point of it — a change here reaches all of them. diff --git a/tests/DependencyModules.Tests/DependencyModules.Tests.csproj b/tests/DependencyModules.Tests/DependencyModules.Tests.csproj index c3d529e..282c4c7 100644 --- a/tests/DependencyModules.Tests/DependencyModules.Tests.csproj +++ b/tests/DependencyModules.Tests/DependencyModules.Tests.csproj @@ -12,19 +12,12 @@ + - - + diff --git a/tests/DependencyModules.Tests/GeneratorTests/ConventionContractTests.cs b/tests/DependencyModules.Tests/GeneratorTests/ConventionContractTests.cs new file mode 100644 index 0000000..3e0c57b --- /dev/null +++ b/tests/DependencyModules.Tests/GeneratorTests/ConventionContractTests.cs @@ -0,0 +1,138 @@ +using System.Linq; +using DependencyModules.Runtime.Conventions; +using DependencyModules.Tests.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +using GeneratorNames = DependencyModules.Conventions.ConventionContractSource; + +namespace DependencyModules.Tests.GeneratorTests; + +/// +/// The generator matches convention declarations by name, and the names live in two places. +/// +/// +/// +/// The contracts are declared in DependencyModules.Runtime; the generator that reads them is +/// an analyzer, and an analyzer must not load the runtime assembly. So it carries the namespace, the +/// interface name and the method name as string constants and matches on those. +/// +/// +/// Nothing but this test connects the two. Rename IConventionModule, or move it to another +/// namespace, and every convention in every project silently stops matching — a green build that +/// registers nothing, which is the failure mode this generator exists to prevent everywhere else. +/// +/// +public class ConventionContractTests { + + [Fact] + public void TheGeneratorLooksForTheNamespaceTheContractsAreDeclaredIn() { + Assert.Equal(GeneratorNames.Namespace, typeof(IConventionModule).Namespace); + } + + [Fact] + public void TheGeneratorLooksForTheInterfaceTheContractsDeclare() { + Assert.Equal(GeneratorNames.ConventionModule, nameof(IConventionModule)); + } + + [Fact] + public void TheGeneratorLooksForTheMethodTheInterfaceDeclares() { + var method = Assert.Single(typeof(IConventionModule).GetMethods()); + + Assert.Equal(GeneratorNames.ConventionMethod, method.Name); + } + + /// + /// The contracts are a compile-time DSL, so every verb has to be reachable from the chain. + /// + /// + /// A verb returning something other than would end the + /// chain, which the fluent form exists to avoid. Asserted because it is the kind of thing a + /// hurried addition gets wrong and nothing else would catch. + /// + [Fact] + public void EveryRegistrationVerbContinuesTheChain() { + var breaks = typeof(IConventionRegistration).GetMethods() + .Where(method => method.ReturnType != typeof(IConventionRegistration)) + .Select(method => method.Name) + .ToArray(); + + Assert.Empty(breaks); + } + + /// + /// Every entry point produces a registration to continue from. + /// + [Fact] + public void EveryRegisterAllOverloadStartsTheChain() { + var breaks = typeof(IConventionDefinitions).GetMethods() + .Where(method => method.ReturnType != typeof(IConventionRegistration)) + .Select(method => method.Name) + .ToArray(); + + Assert.Empty(breaks); + } + + private const string Preamble = + """ + using DependencyModules.Runtime.Attributes; + using DependencyModules.Runtime.Conventions; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + public class Greeter : IGreeter { public string Greet() => "hello"; } + + """; + + /// + /// An ordinary public implementation registers, now that the interface is a public type in a + /// referenced assembly. + /// + /// + /// This is the shape the design notes recorded as impossible: while the contracts were emitted + /// into the consumer as internal, a public method taking one was CS0051, so explicit + /// implementation was the only form that compiled and the interface name had to appear twice. + /// The move to DependencyModules.Runtime is what retires that, and this is the assertion + /// that it stays retired. + /// + [Fact] + public void AnImplicitPublicImplementationDeclaresConventions() { + var assembly = GeneratedAssembly.Create( + Preamble + + """ + [DependencyModule] + public partial class TestModule : IConventionModule { + public void Conventions(IConventionDefinitions conventions) { + conventions.RegisterAll().AsSingleton(); + } + } + """); + + var provider = assembly.BuildProvider(); + + Assert.Equal("hello", ((dynamic)provider.GetRequiredService(assembly.Type("IGreeter"))).Greet()); + } + + /// + /// The explicit form still compiles and still registers, so nobody has to rewrite anything. + /// + [Fact] + public void TheExplicitImplementationStillDeclaresConventions() { + var assembly = GeneratedAssembly.Create( + Preamble + + """ + [DependencyModule] + public partial class TestModule : IConventionModule { + void IConventionModule.Conventions(IConventionDefinitions conventions) { + conventions.RegisterAll().AsSingleton(); + } + } + """); + + var provider = assembly.BuildProvider(); + + Assert.Equal("hello", ((dynamic)provider.GetRequiredService(assembly.Type("IGreeter"))).Greet()); + } +} diff --git a/tests/DependencyModules.Tests/GeneratorTests/ConventionDecoratorTests.cs b/tests/DependencyModules.Tests/GeneratorTests/ConventionDecoratorTests.cs index 5dfcef1..184c03a 100644 --- a/tests/DependencyModules.Tests/GeneratorTests/ConventionDecoratorTests.cs +++ b/tests/DependencyModules.Tests/GeneratorTests/ConventionDecoratorTests.cs @@ -20,7 +20,7 @@ public class ConventionDecoratorTests { """ using System.Collections.Generic; using DependencyModules.Runtime.Attributes; - using DependencyModules.Conventions; + using DependencyModules.Runtime.Conventions; namespace TestNamespace; @@ -55,7 +55,7 @@ public TResponse Handle(TRequest request) { """; private static GeneratedAssembly Compile(string module) => - GeneratedAssembly.Create(Preamble + module, withConventions: true); + GeneratedAssembly.Create(Preamble + module); /// /// One open generic decorator over every handler a convention registered — the ordinary MediatR diff --git a/tests/DependencyModules.Tests/GeneratorTests/ConventionRegistrationTests.cs b/tests/DependencyModules.Tests/GeneratorTests/ConventionRegistrationTests.cs index 10f5b71..91dfd86 100644 --- a/tests/DependencyModules.Tests/GeneratorTests/ConventionRegistrationTests.cs +++ b/tests/DependencyModules.Tests/GeneratorTests/ConventionRegistrationTests.cs @@ -20,17 +20,17 @@ public class ConventionRegistrationTests { """ using System; using DependencyModules.Runtime.Attributes; - using DependencyModules.Conventions; + using DependencyModules.Runtime.Conventions; namespace TestNamespace; """; private static GeneratedAssembly Compile(string source) => - GeneratedAssembly.Create(Preamble + source, withConventions: true); + GeneratedAssembly.Create(Preamble + source); private static GeneratorResult Run(string source) => - GeneratorTestHarness.Run(Preamble + source, withConventions: true); + GeneratorTestHarness.Run(Preamble + source); /// /// A convention candidate carrying an environment condition registers on the same terms the @@ -64,7 +64,6 @@ void IConventionModule.Conventions(IConventionDefinitions conventions) { var assembly = GeneratedAssembly.Create( Preamble + source, - withConventions: true, environment: new ModuleEnvironment(environmentName)); Assert.Equal(expected, assembly.Descriptors("IFoo").Count); @@ -95,7 +94,6 @@ void IConventionModule.Conventions(IConventionDefinitions conventions) { var assembly = GeneratedAssembly.Create( Preamble + source, - withConventions: true, environment: new ModuleEnvironment(environmentName)); Assert.Equal(expected, assembly.Descriptors("IFoo").Count); @@ -135,7 +133,6 @@ void IConventionModule.Conventions(IConventionDefinitions conventions) { var assembly = GeneratedAssembly.Create( Preamble + source, - withConventions: true, environment: new ModuleEnvironment(false, environmentName) { { "REGION", region } }); Assert.Equal(expected, assembly.Descriptors("IFoo").Count); @@ -161,7 +158,6 @@ void IConventionModule.Conventions(IConventionDefinitions conventions) { var assembly = GeneratedAssembly.Create( Preamble + source, - withConventions: true, environment: new ModuleEnvironment(false, "Development") { { "FLAG", flag } }); Assert.Equal(expected, assembly.Descriptors("IFoo").Count); @@ -187,7 +183,6 @@ void IConventionModule.Conventions(IConventionDefinitions conventions) { var assembly = GeneratedAssembly.Create( Preamble + source, - withConventions: true, environment: new ModuleEnvironment(environmentName)); Assert.Equal(expected, assembly.Descriptors("IFoo").Count); @@ -225,7 +220,6 @@ void IConventionModule.Conventions(IConventionDefinitions conventions) { var assembly = GeneratedAssembly.Create( Preamble + source, - withConventions: true, environment: new ModuleEnvironment(environmentName)); Assert.Equal(expectedFoo, assembly.Descriptors("IFoo").Count); @@ -633,8 +627,7 @@ void IConventionModule.Conventions(IConventionDefinitions conventions) { conventions.RegisterAll().WithName("{{pattern}}").AsSelf().AsScoped(); } } - """, - withConventions: true); + """); Assert.Equal(expected, assembly.Services.Count(d => d.ImplementationType?.Namespace == "TestNamespace")); } @@ -1584,7 +1577,7 @@ public void EditingAnUnrelatedMethodBodyReusesTheCachedOutput() { const string template = """ using DependencyModules.Runtime.Attributes; - using DependencyModules.Conventions; + using DependencyModules.Runtime.Conventions; namespace TestNamespace; @@ -1604,8 +1597,7 @@ void IConventionModule.Conventions(IConventionDefinitions conventions) { var result = GeneratorTestHarness.RunIncremental( new Dictionary { ["Test.cs"] = template.Replace("VALUE", "1") }, - new Dictionary { ["Test.cs"] = template.Replace("VALUE", "2") }, - withConventions: true); + new Dictionary { ["Test.cs"] = template.Replace("VALUE", "2") }); Assert.Equal(result.FirstRun, result.SecondRun); Assert.True(result.AllOutputsCached, diff --git a/tests/DependencyModules.Tests/GeneratorTests/DecoratorGenerationTests.cs b/tests/DependencyModules.Tests/GeneratorTests/DecoratorGenerationTests.cs index b59e62b..5ce0169 100644 --- a/tests/DependencyModules.Tests/GeneratorTests/DecoratorGenerationTests.cs +++ b/tests/DependencyModules.Tests/GeneratorTests/DecoratorGenerationTests.cs @@ -112,36 +112,48 @@ public partial class TestModule; } /// - /// A service registered as an open generic cannot be decorated, and saying so while the module - /// is being applied beats the ArgumentException the container throws when the provider is built, - /// which names the service and neither the decorator nor the way out. + /// A service registered as an open generic is left undecorated rather than refused at run time. /// + /// + /// + /// Decoration replaces a registration with a factory, which the container will not accept for an + /// open generic service type — it needs an implementation type it can close per request. That has + /// not changed. What changed is when it is said. + /// + /// + /// Generated code names the service as a type argument, and an unbound generic cannot be written + /// as one, so there is nothing to emit and nothing to refuse at composition either. + /// + /// [Fact] - public void OpenGenericRegistration_IsRefusedWhileTheModuleIsApplied() { - var exception = Assert.Throws( - () => GeneratedAssembly.Create( - """ - using DependencyModules.Runtime.Attributes; + public void OpenGenericRegistration_IsNotDecorated() { + var result = GeneratorTestHarness.Run( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; - namespace TestNamespace; + public interface IRepo { string Name(); } - public interface IRepo { string Name(); } + [SingletonService] + public class Repo : IRepo { public string Name() => "repo"; } - [SingletonService] - public class Repo : IRepo { public string Name() => "repo"; } + [Decorator] + public class LoggingRepo(IRepo inner) : IRepo { + public string Name() => $"logged({inner.Name()})"; + } - [Decorator] - public class LoggingRepo(IRepo inner) : IRepo { - public string Name() => $"logged({inner.Name()})"; - } + [DependencyModule] + public partial class TestModule; + """); - [DependencyModule] - public partial class TestModule; - """)); + // Nothing is emitted for it, so the registration stands undecorated rather than the + // provider throwing when it is built. + Assert.Empty(result.Errors); - Assert.Contains("open generic", exception.Message); - Assert.Contains("LoggingRepo", exception.Message); - Assert.Contains("closed constructions", exception.Message); + Assert.DoesNotContain( + result.GeneratedSources, + source => source.Key.Contains("Decorators") && source.Value.Contains("LoggingRepo")); } /// @@ -362,6 +374,11 @@ public class Outer(IGreeter inner) : IGreeter { private static string Greet(GeneratedAssembly generated) => Invoke(generated.ResolveRequired("IGreeter"), "Greet"); + /// Calls Handle on a resolved handler with a fresh request. + private static void Handle(object handler, GeneratedAssembly assembly) => + handler.GetType().GetMethod("Handle")!.Invoke( + handler, new[] { System.Activator.CreateInstance(assembly.Type("Create")) }); + private static string Invoke(object target, string method) => (string)target.GetType().GetMethod(method)!.Invoke(target, null)!; @@ -381,4 +398,2066 @@ public class Greeter : IGreeter { public string Greet() => "hello"; } [DependencyModule] public partial class TestModule; """; + + /// + /// A decorator's own dependencies are resolved on the terms each parameter declares. + /// + /// + /// + /// Constructing the decorator in generated code means the generator, not + /// ActivatorUtilities, decides how each parameter is resolved — so everything + /// ActivatorUtilities used to honour has to be honoured here. + /// + /// + /// [FromKeyedServices] is the one that fails silently. Resolving it unkeyed returns a + /// registration of the right type, so nothing throws and nothing is logged; the decorator simply + /// wraps its behaviour around the wrong instance. The assertion is on the value the keyed + /// dependency contributes, because a type check would pass either way. + /// + /// + [Fact] + public void Decorator_ResolvesAKeyedDependencyFromTheKeyItDeclares() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + using Microsoft.Extensions.DependencyInjection; + + namespace TestNamespace; + + public interface IStamp { string Value { get; } } + + // Unkeyed, so resolving without the key succeeds and returns the wrong instance + // rather than throwing. That is what makes the bug silent. + [SingletonService] + public class DefaultStamp : IStamp { public string Value => "?"; } + + [SingletonService(Key = "quiet")] + public class QuietStamp : IStamp { public string Value => "."; } + + public interface IGreeter { string Greet(); } + + [SingletonService] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + [Decorator] + public class StampedGreeter( + IGreeter inner, [FromKeyedServices("quiet")] IStamp stamp) : IGreeter { + + public string Greet() => inner.Greet() + stamp.Value; + } + + [DependencyModule] + public partial class TestModule; + """); + + var greeter = assembly.BuildProvider().GetRequiredService(assembly.Type("IGreeter")); + + // "hello?" is what ignoring the key produces: the right type, the wrong instance, no error. + Assert.Equal("hello.", ((dynamic)greeter).Greet()); + } + + /// + /// A nullable dependency the container does not have resolves to null rather than throwing. + /// + [Fact] + public void Decorator_ResolvesAnOptionalDependencyToNull() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IAudit { } + + public interface IGreeter { string Greet(); } + + [SingletonService] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + [Decorator] + public class AuditedGreeter(IGreeter inner, IAudit? audit) : IGreeter { + public string Greet() => inner.Greet() + (audit == null ? " (unaudited)" : " (audited)"); + } + + [DependencyModule] + public partial class TestModule; + """); + + var greeter = assembly.BuildProvider().GetRequiredService(assembly.Type("IGreeter")); + + Assert.Equal("hello (unaudited)", ((dynamic)greeter).Greet()); + } + + /// + /// A decorator named by [Decorate] is constructed by generated code, not by reflection. + /// + /// + /// The attribute carries two type names and nothing else, so the constructor is looked up from + /// the compilation rather than read from a declaration — which is the only route for a decorator + /// that may be declared in a referenced assembly, the case this form exists for. + /// + /// Asserted through a keyed dependency because that is what distinguishes the two paths. A + /// reflective ActivatorUtilities call would also produce a working decorator; only the + /// generated new proves the constructor was actually read, and only the key proves each + /// parameter was resolved on the terms it declares. + /// + [Fact] + public void ModuleLevelDecorate_ConstructsTheDecoratorFromItsResolvedConstructor() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + using Microsoft.Extensions.DependencyInjection; + + namespace TestNamespace; + + public interface IStamp { string Value { get; } } + + [SingletonService] + public class DefaultStamp : IStamp { public string Value => "?"; } + + [SingletonService(Key = "quiet")] + public class QuietStamp : IStamp { public string Value => "."; } + + public interface IGreeter { string Greet(); } + + [SingletonService] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + // No [Decorator] on it: the module names it instead. + public class StampedGreeter( + IGreeter inner, [FromKeyedServices("quiet")] IStamp stamp) : IGreeter { + + public string Greet() => inner.Greet() + stamp.Value; + } + + [DependencyModule] + [Decorate(typeof(IGreeter), typeof(StampedGreeter))] + public partial class TestModule; + """); + + var greeter = assembly.BuildProvider().GetRequiredService(assembly.Type("IGreeter")); + + Assert.Equal("hello.", ((dynamic)greeter).Greet()); + } + + /// + /// A decorator the container could never construct is reported rather than emitted. + /// + /// + /// Generated code constructs the decorator, so no public constructor means there is nothing to + /// emit. The alternative was a reflective call that resolved under a JIT and threw in a + /// published application. + /// + [Fact] + public void ModuleLevelDecorate_WithNoPublicConstructor_IsNotDecorated() { + var result = GeneratorTestHarness.Run( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [SingletonService] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + public class HiddenGreeter : IGreeter { + private HiddenGreeter(IGreeter inner) { Inner = inner; } + public IGreeter Inner { get; } + public string Greet() => Inner.Greet(); + } + + [DependencyModule] + [Decorate(typeof(IGreeter), typeof(HiddenGreeter))] + public partial class TestModule; + """); + + // Generated code constructs the decorator, so a private constructor means there is nothing + // to emit. The build stays green and the service resolves undecorated. + Assert.Empty(result.Errors); + + Assert.DoesNotContain( + result.GeneratedSources, + source => source.Value.Contains("new global::TestNamespace.HiddenGreeter")); + } + + // ------------------------------------------------------------------------------------------ + // Generic decorators after type substitution. Everything below closes a decorator over the type + // arguments a registration used, which rewrites its constructor parameters — so each of these + // exercises DecoratorTypeUtility.Close as much as it does the emission. + // ------------------------------------------------------------------------------------------ + + private const string HandlerPreamble = + """ + using DependencyModules.Runtime.Attributes; + using Microsoft.Extensions.DependencyInjection; + + namespace TestNamespace; + + public interface IHandler { TResponse Handle(TRequest request); } + + public class Create { } + public class Rename { } + public class Id { public string Value = ""; } + + [SingletonService] + public class CreateHandler : IHandler { + public Id Handle(Create r) => new Id { Value = "created" }; + } + + [SingletonService] + public class CountHandler : IHandler { + public int Handle(Create r) => 41; + } + + """; + + /// + /// A generic decorator's keyed dependency survives being closed over the registration's types. + /// + /// + /// Closing a generic decorator rebuilds its constructor with every parameter type substituted. + /// The parameter attributes have to survive that rebuild, and losing them is silent: the + /// decorator resolves the unkeyed registration, which is the right type and the wrong instance. + /// + [Fact] + public void GenericDecorator_KeyedDependencySurvivesTypeSubstitution() { + var assembly = GeneratedAssembly.Create( + HandlerPreamble + + """ + public interface IStamp { string Value { get; } } + + [SingletonService] + public class DefaultStamp : IStamp { public string Value => "?"; } + + [SingletonService(Key = "quiet")] + public class QuietStamp : IStamp { public string Value => "."; } + + [Decorator] + public class StampedHandler( + IHandler inner, + [FromKeyedServices("quiet")] IStamp stamp) : IHandler { + + public TResponse Handle(TRequest request) { + Log.Lines.Add(stamp.Value); + return inner.Handle(request); + } + } + + public static class Log { public static System.Collections.Generic.List Lines = new(); } + + [DependencyModule] + public partial class TestModule; + """); + + var provider = assembly.BuildProvider(); + var handler = assembly.Type("IHandler`2"); + + Handle( + provider.GetRequiredService(handler.MakeGenericType(assembly.Type("Create"), assembly.Type("Id")))!, + assembly); + + var lines = (System.Collections.Generic.List) + assembly.Type("Log").GetField("Lines")!.GetValue(null)!; + + Assert.Equal(["."], lines); + } + + /// + /// A generic decorator's optional dependency resolves to null rather than throwing. + /// + [Fact] + public void GenericDecorator_OptionalDependencyResolvesToNull() { + var assembly = GeneratedAssembly.Create( + HandlerPreamble + + """ + public interface IAudit { } + + [Decorator] + public class AuditedHandler( + IHandler inner, IAudit? audit) : IHandler { + + public bool Audited => audit != null; + + public TResponse Handle(TRequest request) => inner.Handle(request); + } + + [DependencyModule] + public partial class TestModule; + """); + + var handler = assembly.Type("IHandler`2"); + + var resolved = assembly.BuildProvider().GetRequiredService( + handler.MakeGenericType(assembly.Type("Create"), assembly.Type("Id"))); + + Assert.False((bool)resolved.GetType().GetProperty("Audited")!.GetValue(resolved)!); + } + + /// + /// A dependency that is itself generic in the decorator's parameters is closed the same way. + /// + /// + /// IValidator<TRequest> has to become IValidator<Create>. Substituting + /// only the top-level parameter types and not their arguments produces code that does not + /// compile, which is the failure this pins. + /// + [Fact] + public void GenericDecorator_ClosesADependencyOverItsOwnTypeParameters() { + var assembly = GeneratedAssembly.Create( + HandlerPreamble + + """ + public interface IValidator { string Name { get; } } + + [SingletonService] + public class CreateValidator : IValidator { public string Name => "create"; } + + [Decorator] + public class ValidatedHandler( + IHandler inner, + IValidator validator) : IHandler { + + public string ValidatorName => validator.Name; + + public TResponse Handle(TRequest request) => inner.Handle(request); + } + + [DependencyModule] + public partial class TestModule; + """); + + var handler = assembly.Type("IHandler`2"); + + var resolved = assembly.BuildProvider().GetRequiredService( + handler.MakeGenericType(assembly.Type("Create"), assembly.Type("Id"))); + + Assert.Equal("create", resolved.GetType().GetProperty("ValidatorName")!.GetValue(resolved)); + } + + /// + /// A value-type type argument is decorated like any other. + /// + /// + /// This is the instantiation Native AOT can never produce at run time, and the reason the + /// open-generic runtime call had to go. Under a JIT it passes either way, so this asserts the + /// shape rather than the outcome: the emitted call must name the closed decorator. + /// + [Fact] + public void GenericDecorator_ClosesOverAValueTypeArgument() { + var result = GeneratorTestHarness.Run( + HandlerPreamble + + """ + [Decorator] + public class LoggingHandler( + IHandler inner) : IHandler { + + public TResponse Handle(TRequest request) => inner.Handle(request); + } + + [DependencyModule] + public partial class TestModule; + """); + + Assert.Empty(result.Errors); + + var decorators = Assert.Single( + result.GeneratedSources, source => source.Key.Contains("Decorators")).Value; + + // One closed call per registration, each naming the decorator closed over the same + // arguments — including the value-type one, which is the instantiation Native AOT cannot + // produce at run time and the reason the open-generic call had to go. + Assert.True( + System.Text.RegularExpressions.Regex.Matches(decorators, "Decorate<").Count == 2, + "expected one closed Decorate call per registration, got:\n" + decorators); + + // Nothing is closed at run time any more. + Assert.DoesNotContain("IHandler<,>", decorators); + } + + /// + /// Two generic decorators over one service nest in their declared order. + /// + [Fact] + public void GenericDecorators_StackInOrder() { + var assembly = GeneratedAssembly.Create( + HandlerPreamble + + """ + public static class Log { public static System.Collections.Generic.List Lines = new(); } + + [Decorator(Order = 1)] + public class InnerMost( + IHandler inner) : IHandler { + public TResponse Handle(TRequest r) { Log.Lines.Add("inner"); return inner.Handle(r); } + } + + [Decorator(Order = 2)] + public class OuterMost( + IHandler inner) : IHandler { + public TResponse Handle(TRequest r) { Log.Lines.Add("outer"); return inner.Handle(r); } + } + + [DependencyModule] + public partial class TestModule; + """); + + var provider = assembly.BuildProvider(); + var handler = assembly.Type("IHandler`2"); + + Handle( + provider.GetRequiredService(handler.MakeGenericType(assembly.Type("Create"), assembly.Type("Id")))!, + assembly); + + var lines = (System.Collections.Generic.List) + assembly.Type("Log").GetField("Lines")!.GetValue(null)!; + + // Higher order wraps further out, so it runs first. + Assert.Equal(["outer", "inner"], lines); + } + + /// + /// One declaration is applied once, even when two registration paths both name the service. + /// + /// + /// A generic decorator is expanded against the attribute registrations and again against the + /// convention ones, and the two passes cannot see each other. Where both produce the same closed + /// service the decoration is emitted twice, and without the guard in DecoratorHelper the + /// implementation behind it is wrapped twice — two log lines per call, no exception, nothing in + /// the build. + /// + [Fact] + public void GenericDecorator_RegisteredByBothPaths_IsAppliedOnce() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + using DependencyModules.Runtime.Conventions; + + namespace TestNamespace; + + public interface IHandler { TResponse Handle(TRequest request); } + + public class Create { } + public class Id { public string Value = ""; } + + public static class Log { public static System.Collections.Generic.List Lines = new(); } + + // Attribute-registered. + [SingletonService] + public class AttributedHandler : IHandler { + public Id Handle(Create r) => new Id { Value = "attributed" }; + } + + // Convention-registered, same closed service. + public class ConventionHandler : IHandler { + public Id Handle(Create r) => new Id { Value = "convention" }; + } + + [Decorator] + public class LoggingHandler( + IHandler inner) : IHandler { + + public TResponse Handle(TRequest r) { Log.Lines.Add("logged"); return inner.Handle(r); } + } + + [DependencyModule] + public partial class TestModule : IConventionModule { + public void Conventions(IConventionDefinitions conventions) { + conventions.RegisterAll(typeof(IHandler<,>)).AsSingleton(); + } + } + """); + + var provider = assembly.BuildProvider(); + var handler = assembly.Type("IHandler`2") + .MakeGenericType(assembly.Type("Create"), assembly.Type("Id")); + + foreach (var service in (System.Collections.IEnumerable)provider.GetServices(handler)) { + Handle(service!, assembly); + } + + var lines = (System.Collections.Generic.List) + assembly.Type("Log").GetField("Lines")!.GetValue(null)!; + + // Two registrations, one decoration each — not two each. + Assert.Equal(2, lines.Count); + } + + // ------------------------------------------------------------------------------------------ + // Option coverage. Each of these varies one thing the emission has to account for, and the + // reason each is here is that the generator now writes the construction itself — so everything + // ActivatorUtilities and the container used to decide is now the generator's to get right. + // ------------------------------------------------------------------------------------------ + + /// + /// A decorator declaring several constructors gets the one it marked, not the greediest. + /// + /// + /// The container honours [ActivatorUtilitiesConstructor], so generated code has to as + /// well. Picking the greediest instead compiles and resolves — it just builds the decorator a + /// different way than the author asked for, which nothing would report. + /// + [Fact] + public void Decorator_HonoursTheConstructorItMarked() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + using Microsoft.Extensions.DependencyInjection; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [SingletonService] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + [SingletonService] + public class Extra { public string Value => "extra"; } + + [Decorator] + public class PickyGreeter : IGreeter { + private readonly IGreeter _inner; + private readonly string _via; + + [ActivatorUtilitiesConstructor] + public PickyGreeter(IGreeter inner) { _inner = inner; _via = "marked"; } + + public PickyGreeter(IGreeter inner, Extra extra) { _inner = inner; _via = extra.Value; } + + public string Greet() => _inner.Greet() + ":" + _via; + } + + [DependencyModule] + public partial class TestModule; + """); + + Assert.Equal("hello:marked", Invoke( + assembly.BuildProvider().GetRequiredService(assembly.Type("IGreeter")), "Greet")); + } + + /// + /// A decorator can take the provider itself. + /// + /// + /// An IServiceProvider parameter is the provider, not something to resolve from it. + /// Resolving it would work by accident on Microsoft's container and is wrong in principle. + /// + [Fact] + public void Decorator_TakingTheProviderGetsTheProvider() { + var assembly = GeneratedAssembly.Create( + """ + using System; + using DependencyModules.Runtime.Attributes; + using Microsoft.Extensions.DependencyInjection; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [SingletonService] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + [SingletonService] + public class Suffix { public string Value => "!"; } + + [Decorator] + public class LazyGreeter(IGreeter inner, IServiceProvider provider) : IGreeter { + public string Greet() => + inner.Greet() + provider.GetRequiredService().Value; + } + + [DependencyModule] + public partial class TestModule; + """); + + Assert.Equal("hello!", Invoke( + assembly.BuildProvider().GetRequiredService(assembly.Type("IGreeter")), "Greet")); + } + + /// + /// Decorating a keyed registration keeps its key. + /// + /// + /// The decoration replaces the descriptor, so a key dropped in the rewrite makes the service + /// unresolvable under the name it was registered with — while an unkeyed resolution starts + /// working, which looks like the service moved rather than broke. + /// + [Fact] + public void Decorator_KeyedRegistrationKeepsItsKey() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + using Microsoft.Extensions.DependencyInjection; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [SingletonService(Key = "formal")] + public class Greeter : IGreeter { public string Greet() => "good day"; } + + [Decorator] + public class LoudGreeter(IGreeter inner) : IGreeter { + public string Greet() => inner.Greet().ToUpperInvariant(); + } + + [DependencyModule] + public partial class TestModule; + """); + + var provider = assembly.BuildProvider(); + var greeter = assembly.Type("IGreeter"); + + Assert.Equal("GOOD DAY", Invoke(provider.GetRequiredKeyedService(greeter, "formal"), "Greet")); + Assert.Null(provider.GetService(greeter)); + } + + /// + /// A type argument that is itself generic is substituted at depth. + /// + /// + /// IHandler<List<Create>, Id> has to close the decorator over the whole + /// argument, not over its outer shape. Substituting only the top level emits a type argument + /// that does not compile, which is the failure mode this pins. + /// + [Fact] + public void GenericDecorator_ClosesOverANestedTypeArgument() { + var assembly = GeneratedAssembly.Create( + """ + using System.Collections.Generic; + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IHandler { TResponse Handle(TRequest request); } + + public class Create { } + public class Id { public string Value = ""; } + + [SingletonService] + public class BatchHandler : IHandler, Id> { + public Id Handle(List r) => new Id { Value = "batch:" + r.Count }; + } + + [Decorator] + public class LoggingHandler( + IHandler inner) : IHandler { + + public TResponse Handle(TRequest request) => inner.Handle(request); + } + + [DependencyModule] + public partial class TestModule; + """); + + var handler = assembly.Type("IHandler`2").MakeGenericType( + typeof(List<>).MakeGenericType(assembly.Type("Create")), assembly.Type("Id")); + + var resolved = assembly.BuildProvider().GetRequiredService(handler); + + Assert.Equal("LoggingHandler`2", resolved.GetType().Name); + } + + /// + /// Only the closings the compilation registers are decorated. + /// + /// + /// A generic decorator is not "every possible construction" — it is one decoration per + /// registration. Emitting for a construction nothing registers would be dead code at best. + /// + [Fact] + public void GenericDecorator_DecoratesOnlyTheRegisteredClosings() { + var result = GeneratorTestHarness.Run( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IHandler { string Handle(); } + + public class Registered { } + public class NeverRegistered { } + + [SingletonService] + public class RegisteredHandler : IHandler { public string Handle() => "yes"; } + + public class OrphanHandler : IHandler { public string Handle() => "no"; } + + [Decorator] + public class LoggingHandler(IHandler inner) : IHandler { + public string Handle() => inner.Handle(); + } + + [DependencyModule] + public partial class TestModule; + """); + + Assert.Empty(result.Errors); + + var decorators = Assert.Single( + result.GeneratedSources, source => source.Key.Contains("Decorators")).Value; + + Assert.Contains("Registered", decorators); + Assert.DoesNotContain("NeverRegistered", decorators); + } + + /// + /// A generic decorator keeps the lifetime each registration declared. + /// + [Fact] + public void GenericDecorator_PreservesEachRegistrationsLifetime() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IHandler { string Handle(); } + + public class A { } + public class B { } + + [SingletonService] + public class AHandler : IHandler { public string Handle() => "a"; } + + [TransientService] + public class BHandler : IHandler { public string Handle() => "b"; } + + [Decorator] + public class LoggingHandler(IHandler inner) : IHandler { + public string Handle() => inner.Handle(); + } + + [DependencyModule] + public partial class TestModule; + """); + + var handler = assembly.Type("IHandler`1"); + + var singleton = Assert.Single( + assembly.Services, + d => d.ServiceType == handler.MakeGenericType(assembly.Type("A"))); + + var transient = Assert.Single( + assembly.Services, + d => d.ServiceType == handler.MakeGenericType(assembly.Type("B"))); + + Assert.Equal(ServiceLifetime.Singleton, singleton.Lifetime); + Assert.Equal(ServiceLifetime.Transient, transient.Lifetime); + } + + /// + /// A scoped service behind a generic decorator is still disposed by the container. + /// + /// + /// The non-generic case has its own test. This one goes through the substitution path, where the + /// displaced registration is created from a rebuilt model rather than the one the transform + /// produced. + /// + [Fact] + public void GenericDecorator_LeavesTheInnerOwnedByTheContainer() { + var assembly = GeneratedAssembly.Create( + """ + using System; + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IHandler { string Handle(); } + + public class A { } + + public static class Log { public static int Disposals; } + + [ScopedService] + public class AHandler : IHandler, IDisposable { + public string Handle() => "a"; + public void Dispose() => Log.Disposals++; + } + + [Decorator] + public class LoggingHandler(IHandler inner) : IHandler { + public string Handle() => inner.Handle(); + } + + [DependencyModule] + public partial class TestModule; + """); + + var provider = assembly.BuildProvider(); + var handler = assembly.Type("IHandler`1").MakeGenericType(assembly.Type("A")); + + using (var scope = provider.CreateScope()) { + Assert.Equal("a", Invoke(scope.ServiceProvider.GetRequiredService(handler), "Handle")); + } + + Assert.Equal(1, (int)assembly.Type("Log").GetField("Disposals")!.GetValue(null)!); + } + + /// + /// A decorator scoped to a realm decorates only that module's registrations. + /// + [Fact] + public void Decorator_ScopedToARealm_DecoratesOnlyThatModule() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [SingletonService(Realm = typeof(DecoratedModule))] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + [Decorator(Realm = typeof(DecoratedModule))] + public class LoudGreeter(IGreeter inner) : IGreeter { + public string Greet() => inner.Greet().ToUpperInvariant(); + } + + [DependencyModule(OnlyRealm = true)] + public partial class DecoratedModule; + + [DependencyModule(OnlyRealm = true)] + public partial class PlainModule; + """, + moduleName: "DecoratedModule"); + + Assert.Equal("HELLO", Invoke( + assembly.BuildProvider().GetRequiredService(assembly.Type("IGreeter")), "Greet")); + } + + /// + /// A decorator gated on the environment is applied only when the condition holds. + /// + /// + /// The guard wraps the call, not the registration, so a decorator that does not apply is simply + /// never run and the service resolves undecorated — rather than being wrapped by something that + /// re-tests the environment on every call. + /// + [Theory] + [InlineData("Development", "HELLO")] + [InlineData("Production", "hello")] + public void Decorator_WithAnEnvironmentCondition_AppliesOnlyWhenItHolds( + string environment, string expected) { + + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [SingletonService] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + [Decorator] + [IfEnvironment("Development")] + public class LoudGreeter(IGreeter inner) : IGreeter { + public string Greet() => inner.Greet().ToUpperInvariant(); + } + + [DependencyModule] + public partial class TestModule; + """, + environment: new ModuleEnvironment(environment)); + + Assert.Equal(expected, Invoke( + assembly.BuildProvider().GetRequiredService(assembly.Type("IGreeter")), "Greet")); + } + + /// + /// Every implementation behind one service is decorated, not just the last registered. + /// + [Fact] + public void Decorator_WrapsEveryImplementationOfTheService() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [SingletonService] + public class English : IGreeter { public string Greet() => "hello"; } + + [SingletonService] + public class French : IGreeter { public string Greet() => "bonjour"; } + + [Decorator] + public class LoudGreeter(IGreeter inner) : IGreeter { + public string Greet() => inner.Greet().ToUpperInvariant(); + } + + [DependencyModule] + public partial class TestModule; + """); + + var all = ((System.Collections.IEnumerable)assembly.BuildProvider() + .GetServices(assembly.Type("IGreeter"))) + .Cast() + .Select(service => Invoke(service, "Greet")) + .ToArray(); + + Assert.Equal(["HELLO", "BONJOUR"], all); + } + + /// + /// Interception and decoration compose on one service. + /// + /// + /// Both rewrite the same descriptor through the same helper, so they stack rather than one + /// replacing the other. Worth pinning: they are emitted by different writers and nothing else + /// asserts that the second sees what the first produced. + /// + [Fact] + public void Decorator_AndInterceptor_BothWrapTheService() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + using DependencyModules.Runtime.Interception; + + namespace TestNamespace; + + public static class Log { public static System.Collections.Generic.List Lines = new(); } + + public interface IGreeter { string Greet(); } + + [SingletonService] + [Intercept(typeof(TracingInterceptor))] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + [SingletonService] + public class TracingInterceptor : IInterceptor { + public TResult Intercept(InvocationContext context) { + Log.Lines.Add("intercepted"); + return context.Proceed(); + } + } + + [Decorator] + public class LoudGreeter(IGreeter inner) : IGreeter { + public string Greet() { Log.Lines.Add("decorated"); return inner.Greet().ToUpperInvariant(); } + } + + [DependencyModule] + public partial class TestModule; + """); + + var greeted = Invoke( + assembly.BuildProvider().GetRequiredService(assembly.Type("IGreeter")), "Greet"); + + var lines = (System.Collections.Generic.List) + assembly.Type("Log").GetField("Lines")!.GetValue(null)!; + + Assert.Equal("HELLO", greeted); + Assert.Contains("decorated", lines); + Assert.Contains("intercepted", lines); + } + + /// + /// A convention registering matches as themselves is decorated too. + /// + /// + /// AsSelf() registers the implementation as its own service type, so the decorator has to + /// name the concrete class rather than an interface. Nothing else covers a decoration whose + /// service type is the implementation. + /// + [Fact] + public void Decorator_OverAConventionRegisteredAsSelf() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + using DependencyModules.Runtime.Conventions; + + namespace TestNamespace; + + public interface IMarker { } + + public class Worker : IMarker { public virtual string Work() => "work"; } + + [Decorator(Service = typeof(Worker))] + public class LoudWorker(Worker inner) : Worker { + public override string Work() => inner.Work().ToUpperInvariant(); + } + + [DependencyModule] + public partial class TestModule : IConventionModule { + public void Conventions(IConventionDefinitions conventions) { + conventions.RegisterAll().AsSelf().AsSingleton(); + } + } + """); + + Assert.Equal("WORK", Invoke( + assembly.BuildProvider().GetRequiredService(assembly.Type("Worker")), "Work")); + } + + /// + /// A module-level [Decorate] can name a generic decorator. + /// + /// + /// + /// Two things have to be right for this. The attribute is re-emitted onto the generated module + /// partial, and typeof(LoudHandler<>) binds to the unbound symbol — whose type + /// arguments are the declaration's type parameters. Rendered verbatim that writes + /// typeof(LoudHandler<T>) into generated code where T is not in scope, which + /// is CS0246 for an attribute the developer wrote correctly. + /// + /// + /// And the decorator still has to be expanded per registration, with its constructor looked up + /// from the compilation rather than read from a declaration. + /// + /// + [Fact] + public void ModuleLevelDecorate_CanNameAGenericDecorator() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IHandler { string Handle(); } + + public class A { } + public class B { } + + [SingletonService] + public class AHandler : IHandler { public string Handle() => "a"; } + + [SingletonService] + public class BHandler : IHandler { public string Handle() => "b"; } + + // No [Decorator]: the module names it. + public class LoudHandler(IHandler inner) : IHandler { + public string Handle() => inner.Handle().ToUpperInvariant(); + } + + [DependencyModule] + [Decorate(typeof(IHandler<>), typeof(LoudHandler<>))] + public partial class TestModule; + """); + + var provider = assembly.BuildProvider(); + var handler = assembly.Type("IHandler`1"); + + Assert.Equal("A", Invoke( + provider.GetRequiredService(handler.MakeGenericType(assembly.Type("A"))), "Handle")); + Assert.Equal("B", Invoke( + provider.GetRequiredService(handler.MakeGenericType(assembly.Type("B"))), "Handle")); + } + + // ------------------------------------------------------------------------------------------ + // Adversarial cases. Each one is a shape the emission could plausibly get wrong. + // ------------------------------------------------------------------------------------------ + + /// The wrapped service does not have to be the first constructor parameter. + [Fact] + public void Decorator_InnerParameterNeedNotComeFirst() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [SingletonService] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + [SingletonService] + public class Suffix { public string Value => "!"; } + + [Decorator] + public class LoudGreeter(Suffix suffix, IGreeter inner) : IGreeter { + public string Greet() => inner.Greet() + suffix.Value; + } + + [DependencyModule] + public partial class TestModule; + """); + + Assert.Equal("hello!", Invoke( + assembly.BuildProvider().GetRequiredService(assembly.Type("IGreeter")), "Greet")); + } + + /// A record decorator is constructed through its primary constructor. + [Fact] + public void Decorator_DeclaredAsARecord() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [SingletonService] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + [Decorator] + public record LoudGreeter(IGreeter Inner) : IGreeter { + public string Greet() => Inner.Greet().ToUpperInvariant(); + } + + [DependencyModule] + public partial class TestModule; + """); + + Assert.Equal("HELLO", Invoke( + assembly.BuildProvider().GetRequiredService(assembly.Type("IGreeter")), "Greet")); + } + + /// A decorator nested inside another type is named correctly in the emitted new. + [Fact] + public void Decorator_NestedInsideAnotherType() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [SingletonService] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + public static class Outer { + [Decorator] + public class LoudGreeter(IGreeter inner) : IGreeter { + public string Greet() => inner.Greet().ToUpperInvariant(); + } + } + + [DependencyModule] + public partial class TestModule; + """); + + Assert.Equal("HELLO", Invoke( + assembly.BuildProvider().GetRequiredService(assembly.Type("IGreeter")), "Greet")); + } + + /// A decorator whose inner parameter is nullable still finds it. + /// + /// Unusual but legal, and the parameter type then carries a nullable annotation the service type + /// does not. Matching them without normalising means no parameter looks like the service, and + /// the decoration is dropped with nothing said. + /// + [Fact] + public void Decorator_WithANullableInnerParameter() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [SingletonService] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + [Decorator] + public class LoudGreeter(IGreeter? inner) : IGreeter { + public string Greet() => inner?.Greet().ToUpperInvariant() ?? "none"; + } + + [DependencyModule] + public partial class TestModule; + """); + + Assert.Equal("HELLO", Invoke( + assembly.BuildProvider().GetRequiredService(assembly.Type("IGreeter")), "Greet")); + } + + /// + /// A generic decorator whose type parameters are not the service's arguments in order is not + /// emitted, and does not emit anything broken either. + /// + /// + /// Swapped<TResponse, TRequest> : IHandler<TRequest, TResponse> is legal C# + /// that cannot be closed by position. Guessing would emit a new with the arguments the + /// wrong way round, which compiles whenever the two types happen to be compatible. + /// + [Fact] + public void GenericDecorator_WithReorderedTypeParameters_IsNotEmitted() { + var result = GeneratorTestHarness.Run( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IHandler { TResponse Handle(TRequest request); } + + public class Create { } + public class Id { public string Value = ""; } + + [SingletonService] + public class CreateHandler : IHandler { + public Id Handle(Create r) => new Id { Value = "created" }; + } + + [Decorator] + public class Swapped( + IHandler inner) : IHandler { + + public TResponse Handle(TRequest request) => inner.Handle(request); + } + + [DependencyModule] + public partial class TestModule; + """); + + Assert.Empty(result.Errors); + + Assert.DoesNotContain( + result.GeneratedSources, + source => source.Value.Contains("new global::TestNamespace.Swapped")); + } + + /// + /// A generic decorator with fewer type parameters than the service has arguments is not emitted. + /// + [Fact] + public void GenericDecorator_WithMismatchedArity_IsNotEmitted() { + var result = GeneratorTestHarness.Run( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IHandler { TResponse Handle(TRequest request); } + + public class Thing { } + + [SingletonService] + public class ThingHandler : IHandler { + public Thing Handle(Thing r) => r; + } + + [Decorator] + public class Same(IHandler inner) : IHandler { + public T Handle(T request) => inner.Handle(request); + } + + [DependencyModule] + public partial class TestModule; + """); + + Assert.Empty(result.Errors); + } + + /// A cross-wired registration is a factory descriptor, and decorates like one. + [Fact] + public void Decorator_OverACrossWiredRegistration() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [CrossWireService] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + [Decorator] + public class LoudGreeter(IGreeter inner) : IGreeter { + public string Greet() => inner.Greet().ToUpperInvariant(); + } + + [DependencyModule] + public partial class TestModule; + """); + + var provider = assembly.BuildProvider(); + + Assert.Equal("HELLO", Invoke(provider.GetRequiredService(assembly.Type("IGreeter")), "Greet")); + + // The implementation stays resolvable as itself, undecorated — that is what cross-wiring is. + Assert.Equal("hello", Invoke(provider.GetRequiredService(assembly.Type("Greeter")), "Greet")); + } + + /// + /// A decorator whose own dependency is generic in the service's arguments and comes from a + /// convention. + /// + [Fact] + public void GenericDecorator_OverConventionRegistrations_WithAGenericDependency() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + using DependencyModules.Runtime.Conventions; + + namespace TestNamespace; + + public interface IHandler { string Handle(); } + + public class A { } + + public class AHandler : IHandler { public string Handle() => "a"; } + + public interface ILabel { string Text { get; } } + + [SingletonService] + public class ALabel : ILabel { public string Text => "[A]"; } + + [Decorator] + public class LabelledHandler(IHandler inner, ILabel label) : IHandler { + public string Handle() => label.Text + inner.Handle(); + } + + [DependencyModule] + public partial class TestModule : IConventionModule { + public void Conventions(IConventionDefinitions conventions) { + conventions.RegisterAll(typeof(IHandler<>)).AsSingleton(); + } + } + """); + + var handler = assembly.Type("IHandler`1").MakeGenericType(assembly.Type("A")); + + Assert.Equal("[A]a", Invoke(assembly.BuildProvider().GetRequiredService(handler), "Handle")); + } + + /// + /// An unscoped decorator in a compilation with two modules is applied once, not once per module. + /// + /// + /// A decorator with no realm belongs to every module that is not realm-only, so both modules + /// emit it. Both emissions name the same closed service, and the collection they rewrite is the + /// same one — so without the guard the implementation is wrapped twice, which shows up as a + /// decorator's side effects happening twice per call and nothing else. + /// + [Fact] + public void Decorator_WithTwoModulesInTheCompilation_IsAppliedOnce() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public static class Log { public static int Applied; } + + public interface IGreeter { string Greet(); } + + [SingletonService] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + [Decorator] + public class CountingGreeter(IGreeter inner) : IGreeter { + public string Greet() { Log.Applied++; return inner.Greet(); } + } + + [DependencyModule] + public partial class TestModule; + + [DependencyModule] + public partial class OtherModule; + """); + + Invoke(assembly.BuildProvider().GetRequiredService(assembly.Type("IGreeter")), "Greet"); + + Assert.Equal(1, (int)assembly.Type("Log").GetField("Applied")!.GetValue(null)!); + } + + /// Two decorators of one service sharing an order are still refused. + /// + /// The check moved when decoration collapsed into one stage. It is the only thing standing + /// between two decorators and a nesting order nobody declared. + /// + [Fact] + public void Decorators_SharingAnOrder_AreReported() { + var result = GeneratorTestHarness.Run( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [SingletonService] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + [Decorator] + public class First(IGreeter inner) : IGreeter { public string Greet() => inner.Greet(); } + + [Decorator] + public class Second(IGreeter inner) : IGreeter { public string Greet() => inner.Greet(); } + + [DependencyModule] + public partial class TestModule; + """); + + var reported = Assert.Single(result.GeneratorDiagnostics, d => d.Id == "DM0007"); + + Assert.Contains("First", reported.GetMessage()); + Assert.Contains("Second", reported.GetMessage()); + } + + /// + /// A generic and a non-generic decorator over one closed service nest by declared order. + /// + /// + /// They arrive at the writer from different routes — one expanded per registration, one passed + /// through — so this pins that the ordering applies across both rather than within each. + /// + [Fact] + public void GenericAndNonGenericDecorators_NestByOrder() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public static class Log { public static System.Collections.Generic.List Lines = new(); } + + public interface IHandler { string Handle(); } + + public class A { } + + [SingletonService] + public class AHandler : IHandler { public string Handle() => "a"; } + + [Decorator(Order = 1)] + public class GenericInner(IHandler inner) : IHandler { + public string Handle() { Log.Lines.Add("generic"); return inner.Handle(); } + } + + [Decorator(Order = 2)] + public class SpecificOuter(IHandler inner) : IHandler { + public string Handle() { Log.Lines.Add("specific"); return inner.Handle(); } + } + + [DependencyModule] + public partial class TestModule; + """); + + var handler = assembly.Type("IHandler`1").MakeGenericType(assembly.Type("A")); + + Invoke(assembly.BuildProvider().GetRequiredService(handler), "Handle"); + + var lines = (System.Collections.Generic.List) + assembly.Type("Log").GetField("Lines")!.GetValue(null)!; + + Assert.Equal(["specific", "generic"], lines); + } + + /// An unscoped decorator does not reach a realm-only module. + [Fact] + public void Decorator_Unscoped_DoesNotReachARealmOnlyModule() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [SingletonService(Realm = typeof(RealmModule))] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + [Decorator] + public class LoudGreeter(IGreeter inner) : IGreeter { + public string Greet() => inner.Greet().ToUpperInvariant(); + } + + [DependencyModule(OnlyRealm = true)] + public partial class RealmModule; + """, + moduleName: "RealmModule"); + + Assert.Equal("hello", Invoke( + assembly.BuildProvider().GetRequiredService(assembly.Type("IGreeter")), "Greet")); + } + + /// A convention registering matches under a key is decorated under that key. + [Fact] + public void Decorator_OverAKeyedConventionRegistration() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + using DependencyModules.Runtime.Conventions; + using Microsoft.Extensions.DependencyInjection; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + public class Greeter : IGreeter { public string Greet() => "hello"; } + + [Decorator] + public class LoudGreeter(IGreeter inner) : IGreeter { + public string Greet() => inner.Greet().ToUpperInvariant(); + } + + [DependencyModule] + public partial class TestModule : IConventionModule { + public void Conventions(IConventionDefinitions conventions) { + conventions.RegisterAll().WithKey("loud").AsSingleton(); + } + } + """); + + var provider = assembly.BuildProvider(); + var greeter = assembly.Type("IGreeter"); + + Assert.Equal("HELLO", Invoke(provider.GetRequiredKeyedService(greeter, "loud"), "Greet")); + Assert.Null(provider.GetService(greeter)); + } + + /// + /// A convention registering as self and interfaces cross-wires, and the interface is decorated. + /// + /// + /// AsSelfWithInterfaces registers each interface as a factory resolving the + /// implementation, so the decorated descriptor is a factory and the shared instance the contract + /// promises has to survive the rewrite. + /// + [Fact] + public void Decorator_OverAConventionRegisteredAsSelfWithInterfaces() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + using DependencyModules.Runtime.Conventions; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + public class Greeter : IGreeter { public string Greet() => "hello"; } + + [Decorator] + public class LoudGreeter(IGreeter inner) : IGreeter { + public string Greet() => inner.Greet().ToUpperInvariant(); + } + + [DependencyModule] + public partial class TestModule : IConventionModule { + public void Conventions(IConventionDefinitions conventions) { + conventions.RegisterAll().AsSelfWithInterfaces().AsSingleton(); + } + } + """); + + var provider = assembly.BuildProvider(); + + Assert.Equal("HELLO", Invoke(provider.GetRequiredService(assembly.Type("IGreeter")), "Greet")); + + // The implementation itself stays undecorated, which is what cross-wiring means. + Assert.Equal("hello", Invoke(provider.GetRequiredService(assembly.Type("Greeter")), "Greet")); + } + + /// A decorator with no matching registration emits nothing and breaks nothing. + [Fact] + public void Decorator_WithNothingToDecorate_EmitsNothing() { + var result = GeneratorTestHarness.Run( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [Decorator] + public class LoudGreeter(IGreeter inner) : IGreeter { + public string Greet() => inner.Greet().ToUpperInvariant(); + } + + [DependencyModule] + public partial class TestModule; + """); + + Assert.Empty(result.Errors); + } + + /// + /// A generic decorator constrained to reference types is not emitted for a value-type closing. + /// + /// + /// where T : class is ordinary on a decorator, and IHandler<int> is an + /// ordinary registration. Closing the decorator over int emits + /// new Logging<int>(…), which violates the constraint — CS0453, in generated code, + /// for two declarations that are each perfectly legal. + /// + [Fact] + public void GenericDecorator_ConstrainedToReferenceTypes_SkipsValueTypeClosings() { + var result = GeneratorTestHarness.Run( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IHandler { string Handle(); } + + public class Thing { } + + [SingletonService] + public class ThingHandler : IHandler { public string Handle() => "thing"; } + + [SingletonService] + public class IntHandler : IHandler { public string Handle() => "int"; } + + [Decorator] + public class Logging(IHandler inner) : IHandler where T : class { + public string Handle() => inner.Handle(); + } + + [DependencyModule] + public partial class TestModule; + """); + + Assert.Empty(result.Errors); + } + + /// + /// A constraint the closing does satisfy still emits. + /// + [Fact] + public void GenericDecorator_ConstrainedToAnInterface_EmitsForSatisfyingClosings() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IRequest { } + + public interface IHandler where T : IRequest { string Handle(); } + + public class Thing : IRequest { } + + [SingletonService] + public class ThingHandler : IHandler { public string Handle() => "thing"; } + + [Decorator] + public class Logging(IHandler inner) : IHandler where T : IRequest { + public string Handle() => inner.Handle().ToUpperInvariant(); + } + + [DependencyModule] + public partial class TestModule; + """); + + var handler = assembly.Type("IHandler`1").MakeGenericType(assembly.Type("Thing")); + + Assert.Equal("THING", Invoke(assembly.BuildProvider().GetRequiredService(handler), "Handle")); + } + + /// + /// A decorator implementing two interfaces and taking both decorates the one it is told to. + /// + /// + /// Inference picks the first constructor parameter that is also an implemented interface, which + /// is arbitrary when there are two. Service = is the way to say which, and this pins that + /// it wins over inference rather than being one more candidate. + /// + [Fact] + public void Decorator_WithAnExplicitService_DecoratesThatOne() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + public interface IFarewell { string Bye(); } + + [SingletonService] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + [SingletonService] + public class Farewell : IFarewell { public string Bye() => "bye"; } + + [Decorator(Service = typeof(IFarewell))] + public class Loud(IGreeter greeter, IFarewell farewell) : IGreeter, IFarewell { + public string Greet() => greeter.Greet(); + public string Bye() => farewell.Bye().ToUpperInvariant(); + } + + [DependencyModule] + public partial class TestModule; + """); + + var provider = assembly.BuildProvider(); + + Assert.Equal("BYE", Invoke(provider.GetRequiredService(assembly.Type("IFarewell")), "Bye")); + + // The other interface it implements is not decorated. + Assert.Equal("hello", Invoke(provider.GetRequiredService(assembly.Type("IGreeter")), "Greet")); + } + + /// A registration declared with Try is still decorated. + [Fact] + public void Decorator_OverATryRegistration() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [SingletonService(Using = RegistrationType.Try)] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + [Decorator] + public class LoudGreeter(IGreeter inner) : IGreeter { + public string Greet() => inner.Greet().ToUpperInvariant(); + } + + [DependencyModule] + public partial class TestModule; + """); + + Assert.Equal("HELLO", Invoke( + assembly.BuildProvider().GetRequiredService(assembly.Type("IGreeter")), "Greet")); + } + + /// A decorator reaching the service through a base class is still a decorator. + /// + /// Inference reads the base list, which here names a class rather than the interface. If only + /// directly-written interfaces count, this stops being recognised and is silently not applied. + /// + [Fact] + public void Decorator_ImplementingTheServiceThroughABaseClass() { + var result = GeneratorTestHarness.Run( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [SingletonService] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + public abstract class GreeterBase : IGreeter { public abstract string Greet(); } + + [Decorator] + public class LoudGreeter(IGreeter inner) : GreeterBase { + public override string Greet() => inner.Greet().ToUpperInvariant(); + } + + [DependencyModule] + public partial class TestModule; + """); + + Assert.Empty(result.Errors); + } + + /// An environment condition guards every closed call a generic decorator produces. + [Theory] + [InlineData("Development", "A")] + [InlineData("Production", "a")] + public void GenericDecorator_WithAnEnvironmentCondition_GuardsEachClosing( + string environment, string expected) { + + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IHandler { string Handle(); } + + public class A { } + public class B { } + + [SingletonService] + public class AHandler : IHandler { public string Handle() => "a"; } + + [SingletonService] + public class BHandler : IHandler { public string Handle() => "b"; } + + [Decorator] + [IfEnvironment("Development")] + public class Loud(IHandler inner) : IHandler { + public string Handle() => inner.Handle().ToUpperInvariant(); + } + + [DependencyModule] + public partial class TestModule; + """, + environment: new ModuleEnvironment(environment)); + + var handler = assembly.Type("IHandler`1").MakeGenericType(assembly.Type("A")); + + Assert.Equal(expected, Invoke(assembly.BuildProvider().GetRequiredService(handler), "Handle")); + } + + /// Two closings of one generic service each get their own decoration. + /// + /// The decorator must close over each construction separately rather than over whichever was + /// seen first. + /// + [Fact] + public void GenericDecorator_OverTwoClosingsOfOneService() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IHandler { string Handle(); } + + public class A { } + public class B { } + + [SingletonService] + public class AHandler : IHandler { public string Handle() => "multi"; } + + [SingletonService] + public class BHandler : IHandler { public string Handle() => "multi"; } + + [Decorator] + public class Loud(IHandler inner) : IHandler { + public string Handle() => inner.Handle().ToUpperInvariant(); + } + + [DependencyModule] + public partial class TestModule; + """); + + var provider = assembly.BuildProvider(); + var handler = assembly.Type("IHandler`1"); + + Assert.Equal("MULTI", Invoke( + provider.GetRequiredService(handler.MakeGenericType(assembly.Type("A"))), "Handle")); + Assert.Equal("MULTI", Invoke( + provider.GetRequiredService(handler.MakeGenericType(assembly.Type("B"))), "Handle")); + } + + /// A deeply nested type argument is substituted at every level. + [Fact] + public void GenericDecorator_ClosesOverADeeplyNestedTypeArgument() { + var assembly = GeneratedAssembly.Create( + """ + using System.Collections.Generic; + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IHandler { string Handle(); } + + public class Create { } + + [SingletonService] + public class DeepHandler : IHandler>> { + public string Handle() => "deep"; + } + + [Decorator] + public class Loud(IHandler inner) : IHandler { + public string Handle() => inner.Handle().ToUpperInvariant(); + } + + [DependencyModule] + public partial class TestModule; + """); + + var handler = assembly.Type("IHandler`1").MakeGenericType( + typeof(IReadOnlyList<>).MakeGenericType( + typeof(Dictionary<,>).MakeGenericType(typeof(string), assembly.Type("Create")))); + + Assert.Equal("DEEP", Invoke(assembly.BuildProvider().GetRequiredService(handler), "Handle")); + } + + /// Three type parameters are substituted in order. + [Fact] + public void GenericDecorator_WithThreeTypeParameters() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IPipe { string Run(); } + + public class In { } + public class Via { } + public class Out { } + + [SingletonService] + public class Pipe : IPipe { public string Run() => "pipe"; } + + [Decorator] + public class Loud(IPipe inner) : IPipe { + public string Run() => inner.Run().ToUpperInvariant(); + } + + [DependencyModule] + public partial class TestModule; + """); + + var pipe = assembly.Type("IPipe`3").MakeGenericType( + assembly.Type("In"), assembly.Type("Via"), assembly.Type("Out")); + + Assert.Equal("PIPE", Invoke(assembly.BuildProvider().GetRequiredService(pipe), "Run")); + } + + /// A keyed registration with a keyed dependency on the decorator. + [Fact] + public void Decorator_KeyedRegistrationAndKeyedDependency() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + using Microsoft.Extensions.DependencyInjection; + + namespace TestNamespace; + + public interface IStamp { string Value { get; } } + + [SingletonService] + public class DefaultStamp : IStamp { public string Value => "?"; } + + [SingletonService(Key = "quiet")] + public class QuietStamp : IStamp { public string Value => "."; } + + public interface IGreeter { string Greet(); } + + [SingletonService(Key = "formal")] + public class Greeter : IGreeter { public string Greet() => "good day"; } + + [Decorator] + public class StampedGreeter( + IGreeter inner, [FromKeyedServices("quiet")] IStamp stamp) : IGreeter { + + public string Greet() => inner.Greet() + stamp.Value; + } + + [DependencyModule] + public partial class TestModule; + """); + + Assert.Equal("good day.", Invoke( + assembly.BuildProvider().GetRequiredKeyedService(assembly.Type("IGreeter"), "formal"), "Greet")); + } + + /// A decorator can depend on the module environment. + [Fact] + public void Decorator_DependingOnTheModuleEnvironment() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime; + using DependencyModules.Runtime.Attributes; + using DependencyModules.Runtime.Interfaces; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [SingletonService] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + [Decorator] + public class NamedGreeter(IGreeter inner, IModuleEnvironment environment) : IGreeter { + public string Greet() => inner.Greet() + (environment == null ? ":none" : ":env"); + } + + [DependencyModule] + public partial class TestModule; + """, + environment: new ModuleEnvironment("Staging")); + + Assert.Equal("hello:env", Invoke( + assembly.BuildProvider().GetRequiredService(assembly.Type("IGreeter")), "Greet")); + } + + /// A TryEnumerable registration is decorated. + [Fact] + public void Decorator_OverATryEnumerableRegistration() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [SingletonService(Using = RegistrationType.TryEnumerable)] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + [Decorator] + public class LoudGreeter(IGreeter inner) : IGreeter { + public string Greet() => inner.Greet().ToUpperInvariant(); + } + + [DependencyModule] + public partial class TestModule; + """); + + Assert.Equal("HELLO", Invoke( + assembly.BuildProvider().GetRequiredService(assembly.Type("IGreeter")), "Greet")); + } + + /// A convention reaching the interface through a base class is decorated. + [Fact] + public void Decorator_OverAConventionUsingIncludeBaseClasses() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + using DependencyModules.Runtime.Conventions; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + public abstract class GreeterBase : IGreeter { public abstract string Greet(); } + + public class Greeter : GreeterBase { public override string Greet() => "hello"; } + + [Decorator] + public class LoudGreeter(IGreeter inner) : IGreeter { + public string Greet() => inner.Greet().ToUpperInvariant(); + } + + [DependencyModule] + public partial class TestModule : IConventionModule { + public void Conventions(IConventionDefinitions conventions) { + conventions.RegisterAll().IncludeBaseClasses().AsSingleton(); + } + } + """); + + Assert.Equal("HELLO", Invoke( + assembly.BuildProvider().GetRequiredService(assembly.Type("IGreeter")), "Greet")); + } + + /// Interception over a closed construction of a generic service. + [Fact] + public void Interceptor_OverAClosedGenericService() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + using DependencyModules.Runtime.Interception; + + namespace TestNamespace; + + public static class Log { public static int Calls; } + + public interface IHandler { string Handle(); } + + public class A { } + + [SingletonService] + [Intercept(typeof(CountingInterceptor))] + public class AHandler : IHandler { public string Handle() => "a"; } + + [SingletonService] + public class CountingInterceptor : IInterceptor { + public TResult Intercept(InvocationContext context) { + Log.Calls++; + return context.Proceed(); + } + } + + [DependencyModule] + public partial class TestModule; + """); + + var handler = assembly.Type("IHandler`1").MakeGenericType(assembly.Type("A")); + + Assert.Equal("a", Invoke(assembly.BuildProvider().GetRequiredService(handler), "Handle")); + Assert.Equal(1, (int)assembly.Type("Log").GetField("Calls")!.GetValue(null)!); + } } diff --git a/tests/DependencyModules.Tests/GeneratorTests/ExtensionSeamTests.cs b/tests/DependencyModules.Tests/GeneratorTests/ExtensionSeamTests.cs new file mode 100644 index 0000000..0d36ad3 --- /dev/null +++ b/tests/DependencyModules.Tests/GeneratorTests/ExtensionSeamTests.cs @@ -0,0 +1,192 @@ +using CSharpAuthor; +using DependencyModules.SourceGenerator.Impl; +using DependencyModules.Tests.Infrastructure; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace DependencyModules.Tests.GeneratorTests; + +/// +/// The seam a framework builds on: its own module attribute through ModuleAttributeTypes(), +/// its own attribute generators through AttributeSourceGenerators(). Both of these pin +/// behaviour a framework only finds out about in a consuming application, which is too late. +/// +public class ExtensionSeamTests { + + private const string FrameworkAttribute = + """ + namespace Test.Framework; + + [System.AttributeUsage(System.AttributeTargets.Class)] + public class FrameworkModuleAttribute : System.Attribute; + """; + + private const string ModuleAndService = + """ + using DependencyModules.Runtime; + using DependencyModules.Runtime.Attributes; + using Microsoft.Extensions.DependencyInjection; + using Test.Framework; + + namespace TestNamespace; + + public interface IThing; + + [SingletonService] + public class Thing : IThing; + + [FrameworkModule] + public partial class AppModule; + + public static class Composition { + public static IServiceCollection Compose() => + new ServiceCollection().AddModule(); + } + """; + + /// + /// A generator declaring its own module attribute gets the module partial without overriding + /// SetupRootGenerator. + /// + /// + /// The call to AddModule<AppModule>() is the assertion that matters. Its constraint + /// is IDependencyModule, new(), which the generated partial is what satisfies — so with + /// no module emitted this compilation fails, exactly as the consuming application did while + /// SetupRootGenerator was empty by default and easy to miss. + /// + [Fact] + public void FrameworkGenerator_EmitsTheModule_WithoutOverridingSetupRootGenerator() { + var result = GeneratorTestHarness.Run( + new Dictionary { + ["Framework.cs"] = FrameworkAttribute, + ["App.cs"] = ModuleAndService + }, + generators: new ISourceGenerator[] { new FrameworkShapedGenerator().AsSourceGenerator() }); + + result.AssertNoErrors(); + Assert.Contains("IDependencyModule", result.SourceContaining("AppModule.Module")); + } + + /// + /// A generator that only contributes providers opts out, and then nothing declares the module. + /// + [Fact] + public void FrameworkGenerator_OptingOut_EmitsNoModule() { + var result = GeneratorTestHarness.Run( + new Dictionary { + ["Framework.cs"] = FrameworkAttribute, + ["App.cs"] = ModuleAndService + }, + generators: new ISourceGenerator[] { new ProvidersOnlyGenerator().AsSourceGenerator() }); + + Assert.DoesNotContain(result.GeneratedSources.Keys, key => key.Contains("AppModule.Module")); + } + + /// + /// Stacking a framework generator on this package's own produces one ApplicationModule, not two. + /// + /// + /// Program.cs carries no module attribute, so nothing in the syntax distinguishes which + /// generator it belongs to and both used to claim it — each emitting an ApplicationModule + /// partial with the same members, which the compiler rejects. Stacking is the whole point of the + /// extension seam, and a console application is the ordinary shape of a consumer, so the two + /// have to work together. + /// + [Fact] + public void StackedGenerators_OverAConsoleApplication_EmitOneApplicationModule() { + var result = GeneratorTestHarness.Run( + new Dictionary { + ["Framework.cs"] = FrameworkAttribute, + ["Program.cs"] = + """ + System.Console.WriteLine("hello"); + """, + ["App.cs"] = ModuleAndService + }, + outputKind: OutputKind.ConsoleApplication, + generators: new ISourceGenerator[] { + new SourceGenerator.SourceGenerator().AsSourceGenerator(), + new FrameworkShapedGenerator().AsSourceGenerator() + }); + + result.AssertNoErrors(); + + Assert.Empty(result.DuplicateHintNames); + Assert.Single(result.GeneratedSources.Keys, key => key.Contains("ApplicationModule.Module")); + } + + /// + /// The other extension shape: a generator adding registrations to [DependencyModule] + /// modules rather than declaring modules of its own. Those partials belong to the generator + /// this package ships, and writing them from both declares every module twice. + /// + [Fact] + public void ThirdPartyGenerator_OnTheDefaultModuleAttribute_WritesNoModuleOfItsOwn() { + var source = + """ + using DependencyModules.Runtime; + using DependencyModules.Runtime.Attributes; + using Microsoft.Extensions.DependencyInjection; + + namespace TestNamespace; + + public interface IThing; + + [SingletonService] + public class Thing : IThing; + + [DependencyModule] + public partial class AppModule; + + public static class Composition { + public static IServiceCollection Compose() => + new ServiceCollection().AddModule(); + } + """; + + var result = GeneratorTestHarness.Run( + new Dictionary { ["App.cs"] = source }, + generators: new ISourceGenerator[] { + new SourceGenerator.SourceGenerator().AsSourceGenerator(), + new ThirdPartyGenerator().AsSourceGenerator() + }); + + result.AssertNoErrors(); + + Assert.Empty(result.DuplicateHintNames); + Assert.Single(result.GeneratedSources.Keys, key => key.Contains("AppModule.Module")); + } + + /// + /// What a framework declares: its module attribute, and the generators that read its own. + /// + private class FrameworkShapedGenerator : BaseSourceGenerator { + + protected override ITypeDefinition[] ModuleAttributeTypes() => + new[] { TypeDefinition.Get("Test.Framework", "FrameworkModuleAttribute") }; + + protected override IEnumerable AttributeSourceGenerators() { + yield return new global::DependencyModules.SourceGenerator.ServiceSourceGenerator(); + } + } + + /// + /// A generator taking the base class defaults, triggering on [DependencyModule]: the + /// shape the extension guide documents. + /// + private class ThirdPartyGenerator : BaseSourceGenerator { + + protected override IEnumerable AttributeSourceGenerators() { + yield break; + } + } + + private class ProvidersOnlyGenerator : FrameworkShapedGenerator { + + protected override void SetupRootGenerator( + IncrementalGeneratorInitializationContext context, + IncrementalValueProvider> valuesProvider) { } + } +} diff --git a/tests/DependencyModules.Tests/GeneratorTests/ReferencedAssemblyScanTests.cs b/tests/DependencyModules.Tests/GeneratorTests/ReferencedAssemblyScanTests.cs index 4f8b986..df333c3 100644 --- a/tests/DependencyModules.Tests/GeneratorTests/ReferencedAssemblyScanTests.cs +++ b/tests/DependencyModules.Tests/GeneratorTests/ReferencedAssemblyScanTests.cs @@ -41,7 +41,7 @@ public class Unrelated { } private const string Preamble = """ using DependencyModules.Runtime.Attributes; - using DependencyModules.Conventions; + using DependencyModules.Runtime.Conventions; using ThePackage; namespace TestNamespace; @@ -56,12 +56,11 @@ private static (GeneratorResult Result, GeneratedAssembly? Assembly) Run( var result = GeneratorTestHarness.Run( new Dictionary { ["Test.cs"] = Preamble + module }, - withConventions: true, additionalReferences: references); var assembly = compile ? GeneratedAssembly.Create( - Preamble + module, withConventions: true, additionalReferences: references) + Preamble + module, additionalReferences: references) : null; return (result, assembly); diff --git a/tests/DependencyModules.Tests/GeneratorTests/RobustnessTests.cs b/tests/DependencyModules.Tests/GeneratorTests/RobustnessTests.cs new file mode 100644 index 0000000..864aa0f --- /dev/null +++ b/tests/DependencyModules.Tests/GeneratorTests/RobustnessTests.cs @@ -0,0 +1,1465 @@ +using System.Linq; +using DependencyModules.Runtime; +using DependencyModules.Runtime.Interfaces; +using DependencyModules.Tests.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace DependencyModules.Tests.GeneratorTests; + +/// +/// Shapes a container has to survive, outside the decorator surface. +/// +/// +/// Each of these is something a real application does and something the generator could plausibly +/// lose quietly — a registration that never happens reads exactly like one that was never asked for. +/// +public class RobustnessTests { + + private static string Call(object target, string method) => + (string)target.GetType().GetMethod(method)!.Invoke(target, null)!; + + /// Adding the same module twice registers its services once. + /// + /// Composing modules that share a dependency is how module graphs work, so a module arriving + /// twice is normal rather than a mistake. Registering twice gives two instances behind one + /// singleton, which is the kind of thing found much later. + /// + [Fact] + public void Module_AddedTwice_RegistersItsServicesOnce() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [SingletonService] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + [DependencyModule] + public partial class TestModule; + """); + + var module = (IDependencyModule)System.Activator.CreateInstance(assembly.Type("TestModule"))!; + + var services = new ServiceCollection(); + services.AddModules(module, module); + + Assert.Single( + services.BuildServiceProvider().GetServices(assembly.Type("IGreeter")).Cast()); + } + + /// Two environment conditions on one service combine with and. + [Theory] + [InlineData("Development", true, 1)] + [InlineData("Development", false, 0)] + [InlineData("Production", true, 0)] + public void Service_WithTwoConditions_RegistersOnlyWhenBothHold( + string environment, bool flag, int expected) { + + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [SingletonService] + [IfEnvironment("Development")] + [IfEnvironmentValue("feature", "on")] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + [DependencyModule] + public partial class TestModule; + """, + environment: new ModuleEnvironment( + environment, + flag ? new Dictionary { ["feature"] = "on" } : new Dictionary())); + + Assert.Equal( + expected, + assembly.BuildProvider().GetServices(assembly.Type("IGreeter")).Cast().Count()); + } + + /// An explicit service attribute wins over a convention that also matches. + [Fact] + public void Convention_DoesNotAlsoRegisterAnAttributedType() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + using DependencyModules.Runtime.Conventions; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [SingletonService] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + [DependencyModule] + public partial class TestModule : IConventionModule { + public void Conventions(IConventionDefinitions conventions) { + conventions.RegisterAll().AsSingleton(); + } + } + """); + + Assert.Single( + assembly.BuildProvider().GetServices(assembly.Type("IGreeter")).Cast()); + } + + /// A keyed and an unkeyed registration of one service coexist. + [Fact] + public void Service_KeyedAndUnkeyed_AreBothResolvable() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [SingletonService] + public class Plain : IGreeter { public string Greet() => "plain"; } + + [SingletonService(Key = "loud")] + public class Loud : IGreeter { public string Greet() => "LOUD"; } + + [DependencyModule] + public partial class TestModule; + """); + + var provider = assembly.BuildProvider(); + var greeter = assembly.Type("IGreeter"); + + Assert.Equal("plain", Call(provider.GetRequiredService(greeter), "Greet")); + Assert.Equal("LOUD", Call(provider.GetRequiredKeyedService(greeter, "loud"), "Greet")); + } + + /// A cross-wired generic service shares one instance across its interfaces. + [Fact] + public void CrossWire_SharesOneInstanceAcrossServiceTypes() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IReader { string Read(); } + public interface IWriter { string Write(); } + + [CrossWireService] + public class Store : IReader, IWriter { + public string Id { get; } = System.Guid.NewGuid().ToString(); + public string Read() => Id; + public string Write() => Id; + } + + [DependencyModule] + public partial class TestModule; + """); + + var provider = assembly.BuildProvider(); + + Assert.Equal( + Call(provider.GetRequiredService(assembly.Type("IReader")), "Read"), + Call(provider.GetRequiredService(assembly.Type("IWriter")), "Write")); + } + + /// A convention excluding a namespace does not register from it. + [Fact] + public void Convention_NotInNamespaces_ExcludesThatNamespace() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + using DependencyModules.Runtime.Conventions; + + namespace TestNamespace { + public interface IGreeter { string Greet(); } + + [DependencyModule] + public partial class TestModule : IConventionModule { + public void Conventions(IConventionDefinitions conventions) { + conventions.RegisterAll() + .NotInNamespaces("TestNamespace.Excluded") + .AsSingleton(); + } + } + } + + namespace TestNamespace.Included { + public class Kept : TestNamespace.IGreeter { public string Greet() => "kept"; } + } + + namespace TestNamespace.Excluded { + public class Dropped : TestNamespace.IGreeter { public string Greet() => "dropped"; } + } + """); + + var all = assembly.BuildProvider().GetServices(assembly.Type("IGreeter")) + .Cast().Select(g => Call(g, "Greet")).ToArray(); + + Assert.Equal(["kept"], all); + } + + /// An interceptor sees an async method through to its result. + [Fact] + public async Task Interceptor_OverAnAsyncMethod() { + var assembly = GeneratedAssembly.Create( + """ + using System.Threading.Tasks; + using DependencyModules.Runtime.Attributes; + using DependencyModules.Runtime.Interception; + + namespace TestNamespace; + + public static class Log { public static int Calls; } + + public interface IFetcher { Task FetchAsync(); } + + [SingletonService] + [Intercept(typeof(CountingInterceptor))] + public class Fetcher : IFetcher { + public async Task FetchAsync() { await Task.Yield(); return "fetched"; } + } + + [SingletonService] + public class CountingInterceptor : IAsyncInterceptor { + public async ValueTask InterceptAsync( + AsyncInvocationContext context) { + + Log.Calls++; + return await context.ProceedAsync(); + } + } + + [DependencyModule] + public partial class TestModule; + """); + + var fetcher = assembly.BuildProvider().GetRequiredService(assembly.Type("IFetcher")); + + var task = (Task)fetcher.GetType().GetMethod("FetchAsync")!.Invoke(fetcher, null)!; + + Assert.Equal("fetched", await task); + Assert.Equal(1, (int)assembly.Type("Log").GetField("Calls")!.GetValue(null)!); + } + + /// A service depending on a collection of a service gets every registration. + [Fact] + public void Service_DependingOnAnEnumerableOfAService_GetsAllOfThem() { + var assembly = GeneratedAssembly.Create( + """ + using System.Collections.Generic; + using System.Linq; + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IRule { string Name { get; } } + + [SingletonService] + public class First : IRule { public string Name => "first"; } + + [SingletonService] + public class Second : IRule { public string Name => "second"; } + + [SingletonService] + public class Engine(IEnumerable rules) { + public string Describe() => string.Join(",", rules.Select(r => r.Name)); + } + + [DependencyModule] + public partial class TestModule; + """); + + Assert.Equal("first,second", Call( + assembly.BuildProvider().GetRequiredService(assembly.Type("Engine")), "Describe")); + } + + // ------------------------------------------------------------------------------------------ + // Registration. [SingletonService] registers one service type; [CrossWireService] registers + // every implemented interface against a shared instance. These pin the edges of that split. + // ------------------------------------------------------------------------------------------ + + /// An explicitly named service type is the one registered. + [Fact] + public void Service_WithAnExplicitServiceType_RegistersThatOne() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IReader { string Read(); } + public interface IWriter { string Write(); } + + [SingletonService(As = typeof(IWriter))] + public class Store : IReader, IWriter { + public string Read() => "read"; + public string Write() => "write"; + } + + [DependencyModule] + public partial class TestModule; + """); + + var provider = assembly.BuildProvider(); + + Assert.Equal("write", Call(provider.GetRequiredService(assembly.Type("IWriter")), "Write")); + Assert.Null(provider.GetService(assembly.Type("IReader"))); + } + + /// + /// A service reaching an interface only through another interface is registered as what it + /// declares. + /// + /// + /// class Store : IAudited where IAudited : IReader. Whether the base interface is + /// also a service is a policy question; what must not happen is the declared one going missing. + /// + [Fact] + public void Service_ImplementingADerivedInterface_RegistersTheDeclaredOne() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IReader { string Read(); } + public interface IAudited : IReader { } + + [SingletonService] + public class Store : IAudited { public string Read() => "read"; } + + [DependencyModule] + public partial class TestModule; + """); + + Assert.Equal("read", Call( + assembly.BuildProvider().GetRequiredService(assembly.Type("IAudited")), "Read")); + } + + /// A generic implementation registers as the open generic it closes nothing of. + [Fact] + public void Service_GenericImplementation_RegistersAsAnOpenGeneric() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IRepo { string Name(); } + + [SingletonService] + public class Repo : IRepo { public string Name() => "repo"; } + + [DependencyModule] + public partial class TestModule; + """); + + var closed = assembly.Type("IRepo`1").MakeGenericType(typeof(string)); + + Assert.Equal("repo", Call(assembly.BuildProvider().GetRequiredService(closed), "Name")); + } + + /// An abstract class is reported rather than registered. + /// + /// Emitting the registration produces code that throws when the provider is built, a long way + /// from the declaration responsible. + /// + [Fact] + public void Service_ThatIsAbstract_IsReported() { + var result = GeneratorTestHarness.Run( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [SingletonService] + public abstract class Greeter : IGreeter { public abstract string Greet(); } + + [DependencyModule] + public partial class TestModule; + """); + + Assert.Empty(result.Errors); + Assert.Contains(result.GeneratorDiagnostics, d => d.Id == "DM0002"); + } + + /// A record registers like any other class. + [Fact] + public void Service_DeclaredAsARecord() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [SingletonService] + public record Greeter : IGreeter { public string Greet() => "hello"; } + + [DependencyModule] + public partial class TestModule; + """); + + Assert.Equal("hello", Call( + assembly.BuildProvider().GetRequiredService(assembly.Type("IGreeter")), "Greet")); + } + + /// A service nested inside another type registers under its nested name. + [Fact] + public void Service_NestedInsideAnotherType() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + public static class Outer { + [SingletonService] + public class Greeter : IGreeter { public string Greet() => "hello"; } + } + + [DependencyModule] + public partial class TestModule; + """); + + Assert.Equal("hello", Call( + assembly.BuildProvider().GetRequiredService(assembly.Type("IGreeter")), "Greet")); + } + + /// Replace leaves one registration standing, not two. + [Fact] + public void Service_RegisteredWithReplace_LeavesOne() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [SingletonService] + public class First : IGreeter { public string Greet() => "first"; } + + [SingletonService(Using = RegistrationType.Replace)] + public class Second : IGreeter { public string Greet() => "second"; } + + [DependencyModule] + public partial class TestModule; + """); + + var all = assembly.BuildProvider().GetServices(assembly.Type("IGreeter")) + .Cast().Select(g => Call(g, "Greet")).ToArray(); + + Assert.Equal(["second"], all); + } + + /// A service whose only constructor is private is reported rather than registered. + [Fact] + public void Service_WithNoAccessibleConstructor_IsReported() { + var result = GeneratorTestHarness.Run( + """ + using DependencyModules.Runtime.Attributes; + using DependencyModules.Runtime.Conventions; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + public class Greeter : IGreeter { + private Greeter() { } + public string Greet() => "hello"; + } + + [DependencyModule] + public partial class TestModule : IConventionModule { + public void Conventions(IConventionDefinitions conventions) { + conventions.RegisterAll().AsSingleton(); + } + } + """); + + Assert.Contains(result.GeneratorDiagnostics, d => d.Id == "DM0006"); + } + + // ------------------------------------------------------------------------------------------ + // Interception generates a wrapper per service, so every member shape the interface can declare + // has to be forwarded. The design notes call this the hard part and name shapes that should be + // refused with a diagnostic rather than mis-generated; these check both halves. + // ------------------------------------------------------------------------------------------ + + private const string InterceptorPreamble = + """ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using DependencyModules.Runtime.Attributes; + using DependencyModules.Runtime.Interception; + + namespace TestNamespace; + + public static class Log { public static int Calls; } + + [SingletonService] + public class CountingInterceptor : IInterceptor { + public TResult Intercept(InvocationContext context) { + Log.Calls++; + return context.Proceed(); + } + } + + """; + + private static GeneratorResult RunIntercepted(string body) => + GeneratorTestHarness.Run(InterceptorPreamble + body + """ + + [DependencyModule] + public partial class TestModule; + """); + + /// A void method is forwarded. + [Fact] + public void Interceptor_OverAVoidMethod() { + Assert.Empty(RunIntercepted( + """ + public interface IWorker { void Work(); } + + [SingletonService] + [Intercept(typeof(CountingInterceptor))] + public class Worker : IWorker { public void Work() { } } + """).Errors); + } + + /// A property is forwarded. + [Fact] + public void Interceptor_OverAProperty() { + Assert.Empty(RunIntercepted( + """ + public interface IWorker { string Name { get; set; } } + + [SingletonService] + [Intercept(typeof(CountingInterceptor))] + public class Worker : IWorker { public string Name { get; set; } = ""; } + """).Errors); + } + + /// An indexer is forwarded. + [Fact] + public void Interceptor_OverAnIndexer() { + Assert.Empty(RunIntercepted( + """ + public interface IWorker { string this[int index] { get; set; } } + + [SingletonService] + [Intercept(typeof(CountingInterceptor))] + public class Worker : IWorker { + public string this[int index] { get => ""; set { } } + } + """).Errors); + } + + /// An event is forwarded. + [Fact] + public void Interceptor_OverAnEvent() { + Assert.Empty(RunIntercepted( + """ + public interface IWorker { event EventHandler? Done; } + + [SingletonService] + [Intercept(typeof(CountingInterceptor))] + public class Worker : IWorker { public event EventHandler? Done; } + """).Errors); + } + + /// A generic method is forwarded with its type parameters. + [Fact] + public void Interceptor_OverAGenericMethod() { + Assert.Empty(RunIntercepted( + """ + public interface IWorker { T Echo(T value); } + + [SingletonService] + [Intercept(typeof(CountingInterceptor))] + public class Worker : IWorker { public T Echo(T value) => value; } + """).Errors); + } + + /// Default values and params survive forwarding. + [Fact] + public void Interceptor_OverDefaultAndParamsArguments() { + Assert.Empty(RunIntercepted( + """ + public interface IWorker { string Join(string separator = ",", params string[] parts); } + + [SingletonService] + [Intercept(typeof(CountingInterceptor))] + public class Worker : IWorker { + public string Join(string separator = ",", params string[] parts) => + string.Join(separator, parts); + } + """).Errors); + } + + /// Members inherited from a base interface are forwarded too. + [Fact] + public void Interceptor_OverAnInheritedInterfaceMember() { + Assert.Empty(RunIntercepted( + """ + public interface IBase { string Read(); } + public interface IWorker : IBase { string Write(); } + + [SingletonService] + [Intercept(typeof(CountingInterceptor))] + public class Worker : IWorker { + public string Read() => "read"; + public string Write() => "write"; + } + """).Errors); + } + + /// An IAsyncEnumerable member is forwarded. + [Fact] + public void Interceptor_OverAnAsyncEnumerable() { + Assert.Empty(RunIntercepted( + """ + public interface IWorker { IAsyncEnumerable StreamAsync(); } + + [SingletonService] + [Intercept(typeof(CountingInterceptor))] + public class Worker : IWorker { + public async IAsyncEnumerable StreamAsync() { + await Task.Yield(); + yield return "one"; + } + } + """).Errors); + } + + /// + /// A ref or out parameter is either forwarded or refused, but never mis-generated. + /// + /// + /// The design notes list these as a shape to refuse with a diagnostic, because arguments cannot + /// round-trip through a reified invocation. Either outcome is acceptable here; a CS error inside + /// the generated wrapper is not. + /// + [Fact] + public void Interceptor_OverRefAndOutParameters_IsForwardedOrRefused() { + var result = RunIntercepted( + """ + public interface IWorker { bool TryRead(out string value); } + + [SingletonService] + [Intercept(typeof(CountingInterceptor))] + public class Worker : IWorker { + public bool TryRead(out string value) { value = "read"; return true; } + } + """); + + Assert.Empty(result.Errors); + } + + // ------------------------------------------------------------------------------------------ + // The three interceptor contracts, exercised on what they are for: reading and replacing + // arguments, replacing results, short-circuiting, retrying, and observing failures. + // ------------------------------------------------------------------------------------------ + + private const string ArgumentPreamble = + """ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using DependencyModules.Runtime.Attributes; + using DependencyModules.Runtime.Interception; + + namespace TestNamespace; + + public static class Log { + public static List Lines = new(); + public static int Calls; + } + + """; + + private static object Resolve(string body, string serviceName) => + GeneratedAssembly.Create(ArgumentPreamble + body + """ + + [DependencyModule] + public partial class TestModule; + """).BuildProvider().GetRequiredService( + GeneratedAssembly.Create(ArgumentPreamble + body + """ + + [DependencyModule] + public partial class TestModule; + """).Type(serviceName)); + + private static GeneratedAssembly Build(string body) => + GeneratedAssembly.Create(ArgumentPreamble + body + """ + + [DependencyModule] + public partial class TestModule; + """); + + private static object Invoke(object target, string method, params object?[] arguments) => + target.GetType().GetMethod(method)!.Invoke(target, arguments)!; + + /// A synchronous interceptor can rewrite an argument before the call proceeds. + /// + /// Replacing an argument is the point of reifying the call. If the wrapper reads the arguments + /// once and passes its own copies on, the write lands nowhere and the implementation sees the + /// original — with nothing to show for it. + /// + [Fact] + public void Interceptor_CanReplaceAnArgument() { + var assembly = Build( + """ + public interface IGreeter { string Greet(string name); } + + [SingletonService] + [Intercept(typeof(RewritingInterceptor))] + public class Greeter : IGreeter { public string Greet(string name) => "hello " + name; } + + [SingletonService] + public class RewritingInterceptor : IInterceptor { + public TResult Intercept(InvocationContext context) { + context.Arguments[0] = "replaced"; + return context.Proceed(); + } + } + """); + + Assert.Equal( + "hello replaced", + Invoke(assembly.BuildProvider().GetRequiredService(assembly.Type("IGreeter")), "Greet", "original")); + } + + /// Arguments carry the names they were declared with. + [Fact] + public void Interceptor_SeesArgumentNamesAndCount() { + var assembly = Build( + """ + public interface IGreeter { string Greet(string name, int times); } + + [SingletonService] + [Intercept(typeof(NamingInterceptor))] + public class Greeter : IGreeter { public string Greet(string name, int times) => name; } + + [SingletonService] + public class NamingInterceptor : IInterceptor { + public TResult Intercept(InvocationContext context) { + for (var i = 0; i < context.Arguments.Count; i++) { + Log.Lines.Add(context.Arguments.NameAt(i) + "=" + context.Arguments[i]); + } + return context.Proceed(); + } + } + """); + + Invoke(assembly.BuildProvider().GetRequiredService(assembly.Type("IGreeter")), "Greet", "ian", 2); + + Assert.Equal( + ["name=ian", "times=2"], + (List)assembly.Type("Log").GetField("Lines")!.GetValue(null)!); + } + + /// An interceptor that never proceeds returns its own result. + [Fact] + public void Interceptor_CanShortCircuitWithoutProceeding() { + var assembly = Build( + """ + public interface IGreeter { string Greet(); } + + [SingletonService] + [Intercept(typeof(CachingInterceptor))] + public class Greeter : IGreeter { + public string Greet() { Log.Calls++; return "real"; } + } + + [SingletonService] + public class CachingInterceptor : IInterceptor { + public TResult Intercept(InvocationContext context) => + (TResult)(object)"cached"; + } + """); + + Assert.Equal( + "cached", + Invoke(assembly.BuildProvider().GetRequiredService(assembly.Type("IGreeter")), "Greet")); + + Assert.Equal(0, (int)assembly.Type("Log").GetField("Calls")!.GetValue(null)!); + } + + /// Proceeding twice runs the implementation twice — the retry shape. + [Fact] + public void Interceptor_CanProceedMoreThanOnce() { + var assembly = Build( + """ + public interface IGreeter { string Greet(); } + + [SingletonService] + [Intercept(typeof(RetryingInterceptor))] + public class Greeter : IGreeter { + public string Greet() { Log.Calls++; return "call" + Log.Calls; } + } + + [SingletonService] + public class RetryingInterceptor : IInterceptor { + public TResult Intercept(InvocationContext context) { + context.Proceed(); + return context.Proceed(); + } + } + """); + + Assert.Equal( + "call2", + Invoke(assembly.BuildProvider().GetRequiredService(assembly.Type("IGreeter")), "Greet")); + + Assert.Equal(2, (int)assembly.Type("Log").GetField("Calls")!.GetValue(null)!); + } + + /// An interceptor observes an exception the implementation throws. + [Fact] + public void Interceptor_SeesAnExceptionFromTheImplementation() { + var assembly = Build( + """ + public interface IGreeter { string Greet(); } + + [SingletonService] + [Intercept(typeof(CatchingInterceptor))] + public class Greeter : IGreeter { + public string Greet() => throw new InvalidOperationException("boom"); + } + + [SingletonService] + public class CatchingInterceptor : IInterceptor { + public TResult Intercept(InvocationContext context) { + try { return context.Proceed(); } + catch (InvalidOperationException e) { + Log.Lines.Add(e.Message); + return (TResult)(object)"recovered"; + } + } + } + """); + + Assert.Equal( + "recovered", + Invoke(assembly.BuildProvider().GetRequiredService(assembly.Type("IGreeter")), "Greet")); + + Assert.Equal(["boom"], (List)assembly.Type("Log").GetField("Lines")!.GetValue(null)!); + } + + /// Two interceptors nest in declaration order, outermost first. + [Fact] + public void Interceptors_NestInDeclarationOrder() { + var assembly = Build( + """ + public interface IGreeter { string Greet(); } + + [SingletonService] + [Intercept(typeof(Outer))] + [Intercept(typeof(Inner))] + public class Greeter : IGreeter { + public string Greet() { Log.Lines.Add("impl"); return "hello"; } + } + + [SingletonService] + public class Outer : IInterceptor { + public TResult Intercept(InvocationContext context) { + Log.Lines.Add("outer"); + return context.Proceed(); + } + } + + [SingletonService] + public class Inner : IInterceptor { + public TResult Intercept(InvocationContext context) { + Log.Lines.Add("inner"); + return context.Proceed(); + } + } + """); + + Invoke(assembly.BuildProvider().GetRequiredService(assembly.Type("IGreeter")), "Greet"); + + Assert.Equal( + ["outer", "inner", "impl"], + (List)assembly.Type("Log").GetField("Lines")!.GetValue(null)!); + } + + /// An async interceptor can rewrite an argument and replace the result. + [Fact] + public async Task AsyncInterceptor_CanReplaceArgumentsAndResult() { + var assembly = Build( + """ + public interface IFetcher { Task FetchAsync(string key); } + + [SingletonService] + [Intercept(typeof(RewritingAsyncInterceptor))] + public class Fetcher : IFetcher { + public async Task FetchAsync(string key) { + await Task.Yield(); + return "fetched:" + key; + } + } + + [SingletonService] + public class RewritingAsyncInterceptor : IAsyncInterceptor { + public async ValueTask InterceptAsync( + AsyncInvocationContext context) { + + context.Arguments[0] = "replaced"; + var result = await context.ProceedAsync(); + return (TResult)(object)((string)(object)result! + ":seen"); + } + } + """); + + var fetcher = assembly.BuildProvider().GetRequiredService(assembly.Type("IFetcher")); + var task = (Task)Invoke(fetcher, "FetchAsync", "original"); + + Assert.Equal("fetched:replaced:seen", await task); + } + + /// An async interceptor can short-circuit without awaiting the implementation. + [Fact] + public async Task AsyncInterceptor_CanShortCircuit() { + var assembly = Build( + """ + public interface IFetcher { Task FetchAsync(); } + + [SingletonService] + [Intercept(typeof(ShortCircuitingInterceptor))] + public class Fetcher : IFetcher { + public async Task FetchAsync() { + Log.Calls++; + await Task.Yield(); + return "real"; + } + } + + [SingletonService] + public class ShortCircuitingInterceptor : IAsyncInterceptor { + public ValueTask InterceptAsync( + AsyncInvocationContext context) => + new ValueTask((TResult)(object)"cached"); + } + """); + + var fetcher = assembly.BuildProvider().GetRequiredService(assembly.Type("IFetcher")); + + Assert.Equal("cached", await (Task)Invoke(fetcher, "FetchAsync")); + Assert.Equal(0, (int)assembly.Type("Log").GetField("Calls")!.GetValue(null)!); + } + + /// A stream interceptor can replace the items the implementation yields. + [Fact] + public async Task StreamInterceptor_CanReplaceTheYieldedItems() { + var assembly = Build( + """ + public interface IStreamer { IAsyncEnumerable StreamAsync(string prefix); } + + [SingletonService] + [Intercept(typeof(StreamRewritingInterceptor))] + public class Streamer : IStreamer { + public async IAsyncEnumerable StreamAsync(string prefix) { + await Task.Yield(); + yield return prefix + ":one"; + yield return prefix + ":two"; + } + } + + [SingletonService] + public class StreamRewritingInterceptor : IAsyncEnumerableInterceptor { + public async IAsyncEnumerable InterceptStream( + StreamInvocationContext context) { + + context.Arguments[0] = "replaced"; + + await foreach (var item in context.Proceed()) { + Log.Lines.Add(item!.ToString()!); + yield return item; + } + } + } + """); + + var streamer = assembly.BuildProvider().GetRequiredService(assembly.Type("IStreamer")); + var stream = (IAsyncEnumerable)Invoke(streamer, "StreamAsync", "original"); + + var seen = new List(); + await foreach (var item in stream) { + seen.Add(item); + } + + Assert.Equal(["replaced:one", "replaced:two"], seen); + Assert.Equal(seen, (List)assembly.Type("Log").GetField("Lines")!.GetValue(null)!); + } + + /// A value-type argument round-trips through replacement. + /// + /// Arguments are reified as object, so a value type is boxed on the way in and has to be + /// unboxed back to the parameter's type on the way out. + /// + [Fact] + public void Interceptor_CanReplaceAValueTypeArgument() { + var assembly = Build( + """ + public interface ICounter { int Add(int value); } + + [SingletonService] + [Intercept(typeof(DoublingInterceptor))] + public class Counter : ICounter { public int Add(int value) => value + 1; } + + [SingletonService] + public class DoublingInterceptor : IInterceptor { + public TResult Intercept(InvocationContext context) { + context.Arguments[0] = (int)context.Arguments[0]! * 10; + return context.Proceed(); + } + } + """); + + Assert.Equal( + 41, + Invoke(assembly.BuildProvider().GetRequiredService(assembly.Type("ICounter")), "Add", 4)); + } + + // ------------------------------------------------------------------------------------------ + // Module composition. Modules are the unit an application assembles, so what happens when + // several arrive together — in either order, more than once, depending on each other — is the + // part a real application exercises hardest. + // ------------------------------------------------------------------------------------------ + + private static object Instance(GeneratedAssembly assembly, string moduleName) => + System.Activator.CreateInstance(assembly.Type(moduleName))!; + + /// Two modules compose, and each contributes its own registrations. + [Fact] + public void Modules_ComposedTogether_BothContribute() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IReader { string Read(); } + public interface IWriter { string Write(); } + + [SingletonService(Realm = typeof(ReadModule))] + public class Reader : IReader { public string Read() => "read"; } + + [SingletonService(Realm = typeof(WriteModule))] + public class Writer : IWriter { public string Write() => "write"; } + + [DependencyModule(OnlyRealm = true)] + public partial class ReadModule; + + [DependencyModule(OnlyRealm = true)] + public partial class WriteModule; + """, + moduleName: "ReadModule"); + + var services = new ServiceCollection(); + + services.AddModules( + (IDependencyModule)Instance(assembly, "ReadModule"), + (IDependencyModule)Instance(assembly, "WriteModule")); + + var provider = services.BuildServiceProvider(); + + Assert.Equal("read", Call(provider.GetRequiredService(assembly.Type("IReader")), "Read")); + Assert.Equal("write", Call(provider.GetRequiredService(assembly.Type("IWriter")), "Write")); + } + + /// Composition order does not decide what is available. + /// + /// A service in one module depending on one from another has to resolve whichever order the + /// modules were added, because registration is a phase and resolution happens after all of it. + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Modules_CrossModuleDependency_ResolvesInEitherOrder(bool readFirst) { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IReader { string Read(); } + + [SingletonService(Realm = typeof(ReadModule))] + public class Reader : IReader { public string Read() => "read"; } + + [SingletonService(Realm = typeof(UseModule))] + public class Consumer(IReader reader) { + public string Describe() => "using:" + reader.Read(); + } + + [DependencyModule(OnlyRealm = true)] + public partial class ReadModule; + + [DependencyModule(OnlyRealm = true)] + public partial class UseModule; + """, + moduleName: "ReadModule"); + + var read = (IDependencyModule)Instance(assembly, "ReadModule"); + var use = (IDependencyModule)Instance(assembly, "UseModule"); + + var services = new ServiceCollection(); + services.AddModules(readFirst ? new[] { read, use } : new[] { use, read }); + + Assert.Equal("using:read", Call( + services.BuildServiceProvider().GetRequiredService(assembly.Type("Consumer")), "Describe")); + } + + /// Two equal instances of one module register its services once. + /// + /// Composing two modules that each depend on a third is how module graphs work, so the third + /// arriving twice is normal. Registering twice gives two instances behind one singleton. + /// + [Fact] + public void Module_ArrivingTwiceAsSeparateInstances_RegistersOnce() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [SingletonService] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + [DependencyModule] + public partial class TestModule; + """); + + var services = new ServiceCollection(); + + services.AddModules( + (IDependencyModule)Instance(assembly, "TestModule"), + (IDependencyModule)Instance(assembly, "TestModule")); + + Assert.Single( + services.BuildServiceProvider().GetServices(assembly.Type("IGreeter")).Cast()); + } + + /// A realm-only module contributes nothing to a composition it is not part of. + [Fact] + public void Module_RealmOnly_DoesNotLeakIntoAnotherComposition() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [SingletonService(Realm = typeof(HiddenModule))] + public class Hidden : IGreeter { public string Greet() => "hidden"; } + + [DependencyModule(OnlyRealm = true)] + public partial class HiddenModule; + + [DependencyModule(OnlyRealm = true)] + public partial class PlainModule; + """, + moduleName: "PlainModule"); + + var services = new ServiceCollection(); + services.AddModules((IDependencyModule)Instance(assembly, "PlainModule")); + + Assert.Empty( + services.BuildServiceProvider().GetServices(assembly.Type("IGreeter")).Cast()); + } + + /// A decorator in one module wraps a service another module registered. + /// + /// Decorations run as a phase after every module's registrations, which is what lets a package + /// contribute decoration to an application's services. Now that the calls are closed rather than + /// open, that phase ordering still has to hold. + /// + [Fact] + public void Module_DecoratesAServiceAnotherModuleRegistered() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [SingletonService(Realm = typeof(ServiceModule))] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + [Decorator(Realm = typeof(DecoratorModule))] + public class LoudGreeter(IGreeter inner) : IGreeter { + public string Greet() => inner.Greet().ToUpperInvariant(); + } + + [DependencyModule(OnlyRealm = true)] + public partial class ServiceModule; + + [DependencyModule(OnlyRealm = true)] + public partial class DecoratorModule; + """, + moduleName: "ServiceModule"); + + var services = new ServiceCollection(); + + services.AddModules( + (IDependencyModule)Instance(assembly, "ServiceModule"), + (IDependencyModule)Instance(assembly, "DecoratorModule")); + + Assert.Equal("HELLO", Call( + services.BuildServiceProvider().GetRequiredService(assembly.Type("IGreeter")), "Greet")); + } + + // ------------------------------------------------------------------------------------------ + // Environment conditions and convention selectors. Both decide whether a registration happens + // at all, so getting one wrong is invisible until something is missing at run time. + // ------------------------------------------------------------------------------------------ + + private static GeneratedAssembly WithEnvironment(string body, IModuleEnvironment environment) => + GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + using DependencyModules.Runtime.Conventions; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + """ + body + """ + + [DependencyModule] + public partial class TestModule; + """, + environment: environment); + + private static int Count(GeneratedAssembly assembly) => + assembly.BuildProvider().GetServices(assembly.Type("IGreeter")).Cast().Count(); + + /// IfNotEnvironment registers everywhere except the names it lists. + [Theory] + [InlineData("Production", 0)] + [InlineData("Development", 1)] + public void Condition_IfNotEnvironment(string environment, int expected) { + Assert.Equal(expected, Count(WithEnvironment( + """ + [SingletonService] + [IfNotEnvironment("Production")] + public class Greeter : IGreeter { public string Greet() => "hello"; } + """, + new ModuleEnvironment(environment)))); + } + + /// One condition listing several names matches any of them. + [Theory] + [InlineData("Development", 1)] + [InlineData("Staging", 1)] + [InlineData("Production", 0)] + public void Condition_IfEnvironment_WithSeveralNames(string environment, int expected) { + Assert.Equal(expected, Count(WithEnvironment( + """ + [SingletonService] + [IfEnvironment("Development", "Staging")] + public class Greeter : IGreeter { public string Greet() => "hello"; } + """, + new ModuleEnvironment(environment)))); + } + + /// A value condition with no expected value tests only that the key is present. + [Theory] + [InlineData(true, 1)] + [InlineData(false, 0)] + public void Condition_IfEnvironmentValue_KeyPresence(bool present, int expected) { + Assert.Equal(expected, Count(WithEnvironment( + """ + [SingletonService] + [IfEnvironmentValue("feature")] + public class Greeter : IGreeter { public string Greet() => "hello"; } + """, + new ModuleEnvironment( + "Development", + present + ? new Dictionary { ["feature"] = "anything" } + : new Dictionary())))); + } + + /// IfNotEnvironmentValue is the negation. + [Theory] + [InlineData("on", 0)] + [InlineData("off", 1)] + public void Condition_IfNotEnvironmentValue(string value, int expected) { + Assert.Equal(expected, Count(WithEnvironment( + """ + [SingletonService] + [IfNotEnvironmentValue("feature", "on")] + public class Greeter : IGreeter { public string Greet() => "hello"; } + """, + new ModuleEnvironment( + "Development", new Dictionary { ["feature"] = value })))); + } + + /// A condition on the convention and one on the class combine with and. + /// + /// Letting either side win would mean one declaration silently discarding a condition written in + /// the other, which is the kind of thing nobody finds until production. + /// + [Theory] + [InlineData("Development", "on", 1)] + [InlineData("Development", "off", 0)] + [InlineData("Production", "on", 0)] + public void Condition_OnConventionAndOnClass_BothMustHold( + string environment, string flag, int expected) { + + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + using DependencyModules.Runtime.Conventions; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + [IfEnvironmentValue("feature", "on")] + public class Greeter : IGreeter { public string Greet() => "hello"; } + + [DependencyModule] + public partial class TestModule : IConventionModule { + public void Conventions(IConventionDefinitions conventions) { + conventions.RegisterAll().IfEnvironment("Development").AsSingleton(); + } + } + """, + environment: new ModuleEnvironment( + environment, new Dictionary { ["feature"] = flag })); + + Assert.Equal(expected, Count(assembly)); + } + + private static string[] Names(GeneratedAssembly assembly) => + assembly.BuildProvider().GetServices(assembly.Type("IGreeter")) + .Cast().Select(g => Call(g, "Greet")).OrderBy(n => n).ToArray(); + + private static GeneratedAssembly WithConvention(string chain) => + GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + using DependencyModules.Runtime.Conventions; + + namespace TestNamespace; + + public interface IGreeter { string Greet(); } + + public class MorningGreeter : IGreeter { public string Greet() => "morning"; } + public class EveningGreeter : IGreeter { public string Greet() => "evening"; } + public class Salutation : IGreeter { public string Greet() => "salutation"; } + + [DependencyModule] + public partial class TestModule : IConventionModule { + public void Conventions(IConventionDefinitions conventions) { + conventions.RegisterAll()CHAIN.AsSingleton(); + } + } + """.Replace("CHAIN", chain)); + + /// A name glob selects by the type's own name. + [Fact] + public void Convention_WithName_SelectsByGlob() { + Assert.Equal(["evening", "morning"], Names(WithConvention(""".WithName("*Greeter")"""))); + } + + /// An excluding glob removes what it matches. + [Fact] + public void Convention_WithoutName_ExcludesByGlob() { + Assert.Equal(["evening", "salutation"], Names(WithConvention(""".WithoutName("Morning*")"""))); + } + + /// A single-character wildcard matches exactly one character. + [Fact] + public void Convention_WithName_SingleCharacterWildcard() { + Assert.Equal(["salutation"], Names(WithConvention(""".WithName("Salutatio?")"""))); + } + + /// An attribute filter selects only what carries it. + [Fact] + public void Convention_WithAttribute_SelectsOnlyMarkedTypes() { + var assembly = GeneratedAssembly.Create( + """ + using System; + using DependencyModules.Runtime.Attributes; + using DependencyModules.Runtime.Conventions; + + namespace TestNamespace; + + [AttributeUsage(AttributeTargets.Class)] + public class ExportAttribute : Attribute { } + + public interface IGreeter { string Greet(); } + + [Export] + public class Marked : IGreeter { public string Greet() => "marked"; } + + public class Unmarked : IGreeter { public string Greet() => "unmarked"; } + + [DependencyModule] + public partial class TestModule : IConventionModule { + public void Conventions(IConventionDefinitions conventions) { + conventions.RegisterAll().WithAttribute().AsSingleton(); + } + } + """); + + Assert.Equal(["marked"], Names(assembly)); + } + + /// An exact-namespace filter does not match a nested namespace. + [Fact] + public void Convention_InExactNamespaces_DoesNotMatchNested() { + var assembly = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + using DependencyModules.Runtime.Conventions; + + namespace TestNamespace { + public interface IGreeter { string Greet(); } + + [DependencyModule] + public partial class TestModule : IConventionModule { + public void Conventions(IConventionDefinitions conventions) { + conventions.RegisterAll() + .InExactNamespaces("TestNamespace.Direct") + .AsSingleton(); + } + } + } + + namespace TestNamespace.Direct { + public class Here : TestNamespace.IGreeter { public string Greet() => "here"; } + } + + namespace TestNamespace.Direct.Nested { + public class Deeper : TestNamespace.IGreeter { public string Greet() => "deeper"; } + } + """); + + Assert.Equal(["here"], Names(assembly)); + } +} diff --git a/tests/DependencyModules.Tests/Infrastructure/GeneratedAssembly.cs b/tests/DependencyModules.Tests/Infrastructure/GeneratedAssembly.cs index 2a46a1e..17454b0 100644 --- a/tests/DependencyModules.Tests/Infrastructure/GeneratedAssembly.cs +++ b/tests/DependencyModules.Tests/Infrastructure/GeneratedAssembly.cs @@ -49,8 +49,7 @@ public static GeneratedAssembly Create( string source, string moduleName = "TestModule", IReadOnlyDictionary? buildProperties = null, - bool withConventions = false, - IModuleEnvironment? environment = null, + IModuleEnvironment? environment = null, IReadOnlyList? additionalReferences = null) { var assemblyName = "GeneratedAssemblyTest" + Interlocked.Increment(ref _assemblyCounter); @@ -59,8 +58,7 @@ public static GeneratedAssembly Create( new Dictionary { ["Test.cs"] = source }, buildProperties, assemblyName: assemblyName, - withConventions: withConventions, - additionalReferences: additionalReferences); + additionalReferences: additionalReferences); result.AssertNoErrors(); diff --git a/tests/DependencyModules.Tests/Infrastructure/GeneratorTestHarness.cs b/tests/DependencyModules.Tests/Infrastructure/GeneratorTestHarness.cs index 261d65f..8d52ad4 100644 --- a/tests/DependencyModules.Tests/Infrastructure/GeneratorTestHarness.cs +++ b/tests/DependencyModules.Tests/Infrastructure/GeneratorTestHarness.cs @@ -1,4 +1,3 @@ -extern alias ConventionsGen; using System.Collections.Immutable; using System.Reflection; @@ -25,18 +24,15 @@ public static class GeneratorTestHarness { /// treats Program.cs specially when auto-generating an application module. /// MSBuild properties visible to the generator, without the /// build_property. prefix. - /// - /// Also runs the convention generator, which ships as its own analyzer. Opt-in rather than - /// always on, because it adds its contract types to every compilation through post-initialization - /// output and would otherwise change the output of every existing snapshot test. - /// + /// The generators to run. Defaults to the one this package ships; + /// the extension-seam tests pass a framework-shaped generator of their own instead. public static GeneratorResult Run( IReadOnlyDictionary sources, IReadOnlyDictionary? buildProperties = null, OutputKind outputKind = OutputKind.DynamicallyLinkedLibrary, string assemblyName = "GeneratorTestAssembly", - bool withConventions = false, - IReadOnlyList? additionalReferences = null) { + IReadOnlyList? additionalReferences = null, + IReadOnlyList? generators = null) { // MSBuild hands the compiler absolute paths, and the generator compares a file's location // against ProjectDir to decide whether it owns the auto-generated ApplicationModule. @@ -59,7 +55,7 @@ public static GeneratorResult Run( new CSharpCompilationOptions(outputKind, nullableContextOptions: NullableContextOptions.Enable)); var driver = CSharpGeneratorDriver.Create( - Generators(withConventions), + generators == null ? Generators() : generators.ToArray(), optionsProvider: new TestAnalyzerConfigOptionsProvider(buildProperties), parseOptions: new CSharpParseOptions(LanguageVersion.Latest)); @@ -68,11 +64,24 @@ public static GeneratorResult Run( var runResult = driver.GetRunResult(); - var generatedSources = runResult.Results + // Hint names are unique within a generator but not across them, so a run with more than one + // generator can produce the same name twice. Keyed rather than grouped it threw, hiding the + // duplication behind a dictionary error; two generators emitting one type's partial twice + // is a real defect, so it is recorded and asserted on instead. + var emitted = runResult.Results .SelectMany(result => result.GeneratedSources) - .ToDictionary( - generated => generated.HintName, - generated => generated.SourceText.ToString()); + .Select(generated => (generated.HintName, Source: generated.SourceText.ToString())) + .ToArray(); + + var duplicateHintNames = emitted + .GroupBy(generated => generated.HintName) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .ToArray(); + + var generatedSources = emitted + .GroupBy(generated => generated.HintName) + .ToDictionary(group => group.Key, group => group.First().Source); // The generator catches its own exceptions and, with no log folder configured, discards // them. Surface them here so a crashing generator fails loudly instead of producing nothing. @@ -86,7 +95,8 @@ public static GeneratorResult Run( generatorDiagnostics, outputCompilation.GetDiagnostics(), outputCompilation, - exceptions!); + exceptions!, + duplicateHintNames); } /// @@ -94,20 +104,22 @@ public static GeneratorResult Run( /// public static GeneratorResult Run( string source, - IReadOnlyDictionary? buildProperties = null, - bool withConventions = false) => - Run(new Dictionary { ["Test.cs"] = source }, buildProperties, - withConventions: withConventions); - - private static ISourceGenerator[] Generators(bool withConventions) => - withConventions - ? new ISourceGenerator[] { - new SourceGenerator.SourceGenerator().AsSourceGenerator(), - new ConventionsGen::DependencyModules.Conventions.ConventionSourceGenerator().AsSourceGenerator(), - } - : new ISourceGenerator[] { - new SourceGenerator.SourceGenerator().AsSourceGenerator(), - }; + IReadOnlyDictionary? buildProperties = null) => + Run(new Dictionary { ["Test.cs"] = source }, buildProperties); + + /// + /// One generator. Conventions, services, decorators and interception all come from it. + /// + /// + /// Conventions used to ship as a second analyzer and be opt-in here, so its contract types did + /// not land in every compilation. They are declared in DependencyModules.Runtime now, and the + /// generator that reads them is part of this one — which is what lets a decoration be emitted + /// closed over a registration a convention produced. + /// + private static ISourceGenerator[] Generators() => + new ISourceGenerator[] { + new SourceGenerator.SourceGenerator().AsSourceGenerator(), + }; /// /// Runs the generator over , then re-runs the same driver over @@ -136,7 +148,7 @@ Compilation Compile(IReadOnlyDictionary sources) => new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); GeneratorDriver driver = CSharpGeneratorDriver.Create( - Generators(withConventions), + Generators(), optionsProvider: new TestAnalyzerConfigOptionsProvider(buildProperties), parseOptions: new CSharpParseOptions(LanguageVersion.Latest), driverOptions: new GeneratorDriverOptions(default, trackIncrementalGeneratorSteps: true)); @@ -272,7 +284,8 @@ public class GeneratorResult( ImmutableArray generatorDiagnostics, ImmutableArray compilationDiagnostics, Compilation compilation, - IReadOnlyList generatorExceptions) { + IReadOnlyList generatorExceptions, + IReadOnlyList? duplicateHintNames = null) { public IReadOnlyDictionary GeneratedSources { get; } = generatedSources; @@ -284,6 +297,11 @@ public class GeneratorResult( public IReadOnlyList GeneratorExceptions { get; } = generatorExceptions; + /// + /// Hint names emitted by more than one generator in the same run. + /// + public IReadOnlyList DuplicateHintNames { get; } = duplicateHintNames ?? Array.Empty(); + public IEnumerable Errors => GeneratorDiagnostics.Concat(CompilationDiagnostics) .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error); diff --git a/tests/DependencyModules.Tests/NUnitTests/ModuleTestAttributeTests.cs b/tests/DependencyModules.Tests/NUnitTests/ModuleTestAttributeTests.cs new file mode 100644 index 0000000..1a4bda1 --- /dev/null +++ b/tests/DependencyModules.Tests/NUnitTests/ModuleTestAttributeTests.cs @@ -0,0 +1,167 @@ +using DependencyModules.NUnit.Attributes; +using NUnit.Framework.Interfaces; +using NUnit.Framework.Internal; +using Xunit; + +namespace DependencyModules.Tests.NUnitTests; + +/// +/// Drives the NUnit integration's test-case building directly, from xUnit. +/// +/// +/// A row that supplies more arguments than the method takes is reported as a non-runnable test, +/// which NUnit counts as a failing one — so a fixture covering it would turn the integration suite +/// red to prove it works. Calling BuildFrom asserts the same behaviour without that. +/// +/// It also pins what is built and when: one case per row, placeholders rather than resolved +/// services, and the row kept aside so execution knows which leading arguments are real. Building a +/// container here instead would construct every mock in an assembly during discovery. +/// +public class ModuleTestAttributeTests { + + private interface IService; + + /// + /// The methods under test, carrying real attributes — the same reflection BuildFrom reads + /// from at discovery. + /// + private class Samples { + + public void NoParameters() { } + + public void OneServiceParameter(IService service) { } + + public void NumberThenService(int number, IService service) { } + + [ModuleTestCase(1)] + [ModuleTestCase(2)] + public void TwoRows(int number, IService service) { } + + [ModuleTestCase(7)] + public void OneRowCoveringOneOfTwoParameters(int number, IService service) { } + + [ModuleTestCase(1, "text")] + [ModuleTestCase(2, null)] + public void RowsNeedingQuoting(int number, string? text) { } + + [ModuleTestCase(1, TestName = "the first one")] + public void NamedRow(int number) { } + + [ModuleTestCase(1, 2, 3)] + public void TooManyArguments(int first, int second) { } + + [ModuleTestCase(1, 2)] + [ModuleTestCase(1, 2, 3)] + public void OneGoodRowAndOneBad(int first, int second) { } + } + + [Fact] + public void BuildsOneCaseWhenThereAreNoRows() { + var testMethod = Assert.Single(Build(nameof(Samples.OneServiceParameter))); + + Assert.Equal(nameof(Samples.OneServiceParameter), testMethod.Name); + Assert.Equal(RunState.Runnable, testMethod.RunState); + } + + /// + /// The placeholder stands in for a service that does not exist yet. It has to be there, because + /// NUnit checks the argument count against the method's parameters when the case is built. + /// + [Fact] + public void APlaceholderIsSuppliedForEveryParameter() { + var testMethod = Assert.Single(Build(nameof(Samples.NumberThenService))); + + Assert.Equal(2, testMethod.Arguments.Length); + Assert.All(testMethod.Arguments, Assert.Null); + } + + [Fact] + public void AMethodWithNoParametersBuildsWithNoArguments() { + Assert.Empty(Assert.Single(Build(nameof(Samples.NoParameters))).Arguments); + } + + [Fact] + public void BuildsOneCasePerRow() { + var built = Build(nameof(Samples.TwoRows)); + + Assert.Equal(2, built.Length); + Assert.Equal(1, built[0].Arguments[0]); + Assert.Equal(2, built[1].Arguments[0]); + } + + /// + /// A row covers the leading parameters only; the rest stay null until the container fills them. + /// + [Fact] + public void ARowLeavesTheRemainingParametersToTheContainer() { + var testMethod = Assert.Single(Build(nameof(Samples.OneRowCoveringOneOfTwoParameters))); + + Assert.Equal(7, testMethod.Arguments[0]); + Assert.Null(testMethod.Arguments[1]); + } + + [Fact] + public void RowsAreNamedAfterTheirOwnArguments() { + Assert.Equal("OneRowCoveringOneOfTwoParameters(7)", + Assert.Single(Build(nameof(Samples.OneRowCoveringOneOfTwoParameters))).Name); + } + + /// + /// Only the row's arguments appear. Naming a case after the trailing placeholders would produce + /// "OneRowCoveringOneOfTwoParameters(7, null)", where the null is a service that will exist by + /// the time the test runs. + /// + [Fact] + public void ARowsNameOmitsTheParametersTheContainerSupplies() { + Assert.DoesNotContain("null", + Assert.Single(Build(nameof(Samples.OneRowCoveringOneOfTwoParameters))).Name); + } + + [Fact] + public void StringsAreQuotedAndNullsSpelledOutInARowsName() { + var built = Build(nameof(Samples.RowsNeedingQuoting)); + + Assert.Equal("RowsNeedingQuoting(1, \"text\")", built[0].Name); + Assert.Equal("RowsNeedingQuoting(2, null)", built[1].Name); + } + + [Fact] + public void ARowCanNameItself() { + Assert.Equal("the first one", Assert.Single(Build(nameof(Samples.NamedRow))).Name); + } + + /// + /// The case a live fixture cannot cover, because a non-runnable test is a failing one. + /// + [Fact] + public void ARowWithTooManyArgumentsIsReportedRatherThanThrown() { + var testMethod = Assert.Single(Build(nameof(Samples.TooManyArguments))); + + Assert.Equal(RunState.NotRunnable, testMethod.RunState); + + var reason = Assert.IsType(testMethod.Properties.Get(PropertyNames.SkipReason)); + + Assert.Contains("supplied 3 arguments to a method taking 2", reason); + } + + /// + /// One bad row must not take the rest of the fixture with it, which is what throwing during + /// discovery would do. + /// + [Fact] + public void AGoodRowStillBuildsAlongsideABadOne() { + var built = Build(nameof(Samples.OneGoodRowAndOneBad)); + + Assert.Equal(2, built.Length); + Assert.Equal(RunState.Runnable, built[0].RunState); + Assert.Equal(RunState.NotRunnable, built[1].RunState); + } + + private static TestMethod[] Build(string methodName) { + var method = typeof(Samples).GetMethod(methodName)!; + + return new ModuleTestAttribute() + .BuildFrom(new MethodWrapper(typeof(Samples), method), suite: null) + .ToArray(); + } +} diff --git a/tests/DependencyModules.Tests/RuntimeTests/DecoratorHelperTests.cs b/tests/DependencyModules.Tests/RuntimeTests/DecoratorHelperTests.cs index d16b866..af799eb 100644 --- a/tests/DependencyModules.Tests/RuntimeTests/DecoratorHelperTests.cs +++ b/tests/DependencyModules.Tests/RuntimeTests/DecoratorHelperTests.cs @@ -46,6 +46,24 @@ private class RepoWrapper(IRepo inner) : IRepo { private class StringRepo : Repo; + private class DisposableThing : IThing, IDisposable { + public int Disposals { get; private set; } + + public string Describe() => "thing"; + + public void Dispose() => Disposals++; + } + + private class DisposableWrapper(IThing inner) : IThing, IDisposable { + public IThing Inner { get; } = inner; + + public int Disposals { get; private set; } + + public string Describe() => $"wrapped({Inner.Describe()})"; + + public void Dispose() => Disposals++; + } + private static IThing Resolve(IServiceCollection services) => services.BuildServiceProvider().GetRequiredService(); @@ -79,261 +97,85 @@ public void Decorate_WrapsAnInstanceRegistration() { Assert.Equal("wrapped(thing)", Resolve(services).Describe()); } + + + /// - /// The failure this guards against is not a wrong answer but a stack overflow: if the factory - /// reads the collection slot instead of the captured descriptor, it resolves itself forever. + /// The generic overload wraps the same shapes the type-driven one does, without a cast at the + /// call site. /// [Fact] - public void Decorate_DoesNotRecurseIntoItsOwnReplacement() { + public void DecorateOfT_WrapsAnImplementationTypeRegistration() { var services = new ServiceCollection(); services.AddSingleton(); - DecoratorHelper.Decorate(services, typeof(IThing), (_, inner) => new Wrapper((IThing)inner)); + DecoratorHelper.Decorate(services, typeof(Wrapper), (_, inner) => new Wrapper(inner)); - var resolved = Assert.IsType(Resolve(services)); - Assert.IsType(resolved.Inner); + Assert.Equal("wrapped(thing)", Resolve(services).Describe()); } [Fact] - public void Decorate_WrapsEveryRegistrationOfTheService() { + public void DecorateOfT_WrapsEveryRegistrationOfTheService() { var services = new ServiceCollection(); services.AddSingleton(); services.AddSingleton(); - DecoratorHelper.Decorate(services, typeof(IThing), (_, inner) => new Wrapper((IThing)inner)); + DecoratorHelper.Decorate(services, typeof(Wrapper), (_, inner) => new Wrapper(inner)); var all = services.BuildServiceProvider().GetServices().ToArray(); - Assert.Equal(2, all.Length); Assert.All(all, thing => Assert.IsType(thing)); Assert.Equal(["wrapped(thing)", "wrapped(other)"], all.Select(t => t.Describe())); } - [Theory] - [InlineData(ServiceLifetime.Singleton)] - [InlineData(ServiceLifetime.Scoped)] - [InlineData(ServiceLifetime.Transient)] - public void Decorate_PreservesTheOriginalLifetime(ServiceLifetime lifetime) { - IServiceCollection services = new ServiceCollection(); - services.Add(new ServiceDescriptor(typeof(IThing), typeof(Thing), lifetime)); - - DecoratorHelper.Decorate(services, typeof(IThing), (_, inner) => new Wrapper((IThing)inner)); - - Assert.Equal(lifetime, Assert.Single(services).Lifetime); - } - [Fact] - public void Decorate_LeavesOtherServicesAlone() { + public void DecorateOfT_StacksInApplicationOrder() { var services = new ServiceCollection(); services.AddSingleton(); - services.AddSingleton(); - DecoratorHelper.Decorate(services, typeof(IThing), (_, inner) => new Wrapper((IThing)inner)); - - Assert.IsType(services.BuildServiceProvider().GetRequiredService()); - } - - private interface IUnrelated; - - private class Unrelated : IUnrelated; - - [Fact] - public void Decorate_WithNoMatchingRegistration_DoesNothing() { - var services = new ServiceCollection(); - services.AddSingleton(); + DecoratorHelper.Decorate(services, typeof(Wrapper), (_, inner) => new Wrapper(inner)); + DecoratorHelper.Decorate(services, typeof(SecondWrapper), (_, inner) => new SecondWrapper(inner)); - DecoratorHelper.Decorate(services, typeof(IThing), (_, inner) => new Wrapper((IThing)inner)); - - Assert.Single(services); - } - - [Fact] - public void Decorate_AppliedTwice_NestsInApplicationOrder() { - var services = new ServiceCollection(); - services.AddSingleton(); - - DecoratorHelper.Decorate(services, typeof(IThing), (_, inner) => new Wrapper((IThing)inner)); - DecoratorHelper.Decorate(services, typeof(IThing), (_, inner) => new SecondWrapper((IThing)inner)); - - // Applied first ends up innermost. Assert.Equal("second(wrapped(thing))", Resolve(services).Describe()); } - [Fact] - public void Decorate_ResolvesTheDecoratorsOwnDependencies() { - var services = new ServiceCollection(); - services.AddSingleton(); - services.AddSingleton(); - - DecoratorHelper.Decorate(services, typeof(IThing), - (provider, inner) => new DependentWrapper((IThing)inner, provider.GetRequiredService())); - - var resolved = Assert.IsType(Resolve(services)); - Assert.NotNull(resolved.Dependency); - } - - private class DependentWrapper(IThing inner, IUnrelated dependency) : IThing { - public IUnrelated Dependency { get; } = dependency; - - public string Describe() => inner.Describe(); - } - - private interface IGeneric { - string Describe(); - } - - private class GenericThing : IGeneric { - public string Describe() => $"generic<{typeof(T).Name}>"; - } - - private class GenericWrapper(IGeneric inner) : IGeneric { - public string Describe() => $"wrapped({inner.Describe()})"; - } - /// - /// Decorating an open generic has to wrap each closed registration of it. This is the shape a - /// mediator pipeline behaviour takes. + /// A closed construction is decorated by naming it, which is what the generator emits for a + /// generic decorator: one call per closed registration rather than one open-generic call. /// [Fact] - public void Decorate_OpenGeneric_WrapsEveryClosedRegistration() { - var services = new ServiceCollection(); - services.AddSingleton, GenericThing>(); - services.AddSingleton, GenericThing>(); - - DecoratorHelper.Decorate(services, typeof(IGeneric<>), (_, inner) => { - var argument = inner.GetType().GetInterfaces() - .First(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IGeneric<>)) - .GetGenericArguments()[0]; - - return Activator.CreateInstance(typeof(GenericWrapper<>).MakeGenericType(argument), inner)!; - }); - - var provider = services.BuildServiceProvider(); - - Assert.Equal("wrapped(generic)", provider.GetRequiredService>().Describe()); - Assert.Equal("wrapped(generic)", provider.GetRequiredService>().Describe()); - } - - [Fact] - public void Decorate_OpenGeneric_LeavesUnrelatedClosedTypesAlone() { - var services = new ServiceCollection(); - services.AddSingleton(); - services.AddSingleton, GenericThing>(); - - DecoratorHelper.Decorate(services, typeof(IGeneric<>), (_, inner) => inner); - - Assert.IsType(Resolve(services)); - } - - [Fact] - public void Decorate_KeyedRegistration_IsWrappedAndKeepsItsKey() { + public void DecorateOfT_WrapsEachClosedConstructionIndependently() { var services = new ServiceCollection(); - services.AddKeyedSingleton("the-key"); + services.AddSingleton, StringRepo>(); + services.AddSingleton(typeof(IRepo), typeof(Repo)); - DecoratorHelper.Decorate(services, typeof(IThing), (_, inner) => new Wrapper((IThing)inner)); + DecoratorHelper.Decorate>(services, typeof(RepoWrapper), (_, inner) => new RepoWrapper(inner)); + DecoratorHelper.Decorate>(services, typeof(RepoWrapper), (_, inner) => new RepoWrapper(inner)); var provider = services.BuildServiceProvider(); - var resolved = provider.GetRequiredKeyedService("the-key"); - Assert.Equal("wrapped(thing)", resolved.Describe()); - } - - // --- the type-based overload generated code calls --- - - [Fact] - public void DecorateByType_WrapsAndResolvesTheDecoratorsDependencies() { - var services = new ServiceCollection(); - services.AddSingleton(); - services.AddSingleton(); - - DecoratorHelper.Decorate(services, typeof(IThing), typeof(DependentWrapper)); - - var resolved = Assert.IsType(Resolve(services)); - Assert.NotNull(resolved.Dependency); - } - - [Fact] - public void DecorateByType_PassesTheWrappedInstance() { - var services = new ServiceCollection(); - services.AddSingleton(); - - DecoratorHelper.Decorate(services, typeof(IThing), typeof(Wrapper)); - - Assert.Equal("wrapped(thing)", Resolve(services).Describe()); - } - - [Fact] - public void DecorateByType_OpenGeneric_ClosesTheDecoratorPerRegistration() { - var services = new ServiceCollection(); - services.AddSingleton, GenericThing>(); - services.AddSingleton, GenericThing>(); - - DecoratorHelper.Decorate(services, typeof(IGeneric<>), typeof(GenericWrapper<>)); - - var provider = services.BuildServiceProvider(); - - Assert.Equal("wrapped(generic)", provider.GetRequiredService>().Describe()); - Assert.Equal("wrapped(generic)", provider.GetRequiredService>().Describe()); + Assert.Equal("wrapped(repo)", provider.GetRequiredService>().Describe()); + Assert.Equal("wrapped(repo)", provider.GetRequiredService>().Describe()); } /// - /// Stacking open generic decorators means the wrapped instance is itself a decorator, so the - /// type arguments have to be discovered from whichever of them implements the service. + /// The inner stays owned by the container here too. /// [Fact] - public void DecorateByType_OpenGeneric_Stacks() { + public void DecorateOfT_LeavesTheInnerImplementationOwnedByTheContainer() { var services = new ServiceCollection(); - services.AddSingleton, GenericThing>(); + services.AddScoped(); - DecoratorHelper.Decorate(services, typeof(IGeneric<>), typeof(GenericWrapper<>)); - DecoratorHelper.Decorate(services, typeof(IGeneric<>), typeof(GenericWrapper<>)); + DecoratorHelper.Decorate(services, typeof(DisposableWrapper), (_, inner) => new DisposableWrapper(inner)); var provider = services.BuildServiceProvider(); - Assert.Equal("wrapped(wrapped(generic))", provider.GetRequiredService>().Describe()); - } - - [Fact] - public void DecorateByType_StacksInApplicationOrder() { - var services = new ServiceCollection(); - services.AddSingleton(); - - DecoratorHelper.Decorate(services, typeof(IThing), typeof(Wrapper)); - DecoratorHelper.Decorate(services, typeof(IThing), typeof(SecondWrapper)); - - Assert.Equal("second(wrapped(thing))", Resolve(services).Describe()); - } - - /// - /// Decoration replaces a registration with a factory, and the container will not take a factory - /// for an open generic service type. Left alone it throws from BuildServiceProvider naming only - /// the service, so it is refused here instead, where the decorator is still known. - /// - [Fact] - public void Decorate_RefusesAnOpenGenericRegistration() { - var services = new ServiceCollection(); - services.AddSingleton(typeof(IRepo<>), typeof(Repo<>)); - - var exception = Assert.Throws( - () => DecoratorHelper.Decorate(services, typeof(IRepo<>), typeof(RepoWrapper<>))); - - Assert.Contains("open generic", exception.Message); - Assert.Contains("RepoWrapper", exception.Message); - Assert.Contains("closed constructions", exception.Message); - } - - /// - /// The way through: a closed construction is decorated like any other registration. - /// - [Fact] - public void Decorate_WrapsAClosedConstructionOfAGenericService() { - var services = new ServiceCollection(); - services.AddSingleton(typeof(IRepo), typeof(StringRepo)); + DisposableWrapper wrapper; - DecoratorHelper.Decorate(services, typeof(IRepo<>), typeof(RepoWrapper<>)); + using (var scope = provider.CreateScope()) { + wrapper = (DisposableWrapper)scope.ServiceProvider.GetRequiredService(); + } - Assert.Equal( - "wrapped(repo)", - services.BuildServiceProvider().GetRequiredService>().Describe()); + Assert.Equal(1, ((DisposableThing)wrapper.Inner).Disposals); } } diff --git a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.NUnitApi.verified.txt b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.NUnitApi.verified.txt new file mode 100644 index 0000000..ba56937 --- /dev/null +++ b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.NUnitApi.verified.txt @@ -0,0 +1,48 @@ +namespace DependencyModules.NUnit.Attributes +{ + public interface IModuleTestDataAttribute + { + System.Collections.Generic.IEnumerable GetRows(System.Reflection.MethodInfo method); + } + [System.AttributeUsage(System.AttributeTargets.Method)] + public class ModuleTestAttribute : System.Attribute, DependencyModules.Testing.Attributes.Interfaces.IModuleTestAttribute, NUnit.Framework.Interfaces.ICommandWrapper, NUnit.Framework.Interfaces.IImplyFixture, NUnit.Framework.Interfaces.ITestBuilder, NUnit.Framework.Interfaces.IWrapSetUpTearDown + { + public ModuleTestAttribute(params System.Type[] modules) { } + public System.Type[] ModuleTypes { get; } + public System.Collections.Generic.IEnumerable BuildFrom(NUnit.Framework.Interfaces.IMethodInfo method, NUnit.Framework.Internal.Test? suite) { } + public NUnit.Framework.Internal.Commands.TestCommand Wrap(NUnit.Framework.Internal.Commands.TestCommand command) { } + } + [System.AttributeUsage(System.AttributeTargets.Method, AllowMultiple=true)] + public class ModuleTestCaseAttribute : System.Attribute, DependencyModules.NUnit.Attributes.IModuleTestDataAttribute + { + public ModuleTestCaseAttribute(params object?[] arguments) { } + public object?[] Arguments { get; } + public string? TestName { get; set; } + public System.Collections.Generic.IEnumerable GetRows(System.Reflection.MethodInfo method) { } + } +} +namespace DependencyModules.NUnit.Impl +{ + public interface INUnitTestMethodContext : DependencyModules.Testing.Attributes.Interfaces.ITestMethodContext + { + NUnit.Framework.Internal.TestMethod NUnitTestMethod { get; } + } + public interface ITestCaseInfo + { + NUnit.Framework.Internal.TestMethod TestMethod { get; } + System.Collections.Generic.IReadOnlyList TestMethodArguments { get; set; } + System.Collections.Generic.IReadOnlyList TestMethodAttributes { get; } + } + public class ModuleTestCommand : NUnit.Framework.Internal.Commands.DelegatingTestCommand + { + public ModuleTestCommand(NUnit.Framework.Internal.Commands.TestCommand innerCommand) { } + public override NUnit.Framework.Internal.TestResult Execute(NUnit.Framework.Internal.TestExecutionContext context) { } + } + public class TestCaseInfo : DependencyModules.NUnit.Impl.ITestCaseInfo + { + public TestCaseInfo(NUnit.Framework.Internal.TestMethod testMethod, System.Collections.Generic.IReadOnlyList testMethodArguments, System.Collections.Generic.IReadOnlyList testMethodAttributes) { } + public NUnit.Framework.Internal.TestMethod TestMethod { get; } + public System.Collections.Generic.IReadOnlyList TestMethodArguments { get; set; } + public System.Collections.Generic.IReadOnlyList TestMethodAttributes { get; } + } +} diff --git a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.RuntimeApi.verified.txt b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.RuntimeApi.verified.txt index 7661d3c..6a069d6 100644 --- a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.RuntimeApi.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.RuntimeApi.verified.txt @@ -114,6 +114,51 @@ namespace DependencyModules.Runtime.Attributes protected override Microsoft.Extensions.DependencyInjection.ServiceLifetime Lifetime { get; } } } +namespace DependencyModules.Runtime.Conventions +{ + public interface IConventionDefinitions + { + DependencyModules.Runtime.Conventions.IConventionRegistration RegisterAll(); + DependencyModules.Runtime.Conventions.IConventionRegistration RegisterAll(System.Type serviceType); + DependencyModules.Runtime.Conventions.IConventionRegistration RegisterAll(); + } + public interface IConventionModule + { + void Conventions(DependencyModules.Runtime.Conventions.IConventionDefinitions conventions); + } + public interface IConventionRegistration + { + DependencyModules.Runtime.Conventions.IConventionRegistration AlsoAsSelf(); + DependencyModules.Runtime.Conventions.IConventionRegistration As(); + DependencyModules.Runtime.Conventions.IConventionRegistration AsMatchingInterface(); + DependencyModules.Runtime.Conventions.IConventionRegistration AsScoped(); + DependencyModules.Runtime.Conventions.IConventionRegistration AsSelf(); + DependencyModules.Runtime.Conventions.IConventionRegistration AsSelfWithInterfaces(); + DependencyModules.Runtime.Conventions.IConventionRegistration AsSingleton(); + DependencyModules.Runtime.Conventions.IConventionRegistration AsTransient(); + DependencyModules.Runtime.Conventions.IConventionRegistration IfEnvironment(params string[] environmentNames); + DependencyModules.Runtime.Conventions.IConventionRegistration IfEnvironmentValue(string key); + DependencyModules.Runtime.Conventions.IConventionRegistration IfEnvironmentValue(string key, string value); + DependencyModules.Runtime.Conventions.IConventionRegistration IfNotEnvironment(params string[] environmentNames); + DependencyModules.Runtime.Conventions.IConventionRegistration IfNotEnvironmentValue(string key); + DependencyModules.Runtime.Conventions.IConventionRegistration IfNotEnvironmentValue(string key, string value); + DependencyModules.Runtime.Conventions.IConventionRegistration InAssemblyOf(); + DependencyModules.Runtime.Conventions.IConventionRegistration InExactNamespaces(params string[] namespaces); + DependencyModules.Runtime.Conventions.IConventionRegistration InNamespaceOf(); + DependencyModules.Runtime.Conventions.IConventionRegistration InNamespaces(params string[] namespaces); + DependencyModules.Runtime.Conventions.IConventionRegistration IncludeBaseClasses(); + DependencyModules.Runtime.Conventions.IConventionRegistration NotInNamespaceOf(); + DependencyModules.Runtime.Conventions.IConventionRegistration NotInNamespaces(params string[] namespaces); + DependencyModules.Runtime.Conventions.IConventionRegistration Using(DependencyModules.Runtime.Attributes.RegistrationType registrationType); + DependencyModules.Runtime.Conventions.IConventionRegistration WithAttribute() + where TAttribute : System.Attribute; + DependencyModules.Runtime.Conventions.IConventionRegistration WithKey(object key); + DependencyModules.Runtime.Conventions.IConventionRegistration WithName(params string[] patterns); + DependencyModules.Runtime.Conventions.IConventionRegistration WithoutAttribute() + where TAttribute : System.Attribute; + DependencyModules.Runtime.Conventions.IConventionRegistration WithoutName(params string[] patterns); + } +} namespace DependencyModules.Runtime.Features { public class FeatureApplicator : DependencyModules.Runtime.Features.IFeatureApplicator @@ -142,7 +187,8 @@ namespace DependencyModules.Runtime.Helpers public static class DecoratorHelper { public static void Decorate(Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Type serviceType, System.Func decoratorFactory) { } - public static void Decorate(Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Type serviceType, System.Type decoratorType) { } + public static void Decorate(Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Type decoratorIdentity, System.Func decoratorFactory) + where TService : class { } } public readonly struct DecoratorRegistration { @@ -158,7 +204,7 @@ namespace DependencyModules.Runtime.Helpers public static int Add(DependencyModules.Runtime.Helpers.RegistryFunc registryFunc) { } public static int Add(System.Func provider, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = 2) where TInstance : class { } - public static int Add(System.Type implementationType, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = 2, object? serviceKey = null) + public static int Add([System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] System.Type implementationType, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = 2, object? serviceKey = null) where TInstance : class { } public static int AddDecorator(DependencyModules.Runtime.Helpers.EnvironmentRegistryFunc registryFunc, int order = 0) { } public static int AddDecorator(DependencyModules.Runtime.Helpers.RegistryFunc registryFunc, int order = 0) { } diff --git a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.SourceGeneratorApi.verified.txt b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.SourceGeneratorApi.verified.txt index 90f94ca..afcf16d 100644 --- a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.SourceGeneratorApi.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.SourceGeneratorApi.verified.txt @@ -770,58 +770,216 @@ namespace CSharpAuthor protected override void WriteComponentOutput(CSharpAuthor.IOutputContext outputContext) { } } } -namespace DependencyModules.SourceGenerator +namespace DependencyModules.Conventions { - public class DecoratorSourceGenerator : DependencyModules.SourceGenerator.Impl.BaseAttributeSourceGenerator + public static class ConventionContractSource { - public DecoratorSourceGenerator() { } - protected override DependencyModules.SourceGenerator.Impl.Models.DecoratorModel IgnoredModel { get; } - protected override string LoggerName { get; } - protected override System.Collections.Generic.IEnumerable AttributeTypes() { } - protected override DependencyModules.SourceGenerator.Impl.Models.DecoratorModel GenerateAttributeModel(Microsoft.CodeAnalysis.GeneratorAttributeSyntaxContext context, System.Threading.CancellationToken cancellationToken) { } - protected override void GenerateSourceOutput(Microsoft.CodeAnalysis.SourceProductionContext context, [System.Runtime.CompilerServices.TupleElementNames(new string[] { - "Left", - "Right", - "Left", - "Right"})] System.ValueTuple>, System.Collections.Immutable.ImmutableArray> inputData, DependencyModules.SourceGenerator.Impl.Utilities.FileLogger logger) { } - protected override System.Collections.Generic.IEqualityComparer GetComparer() { } + public const string ConventionMethod = "Conventions"; + public const string ConventionModule = "IConventionModule"; + public const string Namespace = "DependencyModules.Runtime.Conventions"; } - public class InterceptorSourceGenerator : DependencyModules.SourceGenerator.Impl.BaseAttributeSourceGenerator + public class ConventionGenerator : DependencyModules.SourceGenerator.Impl.IDependencyModuleSourceGenerator { - public InterceptorSourceGenerator() { } - protected override DependencyModules.SourceGenerator.Impl.Models.InterceptorModel IgnoredModel { get; } - protected override string LoggerName { get; } - protected override System.Collections.Generic.IEnumerable AttributeTypes() { } - protected override DependencyModules.SourceGenerator.Impl.Models.InterceptorModel GenerateAttributeModel(Microsoft.CodeAnalysis.GeneratorAttributeSyntaxContext context, System.Threading.CancellationToken cancellationToken) { } - protected override void GenerateSourceOutput(Microsoft.CodeAnalysis.SourceProductionContext context, [System.Runtime.CompilerServices.TupleElementNames(new string[] { - "Left", - "Right", + public ConventionGenerator() { } + public void SetupGenerator(Microsoft.CodeAnalysis.IncrementalGeneratorInitializationContext context, [System.Runtime.CompilerServices.TupleElementNames(new string[] { "Left", - "Right"})] System.ValueTuple>, System.Collections.Immutable.ImmutableArray> inputData, DependencyModules.SourceGenerator.Impl.Utilities.FileLogger logger) { } - protected override System.Collections.Generic.IEqualityComparer GetComparer() { } + "Right"})] Microsoft.CodeAnalysis.IncrementalValuesProvider> incrementalValueProvider) { } } - public class ServiceSourceGenerator : DependencyModules.SourceGenerator.Impl.BaseAttributeSourceGenerator + public class ModuleDecorators : System.IEquatable { - public ServiceSourceGenerator() { } - protected override DependencyModules.SourceGenerator.Impl.Models.ServiceModel IgnoredModel { get; } - protected override System.Collections.Generic.IEnumerable AttributeTypes() { } - protected override DependencyModules.SourceGenerator.Impl.Models.ServiceModel GenerateAttributeModel(Microsoft.CodeAnalysis.GeneratorAttributeSyntaxContext context, System.Threading.CancellationToken cancellationToken) { } - protected override void GenerateSourceOutput(Microsoft.CodeAnalysis.SourceProductionContext context, [System.Runtime.CompilerServices.TupleElementNames(new string[] { - "Left", - "Right", - "Left", - "Right"})] System.ValueTuple>, System.Collections.Immutable.ImmutableArray> inputData, DependencyModules.SourceGenerator.Impl.Utilities.FileLogger logger) { } - protected void GenerateSourceOutput(Microsoft.CodeAnalysis.SourceProductionContext context, DependencyModules.SourceGenerator.Impl.Models.ModuleEntryPointModel entryPointModel, DependencyModules.SourceGenerator.Impl.Models.DependencyModuleConfigurationModel configurationModel, System.Collections.Immutable.ImmutableArray serviceModels, DependencyModules.SourceGenerator.Impl.Utilities.FileLogger logger) { } - protected override System.Collections.Generic.IEqualityComparer GetComparer() { } + public ModuleDecorators(DependencyModules.Conventions.Models.EquatableList Resolved, Microsoft.CodeAnalysis.Compilation Compilation) { } + public Microsoft.CodeAnalysis.Compilation Compilation { get; init; } + public DependencyModules.Conventions.Models.EquatableList Resolved { get; init; } + public virtual bool Equals(DependencyModules.Conventions.ModuleDecorators? other) { } + public override int GetHashCode() { } } - [Microsoft.CodeAnalysis.Generator] - public class SourceGenerator : DependencyModules.SourceGenerator.Impl.BaseSourceGenerator + public class ResolvedModuleDecorator : System.IEquatable { - public SourceGenerator() { } - protected override System.Collections.Generic.IEnumerable AttributeSourceGenerators() { } - protected override void SetupRootGenerator(Microsoft.CodeAnalysis.IncrementalGeneratorInitializationContext context, [System.Runtime.CompilerServices.TupleElementNames(new string[] { - "Left", - "Right"})] Microsoft.CodeAnalysis.IncrementalValueProvider>> valuesProvider) { } + public ResolvedModuleDecorator(CSharpAuthor.ITypeDefinition ModuleType, DependencyModules.SourceGenerator.Impl.Models.DecoratorModel Model, string? Reason) { } + public DependencyModules.SourceGenerator.Impl.Models.DecoratorModel Model { get; init; } + public CSharpAuthor.ITypeDefinition ModuleType { get; init; } + public string? Reason { get; init; } + } +} +namespace DependencyModules.Conventions.Models +{ + public class AttributeFilterModel : System.IEquatable + { + public AttributeFilterModel(string TypeKey, bool Exclude) { } + public bool Exclude { get; init; } + public string TypeKey { get; init; } + } + public class ConventionCandidateModel : System.IEquatable + { + public static readonly DependencyModules.Conventions.Models.ConventionCandidateModel Ignore; + public ConventionCandidateModel(CSharpAuthor.ITypeDefinition ImplementationType, System.Collections.Generic.IReadOnlyList DeclaredInterfaces, System.Collections.Generic.IReadOnlyList BaseClassInterfaces, DependencyModules.SourceGenerator.Impl.Models.ConstructorInfoModel? Constructor, bool HasAccessibleConstructor, DependencyModules.Conventions.Models.LocationModel Location, System.Collections.Generic.IReadOnlyList? Conditions = null, System.Collections.Generic.IReadOnlyList? AttributeTypeKeys = null, string? AssemblyName = null) { } + public string? AssemblyName { get; init; } + public System.Collections.Generic.IReadOnlyList? AttributeTypeKeys { get; init; } + public System.Collections.Generic.IReadOnlyList BaseClassInterfaces { get; init; } + public System.Collections.Generic.IReadOnlyList? Conditions { get; init; } + public DependencyModules.SourceGenerator.Impl.Models.ConstructorInfoModel? Constructor { get; init; } + public System.Collections.Generic.IReadOnlyList DeclaredInterfaces { get; init; } + public bool HasAccessibleConstructor { get; init; } + public CSharpAuthor.ITypeDefinition ImplementationType { get; init; } + public bool IsIgnored { get; } + public DependencyModules.Conventions.Models.LocationModel Location { get; init; } + public virtual bool Equals(DependencyModules.Conventions.Models.ConventionCandidateModel? other) { } + public override int GetHashCode() { } + public System.Collections.Generic.IEnumerable InterfacesInReach(bool includeBaseClasses) { } + } + public class ConventionModel : System.IEquatable + { + public ConventionModel( + CSharpAuthor.ITypeDefinition? ServiceType, + string? DefinitionKey, + bool IsOpenGeneric, + DependencyModules.SourceGenerator.Impl.Models.ServiceLifestyle? Lifestyle, + bool IncludeBaseClasses, + DependencyModules.Conventions.Models.LocationModel Location, + DependencyModules.Conventions.Models.ConventionRegisterAs RegisterAs = 0, + System.Collections.Generic.IReadOnlyList? NamespaceFilters = null, + DependencyModules.SourceGenerator.Impl.Models.RegistrationType? RegistrationType = default, + object? Key = null, + System.Collections.Generic.IReadOnlyList? KeyNamespaces = null, + System.Collections.Generic.IReadOnlyList? AttributeFilters = null, + System.Collections.Generic.IReadOnlyList? NameFilters = null, + CSharpAuthor.ITypeDefinition? ExplicitServiceType = null, + string? AssemblyName = null, + System.Collections.Generic.IReadOnlyList? Conditions = null) { } + public string? AssemblyName { get; init; } + public System.Collections.Generic.IReadOnlyList? AttributeFilters { get; init; } + public System.Collections.Generic.IReadOnlyList? Conditions { get; init; } + public string? DefinitionKey { get; init; } + public string DisplayName { get; } + public CSharpAuthor.ITypeDefinition? ExplicitServiceType { get; init; } + public bool IncludeBaseClasses { get; init; } + public bool IsOpenGeneric { get; init; } + public object? Key { get; init; } + public System.Collections.Generic.IReadOnlyList? KeyNamespaces { get; init; } + public DependencyModules.SourceGenerator.Impl.Models.ServiceLifestyle? Lifestyle { get; init; } + public DependencyModules.Conventions.Models.LocationModel Location { get; init; } + public System.Collections.Generic.IReadOnlyList? NameFilters { get; init; } + public System.Collections.Generic.IReadOnlyList? NamespaceFilters { get; init; } + public DependencyModules.Conventions.Models.ConventionRegisterAs RegisterAs { get; init; } + public DependencyModules.SourceGenerator.Impl.Models.RegistrationType? RegistrationType { get; init; } + public CSharpAuthor.ITypeDefinition? ServiceType { get; init; } + public bool AttributesMatch(System.Collections.Generic.IReadOnlyList? candidateAttributes) { } + public virtual bool Equals(DependencyModules.Conventions.Models.ConventionModel? other) { } + public override int GetHashCode() { } + public bool NamespaceMatches(string? candidateNamespace) { } + } + public class ConventionModuleModel : System.IEquatable + { + public static readonly DependencyModules.Conventions.Models.ConventionModuleModel Ignore; + public ConventionModuleModel(CSharpAuthor.ITypeDefinition ModuleType, System.Collections.Generic.IReadOnlyList Conventions, System.Collections.Generic.IReadOnlyList Unreadable) { } + public System.Collections.Generic.IReadOnlyList Conventions { get; init; } + public bool IsIgnored { get; } + public CSharpAuthor.ITypeDefinition ModuleType { get; init; } + public System.Collections.Generic.IReadOnlyList Unreadable { get; init; } + public virtual bool Equals(DependencyModules.Conventions.Models.ConventionModuleModel? other) { } + public override int GetHashCode() { } + } + public enum ConventionRegisterAs + { + Interfaces = 0, + Self = 1, + SelfAndInterfaces = 2, + AlsoSelf = 3, + Explicit = 4, + MatchingInterface = 5, + } + public static class ConventionTypeKey + { + public static string For(CSharpAuthor.ITypeDefinition type) { } + } + public sealed class EquatableList : System.Collections.Generic.IEnumerable, System.Collections.Generic.IReadOnlyCollection, System.Collections.Generic.IReadOnlyList, System.Collections.IEnumerable + { + public EquatableList(System.Collections.Generic.IReadOnlyList items) { } + public int Count { get; } + public T this[int index] { get; } + public override bool Equals(object? obj) { } + public System.Collections.Generic.IEnumerator GetEnumerator() { } + public override int GetHashCode() { } + } + public class ImplementedInterfaceModel : System.IEquatable + { + public ImplementedInterfaceModel(CSharpAuthor.ITypeDefinition InterfaceType, string DefinitionKey, string? ViaTypeName) { } + public string DefinitionKey { get; init; } + public CSharpAuthor.ITypeDefinition InterfaceType { get; init; } + public string? ViaTypeName { get; init; } + } + public class LocationModel : System.IEquatable + { + public static readonly DependencyModules.Conventions.Models.LocationModel None; + public LocationModel(string FilePath, int SpanStart, int SpanLength, int StartLine, int StartCharacter, int EndLine, int EndCharacter) { } + public int EndCharacter { get; init; } + public int EndLine { get; init; } + public string FilePath { get; init; } + public int SpanLength { get; init; } + public int SpanStart { get; init; } + public int StartCharacter { get; init; } + public int StartLine { get; init; } + public Microsoft.CodeAnalysis.Location ToLocation() { } + public Microsoft.CodeAnalysis.Location ToLocationOrNone() { } + public static DependencyModules.Conventions.Models.LocationModel From(Microsoft.CodeAnalysis.SyntaxNode node) { } + } + public class NameFilterModel : System.IEquatable + { + public NameFilterModel(string Pattern, bool Exclude) { } + public bool Exclude { get; init; } + public bool IsQualified { get; } + public string Pattern { get; init; } + } + public class NamespaceFilterModel : System.IEquatable + { + public NamespaceFilterModel(string Namespace, bool Exact, bool Exclude) { } + public bool Exact { get; init; } + public bool Exclude { get; init; } + public string Namespace { get; init; } + public bool Covers(string? candidateNamespace) { } + } + public class UnreadableStatementModel : System.IEquatable + { + public UnreadableStatementModel(string Text, string Reason, DependencyModules.Conventions.Models.LocationModel Location) { } + public DependencyModules.Conventions.Models.LocationModel Location { get; init; } + public string Reason { get; init; } + public string Text { get; init; } + } +} +namespace DependencyModules.Conventions.Utilities +{ + public static class ConventionCandidateCache + { + public static DependencyModules.Conventions.Models.ConventionCandidateModel GetOrAdd(DependencyModules.SourceGenerator.Impl.Utilities.SyntaxTransformContext context, System.Threading.CancellationToken cancellationToken) { } + } + public static class ConventionCandidateUtility + { + public static DependencyModules.Conventions.Models.ConventionCandidateModel GetCandidateModel(DependencyModules.SourceGenerator.Impl.Utilities.SyntaxTransformContext context, System.Threading.CancellationToken cancellationToken) { } + public static bool IsCandidate(Microsoft.CodeAnalysis.SyntaxNode node, System.Threading.CancellationToken cancellationToken) { } + } + public static class ConventionMatcher + { + public static System.Collections.Generic.IReadOnlyList Match(DependencyModules.SourceGenerator.Impl.Models.ModuleEntryPointModel entryPointModel, DependencyModules.Conventions.Models.ConventionModuleModel conventionModule, System.Collections.Generic.IReadOnlyList candidates, System.Action report, DependencyModules.SourceGenerator.Impl.Utilities.FileLogger logger) { } + } + public static class ConventionModelUtility + { + public static DependencyModules.Conventions.Models.ConventionModuleModel GetConventionModuleModel(DependencyModules.SourceGenerator.Impl.Utilities.SyntaxTransformContext context, System.Threading.CancellationToken cancellationToken) { } + public static bool IsConventionModuleCandidate(Microsoft.CodeAnalysis.SyntaxNode node, System.Threading.CancellationToken cancellationToken) { } + } + public class ConventionRegistrationMatch : System.IEquatable + { + public ConventionRegistrationMatch(DependencyModules.Conventions.Models.ConventionModel Convention, DependencyModules.Conventions.Models.ConventionCandidateModel Candidate, DependencyModules.Conventions.Models.ImplementedInterfaceModel? Interface) { } + public DependencyModules.Conventions.Models.ConventionCandidateModel Candidate { get; init; } + public DependencyModules.Conventions.Models.ConventionModel Convention { get; init; } + public DependencyModules.Conventions.Models.ImplementedInterfaceModel? Interface { get; init; } + } + public static class DeclarationStamp + { + public static long Of(Microsoft.CodeAnalysis.Compilation compilation) { } + } + public static class MetadataCandidateUtility + { + public static System.Collections.Generic.IReadOnlyList Collect(System.Collections.Generic.IReadOnlyList conventionModules, Microsoft.CodeAnalysis.Compilation compilation, System.Threading.CancellationToken cancellationToken) { } } } namespace DependencyModules.SourceGenerator.Impl @@ -886,7 +1044,7 @@ namespace DependencyModules.SourceGenerator.Impl public class DecoratorFileWriter { public DecoratorFileWriter() { } - public string Write(DependencyModules.SourceGenerator.Impl.Models.ModuleEntryPointModel entryPointModel, DependencyModules.SourceGenerator.Impl.Models.DependencyModuleConfigurationModel configurationModel, System.Collections.Generic.IReadOnlyList decorators) { } + public string Write(DependencyModules.SourceGenerator.Impl.Models.ModuleEntryPointModel entryPointModel, DependencyModules.SourceGenerator.Impl.Models.DependencyModuleConfigurationModel configurationModel, System.Collections.Generic.IReadOnlyList decorators, string uniqueId = "") { } } public class DependencyFileWriter { @@ -1074,13 +1232,19 @@ namespace DependencyModules.SourceGenerator.Impl.Models public class DecoratorModel : System.IEquatable { public static readonly DependencyModules.SourceGenerator.Impl.Models.DecoratorModel Ignore; - public DecoratorModel(CSharpAuthor.ITypeDefinition ServiceType, CSharpAuthor.ITypeDefinition DecoratorType, int Order, CSharpAuthor.ITypeDefinition? Realm, System.Collections.Generic.IReadOnlyList? Conditions = null) { } + public DecoratorModel(CSharpAuthor.ITypeDefinition ServiceType, CSharpAuthor.ITypeDefinition DecoratorType, int Order, CSharpAuthor.ITypeDefinition? Realm, System.Collections.Generic.IReadOnlyList? Conditions = null, DependencyModules.SourceGenerator.Impl.Models.ConstructorInfoModel? Constructor = null, int InnerParameterIndex = -1, bool TypeParametersMatchService = true) { } + public bool CanMonomorphise { get; } public System.Collections.Generic.IReadOnlyList? Conditions { get; init; } + public DependencyModules.SourceGenerator.Impl.Models.ConstructorInfoModel? Constructor { get; init; } public CSharpAuthor.ITypeDefinition DecoratorType { get; init; } + public bool HasUnboundServiceType { get; } + public int InnerParameterIndex { get; init; } public bool IsIgnored { get; } + public bool IsOpenGeneric { get; } public int Order { get; init; } public CSharpAuthor.ITypeDefinition? Realm { get; init; } public CSharpAuthor.ITypeDefinition ServiceType { get; init; } + public bool TypeParametersMatchService { get; init; } } public class DecoratorModelComparer : System.Collections.Generic.IEqualityComparer { @@ -1159,9 +1323,10 @@ namespace DependencyModules.SourceGenerator.Impl.Models } public class InterceptedParameterModel : System.IEquatable { - public InterceptedParameterModel(string Name, string Identifier, CSharpAuthor.ITypeDefinition Type, string? DefaultValue) { } + public InterceptedParameterModel(string Name, string Identifier, CSharpAuthor.ITypeDefinition Type, string? DefaultValue, bool IsParams = false) { } public string? DefaultValue { get; init; } public string Identifier { get; init; } + public bool IsParams { get; init; } public string Name { get; init; } public CSharpAuthor.ITypeDefinition Type { get; init; } } @@ -1356,6 +1521,10 @@ namespace DependencyModules.SourceGenerator.Impl.Models } namespace DependencyModules.SourceGenerator.Impl.Utilities { + public static class AttributeModelCollector + { + public static Microsoft.CodeAnalysis.IncrementalValueProvider> Collect(Microsoft.CodeAnalysis.IncrementalGeneratorInitializationContext context, CSharpAuthor.ITypeDefinition[] attributeTypes, System.Func generate, System.Collections.Generic.IEqualityComparer comparer, TModel ignored) { } + } public static class AttributeModelHelper { public static DependencyModules.SourceGenerator.Impl.Models.AttributeModel? GetAttribute(DependencyModules.SourceGenerator.Impl.Utilities.SyntaxTransformContext context, Microsoft.CodeAnalysis.CSharp.Syntax.AttributeSyntax attribute) { } @@ -1385,11 +1554,28 @@ namespace DependencyModules.SourceGenerator.Impl.Utilities protected abstract bool TestForTypes(Microsoft.CodeAnalysis.SyntaxNode node, System.Threading.CancellationToken token); public bool Where(Microsoft.CodeAnalysis.SyntaxNode node, System.Threading.CancellationToken token) { } } + public static class ConstructorArgumentWriter + { + public static object[] Arguments(CSharpAuthor.ParameterDefinition serviceProvider, System.Collections.Generic.IReadOnlyList parameters) { } + public static object[] Arguments(CSharpAuthor.ParameterDefinition serviceProvider, System.Collections.Generic.IReadOnlyList parameters, int suppliedIndex, object? supplied) { } + } + public static class DecoratorConstraintChecker + { + public static bool CanClose(Microsoft.CodeAnalysis.Compilation compilation, CSharpAuthor.ITypeDefinition decoratorType, CSharpAuthor.GenericTypeDefinition closedService) { } + } + public static class DecoratorExpansion + { + public static System.Collections.Generic.IReadOnlyList Expand(System.Collections.Generic.IReadOnlyList decorators, System.Collections.Generic.IReadOnlyList registeredServiceTypes, bool includeNonGeneric = true, System.Func? canClose = null) { } + } public static class DecoratorModelUtility { public static DependencyModules.SourceGenerator.Impl.Models.DecoratorModel? GetDecoratorModel(DependencyModules.SourceGenerator.Impl.Utilities.SyntaxTransformContext context, System.Threading.CancellationToken cancellationToken) { } public static System.Collections.Generic.IEnumerable GetModuleDeclaredDecorators(DependencyModules.SourceGenerator.Impl.Models.ModuleEntryPointModel entryPointModel) { } } + public static class DecoratorTypeUtility + { + public static DependencyModules.SourceGenerator.Impl.Models.DecoratorModel? Close(DependencyModules.SourceGenerator.Impl.Models.DecoratorModel decorator, CSharpAuthor.GenericTypeDefinition closedService) { } + } public class EntryModelUtil { public EntryModelUtil() { } @@ -1426,6 +1612,7 @@ namespace DependencyModules.SourceGenerator.Impl.Utilities public static class ITypeDefinitionExtensions { public static string GetFileNameHint(this CSharpAuthor.ITypeDefinition typeDefinition, string rootNamespace, string uniquePart) { } + public static CSharpAuthor.ITypeDefinition ToUnboundGeneric(this CSharpAuthor.ITypeDefinition type) { } } public static class InterceptedMemberReader { @@ -1435,12 +1622,26 @@ namespace DependencyModules.SourceGenerator.Impl.Utilities { public static DependencyModules.SourceGenerator.Impl.Models.InterceptorModel GetInterceptorModel(DependencyModules.SourceGenerator.Impl.Utilities.SyntaxTransformContext context, System.Threading.CancellationToken cancellationToken) { } } + public static class ModuleDecoratorResolver + { + public static System.Collections.Generic.IReadOnlyList Resolve(DependencyModules.SourceGenerator.Impl.Models.ModuleEntryPointModel entryPointModel, Microsoft.CodeAnalysis.Compilation compilation, System.Threading.CancellationToken cancellationToken) { } + public class Resolution : System.IEquatable + { + public Resolution(DependencyModules.SourceGenerator.Impl.Models.DecoratorModel Model, string? Reason) { } + public DependencyModules.SourceGenerator.Impl.Models.DecoratorModel Model { get; init; } + public string? Reason { get; init; } + } + } public class ServiceModelUtility { public ServiceModelUtility() { } public static DependencyModules.SourceGenerator.Impl.Models.ConstructorInfoModel? GetConstructorInfo(DependencyModules.SourceGenerator.Impl.Utilities.SyntaxTransformContext context, Microsoft.CodeAnalysis.SyntaxNode node, System.Threading.CancellationToken cancellationToken) { } public static DependencyModules.SourceGenerator.Impl.Models.ServiceModel? GetServiceModel(DependencyModules.SourceGenerator.Impl.Utilities.SyntaxTransformContext context, System.Threading.CancellationToken cancellationToken) { } } + public static class SymbolConstructorReader + { + public static DependencyModules.SourceGenerator.Impl.Models.ConstructorInfoModel? Read(Microsoft.CodeAnalysis.INamedTypeSymbol type) { } + } public static class SyntaxNodeExtensions { public static Microsoft.CodeAnalysis.CSharp.Syntax.AttributeSyntax? GetAttribute(this Microsoft.CodeAnalysis.SyntaxNode node, string attributeName, string ns = "") { } @@ -1494,3 +1695,43 @@ namespace DependencyModules.SourceGenerator.Impl.Utilities protected override void WriteComponentOutput(CSharpAuthor.IOutputContext outputContext) { } } } +namespace DependencyModules.SourceGenerator +{ + public class InterceptorSourceGenerator : DependencyModules.SourceGenerator.Impl.BaseAttributeSourceGenerator + { + public InterceptorSourceGenerator() { } + protected override DependencyModules.SourceGenerator.Impl.Models.InterceptorModel IgnoredModel { get; } + protected override string LoggerName { get; } + protected override System.Collections.Generic.IEnumerable AttributeTypes() { } + protected override DependencyModules.SourceGenerator.Impl.Models.InterceptorModel GenerateAttributeModel(Microsoft.CodeAnalysis.GeneratorAttributeSyntaxContext context, System.Threading.CancellationToken cancellationToken) { } + protected override void GenerateSourceOutput(Microsoft.CodeAnalysis.SourceProductionContext context, [System.Runtime.CompilerServices.TupleElementNames(new string[] { + "Left", + "Right", + "Left", + "Right"})] System.ValueTuple>, System.Collections.Immutable.ImmutableArray> inputData, DependencyModules.SourceGenerator.Impl.Utilities.FileLogger logger) { } + protected override System.Collections.Generic.IEqualityComparer GetComparer() { } + } + public class ServiceSourceGenerator : DependencyModules.SourceGenerator.Impl.BaseAttributeSourceGenerator + { + public ServiceSourceGenerator() { } + protected override DependencyModules.SourceGenerator.Impl.Models.ServiceModel IgnoredModel { get; } + protected override System.Collections.Generic.IEnumerable AttributeTypes() { } + protected override DependencyModules.SourceGenerator.Impl.Models.ServiceModel GenerateAttributeModel(Microsoft.CodeAnalysis.GeneratorAttributeSyntaxContext context, System.Threading.CancellationToken cancellationToken) { } + protected override void GenerateSourceOutput(Microsoft.CodeAnalysis.SourceProductionContext context, [System.Runtime.CompilerServices.TupleElementNames(new string[] { + "Left", + "Right", + "Left", + "Right"})] System.ValueTuple>, System.Collections.Immutable.ImmutableArray> inputData, DependencyModules.SourceGenerator.Impl.Utilities.FileLogger logger) { } + protected void GenerateSourceOutput(Microsoft.CodeAnalysis.SourceProductionContext context, DependencyModules.SourceGenerator.Impl.Models.ModuleEntryPointModel entryPointModel, DependencyModules.SourceGenerator.Impl.Models.DependencyModuleConfigurationModel configurationModel, System.Collections.Immutable.ImmutableArray serviceModels, DependencyModules.SourceGenerator.Impl.Utilities.FileLogger logger) { } + protected override System.Collections.Generic.IEqualityComparer GetComparer() { } + } + [Microsoft.CodeAnalysis.Generator] + public class SourceGenerator : DependencyModules.SourceGenerator.Impl.BaseSourceGenerator + { + public SourceGenerator() { } + protected override System.Collections.Generic.IEnumerable AttributeSourceGenerators() { } + protected override void SetupRootGenerator(Microsoft.CodeAnalysis.IncrementalGeneratorInitializationContext context, [System.Runtime.CompilerServices.TupleElementNames(new string[] { + "Left", + "Right"})] Microsoft.CodeAnalysis.IncrementalValueProvider>> valuesProvider) { } + } +} diff --git a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.TestingApi.verified.txt b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.TestingApi.verified.txt index 782f376..9028398 100644 --- a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.TestingApi.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.TestingApi.verified.txt @@ -5,6 +5,22 @@ namespace DependencyModules.Testing.Attributes public InjectValuesAttribute(params object[] value) { } public object[] ProvideValue(System.IServiceProvider serviceProvider, System.Reflection.ParameterInfo parameter) { } } + [System.AttributeUsage(System.AttributeTargets.Parameter, AllowMultiple=true)] + public class MockAttribute : System.Attribute, DependencyModules.Testing.Attributes.Interfaces.ITestParameterValueProvider + { + public MockAttribute() { } + public System.Threading.Tasks.Task GetParameterValueAsync(DependencyModules.Testing.Attributes.Interfaces.ITestMethodContext testMethod, System.IServiceProvider serviceProvider, System.Reflection.ParameterInfo parameter) { } + public void SetupServiceCollection(DependencyModules.Testing.Attributes.Interfaces.ITestMethodContext testMethod, Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection, System.Reflection.ParameterInfo parameter) { } + } + [System.AttributeUsage(System.AttributeTargets.Assembly | System.AttributeTargets.Class | System.AttributeTargets.Method, AllowMultiple=true)] + public class TestExportAttribute : System.Attribute, DependencyModules.Testing.Attributes.Interfaces.ITestServiceSetupAttribute + { + public TestExportAttribute(System.Type service) { } + public System.Type? Implementation { get; set; } + public Microsoft.Extensions.DependencyInjection.ServiceLifetime Lifetime { get; set; } + public System.Type Service { get; } + public void SetupServiceCollection(DependencyModules.Testing.Attributes.Interfaces.ITestMethodContext testMethod, Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection) { } + } } namespace DependencyModules.Testing.Attributes.Interfaces { @@ -16,6 +32,10 @@ namespace DependencyModules.Testing.Attributes.Interfaces { object ProvideMock(System.Type type); } + public interface IModuleTestAttribute + { + System.Type[] ModuleTypes { get; } + } public interface IOrderedAttribute { int Order { get; } @@ -56,4 +76,10 @@ namespace DependencyModules.Testing.Impl public static System.Collections.Generic.IEnumerable GetTestAttributes(this System.Reflection.ParameterInfo parameterInfo) where T : class { } } + public sealed class TestParameterResolver + { + public TestParameterResolver(DependencyModules.Testing.Attributes.Interfaces.ITestMethodContext testMethod) { } + public System.Threading.Tasks.Task ResolveArgumentsAsync(System.IServiceProvider serviceProvider, object?[] data) { } + public void SetupServiceCollection(Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection) { } + } } diff --git a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.XUnitApi.verified.txt b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.XUnitApi.verified.txt index af5211d..dff157e 100644 --- a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.XUnitApi.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.XUnitApi.verified.txt @@ -1,30 +1,14 @@ namespace DependencyModules.xUnit.Attributes { - [System.AttributeUsage(System.AttributeTargets.Parameter, AllowMultiple=true)] - public class MockAttribute : System.Attribute, DependencyModules.Testing.Attributes.Interfaces.ITestParameterValueProvider - { - public MockAttribute() { } - public System.Threading.Tasks.Task GetParameterValueAsync(DependencyModules.Testing.Attributes.Interfaces.ITestMethodContext testMethod, System.IServiceProvider serviceProvider, System.Reflection.ParameterInfo parameter) { } - public void SetupServiceCollection(DependencyModules.Testing.Attributes.Interfaces.ITestMethodContext testMethod, Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection, System.Reflection.ParameterInfo parameter) { } - } [System.AttributeUsage(System.AttributeTargets.Method)] [Xunit.v3.XunitTestCaseDiscoverer(typeof(DependencyModules.xUnit.Impl.ModuleTestDiscoverer))] - public class ModuleTestAttribute : Xunit.FactAttribute + public class ModuleTestAttribute : Xunit.FactAttribute, DependencyModules.Testing.Attributes.Interfaces.IModuleTestAttribute { public ModuleTestAttribute(params System.Type[] modules) { } public ModuleTestAttribute([System.Runtime.CompilerServices.CallerFilePath] string? sourceFilePath = null, [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = -1) { } public ModuleTestAttribute(System.Type module, [System.Runtime.CompilerServices.CallerFilePath] string? sourceFilePath = null, [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = -1) { } public System.Type[] ModuleTypes { get; } } - [System.AttributeUsage(System.AttributeTargets.Assembly | System.AttributeTargets.Class | System.AttributeTargets.Method, AllowMultiple=true)] - public class TestExportAttribute : System.Attribute, DependencyModules.Testing.Attributes.Interfaces.ITestServiceSetupAttribute - { - public TestExportAttribute(System.Type service) { } - public System.Type? Implementation { get; set; } - public Microsoft.Extensions.DependencyInjection.ServiceLifetime Lifetime { get; set; } - public System.Type Service { get; } - public void SetupServiceCollection(DependencyModules.Testing.Attributes.Interfaces.ITestMethodContext testMethod, Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection) { } - } } namespace DependencyModules.xUnit.Impl { diff --git a/tests/DependencyModules.Tests/xUnitTests/TestExportAttributeTests.cs b/tests/DependencyModules.Tests/TestingTests/TestExportAttributeTests.cs similarity index 97% rename from tests/DependencyModules.Tests/xUnitTests/TestExportAttributeTests.cs rename to tests/DependencyModules.Tests/TestingTests/TestExportAttributeTests.cs index 5ed3b8a..8d6a9f3 100644 --- a/tests/DependencyModules.Tests/xUnitTests/TestExportAttributeTests.cs +++ b/tests/DependencyModules.Tests/TestingTests/TestExportAttributeTests.cs @@ -1,9 +1,9 @@ +using DependencyModules.Testing.Attributes; using DependencyModules.Testing.Attributes.Interfaces; -using DependencyModules.xUnit.Attributes; using Microsoft.Extensions.DependencyInjection; using Xunit; -namespace DependencyModules.Tests.xUnitTests; +namespace DependencyModules.Tests.TestingTests; /// /// [TestExport] lets a test override a registration for the duration of that test, so the lifetime diff --git a/tests/DependencyModules.Tests/TestingTests/TestParameterResolverTests.cs b/tests/DependencyModules.Tests/TestingTests/TestParameterResolverTests.cs new file mode 100644 index 0000000..d5b96ab --- /dev/null +++ b/tests/DependencyModules.Tests/TestingTests/TestParameterResolverTests.cs @@ -0,0 +1,223 @@ +using System.Reflection; +using DependencyModules.Testing.Attributes; +using DependencyModules.Testing.Attributes.Interfaces; +using DependencyModules.Testing.Impl; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace DependencyModules.Tests.TestingTests; + +/// +/// Drives TestParameterResolver directly, without a test framework around it. +/// +/// These rules used to live inside ModuleTestCase, where the only way to reach them was to run a +/// [ModuleTest] through xUnit's whole pipeline — so a change in precedence showed up as some +/// unrelated integration test failing, if at all. The resolver is the piece an NUnit integration +/// would share, which makes its behaviour a contract rather than an implementation detail. +/// +public class TestParameterResolverTests { + + private interface IThing; + + private class Thing : IThing; + + private class Other : IThing; + + /// + /// Takes a dependency the container has and a value it cannot possibly know, which is what + /// [InjectValues] is for. + /// + private class NeedsAValue(IThing thing, string text) { + public IThing Thing { get; } = thing; + public string Text { get; } = text; + } + + [Fact] + public async Task ResolvesAServiceFromTheContainer() { + var arguments = await Resolve(nameof(Samples.OneService), services => services.AddSingleton()); + + Assert.IsType(Assert.Single(arguments)); + } + + /// + /// A test asking for the container itself cannot have it resolved from the container. + /// + [Fact] + public async Task ServiceProviderParameterGetsTheContainerItself() { + var (resolver, provider) = Build(nameof(Samples.WantsTheProvider), _ => { }); + + var arguments = await resolver.ResolveArgumentsAsync(provider, []); + + Assert.Same(provider, Assert.Single(arguments)); + } + + /// + /// A data row owns the parameters it covers, so its arguments are passed through untouched even + /// when the container could have supplied that type. + /// + [Fact] + public async Task DataTakesTheLeadingParametersAndTheContainerTakesTheRest() { + var (resolver, provider) = Build( + nameof(Samples.DataThenService), services => services.AddSingleton()); + + var arguments = await resolver.ResolveArgumentsAsync(provider, [42]); + + Assert.Equal(2, arguments.Length); + Assert.Equal(42, arguments[0]); + Assert.IsType(arguments[1]); + } + + /// + /// The registration a parameter attribute makes during setup is what the container resolves + /// afterwards — the property that lets [Mock] replace a service for the whole test rather than + /// only for the parameter holding it. + /// + [Fact] + public async Task ParameterAttributeRegistrationBeatsTheModuleRegistration() { + var arguments = await Resolve( + nameof(Samples.RegisteringAttribute), services => services.AddSingleton()); + + Assert.IsType(Assert.Single(arguments)); + } + + /// + /// A provider that returns null stands aside rather than forcing a null argument, so several + /// attributes can sit on one parameter with the first that answers winning. + /// + [Fact] + public async Task AProviderReturningNullDefersToTheNextOne() { + var arguments = await Resolve( + nameof(Samples.AbstainingThenAnswering), services => services.AddSingleton()); + + Assert.IsType(Assert.Single(arguments)); + } + + [Fact] + public async Task ResolvesKeyedServices() { + var arguments = await Resolve( + nameof(Samples.Keyed), services => { + services.AddSingleton(); + services.AddKeyedSingleton("other"); + }); + + Assert.IsType(Assert.Single(arguments)); + } + + /// + /// An unregistered concrete type is constructed from the container, so a test can name the class + /// under test without registering it. + /// + [Fact] + public async Task ConstructsAnUnregisteredConcreteType() { + var arguments = await Resolve( + nameof(Samples.UnregisteredWithInjectedValue), services => services.AddSingleton()); + + var value = Assert.IsType(Assert.Single(arguments)); + + Assert.IsType(value.Thing); + Assert.Equal("supplied", value.Text); + } + + /// + /// Resolving without the setup phase would skip every parameter attribute silently, so a [Mock] + /// parameter would hand back the real service. It fails loudly instead. + /// + [Fact] + public async Task ResolvingBeforeSetupThrows() { + var resolver = new TestParameterResolver(ContextFor(nameof(Samples.OneService))); + var provider = new ServiceCollection().BuildServiceProvider(); + + var exception = await Assert.ThrowsAsync( + () => resolver.ResolveArgumentsAsync(provider, [])); + + Assert.Contains(nameof(TestParameterResolver.SetupServiceCollection), exception.Message); + } + + [Fact] + public void SetupIsOfferedEveryParameter() { + var services = new ServiceCollection(); + + new TestParameterResolver(ContextFor(nameof(Samples.TwoRegisteringAttributes))) + .SetupServiceCollection(services); + + Assert.Equal(2, services.Count); + } + + // ---- harness ------------------------------------------------------------------------------- + + private static async Task Resolve(string methodName, Action configure) { + var (resolver, provider) = Build(methodName, configure); + + return await resolver.ResolveArgumentsAsync(provider, []); + } + + private static (TestParameterResolver Resolver, IServiceProvider Provider) Build( + string methodName, Action configure) { + var services = new ServiceCollection(); + var resolver = new TestParameterResolver(ContextFor(methodName)); + + // Module registrations land before the parameters get their say, as they do in a real run. + configure(services); + resolver.SetupServiceCollection(services); + + return (resolver, services.BuildServiceProvider()); + } + + private static ITestMethodContext ContextFor(string methodName) => + new StubContext(typeof(Samples).GetMethod(methodName, BindingFlags.Public | BindingFlags.Static)!); + + private class StubContext(MethodInfo method) : ITestMethodContext { + public MethodInfo Method { get; } = method; + public IReadOnlyList Attributes { get; } = []; + } + + /// + /// Signatures only — never invoked. Static so nothing needs constructing; the members are public + /// within this private class so one set of binding flags finds them all. + /// + private static class Samples { + public static void OneService(IThing thing) { } + + public static void WantsTheProvider(IServiceProvider provider) { } + + public static void DataThenService(int number, IThing thing) { } + + public static void RegisteringAttribute([RegistersOther] IThing thing) { } + + public static void AbstainingThenAnswering([Abstains] [RegistersOther] IThing thing) { } + + public static void Keyed([FromKeyedServices("other")] IThing thing) { } + + public static void UnregisteredWithInjectedValue([InjectValues("supplied")] NeedsAValue value) { } + + public static void TwoRegisteringAttributes([RegistersOther] IThing first, [RegistersOther] IThing second) { } + } + + /// + /// Stands in for [Mock]: registers a replacement during setup, then lets ordinary container + /// resolution hand it back. + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = true)] + private class RegistersOtherAttribute : Attribute, ITestParameterValueProvider { + public void SetupServiceCollection( + ITestMethodContext testMethod, IServiceCollection serviceCollection, ParameterInfo parameter) => + serviceCollection.AddSingleton(parameter.ParameterType, new Other()); + + public Task GetParameterValueAsync( + ITestMethodContext testMethod, IServiceProvider serviceProvider, ParameterInfo parameter) => + Task.FromResult(serviceProvider.GetService(parameter.ParameterType)); + } + + /// + /// Registers nothing and answers null, so the next provider on the parameter gets its turn. + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = true)] + private class AbstainsAttribute : Attribute, ITestParameterValueProvider { + public void SetupServiceCollection( + ITestMethodContext testMethod, IServiceCollection serviceCollection, ParameterInfo parameter) { } + + public Task GetParameterValueAsync( + ITestMethodContext testMethod, IServiceProvider serviceProvider, ParameterInfo parameter) => + Task.FromResult(null); + } +} diff --git a/tests/coverlet.runsettings b/tests/coverlet.runsettings index f28c74b..22110b9 100644 --- a/tests/coverlet.runsettings +++ b/tests/coverlet.runsettings @@ -11,15 +11,11 @@ compiled into the generator assembly. It is not this project's code and would otherwise dominate the numbers. - The Impl sources are compiled into both analyzer assemblies. They are - measured under DependencyModules.SourceGenerator, which exercises all of - them; the second copy inside DependencyModules.Conventions is the same code - counted twice, and the convention generator only ever reaches a slice of it - (module discovery and DependencyFileWriter, not the decorator or interceptor - writers). Counting that copy measures nothing and buries the convention - code's own numbers. + The Impl sources used to be compiled into a second analyzer assembly as + well, and that copy was excluded here so the same code was not counted + twice. There is one analyzer now, so there is nothing left to exclude. --> - [*]CSharpAuthor.*,[DependencyModules.Conventions]DependencyModules.SourceGenerator.Impl.* + [*]CSharpAuthor.* GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute,ObsoleteAttribute diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index be37d5e..7417b8a 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -46,11 +46,9 @@ export default defineConfig({ text: 'SourceGenerator', link: 'https://www.nuget.org/packages/DependencyModules.SourceGenerator/', }, - { - text: 'Conventions', - link: 'https://www.nuget.org/packages/DependencyModules.Conventions/', - }, + { text: 'Testing', link: 'https://www.nuget.org/packages/DependencyModules.Testing/' }, { text: 'xUnit', link: 'https://www.nuget.org/packages/DependencyModules.xUnit/' }, + { text: 'NUnit', link: 'https://www.nuget.org/packages/DependencyModules.NUnit/' }, { text: 'NSubstitute', link: 'https://www.nuget.org/packages/DependencyModules.NSubstitute/', @@ -78,7 +76,9 @@ export default defineConfig({ text: 'Testing', items: [ { text: 'Testing modules', link: '/guide/testing' }, - { text: 'Mocks and values', link: '/guide/testing-mocks' }, + { text: 'xUnit', link: '/guide/testing-xunit' }, + { text: 'NUnit', link: '/guide/testing-nunit' }, + { text: 'Mocking frameworks', link: '/guide/testing-mocking' }, { text: 'Testing registrations', link: '/guide/testing-registrations' }, ], }, diff --git a/website/guide/conventions.md b/website/guide/conventions.md index c0da298..89f6556 100644 --- a/website/guide/conventions.md +++ b/website/guide/conventions.md @@ -21,6 +21,8 @@ the hand-maintained list, just spread across forty files instead of gathered in State the rule once, and let the generator find the types that fit **while it builds**: ```csharp +using DependencyModules.Runtime.Conventions; + [DependencyModule] public partial class DataModule : IConventionModule { void IConventionModule.Conventions(IConventionDefinitions conventions) { @@ -31,16 +33,13 @@ public partial class DataModule : IConventionModule { Forty registrations, one declaration, and the forty-first handler registers itself by existing. -```shell -dotnet add package DependencyModules.Conventions -``` - -Conventions ship in their own analyzer package, so a project that does not use them never loads the -class-scanning providers. +Nothing extra to install: the contracts are part of `DependencyModules.Runtime` and the generator +that reads them is part of `DependencyModules.SourceGenerator`, both of which you already have. -::: warning Implement the interface explicitly -`void IConventionModule.Conventions(…)`, as above. An implicit `public void Conventions(…)` does not -compile. +::: tip Explicit or implicit, either compiles +`void IConventionModule.Conventions(…)` as above, or an ordinary +`public void Conventions(IConventionDefinitions conventions)` — both are matched. The explicit form +is used when a type somehow carries both, since that is the one satisfying the interface. ::: ## The body never runs diff --git a/website/guide/extending.md b/website/guide/extending.md index 2badb6c..9c96dca 100644 --- a/website/guide/extending.md +++ b/website/guide/extending.md @@ -16,11 +16,13 @@ All of it lives in a shared assembly you can compile into your own analyzer. You the same `ServiceModel`s the attribute path produces, so emission needs no special case and your registrations compose with `[SingletonService]` and conventions as if they had always been there. -`DependencyModules.Conventions` is exactly this — a separate analyzer package plugged into the same -pipeline — and it is the worked example throughout this page. +The [convention](/guide/conventions) generator is exactly this — a registration mechanism of its own, +plugged into the same pipeline — and it is the worked example throughout this page. It ships inside +`DependencyModules.SourceGenerator` rather than beside it, but nothing about how it plugs in depends +on that; yours can live in its own analyzer package. ::: warning Not a stable public API yet -These are the extension points the conventions package uses, and they are public. They are **not** +These are the extension points the convention generator uses, and they are public. They are **not** versioned as a stable API, so a minor release may move them. If you build on this, pin the generator package version. ::: @@ -50,10 +52,38 @@ public class MySourceGenerator : BaseSourceGenerator { } // SetupRootGenerator is deliberately not overridden. DependencyModules.SourceGenerator owns the - // module partial; emitting it from here too would declare every module twice. + // module partial; emitting it from here too would declare every module twice. The base class + // knows that from the attribute you trigger on, so the default does the right thing here. } ``` +A generator that declares its **own** module attribute is the other shape, and the default flips to +match: nothing else can write those modules, so the base class writes them for you. + +```csharp +[Generator] +public class MyFrameworkGenerator : BaseSourceGenerator { + + protected override ITypeDefinition[] ModuleAttributeTypes() => + [TypeDefinition.Get("My.Framework", "MyModuleAttribute")]; + + protected override IEnumerable AttributeSourceGenerators() { + yield return new MyGenerator(); + } +} +``` + +`[MyModule]` on a class now gets everything `[DependencyModule]` does — `AddModule()`, services, +conventions, decorators, interception — with no `[DependencyModule]` in the consuming project. Two +things follow from declaring your own attribute: + +- **Override `SetupRootGenerator` with an empty body** if you want the attribute as a marker only, + and no module written for it. +- **`Program.cs` is not yours.** A file of top level statements carries no attribute to tell the two + generators apart, so the generated `ApplicationModule` belongs to whichever generator reads + `[DependencyModule]`. If your framework ships without this package's generator and you want that + module, override `ShouldAutoApproveCompilationUnit` to `true`. + `IDependencyModuleSourceGenerator` is one method. You receive the initialization context and a provider of every discovered module paired with the configuration in effect: diff --git a/website/guide/getting-started.md b/website/guide/getting-started.md index 9cf485f..fcccc7c 100644 --- a/website/guide/getting-started.md +++ b/website/guide/getting-started.md @@ -46,13 +46,15 @@ dotnet add package DependencyModules.SourceGenerator ``` Requires .NET 8.0 or later, and ships both `net8.0` and `net10.0` assemblies so a project on either -LTS release gets one built against its own framework. Two more packages are optional, and this guide -will tell you when you want them: +LTS release gets one built against its own framework. + +Those two are everything the library itself needs — [conventions](/guide/conventions) included. The +optional packages are for [testing](/guide/testing), and this guide will tell you when you want them: | Package | For | |---|---| -| `DependencyModules.Conventions` | [registering by rule](/guide/conventions) instead of per class | -| `DependencyModules.xUnit` | [building a provider in tests](/guide/testing) from your real modules | +| `DependencyModules.xUnit` / `DependencyModules.NUnit` | [building a provider in tests](/guide/testing) from your real modules | +| `DependencyModules.NSubstitute` / `.Moq` / `.FakeItEasy` | [mocking a service](/guide/testing-mocking) inside such a test | ## Your first module diff --git a/website/guide/testing-mocking.md b/website/guide/testing-mocking.md new file mode 100644 index 0000000..bdcc3b1 --- /dev/null +++ b/website/guide/testing-mocking.md @@ -0,0 +1,249 @@ +# Mocking frameworks + +## The problem + +A provider built from your real modules gives you real services, which is usually the point — and +occasionally the problem. One of the services behind `Weather` is non-deterministic: + +```csharp +[SingletonService] +public class TemperatureProvider : ITemperatureProvider { + public int GetTemperature() => Random.Shared.Next(-20, 55); +} +``` + +You cannot assert on a forecast built out of random numbers. But you do not want to abandon the +container either — `Weather` and `SummaryProvider` should still be the real ones, wired the real way. +You want to replace exactly one leaf of the graph and leave the rest alone. + +## How DependencyModules helps + +Mark the parameter `[Mock]` and that service is **replaced in the container** before anything is +resolved. Everything constructed afterwards gets the substitute: + +```csharp +[ModuleTest] +public void GetStaticForecast( + Weather weather, + [Mock] ITemperatureProvider temperatureProvider, + [Mock] IAiSummaryProvider aiSummaryProvider) { + + temperatureProvider.GetTemperature().Returns(38); + aiSummaryProvider.GetSummary().Returns("Sunny"); + + var forecast = weather.GetWeatherForecast().ToArray(); + + Assert.All(forecast, day => Assert.Equal(38, day.TemperatureC)); + Assert.All(forecast, day => Assert.Equal("Sunny", day.Summary)); +} +``` + +`Weather` is still constructed by the container, and it receives the same substitutes the test is +holding. You wire nothing together yourself. + +Note what stayed real: `SummaryProvider` was not mocked, so the call still travels +`Weather` → `SummaryProvider` → `IAiSummaryProvider`. Only the leaf was swapped. + +`[Mock]` comes from `DependencyModules.Testing`, which your [test framework +integration](/guide/testing#pick-an-integration) already brings in — so it needs a +`using DependencyModules.Testing.Attributes;`. It carries no test framework dependency and no mocking +library dependency of its own. + +## Choosing a library + +`[Mock]` does not depend on a particular mocking library. It defines a seam, and a small package +fills it — so use whichever library you already have: + +| Package | Attribute | Creates | +|---|---|---| +| `DependencyModules.NSubstitute` | `[NSubstituteSupport]` | `Substitute.For(type)` | +| `DependencyModules.Moq` | `[MoqSupport]` | `Mock` | +| `DependencyModules.FakeItEasy` | `[FakeItEasySupport]` | `Sdk.Create.Fake(type)` | + +Install one and apply its attribute. Like the module attributes it works at assembly, class or +method level, and assembly is usually right: + +```shell +dotnet add package DependencyModules.Moq +``` + +```csharp +[assembly: MoqSupport] +``` + +Without one, `[Mock]` throws with a message telling you so, rather than quietly handing back the real +service. + +All three work under both [xUnit](/guide/testing-xunit) and [NUnit](/guide/testing-nunit) — the +mocking package and the test framework package are independent choices. + +::: tip Pick one per project +The support attributes are found by walking method, class then assembly, and the first one found +supplies the test's mocks. Two in scope is not an error, but which one wins depends on where each is +declared, which is not a thing to rely on. +::: + +## The same test in each + +Only the configuration lines differ — `[Mock]`, the injection and the assertions around them are +identical. The example above is NSubstitute; here are all three side by side: + +::: code-group + +```csharp [NSubstitute] +// arrange +temperatureProvider.GetTemperature().Returns(38); + +// assert on the interaction +temperatureProvider.Received().GetTemperature(); +temperatureProvider.Received(1).Record(Arg.Any()); +``` + +```csharp [Moq] +// arrange +Mock.Get(temperatureProvider).Setup(x => x.GetTemperature()).Returns(38); + +// assert on the interaction +Mock.Get(temperatureProvider).Verify(x => x.GetTemperature()); +Mock.Get(temperatureProvider).Verify(x => x.Record(It.IsAny()), Times.Once); +``` + +```csharp [FakeItEasy] +// arrange +A.CallTo(() => temperatureProvider.GetTemperature()).Returns(38); + +// assert on the interaction +A.CallTo(() => temperatureProvider.GetTemperature()).MustHaveHappened(); +A.CallTo(() => temperatureProvider.Record(A._)).MustHaveHappenedOnceExactly(); +``` + +::: + +Mocks are **loose** in all three: an unconfigured member returns `default` rather than throwing. That +is each library's own default, kept rather than overridden. + +## NSubstitute + +The substitute is both what gets injected and what you configure, so a `[Mock]` parameter can be set +up directly: + +```csharp +using DependencyModules.NSubstitute; + +[assembly: NSubstituteSupport] +``` + +```csharp +[ModuleTest] +public void SendsTheMail(IEmailSender sender, [Mock] IAuditLog log) { + sender.Send("someone@example.com"); + + log.Received().Write(Arg.Any()); +} +``` + +Nothing else to know — the parameter is the substitute. + +## FakeItEasy + +Same shape. The fake is what gets injected and what you configure, through `A.CallTo`: + +```csharp +using DependencyModules.FakeItEasy; + +[assembly: FakeItEasySupport] +``` + +```csharp +[ModuleTest] +public void SendsTheMail(IEmailSender sender, [Mock] IAuditLog log) { + sender.Send("someone@example.com"); + + A.CallTo(() => log.Write(A._)).MustHaveHappened(); +} +``` + +Fakes are built through `FakeItEasy.Sdk.Create.Fake(type)` rather than `A.Fake()`, because the +type is not known until the test asks for it. The result is the same object either would produce. + +## Moq + +Moq is the one that needs a paragraph, because it keeps the mock and the object it produces apart. +`[Mock] IFoo` gives you the **object**, so configuring it means going back through `Mock.Get`: + +```csharp +[ModuleTest] +public void SendsTheMail(IEmailSender sender, [Mock] IAuditLog log) { + Mock.Get(log).Verify(x => x.Write(It.IsAny())); +} +``` + +### Ask for the `Mock` instead + +You can skip that by naming the mock in the parameter type. No `[Mock]` needed — the type already +says what it is: + +```csharp +[ModuleTest] +public void GetStaticForecast( + Weather weather, + Mock temperatureProvider, + Mock aiSummaryProvider) { + + temperatureProvider.Setup(x => x.GetTemperature()).Returns(38); + aiSummaryProvider.Setup(x => x.GetSummary()).Returns("Sunny"); + + var forecast = weather.GetWeatherForecast().ToArray(); + + Assert.All(forecast, day => Assert.Equal(38, day.TemperatureC)); +} +``` + +This does the same thing `[Mock]` does — `ITemperatureProvider` is replaced in the container before +anything is resolved, so `Weather` is built against the same mock. Both the `Mock` and its +`Object` are registered, which is what lines the two halves up: you hold the mock, and everything the +container builds gets its object. `mock.Object` reaches the object yourself when you want it. + +### The two spellings agree + +Ask for `[Mock] ITemperatureProvider` and `Mock` on one test and you get **one +mock seen two ways**, not two mocks. Two parameters naming the same `Mock` are likewise one mock. + +`[Mock]` on a `Mock` parameter is allowed and does nothing — the type is already enough. + +::: warning `Mock` without `[MoqSupport]` silently does nothing useful +A `Mock` parameter only means anything when `[MoqSupport]` is in scope. Without it the parameter +still resolves — the container constructs a `Mock` like any other concrete type — but nothing +registers it, so the service under test gets the real implementation and your setups apply to a mock +nobody can see. +::: + +## What wins when two things register the same service + +The order is fixed, so it does not depend on how you declare the attributes: + +1. **`[Mock]` parameters** register their doubles first. +2. **Mock support** registers the `Mock` pairs, settling any disagreement with step 1 — which is + what lets `[Mock] IFoo` and `Mock` resolve to a matched pair rather than two unrelated + mocks. +3. **`[TestExport]`** and other setup attributes run last, so a + [`[TestExport]`](/guide/testing#when-you-want-a-real-object-not-a-mock) naming a real + implementation of the same service overrides both. + +Registrations are last-one-wins, so that ordering is the whole rule. If you want a real object rather +than a mock for one service in one test, `[TestExport]` gets it regardless of what is mocked around +it. + +## When not to mock + +A mock is right when you intend to **assert on the interaction** — what was called, with which +arguments. When you want a working implementation that simply behaves differently, a mock makes you +stub out every member you touch, and +[`[TestExport]`](/guide/testing#when-you-want-a-real-object-not-a-mock) is the better tool. When the +parameter is data rather than a service, +[`[InjectValues]`](/guide/testing#when-the-parameter-is-not-a-service-at-all) is. + +## Next + +- [Testing modules](/guide/testing) — the parts shared by both test frameworks +- [Testing registrations](/guide/testing-registrations) — asserting on what a module registered diff --git a/website/guide/testing-mocks.md b/website/guide/testing-mocks.md deleted file mode 100644 index 2aa4a00..0000000 --- a/website/guide/testing-mocks.md +++ /dev/null @@ -1,204 +0,0 @@ -# Mocks and values - -## The problem - -A provider built from your real modules gives you real services, which is usually the point — and -occasionally the problem. Two of the services behind `Weather` are non-deterministic: - -```csharp -[SingletonService] -public class TemperatureProvider : ITemperatureProvider { - public int GetTemperature() => Random.Shared.Next(-20, 55); -} -``` - -You cannot assert on a forecast built out of random numbers. But you do not want to abandon the -container either — `Weather` and `SummaryProvider` should still be the real ones, wired the real way. -You want to replace exactly two leaves of the graph and leave the rest alone. - -## How DependencyModules helps - -Mark the parameter `[Mock]` and that service is **replaced in the container** before anything is -resolved. Everything constructed afterwards gets the substitute: - -```csharp -[ModuleTest] -public void GetStaticForecast( - Weather weather, - [Mock] ITemperatureProvider temperatureProvider, - [Mock] IAiSummaryProvider aiSummaryProvider) { - - temperatureProvider.GetTemperature().Returns(38); - aiSummaryProvider.GetSummary().Returns("Sunny"); - - var forecast = weather.GetWeatherForecast().ToArray(); - - Assert.All(forecast, day => Assert.Equal(38, day.TemperatureC)); - Assert.All(forecast, day => Assert.Equal("Sunny", day.Summary)); -} -``` - -`Weather` is still constructed by the container, and it receives the same substitutes the test is -holding. You wire nothing together yourself. - -Note what stayed real: `SummaryProvider` was not mocked, so the call still travels -`Weather` → `SummaryProvider` → `IAiSummaryProvider`. Only the leaf was swapped. - -## Choosing a mocking library - -`[Mock]` does not depend on a particular mocking library. It defines a seam, and a small package -fills it — so use whichever library you already have: - -| Package | Attribute | -|---|---| -| `DependencyModules.NSubstitute` | `[NSubstituteSupport]` | -| `DependencyModules.Moq` | `[MoqSupport]` | -| `DependencyModules.FakeItEasy` | `[FakeItEasySupport]` | - -Install one and apply its attribute. Like the module attributes it works at assembly, class or -method level, and assembly is usually right: - -```shell -dotnet add package DependencyModules.Moq -``` - -```csharp -[assembly: MoqSupport] -``` - -Without one, `[Mock]` fails with a message telling you so. - -### The same test in each - -Only the configuration lines differ — `[Mock]`, the injection and the assertions are identical. The -example above is NSubstitute; here are the other two: - -::: code-group - -```csharp [NSubstitute] -temperatureProvider.GetTemperature().Returns(38); -aiSummaryProvider.GetSummary().Returns("Sunny"); -``` - -```csharp [Moq] -Mock.Get(temperatureProvider).Setup(x => x.GetTemperature()).Returns(38); -Mock.Get(aiSummaryProvider).Setup(x => x.GetSummary()).Returns("Sunny"); -``` - -```csharp [FakeItEasy] -A.CallTo(() => temperatureProvider.GetTemperature()).Returns(38); -A.CallTo(() => aiSummaryProvider.GetSummary()).Returns("Sunny"); -``` - -::: - -Unconfigured members return `default` rather than throwing, in all three. - -### Moq: ask for the `Mock` instead - -NSubstitute and FakeItEasy hand you an object that *is* the mock, so the parameter the container -injected is the thing you configure. Moq keeps the two apart, which is why the version above needs -`Mock.Get`. - -You can skip that by naming the mock in the parameter type. No `[Mock]` — the type already says what -it is: - -```csharp -[ModuleTest] -public void GetStaticForecast( - Weather weather, - Mock temperatureProvider, - Mock aiSummaryProvider) { - - temperatureProvider.Setup(x => x.GetTemperature()).Returns(38); - aiSummaryProvider.Setup(x => x.GetSummary()).Returns("Sunny"); - - var forecast = weather.GetWeatherForecast().ToArray(); - - Assert.All(forecast, day => Assert.Equal(38, day.TemperatureC)); -} -``` - -This does the same thing `[Mock]` does — `ITemperatureProvider` is replaced in the container before -anything is resolved, so `Weather` is built against the same mock. You just hold the `Mock` rather -than the object, and `mock.Object` gets you the object when you want it. - -Both spellings can appear on one test, and they agree: ask for `[Mock] ITemperatureProvider` and -`Mock` together and you get one mock seen two ways, not two mocks. Two -parameters naming the same `Mock` are likewise one mock. - -`[Mock]` on a `Mock` parameter is allowed and does nothing — the type is already enough. - -::: warning -A `Mock` parameter only means anything when `[MoqSupport]` is in scope. Without it the parameter -still resolves — the container constructs a `Mock` like any other concrete type — but nothing -registers it, so the service under test gets the real implementation and your setups apply to a mock -nobody can see. -::: - -## When you want a real object, not a mock - -A mock is right when you intend to **assert on the interaction** — what was called, with which -arguments. When you instead want a working implementation that simply behaves differently, a mock -makes you stub out every member you touch. - -`[TestExport]` registers a real type into the test's container without touching the module: - -```csharp -public class FixedClock : IClock { - public DateTime UtcNow => new(2026, 1, 1); -} - -[ModuleTest] -[TestExport(typeof(IClock), Implementation = typeof(FixedClock), Lifetime = ServiceLifetime.Singleton)] -public void OrdersAreStampedWithTheCurrentTime(IOrderService service) { } -``` - -`FixedClock` is constructed by the container, so it can have dependencies of its own. - -| Property | | -|---|---| -| *(constructor)* | the service type | -| `Implementation` | defaults to the service type when omitted | -| `Lifetime` | defaults to `Transient` | - -It also applies at assembly, class or method level, so a stub every test needs can sit in your -bootstrap file once. - -## 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: - -```csharp -public record InjectModel(IDependencyOne DependencyOne, string StringValue); - -[ModuleTest] -public void InjectTestValue([InjectValues("Hello World!")] InjectModel model) { - Assert.NotNull(model.DependencyOne); // resolved from the container - Assert.Equal("Hello World!", model.StringValue); // supplied by the attribute -} -``` - -The values are matched against the constructor parameters the container **cannot** supply, so you -list only what it could not work out for itself. - -## Choosing between the three - -| | Reach for it when | -|---|---| -| `[Mock]` | 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 | - -## A trap worth knowing about - -An [intercepted](/guide/interception) service resolves as a **generated wrapper**, not as your class. -So this fails, confusingly: - -```csharp -Assert.IsType(provider.GetRequiredService()); // it is Orders_Intercepted -``` - -Assert on the interface, or on behaviour. The same applies to a [decorated](/guide/decorators) -service, where what resolves is the outermost decorator. diff --git a/website/guide/testing-nunit.md b/website/guide/testing-nunit.md new file mode 100644 index 0000000..ad1f761 --- /dev/null +++ b/website/guide/testing-nunit.md @@ -0,0 +1,123 @@ +# NUnit + +`DependencyModules.NUnit` is the NUnit integration. Read [Testing modules](/guide/testing) first — +this page covers only what is specific to NUnit. + +```shell +dotnet add package DependencyModules.NUnit +``` + +```csharp +using DependencyModules.NUnit.Attributes; + +public class WeatherTests { + [ModuleTest] + [ApplicationModule] + public void GetForecast(Weather weather) { + var forecast = weather.GetWeatherForecast().ToArray(); + + Assert.That(forecast, Has.Length.EqualTo(5)); + } +} +``` + +`[ModuleTest]` replaces `[Test]`. `[TestFixture]` on the class is optional — a module test implies a +fixture the same way `[Test]` does. + +Everything shared applies unchanged: assembly-level module attributes, `[Mock]`, `[InjectValues]`, +`[TestExport]`, keyed services, and all three [mocking packages](/guide/testing-mocking). Those live +in `DependencyModules.Testing` and name no test framework, so they are the same types either +integration hands you — not copies. + +## A container per iteration + +Every iteration of a test gets its own container, torn down when that iteration ends. That includes +each `[Repeat]` pass and each `[Retry]` attempt, not just each test case: + +```csharp +[ModuleTest] +[ApplicationModule] +[Repeat(3)] +public void EachPassStartsClean(ICallCounter counter) { + counter.Record(); + + Assert.That(counter.Count, Is.EqualTo(1)); // never 2, never 3 +} +``` + +The container's lifetime brackets the whole iteration, so `[SetUp]` and `[TearDown]` both run while +it is alive: + +``` +container built → [SetUp] → test method → [TearDown] → container disposed +``` + +That ordering is worth knowing if a `[SetUp]` method needs a service. It cannot take one as a +parameter — NUnit calls it, not this package — but it can read one from `ITestCaseInfo`, or the test +method can do the work instead. + +## Data-driven tests + +Use `[ModuleTestCase]` rather than NUnit's `[TestCase]`. Row arguments come first, injected ones +after: + +```csharp +[ModuleTest] +[ApplicationModule] +[ModuleTestCase("one")] +[ModuleTestCase("two")] +public void MultipleRows(string value, ITemperatureProvider provider) { + Assert.That(value, Is.Not.Null); // from [ModuleTestCase] + Assert.That(provider, Is.Not.Null); // from the container +} +``` + +A row may supply fewer arguments than the method takes — that is the point of it — but not more. + +::: warning `[TestCase]` will not work here +NUnit's own `[TestCase]` requires a row to supply an argument for *every* parameter, and enforces +that when the test case is built, before this package sees it. A method whose trailing parameters +come from the container fails that check with +`Method requires 2 arguments but TestCaseAttribute only supplied 1`. `[TestCase]` also builds its own +test cases, so combining the two produces one case per row *plus* one more. + +`[ModuleTestCase]` is the same idea without the all-or-nothing rule. +::: + +Each row is a separate test case, so each gets its own container. + +To supply rows from somewhere other than an attribute literal, implement `IModuleTestDataAttribute`: + +```csharp +[AttributeUsage(AttributeTargets.Method)] +public class CsvRowsAttribute(string path) : Attribute, IModuleTestDataAttribute { + public IEnumerable GetRows(MethodInfo method) => + File.ReadLines(path).Select(line => line.Split(',').Cast().ToArray()); +} +``` + +## Differences from the xUnit integration + +| | [xUnit](/guide/testing-xunit) | NUnit | +|---|---|---| +| Replaces | `[Fact]` and `[Theory]` | `[Test]` | +| Data rows | `[InlineData]`, `[MemberData]`, any `IDataAttribute` | `[ModuleTestCase]` | +| Class attribute | none needed | `[TestFixture]` optional | +| Fixture instance | one per test | one per fixture, per NUnit's own model | +| Test case metadata | `ITestCaseInfo` exposing `IXunitTestMethod` | `ITestCaseInfo` exposing `TestMethod` | + +The fixture row is NUnit's behaviour, not this package's: NUnit constructs the fixture once and +reuses it, so fixture *fields* are shared across tests even though containers are not. Keep per-test +state in the test method, or in a service resolved from the container. + +## Skipping, timeouts and categories + +NUnit's own attributes work as they always do — `[Ignore]`, `[Explicit]`, `[Category]`, +`[Timeout]`, `[Order]`, `[Parallelizable]`. `[ModuleTest]` only supplies arguments and the container; +it does not replace the rest of NUnit. + +## Next + +- [Mocking frameworks](/guide/testing-mocking) — `[Mock]` and the three libraries behind it +- [xUnit](/guide/testing-xunit) — the same integration for xUnit +- [Testing registrations](/guide/testing-registrations) — asserting on what a module registered diff --git a/website/guide/testing-xunit.md b/website/guide/testing-xunit.md new file mode 100644 index 0000000..610e53d --- /dev/null +++ b/website/guide/testing-xunit.md @@ -0,0 +1,165 @@ +# xUnit + +`DependencyModules.xUnit` is the xUnit integration. Read [Testing modules](/guide/testing) first — +this page covers only what is specific to xUnit. + +```shell +dotnet add package DependencyModules.xUnit +``` + +```csharp +using DependencyModules.xUnit.Attributes; + +public class WeatherTests { + [ModuleTest] + [ApplicationModule] + public void GetForecast(Weather weather) { + var forecast = weather.GetWeatherForecast().ToArray(); + + Assert.Equal(5, forecast.Length); + } +} +``` + +Requires **xUnit v3**. `[ModuleTest]` derives from `FactAttribute` and is discovered through xUnit's +own test case discoverer, so it is a fact as far as the rest of xUnit is concerned. + +## `[ModuleTest]` replaces `[Fact]` + +It replaces `[Theory]` as well. A module test with data attributes on it produces one test case per +row without your saying so — there is no separate attribute for the parameterised case. + +Because it derives from `FactAttribute`, everything `[Fact]` carries carries here too: + +```csharp +[ModuleTest(Skip = "flaky on CI", Explicit = true, Timeout = 5000, DisplayName = "Forecast")] +public void GetForecast(Weather weather) { } +``` + +`Skip`, `SkipUnless`, `SkipWhen`, `SkipExceptions`, `SkipType`, `Explicit`, `Timeout` and +`DisplayName` all behave as xUnit defines them, and `[Trait]` is carried onto the generated test +cases. + +## Naming modules on the attribute + +Beyond the module attributes described in [Testing modules](/guide/testing#stop-repeating-the-module-list), +`[ModuleTest]` takes module types directly: + +```csharp +[ModuleTest(typeof(ApplicationModule))] +public void GetForecast(Weather weather) { } +``` + +::: warning Two or more modules loses the source location +`[ModuleTest]` captures the file and line it sits on through `[CallerFilePath]`/`[CallerLineNumber]`, +which is how a test explorer navigates back to your test. C# will not accept caller-info parameters +after a `params` array, so the overload taking **several** module types cannot capture them. + +Such a test still runs and still reports correctly; only navigation from the explorer to the source +is unavailable. Naming one module, or none, takes an overload that keeps it — so prefer the module +attributes for the multi-module case: + +```csharp +[ModuleTest] // location captured +[ApplicationModule] +[DiagnosticsModule] +public void GetForecast(Weather weather) { } +``` +::: + +## Data-driven tests + +Any xUnit data attribute works — `[InlineData]`, `[MemberData]`, `[ClassData]`, and anything else +implementing `IDataAttribute`. Row arguments come first, injected ones after: + +```csharp +[ModuleTest] +[InlineData("one")] +[InlineData("two")] +public void MultipleRows(string value, ITemperatureProvider provider) { + Assert.NotNull(value); // from [InlineData] + Assert.NotNull(provider); // from the container +} +``` + +A row supplies the **leading** parameters and may supply fewer than the method takes — that is the +point of it. The rest are resolved from the container. + +Each row is a separate test case with its own container, so state cannot carry from one row to the +next. + +`TheoryDataRow`'s own metadata is honoured per row, so a single row can skip or carry its own traits: + +```csharp +public static TheoryData Cases => new() { + new TheoryDataRow("ok"), + new TheoryDataRow("broken") { Skip = "pending #412" }, +}; + +[ModuleTest] +[MemberData(nameof(Cases))] +public void MultipleRows(string value, ITemperatureProvider provider) { } +``` + +## Reading the test case + +`ITestCaseInfo` is resolvable from the container and exposes xUnit's own metadata for the running +test: + +```csharp +[ModuleTest] +public void KnowsWhatItIs(ITestCaseInfo testCase) { + IXunitTestMethod method = testCase.TestMethod; + + Assert.Equal(nameof(KnowsWhatItIs), method.MethodName); +} +``` + +| Member | | +|---|---| +| `TestMethod` | the `IXunitTestMethod` xUnit built | +| `TestMethodArguments` | the arguments the test will be invoked with | +| `TestMethodAttributes` | every attribute on the method | + +## Fixtures and lifetime + +xUnit constructs the test class **once per test**, which is its own model and unchanged here. Combined +with a container per test, that means nothing survives between tests unless you deliberately make it — +a class fixture, a collection fixture, or a static. + +The container's lifetime brackets the test, so a constructor or `IAsyncLifetime` on the class runs +inside it. Anything the test class needs from the container has to come through a `[ModuleTest]` +parameter, though — xUnit constructs the class, not this package, so a constructor parameter is +xUnit's to supply. + +## Customising how the provider is built + +Implement `IServiceProviderBuilderAttribute` to take over the final step, if you want validation on or +a different container: + +```csharp +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly)] +public class ValidatingProviderAttribute : Attribute, IServiceProviderBuilderAttribute { + public IServiceProvider BuildServiceProvider( + ITestMethodContext testMethod, IServiceCollection serviceCollection) => + serviceCollection.BuildServiceProvider(new ServiceProviderOptions { + ValidateScopes = true, + ValidateOnBuild = true, + }); +} +``` + +It runs last, after every other hook has contributed, so it is also the final chance to amend the +collection. Without one, the collection is built with `BuildServiceProvider()` and its defaults. + +Unlike the other hooks, which all contribute, only **one** of these is used. Declare a single one — +assembly level is the usual place, since replacing the container is a project-wide decision. + +This one is not xUnit-specific — it lives in `DependencyModules.Testing` and works the same under +[NUnit](/guide/testing-nunit). + +## Next + +- [Mocking frameworks](/guide/testing-mocking) — `[Mock]` and the three libraries behind it +- [NUnit](/guide/testing-nunit) — the same integration for NUnit +- [Testing registrations](/guide/testing-registrations) — asserting on what a module registered diff --git a/website/guide/testing.md b/website/guide/testing.md index 03ac3bf..5a192c2 100644 --- a/website/guide/testing.md +++ b/website/guide/testing.md @@ -16,13 +16,9 @@ To test it, you have two options and neither is good. **Construct it by hand.** You end up rebuilding the object graph in the test: ```csharp -[Fact] -public void GetForecast() { - var weather = new Weather( - new SummaryProvider(new AiSummaryProvider()), - new TemperatureProvider()); - // … -} +var weather = new Weather( + new SummaryProvider(new AiSummaryProvider()), + new TemperatureProvider()); ``` Every constructor change breaks every test that touches the type, and the wiring you are testing is @@ -32,44 +28,35 @@ the wiring you just wrote — not the wiring your application actually uses. part you care about, repeated in every test, and now you have a provider to dispose: ```csharp -[Fact] -public void GetForecast() { - var services = new ServiceCollection(); - services.AddModule(); - using var provider = services.BuildServiceProvider(); - - var weather = provider.GetRequiredService(); - // … -} +var services = new ServiceCollection(); +services.AddModule(); +using var provider = services.BuildServiceProvider(); + +var weather = provider.GetRequiredService(); ``` ## How DependencyModules helps -`DependencyModules.xUnit` does the second thing for you. You say which modules to load, and the +A test framework integration does the second thing for you. You say which modules to load, and the services your test needs arrive as **method parameters**, resolved from a provider built out of your real modules: -```shell -dotnet add package DependencyModules.xUnit -``` - ```csharp -using DependencyModules.xUnit.Attributes; - public class WeatherTests { [ModuleTest] [ApplicationModule] public void GetForecast(Weather weather) { var forecast = weather.GetWeatherForecast().ToArray(); - Assert.Equal(5, forecast.Length); + // assert on forecast } } ``` Three things are happening in that test: -- **`[ModuleTest]`** replaces `[Fact]`. It builds a service provider and runs your method against it. +- **`[ModuleTest]`** replaces your framework's test attribute. It builds a service provider and runs + your method against it. - **`[ApplicationModule]`** says which modules to load. It is the attribute the generator produced for your module — see [composing modules](/guide/modules#composing-modules). - **`Weather weather`** is resolved from the resulting provider, along with its whole dependency @@ -78,6 +65,34 @@ Three things are happening in that test: Change `Weather`'s constructor and the test keeps compiling, because the test never mentioned the constructor. +## Pick an integration + +One package per test framework. Install the one matching the framework you already use: + +| Package | Framework | | +|---|---|---| +| `DependencyModules.xUnit` | xUnit v3 | [xUnit](/guide/testing-xunit) | +| `DependencyModules.NUnit` | NUnit | [NUnit](/guide/testing-nunit) | + +```shell +dotnet add package DependencyModules.xUnit +``` + +This page is the part they share, and it is most of it. The two framework pages cover only what +differs — how data rows are supplied, and what each framework's own attributes do around a module +test. + +::: warning Reference one integration, not both +Each defines a `ModuleTestAttribute`. They share a name and nothing else, because each has to derive +from what its own framework requires. A project referencing both would need to disambiguate every +`[ModuleTest]`, which is not a configuration worth having. +::: + +Everything else — `[Mock]`, `[TestExport]`, `[InjectValues]`, keyed services — lives in +`DependencyModules.Testing`, which your integration brings in. Those types name no test framework, so +both integrations hand you the *same* attribute rather than a copy of it. They need a +`using DependencyModules.Testing.Attributes;` alongside the one for `[ModuleTest]`. + ## Stop repeating the module list Module attributes apply at **assembly, class or method level**, and they accumulate. Put the ones @@ -91,10 +106,6 @@ using DependencyModules.NSubstitute; [assembly: NSubstituteSupport] // or [MoqSupport] / [FakeItEasySupport] ``` -`NSubstituteSupport` is what enables [`[Mock]`](/guide/testing-mocks), and it comes from a separate -package — one per mocking library, so use whichever you already have. See -[choosing a mocking library](/guide/testing-mocks#choosing-a-mocking-library). - Every test in the project now gets `ApplicationModule` without saying so: ```csharp @@ -108,39 +119,121 @@ public class WeatherTests { } ``` -## Data-driven tests +`NSubstituteSupport` is what enables [`[Mock]`](/guide/testing-mocking), and it comes from a separate +package — one per mocking library, so use whichever you already have. See +[Mocking frameworks](/guide/testing-mocking). + +## A container per test + +Each test gets **its own provider**, built before the test runs and disposed after it, so a singleton +mutated in one test cannot leak into another. That holds per *iteration*, not merely per method — a +data row, a repeat and a retry each get a fresh container. -`[ModuleTest]` composes with xUnit's data attributes. Data parameters come first, injected ones -after: +Within a test, ask for `IServiceProvider` and create scopes as usual: ```csharp [ModuleTest] -[InlineData("one")] -[InlineData("two")] -public void MultipleRows(string value, ITemperatureProvider provider) { - Assert.NotNull(value); // from [InlineData] - Assert.NotNull(provider); // from the container +public void ScopedServicesAreScoped(IServiceProvider provider) { + using var first = provider.CreateScope(); + using var second = provider.CreateScope(); + + var one = first.ServiceProvider.GetRequiredService(); + + // one is the same instance within first, and a different one in second } ``` -## Scopes and isolation +`IServiceProvider` is special-cased: it is the test's container itself, since a container cannot +resolve itself out of itself. + +## How a parameter gets filled + +Worth knowing when a parameter does not arrive as you expected. Each one is tried in this order, and +the first step that answers wins: -Each `[ModuleTest]` gets **its own provider**, so a singleton mutated in one test cannot leak into -another. Within a test, ask for `IServiceProvider` and create scopes as usual: +1. **A data row**, if the test has one. Row arguments fill the leading parameters, so anything the + row supplies is never resolved from the container. +2. **Attributes on the parameter** — `[Mock]`, `[InjectValues]` and anything else implementing + `ITestParameterValueProvider`. Several may sit on one parameter; one returning nothing stands + aside for the next. +3. **The container**, honouring `[FromKeyedServices]` when present. +4. **Direct construction.** A concrete type the container does not know is built anyway, through + `ActivatorUtilities`, with its dependencies resolved from the container. + +That last step is why a test can name the class under test directly without registering it: ```csharp [ModuleTest] -public void ScopedServicesAreScoped(IServiceProvider provider) { - using var first = provider.CreateScope(); - using var second = provider.CreateScope(); +public void ConstructsTheSubjectDirectly(OrderCalculator calculator) { } // never registered +``` - var one = first.ServiceProvider.GetRequiredService(); +## Keyed services + +`[FromKeyedServices]` works on a test parameter the way it does on a constructor parameter: + +```csharp +[ModuleTest] +public void ResolvesTheKeyedOne([FromKeyedServices("primary")] IRepository repository) { } +``` + +See [registering services](/guide/services) for how a registration acquires a key. + +## When you want a real object, not a mock + +A [mock](/guide/testing-mocking) is right when you intend to **assert on the interaction** — what was +called, with which arguments. When you instead want a working implementation that simply behaves +differently, a mock makes you stub out every member you touch. + +`[TestExport]` registers a real type into the test's container without touching the module: + +```csharp +public class FixedClock : IClock { + public DateTime UtcNow => new(2026, 1, 1); +} + +[ModuleTest] +[TestExport(typeof(IClock), Implementation = typeof(FixedClock), Lifetime = ServiceLifetime.Singleton)] +public void OrdersAreStampedWithTheCurrentTime(IOrderService service) { } +``` - Assert.Same(one, first.ServiceProvider.GetRequiredService()); - Assert.NotSame(one, second.ServiceProvider.GetRequiredService()); +`FixedClock` is constructed by the container, so it can have dependencies of its own. + +| Property | | +|---|---| +| *(constructor)* | the service type | +| `Implementation` | defaults to the service type when omitted | +| `Lifetime` | defaults to `Transient` | + +Like the module attributes it applies at assembly, class or method level, so a stub every test needs +can sit in your bootstrap file once. A `[TestExport]` also beats a mock for the same service, +whatever order the two are declared in — see [ordering](/guide/testing-mocking#what-wins-when-two-things-register-the-same-service). + +## 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: + +```csharp +public record InjectModel(IDependencyOne DependencyOne, string StringValue); + +[ModuleTest] +public void InjectTestValue([InjectValues("Hello World!")] InjectModel model) { + // model.DependencyOne came from the container + // model.StringValue came from the attribute } ``` +The values are matched against the constructor parameters the container **cannot** supply, so you +list only what it could not work out for itself. + +## 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 | + ## What is worth testing Asserting that `[SingletonService]` produced an `AddSingleton` call is testing this library, and this @@ -156,7 +249,20 @@ The build already covers a good deal of the rest. A convention that matches noth [DM0005](/reference/diagnostics#dm0005), and a service that cannot be constructed is [DM0002](/reference/diagnostics#dm0002) — both before a test runs. +## A trap worth knowing about + +An [intercepted](/guide/interception) service resolves as a **generated wrapper**, not as your class. +So this fails, confusingly: + +```csharp +Assert.IsType(provider.GetRequiredService()); // it is Orders_Intercepted +``` + +Assert on the interface, or on behaviour. The same applies to a [decorated](/guide/decorators) +service, where what resolves is the outermost decorator. + ## Next -- [Mocks and values](/guide/testing-mocks) — faking one service while the rest stays real +- [xUnit](/guide/testing-xunit) and [NUnit](/guide/testing-nunit) — what differs per framework +- [Mocking frameworks](/guide/testing-mocking) — faking one service while the rest stays real - [Testing registrations](/guide/testing-registrations) — asserting on what a module registered