From 53467f0532ded48a5e94ee43de38fa5ccee094dd Mon Sep 17 00:00:00 2001 From: Koddek Date: Thu, 29 Jan 2026 11:17:52 -0400 Subject: [PATCH 1/4] feat(generator): add service grouping and priority ordering Add `group` and `priority` parameters to AutorediAttribute for organizing services into named groups and controlling registration order. Generate group-specific extension methods (e.g., AddAutorediServicesFirebase) for selective registration. Higher priority values are registered first within each group. Include validation diagnostics for group names and priority ranges. Update benchmarks and documentation with new features. --- Autoredi.slnx | 7 +- CHANGELOG.md | 30 ++ README.md | 59 ++++ .../ComprehensiveRegistrationBenchmarks.cs | 167 ++++++++++ .../Benchmarks/GroupingBenchmarks.cs | 34 ++ benchmarks/Autoredi.Benchmarks/Program.cs | 18 +- .../Services/Generated/BenchmarkServices.cs | 52 +++ .../Services/GroupingBenchmarkServices.cs | 19 ++ samples/Samples.Modular.App/Program.cs | 73 +++++ .../Samples.Modular.App.csproj | 15 + .../Samples.Modular.Infrastructure.csproj | 13 + .../Services/InfrastructureServices.cs | 16 + src/Autoredi.Generators/AutorediGenerator.cs | 296 +++++++++++++++--- src/Autoredi.Generators/Diagnostics.cs | 42 +++ src/Autoredi/Attributes/AutorediAttribute.cs | 16 +- .../Features/GroupedRegistrationTests.cs | 112 +++++++ .../Fixtures/GroupedTestServices.cs | 27 ++ 17 files changed, 943 insertions(+), 53 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 benchmarks/Autoredi.Benchmarks/Benchmarks/ComprehensiveRegistrationBenchmarks.cs create mode 100644 benchmarks/Autoredi.Benchmarks/Benchmarks/GroupingBenchmarks.cs create mode 100644 benchmarks/Autoredi.Benchmarks/Services/Generated/BenchmarkServices.cs create mode 100644 benchmarks/Autoredi.Benchmarks/Services/GroupingBenchmarkServices.cs create mode 100644 samples/Samples.Modular.App/Program.cs create mode 100644 samples/Samples.Modular.App/Samples.Modular.App.csproj create mode 100644 samples/Samples.Modular.Infrastructure/Samples.Modular.Infrastructure.csproj create mode 100644 samples/Samples.Modular.Infrastructure/Services/InfrastructureServices.cs create mode 100644 tests/Autoredi.Tests/Features/GroupedRegistrationTests.cs create mode 100644 tests/Autoredi.Tests/Fixtures/GroupedTestServices.cs diff --git a/Autoredi.slnx b/Autoredi.slnx index f9fc234..193d9b6 100644 --- a/Autoredi.slnx +++ b/Autoredi.slnx @@ -1,9 +1,4 @@ - - - - - @@ -19,6 +14,8 @@ + + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..94fbad8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,30 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.2.0] - 2026-01-28 + +### Added +- **Group Property**: Organize services into logical groups with selective registration. + - `AddAutorediServices()` still registers all services (backward compatible). + - New group-specific methods: `AddAutorediServicesFirebase()`, `AddAutorediServicesAccount()`, etc. + - Services without a group go to `AddAutorediServicesDefault()`. + - Global aggregation: Group methods automatically include services from the same group in referenced assemblies. +- **Priority Property**: Control registration order within groups using `priority` (int). + - Higher values are registered first (e.g., 100 before 0). + - Default priority is 0. + - Useful for controlling service registration order for decorators or overrides. +- **Modular Sample**: Added `Samples.Modular` demonstrating cross-assembly grouping and priority. +- **Performance Benchmarks**: Added `GroupingBenchmarks` to measure selective registration overhead. + - Selective registration is faster than full registration for large containers. + - Priority sorting adds zero runtime overhead (pre-sorted in generated code). + +### Changed +- `AutorediAttribute` now accepts optional `group` and `priority` parameters. +- Generated extension methods now include group-specific registration methods. +- Version bumped to 0.2.0. + +### Backward Compatibility +- ✅ All existing code continues to work without changes. +- ✅ New parameters are optional with sensible defaults. +- ✅ `AddAutorediServices()` behavior remains unchanged (registers all services). diff --git a/README.md b/README.md index c3b421f..857df90 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ Autoredi is a powerful source generator for .NET that simplifies dependency inje - [Intermediate: Single Interface Implementation](#intermediate-single-interface-implementation) - [Advanced: Keyed Services for Multiple Implementations](#advanced-keyed-services-for-multiple-implementations) - [Complex: Controllers and Dynamic Resolution](#complex-controllers-and-dynamic-resolution) + - [Grouped Registration](#grouped-registration) + - [Priority Ordering](#priority-ordering) - [Contributing](#contributing) - [License](#license) @@ -290,6 +292,63 @@ In this scenario: This demonstrates Autoredi’s flexibility in handling complex DI scenarios, from constructor injection to runtime service selection. +### Grouped Registration + +For large applications, you can organize services into named groups for selective registration. This is useful for modularizing your DI setup or conditionally registering sets of services. + +```csharp +// Group: "Firebase" +[Autoredi(ServiceLifetime.Singleton, group: "Firebase")] +public class FirebaseConfig { } + +[Autoredi(ServiceLifetime.Transient, group: "Firebase")] +public class FirebaseRepository { } + +// Group: "Account" +[Autoredi(ServiceLifetime.Scoped, group: "Account")] +public class AccountService { } + +// No Group (Default) +[Autoredi(ServiceLifetime.Transient)] +public class GlobalService { } +``` + +**Usage:** + +```csharp +var services = new ServiceCollection(); + +// Option 1: Register EVERYTHING (Default behavior) +services.AddAutorediServices(); + +// Option 2: Selective Registration +services.AddAutorediServicesFirebase(); // Registers only Firebase group +services.AddAutorediServicesAccount(); // Registers only Account group +services.AddAutorediServicesDefault(); // Registers ungrouped services +``` + +*Note: Group registration methods (e.g., `AddAutorediServicesFirebase`) automatically include services from the same group in referenced assemblies. Check the `samples/Samples.Modular` directory for a complete cross-project example.* + +### Priority Ordering + +You can control the order in which services are registered within their groups using the `priority` parameter. Higher values are registered first. + +```csharp +// Priority 100: Registered first +[Autoredi(ServiceLifetime.Singleton, priority: 100)] +public class FirstService { } + +// Priority 50: Registered second +[Autoredi(ServiceLifetime.Singleton, priority: 50)] +public class SecondService { } + +// Default Priority (0): Registered last (in alphabetical order) +[Autoredi(ServiceLifetime.Singleton)] +public class LastService { } +``` + +Priorities are scoped to their group (or the default group). This is helpful when service registration order matters, such as when using decorators or the `TryAdd` pattern (though Autoredi always uses `Add`). + ## Contributing Contributions are welcome! To get started: diff --git a/benchmarks/Autoredi.Benchmarks/Benchmarks/ComprehensiveRegistrationBenchmarks.cs b/benchmarks/Autoredi.Benchmarks/Benchmarks/ComprehensiveRegistrationBenchmarks.cs new file mode 100644 index 0000000..fe3ba9b --- /dev/null +++ b/benchmarks/Autoredi.Benchmarks/Benchmarks/ComprehensiveRegistrationBenchmarks.cs @@ -0,0 +1,167 @@ +using BenchmarkDotNet.Attributes; +using Microsoft.Extensions.DependencyInjection; +using Autoredi.Benchmarks.Autoredi; +using Autoredi.Benchmarks.Services.Generated; + +namespace Autoredi.Benchmarks.Benchmarks; + +[MemoryDiagnoser] +[CategoriesColumn] +[GroupBenchmarksBy(BenchmarkDotNet.Configs.BenchmarkLogicalGroupRule.ByCategory)] +public class ComprehensiveRegistrationBenchmarks +{ + // ======================================================================== + // 1. Full Registration (All Services) + // ======================================================================== + + [Benchmark(Description = "Autoredi - All Groups (40 Services)", Baseline = false)] + [BenchmarkCategory("Full_Registration")] + public void Autoredi_All() + { + var services = new ServiceCollection(); + services.AddAutorediServices(); + } + + [Benchmark(Description = "Manual - All Groups (40 Services)", Baseline = true)] + [BenchmarkCategory("Full_Registration")] + public void Manual_All() + { + var services = new ServiceCollection(); + + // Singletons + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Scopeds + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Transients + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + // Priority + services.AddSingleton(); + services.AddScoped(); + services.AddTransient(); + services.AddSingleton(); + services.AddScoped(); + services.AddTransient(); + services.AddSingleton(); + services.AddScoped(); + services.AddTransient(); + services.AddSingleton(); + } + + // ======================================================================== + // 2. Lifetime Specific Groups + // ======================================================================== + + [Benchmark(Description = "Autoredi - Singletons (10)", Baseline = false)] + [BenchmarkCategory("Lifetime_Singleton")] + public void Autoredi_Singletons() + { + var services = new ServiceCollection(); + services.AddAutorediServicesSingletons(); + } + + [Benchmark(Description = "Manual - Singletons (10)", Baseline = true)] + [BenchmarkCategory("Lifetime_Singleton")] + public void Manual_Singletons() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + + [Benchmark(Description = "Autoredi - Transients (10)", Baseline = false)] + [BenchmarkCategory("Lifetime_Transient")] + public void Autoredi_Transients() + { + var services = new ServiceCollection(); + services.AddAutorediServicesTransients(); + } + + [Benchmark(Description = "Manual - Transients (10)", Baseline = true)] + [BenchmarkCategory("Lifetime_Transient")] + public void Manual_Transients() + { + var services = new ServiceCollection(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + } + + // ======================================================================== + // 3. Priority & Mixing + // ======================================================================== + + [Benchmark(Description = "Autoredi - Prioritized Group (10 Mixed)", Baseline = false)] + [BenchmarkCategory("Priority_Mixed")] + public void Autoredi_Prioritized() + { + var services = new ServiceCollection(); + services.AddAutorediServicesPrioritized(); + } + + [Benchmark(Description = "Manual - Prioritized Group (10 Mixed)", Baseline = true)] + [BenchmarkCategory("Priority_Mixed")] + public void Manual_Prioritized() + { + var services = new ServiceCollection(); + // Manually registering in the correct priority order to match Autoredi's output + // High (100) + services.AddSingleton(); + services.AddScoped(); + services.AddTransient(); + // Mid (50) + services.AddSingleton(); + services.AddScoped(); + services.AddTransient(); + // Low (0) + services.AddSingleton(); + services.AddScoped(); + services.AddTransient(); + // Min (0) + services.AddSingleton(); + } +} diff --git a/benchmarks/Autoredi.Benchmarks/Benchmarks/GroupingBenchmarks.cs b/benchmarks/Autoredi.Benchmarks/Benchmarks/GroupingBenchmarks.cs new file mode 100644 index 0000000..9a003e5 --- /dev/null +++ b/benchmarks/Autoredi.Benchmarks/Benchmarks/GroupingBenchmarks.cs @@ -0,0 +1,34 @@ +using BenchmarkDotNet.Attributes; +using Microsoft.Extensions.DependencyInjection; +using Autoredi.Benchmarks.Autoredi; +using Autoredi.Benchmarks.Services; + +namespace Autoredi.Benchmarks.Benchmarks; + +/// +/// Benchmarks comparing grouping and selective registration performance. +/// +[MemoryDiagnoser] +public class GroupingBenchmarks +{ + [Benchmark(Description = "Autoredi - Full Registration (All Groups)")] + public void FullRegistration() + { + var services = new ServiceCollection(); + services.AddAutorediServices(); + } + + [Benchmark(Description = "Autoredi - Selective Registration (GroupA Only)")] + public void SelectiveRegistration() + { + var services = new ServiceCollection(); + services.AddAutorediServicesGroupA(); + } + + [Benchmark(Description = "Autoredi - Default Group Only")] + public void DefaultGroupOnly() + { + var services = new ServiceCollection(); + services.AddAutorediServicesDefault(); + } +} diff --git a/benchmarks/Autoredi.Benchmarks/Program.cs b/benchmarks/Autoredi.Benchmarks/Program.cs index 7ba08a0..8b76628 100644 --- a/benchmarks/Autoredi.Benchmarks/Program.cs +++ b/benchmarks/Autoredi.Benchmarks/Program.cs @@ -14,10 +14,12 @@ static void Main(string[] args) Console.WriteLine("Comparing Autoredi vs Manual DI Registration Performance\n"); Console.WriteLine("Available Benchmarks:"); - Console.WriteLine(" 1. ContainerBuildBenchmarks - Container build performance"); + Console.WriteLine(" 1. ContainerBuildBenchmarks - Basic build performance"); Console.WriteLine(" 2. ServiceResolutionBenchmarks - Service resolution performance"); Console.WriteLine(" 3. ScopedResolutionBenchmarks - Scoped service resolution"); - Console.WriteLine(" 4. All - Run all benchmarks\n"); + Console.WriteLine(" 4. GroupingBenchmarks - Basic grouping performance"); + Console.WriteLine(" 5. ComprehensiveRegistrationBenchmarks - Detailed Manual vs Autoredi (Groups, Priorities, Lifetimes)"); + Console.WriteLine(" 6. All - Run all benchmarks\n"); if (args.Length == 0) { @@ -25,6 +27,8 @@ static void Main(string[] args) BenchmarkRunner.Run(); BenchmarkRunner.Run(); BenchmarkRunner.Run(); + BenchmarkRunner.Run(); + BenchmarkRunner.Run(); } else { @@ -43,11 +47,21 @@ static void Main(string[] args) BenchmarkRunner.Run(); break; case "4": + case "grouping": + BenchmarkRunner.Run(); + break; + case "5": + case "comprehensive": + BenchmarkRunner.Run(); + break; + case "6": case "all": default: BenchmarkRunner.Run(); BenchmarkRunner.Run(); BenchmarkRunner.Run(); + BenchmarkRunner.Run(); + BenchmarkRunner.Run(); break; } } diff --git a/benchmarks/Autoredi.Benchmarks/Services/Generated/BenchmarkServices.cs b/benchmarks/Autoredi.Benchmarks/Services/Generated/BenchmarkServices.cs new file mode 100644 index 0000000..4e4f03e --- /dev/null +++ b/benchmarks/Autoredi.Benchmarks/Services/Generated/BenchmarkServices.cs @@ -0,0 +1,52 @@ +using Autoredi.Attributes; +using Microsoft.Extensions.DependencyInjection; + +namespace Autoredi.Benchmarks.Services.Generated; + +// --- Singleton Group --- +[Autoredi(ServiceLifetime.Singleton, group: "Singletons")] public class Singleton_0 {} +[Autoredi(ServiceLifetime.Singleton, group: "Singletons")] public class Singleton_1 {} +[Autoredi(ServiceLifetime.Singleton, group: "Singletons")] public class Singleton_2 {} +[Autoredi(ServiceLifetime.Singleton, group: "Singletons")] public class Singleton_3 {} +[Autoredi(ServiceLifetime.Singleton, group: "Singletons")] public class Singleton_4 {} +[Autoredi(ServiceLifetime.Singleton, group: "Singletons")] public class Singleton_5 {} +[Autoredi(ServiceLifetime.Singleton, group: "Singletons")] public class Singleton_6 {} +[Autoredi(ServiceLifetime.Singleton, group: "Singletons")] public class Singleton_7 {} +[Autoredi(ServiceLifetime.Singleton, group: "Singletons")] public class Singleton_8 {} +[Autoredi(ServiceLifetime.Singleton, group: "Singletons")] public class Singleton_9 {} + +// --- Scoped Group --- +[Autoredi(ServiceLifetime.Scoped, group: "Scopeds")] public class Scoped_0 {} +[Autoredi(ServiceLifetime.Scoped, group: "Scopeds")] public class Scoped_1 {} +[Autoredi(ServiceLifetime.Scoped, group: "Scopeds")] public class Scoped_2 {} +[Autoredi(ServiceLifetime.Scoped, group: "Scopeds")] public class Scoped_3 {} +[Autoredi(ServiceLifetime.Scoped, group: "Scopeds")] public class Scoped_4 {} +[Autoredi(ServiceLifetime.Scoped, group: "Scopeds")] public class Scoped_5 {} +[Autoredi(ServiceLifetime.Scoped, group: "Scopeds")] public class Scoped_6 {} +[Autoredi(ServiceLifetime.Scoped, group: "Scopeds")] public class Scoped_7 {} +[Autoredi(ServiceLifetime.Scoped, group: "Scopeds")] public class Scoped_8 {} +[Autoredi(ServiceLifetime.Scoped, group: "Scopeds")] public class Scoped_9 {} + +// --- Transient Group --- +[Autoredi(ServiceLifetime.Transient, group: "Transients")] public class Transient_0 {} +[Autoredi(ServiceLifetime.Transient, group: "Transients")] public class Transient_1 {} +[Autoredi(ServiceLifetime.Transient, group: "Transients")] public class Transient_2 {} +[Autoredi(ServiceLifetime.Transient, group: "Transients")] public class Transient_3 {} +[Autoredi(ServiceLifetime.Transient, group: "Transients")] public class Transient_4 {} +[Autoredi(ServiceLifetime.Transient, group: "Transients")] public class Transient_5 {} +[Autoredi(ServiceLifetime.Transient, group: "Transients")] public class Transient_6 {} +[Autoredi(ServiceLifetime.Transient, group: "Transients")] public class Transient_7 {} +[Autoredi(ServiceLifetime.Transient, group: "Transients")] public class Transient_8 {} +[Autoredi(ServiceLifetime.Transient, group: "Transients")] public class Transient_9 {} + +// --- Mixed Priority Group --- +[Autoredi(ServiceLifetime.Singleton, group: "Prioritized", priority: 100)] public class PriorityHigh_0 {} +[Autoredi(ServiceLifetime.Scoped, group: "Prioritized", priority: 100)] public class PriorityHigh_1 {} +[Autoredi(ServiceLifetime.Transient, group: "Prioritized", priority: 100)] public class PriorityHigh_2 {} +[Autoredi(ServiceLifetime.Singleton, group: "Prioritized", priority: 50)] public class PriorityMid_0 {} +[Autoredi(ServiceLifetime.Scoped, group: "Prioritized", priority: 50)] public class PriorityMid_1 {} +[Autoredi(ServiceLifetime.Transient, group: "Prioritized", priority: 50)] public class PriorityMid_2 {} +[Autoredi(ServiceLifetime.Singleton, group: "Prioritized", priority: 0)] public class PriorityLow_0 {} +[Autoredi(ServiceLifetime.Scoped, group: "Prioritized", priority: 0)] public class PriorityLow_1 {} +[Autoredi(ServiceLifetime.Transient, group: "Prioritized", priority: 0)] public class PriorityLow_2 {} +[Autoredi(ServiceLifetime.Singleton, group: "Prioritized", priority: 0)] public class PriorityMin_0 {} diff --git a/benchmarks/Autoredi.Benchmarks/Services/GroupingBenchmarkServices.cs b/benchmarks/Autoredi.Benchmarks/Services/GroupingBenchmarkServices.cs new file mode 100644 index 0000000..2fe2721 --- /dev/null +++ b/benchmarks/Autoredi.Benchmarks/Services/GroupingBenchmarkServices.cs @@ -0,0 +1,19 @@ +using Autoredi.Attributes; +using Microsoft.Extensions.DependencyInjection; + +namespace Autoredi.Benchmarks.Services; + +// --- Group A --- +[Autoredi(ServiceLifetime.Singleton, group: "GroupA", priority: 100)] +public class GroupAService1; + +[Autoredi(ServiceLifetime.Transient, group: "GroupA", priority: 50)] +public class GroupAService2; + +// --- Group B --- +[Autoredi(ServiceLifetime.Singleton, group: "GroupB")] +public class GroupBService1; + +// --- Default Group --- +[Autoredi(ServiceLifetime.Transient)] +public class DefaultBenchmarkService; diff --git a/samples/Samples.Modular.App/Program.cs b/samples/Samples.Modular.App/Program.cs new file mode 100644 index 0000000..b0bf944 --- /dev/null +++ b/samples/Samples.Modular.App/Program.cs @@ -0,0 +1,73 @@ +using Autoredi.Attributes; +using Microsoft.Extensions.DependencyInjection; +using Samples.Modular.App.Autoredi; +using Samples.Modular.Infrastructure.Services; + +namespace Samples.Modular.App; + +[Autoredi(ServiceLifetime.Singleton, group: "Firebase", priority: 1)] +public class LocalFirebaseConfig +{ + public string Name => "Local App Firebase"; +} + +[Autoredi(ServiceLifetime.Transient)] +public class AppService +{ + public string Message => "Hello from Modular App!"; +} + +class Program +{ + static void Main(string[] args) + { + Console.WriteLine("=== Autoredi Modular Sample ===\n"); + + var services = new ServiceCollection(); + + // Selective registration of the "Firebase" group. + Console.WriteLine("Registering 'Firebase' group..."); + services.AddAutorediServicesFirebase(); + + // Verify registrations in IServiceCollection + Console.WriteLine("\nChecking IServiceCollection for Firebase registrations:"); + var registrations = services.ToList(); + + var firebaseCore = registrations.FirstOrDefault(r => r.ImplementationType == typeof(FirebaseCore)); + var localConfig = registrations.FirstOrDefault(r => r.ImplementationType == typeof(LocalFirebaseConfig)); + + Console.WriteLine($" - FirebaseCore registered? {firebaseCore != null}"); + Console.WriteLine($" - LocalFirebaseConfig registered? {localConfig != null}"); + + if (firebaseCore != null && localConfig != null) + { + var coreIndex = registrations.IndexOf(firebaseCore); + var localIndex = registrations.IndexOf(localConfig); + Console.WriteLine($"\nRegistration Order (within Firebase group):"); + Console.WriteLine($" 1. {firebaseCore.ImplementationType?.Name} (Index: {coreIndex}, Priority: 100)"); + Console.WriteLine($" 2. {localConfig.ImplementationType?.Name} (Index: {localIndex}, Priority: 1)"); + + if (coreIndex < localIndex) + Console.WriteLine(" >> SUCCESS: High priority service registered first!"); + else + Console.WriteLine(" >> FAILURE: Priority ordering not respected."); + } + + // Verify that "Storage" group from Infrastructure is NOT registered yet + var storageReg = registrations.FirstOrDefault(r => r.ImplementationType == typeof(DatabaseService)); + Console.WriteLine($"\nDatabaseService (Storage group) registered? {storageReg != null}"); + + // Now register everything + Console.WriteLine("\nRegistering ALL services..."); + services.AddAutorediServices(); + + var allRegs = services.ToList(); + storageReg = allRegs.FirstOrDefault(r => r.ImplementationType == typeof(DatabaseService)); + var appReg = allRegs.FirstOrDefault(r => r.ImplementationType == typeof(AppService)); + + Console.WriteLine($" - DatabaseService registered? {storageReg != null}"); + Console.WriteLine($" - AppService registered? {appReg != null}"); + + Console.WriteLine("\n=== Modular Sample Complete ==="); + } +} diff --git a/samples/Samples.Modular.App/Samples.Modular.App.csproj b/samples/Samples.Modular.App/Samples.Modular.App.csproj new file mode 100644 index 0000000..fdb5806 --- /dev/null +++ b/samples/Samples.Modular.App/Samples.Modular.App.csproj @@ -0,0 +1,15 @@ + + + Exe + net10.0 + Samples.Modular.App + enable + enable + + + + + + + + diff --git a/samples/Samples.Modular.Infrastructure/Samples.Modular.Infrastructure.csproj b/samples/Samples.Modular.Infrastructure/Samples.Modular.Infrastructure.csproj new file mode 100644 index 0000000..8a0d65e --- /dev/null +++ b/samples/Samples.Modular.Infrastructure/Samples.Modular.Infrastructure.csproj @@ -0,0 +1,13 @@ + + + net10.0 + Samples.Modular.Infrastructure + enable + enable + + + + + + + diff --git a/samples/Samples.Modular.Infrastructure/Services/InfrastructureServices.cs b/samples/Samples.Modular.Infrastructure/Services/InfrastructureServices.cs new file mode 100644 index 0000000..51fe900 --- /dev/null +++ b/samples/Samples.Modular.Infrastructure/Services/InfrastructureServices.cs @@ -0,0 +1,16 @@ +using Autoredi.Attributes; +using Microsoft.Extensions.DependencyInjection; + +namespace Samples.Modular.Infrastructure.Services; + +[Autoredi(ServiceLifetime.Singleton, group: "Firebase", priority: 100)] +public class FirebaseCore +{ + public string Name => "Infrastructure Core Firebase"; +} + +[Autoredi(ServiceLifetime.Scoped, group: "Storage")] +public class DatabaseService +{ + public string Status => "Connected"; +} diff --git a/src/Autoredi.Generators/AutorediGenerator.cs b/src/Autoredi.Generators/AutorediGenerator.cs index c45c044..f2817a5 100644 --- a/src/Autoredi.Generators/AutorediGenerator.cs +++ b/src/Autoredi.Generators/AutorediGenerator.cs @@ -1,4 +1,4 @@ -using System.Collections.Immutable; +using System.Collections.Immutable; using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -132,7 +132,7 @@ private static void Execute(Compilation compilation, ImmutableArray 5) { context.ReportDiagnostic(Diagnostic.Create(InvalidAttributeArgumentsDescriptor, classDecl.GetLocation(), classSymbol.Name)); @@ -143,6 +143,49 @@ private static void Execute(Compilation compilation, ImmutableArray= 4) + { + var groupArg = attributeData.ConstructorArguments[3]; + if (!groupArg.IsNull && groupArg.Value is string groupValue) + { + group = ValidateGroupName(groupValue, classDecl.GetLocation(), context); + } + } + + // Extract priority (argument 4) + int priority = 0; + if (attributeData.ConstructorArguments.Length >= 5) + { + var priorityArg = attributeData.ConstructorArguments[4]; + if (!priorityArg.IsNull) + { + if (priorityArg.Value is int priorityValue) + { + if (priorityValue < 0 || priorityValue > 999) + { + context.ReportDiagnostic(Diagnostic.Create( + PriorityOutOfRangeDescriptor, + classDecl.GetLocation(), + classSymbol.Name, + priorityValue)); + continue; + } + + priority = priorityValue; + } + else + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidPriorityTypeDescriptor, + classDecl.GetLocation(), + classSymbol.Name)); + continue; + } + } + } + // Check 9: Validate lifetime argument type if (lifetimeArg.Value is not int lifetimeInt) { @@ -195,6 +238,8 @@ private static void Execute(Compilation compilation, ImmutableArray r.InterfaceName == null) - .OrderBy(r => r.ClassName) - .Concat( - registrations - .Where(r => r.InterfaceName != null) - .OrderBy(r => r.InterfaceName) - .ThenBy(r => r.ClassName)) - .ToList(); + // Sort registrations - Removed as we do per-group sorting now + + // Identify all unique groups (local + referenced) + var localGroups = new HashSet( + registrations + .Select(r => string.IsNullOrWhiteSpace(r.Group) ? "Default" : r.Group!) + .Distinct()); + + var allGroups = new HashSet(localGroups); + foreach (var refAsm in referencedAutorediNamespaces) + { + foreach (var group in refAsm.Groups) + { + allGroups.Add(group); + } + } + + // Group local registrations + var groupedRegistrations = registrations + .GroupBy(r => string.IsNullOrWhiteSpace(r.Group) ? "Default" : r.Group) + .ToDictionary(g => g.Key!, g => g.ToList()); // Generate the extension method var sb = new StringBuilder(); @@ -301,14 +357,17 @@ private static void Execute(Compilation compilation, ImmutableArray"); + sb.AppendLine(" /// Registers all Autoredi services from this assembly and referenced assemblies."); + sb.AppendLine(" /// "); sb.AppendLine(" public static IServiceCollection AddAutorediServices(this IServiceCollection services)"); sb.AppendLine(" {"); // Call AddAutorediServices for referenced projects using fully qualified names - foreach (var ns in referencedAutorediNamespaces) + foreach (var refAsm in referencedAutorediNamespaces) { - var assemblyName = ns.Substring(0, ns.Length - ".Autoredi".Length); - sb.AppendLine($" {ns}.AutorediExtensions.AddAutorediServices(services); // {assemblyName}"); + var assemblyName = refAsm.Namespace.Substring(0, refAsm.Namespace.Length - ".Autoredi".Length); + sb.AppendLine($" {refAsm.Namespace}.AutorediExtensions.AddAutorediServices(services); // {assemblyName}"); } if (!referencedAutorediNamespaces.IsEmpty) @@ -316,58 +375,103 @@ private static void Execute(Compilation compilation, ImmutableArray g)) { - var lifetimeMethod = reg.Lifetime switch - { - 0 => "Singleton", - 1 => "Scoped", - 2 => "Transient", - _ => throw new InvalidOperationException("Invalid lifetime") // Unreachable - }; + var groupMethodName = $"AddAutorediServices{group}"; + sb.AppendLine($" services.{groupMethodName}();"); + } - if (reg.ServiceKey == null) + sb.AppendLine(); + sb.AppendLine(" return services;"); + sb.AppendLine(" }"); + + // Generate group-specific methods for ALL known groups (local + referenced) + foreach (var group in allGroups.OrderBy(g => g)) + { + sb.AppendLine(); + var groupMethodName = $"AddAutorediServices{group}"; + + sb.AppendLine($" /// "); + sb.AppendLine($" /// Registers Autoredi services from the '{group}' group."); + sb.AppendLine($" /// "); + sb.AppendLine($" public static IServiceCollection {groupMethodName}(this IServiceCollection services)"); + sb.AppendLine(" {"); + + // 1. Call referenced assemblies' group methods if they exist + foreach (var refAsm in referencedAutorediNamespaces) { - if (reg.InterfaceName != null) - { - sb.AppendLine($" services.Add{lifetimeMethod}<{reg.InterfaceName}, {reg.ClassName}>();"); - } - else + if (refAsm.Groups.Contains(group)) { - sb.AppendLine($" services.Add{lifetimeMethod}<{reg.ClassName}>();"); + sb.AppendLine($" {refAsm.Namespace}.AutorediExtensions.{groupMethodName}(services);"); } } - else + + // 2. Register local services for this group + if (groupedRegistrations.TryGetValue(group, out var groupRegs)) { - if (reg.InterfaceName != null) - { - sb.AppendLine( - $" services.AddKeyed{lifetimeMethod}<{reg.InterfaceName}, {reg.ClassName}>(\"{reg.ServiceKey}\");"); - } - else + // Sort within group: Priority DESC, then has-interface, then name + var sortedInGroup = groupRegs + .OrderByDescending(r => r.Priority) + .ThenBy(r => r.InterfaceName == null ? 0 : 1) + .ThenBy(r => r.InterfaceName ?? r.ClassName, StringComparer.OrdinalIgnoreCase) + .ToList(); + + foreach (var reg in sortedInGroup) { - sb.AppendLine($" services.AddKeyed{lifetimeMethod}<{reg.ClassName}>(\"{reg.ServiceKey}\");"); + var lifetimeMethod = reg.Lifetime switch + { + 0 => "Singleton", + 1 => "Scoped", + 2 => "Transient", + _ => throw new InvalidOperationException("Invalid lifetime") // Unreachable + }; + + if (reg.ServiceKey == null) + { + if (reg.InterfaceName != null) + { + sb.AppendLine($" services.Add{lifetimeMethod}<{reg.InterfaceName}, {reg.ClassName}>();"); + } + else + { + sb.AppendLine($" services.Add{lifetimeMethod}<{reg.ClassName}>();"); + } + } + else + { + if (reg.InterfaceName != null) + { + sb.AppendLine( + $" services.AddKeyed{lifetimeMethod}<{reg.InterfaceName}, {reg.ClassName}>(\"{reg.ServiceKey}\");"); + } + else + { + sb.AppendLine($" services.AddKeyed{lifetimeMethod}<{reg.ClassName}>(\"{reg.ServiceKey}\");"); + } + } } } + + sb.AppendLine(); + sb.AppendLine(" return services;"); + sb.AppendLine(" }"); } - sb.AppendLine(); - sb.AppendLine(" return services;"); - sb.AppendLine(" }"); sb.AppendLine("}"); var fileName = $"{targetNamespace}.AutorediExtensions.cs"; context.AddSource(fileName, SourceText.From(sb.ToString(), Encoding.UTF8)); } - private static ImmutableArray GetReferencedAutorediNamespaces( + private static ImmutableArray GetReferencedAutorediNamespaces( Compilation compilation, string projectName, INamedTypeSymbol attributeType, HashSet visitedAssemblies, SourceProductionContext context) { - var namespaces = new List(); + var result = new List(); foreach (var reference in compilation.References) { @@ -400,13 +504,21 @@ private static ImmutableArray GetReferencedAutorediNamespaces( // Check for Autoredi attribute or namespace if (HasAutorediAttributeOrNamespace(compilation, asm, attributeType)) { - namespaces.Add($"{asm.Name}.Autoredi"); + var groups = GetAutorediGroups(compilation, asm); + result.Add(new ReferencedAssemblyAutorediInfo + { + Namespace = $"{asm.Name}.Autoredi", + Groups = groups + }); } visitedAssemblies.Remove(asm.Name); // Allow re-visiting in other contexts } - return namespaces.Distinct().ToImmutableArray(); + // De-duplicate based on Namespace + return result.GroupBy(x => x.Namespace) + .Select(g => g.First()) + .ToImmutableArray(); } private static bool HasAutorediAttributeOrNamespace(Compilation compilation, IAssemblySymbol assembly, @@ -440,12 +552,106 @@ private static IEnumerable GetAllTypes(INamespaceSymbol namesp } } + private static string? ValidateGroupName(string groupName, Location location, SourceProductionContext context) + { + if (string.IsNullOrWhiteSpace(groupName)) + { + return null; // Treat as Default group + } + + // Check length + if (groupName.Length > 100) + { + context.ReportDiagnostic(Diagnostic.Create( + GroupNameTooLongDescriptor, location, groupName)); + return null; + } + + // Check valid C# identifier + if (!IsValidIdentifier(groupName)) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidGroupNameDescriptor, location, groupName)); + return null; + } + + // Check against C# keywords + var keywords = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char", + "checked", "class", "const", "continue", "decimal", "default", "delegate", + "do", "double", "else", "enum", "event", "explicit", "extern", "false", + "finally", "fixed", "float", "for", "foreach", "goto", "if", "implicit", + "in", "int", "interface", "internal", "is", "lock", "long", "namespace", + "new", "null", "object", "operator", "out", "override", "params", "private", + "protected", "public", "readonly", "ref", "return", "sbyte", "sealed", + "short", "sizeof", "stackalloc", "static", "string", "struct", "switch", + "this", "throw", "true", "try", "typeof", "uint", "ulong", "unchecked", + "unsafe", "ushort", "using", "virtual", "void", "volatile", "while" + }; + + if (keywords.Contains(groupName)) + { + context.ReportDiagnostic(Diagnostic.Create( + GroupNameIsKeywordDescriptor, location, groupName)); + return null; + } + + return groupName; + } + + private static bool IsValidIdentifier(string identifier) + { + if (string.IsNullOrEmpty(identifier)) return false; + + if (!char.IsLetter(identifier[0]) && identifier[0] != '_') return false; + + for (int i = 1; i < identifier.Length; i++) + { + if (!char.IsLetterOrDigit(identifier[i]) && identifier[i] != '_') return false; + } + + return true; + } + private sealed record RegistrationInfo { public string ClassName { get; set; } = string.Empty; public int Lifetime { get; set; } public string? InterfaceName { get; set; } public string? ServiceKey { get; set; } + public string? Group { get; set; } + public int Priority { get; set; } public Location? Location { get; set; } } + + private sealed record ReferencedAssemblyAutorediInfo + { + public string Namespace { get; set; } = string.Empty; + public ImmutableArray Groups { get; set; } = ImmutableArray.Empty; + } + + private static ImmutableArray GetAutorediGroups(Compilation compilation, IAssemblySymbol assembly) + { + var autorediExtensionsType = compilation.GetTypeByMetadataName($"{assembly.Name}.Autoredi.AutorediExtensions"); + if (autorediExtensionsType == null) + { + return ImmutableArray.Empty; + } + + var groups = new HashSet(); + foreach (var member in autorediExtensionsType.GetMembers()) + { + if (member is IMethodSymbol method && + method.IsStatic && + method.Name.StartsWith("AddAutorediServices") && + method.Name.Length > "AddAutorediServices".Length) + { + var groupName = method.Name.Substring("AddAutorediServices".Length); + groups.Add(groupName); + } + } + + return groups.ToImmutableArray(); + } } diff --git a/src/Autoredi.Generators/Diagnostics.cs b/src/Autoredi.Generators/Diagnostics.cs index 3117c87..2891f82 100644 --- a/src/Autoredi.Generators/Diagnostics.cs +++ b/src/Autoredi.Generators/Diagnostics.cs @@ -157,4 +157,46 @@ public static class Diagnostics category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor InvalidGroupNameDescriptor = new( + id: "AUTOREDI018", + title: "Invalid group name", + messageFormat: + "The group name '{0}' contains invalid characters. Group names must be valid C# identifiers (letters, digits, underscore) and cannot be reserved keywords.", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor InvalidPriorityTypeDescriptor = new( + id: "AUTOREDI019", + title: "Invalid priority type", + messageFormat: "The priority value for class '{0}' must be an integer.", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor PriorityOutOfRangeDescriptor = new( + id: "AUTOREDI020", + title: "Priority value out of range", + messageFormat: + "The priority value {1} for class '{0}' is out of range. Priority must be between 0 and 999 (inclusive).", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor GroupNameTooLongDescriptor = new( + id: "AUTOREDI021", + title: "Group name too long", + messageFormat: "The group name '{0}' exceeds the maximum length of 100 characters.", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor GroupNameIsKeywordDescriptor = new( + id: "AUTOREDI022", + title: "Group name is a reserved keyword", + messageFormat: "The group name '{0}' is a C# reserved keyword and cannot be used as a group name.", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); } diff --git a/src/Autoredi/Attributes/AutorediAttribute.cs b/src/Autoredi/Attributes/AutorediAttribute.cs index 8f685f2..656b163 100644 --- a/src/Autoredi/Attributes/AutorediAttribute.cs +++ b/src/Autoredi/Attributes/AutorediAttribute.cs @@ -8,11 +8,15 @@ namespace Autoredi.Attributes; /// The service lifetime (e.g., Transient, Scoped, Singleton). /// The optional interface to register the class against. /// The optional key for named registrations when multiple implementations of the same interface exist. +/// The optional group name for organizing services (default: null/Default). +/// The optional registration priority (higher values registered first, default: 0). [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] public sealed class AutorediAttribute( ServiceLifetime lifetime = ServiceLifetime.Transient, Type? interfaceType = null, - string? serviceKey = null + string? serviceKey = null, + string? group = null, + int priority = 0 ) : Attribute { /// @@ -29,4 +33,14 @@ public sealed class AutorediAttribute( /// Gets the service key for named registrations, if specified. /// public string? ServiceKey => serviceKey; + + /// + /// Gets the group name for organizing services. + /// + public string? Group => group; + + /// + /// Gets the registration priority (higher values are registered first). + /// + public int Priority => priority; } diff --git a/tests/Autoredi.Tests/Features/GroupedRegistrationTests.cs b/tests/Autoredi.Tests/Features/GroupedRegistrationTests.cs new file mode 100644 index 0000000..f62d308 --- /dev/null +++ b/tests/Autoredi.Tests/Features/GroupedRegistrationTests.cs @@ -0,0 +1,112 @@ +using Autoredi.Tests.Autoredi; +using Autoredi.Tests.Fixtures; +using Microsoft.Extensions.DependencyInjection; + +namespace Autoredi.Tests.Features; + +public class GroupedRegistrationTests +{ + [Test] + public async Task AddAutorediServicesFirebase_OnlyRegistersFirebaseServices() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddAutorediServicesFirebase(); + var provider = services.BuildServiceProvider(); + + // Assert + // Should have Firebase services + await Assert.That(provider.GetService()).IsNotNull(); + await Assert.That(provider.GetService()).IsNotNull(); + await Assert.That(provider.GetService()).IsNotNull(); + + // Should NOT have Account or Default services + await Assert.That(provider.GetService()).IsNull(); + await Assert.That(provider.GetService()).IsNull(); + } + + [Test] + public async Task AddAutorediServicesAccount_OnlyRegistersAccountServices() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddAutorediServicesAccount(); + var provider = services.BuildServiceProvider(); + + // Assert + await Assert.That(provider.GetService()).IsNotNull(); + + // Should NOT have others + await Assert.That(provider.GetService()).IsNull(); + await Assert.That(provider.GetService()).IsNull(); + } + + [Test] + public async Task AddAutorediServicesDefault_OnlyRegistersUngroupedServices() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddAutorediServicesDefault(); + var provider = services.BuildServiceProvider(); + + // Assert + await Assert.That(provider.GetService()).IsNotNull(); + + // Should NOT have others + await Assert.That(provider.GetService()).IsNull(); + await Assert.That(provider.GetService()).IsNull(); + } + + [Test] + public async Task AddAutorediServices_RegistersAllGroups() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddAutorediServices(); + var provider = services.BuildServiceProvider(); + + // Assert - All services available + await Assert.That(provider.GetService()).IsNotNull(); + await Assert.That(provider.GetService()).IsNotNull(); + await Assert.That(provider.GetService()).IsNotNull(); + } + + [Test] + public async Task Priority_RegistersServicesInDescendingOrder() + { + // We verify order by inspecting the ServiceCollection directly. + // Expected order for Firebase group: + // 1. FirebaseConfig (Priority 100) + // 2. FirebaseRepo (Priority 10) + // 3. FirebaseLogger (Priority 0) + + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddAutorediServicesFirebase(); + + // Assert + var descriptors = services.ToList(); + + // Find indices + var configIndex = descriptors.FindIndex(d => d.ServiceType == typeof(FirebaseConfig)); + var repoIndex = descriptors.FindIndex(d => d.ServiceType == typeof(FirebaseRepo)); + var loggerIndex = descriptors.FindIndex(d => d.ServiceType == typeof(FirebaseLogger)); + + await Assert.That(configIndex).IsGreaterThanOrEqualTo(0); + await Assert.That(repoIndex).IsGreaterThanOrEqualTo(0); + await Assert.That(loggerIndex).IsGreaterThanOrEqualTo(0); + + await Assert.That(configIndex).IsLessThan(repoIndex); + await Assert.That(repoIndex).IsLessThan(loggerIndex); + } +} \ No newline at end of file diff --git a/tests/Autoredi.Tests/Fixtures/GroupedTestServices.cs b/tests/Autoredi.Tests/Fixtures/GroupedTestServices.cs new file mode 100644 index 0000000..82495d5 --- /dev/null +++ b/tests/Autoredi.Tests/Fixtures/GroupedTestServices.cs @@ -0,0 +1,27 @@ +using Autoredi.Attributes; +using Microsoft.Extensions.DependencyInjection; + +namespace Autoredi.Tests.Fixtures; + +// --- Firebase Group --- + +[Autoredi(ServiceLifetime.Singleton, group: "Firebase", priority: 100)] +public class FirebaseConfig; + +[Autoredi(ServiceLifetime.Transient, group: "Firebase", priority: 10)] +public class FirebaseRepo; + +[Autoredi(ServiceLifetime.Singleton, group: "Firebase")] // Default priority 0 +public class FirebaseLogger; + + +// --- Account Group --- + +[Autoredi(ServiceLifetime.Scoped, group: "Account")] +public class AccountService; + + +// --- Mixed / No Group --- + +[Autoredi(ServiceLifetime.Transient)] +public class DefaultService; From bfc7cf39c81d2e669ac9717f7d9b3a69d8760301 Mon Sep 17 00:00:00 2001 From: Koddek Date: Thu, 29 Jan 2026 11:18:39 -0400 Subject: [PATCH 2/4] chore(version): bump package version to 0.2.0 Update Autoredi.csproj version from 0.1.10 to 0.2.0 to reflect new minor release including service grouping and priority ordering features. --- src/Autoredi/Autoredi.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Autoredi/Autoredi.csproj b/src/Autoredi/Autoredi.csproj index a42fbaa..a11f975 100644 --- a/src/Autoredi/Autoredi.csproj +++ b/src/Autoredi/Autoredi.csproj @@ -1,9 +1,9 @@ - + net10.0 Autoredi - 0.1.10 + 0.2.0 true true From 8454a431374951e0c6cdb69c499bab0df9a4c858 Mon Sep 17 00:00:00 2001 From: Koddek Date: Fri, 30 Jan 2026 11:35:09 -0400 Subject: [PATCH 3/4] refactored to use flowgen --- .../Autoredi.Generators.csproj | 18 +- src/Autoredi.Generators/AutorediGenerator.cs | 662 +----------------- src/Autoredi.Generators/Diagnostics.cs | 53 +- .../Extraction/AutorediSourceBuilder.cs | 119 ++++ .../ServiceRegistrationExtractor.cs | 85 +++ src/Autoredi.Generators/GlobalUsings.cs | 12 + .../Models/ServiceRegistration.cs | 25 + src/Autoredi.Generators/Polyfills.cs | 12 + tests/Autoredi.Tests/Autoredi.Tests.csproj | 2 +- 9 files changed, 305 insertions(+), 683 deletions(-) create mode 100644 src/Autoredi.Generators/Extraction/AutorediSourceBuilder.cs create mode 100644 src/Autoredi.Generators/Extraction/ServiceRegistrationExtractor.cs create mode 100644 src/Autoredi.Generators/GlobalUsings.cs create mode 100644 src/Autoredi.Generators/Models/ServiceRegistration.cs create mode 100644 src/Autoredi.Generators/Polyfills.cs diff --git a/src/Autoredi.Generators/Autoredi.Generators.csproj b/src/Autoredi.Generators/Autoredi.Generators.csproj index 960036c..99670b6 100644 --- a/src/Autoredi.Generators/Autoredi.Generators.csproj +++ b/src/Autoredi.Generators/Autoredi.Generators.csproj @@ -1,11 +1,10 @@ - + netstandard2.0 false true Autoredi.Generators - false false true @@ -13,14 +12,25 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + + $(GetTargetPathDependsOn);GetDependencyTargetPaths + + + + + + + + diff --git a/src/Autoredi.Generators/AutorediGenerator.cs b/src/Autoredi.Generators/AutorediGenerator.cs index f2817a5..c8fa0d3 100644 --- a/src/Autoredi.Generators/AutorediGenerator.cs +++ b/src/Autoredi.Generators/AutorediGenerator.cs @@ -1,657 +1,31 @@ -using System.Collections.Immutable; -using System.Text; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Text; -using static Autoredi.Generators.Diagnostics; -using static Autoredi.Generators.Names; - namespace Autoredi.Generators; -[Generator(LanguageNames.CSharp)] +/// +/// Incremental source generator for Autoredi. +/// +[Generator] public sealed class AutorediGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { -#if DEBUG - if (!System.Diagnostics.Debugger.IsAttached) - { - System.Diagnostics.Debugger.Launch(); - } -#endif - var classDeclarations = context.SyntaxProvider - .CreateSyntaxProvider( - predicate: static (node, _) => IsCandidateTypeDeclaration(node), - transform: static (gsc, _) => (ClassDeclarationSyntax)gsc.Node); - - var compilationAndClasses = context.CompilationProvider.Combine(classDeclarations.Collect()); - - context.RegisterSourceOutput(compilationAndClasses, - static (spc, source) => Execute(source.Left, source.Right, spc)); - } - - private static bool IsCandidateTypeDeclaration(SyntaxNode node) - { - if (node is not ClassDeclarationSyntax classDecl) - { - return false; - } - - if (classDecl.AttributeLists.Count == 0) - { - return false; - } - - return classDecl.AttributeLists - .Any(attrList => attrList.Attributes - .Select(attr => attr.Name.ToString()) - .Any(name => name == AutorediAttName || - name == AutorediAttNameWithPostfix || - name.StartsWith(AutorediAttName + "<"))); - } - - private static void Execute(Compilation compilation, ImmutableArray classes, - SourceProductionContext context) - { - // Check 1: Ensure classes are provided - if (classes.IsDefaultOrEmpty) - { - context.ReportDiagnostic(Diagnostic.Create(NoClassesFoundDescriptor, null)); - return; - } - - // Check 2: Get the ServiceLifetime type symbol - var serviceLifetimeType = - compilation.GetTypeByMetadataName("Microsoft.Extensions.DependencyInjection.ServiceLifetime"); - if (serviceLifetimeType == null) - { - context.ReportDiagnostic(Diagnostic.Create(MissingServiceLifetimeTypeDescriptor, null)); - return; - } - - // Check 3: Verify ServiceLifetime is an enum - if (serviceLifetimeType.TypeKind != TypeKind.Enum) - { - context.ReportDiagnostic(Diagnostic.Create(InvalidServiceLifetimeTypeDescriptor, null)); - return; - } - - // Check 4: Get valid ServiceLifetime values - var validLifetimeValues = serviceLifetimeType.GetMembers() - .OfType() - .Where(f => f.HasConstantValue) - .Select(f => (int)f.ConstantValue!) - .ToImmutableHashSet(); - - if (validLifetimeValues.Count == 0) - { - context.ReportDiagnostic(Diagnostic.Create(NoValidLifetimeValuesDescriptor, null)); - return; - } - - // Check 5: Get the attribute type symbol - var attributeType = compilation.GetTypeByMetadataName(AutorediAttFullName); - if (attributeType == null) - { - context.ReportDiagnostic(Diagnostic.Create(MissingAttributeTypeDescriptor, null)); - return; - } - - // Determine target namespace from project name - var projectName = compilation.AssemblyName ?? "Autoredi.Generated"; - var targetNamespace = $"{projectName}.Autoredi"; - - // Collect referenced assemblies with Autoredi attribute or namespace - var visitedAssemblies = new HashSet(StringComparer.OrdinalIgnoreCase) { projectName }; - var referencedAutorediNamespaces = - GetReferencedAutorediNamespaces(compilation, projectName, attributeType, visitedAssemblies, context); - - var registrations = new List(); - - foreach (var classDecl in classes) - { - // Check 6: Get class symbol - var semanticModel = compilation.GetSemanticModel(classDecl.SyntaxTree); - var classSymbol = semanticModel.GetDeclaredSymbol(classDecl) as ITypeSymbol; - if (classSymbol == null) - { - context.ReportDiagnostic(Diagnostic.Create(InvalidClassSymbolDescriptor, classDecl.GetLocation(), - classDecl.Identifier.Text)); - continue; - } - - // Check 7: Find AutorediAttribute - var attributeData = classSymbol.GetAttributes() - .FirstOrDefault(ad => SymbolEqualityComparer.Default.Equals(ad.AttributeClass, attributeType)); - if (attributeData == null) + Flow.Create(context) + .ForAttributeWithMetadataName(Names.AutorediAttFullName) + .Select(ServiceRegistrationExtractor.Extract) + .Collect() + .EmitAll((spc, registrations) => { - context.ReportDiagnostic(Diagnostic.Create(MissingAttributeOnClassDescriptor, classDecl.GetLocation(), - classSymbol.Name)); - continue; - } - - // Check 8: Validate constructor arguments - if (attributeData.ConstructorArguments.Length < 3 || attributeData.ConstructorArguments.Length > 5) - { - context.ReportDiagnostic(Diagnostic.Create(InvalidAttributeArgumentsDescriptor, classDecl.GetLocation(), - classSymbol.Name)); - continue; - } - - var lifetimeArg = attributeData.ConstructorArguments[0]; - var interfaceTypeArg = attributeData.ConstructorArguments[1]; - var serviceKeyArg = attributeData.ConstructorArguments[2]; - - // Extract group (argument 3) - string? group = null; - if (attributeData.ConstructorArguments.Length >= 4) - { - var groupArg = attributeData.ConstructorArguments[3]; - if (!groupArg.IsNull && groupArg.Value is string groupValue) - { - group = ValidateGroupName(groupValue, classDecl.GetLocation(), context); - } - } - - // Extract priority (argument 4) - int priority = 0; - if (attributeData.ConstructorArguments.Length >= 5) - { - var priorityArg = attributeData.ConstructorArguments[4]; - if (!priorityArg.IsNull) + if (registrations.IsDefaultOrEmpty) { - if (priorityArg.Value is int priorityValue) - { - if (priorityValue < 0 || priorityValue > 999) - { - context.ReportDiagnostic(Diagnostic.Create( - PriorityOutOfRangeDescriptor, - classDecl.GetLocation(), - classSymbol.Name, - priorityValue)); - continue; - } - - priority = priorityValue; - } - else - { - context.ReportDiagnostic(Diagnostic.Create( - InvalidPriorityTypeDescriptor, - classDecl.GetLocation(), - classSymbol.Name)); - continue; - } + return; } - } - - // Check 9: Validate lifetime argument type - if (lifetimeArg.Value is not int lifetimeInt) - { - context.ReportDiagnostic(Diagnostic.Create(InvalidLifetimeTypeDescriptor, classDecl.GetLocation(), - classSymbol.Name)); - continue; - } - - // Check 10: Validate lifetime value - if (!validLifetimeValues.Contains(lifetimeInt)) - { - context.ReportDiagnostic(Diagnostic.Create(InvalidLifetimeDescriptor, classDecl.GetLocation(), - classSymbol.Name)); - continue; - } - - // Check 11: Validate interface type - string? interfaceName = null; - if (interfaceTypeArg is { IsNull: false, Value: ITypeSymbol interfaceSymbol }) - { - if (interfaceSymbol.TypeKind != TypeKind.Interface) - { - context.ReportDiagnostic(Diagnostic.Create(InvalidInterfaceTypeDescriptor, classDecl.GetLocation(), - classSymbol.Name, interfaceSymbol.Name)); - continue; - } - - interfaceName = interfaceSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - } - - // Check 12: Validate service key type - string? serviceKey = null; - if (!serviceKeyArg.IsNull) - { - if (serviceKeyArg.Value is not string keyValue) - { - context.ReportDiagnostic(Diagnostic.Create(InvalidServiceKeyTypeDescriptor, classDecl.GetLocation(), - classSymbol.Name)); - continue; - } - - serviceKey = keyValue; - } - - var className = classSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - - registrations.Add(new RegistrationInfo - { - ClassName = className, - Lifetime = lifetimeInt, - InterfaceName = interfaceName, - ServiceKey = serviceKey, - Group = group, - Priority = priority, - Location = classDecl.GetLocation() - }); - } - - // Check 13: Validate for duplicate class registrations - var duplicateClasses = registrations.GroupBy(r => r.ClassName) - .Where(g => g.Count() > 1) - .Select(g => (ClassName: g.Key, Locations: g.Select(r => r.Location).ToList())) - .ToList(); - - foreach (var (className, locations) in duplicateClasses) - { - foreach (var location in locations) - { - context.ReportDiagnostic(Diagnostic.Create(DuplicateClassDescriptor, location, className)); - } - } - - // Check 14: Validate non-keyed registrations - var registrationsWithoutKey = registrations.Where(r => r.ServiceKey == null).ToList(); - var duplicateServiceTypesWithoutKey = registrationsWithoutKey - .GroupBy(r => r.InterfaceName ?? r.ClassName) - .Where(g => g.Count() > 1) - .Select(g => (ServiceType: g.Key, Locations: g.Select(r => r.Location).ToList())) - .ToList(); - - foreach (var (serviceType, locations) in duplicateServiceTypesWithoutKey) - { - foreach (var location in locations) - { - context.ReportDiagnostic(Diagnostic.Create(DuplicateRegistrationWithoutKeyDescriptor, location, - serviceType)); - } - } - - // Check 15: Validate keyed registrations - var registrationsWithKey = registrations.Where(r => r.ServiceKey != null).ToList(); - var duplicateKeyedRegistrations = registrationsWithKey - .GroupBy(r => (ServiceType: r.InterfaceName ?? r.ClassName, r.ServiceKey)) - .Where(g => g.Count() > 1) - .Select(g => (g.Key.ServiceType, g.Key.ServiceKey, Locations: g.Select(r => r.Location).ToList())) - .ToList(); - - foreach (var (serviceType, key, locations) in duplicateKeyedRegistrations) - { - foreach (var location in locations) - { - context.ReportDiagnostic(Diagnostic.Create(DuplicateRegistrationWithKeyDescriptor, location, - serviceType)); - } - } - - // Check 16: Validate interfaces with multiple implementations - var interfaceUsage = registrations - .Where(r => r.InterfaceName != null) - .GroupBy(r => r.InterfaceName) - .Where(g => g.Count() > 1) - .ToList(); - - foreach (var group in interfaceUsage) - { - var missingKeyRegistrations = group.Where(r => r.ServiceKey == null).ToList(); - foreach (var reg in missingKeyRegistrations) - { - context.ReportDiagnostic(Diagnostic.Create(MissingAliasForMultipleInterfaceDescriptor, reg.Location, - group.Key)); - } - } - - // Stop if any validation errors exist - if (duplicateClasses.Any() || duplicateServiceTypesWithoutKey.Any() || - duplicateKeyedRegistrations.Any() || interfaceUsage.Any(g => g.Any(r => r.ServiceKey == null))) - { - return; - } - - // Check 17: Verify IServiceCollection exists for code generation - var serviceCollectionType = - compilation.GetTypeByMetadataName("Microsoft.Extensions.DependencyInjection.IServiceCollection"); - if (serviceCollectionType == null) - { - context.ReportDiagnostic(Diagnostic.Create(MissingServiceCollectionTypeDescriptor, null)); - return; - } - - // Sort registrations - Removed as we do per-group sorting now - - // Identify all unique groups (local + referenced) - var localGroups = new HashSet( - registrations - .Select(r => string.IsNullOrWhiteSpace(r.Group) ? "Default" : r.Group!) - .Distinct()); - - var allGroups = new HashSet(localGroups); - foreach (var refAsm in referencedAutorediNamespaces) - { - foreach (var group in refAsm.Groups) - { - allGroups.Add(group); - } - } - // Group local registrations - var groupedRegistrations = registrations - .GroupBy(r => string.IsNullOrWhiteSpace(r.Group) ? "Default" : r.Group) - .ToDictionary(g => g.Key!, g => g.ToList()); - - // Generate the extension method - var sb = new StringBuilder(); - sb.AppendLine("// "); - sb.AppendLine(); - sb.AppendLine("using Microsoft.Extensions.DependencyInjection;"); - sb.AppendLine(); - sb.AppendLine($"namespace {targetNamespace};"); - sb.AppendLine(); - sb.AppendLine("public static class AutorediExtensions"); - sb.AppendLine("{"); - sb.AppendLine(" /// "); - sb.AppendLine(" /// Registers all Autoredi services from this assembly and referenced assemblies."); - sb.AppendLine(" /// "); - sb.AppendLine(" public static IServiceCollection AddAutorediServices(this IServiceCollection services)"); - sb.AppendLine(" {"); - - // Call AddAutorediServices for referenced projects using fully qualified names - foreach (var refAsm in referencedAutorediNamespaces) - { - var assemblyName = refAsm.Namespace.Substring(0, refAsm.Namespace.Length - ".Autoredi".Length); - sb.AppendLine($" {refAsm.Namespace}.AutorediExtensions.AddAutorediServices(services); // {assemblyName}"); - } - - if (!referencedAutorediNamespaces.IsEmpty) - { - sb.AppendLine(); - } - - // Call all local group methods - foreach (var group in localGroups.OrderBy(g => g)) - { - var groupMethodName = $"AddAutorediServices{group}"; - sb.AppendLine($" services.{groupMethodName}();"); - } - - sb.AppendLine(); - sb.AppendLine(" return services;"); - sb.AppendLine(" }"); - - // Generate group-specific methods for ALL known groups (local + referenced) - foreach (var group in allGroups.OrderBy(g => g)) - { - sb.AppendLine(); - var groupMethodName = $"AddAutorediServices{group}"; - - sb.AppendLine($" /// "); - sb.AppendLine($" /// Registers Autoredi services from the '{group}' group."); - sb.AppendLine($" /// "); - sb.AppendLine($" public static IServiceCollection {groupMethodName}(this IServiceCollection services)"); - sb.AppendLine(" {"); - - // 1. Call referenced assemblies' group methods if they exist - foreach (var refAsm in referencedAutorediNamespaces) - { - if (refAsm.Groups.Contains(group)) - { - sb.AppendLine($" {refAsm.Namespace}.AutorediExtensions.{groupMethodName}(services);"); - } - } - - // 2. Register local services for this group - if (groupedRegistrations.TryGetValue(group, out var groupRegs)) - { - // Sort within group: Priority DESC, then has-interface, then name - var sortedInGroup = groupRegs - .OrderByDescending(r => r.Priority) - .ThenBy(r => r.InterfaceName == null ? 0 : 1) - .ThenBy(r => r.InterfaceName ?? r.ClassName, StringComparer.OrdinalIgnoreCase) - .ToList(); - - foreach (var reg in sortedInGroup) - { - var lifetimeMethod = reg.Lifetime switch - { - 0 => "Singleton", - 1 => "Scoped", - 2 => "Transient", - _ => throw new InvalidOperationException("Invalid lifetime") // Unreachable - }; - - if (reg.ServiceKey == null) - { - if (reg.InterfaceName != null) - { - sb.AppendLine($" services.Add{lifetimeMethod}<{reg.InterfaceName}, {reg.ClassName}>();"); - } - else - { - sb.AppendLine($" services.Add{lifetimeMethod}<{reg.ClassName}>();"); - } - } - else - { - if (reg.InterfaceName != null) - { - sb.AppendLine( - $" services.AddKeyed{lifetimeMethod}<{reg.InterfaceName}, {reg.ClassName}>(\"{reg.ServiceKey}\");"); - } - else - { - sb.AppendLine($" services.AddKeyed{lifetimeMethod}<{reg.ClassName}>(\"{reg.ServiceKey}\");"); - } - } - } - } - - sb.AppendLine(); - sb.AppendLine(" return services;"); - sb.AppendLine(" }"); - } - - sb.AppendLine("}"); - - var fileName = $"{targetNamespace}.AutorediExtensions.cs"; - context.AddSource(fileName, SourceText.From(sb.ToString(), Encoding.UTF8)); - } - - private static ImmutableArray GetReferencedAutorediNamespaces( - Compilation compilation, - string projectName, - INamedTypeSymbol attributeType, - HashSet visitedAssemblies, - SourceProductionContext context) - { - var result = new List(); - - foreach (var reference in compilation.References) - { - if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol asm || - asm.Name == projectName || - string.IsNullOrEmpty(asm.Name) || - asm.Name.StartsWith("System", StringComparison.OrdinalIgnoreCase) || - asm.Name.StartsWith("Microsoft", StringComparison.OrdinalIgnoreCase) || - asm.Name.Equals("mscorlib", StringComparison.OrdinalIgnoreCase) || - asm.Name.Equals("netstandard", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - // Skip if already visited to prevent circular dependencies - if (!visitedAssemblies.Add(asm.Name)) - { - context.ReportDiagnostic(Diagnostic.Create( - new DiagnosticDescriptor( - "AUTOREDI002", - "Circular Dependency Detected", - $"Circular reference detected for assembly '{asm.Name}'. Skipping to prevent infinite recursion.", - "Autoredi", - DiagnosticSeverity.Warning, - isEnabledByDefault: true), - null)); - continue; - } - - // Check for Autoredi attribute or namespace - if (HasAutorediAttributeOrNamespace(compilation, asm, attributeType)) - { - var groups = GetAutorediGroups(compilation, asm); - result.Add(new ReferencedAssemblyAutorediInfo - { - Namespace = $"{asm.Name}.Autoredi", - Groups = groups - }); - } - - visitedAssemblies.Remove(asm.Name); // Allow re-visiting in other contexts - } - - // De-duplicate based on Namespace - return result.GroupBy(x => x.Namespace) - .Select(g => g.First()) - .ToImmutableArray(); - } - - private static bool HasAutorediAttributeOrNamespace(Compilation compilation, IAssemblySymbol assembly, - INamedTypeSymbol attributeType) - { - // Check for Autoredi namespace (e.g., .Autoredi) - if (compilation.GetTypeByMetadataName($"{assembly.Name}.Autoredi.AutorediExtensions") != null) - { - return true; - } - - // Check for Autoredi attribute on any type - var types = GetAllTypes(assembly.GlobalNamespace); - return types.Any(type => type.GetAttributes() - .Any(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, attributeType))); - } - - private static IEnumerable GetAllTypes(INamespaceSymbol namespaceSymbol) - { - foreach (var type in namespaceSymbol.GetTypeMembers()) - { - yield return type; - } - - foreach (var nestedNamespace in namespaceSymbol.GetNamespaceMembers()) - { - foreach (var type in GetAllTypes(nestedNamespace)) - { - yield return type; - } - } - } - - private static string? ValidateGroupName(string groupName, Location location, SourceProductionContext context) - { - if (string.IsNullOrWhiteSpace(groupName)) - { - return null; // Treat as Default group - } - - // Check length - if (groupName.Length > 100) - { - context.ReportDiagnostic(Diagnostic.Create( - GroupNameTooLongDescriptor, location, groupName)); - return null; - } - - // Check valid C# identifier - if (!IsValidIdentifier(groupName)) - { - context.ReportDiagnostic(Diagnostic.Create( - InvalidGroupNameDescriptor, location, groupName)); - return null; - } - - // Check against C# keywords - var keywords = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char", - "checked", "class", "const", "continue", "decimal", "default", "delegate", - "do", "double", "else", "enum", "event", "explicit", "extern", "false", - "finally", "fixed", "float", "for", "foreach", "goto", "if", "implicit", - "in", "int", "interface", "internal", "is", "lock", "long", "namespace", - "new", "null", "object", "operator", "out", "override", "params", "private", - "protected", "public", "readonly", "ref", "return", "sbyte", "sealed", - "short", "sizeof", "stackalloc", "static", "string", "struct", "switch", - "this", "throw", "true", "try", "typeof", "uint", "ulong", "unchecked", - "unsafe", "ushort", "using", "virtual", "void", "volatile", "while" - }; - - if (keywords.Contains(groupName)) - { - context.ReportDiagnostic(Diagnostic.Create( - GroupNameIsKeywordDescriptor, location, groupName)); - return null; - } - - return groupName; - } - - private static bool IsValidIdentifier(string identifier) - { - if (string.IsNullOrEmpty(identifier)) return false; - - if (!char.IsLetter(identifier[0]) && identifier[0] != '_') return false; - - for (int i = 1; i < identifier.Length; i++) - { - if (!char.IsLetterOrDigit(identifier[i]) && identifier[i] != '_') return false; - } - - return true; - } - - private sealed record RegistrationInfo - { - public string ClassName { get; set; } = string.Empty; - public int Lifetime { get; set; } - public string? InterfaceName { get; set; } - public string? ServiceKey { get; set; } - public string? Group { get; set; } - public int Priority { get; set; } - public Location? Location { get; set; } - } - - private sealed record ReferencedAssemblyAutorediInfo - { - public string Namespace { get; set; } = string.Empty; - public ImmutableArray Groups { get; set; } = ImmutableArray.Empty; - } - - private static ImmutableArray GetAutorediGroups(Compilation compilation, IAssemblySymbol assembly) - { - var autorediExtensionsType = compilation.GetTypeByMetadataName($"{assembly.Name}.Autoredi.AutorediExtensions"); - if (autorediExtensionsType == null) - { - return ImmutableArray.Empty; - } - - var groups = new HashSet(); - foreach (var member in autorediExtensionsType.GetMembers()) - { - if (member is IMethodSymbol method && - method.IsStatic && - method.Name.StartsWith("AddAutorediServices") && - method.Name.Length > "AddAutorediServices".Length) - { - var groupName = method.Name.Substring("AddAutorediServices".Length); - groups.Add(groupName); - } - } + // The consumer projects follow [AssemblyName].Autoredi pattern. + var targetNamespace = registrations[0].AssemblyName + ".Autoredi"; - return groups.ToImmutableArray(); + var source = AutorediSourceBuilder.Generate(registrations, targetNamespace); + spc.AddSource("AutorediServices.g.cs", source); + }) + .Build() + .Initialize(context); } } diff --git a/src/Autoredi.Generators/Diagnostics.cs b/src/Autoredi.Generators/Diagnostics.cs index 2891f82..b33bf44 100644 --- a/src/Autoredi.Generators/Diagnostics.cs +++ b/src/Autoredi.Generators/Diagnostics.cs @@ -12,8 +12,7 @@ public static class Diagnostics public static readonly DiagnosticDescriptor NoClassesFoundDescriptor = new( id: "AUTOREDI001", title: "No classes found with AutorediAttribute", - messageFormat: - "No classes with the AutorediAttribute were found. Ensure classes are decorated with [Autoredi].", + messageFormat: "No classes with the AutorediAttribute were found. Ensure classes are decorated with [Autoredi].", category: Category, defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true); @@ -21,8 +20,7 @@ public static class Diagnostics public static readonly DiagnosticDescriptor MissingServiceLifetimeTypeDescriptor = new( id: "AUTOREDI002", title: "Missing ServiceLifetime type", - messageFormat: - "The Microsoft.Extensions.DependencyInjection.ServiceLifetime enum was not found. Ensure the project references Microsoft.Extensions.DependencyInjection.Abstractions.", + messageFormat: "The Microsoft.Extensions.DependencyInjection.ServiceLifetime enum was not found. Ensure the project references Microsoft.Extensions.DependencyInjection.Abstractions.", category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -30,8 +28,7 @@ public static class Diagnostics public static readonly DiagnosticDescriptor InvalidServiceLifetimeTypeDescriptor = new( id: "AUTOREDI003", title: "Invalid ServiceLifetime type", - messageFormat: - "The ServiceLifetime type is not an enum. Ensure the correct Microsoft.Extensions.DependencyInjection.Abstractions assembly is referenced.", + messageFormat: "The ServiceLifetime type is not an enum. Ensure the correct Microsoft.Extensions.DependencyInjection.Abstractions assembly is referenced.", category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -39,8 +36,7 @@ public static class Diagnostics public static readonly DiagnosticDescriptor NoValidLifetimeValuesDescriptor = new( id: "AUTOREDI004", title: "No valid ServiceLifetime values", - messageFormat: - "No valid values were found in the ServiceLifetime enum. Ensure the Microsoft.Extensions.DependencyInjection.Abstractions assembly is correct.", + messageFormat: "No valid values were found in the ServiceLifetime enum. Ensure the Microsoft.Extensions.DependencyInjection.Abstractions assembly is correct.", category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -64,7 +60,7 @@ public static class Diagnostics public static readonly DiagnosticDescriptor MissingAttributeOnClassDescriptor = new( id: "AUTOREDI007", title: "Missing AutorediAttribute on class", - messageFormat: "The class '{0}' was identified as a candidate but lacks the AutorediAttribute.", + messageFormat: "Class '{0}' is decorated with [Autoredi] but does not implement interface '{1}'", category: Category, defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true); @@ -72,8 +68,7 @@ public static class Diagnostics public static readonly DiagnosticDescriptor InvalidAttributeArgumentsDescriptor = new( id: "AUTOREDI008", title: "Invalid AutorediAttribute arguments", - messageFormat: - "The AutorediAttribute on class '{0}' has an invalid number of arguments. Expected: lifetime, interfaceType, serviceKey.", + messageFormat: "The AutorediAttribute on class '{0}' has an invalid number of arguments. Expected: lifetime, interfaceType, serviceKey, group, priority.", category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -81,8 +76,7 @@ public static class Diagnostics public static readonly DiagnosticDescriptor InvalidLifetimeTypeDescriptor = new( id: "AUTOREDI009", title: "Invalid lifetime argument type", - messageFormat: - "The lifetime argument for class '{0}' is not an integer. It must be a valid ServiceLifetime value.", + messageFormat: "The lifetime argument for class '{0}' is not an integer. It must be a valid ServiceLifetime value.", category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -90,8 +84,7 @@ public static class Diagnostics public static readonly DiagnosticDescriptor InvalidLifetimeDescriptor = new( id: "AUTOREDI010", title: "Invalid service lifetime specified", - messageFormat: - "The service lifetime specified for class '{0}' is invalid. Use a valid ServiceLifetime value (Transient, Scoped, or Singleton).", + messageFormat: "The service lifetime specified for class '{0}' is invalid. Use a valid ServiceLifetime value (Transient, Scoped, or Singleton).", category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -99,8 +92,7 @@ public static class Diagnostics public static readonly DiagnosticDescriptor InvalidInterfaceTypeDescriptor = new( id: "AUTOREDI011", title: "Invalid interface type", - messageFormat: - "The interface type specified for class '{0}' ('{1}') is not an interface. Specify a valid interface type.", + messageFormat: "The interface type specified for class '{0}' ('{1}') is not an interface. Specify a valid interface type.", category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -116,8 +108,7 @@ public static class Diagnostics public static readonly DiagnosticDescriptor DuplicateClassDescriptor = new( id: "AUTOREDI013", title: "Duplicate class registration", - messageFormat: - "The class '{0}' is registered multiple times with the AutorediAttribute. Each class can only be registered once.", + messageFormat: "The class '{0}' is registered multiple times with the AutorediAttribute. Each class can only be registered once.", category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -125,8 +116,7 @@ public static class Diagnostics public static readonly DiagnosticDescriptor DuplicateRegistrationWithoutKeyDescriptor = new( id: "AUTOREDI014", title: "Duplicate registration without service key", - messageFormat: - "The service type '{0}' is registered multiple times without a service key. Use a unique service key for each registration of the same service type.", + messageFormat: "The service type '{0}' is registered multiple times without a service key. Use a unique service key for each registration of the same service type.", category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -134,8 +124,7 @@ public static class Diagnostics public static readonly DiagnosticDescriptor DuplicateRegistrationWithKeyDescriptor = new( id: "AUTOREDI015", title: "Duplicate registration with same service key", - messageFormat: - "The service type '{0}' is registered multiple times with the same service key '{1}'. Each (service type, key) combination must be unique.", + messageFormat: "The service type '{0}' is registered multiple times with the same service key '{1}'. Each (service type, key) combination must be unique.", category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -143,8 +132,7 @@ public static class Diagnostics public static readonly DiagnosticDescriptor MissingAliasForMultipleInterfaceDescriptor = new( id: "AUTOREDI016", title: "Missing service key for multiple interface implementations", - messageFormat: - "The interface '{0}' is implemented by multiple classes, but at least one registration lacks a service key. All registrations for the same interface must specify a unique service key.", + messageFormat: "The interface '{0}' is implemented by multiple classes, but at least one registration lacks a service key. All registrations for the same interface must specify a unique service key.", category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -152,8 +140,7 @@ public static class Diagnostics public static readonly DiagnosticDescriptor MissingServiceCollectionTypeDescriptor = new( id: "AUTOREDI017", title: "Missing IServiceCollection type", - messageFormat: - "The Microsoft.Extensions.DependencyInjection.IServiceCollection type was not found. Ensure the project references Microsoft.Extensions.DependencyInjection.Abstractions.", + messageFormat: "The Microsoft.Extensions.DependencyInjection.IServiceCollection type was not found. Ensure the project references Microsoft.Extensions.DependencyInjection.Abstractions.", category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -161,8 +148,7 @@ public static class Diagnostics public static readonly DiagnosticDescriptor InvalidGroupNameDescriptor = new( id: "AUTOREDI018", title: "Invalid group name", - messageFormat: - "The group name '{0}' contains invalid characters. Group names must be valid C# identifiers (letters, digits, underscore) and cannot be reserved keywords.", + messageFormat: "The group name '{0}' contains invalid characters. Group names must be valid C# identifiers (letters, digits, underscore) and cannot be reserved keywords.", category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -170,7 +156,7 @@ public static class Diagnostics public static readonly DiagnosticDescriptor InvalidPriorityTypeDescriptor = new( id: "AUTOREDI019", title: "Invalid priority type", - messageFormat: "The priority value for class '{0}' must be an integer.", + messageFormat: "The priority value for class '{0}' must be an integer", category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -178,8 +164,7 @@ public static class Diagnostics public static readonly DiagnosticDescriptor PriorityOutOfRangeDescriptor = new( id: "AUTOREDI020", title: "Priority value out of range", - messageFormat: - "The priority value {1} for class '{0}' is out of range. Priority must be between 0 and 999 (inclusive).", + messageFormat: "The priority value {1} for class '{0}' is out of range. Priority must be between 0 and 999 (inclusive).", category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -187,7 +172,7 @@ public static class Diagnostics public static readonly DiagnosticDescriptor GroupNameTooLongDescriptor = new( id: "AUTOREDI021", title: "Group name too long", - messageFormat: "The group name '{0}' exceeds the maximum length of 100 characters.", + messageFormat: "The group name '{0}' exceeds the maximum length of 100 characters", category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -195,7 +180,7 @@ public static class Diagnostics public static readonly DiagnosticDescriptor GroupNameIsKeywordDescriptor = new( id: "AUTOREDI022", title: "Group name is a reserved keyword", - messageFormat: "The group name '{0}' is a C# reserved keyword and cannot be used as a group name.", + messageFormat: "The group name '{0}' is a C# reserved keyword and cannot be used as a group name", category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); diff --git a/src/Autoredi.Generators/Extraction/AutorediSourceBuilder.cs b/src/Autoredi.Generators/Extraction/AutorediSourceBuilder.cs new file mode 100644 index 0000000..db4d09d --- /dev/null +++ b/src/Autoredi.Generators/Extraction/AutorediSourceBuilder.cs @@ -0,0 +1,119 @@ +namespace Autoredi.Generators.Extraction; + +/// +/// Generates the IServiceCollection extension methods for Autoredi registrations. +/// +internal static class AutorediSourceBuilder +{ + public static string Generate(ImmutableArray registrations, string @namespace) + { + var builder = new SourceBuilder(); + builder.AutoGenerated(); + builder.Using("Microsoft.Extensions.DependencyInjection"); + builder.Using("Microsoft.Extensions.DependencyInjection.Extensions"); + builder.Line(); + + builder.Namespace(@namespace); + + builder.Class("public static", "AutorediServiceCollectionExtensions"); + builder.Block(b => + { + GenerateMainExtensionMethod(b, registrations); + + var groups = registrations + .Where(r => !string.IsNullOrEmpty(r.Group)) + .Select(r => r.Group!) + .Distinct() + .OrderBy(g => g); + + foreach (var group in groups) + { + GenerateGroupExtensionMethod(b, registrations, group); + } + + GenerateDefaultGroupExtensionMethod(b, registrations); + }); + + return builder.ToString(); + } + + private static void GenerateMainExtensionMethod(SourceBuilder builder, ImmutableArray registrations) + { + builder.Method("public static", "IServiceCollection", "AddAutorediServices", "this IServiceCollection services"); + builder.Block(b => + { + var orderedRegistrations = registrations + .OrderByDescending(r => r.Priority) + .ThenBy(r => r.ImplementationType); + + foreach (var reg in orderedRegistrations) + { + GenerateRegistration(b, reg); + } + + b.Line("return services;"); + }); + builder.Line(); + } + + private static void GenerateGroupExtensionMethod(SourceBuilder builder, ImmutableArray registrations, string group) + { + builder.Method("public static", "IServiceCollection", $"AddAutorediServices{group}", "this IServiceCollection services"); + builder.Block(b => + { + var groupRegistrations = registrations + .Where(r => r.Group == group) + .OrderByDescending(r => r.Priority) + .ThenBy(r => r.ImplementationType); + + foreach (var reg in groupRegistrations) + { + GenerateRegistration(b, reg); + } + + b.Line("return services;"); + }); + builder.Line(); + } + + private static void GenerateDefaultGroupExtensionMethod(SourceBuilder builder, ImmutableArray registrations) + { + builder.Method("public static", "IServiceCollection", "AddAutorediServicesDefault", "this IServiceCollection services"); + builder.Block(b => + { + var defaultRegistrations = registrations + .Where(r => string.IsNullOrEmpty(r.Group)) + .OrderByDescending(r => r.Priority) + .ThenBy(r => r.ImplementationType); + + foreach (var reg in defaultRegistrations) + { + GenerateRegistration(b, reg); + } + + b.Line("return services;"); + }); + } + + private static void GenerateRegistration(SourceBuilder builder, ServiceRegistration reg) + { + var method = (reg.Lifetime, reg.ServiceKey) switch + { + (ServiceLifetime.Singleton, null) => "AddSingleton", + (ServiceLifetime.Scoped, null) => "AddScoped", + (ServiceLifetime.Transient, null) => "AddTransient", + (ServiceLifetime.Singleton, _) => "AddKeyedSingleton", + (ServiceLifetime.Scoped, _) => "AddKeyedScoped", + (ServiceLifetime.Transient, _) => "AddKeyedTransient", + _ => "AddTransient" + }; + + var typeArgs = string.IsNullOrEmpty(reg.InterfaceType) + ? $"<{reg.ImplementationType}>" + : $"<{reg.InterfaceType}, {reg.ImplementationType}>"; + + var keyArg = string.IsNullOrEmpty(reg.ServiceKey) ? "" : $"\"{reg.ServiceKey}\""; + + builder.Line($"services.{method}{typeArgs}({keyArg});"); + } +} diff --git a/src/Autoredi.Generators/Extraction/ServiceRegistrationExtractor.cs b/src/Autoredi.Generators/Extraction/ServiceRegistrationExtractor.cs new file mode 100644 index 0000000..b1ffdfe --- /dev/null +++ b/src/Autoredi.Generators/Extraction/ServiceRegistrationExtractor.cs @@ -0,0 +1,85 @@ +namespace Autoredi.Generators.Extraction; + +/// +/// Extracts ServiceRegistration models from attributes. +/// +internal static class ServiceRegistrationExtractor +{ + public static ServiceRegistration Extract(GeneratorAttributeSyntaxContext context, CancellationToken ct) + { + var symbol = (INamedTypeSymbol)context.TargetSymbol; + var attribute = context.Attributes.FirstOrDefault(); + + if (attribute == null) + { + return null!; + } + + var lifetime = ServiceLifetime.Transient; + string? interfaceType = null; + string? serviceKey = null; + string? group = null; + var priority = 0; + + // Extract from constructor arguments + if (attribute.ConstructorArguments.Length > 0) + { + lifetime = (ServiceLifetime)(int)attribute.ConstructorArguments[0].Value!; + } + + if (attribute.ConstructorArguments.Length > 1) + { + var interfaceTypeSymbol = attribute.ConstructorArguments[1].Value as INamedTypeSymbol; + interfaceType = interfaceTypeSymbol?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + } + + if (attribute.ConstructorArguments.Length > 2) + { + serviceKey = attribute.ConstructorArguments[2].Value?.ToString(); + } + + if (attribute.ConstructorArguments.Length > 3) + { + group = attribute.ConstructorArguments[3].Value?.ToString(); + } + + if (attribute.ConstructorArguments.Length > 4) + { + priority = (int)attribute.ConstructorArguments[4].Value!; + } + + // Extract from named arguments (overrides constructor arguments if present) + foreach (var namedArg in attribute.NamedArguments) + { + switch (namedArg.Key) + { + case "Lifetime": + lifetime = (ServiceLifetime)(int)namedArg.Value.Value!; + break; + case "InterfaceType": + interfaceType = (namedArg.Value.Value as INamedTypeSymbol)?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + break; + case "ServiceKey": + serviceKey = namedArg.Value.Value?.ToString(); + break; + case "Group": + group = namedArg.Value.Value?.ToString(); + break; + case "Priority": + priority = (int)namedArg.Value.Value!; + break; + } + } + + return new ServiceRegistration( + ImplementationType: symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + InterfaceType: interfaceType, + Lifetime: lifetime, + ServiceKey: serviceKey, + Group: group, + Priority: priority, + Namespace: symbol.ContainingNamespace.ToDisplayString(), + AssemblyName: symbol.ContainingAssembly.Name + ); + } +} diff --git a/src/Autoredi.Generators/GlobalUsings.cs b/src/Autoredi.Generators/GlobalUsings.cs new file mode 100644 index 0000000..0465871 --- /dev/null +++ b/src/Autoredi.Generators/GlobalUsings.cs @@ -0,0 +1,12 @@ +global using System; +global using System.Collections.Generic; +global using System.Collections.Immutable; +global using System.Linq; +global using System.Threading; +global using Flowgen; +global using Flowgen.Builders; +global using Microsoft.CodeAnalysis; +global using Microsoft.CodeAnalysis.CSharp; +global using Microsoft.CodeAnalysis.CSharp.Syntax; +global using Autoredi.Generators.Models; +global using Autoredi.Generators.Extraction; diff --git a/src/Autoredi.Generators/Models/ServiceRegistration.cs b/src/Autoredi.Generators/Models/ServiceRegistration.cs new file mode 100644 index 0000000..b8ccf6b --- /dev/null +++ b/src/Autoredi.Generators/Models/ServiceRegistration.cs @@ -0,0 +1,25 @@ +namespace Autoredi.Generators.Models; + +/// +/// Mirrors Microsoft.Extensions.DependencyInjection.ServiceLifetime to avoid external dependencies. +/// +public enum ServiceLifetime +{ + Singleton = 0, + Scoped = 1, + Transient = 2 +} + +/// +/// Represents a service registration extracted from the syntax tree. +/// +public sealed record ServiceRegistration( + string ImplementationType, + string? InterfaceType, + ServiceLifetime Lifetime, + string? ServiceKey, + string? Group, + int Priority, + string Namespace, + string AssemblyName +); diff --git a/src/Autoredi.Generators/Polyfills.cs b/src/Autoredi.Generators/Polyfills.cs new file mode 100644 index 0000000..5073779 --- /dev/null +++ b/src/Autoredi.Generators/Polyfills.cs @@ -0,0 +1,12 @@ +namespace System.Runtime.CompilerServices; + +#if !NET5_0_OR_GREATER +/// +/// Reserved to be used by the compiler for tracking metadata. +/// This class should not be used by developers in source code. +/// +[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] +internal static class IsExternalInit +{ +} +#endif diff --git a/tests/Autoredi.Tests/Autoredi.Tests.csproj b/tests/Autoredi.Tests/Autoredi.Tests.csproj index f51dc16..d864edb 100644 --- a/tests/Autoredi.Tests/Autoredi.Tests.csproj +++ b/tests/Autoredi.Tests/Autoredi.Tests.csproj @@ -11,7 +11,7 @@ - + From f52f3d435d252de36fc040b763eeb73da3532620 Mon Sep 17 00:00:00 2001 From: Koddek Date: Fri, 30 Jan 2026 14:13:39 -0400 Subject: [PATCH 4/4] bump version to 0.3.0 --- src/Autoredi/Autoredi.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Autoredi/Autoredi.csproj b/src/Autoredi/Autoredi.csproj index a11f975..7d04968 100644 --- a/src/Autoredi/Autoredi.csproj +++ b/src/Autoredi/Autoredi.csproj @@ -3,7 +3,7 @@ net10.0 Autoredi - 0.2.0 + 0.3.0 true true