diff --git a/DesignPatterns.Analyzers/CaptiveDependencyAnalyzer.cs b/DesignPatterns.Analyzers/CaptiveDependencyAnalyzer.cs index 4b59fe2..b9ca0e2 100644 --- a/DesignPatterns.Analyzers/CaptiveDependencyAnalyzer.cs +++ b/DesignPatterns.Analyzers/CaptiveDependencyAnalyzer.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using DesignPatterns.Analyzers.Di; using DesignPatterns.Diagnostics; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -37,13 +38,6 @@ public override void Initialize(AnalysisContext context) context.RegisterCompilationStartAction(OnCompilationStart); } - private enum Lifetime - { - Singleton = 0, - Scoped = 1, - Transient = 2, - } - /// /// Categories of DesignPatterns registration attributes. /// Used to match attributed types to the correct RegisterDi call @@ -456,8 +450,8 @@ private static void CollectTryAddRegistration( return; } - // Third arg: ServiceLifetime.Singleton - var lifetime = ResolveLifetimeArg(descriptorArgs[2].Expression, context.SemanticModel); + // Third arg: lifetime constant (e.g. Singleton) + var lifetime = LifetimeResolution.TryResolve(descriptorArgs[2].Expression, context.SemanticModel); if (lifetime is null) { return; @@ -513,7 +507,8 @@ private static void CollectRegisterDiRegistration( } // Resolve the lifetime value from the call arguments. - var lifetime = ResolveLifetimeFromArg(invocation, implLifetimeParam, context.SemanticModel); + var lifetime = LifetimeResolution.TryResolveArgument( + invocation, implLifetimeParam, context.SemanticModel); var containingTypeName = methodSymbol.ContainingType?.Name ?? ""; if (lifetime is null) { @@ -588,54 +583,6 @@ private static void CollectRegisterDiRegistration( return null; } - /// - /// Resolves a ServiceLifetime argument from an invocation by parameter name. - /// - private static Lifetime? ResolveLifetimeFromArg( - InvocationExpressionSyntax invocation, - IParameterSymbol parameter, - SemanticModel semanticModel) - { - var argList = invocation.ArgumentList; - if (argList is null) - { - return null; - } - - // Check named arguments first. - ArgumentSyntax? matchedArg = null; - foreach (var arg in argList.Arguments) - { - if (arg.NameColon is { Name.Identifier.ValueText: var name } && - name == parameter.Name) - { - matchedArg = arg; - break; - } - } - - // Fall back to positional argument. - if (matchedArg is null) - { - var index = parameter.Ordinal; - if (index < argList.Arguments.Count) - { - var arg = argList.Arguments[index]; - if (arg.NameColon is null) - { - matchedArg = arg; - } - } - } - - if (matchedArg is null) - { - return null; - } - - return ResolveLifetimeArg(matchedArg.Expression, semanticModel); - } - private static void AnalyzeRegistrations( CompilationAnalysisContext context, List registrations, @@ -793,39 +740,6 @@ private static void AnalyzeSingleton( return typeInfo.Type as INamedTypeSymbol; } - private static Lifetime? ResolveLifetimeArg( - ExpressionSyntax expr, - SemanticModel semanticModel) - { - // Try constant value (enum member → int) - var constantValue = semanticModel.GetConstantValue(expr); - if (constantValue.HasValue && constantValue.Value is int intValue) - { - return intValue switch - { - 0 => Lifetime.Singleton, - 1 => Lifetime.Scoped, - 2 => Lifetime.Transient, - _ => null, - }; - } - - // Try field symbol (ServiceLifetime.Singleton etc.) - var symbol = semanticModel.GetSymbolInfo(expr).Symbol; - if (symbol is IFieldSymbol field && field.HasConstantValue) - { - return field.ConstantValue switch - { - 0 => Lifetime.Singleton, - 1 => Lifetime.Scoped, - 2 => Lifetime.Transient, - _ => null, - }; - } - - return null; - } - private static bool IsFactoryOrInstanceArg( ArgumentSyntax arg, SemanticModel semanticModel) diff --git a/DesignPatterns.Analyzers/DesignPatterns.Analyzers.csproj b/DesignPatterns.Analyzers/DesignPatterns.Analyzers.csproj index 41c4aa4..b480040 100644 --- a/DesignPatterns.Analyzers/DesignPatterns.Analyzers.csproj +++ b/DesignPatterns.Analyzers/DesignPatterns.Analyzers.csproj @@ -32,6 +32,10 @@ + + + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/DesignPatterns.Analyzers/Di/Lifetime.cs b/DesignPatterns.Analyzers/Di/Lifetime.cs new file mode 100644 index 0000000..a4135c0 --- /dev/null +++ b/DesignPatterns.Analyzers/Di/Lifetime.cs @@ -0,0 +1,13 @@ +namespace DesignPatterns.Analyzers.Di; + +/// +/// DI container lifetime aligned with common meaning (Singleton / Scoped / Transient). +/// Numeric values match Microsoft.Extensions.DependencyInjection.ServiceLifetime +/// so constant folding of that enum can be mapped directly. +/// +internal enum Lifetime +{ + Singleton = 0, + Scoped = 1, + Transient = 2, +} diff --git a/DesignPatterns.Analyzers/Di/LifetimeResolution.cs b/DesignPatterns.Analyzers/Di/LifetimeResolution.cs new file mode 100644 index 0000000..a99191c --- /dev/null +++ b/DesignPatterns.Analyzers/Di/LifetimeResolution.cs @@ -0,0 +1,91 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace DesignPatterns.Analyzers.Di; + +/// +/// Shared primitives for resolving DI lifetime arguments from Roslyn syntax. +/// +internal static class LifetimeResolution +{ + /// + /// Resolves a from an expression that is a compile-time + /// constant matching MSDI ServiceLifetime values (0/1/2) or a constant field. + /// Returns when the value cannot be determined. + /// + internal static Lifetime? TryResolve(ExpressionSyntax expression, SemanticModel semanticModel) + { + var constantValue = semanticModel.GetConstantValue(expression); + if (constantValue.HasValue && constantValue.Value is int intValue) + { + return FromInt(intValue); + } + + var symbol = semanticModel.GetSymbolInfo(expression).Symbol; + if (symbol is IFieldSymbol field && field.HasConstantValue) + { + return field.ConstantValue is int fieldValue ? FromInt(fieldValue) : null; + } + + return null; + } + + /// + /// Resolves a lifetime argument on an invocation by matching the given parameter + /// (named argument first, then positional). Returns when + /// the argument is omitted or its value cannot be resolved. + /// + internal static Lifetime? TryResolveArgument( + InvocationExpressionSyntax invocation, + IParameterSymbol parameter, + SemanticModel semanticModel) + { + var argument = FindArgument(invocation.ArgumentList, parameter); + if (argument is null) + { + return null; + } + + return TryResolve(argument.Expression, semanticModel); + } + + private static ArgumentSyntax? FindArgument( + ArgumentListSyntax? argumentList, + IParameterSymbol parameter) + { + if (argumentList is null) + { + return null; + } + + foreach (var arg in argumentList.Arguments) + { + if (arg.NameColon is { Name.Identifier.ValueText: var name } && + name == parameter.Name) + { + return arg; + } + } + + var index = parameter.Ordinal; + if (index < argumentList.Arguments.Count) + { + var arg = argumentList.Arguments[index]; + if (arg.NameColon is null) + { + return arg; + } + } + + return null; + } + + private static Lifetime? FromInt(int value) => + value switch + { + 0 => Lifetime.Singleton, + 1 => Lifetime.Scoped, + 2 => Lifetime.Transient, + _ => null, + }; +} diff --git a/tests/DesignPatterns.Analyzers.Tests/DiLifetimeResolutionTests.cs b/tests/DesignPatterns.Analyzers.Tests/DiLifetimeResolutionTests.cs new file mode 100644 index 0000000..42500da --- /dev/null +++ b/tests/DesignPatterns.Analyzers.Tests/DiLifetimeResolutionTests.cs @@ -0,0 +1,203 @@ +using System.Collections.Immutable; +using System.Linq; +using DesignPatterns.Analyzers.Di; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.Extensions.DependencyInjection; + +namespace DesignPatterns.Analyzers.Tests; + +public sealed class DiLifetimeResolutionTests +{ + [Theory] + [InlineData("ServiceLifetime.Singleton", 0)] + [InlineData("ServiceLifetime.Scoped", 1)] + [InlineData("ServiceLifetime.Transient", 2)] + [InlineData("(ServiceLifetime)0", 0)] + [InlineData("(ServiceLifetime)1", 1)] + [InlineData("(ServiceLifetime)2", 2)] + public void TryResolve_returns_lifetime_for_constant_expressions(string expression, int expected) + { + var (model, expr) = CompileFieldInitializer(expression); + + var actual = LifetimeResolution.TryResolve(expr, model); + + Assert.Equal((Lifetime)expected, actual); + } + + [Theory] + [InlineData("(ServiceLifetime)3")] + [InlineData("lifetimeVariable")] + public void TryResolve_returns_null_for_non_constant_or_unknown_values(string expression) + { + var source = $$""" + using Microsoft.Extensions.DependencyInjection; + + class C + { + static ServiceLifetime lifetimeVariable = ServiceLifetime.Scoped; + object _ = {{expression}}; + } + """; + var (model, expr) = CompileAndGetFieldInitializer(source); + + var actual = LifetimeResolution.TryResolve(expr, model); + + Assert.Null(actual); + } + + [Fact] + public void TryResolveArgument_reads_named_argument() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + + static class Holder + { + public static void Register( + IServiceCollection services, + ServiceLifetime implementationLifetime = ServiceLifetime.Singleton, + ServiceLifetime registryLifetime = ServiceLifetime.Singleton) + { + } + + public static void Call(IServiceCollection services) + { + Register(services, implementationLifetime: ServiceLifetime.Transient); + } + } + """; + + var (invocation, parameter, model) = CompileRegisterCall(source, "implementationLifetime"); + + var actual = LifetimeResolution.TryResolveArgument(invocation, parameter, model); + + Assert.Equal(Lifetime.Transient, actual); + } + + [Fact] + public void TryResolveArgument_reads_positional_argument() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + + static class Holder + { + public static void Register( + IServiceCollection services, + ServiceLifetime implementationLifetime = ServiceLifetime.Singleton, + ServiceLifetime registryLifetime = ServiceLifetime.Singleton) + { + } + + public static void Call(IServiceCollection services) + { + Register(services, ServiceLifetime.Scoped); + } + } + """; + + var (invocation, parameter, model) = CompileRegisterCall(source, "implementationLifetime"); + + var actual = LifetimeResolution.TryResolveArgument(invocation, parameter, model); + + Assert.Equal(Lifetime.Scoped, actual); + } + + [Fact] + public void TryResolveArgument_returns_null_when_argument_omitted() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + + static class Holder + { + public static void Register( + IServiceCollection services, + ServiceLifetime implementationLifetime = ServiceLifetime.Singleton, + ServiceLifetime registryLifetime = ServiceLifetime.Singleton) + { + } + + public static void Call(IServiceCollection services) + { + Register(services); + } + } + """; + + var (invocation, parameter, model) = CompileRegisterCall(source, "implementationLifetime"); + + var actual = LifetimeResolution.TryResolveArgument(invocation, parameter, model); + + Assert.Null(actual); + } + + private static (SemanticModel Model, ExpressionSyntax Expression) CompileFieldInitializer(string expression) + { + var source = $$""" + using Microsoft.Extensions.DependencyInjection; + + class C + { + object _ = {{expression}}; + } + """; + return CompileAndGetFieldInitializer(source); + } + + private static (SemanticModel Model, ExpressionSyntax Expression) CompileAndGetFieldInitializer(string source) + { + var compilation = CreateCompilation(source); + var tree = compilation.SyntaxTrees.Single(); + var model = compilation.GetSemanticModel(tree); + var field = tree.GetRoot().DescendantNodes().OfType().Last(); + var expression = field.Initializer?.Value + ?? throw new InvalidOperationException("Expected a field initializer expression."); + return (model, expression); + } + + private static (InvocationExpressionSyntax Invocation, IParameterSymbol Parameter, SemanticModel Model) + CompileRegisterCall(string source, string parameterName) + { + var compilation = CreateCompilation(source); + var tree = compilation.SyntaxTrees.Single(); + var model = compilation.GetSemanticModel(tree); + var invocation = tree.GetRoot() + .DescendantNodes() + .OfType() + .Single(i => i.Expression is IdentifierNameSyntax { Identifier.ValueText: "Register" }); + + var method = (IMethodSymbol)model.GetSymbolInfo(invocation).Symbol!; + var parameter = method.Parameters.Single(p => p.Name == parameterName); + return (invocation, parameter, model); + } + + private static CSharpCompilation CreateCompilation(string source) + { + var parseOptions = new CSharpParseOptions(LanguageVersion.Latest); + var tree = CSharpSyntaxTree.ParseText(source, parseOptions, path: "Test.cs"); + var references = ImmutableArray.Create( + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(ServiceLifetime).Assembly.Location)); + + var trustedPlatformAssemblies = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string; + if (!string.IsNullOrEmpty(trustedPlatformAssemblies)) + { + var extras = trustedPlatformAssemblies + .Split(Path.PathSeparator) + .Where(path => + path.EndsWith("System.Runtime.dll", StringComparison.OrdinalIgnoreCase) || + path.EndsWith("netstandard.dll", StringComparison.OrdinalIgnoreCase)) + .Select(path => MetadataReference.CreateFromFile(path)); + references = references.AddRange(extras); + } + + return CSharpCompilation.Create( + "DiLifetimeResolutionTests", + new[] { tree }, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } +}