diff --git a/src/DependencyModules.SourceGenerator.Impl/Utilities/ServiceModelUtility.cs b/src/DependencyModules.SourceGenerator.Impl/Utilities/ServiceModelUtility.cs index 136c447..9e54da5 100644 --- a/src/DependencyModules.SourceGenerator.Impl/Utilities/ServiceModelUtility.cs +++ b/src/DependencyModules.SourceGenerator.Impl/Utilities/ServiceModelUtility.cs @@ -9,59 +9,6 @@ namespace DependencyModules.SourceGenerator.Impl.Utilities; public class ServiceModelUtility { - /// - /// Interfaces that describe a capability rather than a role, keyed by namespace and name. - /// - /// - /// - /// Passed over when choosing a service type nobody named. Writing : IDisposable says the - /// class cleans up after itself; it does not say IDisposable is what callers ask for. - /// Without this, class OrderedPool : IDisposable, IPool registers as IDisposable, - /// because the first interface in the declaration wins. - /// - /// - /// A list rather than the namespace rule AsSelfWithInterfaces uses, because the two are - /// not the same problem. That expansion is additive — excluding too much costs a bonus - /// registration. This is an exclusive choice, so excluding too much means the interface the - /// developer wanted is not registered at all. System holds plenty of interfaces that are - /// genuinely service roles: IEqualityComparer<T>, IJsonTypeInfoResolver, - /// IHttpClientFactory. Precision matters more here than a rule stated in one sentence. - /// - /// - /// The list is short and stays short. These are the BCL's language and framework integration - /// points, and the set has barely moved in twenty years — unlike the open-ended set of - /// interfaces a type happens to reach, which is what makes the namespace rule right over there. - /// - /// - /// IEnumerable earns its place twice over: registering a service as - /// IEnumerable<T> collides with how the container represents "every registration of - /// T". - /// - /// - /// A service type the developer names is untouched — [SingletonService(As = - /// typeof(IDisposable))] still registers IDisposable. This governs inference only. - /// - /// - private static readonly HashSet _capabilityInterfaces = new() - { - "System.IDisposable", - "System.IAsyncDisposable", - "System.ICloneable", - "System.IComparable", // covers IComparable, same name - "System.IEquatable", - "System.IConvertible", - "System.IFormattable", - "System.ISpanFormattable", - "System.IParsable", - "System.ISpanParsable", - "System.Collections.IEnumerable", - "System.Collections.Generic.IEnumerable", - "System.Runtime.Serialization.ISerializable", - "System.ComponentModel.INotifyPropertyChanged", - "System.ComponentModel.INotifyPropertyChanging", - "System.Collections.Specialized.INotifyCollectionChanged", - }; - private static readonly ITypeDefinition _crossWireService = KnownTypes .DependencyModules .Attributes @@ -839,9 +786,10 @@ argumentSyntax.Expression is TypeOfExpressionSyntax typeOfExpression if (registration is GenericTypeDefinition) { - registration = ReplaceGenericParametersForRegistration( - registration - ); + registration = + ServiceTypeInference.ReplaceGenericParametersForRegistration( + registration + ); } } break; @@ -880,126 +828,10 @@ private static ITypeDefinition GetServiceTypeFromClass( ITypeDefinition classDefinition ) { - return GetBaseTypeRegistration(context) ?? classDefinition; - } - - /// - /// The service type to register a class as when the developer did not name one: the first - /// declared interface that is not a capability, else the - /// first one a base class provides. - /// - private static ITypeDefinition? GetBaseTypeRegistration(SyntaxTransformContext context) - { - if (context.Node is TypeDeclarationSyntax { BaseList: not null } typeDeclarationSyntax) - { - INamedTypeSymbol? baseClassSymbol = null; - - foreach (var baseTypeSyntax in typeDeclarationSyntax.BaseList.Types) - { - var symbolInfo = ModelExtensions.GetSymbolInfo( - context.SemanticModel, - baseTypeSyntax.Type - ); - - if (symbolInfo.Symbol is INamedTypeSymbol namedTypeSymbol) - { - var baseTypeDefinition = namedTypeSymbol.GetTypeDefinitionFromNamedSymbol(); - - // only auto register interfaces - if ( - baseTypeDefinition is - { TypeDefinitionEnum: TypeDefinitionEnum.InterfaceDefinition } - ) - { - // Passed over rather than remembered: a skipped interface must not become the - // symbol walked below, or IEnumerable would hand back IEnumerable. - if (SkipInterface(baseTypeDefinition)) - { - continue; - } - - if (baseTypeDefinition is GenericTypeDefinition) - { - baseTypeDefinition = ReplaceGenericParametersForRegistration( - baseTypeDefinition - ); - } - - return baseTypeDefinition; - } - - baseClassSymbol = namedTypeSymbol; - } - } - - if (baseClassSymbol != null) - { - return GetBaseInterface(context, baseClassSymbol); - } - } - - return null; - } - - private static ITypeDefinition? GetBaseInterface( - SyntaxTransformContext context, - INamedTypeSymbol baseTypeSymbol - ) - { - foreach (var interfaceSymbol in baseTypeSymbol.Interfaces) - { - var interfaceType = interfaceSymbol.GetTypeDefinitionFromNamedSymbol(); - - // only auto register interfaces - if (interfaceType == null || SkipInterface(interfaceType)) - { - continue; - } - - if (interfaceType is GenericTypeDefinition) - { - interfaceType = ReplaceGenericParametersForRegistration(interfaceType); - } - - return interfaceType; - } - - if (baseTypeSymbol.BaseType == null) - { - return null; - } - - return GetBaseInterface(context, baseTypeSymbol.BaseType); - } - - /// - /// Whether an interface is passed over when choosing a service type nobody named. - /// - /// - /// Matched on namespace and name so one entry covers a generic and its closings — - /// IEquatable<Money> and IEquatable<T> both render as - /// System.IEquatable. - /// - private static bool SkipInterface(ITypeDefinition interfaceType) => - _capabilityInterfaces.Contains($"{interfaceType.Namespace}.{interfaceType.Name}"); - - private static ITypeDefinition ReplaceGenericParametersForRegistration( - ITypeDefinition registration - ) - { - var argumentTypes = registration - .TypeArguments.Select(_ => - _ is TypeParameterDefinition ? TypeDefinition.Get("", "") : _ - ) - .ToArray(); - - registration = new GenericTypeDefinition( - registration.TypeDefinitionEnum, - registration.Namespace, - registration.Name, - argumentTypes - ); + var baseListTypes = context.Node is TypeDeclarationSyntax typeDeclarationSyntax + ? ServiceTypeInference.BaseListSymbols(typeDeclarationSyntax, context.SemanticModel) + : Enumerable.Empty(); - return registration; + return ServiceTypeInference.GetServiceType(classDefinition, baseListTypes); } } diff --git a/src/DependencyModules.SourceGenerator.Impl/Utilities/ServiceTypeInference.cs b/src/DependencyModules.SourceGenerator.Impl/Utilities/ServiceTypeInference.cs new file mode 100644 index 0000000..0dad4a2 --- /dev/null +++ b/src/DependencyModules.SourceGenerator.Impl/Utilities/ServiceTypeInference.cs @@ -0,0 +1,242 @@ +using CSharpAuthor; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace DependencyModules.SourceGenerator.Impl.Utilities; + +/// +/// The service type a [SingletonService], [ScopedService] or [TransientService] +/// registers when the attribute has no As. +/// +/// +/// +/// On a class or record, the inference reads the base list of the declaration that carries the +/// attribute, in written order. The first interface that is not a +/// capability wins. When no written interface qualifies, +/// the interfaces each base class declares are tried, from the nearest base class outward. When +/// none qualifies, the implementation is registered as itself. Only the attributed declaration's +/// base list counts. An interface written on another part of a partial class is not seen. +/// +/// +/// On a static factory method, nothing is inferred. The service type is the method's declared +/// return type, as written. A method that returns Thing registers Thing, even when +/// Thing implements IThing. Pass an empty base list and the return type to +/// to get this result. +/// +/// +/// An open generic interface has its type parameters replaced by empty arguments, so it is +/// registered as IGeneric<>. The same replacement applies to an open generic type +/// named in As. +/// +/// +public static class ServiceTypeInference +{ + /// + /// Interfaces that describe a capability rather than a role, keyed by namespace and name. + /// + /// + /// + /// Passed over when choosing a service type nobody named. Writing : IDisposable says the + /// class cleans up after itself; it does not say IDisposable is what callers ask for. + /// Without this, class OrderedPool : IDisposable, IPool registers as IDisposable, + /// because the first interface in the declaration wins. + /// + /// + /// A list rather than the namespace rule AsSelfWithInterfaces uses, because the two are + /// not the same problem. That expansion is additive — excluding too much costs a bonus + /// registration. This is an exclusive choice, so excluding too much means the interface the + /// developer wanted is not registered at all. System holds plenty of interfaces that are + /// genuinely service roles: IEqualityComparer<T>, IJsonTypeInfoResolver, + /// IHttpClientFactory. Precision matters more here than a rule stated in one sentence. + /// + /// + /// The list is short and stays short. These are the BCL's language and framework integration + /// points, and the set has barely moved in twenty years — unlike the open-ended set of + /// interfaces a type happens to reach, which is what makes the namespace rule right over there. + /// + /// + /// IEnumerable earns its place twice over: registering a service as + /// IEnumerable<T> collides with how the container represents "every registration of + /// T". + /// + /// + /// A service type the developer names is untouched — [SingletonService(As = + /// typeof(IDisposable))] still registers IDisposable. This governs inference only. + /// + /// + private static readonly HashSet _capabilityInterfaces = new() + { + "System.IDisposable", + "System.IAsyncDisposable", + "System.ICloneable", + "System.IComparable", // covers IComparable, same name + "System.IEquatable", + "System.IConvertible", + "System.IFormattable", + "System.ISpanFormattable", + "System.IParsable", + "System.ISpanParsable", + "System.Collections.IEnumerable", + "System.Collections.Generic.IEnumerable", + "System.Runtime.Serialization.ISerializable", + "System.ComponentModel.INotifyPropertyChanged", + "System.ComponentModel.INotifyPropertyChanging", + "System.Collections.Specialized.INotifyCollectionChanged", + }; + + /// + /// The service type to register when the attribute has no As. + /// + /// + /// The attributed class, or the return type of the attributed factory method. + /// + /// + /// The types in the attributed declaration's base list, in written order, as + /// resolves them. Empty for a factory method. + /// + public static ITypeDefinition GetServiceType( + ITypeDefinition implementation, + IEnumerable baseListTypes + ) + { + return GetBaseTypeRegistration(baseListTypes) ?? implementation; + } + + /// + /// The types in a declaration's base list that resolve to a named type, in written order. + /// + /// + /// Resolved lazily, so stops binding at the first interface it + /// takes. + /// + public static IEnumerable BaseListSymbols( + TypeDeclarationSyntax typeDeclarationSyntax, + SemanticModel semanticModel + ) + { + if (typeDeclarationSyntax.BaseList == null) + { + yield break; + } + + foreach (var baseTypeSyntax in typeDeclarationSyntax.BaseList.Types) + { + var symbolInfo = ModelExtensions.GetSymbolInfo(semanticModel, baseTypeSyntax.Type); + + if (symbolInfo.Symbol is INamedTypeSymbol namedTypeSymbol) + { + yield return namedTypeSymbol; + } + } + } + + /// + /// The first declared interface that is not a capability, + /// else the first one a base class provides. + /// + private static ITypeDefinition? GetBaseTypeRegistration( + IEnumerable baseListTypes + ) + { + INamedTypeSymbol? baseClassSymbol = null; + + foreach (var namedTypeSymbol in baseListTypes) + { + var baseTypeDefinition = namedTypeSymbol.GetTypeDefinitionFromNamedSymbol(); + + // only auto register interfaces + if ( + baseTypeDefinition is { TypeDefinitionEnum: TypeDefinitionEnum.InterfaceDefinition } + ) + { + // Passed over rather than remembered: a skipped interface must not become the + // symbol walked below, or IEnumerable would hand back IEnumerable. + if (SkipInterface(baseTypeDefinition)) + { + continue; + } + + if (baseTypeDefinition is GenericTypeDefinition) + { + baseTypeDefinition = ReplaceGenericParametersForRegistration( + baseTypeDefinition + ); + } + + return baseTypeDefinition; + } + + baseClassSymbol = namedTypeSymbol; + } + + if (baseClassSymbol != null) + { + return GetBaseInterface(baseClassSymbol); + } + + return null; + } + + private static ITypeDefinition? GetBaseInterface(INamedTypeSymbol baseTypeSymbol) + { + foreach (var interfaceSymbol in baseTypeSymbol.Interfaces) + { + var interfaceType = interfaceSymbol.GetTypeDefinitionFromNamedSymbol(); + + // only auto register interfaces + if (interfaceType == null || SkipInterface(interfaceType)) + { + continue; + } + + if (interfaceType is GenericTypeDefinition) + { + interfaceType = ReplaceGenericParametersForRegistration(interfaceType); + } + + return interfaceType; + } + + if (baseTypeSymbol.BaseType == null) + { + return null; + } + + return GetBaseInterface(baseTypeSymbol.BaseType); + } + + /// + /// Whether an interface is passed over when choosing a service type nobody named. + /// + /// + /// Matched on namespace and name so one entry covers a generic and its closings — + /// IEquatable<Money> and IEquatable<T> both render as + /// System.IEquatable. + /// + private static bool SkipInterface(ITypeDefinition interfaceType) => + _capabilityInterfaces.Contains($"{interfaceType.Namespace}.{interfaceType.Name}"); + + /// + /// The generic type with each type parameter replaced by an empty argument, so it renders as + /// an open generic such as IGeneric<>. + /// + public static ITypeDefinition ReplaceGenericParametersForRegistration( + ITypeDefinition registration + ) + { + var argumentTypes = registration + .TypeArguments.Select(_ => + _ is TypeParameterDefinition ? TypeDefinition.Get("", "") : _ + ) + .ToArray(); + + registration = new GenericTypeDefinition( + registration.TypeDefinitionEnum, + registration.Namespace, + registration.Name, + argumentTypes + ); + + return registration; + } +} diff --git a/tests/DependencyModules.Tests/GeneratorTests/ServiceRegistrationTests.cs b/tests/DependencyModules.Tests/GeneratorTests/ServiceRegistrationTests.cs index ccf0866..7ba3fb8 100644 --- a/tests/DependencyModules.Tests/GeneratorTests/ServiceRegistrationTests.cs +++ b/tests/DependencyModules.Tests/GeneratorTests/ServiceRegistrationTests.cs @@ -235,6 +235,37 @@ public partial class TestModule; Assert.Contains("AddSingleton", generated); } + /// + /// A factory method registers its declared return type. The interfaces of that type are not + /// inferred. + /// + [Fact] + public void FactoryMethod_RegistersItsDeclaredReturnType() + { + var generated = GeneratedAssembly.Create( + """ + using DependencyModules.Runtime.Attributes; + + namespace TestNamespace; + + public interface IThing; + + public class Thing : IThing; + + public static class ThingFactory { + [SingletonService] + public static Thing Create() => new Thing(); + } + + [DependencyModule] + public partial class TestModule; + """ + ); + + Assert.Single(generated.Descriptors("Thing")); + Assert.Empty(generated.Descriptors("IThing")); + } + [Fact] public void ModuleWithNoServices_DoesNotEmitADependenciesFile() { diff --git a/tests/DependencyModules.Tests/GeneratorTests/ServiceTypeInferenceTests.cs b/tests/DependencyModules.Tests/GeneratorTests/ServiceTypeInferenceTests.cs new file mode 100644 index 0000000..387ac0b --- /dev/null +++ b/tests/DependencyModules.Tests/GeneratorTests/ServiceTypeInferenceTests.cs @@ -0,0 +1,101 @@ +using CSharpAuthor; +using DependencyModules.SourceGenerator.Impl.Utilities; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Xunit; + +namespace DependencyModules.Tests.GeneratorTests; + +/// +/// ServiceTypeInference called on symbols, the way an analyzer calls it. +/// +public class ServiceTypeInferenceTests +{ + [Fact] + public void FirstRoleInterface_IsTheServiceType() + { + var serviceType = Infer( + """ + public interface IThing; + + public class Thing : System.IDisposable, IThing { + public void Dispose() { } + } + """ + ); + + Assert.Equal("IThing", serviceType.Name); + } + + [Fact] + public void InterfaceOfABaseClass_IsTheServiceType() + { + var serviceType = Infer( + """ + public interface IThing; + + public abstract class ThingBase : IThing; + + public class Thing : ThingBase; + """ + ); + + Assert.Equal("IThing", serviceType.Name); + } + + [Fact] + public void OpenGenericInterface_HasItsTypeParametersEmptied() + { + var serviceType = Infer( + """ + public interface IGeneric; + + public class Thing : IGeneric; + """ + ); + + Assert.Equal("IGeneric", serviceType.Name); + Assert.All(serviceType.TypeArguments, argument => Assert.Equal("", argument.Name)); + } + + /// + /// Only the base list of the declaration that carries the attribute is read. + /// + [Fact] + public void InterfaceOnAnotherPartialDeclaration_IsNotSeen() + { + var serviceType = Infer( + """ + public interface IThing; + + public partial class Thing; + + public partial class Thing : IThing; + """ + ); + + Assert.Equal("Thing", serviceType.Name); + } + + private static ITypeDefinition Infer(string source) + { + var tree = CSharpSyntaxTree.ParseText("namespace TestNamespace;\n" + source); + var compilation = CSharpCompilation.Create( + "InferenceTest", + new[] { tree }, + new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) }, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + var declaration = tree.GetRoot() + .DescendantNodes() + .OfType() + .First(type => type.Identifier.Text == "Thing"); + + return ServiceTypeInference.GetServiceType( + TypeDefinition.Get("TestNamespace", "Thing"), + ServiceTypeInference.BaseListSymbols(declaration, compilation.GetSemanticModel(tree)) + ); + } +} diff --git a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.SourceGeneratorApi.verified.txt b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.SourceGeneratorApi.verified.txt index 6304441..22b208a 100644 --- a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.SourceGeneratorApi.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.SourceGeneratorApi.verified.txt @@ -1959,6 +1959,12 @@ namespace DependencyModules.SourceGenerator.Impl.Utilities public static DependencyModules.SourceGenerator.Impl.Models.ConstructorInfoModel? GetConstructorInfo(DependencyModules.SourceGenerator.Impl.Utilities.SyntaxTransformContext context, Microsoft.CodeAnalysis.SyntaxNode node, System.Threading.CancellationToken cancellationToken, bool callableOnly = false) { } public static DependencyModules.SourceGenerator.Impl.Models.ServiceModel? GetServiceModel(DependencyModules.SourceGenerator.Impl.Utilities.SyntaxTransformContext context, System.Threading.CancellationToken cancellationToken) { } } + public static class ServiceTypeInference + { + public static System.Collections.Generic.IEnumerable BaseListSymbols(Microsoft.CodeAnalysis.CSharp.Syntax.TypeDeclarationSyntax typeDeclarationSyntax, Microsoft.CodeAnalysis.SemanticModel semanticModel) { } + public static CSharpAuthor.ITypeDefinition GetServiceType(CSharpAuthor.ITypeDefinition implementation, System.Collections.Generic.IEnumerable baseListTypes) { } + public static CSharpAuthor.ITypeDefinition ReplaceGenericParametersForRegistration(CSharpAuthor.ITypeDefinition registration) { } + } public static class SymbolConstructorReader { public static DependencyModules.SourceGenerator.Impl.Models.ConstructorInfoModel? Read(Microsoft.CodeAnalysis.INamedTypeSymbol type) { }