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/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 c45c044..c8fa0d3 100644 --- a/src/Autoredi.Generators/AutorediGenerator.cs +++ b/src/Autoredi.Generators/AutorediGenerator.cs @@ -1,451 +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) - { - context.ReportDiagnostic(Diagnostic.Create(MissingAttributeOnClassDescriptor, classDecl.GetLocation(), - classSymbol.Name)); - continue; - } - - // Check 8: Validate constructor arguments - if (attributeData.ConstructorArguments.Length != 3) - { - 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]; - - // 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, - 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 - var sortedRegistrations = registrations - .Where(r => r.InterfaceName == null) - .OrderBy(r => r.ClassName) - .Concat( - registrations - .Where(r => r.InterfaceName != null) - .OrderBy(r => r.InterfaceName) - .ThenBy(r => r.ClassName)) - .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(" public static IServiceCollection AddAutorediServices(this IServiceCollection services)"); - sb.AppendLine(" {"); - - // Call AddAutorediServices for referenced projects using fully qualified names - foreach (var ns in referencedAutorediNamespaces) - { - var assemblyName = ns.Substring(0, ns.Length - ".Autoredi".Length); - sb.AppendLine($" {ns}.AutorediExtensions.AddAutorediServices(services); // {assemblyName}"); - } - - if (!referencedAutorediNamespaces.IsEmpty) - { - sb.AppendLine(); - } - - foreach (var reg in sortedRegistrations) - { - 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 + Flow.Create(context) + .ForAttributeWithMetadataName(Names.AutorediAttFullName) + .Select(ServiceRegistrationExtractor.Extract) + .Collect() + .EmitAll((spc, registrations) => { - if (reg.InterfaceName != null) - { - sb.AppendLine( - $" services.AddKeyed{lifetimeMethod}<{reg.InterfaceName}, {reg.ClassName}>(\"{reg.ServiceKey}\");"); - } - else + if (registrations.IsDefaultOrEmpty) { - sb.AppendLine($" services.AddKeyed{lifetimeMethod}<{reg.ClassName}>(\"{reg.ServiceKey}\");"); + return; } - } - } - sb.AppendLine(); - sb.AppendLine(" return services;"); - sb.AppendLine(" }"); - sb.AppendLine("}"); + // The consumer projects follow [AssemblyName].Autoredi pattern. + var targetNamespace = registrations[0].AssemblyName + ".Autoredi"; - 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 namespaces = 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)) - { - namespaces.Add($"{asm.Name}.Autoredi"); - } - - visitedAssemblies.Remove(asm.Name); // Allow re-visiting in other contexts - } - - return namespaces.Distinct().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 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 Location? Location { get; set; } + 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 3117c87..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,47 @@ 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); + + 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.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/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/src/Autoredi/Autoredi.csproj b/src/Autoredi/Autoredi.csproj index a42fbaa..7d04968 100644 --- a/src/Autoredi/Autoredi.csproj +++ b/src/Autoredi/Autoredi.csproj @@ -1,9 +1,9 @@ - + net10.0 Autoredi - 0.1.10 + 0.3.0 true true 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 @@ - + 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;