diff --git a/compiler-tests/src/test/data/box/aggregation/ContributesIntoMap_DefaultBindingImplicitMapKeyMultiModule.kt b/compiler-tests/src/test/data/box/aggregation/ContributesIntoMap_DefaultBindingImplicitMapKeyMultiModule.kt new file mode 100644 index 0000000000..625b7eb398 --- /dev/null +++ b/compiler-tests/src/test/data/box/aggregation/ContributesIntoMap_DefaultBindingImplicitMapKeyMultiModule.kt @@ -0,0 +1,59 @@ +import kotlin.reflect.KClass + +// MODULE: api +interface RouteKey + +@MapKey(implicitClassKey = true) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FUNCTION, + AnnotationTarget.TYPE, + AnnotationTarget.TYPE_PARAMETER, +) +annotation class RouteMapKey(val value: KClass = Nothing::class) + +@DefaultBinding> +interface RouteScreen<@RouteMapKey T : RouteKey> + +// MODULE: impl(api) +class HomeKey : RouteKey + +@ContributesIntoMap(AppScope::class) +@Inject +class HomeScreen : RouteScreen + +class OverrideHomeKey : RouteKey + +class AlternateHomeKey : RouteKey + +@ContributesIntoMap(AppScope::class) +@Inject +class OverrideHomeScreen : RouteScreen<@RouteMapKey(AlternateHomeKey::class) OverrideHomeKey> + +class ClassOverrideHomeKey : RouteKey + +class ClassAlternateHomeKey : RouteKey + +@RouteMapKey(ClassAlternateHomeKey::class) +@ContributesIntoMap(AppScope::class) +@Inject +class ClassOverrideHomeScreen : RouteScreen + +// MODULE: main(api, impl) +import kotlin.reflect.KClass + +@DependencyGraph(AppScope::class) +interface RouteGraph { + val screens: Map, RouteScreen<*>> +} + +fun box(): String { + val graph = createGraph() + assertEquals(3, graph.screens.size) + assertIs(graph.screens.getValue(HomeKey::class)) + assertIs(graph.screens.getValue(AlternateHomeKey::class)) + assertIs(graph.screens.getValue(ClassAlternateHomeKey::class)) + assertFalse(graph.screens.containsKey(OverrideHomeKey::class)) + assertFalse(graph.screens.containsKey(ClassOverrideHomeKey::class)) + return "OK" +} diff --git a/compiler-tests/src/test/data/box/aggregation/ContributesIntoMap_ImplicitMapKeyOnTypeArgument.kt b/compiler-tests/src/test/data/box/aggregation/ContributesIntoMap_ImplicitMapKeyOnTypeArgument.kt new file mode 100644 index 0000000000..e5eafef166 --- /dev/null +++ b/compiler-tests/src/test/data/box/aggregation/ContributesIntoMap_ImplicitMapKeyOnTypeArgument.kt @@ -0,0 +1,23 @@ +import kotlin.reflect.KClass + +interface DirectRouteKey + +interface DirectRouteScreen + +class DirectHomeKey : DirectRouteKey + +@ContributesIntoMap(AppScope::class) +@Inject +class DirectHomeScreen : DirectRouteScreen<@ClassKey DirectHomeKey> + +@DependencyGraph(AppScope::class) +interface DirectRouteGraph { + val screens: Map, DirectRouteScreen> +} + +fun box(): String { + val graph = createGraph() + assertEquals(1, graph.screens.size) + assertIs(graph.screens.getValue(DirectHomeKey::class)) + return "OK" +} diff --git a/compiler-tests/src/test/data/diagnostic/aggregation/DefaultBinding_InvalidTypeArg.kt b/compiler-tests/src/test/data/diagnostic/aggregation/DefaultBinding_InvalidTypeArg.kt index 3222cf868c..b9b37c5efe 100644 --- a/compiler-tests/src/test/data/diagnostic/aggregation/DefaultBinding_InvalidTypeArg.kt +++ b/compiler-tests/src/test/data/diagnostic/aggregation/DefaultBinding_InvalidTypeArg.kt @@ -11,3 +11,11 @@ interface Base1 // Valid usage for comparison @DefaultBinding interface Base3 + +@MapKey +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.TYPE_PARAMETER) +annotation class TypeParameterKey(val value: String) + +// Only one map key is allowed on type parameters +@DefaultBinding> +interface KeyedBase<@ClassKey @TypeParameterKey("key") A, B> diff --git a/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/BoxTestGenerated.java b/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/BoxTestGenerated.java index 73cc5c0c9d..d6420ed36c 100644 --- a/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/BoxTestGenerated.java +++ b/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/BoxTestGenerated.java @@ -122,6 +122,18 @@ public void testContributesBindingScopeArgumentOrder() { run("ContributesBindingScopeArgumentOrder.kt"); } + @Test + @TestMetadata("ContributesIntoMap_DefaultBindingImplicitMapKeyMultiModule.kt") + public void testContributesIntoMap_DefaultBindingImplicitMapKeyMultiModule() { + run("ContributesIntoMap_DefaultBindingImplicitMapKeyMultiModule.kt"); + } + + @Test + @TestMetadata("ContributesIntoMap_ImplicitMapKeyOnTypeArgument.kt") + public void testContributesIntoMap_ImplicitMapKeyOnTypeArgument() { + run("ContributesIntoMap_ImplicitMapKeyOnTypeArgument.kt"); + } + @Test @TestMetadata("ContributingMultibileNullableBindings.kt") public void testContributingMultibileNullableBindings() { diff --git a/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/ContributionProvidersBoxTestGenerated.java b/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/ContributionProvidersBoxTestGenerated.java index 77bcd7af5d..dc536a5eac 100644 --- a/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/ContributionProvidersBoxTestGenerated.java +++ b/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/ContributionProvidersBoxTestGenerated.java @@ -122,6 +122,18 @@ public void testContributesBindingScopeArgumentOrder() { run("ContributesBindingScopeArgumentOrder.kt"); } + @Test + @TestMetadata("ContributesIntoMap_DefaultBindingImplicitMapKeyMultiModule.kt") + public void testContributesIntoMap_DefaultBindingImplicitMapKeyMultiModule() { + run("ContributesIntoMap_DefaultBindingImplicitMapKeyMultiModule.kt"); + } + + @Test + @TestMetadata("ContributesIntoMap_ImplicitMapKeyOnTypeArgument.kt") + public void testContributesIntoMap_ImplicitMapKeyOnTypeArgument() { + run("ContributesIntoMap_ImplicitMapKeyOnTypeArgument.kt"); + } + @Test @TestMetadata("ContributingMultibileNullableBindings.kt") public void testContributingMultibileNullableBindings() { diff --git a/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/FastInitBoxTestGenerated.java b/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/FastInitBoxTestGenerated.java index 638992ca36..fd3cf3c479 100644 --- a/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/FastInitBoxTestGenerated.java +++ b/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/FastInitBoxTestGenerated.java @@ -122,6 +122,18 @@ public void testContributesBindingScopeArgumentOrder() { run("ContributesBindingScopeArgumentOrder.kt"); } + @Test + @TestMetadata("ContributesIntoMap_DefaultBindingImplicitMapKeyMultiModule.kt") + public void testContributesIntoMap_DefaultBindingImplicitMapKeyMultiModule() { + run("ContributesIntoMap_DefaultBindingImplicitMapKeyMultiModule.kt"); + } + + @Test + @TestMetadata("ContributesIntoMap_ImplicitMapKeyOnTypeArgument.kt") + public void testContributesIntoMap_ImplicitMapKeyOnTypeArgument() { + run("ContributesIntoMap_ImplicitMapKeyOnTypeArgument.kt"); + } + @Test @TestMetadata("ContributingMultibileNullableBindings.kt") public void testContributingMultibileNullableBindings() { diff --git a/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/IrOnlyClassesBoxTestGenerated.java b/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/IrOnlyClassesBoxTestGenerated.java index dc916d23c7..ab6197b259 100644 --- a/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/IrOnlyClassesBoxTestGenerated.java +++ b/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/IrOnlyClassesBoxTestGenerated.java @@ -122,6 +122,18 @@ public void testContributesBindingScopeArgumentOrder() { run("ContributesBindingScopeArgumentOrder.kt"); } + @Test + @TestMetadata("ContributesIntoMap_DefaultBindingImplicitMapKeyMultiModule.kt") + public void testContributesIntoMap_DefaultBindingImplicitMapKeyMultiModule() { + run("ContributesIntoMap_DefaultBindingImplicitMapKeyMultiModule.kt"); + } + + @Test + @TestMetadata("ContributesIntoMap_ImplicitMapKeyOnTypeArgument.kt") + public void testContributesIntoMap_ImplicitMapKeyOnTypeArgument() { + run("ContributesIntoMap_ImplicitMapKeyOnTypeArgument.kt"); + } + @Test @TestMetadata("ContributingMultibileNullableBindings.kt") public void testContributingMultibileNullableBindings() { diff --git a/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/JsBoxTestGenerated.java b/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/JsBoxTestGenerated.java index b49cf0c3b2..12e8981f89 100644 --- a/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/JsBoxTestGenerated.java +++ b/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/JsBoxTestGenerated.java @@ -122,6 +122,18 @@ public void testContributesBindingScopeArgumentOrder() { run("ContributesBindingScopeArgumentOrder.kt"); } + @Test + @TestMetadata("ContributesIntoMap_DefaultBindingImplicitMapKeyMultiModule.kt") + public void testContributesIntoMap_DefaultBindingImplicitMapKeyMultiModule() { + run("ContributesIntoMap_DefaultBindingImplicitMapKeyMultiModule.kt"); + } + + @Test + @TestMetadata("ContributesIntoMap_ImplicitMapKeyOnTypeArgument.kt") + public void testContributesIntoMap_ImplicitMapKeyOnTypeArgument() { + run("ContributesIntoMap_ImplicitMapKeyOnTypeArgument.kt"); + } + @Test @TestMetadata("ContributingMultibileNullableBindings.kt") public void testContributingMultibileNullableBindings() { diff --git a/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/JsContributionProvidersBoxTestGenerated.java b/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/JsContributionProvidersBoxTestGenerated.java index 9561ecc016..0c841db846 100644 --- a/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/JsContributionProvidersBoxTestGenerated.java +++ b/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/JsContributionProvidersBoxTestGenerated.java @@ -122,6 +122,18 @@ public void testContributesBindingScopeArgumentOrder() { run("ContributesBindingScopeArgumentOrder.kt"); } + @Test + @TestMetadata("ContributesIntoMap_DefaultBindingImplicitMapKeyMultiModule.kt") + public void testContributesIntoMap_DefaultBindingImplicitMapKeyMultiModule() { + run("ContributesIntoMap_DefaultBindingImplicitMapKeyMultiModule.kt"); + } + + @Test + @TestMetadata("ContributesIntoMap_ImplicitMapKeyOnTypeArgument.kt") + public void testContributesIntoMap_ImplicitMapKeyOnTypeArgument() { + run("ContributesIntoMap_ImplicitMapKeyOnTypeArgument.kt"); + } + @Test @TestMetadata("ContributingMultibileNullableBindings.kt") public void testContributingMultibileNullableBindings() { diff --git a/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/JsFastInitBoxTestGenerated.java b/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/JsFastInitBoxTestGenerated.java index fafa0b1c95..68024f4bed 100644 --- a/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/JsFastInitBoxTestGenerated.java +++ b/compiler-tests/src/test/java/dev/zacsweers/metro/compiler/JsFastInitBoxTestGenerated.java @@ -122,6 +122,18 @@ public void testContributesBindingScopeArgumentOrder() { run("ContributesBindingScopeArgumentOrder.kt"); } + @Test + @TestMetadata("ContributesIntoMap_DefaultBindingImplicitMapKeyMultiModule.kt") + public void testContributesIntoMap_DefaultBindingImplicitMapKeyMultiModule() { + run("ContributesIntoMap_DefaultBindingImplicitMapKeyMultiModule.kt"); + } + + @Test + @TestMetadata("ContributesIntoMap_ImplicitMapKeyOnTypeArgument.kt") + public void testContributesIntoMap_ImplicitMapKeyOnTypeArgument() { + run("ContributesIntoMap_ImplicitMapKeyOnTypeArgument.kt"); + } + @Test @TestMetadata("ContributingMultibileNullableBindings.kt") public void testContributingMultibileNullableBindings() { diff --git a/compiler/src/main/kotlin/dev/zacsweers/metro/compiler/fir/checkers/AggregationChecker.kt b/compiler/src/main/kotlin/dev/zacsweers/metro/compiler/fir/checkers/AggregationChecker.kt index 10b1d829c4..5a0262a830 100644 --- a/compiler/src/main/kotlin/dev/zacsweers/metro/compiler/fir/checkers/AggregationChecker.kt +++ b/compiler/src/main/kotlin/dev/zacsweers/metro/compiler/fir/checkers/AggregationChecker.kt @@ -4,6 +4,7 @@ package dev.zacsweers.metro.compiler.fir.checkers import dev.drewhamilton.poko.Poko import dev.zacsweers.metro.compiler.MetroOptions +import dev.zacsweers.metro.compiler.fir.FirTypeArgumentMapKey import dev.zacsweers.metro.compiler.fir.FirTypeKey import dev.zacsweers.metro.compiler.fir.MetroDiagnostics import dev.zacsweers.metro.compiler.fir.MetroFirAnnotation @@ -19,6 +20,8 @@ import dev.zacsweers.metro.compiler.fir.isKiaIntoMultibinding import dev.zacsweers.metro.compiler.fir.isOrImplements import dev.zacsweers.metro.compiler.fir.isResolved import dev.zacsweers.metro.compiler.fir.mapKeyAnnotation +import dev.zacsweers.metro.compiler.fir.mapKeyAnnotationsFromDefaultBindingTypeParameters +import dev.zacsweers.metro.compiler.fir.mapKeyAnnotationsFromTypeArguments import dev.zacsweers.metro.compiler.fir.metroFirBuiltIns import dev.zacsweers.metro.compiler.fir.qualifierAnnotation import dev.zacsweers.metro.compiler.fir.resolveDefaultBindingType @@ -40,6 +43,8 @@ import org.jetbrains.kotlin.fir.analysis.checkers.context.CheckerContext import org.jetbrains.kotlin.fir.analysis.checkers.declaration.FirClassChecker import org.jetbrains.kotlin.fir.analysis.checkers.fullyExpandedClassId import org.jetbrains.kotlin.fir.declarations.FirClass +import org.jetbrains.kotlin.fir.declarations.primaryConstructorIfAny +import org.jetbrains.kotlin.fir.declarations.toAnnotationClass import org.jetbrains.kotlin.fir.declarations.toAnnotationClassId import org.jetbrains.kotlin.fir.declarations.utils.classId import org.jetbrains.kotlin.fir.declarations.utils.effectiveVisibility @@ -54,8 +59,11 @@ import org.jetbrains.kotlin.fir.types.UnexpandedTypeCheck import org.jetbrains.kotlin.fir.types.classId import org.jetbrains.kotlin.fir.types.coneType import org.jetbrains.kotlin.fir.types.coneTypeOrNull +import org.jetbrains.kotlin.fir.types.impl.ConeClassLikeTypeImpl import org.jetbrains.kotlin.fir.types.isAny import org.jetbrains.kotlin.fir.types.isNothing +import org.jetbrains.kotlin.fir.types.isSubtypeOf +import org.jetbrains.kotlin.fir.types.renderReadableWithFqNames import org.jetbrains.kotlin.fir.types.toLookupTag import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.StandardClassIds @@ -420,36 +428,114 @@ internal object AggregationChecker : FirClassChecker(MppCheckerKind.Common) { val mapKey = if (isMapBinding) { - val classMapKey = declaration.annotations.mapKeyAnnotation(session) + val bindingMapKey = explicitBindingType?.annotations?.mapKeyAnnotation(session) + val hasImplicitMapContribution = + explicitBindingType != null && + declaration + .annotationsIn(session, session.classIds.contributesIntoMapAnnotations) + .any { it.resolvedBindingArgument(session) == null } + + val boundClassId = typeKey.type.fullyExpandedClassId(session) + val implementationBoundTypeRef = supertypesExcludingAny.firstOrNull { + it.coneTypeOrNull?.fullyExpandedClassId(session) == boundClassId + } + val boundTypeRef = explicitBindingType ?: implementationBoundTypeRef + val boundTypeArgumentMapKeys = + boundTypeRef?.mapKeyAnnotationsFromTypeArguments(session).orEmpty().ifEmpty { + implementationBoundTypeRef + ?.takeUnless { it === boundTypeRef } + ?.mapKeyAnnotationsFromTypeArguments(session) + .orEmpty() + } + val hasContributionSpecificMapKey = + bindingMapKey != null || boundTypeArgumentMapKeys.isNotEmpty() + val classMapKey = + declaration.annotations + .mapKeyAnnotation(session) + ?.takeUnless { hasImplicitMapContribution && hasContributionSpecificMapKey } + if (boundTypeArgumentMapKeys.size > 1) { + val foundMapKeys = + boundTypeArgumentMapKeys.joinToString(separator = "; ") { + it.annotation.simpleString() + } + reporter.reportOn( + annotation.source, + MetroDiagnostics.AGGREGATION_ERROR, + "`@$kind` found multiple map keys: $foundMapKeys. Declare a map key in only one location: the explicit bound type, contributed class, or bound type generic argument.", + ) + return false + } + + val boundTypeArgumentMapKey = boundTypeArgumentMapKeys.singleOrNull() + val declarationSiteMapKeys = + listOfNotNull( + bindingMapKey, + classMapKey, + boundTypeArgumentMapKey?.annotation, + ) + if (declarationSiteMapKeys.size > 1) { + val foundMapKeys = + declarationSiteMapKeys.joinToString(separator = "; ") { it.simpleString() } + reporter.reportOn( + annotation.source, + MetroDiagnostics.AGGREGATION_ERROR, + "`@$kind` found multiple map keys: $foundMapKeys. Declare a map key in only one location: the explicit bound type, contributed class, or bound type generic argument.", + ) + return false + } + if ( + boundTypeArgumentMapKey != null && + !checkImplicitClassKeyType(session, boundTypeArgumentMapKey) + ) { + return false + } + + val defaultBindingMapKeys = + if (declarationSiteMapKeys.isEmpty()) { + implementationBoundTypeRef + ?.mapKeyAnnotationsFromDefaultBindingTypeParameters(session) + .orEmpty() + } else { + emptyList() + } + + val defaultBindingMapKey = defaultBindingMapKeys.firstOrNull() + if ( + defaultBindingMapKey != null && !checkImplicitClassKeyType(session, defaultBindingMapKey) + ) { + return false + } + val resolvedKey = + declarationSiteMapKeys.singleOrNull() + ?: defaultBindingMapKey?.annotation + if (resolvedKey == null) { if (explicitBindingType == null) { - classMapKey.also { - if (it == null) { - reporter.reportOn( - annotation.source, - MetroDiagnostics.AGGREGATION_ERROR, - "`@$kind`-annotated class ${declaration.classId.asSingleFqName()} must declare a map key on the class or an explicit bound type but doesn't.", - ) - } - } + reporter.reportOn( + annotation.source, + MetroDiagnostics.AGGREGATION_ERROR, + "`@$kind`-annotated class ${declaration.classId.asSingleFqName()} must declare a map key on the class or an explicit bound type but doesn't.", + ) } else { - (explicitBindingType.annotations.mapKeyAnnotation(session) ?: classMapKey).also { - if (it == null) { - reporter.reportOn( - explicitBindingType.source, - MetroDiagnostics.AGGREGATION_ERROR, - "`@$kind`-annotated class @${declaration.symbol.classId.asSingleFqName()} must declare a map key but doesn't. Add one on the explicit bound type or the class.", - ) - } - } + reporter.reportOn( + explicitBindingType.source, + MetroDiagnostics.AGGREGATION_ERROR, + "`@$kind`-annotated class @${declaration.symbol.classId.asSingleFqName()} must declare a map key but doesn't. Add one on the explicit bound type or the class.", + ) } - resolvedKey ?: return false + return false + } // Check implicit class key usage checkImplicitClassKeyUsage( session, resolvedKey, - implicitType = declaration.symbol.classId, + implicitType = + if (resolvedKey === boundTypeArgumentMapKey?.annotation) { + boundTypeArgumentMapKey.argumentClassId + } else { + declaration.symbol.classId + }, source = declaration.source, ) @@ -599,6 +685,37 @@ internal object AggregationChecker : FirClassChecker(MppCheckerKind.Common) { reporter.reportOn(declaration.source, diagnosticFactory, message) } + context(context: CheckerContext, reporter: DiagnosticReporter) + private fun checkImplicitClassKeyType( + session: FirSession, + mapKey: FirTypeArgumentMapKey, + ): Boolean { + if (!mapKey.usesImplicitClassKey) return true + + val annotationClass = mapKey.annotation.fir.toAnnotationClass(session) ?: return true + val parameterType = + annotationClass + .primaryConstructorIfAny(session) + ?.valueParameterSymbols + ?.singleOrNull() + ?.resolvedReturnTypeRef + ?.coneType ?: return true + val implicitKClassType = + ConeClassLikeTypeImpl( + StandardClassIds.KClass.toLookupTag(), + arrayOf(mapKey.argumentType), + isMarkedNullable = false, + ) + if (implicitKClassType.isSubtypeOf(parameterType, session)) return true + + reporter.reportOn( + mapKey.argumentSource, + MetroDiagnostics.AGGREGATION_ERROR, + "Implicit class key type `${mapKey.argumentType.renderReadableWithFqNames()}` is not compatible with map key parameter type `${parameterType.renderReadableWithFqNames()}`.", + ) + return false + } + sealed interface DefaultBindingResult { data class Found(val type: ConeKotlinType) : DefaultBindingResult diff --git a/compiler/src/main/kotlin/dev/zacsweers/metro/compiler/fir/checkers/DefaultBindingChecker.kt b/compiler/src/main/kotlin/dev/zacsweers/metro/compiler/fir/checkers/DefaultBindingChecker.kt index 9cbceacc38..983fd7b733 100644 --- a/compiler/src/main/kotlin/dev/zacsweers/metro/compiler/fir/checkers/DefaultBindingChecker.kt +++ b/compiler/src/main/kotlin/dev/zacsweers/metro/compiler/fir/checkers/DefaultBindingChecker.kt @@ -5,6 +5,7 @@ package dev.zacsweers.metro.compiler.fir.checkers import dev.zacsweers.metro.compiler.fir.MetroDiagnostics import dev.zacsweers.metro.compiler.fir.annotationsIn import dev.zacsweers.metro.compiler.fir.classIds +import dev.zacsweers.metro.compiler.fir.mapKeyAnnotations import org.jetbrains.kotlin.descriptors.ClassKind import org.jetbrains.kotlin.diagnostics.DiagnosticReporter import org.jetbrains.kotlin.diagnostics.reportOn @@ -70,5 +71,17 @@ internal object DefaultBindingChecker : FirClassChecker(MppCheckerKind.Common) { // No issues } } + + val typeParameterMapKeys = + declaration.symbol.typeParameterSymbols.flatMap { it.mapKeyAnnotations(session) } + if (typeParameterMapKeys.size > 1) { + val foundMapKeys = + typeParameterMapKeys.joinToString(separator = "; ") { it.simpleString() } + reporter.reportOn( + annotation.source, + MetroDiagnostics.DEFAULT_BINDING_ERROR, + "`@DefaultBinding` type parameters declare multiple map keys: $foundMapKeys. Declare a map key on only one type parameter.", + ) + } } } diff --git a/compiler/src/main/kotlin/dev/zacsweers/metro/compiler/fir/fir.kt b/compiler/src/main/kotlin/dev/zacsweers/metro/compiler/fir/fir.kt index 0cdc4ad833..2044ac75a9 100644 --- a/compiler/src/main/kotlin/dev/zacsweers/metro/compiler/fir/fir.kt +++ b/compiler/src/main/kotlin/dev/zacsweers/metro/compiler/fir/fir.kt @@ -47,6 +47,7 @@ import org.jetbrains.kotlin.fir.declarations.getAnnotationByClassId import org.jetbrains.kotlin.fir.declarations.getTargetType import org.jetbrains.kotlin.fir.declarations.origin import org.jetbrains.kotlin.fir.declarations.primaryConstructorIfAny +import org.jetbrains.kotlin.fir.declarations.toAnnotationClass import org.jetbrains.kotlin.fir.declarations.toAnnotationClassId import org.jetbrains.kotlin.fir.declarations.toAnnotationClassIdSafe import org.jetbrains.kotlin.fir.declarations.toAnnotationClassLikeSymbol @@ -128,6 +129,7 @@ import org.jetbrains.kotlin.fir.types.ConeKotlinType import org.jetbrains.kotlin.fir.types.ConeKotlinTypeProjection import org.jetbrains.kotlin.fir.types.ConeTypeProjection import org.jetbrains.kotlin.fir.types.FirPlaceholderProjection +import org.jetbrains.kotlin.fir.types.FirResolvedTypeRef import org.jetbrains.kotlin.fir.types.FirStarProjection import org.jetbrains.kotlin.fir.types.FirTypeProjectionWithVariance import org.jetbrains.kotlin.fir.types.FirTypeRef @@ -800,6 +802,127 @@ internal fun FirAnnotationContainer.mapKeyAnnotation(session: FirSession): Metro internal fun List.mapKeyAnnotation(session: FirSession): MetroFirAnnotation? = asSequence().annotationAnnotatedWithAny(session, session.classIds.mapKeyAnnotations) +internal fun FirBasedSymbol<*>.mapKeyAnnotations(session: FirSession): List = + resolvedCompilerAnnotationsWithClassIds.mapKeyAnnotations(session) + +internal fun List.mapKeyAnnotations( + session: FirSession +): List = + asSequence() + .annotationsAnnotatedWithAny(session, session.classIds.mapKeyAnnotations) + .toList() + +internal data class FirTypeArgumentMapKey( + val annotation: MetroFirAnnotation, + val argumentClassId: ClassId, + val argumentType: ConeKotlinType, + val argumentSource: KtSourceElement?, + val usesImplicitClassKey: Boolean, +) + +internal fun FirTypeRef.mapKeyAnnotationFromDefaultBindingTypeParameter( + session: FirSession +): MetroFirAnnotation? = + mapKeyAnnotationsFromDefaultBindingTypeParameters(session).firstOrNull()?.annotation + +internal fun FirTypeRef.mapKeyAnnotationsFromDefaultBindingTypeParameters( + session: FirSession +): List { + val resolvedTypeRef = this as? FirResolvedTypeRef ?: return emptyList() + val boundClass = resolvedTypeRef.coneType.toRegularClassSymbol(session) ?: return emptyList() + if (!boundClass.isAnnotatedWithAny(session, setOf(session.classIds.defaultBindingAnnotation))) { + return emptyList() + } + + val sourceTypeRef = resolvedTypeRef.delegatedTypeRef as? FirUserTypeRef ?: return emptyList() + val sourceArguments = + sourceTypeRef.qualifier.lastOrNull()?.typeArgumentList?.typeArguments ?: return emptyList() + val resolvedArguments = (resolvedTypeRef.coneType as? ConeClassLikeType)?.typeArguments.orEmpty() + return boundClass.typeParameterSymbols.withIndex().flatMap { (index, typeParameter) -> + val mapKeys = typeParameter.mapKeyAnnotations(session) + if (mapKeys.isEmpty()) return@flatMap emptyList() + val argumentTypeRef = + (sourceArguments.getOrNull(index) as? FirTypeProjectionWithVariance)?.typeRef + val argumentType = + argumentTypeRef?.coneTypeOrNull + ?: resolvedArguments.getOrNull(index)?.type + ?: return@flatMap emptyList() + val argumentClassId = + argumentType.toRegularClassSymbol(session)?.classId ?: return@flatMap emptyList() + mapKeys.map { mapKey -> + val usesImplicitClassKey = + mapKey.hasImplicitClassKey(session) && mapKey.mapKeyClassValueExpression() == null + FirTypeArgumentMapKey( + annotation = mapKey.withImplicitClassKeyValue(session, argumentClassId), + argumentClassId = argumentClassId, + argumentType = argumentType, + argumentSource = argumentTypeRef?.source, + usesImplicitClassKey = usesImplicitClassKey, + ) + } + } +} + +internal fun FirTypeRef.mapKeyAnnotationFromTypeArguments( + session: FirSession +): MetroFirAnnotation? = mapKeyAnnotationsFromTypeArguments(session).firstOrNull()?.annotation + +internal fun FirTypeRef.mapKeyAnnotationsFromTypeArguments( + session: FirSession +): List { + val sourceTypeRef = (this as? FirResolvedTypeRef)?.delegatedTypeRef ?: this + val userTypeRef = sourceTypeRef as? FirUserTypeRef ?: return emptyList() + val sourceArguments = + userTypeRef.qualifier.lastOrNull()?.typeArgumentList?.typeArguments ?: return emptyList() + val resolvedArguments = + (coneTypeOrNull as? ConeClassLikeType)?.typeArguments ?: return emptyList() + + return sourceArguments.zip(resolvedArguments).flatMap { (sourceArgument, resolvedArgument) -> + val argumentTypeRef = + (sourceArgument as? FirTypeProjectionWithVariance)?.typeRef + ?: return@flatMap emptyList() + val mapKeys = argumentTypeRef.annotations.mapKeyAnnotations(session) + if (mapKeys.isEmpty()) return@flatMap emptyList() + val argumentType = + argumentTypeRef.coneTypeOrNull + ?: resolvedArgument.type + ?: return@flatMap emptyList() + val argumentClassId = + argumentType.toRegularClassSymbol(session)?.classId ?: return@flatMap emptyList() + mapKeys.map { mapKey -> + val usesImplicitClassKey = + mapKey.hasImplicitClassKey(session) && mapKey.mapKeyClassValueExpression() == null + FirTypeArgumentMapKey( + annotation = mapKey.withImplicitClassKeyValue(session, argumentClassId), + argumentClassId = argumentClassId, + argumentType = argumentType, + argumentSource = argumentTypeRef.source, + usesImplicitClassKey = usesImplicitClassKey, + ) + } + } +} + +private fun MetroFirAnnotation.withImplicitClassKeyValue( + session: FirSession, + implicitClassId: ClassId, +): MetroFirAnnotation { + if (!hasImplicitClassKey(session) || mapKeyClassValueExpression() != null) return this + val annotationClass = fir.toAnnotationClass(session) ?: return this + val annotation = buildSimpleAnnotation { + annotationClass.symbol + } + .apply { + replaceArgumentMapping( + buildAnnotationArgumentMapping { + mapping[StandardNames.DEFAULT_VALUE_PARAMETER] = + buildClassReference(session, implicitClassId) + } + ) + } + return MetroFirAnnotation(annotation, session) +} + /** * Checks if the given [mapKeyAnnotation]'s `@MapKey` meta-annotation has `implicitClassKey = true`. */ diff --git a/compiler/src/main/kotlin/dev/zacsweers/metro/compiler/fir/generators/ContributionsFirGenerator.kt b/compiler/src/main/kotlin/dev/zacsweers/metro/compiler/fir/generators/ContributionsFirGenerator.kt index a72e6320dc..ee430d4526 100644 --- a/compiler/src/main/kotlin/dev/zacsweers/metro/compiler/fir/generators/ContributionsFirGenerator.kt +++ b/compiler/src/main/kotlin/dev/zacsweers/metro/compiler/fir/generators/ContributionsFirGenerator.kt @@ -30,6 +30,8 @@ import dev.zacsweers.metro.compiler.fir.isBindingContainer import dev.zacsweers.metro.compiler.fir.isKiaIntoMultibinding import dev.zacsweers.metro.compiler.fir.isResolved import dev.zacsweers.metro.compiler.fir.mapKeyAnnotation +import dev.zacsweers.metro.compiler.fir.mapKeyAnnotationFromDefaultBindingTypeParameter +import dev.zacsweers.metro.compiler.fir.mapKeyAnnotationFromTypeArguments import dev.zacsweers.metro.compiler.fir.mapKeyClassValueExpression import dev.zacsweers.metro.compiler.fir.markAsDeprecatedHidden import dev.zacsweers.metro.compiler.fir.metroFirBuiltIns @@ -497,7 +499,8 @@ internal class ContributionsFirGenerator( add(buildIntoMapAnnotation()) // Copy map key annotation (already resolved on the contribution) val mapKey = matchingContribution.mapKey - mapKey?.fir?.expectAsOrNull()?.let { mapKeyFirAnnotation -> + val mapKeyFirAnnotation = mapKey?.fir + if (mapKeyFirAnnotation is FirAnnotationCall) { // For implicit class keys (@MapKey(implicitClassKey = true)), the annotation // value is Nothing::class (sentinel) or absent. Build a new annotation with the // contributing class as the value instead of copying the sentinel. @@ -536,6 +539,8 @@ internal class ContributionsFirGenerator( } ) } + } else if (mapKeyFirAnnotation != null) { + add(mapKeyFirAnnotation) } } is Contribution.ContributesBinding -> {} @@ -710,10 +715,26 @@ internal class ContributionsFirGenerator( ?: classAnnotations.qualifierAnnotation(session), mapKey = boundTypeAnnotations?.mapKeyAnnotation(session) - ?: classAnnotations.mapKeyAnnotation(session), + ?: classAnnotations.mapKeyAnnotation(session) + ?: boundTypeRef?.mapKeyAnnotationFromTypeArguments(session) + ?: contributingClassSymbol.mapKeyAnnotationFromBoundSupertype(boundTypeRef), ) } + private fun FirClassSymbol<*>.mapKeyAnnotationFromBoundSupertype( + boundTypeRef: FirTypeRef? + ): MetroFirAnnotation? { + val boundClassId = boundTypeRef?.coneTypeOrNull?.toRegularClassSymbol(session)?.classId + return resolvedSuperTypeRefs + .firstOrNull { + it.coneType.toRegularClassSymbol(session)?.classId == boundClassId + } + ?.run { + takeUnless { it === boundTypeRef }?.mapKeyAnnotationFromTypeArguments(session) + ?: mapKeyAnnotationFromDefaultBindingTypeParameter(session) + } + } + /** * Resolve the bound type ref for a binding contribution. Returns a [FirTypeRef] so callers can * read both the type (via [FirTypeRef.coneType]) and any type annotations (qualifier, map key). diff --git a/compiler/src/main/kotlin/dev/zacsweers/metro/compiler/ir/irMultibindings.kt b/compiler/src/main/kotlin/dev/zacsweers/metro/compiler/ir/irMultibindings.kt index 7450f0c91e..d5019b4e37 100644 --- a/compiler/src/main/kotlin/dev/zacsweers/metro/compiler/ir/irMultibindings.kt +++ b/compiler/src/main/kotlin/dev/zacsweers/metro/compiler/ir/irMultibindings.kt @@ -9,6 +9,7 @@ import dev.zacsweers.metro.compiler.symbols.Symbols import java.util.Objects import org.jetbrains.kotlin.ir.UNDEFINED_OFFSET import org.jetbrains.kotlin.ir.builders.irString +import org.jetbrains.kotlin.ir.declarations.IrClass import org.jetbrains.kotlin.ir.declarations.IrOverridableDeclaration import org.jetbrains.kotlin.ir.declarations.IrProperty import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction @@ -16,15 +17,18 @@ import org.jetbrains.kotlin.ir.expressions.IrClassReference import org.jetbrains.kotlin.ir.expressions.IrConstructorCall import org.jetbrains.kotlin.ir.expressions.impl.IrClassReferenceImpl import org.jetbrains.kotlin.ir.symbols.IrSymbol +import org.jetbrains.kotlin.ir.types.IrSimpleType import org.jetbrains.kotlin.ir.types.IrType import org.jetbrains.kotlin.ir.types.classOrNull import org.jetbrains.kotlin.ir.types.removeAnnotations import org.jetbrains.kotlin.ir.types.typeOrFail +import org.jetbrains.kotlin.ir.types.typeOrNull import org.jetbrains.kotlin.ir.types.typeWith import org.jetbrains.kotlin.ir.util.classId import org.jetbrains.kotlin.ir.util.deepCopyWithSymbols import org.jetbrains.kotlin.ir.util.kotlinFqName import org.jetbrains.kotlin.ir.util.primaryConstructor +import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.StandardClassIds context(context: IrMetroContext) @@ -162,6 +166,39 @@ internal fun populateImplicitClassKey(mapKey: IrConstructorCall, implicitType: I ) } +context(context: IrMetroContext) +internal fun IrType.mapKeyAnnotationFromTypeArguments(): IrAnnotation? { + val simpleType = this as? IrSimpleType ?: return null + return simpleType.arguments.firstNotNullOfOrNull { typeArgument -> + val argumentType = typeArgument.typeOrNull ?: return@firstNotNullOfOrNull null + val mapKey = argumentType.mapKeyAnnotation() ?: return@firstNotNullOfOrNull null + mapKey.withImplicitClassKeyValue(argumentType) + } +} + +context(context: IrMetroContext) +internal fun IrClass.mapKeyAnnotationFromBoundSupertype(boundClassId: ClassId?): IrAnnotation? { + val boundType = superTypes.firstOrNull { it.rawTypeOrNull()?.classId == boundClassId } + return boundType?.mapKeyAnnotationFromTypeArguments() + ?: boundType?.mapKeyAnnotationFromDefaultBindingTypeParameter() +} + +context(context: IrMetroContext) +private fun IrType.mapKeyAnnotationFromDefaultBindingTypeParameter(): IrAnnotation? { + val simpleType = this as? IrSimpleType ?: return null + val boundClass = simpleType.classOrNull?.owner ?: return null + val isDefaultBinding = + boundClass.isAnnotatedWithAny(setOf(context.metroSymbols.classIds.defaultBindingAnnotation)) + if (!isDefaultBinding) return null + + return boundClass.typeParameters.zip(simpleType.arguments) + .firstNotNullOfOrNull { (typeParameter, typeArgument) -> + val mapKey = typeParameter.mapKeyAnnotation() ?: return@firstNotNullOfOrNull null + val argumentType = typeArgument.typeOrNull ?: return@firstNotNullOfOrNull null + mapKey.withImplicitClassKeyValue(argumentType) + } +} + /** * Returns a new [MetroAnnotations] whose [MetroAnnotations.mapKey] has its implicit class key * populated with a reference to [implicitType]. Returns `this` if there is no map key or the map @@ -172,10 +209,17 @@ internal fun MetroAnnotations.withPopulatedImplicitClassKey( implicitType: IrType ): MetroAnnotations { val mapKey = this.mapKey ?: return this - if (!isImplicitClassKeySentinel(mapKey.ir)) return this - val copied = mapKey.ir.deepCopyWithSymbols() + val populatedMapKey = mapKey.withImplicitClassKeyValue(implicitType) + if (populatedMapKey === mapKey) return this + return copy(mapKey = populatedMapKey) +} + +context(context: IrMetroContext) +private fun IrAnnotation.withImplicitClassKeyValue(implicitType: IrType): IrAnnotation { + if (!isImplicitClassKeySentinel(ir)) return this + val copied = ir.deepCopyWithSymbols() populateImplicitClassKey(copied, implicitType) - return copy(mapKey = IrAnnotation(copied)) + return IrAnnotation(copied) } context(context: IrMetroContext) diff --git a/compiler/src/main/kotlin/dev/zacsweers/metro/compiler/ir/transformers/ContributionIrTransformer.kt b/compiler/src/main/kotlin/dev/zacsweers/metro/compiler/ir/transformers/ContributionIrTransformer.kt index b4f795c41b..d6b1212e2d 100644 --- a/compiler/src/main/kotlin/dev/zacsweers/metro/compiler/ir/transformers/ContributionIrTransformer.kt +++ b/compiler/src/main/kotlin/dev/zacsweers/metro/compiler/ir/transformers/ContributionIrTransformer.kt @@ -35,6 +35,8 @@ import dev.zacsweers.metro.compiler.ir.isKiaIntoMultibinding import dev.zacsweers.metro.compiler.ir.kClassReference import dev.zacsweers.metro.compiler.ir.lookupClass import dev.zacsweers.metro.compiler.ir.mapKeyAnnotation +import dev.zacsweers.metro.compiler.ir.mapKeyAnnotationFromBoundSupertype +import dev.zacsweers.metro.compiler.ir.mapKeyAnnotationFromTypeArguments import dev.zacsweers.metro.compiler.ir.originClassId import dev.zacsweers.metro.compiler.ir.parameters.Parameters import dev.zacsweers.metro.compiler.ir.parameters.dedupeParameters @@ -556,6 +558,8 @@ internal class ContributionIrTransformer( val mapKey = explicitBindingType?.originalType?.mapKeyAnnotation() ?: originClass.mapKeyAnnotation() + ?: explicitBindingType?.originalType?.mapKeyAnnotationFromTypeArguments() + ?: originClass.mapKeyAnnotationFromBoundSupertype(bindingTypeKey.classId) mapKey?.let { mk -> val copied = mk.ir.deepCopyWithSymbols() if (isImplicitClassKeySentinel(copied)) { @@ -868,6 +872,8 @@ internal class ContributionIrTransformer( val mapKey = explicitBindingType?.originalType?.mapKeyAnnotation() ?: annotatedType.mapKeyAnnotation() + ?: explicitBindingType?.originalType?.mapKeyAnnotationFromTypeArguments() + ?: annotatedType.mapKeyAnnotationFromBoundSupertype(bindingTypeKey.classId) // For map key hashing, use the effective key value. For implicit class keys // (sentinel Nothing::class), incorporate the annotated type's class ID instead diff --git a/compiler/src/test/kotlin/dev/zacsweers/metro/compiler/fir/AggregationTest.kt b/compiler/src/test/kotlin/dev/zacsweers/metro/compiler/fir/AggregationTest.kt index 657f73d88a..1171742d6f 100644 --- a/compiler/src/test/kotlin/dev/zacsweers/metro/compiler/fir/AggregationTest.kt +++ b/compiler/src/test/kotlin/dev/zacsweers/metro/compiler/fir/AggregationTest.kt @@ -9,6 +9,7 @@ import dev.zacsweers.metro.compiler.ExampleGraph import dev.zacsweers.metro.compiler.MetroCompilerTest import dev.zacsweers.metro.compiler.allSupertypes import dev.zacsweers.metro.compiler.assertDiagnostics +import dev.zacsweers.metro.compiler.assertNoWarningsOrErrors import dev.zacsweers.metro.compiler.callFunction import dev.zacsweers.metro.compiler.callProperty import dev.zacsweers.metro.compiler.createGraphWithNoArgs @@ -2457,14 +2458,184 @@ class AggregationTest : MetroCompilerTest() { } @Test - fun `explicit bound types into map must declare map key`() { + fun `map keys in multiple locations are an error`() { + compile( + source( + """ + interface RouteKey + interface RouteScreen + class HomeKey : RouteKey + + @ContributesIntoMap(AppScope::class, binding<@ClassKey RouteScreen>()) + @ClassKey + @Inject + class ExplicitAndClass : RouteScreen + + @ContributesIntoMap(AppScope::class) + @ClassKey + @Inject + class ClassAndGeneric : RouteScreen<@ClassKey HomeKey> + + @ContributesIntoMap(AppScope::class, binding<@ClassKey RouteScreen>()) + @Inject + class ExplicitAndGeneric : RouteScreen<@ClassKey HomeKey> + """ + .trimIndent() + ), + expectedExitCode = KotlinCompilation.ExitCode.COMPILATION_ERROR, + ) { + assertDiagnostics( + """ + e: RouteKey.kt:11:1 `@ContributesIntoMap` found multiple map keys: @ClassKey; @ClassKey. Declare a map key in only one location: the explicit bound type, contributed class, or bound type generic argument. + e: RouteKey.kt:16:1 `@ContributesIntoMap` found multiple map keys: @ClassKey; @ClassKey(value=test.HomeKey::class). Declare a map key in only one location: the explicit bound type, contributed class, or bound type generic argument. + e: RouteKey.kt:21:1 `@ContributesIntoMap` found multiple map keys: @ClassKey; @ClassKey(value=test.HomeKey::class). Declare a map key in only one location: the explicit bound type, contributed class, or bound type generic argument. + """ + .trimIndent() + ) + } + } + + @Test + fun `map keys on multiple bound type arguments are an error`() { + compile( + source( + """ + interface RouteKey + interface RouteScreen + class HomeKey : RouteKey + class SettingsKey : RouteKey + + @ContributesIntoMap(AppScope::class) + @Inject + class InvalidScreen : + RouteScreen<@ClassKey HomeKey, @ClassKey SettingsKey> + """ + .trimIndent() + ), + expectedExitCode = KotlinCompilation.ExitCode.COMPILATION_ERROR, + ) { + assertContains( + "`@ContributesIntoMap` found multiple map keys: @ClassKey(value=test.HomeKey::class); @ClassKey(value=test.SettingsKey::class)." + ) + } + } + + @Test + fun `multiple map keys on one bound type argument are an error`() { + compile( + source( + """ + interface RouteScreen + + @ContributesIntoMap(AppScope::class) + @Inject + class InvalidScreen : + RouteScreen<@ClassKey @StringKey("home") String> + """ + .trimIndent() + ), + expectedExitCode = KotlinCompilation.ExitCode.COMPILATION_ERROR, + ) { + assertContains( + "`@ContributesIntoMap` found multiple map keys: @ClassKey(value=kotlin.String::class); @StringKey(\"home\")." + ) + } + } + + @Test + fun `map keys on multiple DefaultBinding type parameters are an error`() { + compile( + source( + """ + interface RouteKey + class HomeKey : RouteKey + class SettingsKey : RouteKey + + @DefaultBinding> + interface RouteScreen<@ClassKey A : RouteKey, @ClassKey B : RouteKey> + + @ContributesIntoMap(AppScope::class) + @Inject + class InvalidScreen : RouteScreen + """ + .trimIndent() + ), + expectedExitCode = KotlinCompilation.ExitCode.COMPILATION_ERROR, + ) { + assertContains( + "`@DefaultBinding` type parameters declare multiple map keys: @ClassKey; @ClassKey." + ) + } + } + + @Test + fun `implicit map key type must accept bound type argument`() { + compile( + source( + """ + interface RouteKey + + @MapKey(implicitClassKey = true) + @Target(AnnotationTarget.FUNCTION, AnnotationTarget.TYPE) + annotation class RouteMapKey( + val value: KClass = Nothing::class + ) + + interface RouteScreen + + @ContributesIntoMap(AppScope::class) + @Inject + class InvalidScreen : RouteScreen<@RouteMapKey String> + """ + .trimIndent() + ), + expectedExitCode = KotlinCompilation.ExitCode.COMPILATION_ERROR, + ) { + assertContains( + "Implicit class key type `kotlin.String` is not compatible with map key parameter type `kotlin.reflect.KClass`." + ) + } + } + + @Test + fun `implicit map key type must accept DefaultBinding type argument`() { + compile( + source( + """ + interface RouteKey + + @MapKey(implicitClassKey = true) + @Target(AnnotationTarget.FUNCTION, AnnotationTarget.TYPE_PARAMETER) + annotation class RouteMapKey( + val value: KClass = Nothing::class + ) + + @DefaultBinding> + interface RouteScreen<@RouteMapKey T> + + @ContributesIntoMap(AppScope::class) + @Inject + class InvalidScreen : RouteScreen + """ + .trimIndent() + ), + expectedExitCode = KotlinCompilation.ExitCode.COMPILATION_ERROR, + ) { + assertContains( + "Implicit class key type `kotlin.String` is not compatible with map key parameter type `kotlin.reflect.KClass`." + ) + } + } + + @Test + fun `explicit bound types into map may declare map key on class`() { compile( source( """ interface ContributedInterface @ContributesIntoMap(AppScope::class, binding()) - @ClassKey // Class key is ignored if bound is explicit + @ClassKey @Inject class Impl : ContributedInterface """ @@ -2474,7 +2645,7 @@ class AggregationTest : MetroCompilerTest() { } @Test - fun `explicit bound types into map must declare map key - class is ok`() { + fun `explicit bound types into map must declare map key`() { compile( source( """ @@ -2494,6 +2665,48 @@ class AggregationTest : MetroCompilerTest() { } } + @Test + fun `generic explicit map key matching contributed class is not redundant`() { + compile( + source( + """ + interface RouteKey + interface RouteScreen + class HomeKey : RouteKey + + @ContributesIntoMap(AppScope::class) + @Inject + class HomeScreen : RouteScreen<@ClassKey(HomeScreen::class) HomeKey> + """ + .trimIndent() + ) + ) { + assertNoWarningsOrErrors() + } + } + + @Test + fun `generic explicit map key matching type argument is redundant`() { + compile( + source( + """ + interface RouteKey + interface RouteScreen + class HomeKey : RouteKey + + @ContributesIntoMap(AppScope::class) + @Inject + class HomeScreen : RouteScreen<@ClassKey(HomeKey::class) HomeKey> + """ + .trimIndent() + ) + ) { + assertContains( + "Explicit class key value 'test.HomeKey::class' is the same as the implicit class key and can be omitted." + ) + } + } + @Test fun `implicit bound types into map must declare map key on class`() { compile( @@ -2572,7 +2785,6 @@ class AggregationTest : MetroCompilerTest() { interface ContributedInterface @ContributesIntoMap(AppScope::class, binding<@ClassKey Any>()) - @ClassKey @Inject class Impl : ContributedInterface """ diff --git a/docs/aggregation.md b/docs/aggregation.md index c3437bdea0..6c64460cb0 100644 --- a/docs/aggregation.md +++ b/docs/aggregation.md @@ -162,7 +162,8 @@ class CacheImpl(...) : Cache Same rules around qualifiers and `binding()` apply in this scenario -To contribute into a Map multibinding, the map key annotation must be specified on the class or `binding` type argument. +To contribute into a Map multibinding, the map key annotation must be specified on the class, +`binding` type argument, or a generic argument of the bound type. ```kotlin // Will be contributed into a Map multibinding with @StringKey("Networking") @@ -180,6 +181,30 @@ class CacheImpl(...) : Cache class CacheImpl(...) : Cache ``` +For generic bound types, a map key can be placed directly on a concrete type argument. An implicit +class key uses that argument as the key. + +```kotlin +interface RouteKey + +interface RouteScreen + +class HomeKey : RouteKey + +@ContributesIntoMap(AppScope::class) +@Inject +class HomeScreen(...) : RouteScreen<@ClassKey HomeKey> + +@DependencyGraph(AppScope::class) +interface AppGraph { + // Contains HomeKey::class to HomeScreen + val routeScreens: Map, RouteScreen<*>> +} +``` + +Specify a map key in only one declaration site location: the explicit `binding` type, contributed +class, or bound type generic argument. + This annotation is also repeatable and can be used to contribute to multiple scopes, multiple bound types, and multiple map keys. You can use `@IntoMap`/`@IntoSet` to provide into the same container: @@ -309,6 +334,25 @@ interface BaseFactory> class HomeFactory(...) : BaseFactory ``` +A default binding can also declare an implicit class key on one of its type parameters. Each +implementation then uses its concrete type argument as the key. + +```kotlin +interface RouteKey + +@DefaultBinding> +interface RouteScreen<@ClassKey T : RouteKey> + +class HomeKey : RouteKey + +@ContributesIntoMap(AppScope::class) +@Inject +class HomeScreen(...) : RouteScreen +``` + +A map key declared by an implementation on its class, explicit `binding` type, or bound type +generic argument takes precedence over the key declared by `@DefaultBinding`. + ## `generateContributionProviders` If you enable the new `generateContributionProviders` feature, Metro will instead generate top-level `@Provides` declarations that mirror the injected class's inputs but only return its _bound type_. This means the annotated class can remain `internal`, which both helps encapsulation and incremental compilation. diff --git a/docs/bindings.md b/docs/bindings.md index 63436c1922..26c31bff56 100644 --- a/docs/bindings.md +++ b/docs/bindings.md @@ -150,7 +150,10 @@ interface MapMultibinding { ``` !!! idea - In Metro, class-based map keys like `@ClassKey` do not require you to explicitly specify a value if used on a class or `@Binds` declaration as they can be inferred by the compiler. + In Metro, class-based map keys like `@ClassKey` do not require you to explicitly specify a + value when used on a class, `@Binds` declaration, bound type generic argument, or + `@DefaultBinding` type parameter. Metro infers the appropriate class from the annotated + declaration or concrete type argument. Alternatively, they can be declared with an `@Multibinds`-annotated accessor property/function in a component. This member will be implemented by the Metro compiler and is useful for scenarios where the multibinding may be empty. diff --git a/runtime/src/commonMain/kotlin/dev/zacsweers/metro/ClassKey.kt b/runtime/src/commonMain/kotlin/dev/zacsweers/metro/ClassKey.kt index 2ce001774f..f4fa8cf559 100644 --- a/runtime/src/commonMain/kotlin/dev/zacsweers/metro/ClassKey.kt +++ b/runtime/src/commonMain/kotlin/dev/zacsweers/metro/ClassKey.kt @@ -23,7 +23,8 @@ import kotlin.reflect.KClass * If your map's keys can be constrained, consider using a custom annotation instead, with a member * whose type is `KClass`. * - * This map key supports [MapKey.implicitClassKey]. + * This map key supports [MapKey.implicitClassKey], including when it annotates a contributed + * binding's generic type argument or a [DefaultBinding] type parameter. */ @MustBeDocumented @Target( @@ -33,6 +34,7 @@ import kotlin.reflect.KClass AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.CLASS, AnnotationTarget.TYPE, + AnnotationTarget.TYPE_PARAMETER, ) @Retention(AnnotationRetention.RUNTIME) @MapKey(implicitClassKey = true) diff --git a/runtime/src/commonMain/kotlin/dev/zacsweers/metro/MapKey.kt b/runtime/src/commonMain/kotlin/dev/zacsweers/metro/MapKey.kt index bb6177988a..76584cba5e 100644 --- a/runtime/src/commonMain/kotlin/dev/zacsweers/metro/MapKey.kt +++ b/runtime/src/commonMain/kotlin/dev/zacsweers/metro/MapKey.kt @@ -100,6 +100,10 @@ public annotation class MapKey( * Requirements: * - Must have a single value parameter of type [kotlin.reflect.KClass]. * - It must have a default value of `Nothing::class`. + * + * On [ContributesIntoMap] bindings, an implicit class key may annotate a direct generic type + * argument. A [DefaultBinding] may instead declare it on a type parameter for its implementations. + * A map key declared by an implementation takes precedence over the default binding's key. */ val implicitClassKey: Boolean = false, )