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) { }