From 6396f1b4881249f261ca0677b15bd1544f331cdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabr=C3=ADcio=20Godoy?= Date: Mon, 20 Nov 2023 13:43:02 -0300 Subject: [PATCH] feat: support nullable as optional dependency --- .../ConstructorSelectionTests.cs | 33 +++++++++++++++ .../Jab.FunctionalTest.props | 1 + .../ServiceImplementationWithNullable.cs | 22 ++++++++++ ...rviceImplementationWithNullableOptional.cs | 22 ++++++++++ src/Jab.Tests/DiagnosticsTest.cs | 41 +++++++++++++++++++ src/Jab/CodeWriter.cs | 2 +- src/Jab/ContainerGenerator.cs | 22 ++++++---- src/Jab/DefaultValueCallSite.cs | 10 +++++ src/Jab/DiagnosticDescriptors.cs | 8 ++++ src/Jab/ServiceCallSite.cs | 1 + src/Jab/ServiceProviderBuilder.cs | 13 +++++- 11 files changed, 165 insertions(+), 10 deletions(-) create mode 100644 src/Jab.FunctionalTests.Common/Mocks/ServiceImplementationWithNullable.cs create mode 100644 src/Jab.FunctionalTests.Common/Mocks/ServiceImplementationWithNullableOptional.cs create mode 100644 src/Jab/DefaultValueCallSite.cs diff --git a/src/Jab.FunctionalTests.Common/ConstructorSelectionTests.cs b/src/Jab.FunctionalTests.Common/ConstructorSelectionTests.cs index 01ccb7c..2dd49d1 100644 --- a/src/Jab.FunctionalTests.Common/ConstructorSelectionTests.cs +++ b/src/Jab.FunctionalTests.Common/ConstructorSelectionTests.cs @@ -59,6 +59,7 @@ public void PassesOptionalParametersWhenAvailable() Assert.NotNull(service.Parameter1); Assert.Null(service.Parameter2); Assert.NotNull(service.Parameter3); + Assert.False(typeof(IServiceProvider).IsAssignableFrom(typeof(PassesOptionalParametersWhenAvailableContainer))); } [ServiceProvider] @@ -83,5 +84,37 @@ public void IgnoresNonReferenceTypedParameters() [Transient(typeof(IService3), typeof(ServiceImplementation))] [Transient(typeof(IService), typeof(ServiceImplementationWithParameter))] internal partial class IgnoresNonReferenceTypedParametersContainer { } + + [Fact] + public void IgnoresNullableParametersWhenNotAvailable() + { + IgnoresNullableParametersWhenNotAvailableContainer c = new(); + var service = Assert.IsType(c.GetService()); + Assert.NotNull(service.Parameter1); + Assert.Null(service.Parameter2); + Assert.Empty(service.Parameter3!); + Assert.False(typeof(IServiceProvider).IsAssignableFrom(typeof(IgnoresNullableOptionalParametersWhenNotAvailableContainer))); + } + + [ServiceProvider] + [Transient(typeof(IService1), typeof(ServiceImplementation))] + [Transient(typeof(IService), typeof(ServiceImplementationWithNullable))] + internal partial class IgnoresNullableParametersWhenNotAvailableContainer { } + + [Fact] + public void IgnoresNullableOptionalParametersWhenNotAvailable() + { + IgnoresNullableOptionalParametersWhenNotAvailableContainer c = new(); + var service = Assert.IsType(c.GetService()); + Assert.NotNull(service.Parameter1); + Assert.Null(service.Parameter2); + Assert.Empty(service.Parameter3!); + Assert.False(typeof(IServiceProvider).IsAssignableFrom(typeof(IgnoresNullableOptionalParametersWhenNotAvailableContainer))); + } + + [ServiceProvider] + [Transient(typeof(IService1), typeof(ServiceImplementation))] + [Transient(typeof(IService), typeof(ServiceImplementationWithNullableOptional))] + internal partial class IgnoresNullableOptionalParametersWhenNotAvailableContainer { } } } \ No newline at end of file diff --git a/src/Jab.FunctionalTests.Common/Jab.FunctionalTest.props b/src/Jab.FunctionalTests.Common/Jab.FunctionalTest.props index 2183623..e3f459d 100644 --- a/src/Jab.FunctionalTests.Common/Jab.FunctionalTest.props +++ b/src/Jab.FunctionalTests.Common/Jab.FunctionalTest.props @@ -10,6 +10,7 @@ preview JabTests false + $(NoWarn);JAB0013;JAB0014 diff --git a/src/Jab.FunctionalTests.Common/Mocks/ServiceImplementationWithNullable.cs b/src/Jab.FunctionalTests.Common/Mocks/ServiceImplementationWithNullable.cs new file mode 100644 index 0000000..156523d --- /dev/null +++ b/src/Jab.FunctionalTests.Common/Mocks/ServiceImplementationWithNullable.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace JabTests; + +#nullable enable + +internal class ServiceImplementationWithNullable : IService +{ + public IService1 Parameter1 { get; } + public IService2? Parameter2 { get; } + public IEnumerable? Parameter3 { get; } + + public ServiceImplementationWithNullable( + IService1 parameter1, + IService2? parameter2, + IEnumerable? parameter3) + { + Parameter1 = parameter1; + Parameter2 = parameter2; + Parameter3 = parameter3; + } +} \ No newline at end of file diff --git a/src/Jab.FunctionalTests.Common/Mocks/ServiceImplementationWithNullableOptional.cs b/src/Jab.FunctionalTests.Common/Mocks/ServiceImplementationWithNullableOptional.cs new file mode 100644 index 0000000..0871c90 --- /dev/null +++ b/src/Jab.FunctionalTests.Common/Mocks/ServiceImplementationWithNullableOptional.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace JabTests; + +#nullable enable + +internal class ServiceImplementationWithNullableOptional : IService +{ + public IService1 Parameter1 { get; } + public IService2? Parameter2 { get; } + public IEnumerable? Parameter3 { get; } + + public ServiceImplementationWithNullableOptional( + IService1 parameter1, + IService2? parameter2 = null, + IEnumerable? parameter3 = null) + { + Parameter1 = parameter1; + Parameter2 = parameter2; + Parameter3 = parameter3; + } +} \ No newline at end of file diff --git a/src/Jab.Tests/DiagnosticsTest.cs b/src/Jab.Tests/DiagnosticsTest.cs index d9cf58b..d0728a2 100644 --- a/src/Jab.Tests/DiagnosticsTest.cs +++ b/src/Jab.Tests/DiagnosticsTest.cs @@ -1,4 +1,5 @@ using System.Threading.Tasks; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Testing; using Xunit; using Verify = JabTests.GeneratorAnalyzerVerifier; @@ -279,5 +280,45 @@ await Verify.VerifyAnalyzerAsync(testCode, .WithLocation(1) .WithArguments("IService")); } + + [Fact] + public async Task ProducesJAB0013WhenNullableNonOptionalDependencyNotFound() + { + string testCode = $@" +#nullable enable +interface IDependency {{ }} +class Service {{ public Service(IDependency? dep) {{}} }} +[ServiceProvider] +[{{|#1:Transient(typeof(Service))|}}] +public partial class Container {{}} +"; + await Verify.VerifyAnalyzerAsync(testCode, + DiagnosticResult + .CompilerError("JAB0013") + .WithSeverity(DiagnosticSeverity.Warning) + .WithLocation(1) + .WithArguments("IDependency?", "Service")); + } + + [Fact] + public async Task ProducesJAB0014WhenNullableNonOptionalDependencyFound() + { + string testCode = $@" +#nullable enable +interface IDependency {{ }} +class Dependency : IDependency {{ }} +class Service {{ public Service(IDependency? dep) {{}} }} +[ServiceProvider] +[{{|#1:Transient(typeof(Service))|}}] +[{{|#2:Transient(typeof(IDependency), typeof(Dependency))|}}] +public partial class Container {{}} +"; + await Verify.VerifyAnalyzerAsync(testCode, + DiagnosticResult + .CompilerError("JAB0014") + .WithSeverity(DiagnosticSeverity.Warning) + .WithLocation(1) + .WithArguments("IDependency?", "Service")); + } } } \ No newline at end of file diff --git a/src/Jab/CodeWriter.cs b/src/Jab/CodeWriter.cs index 9a7b052..61d4bee 100644 --- a/src/Jab/CodeWriter.cs +++ b/src/Jab/CodeWriter.cs @@ -170,7 +170,7 @@ private void AppendType(INamedTypeSymbol namedTypeSymbol) { if (!_typeNameCache.TryGetValue(namedTypeSymbol, out var name)) { - name = _typeNameCache[namedTypeSymbol] = namedTypeSymbol.ToDisplayString(); + name = _typeNameCache[namedTypeSymbol] = namedTypeSymbol.ToDisplayString(NullableFlowState.NotNull); } AppendRaw(name); diff --git a/src/Jab/ContainerGenerator.cs b/src/Jab/ContainerGenerator.cs index 4cfc715..95f6fa0 100644 --- a/src/Jab/ContainerGenerator.cs +++ b/src/Jab/ContainerGenerator.cs @@ -54,9 +54,13 @@ private void GenerateCallSiteWithCache(CodeWriter codeWriter, string rootReferen private void WriteResolutionCall(CodeWriter codeWriter, ServiceCallSite other, string reference) { - if (other.IsMainImplementation) + if (other is DefaultValueCallSite) { - codeWriter.Append($"{reference}.GetService<{other.ServiceType}>()"); + codeWriter.Append($"default({other.ServiceTypeString})"); + } + else if (other.IsMainImplementation) + { + codeWriter.Append($"{reference}.GetService<{other.ServiceTypeString}>()"); } else { @@ -208,7 +212,7 @@ private void Execute(GeneratorContext context) foreach (var rootService in root.RootCallSites) { - var rootServiceType = rootService.ServiceType; + string rootServiceType = rootService.ServiceTypeString; if (rootService.IsMainImplementation) { codeWriter.Append($"{rootServiceType} IServiceProvider<{rootServiceType}>.GetService()"); @@ -267,7 +271,7 @@ private void Execute(GeneratorContext context) { codeWriter.Line($" ||"); } - codeWriter.Append($"typeof({rootService.ServiceType}) == service"); + codeWriter.Append($"typeof({rootService.ServiceType.ToDisplayString(NullableFlowState.NotNull)}) == service"); } if (first) { @@ -296,7 +300,7 @@ private void Execute(GeneratorContext context) foreach (var rootService in root.RootCallSites) { - var rootServiceType = rootService.ServiceType; + string rootServiceType = rootService.ServiceTypeString; using (rootService.IsMainImplementation ? codeWriter.Scope($"{rootServiceType} IServiceProvider<{rootServiceType}>.GetService()") : @@ -358,7 +362,7 @@ private void WriteServiceProvider(CodeWriter codeWriter, ServiceProvider root) { if (rootRootCallSite.IsMainImplementation) { - codeWriter.Append($"if (type == typeof({rootRootCallSite.ServiceType})) return "); + codeWriter.Append($"if (type == typeof({rootRootCallSite.ServiceType.ToDisplayString(NullableFlowState.NotNull)})) return "); WriteResolutionCall(codeWriter, rootRootCallSite, "this"); codeWriter.Line($";"); } @@ -494,7 +498,7 @@ private static void WriteInterfaces(CodeWriter codeWriter, ServiceProvider root, { if (serviceCallSite.IsMainImplementation) { - codeWriter.Line($" IServiceProvider<{serviceCallSite.ServiceType}>,"); + codeWriter.Line($" IServiceProvider<{serviceCallSite.ServiceTypeString}>,"); } } @@ -510,7 +514,7 @@ private void WriteCacheLocations(ServiceProvider root, CodeWriter codeWriter, bo (rootService.Lifetime == ServiceLifetime.Scoped && !isScope) || rootService.Lifetime == ServiceLifetime.Transient) continue; - codeWriter.Line($"private {rootService.ImplementationType}? {GetCacheLocation(rootService)};"); + codeWriter.Line($"private {rootService.ImplementationType.ToDisplayString(NullableFlowState.NotNull)}? {GetCacheLocation(rootService)};"); } codeWriter.Line(); } @@ -589,6 +593,8 @@ public override void Initialize(AnalysisContext context) DiagnosticDescriptors.NoServiceTypeRegistered, DiagnosticDescriptors.ImplementationTypeAndFactoryNotAllowed, DiagnosticDescriptors.FactoryMemberMustBeAMethodOrHaveDelegateType, + DiagnosticDescriptors.NullableServiceNotRegistered, + DiagnosticDescriptors.NullableServiceRegistered, }.ToImmutableArray(); private static string ReadAttributesFile() diff --git a/src/Jab/DefaultValueCallSite.cs b/src/Jab/DefaultValueCallSite.cs new file mode 100644 index 0000000..92f8e5b --- /dev/null +++ b/src/Jab/DefaultValueCallSite.cs @@ -0,0 +1,10 @@ +namespace Jab; + +internal record DefaultValueCallSite: ServiceCallSite +{ + public DefaultValueCallSite(ITypeSymbol serviceType) : base(serviceType, serviceType, ServiceLifetime.Transient, 0, false) + { + } + + public override string ServiceTypeString => ServiceType.ToDisplayString(NullableFlowState.MaybeNull); +} \ No newline at end of file diff --git a/src/Jab/DiagnosticDescriptors.cs b/src/Jab/DiagnosticDescriptors.cs index e14b060..49a015f 100644 --- a/src/Jab/DiagnosticDescriptors.cs +++ b/src/Jab/DiagnosticDescriptors.cs @@ -50,4 +50,12 @@ internal static class DiagnosticDescriptors "The factory member has to be a method or have a delegate type", "The factory member '{0}' has to be a method of have a delegate type, for service '{1}'", "Usage", DiagnosticSeverity.Error, true); + public static readonly DiagnosticDescriptor NullableServiceNotRegistered = new("JAB0013", + "Not registered nullable dependency without a default value", + "'{0}' parameter to construct '{1}' will always be null when constructing using a service provider. Add a default value to make the service reference optional", "Usage", DiagnosticSeverity.Warning, true); + + public static readonly DiagnosticDescriptor NullableServiceRegistered = new("JAB0014", + "Nullable dependency without a default value", + "'{0}' parameter to construct '{1}' will never be null when constructing using a service provider. Add a default value to make the service reference optional", "Usage", DiagnosticSeverity.Warning, true); + } diff --git a/src/Jab/ServiceCallSite.cs b/src/Jab/ServiceCallSite.cs index d5ce5db..662af7d 100644 --- a/src/Jab/ServiceCallSite.cs +++ b/src/Jab/ServiceCallSite.cs @@ -8,4 +8,5 @@ internal abstract record ServiceCallSite(ITypeSymbol ServiceType, ITypeSymbol Im public int ReverseIndex { get; } = ReverseIndex; public bool? IsDisposable { get; } = IsDisposable; public bool IsMainImplementation => ReverseIndex == 0; + public virtual string ServiceTypeString => ServiceType.ToDisplayString(NullableFlowState.NotNull); } \ No newline at end of file diff --git a/src/Jab/ServiceProviderBuilder.cs b/src/Jab/ServiceProviderBuilder.cs index 45fbf3c..bd50ebf 100644 --- a/src/Jab/ServiceProviderBuilder.cs +++ b/src/Jab/ServiceProviderBuilder.cs @@ -690,7 +690,18 @@ private ServiceCallSite CreateConstructorCallSite( } else { - if (parameterCallSite == null) + if (parameterSymbol.Type.NullableAnnotation == NullableAnnotation.Annotated) + { + var diagnostic = Diagnostic.Create( + parameterCallSite is null ? DiagnosticDescriptors.NullableServiceNotRegistered : DiagnosticDescriptors.NullableServiceRegistered, + registrationLocation, + parameterSymbol.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat), + implementationType.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat)); + + _context.ReportDiagnostic(diagnostic); + callSites.Add(parameterCallSite ?? new DefaultValueCallSite(parameterSymbol.Type)); + } + else if (parameterCallSite == null) { var diagnostic = Diagnostic.Create(DiagnosticDescriptors.ServiceRequiredToConstructNotRegistered, registrationLocation,